sc-postiz-poster/lib/postizClient.js
2026-10-19 01:20:42 -05:00

140 lines
5.3 KiB
JavaScript

// 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, REQUEST_TIMEOUT_MS, MAX_RESPONSE_BYTES, ERROR_SNIPPET_CHARS } = require("./constants");
const { readCappedResponse } = require("./common/cappedBody");
// The user-info part of a URL ("//user:password@"), up to the last @ before
// the path as URL parsers read it, matched without parsing (it may not parse).
const USERINFO_RE = /\/\/[^/?#]*@/;
// The operator-set baseUrl without surrounding whitespace or trailing slashes.
const trimBaseUrl = (raw) => {
return String(raw || "").trim().replace(/\/+$/, "");
};
// The baseUrl as shown in messages and status: trimmed, user-info removed.
const displayBaseUrl = (raw) => {
return trimBaseUrl(raw).replace(USERINFO_RE, "//");
};
const sameBaseUrl = (a, b) => {
return trimBaseUrl(a) === trimBaseUrl(b);
};
// Guard the operator-set baseUrl before we ever send the apiKey: reject
// unparseable / non-http(s) schemes (file:, gopher:, data:, ...), user-info
// (fetch refuses it, quoting the credentials in its error) and a query or
// fragment (the API path is appended). Private/self-hosted hosts are allowed by
// design (self-hosted Postiz). Messages quote the URL without user-info.
const checkedBaseUrl = (raw) => {
const text = trimBaseUrl(raw);
const shown = displayBaseUrl(text);
let parsed;
try {
parsed = new URL(text);
} catch (e) {
throw new Error(`Postiz baseUrl is not a valid URL: ${shown}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`Postiz baseUrl must use http or https: ${shown}`);
}
if (parsed.username || parsed.password) {
throw new Error(`Postiz baseUrl must not contain a user name or password: ${shown}`);
}
if (parsed.search || parsed.hash) {
throw new Error(`Postiz baseUrl must not contain a query or fragment: ${shown}`);
}
return text;
};
// An error quoting a bounded snippet of a Postiz response body. The snippet is
// remote text: summary and remoteText keep it apart for callers that must treat
// it as untrusted data (the MCP tools).
const remoteError = (summary, body) => {
const snippet = body.slice(0, ERROR_SNIPPET_CHARS);
return Object.assign(new Error(`${summary}: ${snippet}`), { summary: summary, remoteText: snippet });
};
// opts.timeoutMs overrides REQUEST_TIMEOUT_MS for every call of this client.
const makePostizClient = (pluginCfg, opts) => {
const apiKey = String((pluginCfg && pluginCfg.apiKey) || "");
const timeoutMs = (opts && opts.timeoutMs) || REQUEST_TIMEOUT_MS;
// Parsed JSON, or null for an empty 2xx body. A non-2xx status, a timeout,
// an oversized body, or a non-JSON body (e.g. the Postiz web frontend
// answering because the base URL lacks /api) is an error naming the status
// and content type.
const request = async (method, endpoint, body) => {
const url = `${checkedBaseUrl(pluginCfg && pluginCfg.baseUrl)}${PATH_BASE}${endpoint}`;
const label = `Postiz ${method} ${endpoint}`;
const headers = { [AUTH_HEADER]: apiKey };
const init = { method, headers, signal: AbortSignal.timeout(timeoutMs) };
if (body !== undefined) {
headers["Content-Type"] = "application/json";
init.body = JSON.stringify(body);
}
let res;
let text;
try {
res = await fetch(url, init);
text = (await readCappedResponse(res, MAX_RESPONSE_BYTES)).toString("utf8");
} catch (e) {
if (e.name === "TimeoutError") {
throw new Error(`${label}: no response within ${timeoutMs}ms`);
}
throw new Error(`${label}: ${e.message}`);
}
const status = `${label} -> ${res.status} (${res.headers.get("content-type") || "no content-type"})`;
if (!res.ok) {
throw remoteError(status, text);
}
if (text.trim() === "") {
return null;
}
try {
return JSON.parse(text);
} catch (e) {
throw remoteError(`${status}: response is not JSON`, text);
}
};
const listIntegrations = async () => {
const list = await request("GET", ENDPOINTS.INTEGRATIONS);
if (!Array.isArray(list)) {
throw new Error(`Postiz GET ${ENDPOINTS.INTEGRATIONS}: expected an array of integrations`);
}
return list;
};
// Postiz fetches the URL itself; returns the { id, path } a post references.
const uploadFromUrl = async (mediaUrl) => {
const media = await request("POST", ENDPOINTS.UPLOAD_FROM_URL, { url: mediaUrl });
if (!media || !media.id || !media.path) {
throw new Error(`Postiz POST ${ENDPOINTS.UPLOAD_FROM_URL}: response lacks id/path`);
}
return { id: media.id, path: media.path };
};
return {
createPost: (payload) => request("POST", ENDPOINTS.POSTS, payload),
listIntegrations,
uploadFromUrl
};
};
module.exports = {
checkedBaseUrl,
displayBaseUrl,
makePostizClient,
sameBaseUrl
};