140 lines
5.3 KiB
JavaScript
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
|
|
};
|