- Implemented a migration process to move resources from the legacy owner "default" to the new default owner "local", ensuring no data loss during the transition.
- Updated configuration reference handling to replace legacy `$VAR_`, `$SECRET_`, and `$CONTEXT_` prefixes with mustache-style `{{ vars.name }}`, `{{ secrets.name }}`, and `{{ context.name }}`.
- Enhanced YAML configuration files and scripts to reflect the new mustache syntax, improving consistency across the application.
- Added tests to validate the migration process and ensure proper handling of legacy references.
- Updated documentation to guide users on the new configuration reference format.
247 lines
7.0 KiB
JavaScript
247 lines
7.0 KiB
JavaScript
const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
|
const DEFAULT_TIMEOUT_MS = 600_000;
|
|
const JOPLIN_ID_RE = /^[a-fA-F0-9]{32}$/;
|
|
|
|
function passContext(ctx) {
|
|
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
|
return { ...ctx.context };
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function unwrapScalar(value) {
|
|
if (value == null) return "";
|
|
if (typeof value === "string") return value;
|
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
if (typeof value.reveal === "function") {
|
|
return unwrapScalar(value.reveal());
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function fail(ctx, startedAt, message, extra = {}) {
|
|
const durationMs = Date.now() - startedAt;
|
|
const output = {
|
|
ok: false,
|
|
status: extra.status ?? null,
|
|
httpResponse: extra.httpResponse ?? null,
|
|
durationMs,
|
|
message,
|
|
};
|
|
log.warn({ durationMs, status: output.status, message }, "joplin-api: failed");
|
|
return { output, context: { ...passContext(ctx), ...output } };
|
|
}
|
|
|
|
function okResult(ctx, startedAt, status, httpResponse, message) {
|
|
const durationMs = Date.now() - startedAt;
|
|
const output = {
|
|
ok: true,
|
|
status,
|
|
httpResponse,
|
|
durationMs,
|
|
message,
|
|
};
|
|
log.info({ durationMs, status }, "joplin-api: ok");
|
|
return { output, context: { ...passContext(ctx), ...output } };
|
|
}
|
|
|
|
function resolveMethod(ctx) {
|
|
const raw = unwrapScalar(ctx.config?.method || ctx.data?.method || "POST");
|
|
const method = raw.toUpperCase();
|
|
if (!HTTP_METHODS.includes(method)) {
|
|
return { error: `unsupported method "${raw}"` };
|
|
}
|
|
return { method };
|
|
}
|
|
|
|
function resolveTimeoutMs(ctx) {
|
|
const raw = ctx.config?.timeoutMs ?? ctx.data?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
const n = typeof raw === "number" ? raw : Number(raw);
|
|
if (!Number.isFinite(n) || n <= 0) return DEFAULT_TIMEOUT_MS;
|
|
return n;
|
|
}
|
|
|
|
function noteIdFromData(ctx) {
|
|
const id = unwrapScalar(ctx.data?.id).trim();
|
|
return id.length > 0 ? id : null;
|
|
}
|
|
|
|
function joinUrl(base, id) {
|
|
const trimmed = base.replace(/\/+$/, "");
|
|
return `${trimmed}/${id}`;
|
|
}
|
|
|
|
function authorizationHeader(token) {
|
|
const trimmed = token.trim();
|
|
if (/^bearer\s+/i.test(trimmed)) return trimmed;
|
|
return `Bearer ${trimmed}`;
|
|
}
|
|
|
|
function summarizeSuccess(status, body, durationMs) {
|
|
if (body != null && typeof body === "object" && !Array.isArray(body)) {
|
|
if (typeof body.date === "string" && body.date) {
|
|
return `ok ${status} date=${body.date} in ${durationMs}ms`;
|
|
}
|
|
if (typeof body.status === "string" && body.status) {
|
|
return `ok ${status} ${body.status} in ${durationMs}ms`;
|
|
}
|
|
}
|
|
return `ok ${status} in ${durationMs}ms`;
|
|
}
|
|
|
|
function summarizeError(status, body) {
|
|
if (body != null && typeof body === "object" && typeof body.error === "string") {
|
|
return status != null ? `HTTP ${status}: ${body.error}` : body.error;
|
|
}
|
|
if (typeof body === "string" && body.length > 0) {
|
|
const clip = body.length > 300 ? `${body.slice(0, 300)}…` : body;
|
|
return status != null ? `HTTP ${status}: ${clip}` : clip;
|
|
}
|
|
return status != null ? `HTTP ${status}` : "request failed";
|
|
}
|
|
|
|
async function parseBody(response) {
|
|
const text = await response.text();
|
|
if (text.length === 0) return null;
|
|
const contentType = response.headers.get("content-type") || "";
|
|
if (contentType.includes("application/json") || /^[\[{]/.test(text.trim())) {
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return text;
|
|
}
|
|
}
|
|
return text;
|
|
}
|
|
|
|
async function joplinHttp(ctx) {
|
|
const startedAt = Date.now();
|
|
const url = unwrapScalar(ctx.config?.url).trim();
|
|
if (!url) {
|
|
return fail(ctx, startedAt, "config.url is required");
|
|
}
|
|
|
|
const token = unwrapScalar(ctx.config?.token);
|
|
if (!token) {
|
|
return fail(ctx, startedAt, "config.token is required");
|
|
}
|
|
|
|
const methodResult = resolveMethod(ctx);
|
|
if (methodResult.error) {
|
|
return fail(ctx, startedAt, methodResult.error);
|
|
}
|
|
const { method } = methodResult;
|
|
|
|
const id = noteIdFromData(ctx);
|
|
if (id != null && !JOPLIN_ID_RE.test(id)) {
|
|
return fail(ctx, startedAt, "data.id must be a 32-character hex Joplin id");
|
|
}
|
|
|
|
const requestUrl = id != null ? joinUrl(url, id) : url;
|
|
const timeoutMs = resolveTimeoutMs(ctx);
|
|
|
|
log.info({ url: requestUrl, method, timeoutMs, hasId: id != null }, "joplin-api: request");
|
|
|
|
try {
|
|
const response = await fetch(requestUrl, {
|
|
method,
|
|
headers: {
|
|
Accept: "application/json",
|
|
Authorization: authorizationHeader(token),
|
|
},
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
const httpResponse = await parseBody(response);
|
|
const durationMs = Date.now() - startedAt;
|
|
if (response.ok) {
|
|
return okResult(
|
|
ctx,
|
|
startedAt,
|
|
response.status,
|
|
httpResponse,
|
|
summarizeSuccess(response.status, httpResponse, durationMs),
|
|
);
|
|
}
|
|
return fail(ctx, startedAt, summarizeError(response.status, httpResponse), {
|
|
status: response.status,
|
|
httpResponse,
|
|
});
|
|
} catch (err) {
|
|
const name = err instanceof Error ? err.name : "";
|
|
const message =
|
|
name === "TimeoutError" || name === "AbortError"
|
|
? `request timed out after ${timeoutMs}ms`
|
|
: err instanceof Error
|
|
? err.message
|
|
: String(err);
|
|
return fail(ctx, startedAt, message);
|
|
}
|
|
}
|
|
|
|
joplinHttp.meta = {
|
|
description:
|
|
"GET/POST a Joplin host with Bearer token using native fetch (LAN/WG IPs). Optional data.id is appended to the URL. Does not throw; ok=false on failure.",
|
|
previewConfigKey: "url",
|
|
tags: ["HTTP", "Joplin"],
|
|
config: {
|
|
url: {
|
|
type: "string",
|
|
required: true,
|
|
description: "Request URL. data.id is appended as a path segment when set.",
|
|
},
|
|
method: {
|
|
type: "string",
|
|
default: "POST",
|
|
enum: HTTP_METHODS,
|
|
description: "HTTP method",
|
|
},
|
|
token: {
|
|
type: "string",
|
|
required: true,
|
|
description: "Bearer token ({{ secrets.* }}); Bearer prefix is added if missing",
|
|
},
|
|
timeoutMs: {
|
|
type: "number",
|
|
required: false,
|
|
description: "Abort timeout in milliseconds (default 600000)",
|
|
},
|
|
},
|
|
input: {
|
|
id: {
|
|
type: "string",
|
|
required: false,
|
|
description: "32-char hex Joplin note id; appended to config.url",
|
|
},
|
|
method: {
|
|
type: "string",
|
|
required: false,
|
|
description: "Fallback when config.method is omitted",
|
|
},
|
|
},
|
|
output: {
|
|
ok: { type: "boolean" },
|
|
status: { type: "number", description: "HTTP status, or null on network error" },
|
|
httpResponse: { type: "any", description: "Parsed JSON or response text" },
|
|
durationMs: { type: "number" },
|
|
message: { type: "string" },
|
|
},
|
|
context: {
|
|
ok: { type: "boolean" },
|
|
status: { type: "number" },
|
|
httpResponse: { type: "any" },
|
|
durationMs: { type: "number" },
|
|
message: { type: "string" },
|
|
},
|
|
example: {
|
|
data: { id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" },
|
|
config: {
|
|
url: "http://10.8.0.6:3030/notes",
|
|
method: "GET",
|
|
token: "{{ secrets.joplin_api_token }}",
|
|
timeoutMs: 60000,
|
|
},
|
|
},
|
|
};
|
|
|
|
export default joplinHttp;
|