feat: configure signed Memos webhooks when supported

This commit is contained in:
2026-07-19 05:14:01 +08:00
parent 3d6d1c03f3
commit 97d4c15407
7 changed files with 26 additions and 6 deletions
+3
View File
@@ -125,6 +125,9 @@ applyColumnMigration(20, "sources", "attachment_storage_mode", "ALTER TABLE sour
applyColumnMigration(21, "sources", "attachment_cache_limit_bytes", "ALTER TABLE sources ADD COLUMN attachment_cache_limit_bytes INTEGER NOT NULL DEFAULT 104857600");
applyColumnMigration(22, "sources", "attachment_cache_error", "ALTER TABLE sources ADD COLUMN attachment_cache_error TEXT");
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(23)").run();
applyColumnMigration(24, "sources", "webhook_mode", "ALTER TABLE sources ADD COLUMN webhook_mode TEXT NOT NULL DEFAULT 'manual'");
applyColumnMigration(25, "sources", "webhook_remote_name", "ALTER TABLE sources ADD COLUMN webhook_remote_name TEXT");
applyColumnMigration(26, "sources", "webhook_signing_secret_encrypted", "ALTER TABLE sources ADD COLUMN webhook_signing_secret_encrypted TEXT");
const admin = process.env.ADMIN_USERNAME;
const adminPassword = process.env.ADMIN_PASSWORD;
+4
View File
@@ -6,6 +6,10 @@ async function request(url: string, token: string, init?: RequestInit) {
const res = await fetch(url, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers || {}) }, cache: "no-store" });
if (!res.ok) throw new Error(`Memos API ${res.status}: ${await res.text()}`); return res;
}
export async function createUserWebhook(baseUrl: string, token: string, userName: string, webhook: { url: string; displayName: string; signingSecret: string }) {
const user = userName.split("/").at(-1); if (!user) throw new Error("Invalid Memos user");
return (await request(`${base(baseUrl)}/users/${encodeURIComponent(user)}/webhooks`, token, { method: "POST", body: JSON.stringify(webhook) })).json() as Promise<{ name: string }>;
}
export async function verifyMemos(baseUrl: string, token: string) { await request(`${base(baseUrl)}/memos?pageSize=1`, token); }
export async function getMemosIdentity(baseUrl: string, token: string) {
const user = await (await request(`${base(baseUrl)}/auth/status`, token, { method: "POST", body: "{}" })).json() as MemosIdentity;
+6 -1
View File
@@ -1,4 +1,4 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
export function createWebhookSecret() { return randomBytes(32).toString("base64url"); }
export function webhookSecretHash(secret: string) { return createHash("sha256").update(secret).digest("hex"); }
@@ -8,3 +8,8 @@ export function webhookSecretMatches(secret: string, expectedHash: string | null
const expected = Buffer.from(expectedHash, "hex");
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
export function standardWebhookMatches(secret: string, id: string | null, timestamp: string | null, signature: string | null, body: string) {
if (!id || !timestamp || !signature || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${id}.${timestamp}.${body}`).digest("base64");
return signature.split(" ").some((item) => { const value = item.split(",")[1]; if (!value) return false; const actual = Buffer.from(value); const target = Buffer.from(expected); return actual.length === target.length && timingSafeEqual(actual, target); });
}