sc-postiz-poster/lib/action.js
2026-06-17 17:40:57 -05:00

139 lines
5.8 KiB
JavaScript

// The post_to_postiz action. Attach it to any Saltcorn trigger (row Insert /
// Update, or a button): it maps the triggering row into a Postiz post and
// publishes through the configured channels.
//
// The Postiz API key is stored sealed in the plugin config (see
// lib/secretsAtRest.js); it is decrypted only at the point of use here.
const { makePostizClient } = require("./postizClient");
const { encryptSecret, decryptSecret, isEncryptedBlob } = require("./secretsAtRest");
const { PLUGIN_NAME, POST_TYPES } = require("./constants");
// Domain label that ties the sealed apiKey to this plugin + field (key
// derivation). Changing it would orphan already-sealed keys.
const API_KEY_INFO = `${PLUGIN_NAME}:apiKey`;
// Resolve the usable plaintext API key from saved config -- whether it is
// sealed (normal) or still plaintext (transiently, right after a config save
// and before onLoad re-seals it). Throws a clear, actionable error if a sealed
// key cannot be opened (e.g. session_secret was rotated).
const resolveApiKey = (pluginCfg) => {
const raw = pluginCfg && pluginCfg.apiKey;
if (!raw) {
return "";
}
if (!isEncryptedBlob(raw)) {
return String(raw);
}
const opened = decryptSecret(raw, API_KEY_INFO);
if (opened === null) {
throw new Error(`${PLUGIN_NAME}: stored Postiz API key could not be decrypted (was SALTCORN_SESSION_SECRET rotated?). Re-enter it in the plugin config.`);
}
return opened;
};
// Build a Postiz client from saved config with the key decrypted for use.
const clientFor = (pluginCfg) => {
return makePostizClient({
baseUrl: pluginCfg && pluginCfg.baseUrl,
apiKey: resolveApiKey(pluginCfg)
});
};
// onLoad migration: Saltcorn's autoSave writes raw form values, so a freshly
// entered apiKey lands in the DB as plaintext. Re-seal it in place so the
// database never retains the plaintext beyond the load that follows the save.
const sealStoredApiKey = async () => {
const Plugin = require("@saltcorn/data/models/plugin");
const plugin = await Plugin.findOne({ name: PLUGIN_NAME });
if (!plugin || !plugin.configuration) {
return;
}
const key = plugin.configuration.apiKey;
if (!key || isEncryptedBlob(key)) {
return;
}
plugin.configuration = { ...plugin.configuration, apiKey: encryptSecret(key, API_KEY_INFO) };
await plugin.upsert();
};
// Pure: (action config, row, media ids, now) -> Postiz create-post body. Kept
// side-effect free so the test suite can assert the shape without a live
// server. NOTE: verify this body against docs.postiz.com/public-api -- the
// schema has churned across Postiz's weekly releases.
const buildPostPayload = (configuration, row, mediaIds, nowIso) => {
const content = row[configuration.contentField];
const integrationIds = String(configuration.integrationIds || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const value = [{ content, image: mediaIds.map((id) => ({ id })) }];
const date = configuration.when === POST_TYPES.SCHEDULE && configuration.scheduleField
? new Date(row[configuration.scheduleField]).toISOString()
: nowIso;
return {
type: configuration.when,
date: date,
tags: [],
posts: integrationIds.map((id) => ({
integration: { id },
value: value,
settings: {}
}))
};
};
// Builds the per-action config form. Populates the channel picker from the live
// Postiz instance so the admin selects real connected accounts; if Postiz is
// unreachable at config time the field falls back to a free-text id list.
const buildConfigFields = (pluginCfg) => async ({ table }) => {
const strFields = (table && table.fields ? table.fields : [])
.filter((f) => f.type && f.type.name === "String")
.map((f) => f.name);
let channelOptions = [];
try {
const integrations = await clientFor(pluginCfg).listIntegrations();
channelOptions = (integrations || []).map((i) => ({
label: `${i.name} (${i.identifier || i.providerIdentifier || "?"})`,
value: i.id
}));
} catch (e) {
// Postiz unreachable / key unset at config time -> admin types ids by hand.
}
return [
{ name: "contentField", label: "Content field", type: "String", required: true, attributes: { options: strFields } },
{ name: "mediaUrlField", label: "Media URL field (optional)", type: "String", attributes: { options: strFields } },
{ name: "integrationIds", label: "Post to channels", type: "String", required: true, attributes: { options: channelOptions } },
{ name: "when", label: "Timing", type: "String", required: true, attributes: { options: Object.values(POST_TYPES) } },
{ name: "scheduleField", label: "Schedule date field", type: "String", attributes: { options: strFields }, showIf: { when: POST_TYPES.SCHEDULE } }
];
};
const makePostAction = (pluginCfg) => ({
configFields: buildConfigFields(pluginCfg),
run: async ({ row, configuration }) => {
const client = clientFor(pluginCfg);
// Postiz wants media uploaded first, then referenced by id.
let mediaIds = [];
if (configuration.mediaUrlField && row[configuration.mediaUrlField]) {
const uploaded = await client.uploadMedia({ url: row[configuration.mediaUrlField] });
mediaIds = uploaded && uploaded.id ? [uploaded.id] : [];
}
const payload = buildPostPayload(configuration, row, mediaIds, new Date().toISOString());
return await client.createPost(payload);
}
});
module.exports = { makePostAction, buildPostPayload, buildConfigFields, resolveApiKey, sealStoredApiKey, API_KEY_INFO };