diff --git a/README.md b/README.md index 6cd2eae..c9cdda3 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,20 @@ maps a Saltcorn row into a Postiz post and calls Postiz's public API. - **Plugin config** holds the Postiz connection: a base URL and a public API key. Self-hosted and cloud use the *same* `/public/v1` API, so switching between them is only a base-URL change -- no code change. -- **One action**, `post_to_postiz`, is attached to any trigger (row Insert / - Update, or a button). When it fires it reads the post text (and an optional - media URL) from the row, optionally uploads the media to Postiz, and creates - the post against the channels you selected. -- The channel picker in the action config is **populated from the live Postiz - instance** (`GET /public/v1/integrations`), so admins choose real connected - accounts instead of pasting ids. If Postiz is unreachable at config time the - field degrades to a free-text id list. +- **One action**, `post_to_postiz`, is attached to a table trigger (row Insert + / Update) or a row button (it needs a row). When it fires it reads the post + text (and an optional media URL) from the row, has Postiz fetch the media + (`POST /public/v1/upload-from-url`), and creates the post on the configured + channels (`POST /public/v1/posts`). It reports a short `notify` message; the + Postiz response itself is not passed back into Saltcorn. +- **Channels** are a comma-separated list of Postiz integration ids. The action + config lists the live connected channels (name, provider, id) under the field, + from `GET /public/v1/integrations` (fetched with a short timeout and cached + for a minute, because Saltcorn rebuilds action config forms on every + view-builder load). It is free text rather than a picker on purpose: + Saltcorn's select is single-choice, and a required select with no options + (Postiz unreachable) renders no input, which would leave the trigger + unsavable. ## Layout @@ -26,13 +32,19 @@ maps a Saltcorn row into a Postiz post and calls Postiz's public API. postiz/ index.js plugin entry: wires config workflow + the action lib/ - constants.js plugin name/version, Postiz endpoints, post types + constants.js plugin name, Postiz endpoints, post types, timeouts/limits postizClient.js the only module that speaks HTTP to Postiz - config.js configuration_workflow (base URL + API key) - action.js post_to_postiz: configFields, payload builder, run - secretsAtRest.js AES-256-GCM seal/open for the API key (shared helper) + config.js configuration_workflow (base URL + API key, sealed on save) + action.js post_to_postiz: configFields, payload builder, publish, run + apiKey.js seal / open / migrate the stored API key + mcpTools.js saltcorn-mcp tools (postiz_*), adapters over publish + the client + common/ aesGcm + sessionKey (at-rest sealing), cappedBody, + htmlEscape test/ - e2e.js payload + secrets gates, plus optional live gate + e2e.js offline gates (stub servers, stubbed Saltcorn) + optional live gate + fakePostiz.js the local Postiz stand-in the offline gates use + compatVectors.js recorded vectors (compatVectors.json): a key sealed by + earlier versions still opens ``` ## Secrets at rest @@ -42,26 +54,26 @@ left plaintext in the database. Saltcorn stores plugin configuration unencrypted in `_sc_plugins.configuration`, which would otherwise expose the key to DB backups, admin reads, and dev-deploy's journal sync. Instead: -- `lib/secretsAtRest.js` seals the key with AES-256-GCM under a key derived - (HKDF-SHA256) from Saltcorn's `session_secret`, which it resolves from the - Saltcorn installation itself -- no extra env var required. `getSessionSecret` - tries, in order: `SALTCORN_SESSION_SECRET` (an optional override, checked - first and wins when present), `db.connectObj.session_secret` (Saltcorn's - resolved value, which already merges the env var and the `~/.config/.saltcorn` - config file), `getConfigFile()` reading `~/.config/.saltcorn` directly (for - when the db module is not yet initialised), and finally the `session_secret` - DB config. It throws (`secretsAtRest: session_secret unavailable ...`) only if - all four miss. The derived KEK is cached keyed by a hash of the resolved - secret, so changing the secret re-derives it. Ciphertext in the DB is useless - without it. -- Because Saltcorn's config form autosaves raw values, `onLoad` re-seals any - plaintext key the moment the plugin (re)loads after a save; the action - decrypts only at the point of use. +- `lib/apiKey.js` seals the key (via the shared `lib/common/aesGcm.js` + + `sessionKey.js`) with AES-256-GCM (full 16-byte tags + enforced) under a key derived (HKDF-SHA256) from Saltcorn's `session_secret`, + resolved with Saltcorn's own precedence and ONLY from: the + `SALTCORN_SESSION_SECRET` env var, then the `session_secret` in the Saltcorn + config file (`~/.config/.saltcorn`). When neither is set, Saltcorn falls back + to a secret it generates per process (or the constant `deadbeef`); a key + derived from that would not open after a restart or in another worker, or + would be derivable by anyone, so the plugin refuses it: the API key then stays + plaintext and a warning is logged. The derived KEK is cached for the resolved + secret, so changing the secret re-derives it. +- The configuration workflow seals a typed key **before** Saltcorn saves it (in + both the full save and the autosave path), so the plaintext never reaches the + database. `onLoad` seals a plaintext key left by versions before 0.0.3. The + action decrypts only at the point of use. - **Threat model:** this protects DB-only exposure (backups, sync, config-table reads). It does not protect a host that leaks both the database and the - resolved `session_secret` (config file, env override, or DB config). - **Rotating `session_secret` invalidates the sealed key** (the hash-keyed KEK - cache re-derives) -- you must re-enter it in the plugin config (the action + `session_secret` (env var or config file). + **Rotating `session_secret` invalidates the sealed key** (the KEK cache + re-derives) -- you must re-enter it in the plugin config (the action raises a clear error saying so). - For the same reason, the sealed key is environment-specific: it will not decrypt on a peer with a different `session_secret`, so exclude it from any @@ -72,37 +84,74 @@ backups, admin reads, and dev-deploy's journal sync. Instead: 1. In Postiz, connect your social accounts, then create a key under **Settings -> Developers -> Public API**. 2. In Saltcorn: **Settings -> Plugins -> postiz-poster**, set: - - **Postiz base URL** -- self-hosted `https://postiz.example.com`, or cloud - `https://api.postiz.com`. + - **Postiz base URL** -- cloud `https://api.postiz.com`; self-hosted, the + backend URL (`NEXT_PUBLIC_BACKEND_URL`), normally your Postiz site plus + `/api`, e.g. `https://postiz.example.com/api`. (The bare site URL reaches + the web frontend; the action then reports a "response is not JSON" error.) + A user name or password in the URL is refused (fetch cannot send them). - **Public API key**. 3. Create a trigger on the table you want to publish from, choose action - `post_to_postiz`, then pick the content field, optional media field, target - channels, and timing (now / schedule / draft). + `post_to_postiz`, then pick the content field, optional media URL field, + the channel ids (listed under the field), and timing (now / schedule / + draft). A schedule date comes from a Date field, or a text field holding an + ISO date with a time zone (e.g. `2026-12-25T09:00:00Z`); zone-less text is + rejected rather than read in the server's local time zone. + +## MCP tools (saltcorn-mcp) + +With the [saltcorn-mcp](../mcp) plugin installed, an AI client can drive this +plugin through these tools (contract: `../mcp/docs/pluginTools.md`). They are +adapters over the same `publish()` the action's `run` uses, read the plugin +configuration as it is at call time, and never return the API key: results and +errors are scrubbed of it, only allow-listed fields of Postiz responses pass, +and quoted Postiz response text is marked as untrusted data. + +| tool | scope | what it does | +|---|---|---| +| `postiz_status` | read | configured?, base URL (user-info removed) and why it would be refused, API key state (`unset` / `sealed` / `sealed_unopenable` / `plaintext`), session secret ok; `probe: true` also tests the connection | +| `postiz_list_channels` | read | the connected channels, fetched now: `id`, `name`, `identifier`, `disabled`, `unsupported` (providers this plugin cannot post to); `include_disabled` | +| `postiz_preview_post` | read | dry run for a `post_to_postiz` trigger or a configuration, and a stored row (`table` + `row_id`, read as the token's user may read it) or a row object: the exact payload (media placeholder), media URL, warnings, the notify text. Lists channels only: no upload, no post. Secret-named row fields are masked | +| `postiz_post_row` | admin | publishes like the action (same inputs as the preview, optional `when` override); returns the notify text and the created post ids | +| `postiz_post_text` | admin | publishes free text: `content`, `integration_ids`, `when`, `date` (schedule), `media_url` | +| `postiz_check_connection` | admin | tests a candidate `base_url` and/or `api_key` before `configure_plugin`; a base URL other than the configured one needs `api_key` too (the stored key is only sent to the configured URL) | + +Posting is admin because it is public and irreversible; checking candidate +values is admin because it sends a key to a URL the caller chooses. The action +also declares `mcp_admin_reason`, so saltcorn-mcp requires an admin token to +create, change or run a `post_to_postiz` trigger as well. ## Testing ``` -node test/e2e.js # pure payload gate only -POSTIZ_BASE_URL=http://localhost:5000 \ +node test/e2e.js # offline gates +POSTIZ_BASE_URL=https://postiz.example.com/api \ POSTIZ_API_KEY=xxxxxxxx node test/e2e.js # + live gate vs a real Postiz ``` -The live gate lists integrations and creates a **draft** (safe to delete) using -the first connected channel. With no env vars it is skipped, not failed. +The offline gates include `mcpGate`: every tool definition checked against +saltcorn-mcp's schema subset (`../mcp/lib/schemaCheck.js`, when present) and +every tool driven against the fake Postiz with a ctx stub. The suite starts with +`compatGate`: `test/compatVectors.js` proves an API key sealed by earlier +versions (recorded in `test/compatVectors.json`) still opens. The offline gates need no Postiz and no Saltcorn: they stub the +`@saltcorn/data` modules and run the HTTP client and the action against local +stub servers. The live gate lists integrations and creates a **draft** (safe to +delete) on the first enabled channel; with no env vars it is skipped, and with +no enabled channel it fails. -## To confirm before production +## Postiz API conformance -These are sketched against the documented Postiz API and isolated to one or two -spots each, but verify against [docs.postiz.com/public-api](https://docs.postiz.com/public-api), -which has changed across Postiz's weekly releases: - -- **Create-post body shape** (`type` / `date` / `posts[].integration` / - `value[].content` / `value[].image`) -- see `buildPostPayload` in - `lib/action.js`. -- **Auth header format** -- bare key vs `Bearer` (`AUTH_HEADER` in - `lib/constants.js`). -- **Media upload** -- whether `/upload` accepts a URL fetch or wants multipart, - and the id/path field it returns (`uploadMedia` in `lib/postizClient.js`). +The request shapes follow the published public API +([docs.postiz.com/public-api](https://docs.postiz.com/public-api)): bare key in +`Authorization`; `upload-from-url` with `{ url }` returning `{ id, path }`; the +create-post body with `type`, `date`, `shortLink: false`, `tags: []` and, per +channel, `integration.id`, `value[{ content, image[{ id, path }] }]` and +`settings.__type` (the integration's `identifier`). They have not yet been run +against a live Postiz. Providers that require settings beyond `__type`: X gets +`who_can_reply_post: "everyone"` and Instagram `post_type: "post"` +(`PROVIDER_DEFAULT_SETTINGS` in `lib/constants.js`); YouTube, TikTok, Reddit +and Pinterest need per-post choices this plugin does not collect +(`UNSUPPORTED_PROVIDERS`), so Postiz rejects those posts with a 400 that the +action reports (the MCP channel listing flags them and the preview warns). ## Notes diff --git a/index.js b/index.js index 8aafe96..8b3615d 100644 --- a/index.js +++ b/index.js @@ -2,16 +2,18 @@ // through a Postiz instance (self-hosted or cloud). Exposes one trigger action, // post_to_postiz, configured per-trigger. The Postiz connection itself lives in // the plugin configuration (see lib/config.js); the API key is sealed at rest -// (see lib/secretsAtRest.js). +// (see lib/apiKey.js). The saltcorn-mcp server drives the plugin through the +// tools of lib/mcpTools.js. -const { PLUGIN_NAME } = require("./lib/constants"); -const { configurationWorkflow } = require("./lib/config"); -const { makePostAction, sealStoredApiKey } = require("./lib/action"); +const { PLUGIN_NAME, ACTION_NAME } = require("./lib/constants"); +const { configurationWorkflow } = require("./lib/config"); +const { makePostAction } = require("./lib/action"); +const { sealStoredApiKey } = require("./lib/apiKey"); +const mcpTools = require("./lib/mcpTools"); -// Runs each time the plugin loads -- including immediately after a config save -// (Saltcorn calls Plugin.loadPlugin after upsert). Seals any plaintext apiKey -// the autoSave form left in the database. +// Runs each time the plugin loads. Seals a plaintext apiKey left in the database +// by plugin versions before 0.0.3 (the configuration workflow now seals on save). const onLoad = async () => { try { await sealStoredApiKey(); @@ -30,6 +32,9 @@ module.exports = { // Saltcorn calls capability keys with the saved plugin config when a // configuration_workflow is present (see saltcorn-data db/state.ts). actions: (pluginCfg) => ({ - post_to_postiz: makePostAction(pluginCfg || {}) - }) + [ACTION_NAME]: makePostAction(pluginCfg || {}) + }), + // saltcorn-mcp tools (mcp/docs/pluginTools.md); handlers read the current + // configuration themselves, so the list does not depend on it. + mcp_tools: () => mcpTools.tools() }; diff --git a/lib/action.js b/lib/action.js index 90bb54d..b471af9 100644 --- a/lib/action.js +++ b/lib/action.js @@ -1,148 +1,229 @@ -// 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 post_to_postiz action. Attach it to a table trigger (row Insert / Update) +// or a row button: it maps the triggering row into a Postiz post and publishes +// through the configured channels. The body follows the documented public API +// (docs.postiz.com/public-api, create post): type, date, shortLink, tags, and per +// channel integration.id, value[{ content, image[{ id, path }] }] and settings +// with the provider's __type. // -// 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. +// The Postiz API key is stored sealed in the plugin config (lib/apiKey.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"); +const { resolveApiKey } = require("./apiKey"); +// Saltcorn renders a field sublabel as raw HTML; channel names come from Postiz. +const { escapeHtml } = require("./common/htmlEscape"); +const { PLUGIN_NAME, ACTION_NAME, POST_TYPES, PROVIDER_DEFAULT_SETTINGS, CONFIG_LIST_TIMEOUT_MS, CHANNEL_CACHE_TTL_MS, MEDIA_PLACEHOLDER } = 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`; +// Row fields offered as content / media URL sources, and as a schedule date. +const TEXT_FIELD_TYPES = ["String"]; +const DATE_FIELD_TYPES = ["Date", "String"]; + +// Free-text schedule dates must name their zone (Z, +hh:mm, UTC/GMT); without +// one, Date parses them in the server's local time zone. +const ZONED_DATE_RE = /(Z|UTC|GMT|[+-]\d{2}:?\d{2})$/i; + +// Why an MCP token needs admin scope to create, change or run a trigger using +// the action (saltcorn-mcp docs/pluginTools.md). +const MCP_ADMIN_REASON = "it publishes posts to social networks through Postiz"; + +// The run() result message per timing. +const NOTIFY_TEXT = { + [POST_TYPES.NOW]: "Posted to Postiz", + [POST_TYPES.SCHEDULE]: "Scheduled on Postiz", + [POST_TYPES.DRAFT]: "Saved as a Postiz draft" +}; + +// Last config-page channel listing: { key, at, channels, error }. +let channelListing = null; -// 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; +const isConfigured = (pluginCfg) => { + return !!(pluginCfg && pluginCfg.baseUrl && pluginCfg.apiKey); }; -// Build a Postiz client from saved config with the key decrypted for use. -const clientFor = (pluginCfg) => { +// A Postiz client from saved config with the key decrypted for use. Throws an +// actionable error when the connection is not configured. +const configuredClient = (pluginCfg, opts) => { + if (!isConfigured(pluginCfg)) { + throw new Error(`${PLUGIN_NAME}: set the Postiz base URL and API key in the plugin configuration`); + } return makePostizClient({ - baseUrl: pluginCfg && pluginCfg.baseUrl, + baseUrl: pluginCfg.baseUrl, apiKey: resolveApiKey(pluginCfg) - }); + }, opts); }; -// 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(); +const parseIds = (text) => { + return String(text || "").split(",").map((s) => s.trim()).filter(Boolean); }; -// 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 })) }]; - let date = nowIso; - if (configuration.when === POST_TYPES.SCHEDULE && configuration.scheduleField) { - // The schedule field is free-text row data; a blank or non-date value makes - // new Date(...).toISOString() throw an unhandled RangeError that fails the - // action with a cryptic message. Validate and fail with an actionable one. - const raw = row[configuration.scheduleField]; - const parsed = new Date(raw); - if (Number.isNaN(parsed.getTime())) { - throw new Error(`${PLUGIN_NAME}: schedule field "${configuration.scheduleField}" is not a valid date (${JSON.stringify(raw)}); set a parseable date to schedule this post`); +// The connected channels for the config page, reused for CHANNEL_CACHE_TTL_MS +// (Saltcorn rebuilds every action's config fields on each view-builder load, and +// on each list-row action click) and fetched with a short timeout; a failure is +// cached too, so an unreachable Postiz does not stall every page. +const listChannelsForConfig = async (pluginCfg) => { + const key = `${pluginCfg.baseUrl}\n${pluginCfg.apiKey}`; + if (channelListing && channelListing.key === key && Date.now() - channelListing.at < CHANNEL_CACHE_TTL_MS) { + return channelListing; + } + let channels = []; + let error = null; + try { + channels = await configuredClient(pluginCfg, { timeoutMs: CONFIG_LIST_TIMEOUT_MS }).listIntegrations(); + } catch (e) { + error = e.message; + } + channelListing = { key: key, at: Date.now(), channels: channels, error: error }; + return channelListing; +}; + + +const channelSublabel = (listing) => { + if (listing.error) { + return `Could not list channels from Postiz (${escapeHtml(listing.error)}).`; + } + if (listing.channels.length === 0) { + return "No channels are connected in Postiz yet."; + } + return "Connected: " + listing.channels.map((c) => `${escapeHtml(c.name)} (${escapeHtml(c.identifier)}) = ${escapeHtml(c.id)}`).join("; "); +}; + + +// The ISO date the post is published / scheduled at. +const postDate = (configuration, row, nowIso) => { + if (configuration.when !== POST_TYPES.SCHEDULE) { + return nowIso; + } + if (!configuration.scheduleField) { + throw new Error(`${PLUGIN_NAME}: timing is "schedule" but no schedule date field is set`); + } + // A Date-typed field arrives as a Date; free text is row data that may be + // blank, unparseable, or zone-less -- fail with an actionable message. + const raw = row[configuration.scheduleField]; + const zoned = raw instanceof Date || ZONED_DATE_RE.test(String(raw === null || raw === undefined ? "" : raw).trim()); + const parsed = new Date(raw); + if (!zoned || Number.isNaN(parsed.getTime())) { + throw new Error(`${PLUGIN_NAME}: schedule field "${configuration.scheduleField}" is not a valid date with a time zone (${JSON.stringify(raw)}); use e.g. 2026-12-25T09:00:00Z or a Date field`); + } + return parsed.toISOString(); +}; + + +// Pure: (action config, row, uploaded media [{ id, path }], now, the account's +// Postiz integrations) -> create-post body. Side-effect free so the test suite +// asserts the documented shape without a live server. Throws an actionable error +// for an empty content field, no / unknown / disabled channels, or a bad +// schedule date. +const buildPostPayload = (configuration, row, media, nowIso, integrations) => { + if (!Object.values(POST_TYPES).includes(configuration.when)) { + throw new Error(`${PLUGIN_NAME}: timing must be one of ${Object.values(POST_TYPES).join(", ")}`); + } + const content = row[configuration.contentField]; + if (content === undefined || content === null || String(content).trim() === "") { + throw new Error(`${PLUGIN_NAME}: content field "${configuration.contentField}" is empty`); + } + const ids = parseIds(configuration.integrationIds); + if (ids.length === 0) { + throw new Error(`${PLUGIN_NAME}: no Postiz channel ids configured`); + } + const byId = new Map(integrations.map((i) => [String(i.id), i])); + const channels = ids.map((id) => { + const channel = byId.get(id); + if (!channel || channel.disabled) { + throw new Error(`${PLUGIN_NAME}: Postiz channel "${id}" is not connected (or is disabled) on this Postiz account`); } - date = parsed.toISOString(); - } - + return channel; + }); + const value = [{ content: String(content), image: media.map((m) => ({ id: m.id, path: m.path })) }]; return { - type: configuration.when, - date: date, - tags: [], - posts: integrationIds.map((id) => ({ - integration: { id }, + type: configuration.when, + shortLink: false, + date: postDate(configuration, row, nowIso), + tags: [], + posts: channels.map((channel) => ({ + integration: { id: channel.id }, value: value, - settings: {} + settings: { __type: channel.identifier, ...PROVIDER_DEFAULT_SETTINGS[channel.identifier] } })) }; }; -// 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. +// Builds the per-action config form. Channels are a free-text, comma-separated +// list of Postiz integration ids, with the live connected channels (name, +// provider, id) listed in the sublabel. A select is not used: Saltcorn's String +// select is single-choice, and a required select with no options (Postiz +// unreachable) renders no input at all, leaving the trigger unsavable. 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. - } - + const fields = table && table.fields ? table.fields : []; + const namesOf = (types) => fields.filter((f) => f.type && types.includes(f.type.name)).map((f) => f.name); + const textFields = namesOf(TEXT_FIELD_TYPES); + const listing = await listChannelsForConfig(pluginCfg); 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: "contentField", label: "Content field", type: "String", required: true, attributes: { options: textFields } }, + { name: "mediaUrlField", label: "Media URL field (optional)", type: "String", attributes: { options: textFields } }, + { name: "integrationIds", label: "Post to channels", type: "String", required: true, sublabel: "Comma-separated Postiz integration ids. " + channelSublabel(listing) }, { 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 } } + { name: "scheduleField", label: "Schedule date field", type: "String", attributes: { options: namesOf(DATE_FIELD_TYPES) }, showIf: { when: POST_TYPES.SCHEDULE } } ]; }; +// One post of a row through the configured Postiz: list the account's channels, +// build and validate the create-post body, have Postiz fetch the media, create +// the post. Returns { payload, mediaUrl, notify, response } (response is the +// raw Postiz answer: remote data, for callers to allow-list). A dry run +// (opts.dryRun) makes only the channel listing GET -- no upload (Postiz would +// fetch an arbitrary URL) and no post; its payload carries MEDIA_PLACEHOLDER. +const publish = async (pluginCfg, configuration, row, opts) => { + const dryRun = !!(opts && opts.dryRun); + const client = configuredClient(pluginCfg); + const integrations = await client.listIntegrations(); + const nowIso = new Date().toISOString(); + // Validate everything before uploading media, so a bad config or row + // does not leave an orphan upload on Postiz. + buildPostPayload(configuration, row, [], nowIso, integrations); + const field = configuration.mediaUrlField; + const mediaUrl = field && row[field] ? String(row[field]) : null; + let media = []; + if (mediaUrl) { + media = [dryRun ? MEDIA_PLACEHOLDER : await client.uploadFromUrl(mediaUrl)]; + } + const payload = buildPostPayload(configuration, row, media, nowIso, integrations); + const response = dryRun ? null : await client.createPost(payload); + return { + payload: payload, + mediaUrl: mediaUrl, + notify: `${NOTIFY_TEXT[configuration.when]} (${payload.posts.length} channel(s))`, + response: response + }; +}; + + const makePostAction = (pluginCfg) => ({ - configFields: buildConfigFields(pluginCfg), + requireRow: true, + mcp_admin_reason: MCP_ADMIN_REASON, + configFields: buildConfigFields(pluginCfg), + // Returns only a fixed { notify } shape: Saltcorn merges an action's result + // into the response it acts on (goto, eval_js, ...), so the remote Postiz + // response must not pass through. 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] : []; + if (!row) { + throw new Error(`${PLUGIN_NAME}: ${ACTION_NAME} needs a row (attach it to a table trigger or a row button)`); } - - const payload = buildPostPayload(configuration, row, mediaIds, new Date().toISOString()); - return await client.createPost(payload); + const posted = await publish(pluginCfg, configuration, row); + return { notify: posted.notify }; } }); -module.exports = { makePostAction, buildPostPayload, buildConfigFields, resolveApiKey, sealStoredApiKey, API_KEY_INFO }; +module.exports = { + buildPostPayload, + configuredClient, + isConfigured, + makePostAction, + publish +}; diff --git a/lib/apiKey.js b/lib/apiKey.js new file mode 100644 index 0000000..981f82f --- /dev/null +++ b/lib/apiKey.js @@ -0,0 +1,122 @@ +// The Postiz API key at rest: sealed in _sc_plugins.configuration (Saltcorn +// stores plugin configuration plaintext, so a DB dump, a backup or a dev-deploy +// sync would otherwise expose it), opened only at the point of use. The key is +// derived from the session secret (env / config file, NOT the database), see +// lib/common/aesGcm.js + sessionKey.js. The configuration workflow seals a newly +// typed key before Saltcorn saves it (lib/config.js); onLoad seals a plaintext +// key left by plugin versions before 0.0.3. Rotating the session secret means +// re-entering the key. + +const { createSealer, isSealedString, STRING_PREFIX } = require("./common/aesGcm"); +const { getSessionSecret } = require("./common/sessionKey"); +const { PLUGIN_NAME } = 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`; + +// What the stored apiKey is: not set, sealed (and it opens), sealed but it does +// not open (session secret missing or rotated), or plaintext (legacy, or no +// session secret to seal under). +const API_KEY_STATES = { + UNSET: "unset", + SEALED: "sealed", + SEALED_UNOPENABLE: "sealed_unopenable", + PLAINTEXT: "plaintext" +}; + + +// The sealer of one configuration field, keyed by its domain label: HKDF salt +// "scs1: