139 lines
5.8 KiB
JavaScript
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 };
|