// 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 };