diff --git a/CHANGELOG.md b/CHANGELOG.md index 225327c..f844cca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - 來源名稱改由 API Key 對應的 Memos 帳號自動產生與更新,控制台不再接受手動命名。 - 同步會優先使用新版 Memos 的伺服器端 filter;不支援該 API 的舊版 Memos 會安全退回本機篩選。 +- 新版 Memos 來源建立時會自動建立帶 HMAC 簽名的 webhook;不支援 User Webhook API 的舊版來源維持手動模式。 ## [0.6.0] - Unreleased diff --git a/app/api/sources/route.ts b/app/api/sources/route.ts index b7438ea..38981e7 100644 --- a/app/api/sources/route.ts +++ b/app/api/sources/route.ts @@ -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"))); } diff --git a/app/api/sync/webhook/[sourceId]/[secret]/route.ts b/app/api/sync/webhook/[sourceId]/[secret]/route.ts index acecd3e..8a0443b 100644 --- a/app/api/sync/webhook/[sourceId]/[secret]/route.ts +++ b/app/api/sync/webhook/[sourceId]/[secret]/route.ts @@ -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 }); diff --git a/lib/db.ts b/lib/db.ts index 854f23c..1e58220 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -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; diff --git a/lib/memos.ts b/lib/memos.ts index cd7850a..05f52b9 100644 --- a/lib/memos.ts +++ b/lib/memos.ts @@ -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; diff --git a/lib/webhook.ts b/lib/webhook.ts index 49a69f8..eb7fd30 100644 --- a/lib/webhook.ts +++ b/lib/webhook.ts @@ -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); }); +} diff --git a/tests/sync.test.ts b/tests/sync.test.ts index 87fb738..69cf32f 100644 --- a/tests/sync.test.ts +++ b/tests/sync.test.ts @@ -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);