Initial commit.
This commit is contained in:
commit
9e776ded42
11 changed files with 749 additions and 0 deletions
52
.gitattributes
vendored
Normal file
52
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Default line-ending handling for text files: normalize to LF in the repo,
|
||||
# check out native on each platform.
|
||||
* text=auto
|
||||
|
||||
# Executable shell scripts must stay LF so the shebang works on Unix even when
|
||||
# checked out on Windows.
|
||||
*.sh text eol=lf
|
||||
|
||||
# Collapse the lockfile in diffs / PR reviews and mark it generated.
|
||||
package-lock.json linguist-generated=true -diff
|
||||
|
||||
# Git LFS. Run `git lfs install` once per clone to activate the filters below.
|
||||
# This plugin is currently code-only; these patterns are a safety net so any
|
||||
# binary blob dropped into the tree is tracked correctly without anyone having
|
||||
# to remember to update this file.
|
||||
|
||||
# Archives
|
||||
*.zip filter=lfs diff=lfs merge=lfs -text
|
||||
*.tar filter=lfs diff=lfs merge=lfs -text
|
||||
*.tar.gz filter=lfs diff=lfs merge=lfs -text
|
||||
*.tgz filter=lfs diff=lfs merge=lfs -text
|
||||
*.gz filter=lfs diff=lfs merge=lfs -text
|
||||
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
||||
*.7z filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# Databases / snapshots
|
||||
*.sqlite filter=lfs diff=lfs merge=lfs -text
|
||||
*.sqlite3 filter=lfs diff=lfs merge=lfs -text
|
||||
*.db filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# Images
|
||||
*.png filter=lfs diff=lfs merge=lfs -text
|
||||
*.jpg filter=lfs diff=lfs merge=lfs -text
|
||||
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
||||
*.gif filter=lfs diff=lfs merge=lfs -text
|
||||
*.webp filter=lfs diff=lfs merge=lfs -text
|
||||
*.ico filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# Documents
|
||||
*.pdf filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# Fonts
|
||||
*.ttf filter=lfs diff=lfs merge=lfs -text
|
||||
*.otf filter=lfs diff=lfs merge=lfs -text
|
||||
*.woff filter=lfs diff=lfs merge=lfs -text
|
||||
*.woff2 filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# Audio / video
|
||||
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
||||
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||
*.wav filter=lfs diff=lfs merge=lfs -text
|
||||
*.webm filter=lfs diff=lfs merge=lfs -text
|
||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Installed npm dependencies. Restored from package.json on install; never
|
||||
# committed. (This plugin has no third-party runtime deps -- it uses Node 20's
|
||||
# global fetch -- but the entry guards against a future dependency slipping in.)
|
||||
node_modules/
|
||||
|
||||
# npm/yarn diagnostics and `npm pack` output.
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
*.tgz
|
||||
|
||||
# Test coverage output.
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Local environment overrides / secrets. The Postiz base URL and public API key
|
||||
# are entered in the Saltcorn plugin config UI; if a local .env is used to drive
|
||||
# the live test gate it must never be committed.
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Editor / OS junk.
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.idea/
|
||||
.vscode/
|
||||
104
README.md
Normal file
104
README.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# 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 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.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
postiz/
|
||||
index.js plugin entry: wires config workflow + the action
|
||||
lib/
|
||||
constants.js plugin name/version, Postiz endpoints, post types
|
||||
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)
|
||||
test/
|
||||
e2e.js payload + secrets gates, plus optional live gate
|
||||
```
|
||||
|
||||
## 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/secretsAtRest.js` seals the key with AES-256-GCM under a key derived
|
||||
(HKDF-SHA256) from Saltcorn's `session_secret` -- which lives in the env /
|
||||
config file, not the database. 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.
|
||||
- **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 env /
|
||||
config file. **Rotating `session_secret` invalidates the sealed key** -- 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** -- self-hosted `https://postiz.example.com`, or cloud
|
||||
`https://api.postiz.com`.
|
||||
- **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).
|
||||
|
||||
## Testing
|
||||
|
||||
```
|
||||
node test/e2e.js # pure payload gate only
|
||||
POSTIZ_BASE_URL=http://localhost:5000 \
|
||||
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.
|
||||
|
||||
## To confirm before production
|
||||
|
||||
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`).
|
||||
|
||||
## 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.
|
||||
35
index.js
Normal file
35
index.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// postiz-poster: Saltcorn plugin that publishes table rows to social networks
|
||||
// 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).
|
||||
|
||||
const { PLUGIN_NAME } = require("./lib/constants");
|
||||
const { configurationWorkflow } = require("./lib/config");
|
||||
const { makePostAction, sealStoredApiKey } = require("./lib/action");
|
||||
|
||||
|
||||
// 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.
|
||||
const onLoad = async () => {
|
||||
try {
|
||||
await sealStoredApiKey();
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${PLUGIN_NAME}] sealing stored secrets failed:`, err);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
module.exports = {
|
||||
sc_plugin_api_version: 1,
|
||||
plugin_name: PLUGIN_NAME,
|
||||
configuration_workflow: configurationWorkflow,
|
||||
onLoad: onLoad,
|
||||
// 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 || {})
|
||||
})
|
||||
};
|
||||
139
lib/action.js
Normal file
139
lib/action.js
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// 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 };
|
||||
39
lib/config.js
Normal file
39
lib/config.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// Plugin-level configuration: the Postiz connection (base URL + API key). Set
|
||||
// once per Saltcorn site under Settings -> Plugins -> postiz-poster. Every
|
||||
// trigger action reuses this saved connection.
|
||||
|
||||
const Workflow = require("@saltcorn/data/models/workflow");
|
||||
const Form = require("@saltcorn/data/models/form");
|
||||
|
||||
|
||||
const configurationWorkflow = () =>
|
||||
new Workflow({
|
||||
steps: [
|
||||
{
|
||||
name: "Postiz connection",
|
||||
form: async () =>
|
||||
new Form({
|
||||
fields: [
|
||||
{
|
||||
name: "baseUrl",
|
||||
label: "Postiz base URL",
|
||||
type: "String",
|
||||
required: true,
|
||||
sublabel: "Self-hosted: https://postiz.example.com | Cloud: https://api.postiz.com"
|
||||
},
|
||||
{
|
||||
name: "apiKey",
|
||||
label: "Public API key",
|
||||
type: "String",
|
||||
required: true,
|
||||
sublabel: "Postiz -> Settings -> Developers -> Public API",
|
||||
attributes: { input_type: "password" }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
module.exports = { configurationWorkflow };
|
||||
36
lib/constants.js
Normal file
36
lib/constants.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Compile-time constants for the postiz-poster plugin.
|
||||
|
||||
const PLUGIN_NAME = "postiz-poster";
|
||||
const PLUGIN_VERSION = "0.0.1";
|
||||
|
||||
// Postiz public REST API. The surface is identical for self-hosted and cloud;
|
||||
// only the base URL (plugin config) differs. Endpoint paths below are appended
|
||||
// to <baseUrl><PATH_BASE>.
|
||||
const PATH_BASE = "/public/v1";
|
||||
|
||||
const ENDPOINTS = {
|
||||
INTEGRATIONS: "/integrations", // GET -> list connected channels
|
||||
UPLOAD: "/upload", // POST -> media, returns { id, path }
|
||||
POSTS: "/posts" // POST -> create / schedule a post
|
||||
};
|
||||
|
||||
// Postiz post "type": publish now, schedule for a date, or save as a draft.
|
||||
const POST_TYPES = {
|
||||
NOW: "now",
|
||||
SCHEDULE: "schedule",
|
||||
DRAFT: "draft"
|
||||
};
|
||||
|
||||
// Header carrying the Postiz public API key. Some versions expect a bare key
|
||||
// rather than a Bearer prefix; isolated here so a change is one edit, not many.
|
||||
// Confirm against docs.postiz.com/public-api before shipping.
|
||||
const AUTH_HEADER = "Authorization";
|
||||
|
||||
module.exports = {
|
||||
PLUGIN_NAME,
|
||||
PLUGIN_VERSION,
|
||||
PATH_BASE,
|
||||
ENDPOINTS,
|
||||
POST_TYPES,
|
||||
AUTH_HEADER
|
||||
};
|
||||
36
lib/postizClient.js
Normal file
36
lib/postizClient.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Single source of truth for talking to the Postiz public API. One factory,
|
||||
// built from the plugin's saved { baseUrl, apiKey }. Self-hosted vs cloud is
|
||||
// purely the baseUrl -- no other code in the plugin branches on deployment.
|
||||
|
||||
const { PATH_BASE, ENDPOINTS, AUTH_HEADER } = require("./constants");
|
||||
|
||||
|
||||
const makePostizClient = (pluginCfg) => {
|
||||
const baseUrl = String((pluginCfg && pluginCfg.baseUrl) || "").replace(/\/+$/, "");
|
||||
const apiKey = String((pluginCfg && pluginCfg.apiKey) || "");
|
||||
|
||||
const request = async (method, endpoint, body) => {
|
||||
const url = `${baseUrl}${PATH_BASE}${endpoint}`;
|
||||
const headers = { [AUTH_HEADER]: apiKey };
|
||||
const init = { method, headers };
|
||||
if (body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(url, init);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Postiz ${method} ${endpoint} -> ${res.status} ${text}`);
|
||||
}
|
||||
return res.status === 204 ? null : res.json();
|
||||
};
|
||||
|
||||
return {
|
||||
createPost: (payload) => request("POST", ENDPOINTS.POSTS, payload),
|
||||
listIntegrations: () => request("GET", ENDPOINTS.INTEGRATIONS),
|
||||
uploadMedia: (payload) => request("POST", ENDPOINTS.UPLOAD, payload)
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
module.exports = { makePostizClient };
|
||||
96
lib/secretsAtRest.js
Normal file
96
lib/secretsAtRest.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// Encryption-at-rest for plugin secrets kept in _sc_plugins.configuration.
|
||||
//
|
||||
// Saltcorn persists plugin configuration PLAINTEXT in the database, so a DB
|
||||
// dump, a backup, or a dev-deploy journal sync would otherwise expose any API
|
||||
// key or signing secret stored there. This module seals such fields with
|
||||
// AES-256-GCM under a key derived (HKDF-SHA256) from Saltcorn's session_secret,
|
||||
// which lives in the env / config file -- NOT the database. Ciphertext in the
|
||||
// DB is therefore useless without the separately-held key.
|
||||
//
|
||||
// Threat model: this protects DB-only exposure (backups, peer sync, an admin
|
||||
// reading the config table). It does NOT protect an attacker who also holds the
|
||||
// host env / config file -- they have the key. Rotating session_secret
|
||||
// invalidates every sealed value (the secret must be re-entered in the UI).
|
||||
//
|
||||
// Shareable verbatim across plugins: callers pass a domain label (info) so each
|
||||
// plugin/field derives a distinct key.
|
||||
|
||||
const crypto = require("crypto");
|
||||
|
||||
const BLOB_PREFIX = "scs1:"; // saltcorn-secret, format v1
|
||||
const HASH = "sha256";
|
||||
const GCM = "aes-256-gcm";
|
||||
const IV_BYTES = 12;
|
||||
const TAG_BYTES = 16;
|
||||
const KEY_BYTES = 32;
|
||||
|
||||
const kekCache = new Map(); // info label -> 32-byte derived key
|
||||
|
||||
|
||||
const getSessionSecret = () => {
|
||||
const fromEnv = process.env.SALTCORN_SESSION_SECRET;
|
||||
if (fromEnv && fromEnv.length > 0) {
|
||||
return fromEnv;
|
||||
}
|
||||
// Fall back to the value Saltcorn loaded into its config state.
|
||||
try {
|
||||
const { getState } = require("@saltcorn/data/db/state");
|
||||
const v = getState().getConfig("session_secret");
|
||||
if (v) {
|
||||
return v;
|
||||
}
|
||||
} catch (e) {
|
||||
// state not available (e.g. unit tests) -> fall through to throw
|
||||
}
|
||||
throw new Error("secretsAtRest: SALTCORN_SESSION_SECRET unavailable; cannot derive key");
|
||||
};
|
||||
|
||||
|
||||
const getKek = (info) => {
|
||||
const cached = kekCache.get(info);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const ikm = Buffer.from(getSessionSecret(), "utf8");
|
||||
const salt = Buffer.from(`${BLOB_PREFIX}${info}`, "utf8");
|
||||
const kek = Buffer.from(crypto.hkdfSync(HASH, ikm, salt, Buffer.from(info, "utf8"), KEY_BYTES));
|
||||
kekCache.set(info, kek);
|
||||
return kek;
|
||||
};
|
||||
|
||||
|
||||
const isEncryptedBlob = (value) => {
|
||||
return typeof value === "string" && value.startsWith(BLOB_PREFIX);
|
||||
};
|
||||
|
||||
|
||||
const encryptSecret = (plaintext, info) => {
|
||||
const iv = crypto.randomBytes(IV_BYTES);
|
||||
const cipher = crypto.createCipheriv(GCM, getKek(info), iv);
|
||||
const ct = Buffer.concat([cipher.update(Buffer.from(String(plaintext), "utf8")), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return BLOB_PREFIX + Buffer.concat([iv, tag, ct]).toString("base64");
|
||||
};
|
||||
|
||||
|
||||
// Returns the plaintext, or null if the blob can't be opened (wrong/rotated
|
||||
// key, tampering, corruption). Callers treat null as "re-enter the secret".
|
||||
const decryptSecret = (blob, info) => {
|
||||
if (!isEncryptedBlob(blob)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = Buffer.from(blob.slice(BLOB_PREFIX.length), "base64");
|
||||
const iv = raw.subarray(0, IV_BYTES);
|
||||
const tag = raw.subarray(IV_BYTES, IV_BYTES + TAG_BYTES);
|
||||
const ct = raw.subarray(IV_BYTES + TAG_BYTES);
|
||||
const decipher = crypto.createDecipheriv(GCM, getKek(info), iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
module.exports = { encryptSecret, decryptSecret, isEncryptedBlob };
|
||||
21
package.json
Normal file
21
package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "postiz-poster",
|
||||
"version": "0.0.1",
|
||||
"description": "Saltcorn plugin: publish table rows to social networks (Mastodon, Bluesky, X, LinkedIn, Facebook, Instagram, Threads, and more) through a Postiz instance -- self-hosted or cloud -- via a single per-trigger action. Channels are chosen from the live Postiz integration list; post content and optional media come from row fields.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "node test/e2e.js"
|
||||
},
|
||||
"keywords": [
|
||||
"saltcorn",
|
||||
"postiz",
|
||||
"social-media",
|
||||
"publishing",
|
||||
"scheduling"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"author": "Scott Duensing",
|
||||
"license": "MIT"
|
||||
}
|
||||
162
test/e2e.js
Normal file
162
test/e2e.js
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
// postiz-poster integration test suite.
|
||||
//
|
||||
// Two gates:
|
||||
// 1. payloadGate -- pure assertions on buildPostPayload (always runs, no net).
|
||||
// 2. liveGate -- drives a real Postiz instance; runs only when both
|
||||
// POSTIZ_BASE_URL and POSTIZ_API_KEY are set.
|
||||
//
|
||||
// Run from the postiz/ directory:
|
||||
//
|
||||
// node test/e2e.js # payloadGate only
|
||||
// POSTIZ_BASE_URL=http://localhost:5000 \
|
||||
// POSTIZ_API_KEY=xxxxxxxx node test/e2e.js # + liveGate
|
||||
//
|
||||
// Without the env vars the live gate is skipped (not failed), so the pure gate
|
||||
// still runs anywhere with no Postiz available. Assertion failures are caught
|
||||
// and counted; one failure does not stop the suite.
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { buildPostPayload } = require("../lib/action");
|
||||
const { makePostizClient } = require("../lib/postizClient");
|
||||
const { encryptSecret, decryptSecret, isEncryptedBlob } = require("../lib/secretsAtRest");
|
||||
const { POST_TYPES } = require("../lib/constants");
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
|
||||
const check = (name, fn) => {
|
||||
try {
|
||||
fn();
|
||||
passed += 1;
|
||||
console.log(`ok - ${name}`);
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
console.log(`FAIL - ${name}: ${err && err.message ? err.message : err}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const checkAsync = async (name, fn) => {
|
||||
try {
|
||||
await fn();
|
||||
passed += 1;
|
||||
console.log(`ok - ${name}`);
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
console.log(`FAIL - ${name}: ${err && err.message ? err.message : err}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const payloadGate = () => {
|
||||
const nowIso = "2026-06-08T20:00:00.000Z";
|
||||
|
||||
check("now post targets every channel id, content carried through", () => {
|
||||
const cfg = { contentField: "body", integrationIds: "a, b ,c", when: POST_TYPES.NOW };
|
||||
const out = buildPostPayload(cfg, { body: "hi" }, [], nowIso);
|
||||
assert.equal(out.type, POST_TYPES.NOW);
|
||||
assert.equal(out.date, nowIso);
|
||||
assert.equal(out.posts.length, 3);
|
||||
assert.deepEqual(out.posts.map((p) => p.integration.id), ["a", "b", "c"]);
|
||||
assert.equal(out.posts[0].value[0].content, "hi");
|
||||
});
|
||||
|
||||
check("media ids attach as image refs", () => {
|
||||
const cfg = { contentField: "body", integrationIds: "a", when: POST_TYPES.NOW };
|
||||
const out = buildPostPayload(cfg, { body: "pic" }, ["m1", "m2"], nowIso);
|
||||
assert.deepEqual(out.posts[0].value[0].image, [{ id: "m1" }, { id: "m2" }]);
|
||||
});
|
||||
|
||||
check("scheduled post reads the date field, not now", () => {
|
||||
const cfg = { contentField: "body", integrationIds: "a", when: POST_TYPES.SCHEDULE, scheduleField: "at" };
|
||||
const out = buildPostPayload(cfg, { body: "later", at: "2026-12-25T09:00:00Z" }, [], nowIso);
|
||||
assert.equal(out.type, POST_TYPES.SCHEDULE);
|
||||
assert.equal(out.date, "2026-12-25T09:00:00.000Z");
|
||||
});
|
||||
|
||||
check("blank / whitespace channel ids are dropped", () => {
|
||||
const cfg = { contentField: "body", integrationIds: " , ,", when: POST_TYPES.DRAFT };
|
||||
const out = buildPostPayload(cfg, { body: "x" }, [], nowIso);
|
||||
assert.equal(out.posts.length, 0);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const secretsGate = () => {
|
||||
// secretsAtRest reads the key root from this env var (see getSessionSecret).
|
||||
process.env.SALTCORN_SESSION_SECRET = process.env.SALTCORN_SESSION_SECRET || "test-session-secret-do-not-use";
|
||||
const info = "postiz-poster:apiKey";
|
||||
|
||||
check("seal -> open round-trips the plaintext", () => {
|
||||
const blob = encryptSecret("super-secret-key", info);
|
||||
assert.ok(isEncryptedBlob(blob), "blob should carry the scs1: prefix");
|
||||
assert.notEqual(blob, "super-secret-key");
|
||||
assert.equal(decryptSecret(blob, info), "super-secret-key");
|
||||
});
|
||||
|
||||
check("ciphertext differs each call (random IV)", () => {
|
||||
assert.notEqual(encryptSecret("x", info), encryptSecret("x", info));
|
||||
});
|
||||
|
||||
check("wrong domain label cannot open the blob", () => {
|
||||
const blob = encryptSecret("k", info);
|
||||
assert.equal(decryptSecret(blob, "postiz-poster:other"), null);
|
||||
});
|
||||
|
||||
check("tampered blob fails GCM auth -> null", () => {
|
||||
const blob = encryptSecret("k", info);
|
||||
const i = "scs1:".length + 2;
|
||||
const flipped = blob[i] === "A" ? "B" : "A";
|
||||
const tampered = blob.slice(0, i) + flipped + blob.slice(i + 1);
|
||||
assert.equal(decryptSecret(tampered, info), null);
|
||||
});
|
||||
|
||||
check("plaintext is not mistaken for an encrypted blob", () => {
|
||||
assert.equal(isEncryptedBlob("plain"), false);
|
||||
assert.equal(decryptSecret("plain", info), null);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const liveGate = async () => {
|
||||
const baseUrl = process.env.POSTIZ_BASE_URL;
|
||||
const apiKey = process.env.POSTIZ_API_KEY;
|
||||
if (!baseUrl || !apiKey) {
|
||||
console.log("skip - liveGate (set POSTIZ_BASE_URL + POSTIZ_API_KEY to enable)");
|
||||
return;
|
||||
}
|
||||
|
||||
const client = makePostizClient({ baseUrl, apiKey });
|
||||
let firstChannel = null;
|
||||
|
||||
await checkAsync("listIntegrations returns an array", async () => {
|
||||
const integrations = await client.listIntegrations();
|
||||
assert.ok(Array.isArray(integrations), "expected an array of integrations");
|
||||
firstChannel = integrations[0] || null;
|
||||
});
|
||||
|
||||
await checkAsync("createPost (draft) is accepted", async () => {
|
||||
if (!firstChannel) {
|
||||
console.log(" (no channels connected -- connect one in Postiz to exercise createPost)");
|
||||
return;
|
||||
}
|
||||
const cfg = { contentField: "body", integrationIds: String(firstChannel.id), when: POST_TYPES.DRAFT };
|
||||
const payload = buildPostPayload(cfg, { body: "postiz-poster e2e draft -- safe to delete" }, [], new Date().toISOString());
|
||||
const res = await client.createPost(payload);
|
||||
assert.ok(res, "expected a create-post response");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const main = async () => {
|
||||
payloadGate();
|
||||
secretsGate();
|
||||
await liveGate();
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
};
|
||||
|
||||
|
||||
main();
|
||||
Loading…
Add table
Reference in a new issue