163 lines
9.8 KiB
Markdown
163 lines
9.8 KiB
Markdown
# postiz-poster
|
|
|
|
A Saltcorn plugin that publishes table rows to social networks through a
|
|
[Postiz](https://github.com/gitroomhq/postiz-app) instance -- self-hosted or
|
|
Postiz Cloud. Postiz handles the per-platform fan-out (Mastodon, Bluesky, X,
|
|
LinkedIn, Facebook, Instagram, Threads, Telegram, and more); this plugin just
|
|
maps a Saltcorn row into a Postiz post and calls Postiz's public API.
|
|
|
|
## How it works
|
|
|
|
- **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 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
|
|
|
|
```
|
|
postiz/
|
|
index.js plugin entry: wires config workflow + the action
|
|
lib/
|
|
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, 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 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
|
|
|
|
The Postiz API key is configured through the normal plugin UI but is **not**
|
|
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/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
|
|
`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
|
|
dev-deploy sync and set it per environment.
|
|
|
|
## Configuring
|
|
|
|
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** -- 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 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 # 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 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.
|
|
|
|
## Postiz API conformance
|
|
|
|
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
|
|
|
|
- Postiz removes the *subscription* cost (self-hosted) but not the *gatekeeping*:
|
|
you still register your own developer apps with each walled-garden platform
|
|
(Meta App Review, LinkedIn Community Management API, separate Threads app) and
|
|
own the OAuth token upkeep. Mastodon / Bluesky / X are straightforward.
|
|
- Underlying platform fees still pass through (e.g. X per-post pricing). Postiz
|
|
brokers the call; it does not make any platform's API free.
|