// 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 };