feat: configure signed Memos webhooks when supported
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
|
||||
- 來源名稱改由 API Key 對應的 Memos 帳號自動產生與更新,控制台不再接受手動命名。
|
||||
- 同步會優先使用新版 Memos 的伺服器端 filter;不支援該 API 的舊版 Memos 會安全退回本機篩選。
|
||||
- 新版 Memos 來源建立時會自動建立帶 HMAC 簽名的 webhook;不支援 User Webhook API 的舊版來源維持手動模式。
|
||||
|
||||
## [0.6.0] - Unreleased
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import { requireUser } from "@/lib/auth";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { db } from "@/lib/db";
|
||||
import { externalUrl } from "@/lib/http";
|
||||
import { getMemosIdentity, verifyMemos } from "@/lib/memos";
|
||||
import { createUserWebhook, getMemosIdentity, verifyMemos } from "@/lib/memos";
|
||||
import { createWebhookSecret, webhookSecretHash } from "@/lib/webhook";
|
||||
import { queuePull } from "@/lib/sync";
|
||||
import { requireSameOrigin } from "@/lib/security";
|
||||
|
||||
@@ -25,6 +26,9 @@ export async function POST(req: Request) {
|
||||
const out = db.prepare("INSERT INTO sources(user_id,name,base_url,token_encrypted,remote_user,sync_status) VALUES(?,?,?,?,?, 'queued')").run(user.id, name, baseUrl, encrypt(token), identity.name);
|
||||
const sourceId = Number(out.lastInsertRowid);
|
||||
db.prepare("INSERT INTO source_members(source_id,user_id,role) VALUES(?,?,'owner')").run(sourceId, user.id);
|
||||
const pathSecret = createWebhookSecret(); const signingSecret = createWebhookSecret(); const publicOrigin = (process.env.NEXT_PUBLIC_APP_URL || new URL(req.url).origin).replace(/\/$/, "");
|
||||
try { const remote = await createUserWebhook(baseUrl, token, identity.name, { url: `${publicOrigin}/api/sync/webhook/${sourceId}/${pathSecret}`, displayName: "Mebbling", signingSecret }); db.prepare("UPDATE sources SET webhook_secret_hash=?,webhook_mode='signed',webhook_remote_name=?,webhook_signing_secret_encrypted=? WHERE id=?").run(webhookSecretHash(pathSecret), remote.name, encrypt(signingSecret), sourceId); }
|
||||
catch (webhookError) { const message = webhookError instanceof Error ? webhookError.message : "Webhook unsupported"; db.prepare("UPDATE sources SET webhook_mode=? WHERE id=?").run(message.includes("Memos API 404") ? "manual" : "unavailable", sourceId); }
|
||||
queuePull(sourceId, "source-created");
|
||||
return NextResponse.redirect(externalUrl(req, "/dashboard?source=connected"));
|
||||
} catch (error) { return NextResponse.redirect(externalUrl(req, "/dashboard?error=" + encodeURIComponent(error instanceof Error ? error.message : "source"))); }
|
||||
|
||||
@@ -4,16 +4,19 @@ import { withinRateLimit } from "@/lib/rate-limit";
|
||||
import { clientIp } from "@/lib/security";
|
||||
import { logEvent } from "@/lib/observability";
|
||||
import { webhookSecretMatches } from "@/lib/webhook";
|
||||
import { standardWebhookMatches } from "@/lib/webhook";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { queuePull } from "@/lib/sync";
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ sourceId: string; secret: string }> }) {
|
||||
const { sourceId, secret } = await params;
|
||||
const id = Number(sourceId);
|
||||
const source = db.prepare("SELECT id, webhook_secret_hash FROM sources WHERE id=? AND is_enabled=1").get(id) as { id: number; webhook_secret_hash: string | null } | undefined;
|
||||
const source = db.prepare("SELECT id, webhook_secret_hash,webhook_mode,webhook_signing_secret_encrypted FROM sources WHERE id=? AND is_enabled=1").get(id) as { id: number; webhook_secret_hash: string | null; webhook_mode: string; webhook_signing_secret_encrypted: string | null } | undefined;
|
||||
if (!source || !webhookSecretMatches(secret, source.webhook_secret_hash)) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
if (!withinRateLimit(`webhook:${id}:${clientIp(request)}`, 30, 60_000)) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
let payload: unknown = {};
|
||||
try { payload = await request.json(); } catch { /* Memos payload is optional; a pull reconciles source state. */ }
|
||||
const raw = await request.text();
|
||||
if (source.webhook_mode === "signed") { const signingSecret = source.webhook_signing_secret_encrypted ? decrypt(source.webhook_signing_secret_encrypted) : ""; if (!standardWebhookMatches(signingSecret, request.headers.get("webhook-id"), request.headers.get("webhook-timestamp"), request.headers.get("webhook-signature"), raw)) return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); }
|
||||
let payload: unknown = {}; try { payload = raw ? JSON.parse(raw) : {}; } catch { /* Memos payload is optional; a pull reconciles source state. */ }
|
||||
db.prepare("UPDATE sources SET last_webhook_at=CURRENT_TIMESTAMP WHERE id=?").run(id);
|
||||
const queued = queuePull(id, "webhook", payload);
|
||||
logEvent("info", "webhook_received", { sourceId: id, queued });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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); });
|
||||
}
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ test("applies tracked migrations and deduplicates active pull jobs", async () =>
|
||||
const { queuePull } = await import("../lib/sync");
|
||||
const { notify } = await import("../lib/notifications");
|
||||
const migrations = db.prepare("SELECT version FROM schema_migrations ORDER BY version").all() as { version: number }[];
|
||||
assert.deepEqual(migrations.map((item) => item.version), Array.from({ length: 23 }, (_, index) => index + 1));
|
||||
assert.deepEqual(migrations.map((item) => item.version), Array.from({ length: 26 }, (_, index) => index + 1));
|
||||
const userId = Number(db.prepare("INSERT INTO users(username,password_hash) VALUES('sync-test','hash')").run().lastInsertRowid);
|
||||
const sourceId = Number(db.prepare("INSERT INTO sources(user_id,name,base_url,token_encrypted,is_enabled) VALUES(?,?,?,?,1)").run(userId, "Test", "https://example.test", "encrypted").lastInsertRowid);
|
||||
assert.equal(queuePull(sourceId, "manual"), true);
|
||||
|
||||
Reference in New Issue
Block a user