diff --git a/CHANGELOG.md b/CHANGELOG.md index 83920e6..57d5d0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ ### Added +- Added safe signed Memos webhook rotation: create the replacement first, then remove the previous remote webhook before committing the new credentials. - Added configurable retention cleanup and a password-confirmed self-service account deletion flow. - Added append-only audit records for source management, invitations, posting, and administrator moderation. - Added verified SQLite/upload backups with retention, optional offsite copy, restore script, and installable daily WSL cron schedule. diff --git a/app/api/sources/[id]/webhook/route.ts b/app/api/sources/[id]/webhook/route.ts index 7236bfb..3a55121 100644 --- a/app/api/sources/[id]/webhook/route.ts +++ b/app/api/sources/[id]/webhook/route.ts @@ -1,15 +1,24 @@ import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { db } from "@/lib/db"; +import { decrypt, encrypt } from "@/lib/crypto"; +import { createUserWebhook, deleteUserWebhook, getMemosIdentity } from "@/lib/memos"; import { createWebhookSecret, webhookSecretHash } from "@/lib/webhook"; import { requireSameOrigin } from "@/lib/security"; export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { try { requireSameOrigin(request); const user = await requireUser(); const { id: rawId } = await params; const id = Number(rawId); - const source = db.prepare("SELECT id,webhook_mode FROM sources WHERE id=? AND user_id=?").get(id, user.id) as { id: number; webhook_mode: string } | undefined; + const source = db.prepare("SELECT id,base_url,token_encrypted,remote_user,webhook_mode,webhook_remote_name FROM sources WHERE id=? AND user_id=?").get(id, user.id) as { id: number; base_url: string; token_encrypted: string; remote_user: string | null; webhook_mode: string; webhook_remote_name: string | null } | undefined; if (!source) return NextResponse.json({ error: "Not found" }, { status: 404 }); - if (source.webhook_mode === "signed") return NextResponse.json({ error: "這個 Webhook 已由 Memos 自動管理;請重新連接來源以重新建立。" }, { status: 409 }); + if (source.webhook_mode === "signed") { + const token = decrypt(source.token_encrypted); const identity = source.remote_user ? { name: source.remote_user } : await getMemosIdentity(source.base_url, token); const pathSecret = createWebhookSecret(); const signingSecret = createWebhookSecret(); const publicOrigin = (process.env.NEXT_PUBLIC_APP_URL || new URL(request.url).origin).replace(/\/$/, ""); + const remote = await createUserWebhook(source.base_url, token, identity.name, { url: `${publicOrigin}/api/sync/webhook/${id}/${pathSecret}`, displayName: "Mebbling", signingSecret }); + try { if (source.webhook_remote_name) await deleteUserWebhook(source.base_url, token, identity.name, source.webhook_remote_name); } + catch (error) { try { await deleteUserWebhook(source.base_url, token, identity.name, remote.name); } catch {} throw error; } + db.prepare("UPDATE sources SET webhook_secret_hash=?,webhook_remote_name=?,webhook_signing_secret_encrypted=? WHERE id=?").run(webhookSecretHash(pathSecret), remote.name, encrypt(signingSecret), id); + return NextResponse.json({ rotated: true }); + } const secret = createWebhookSecret(); db.prepare("UPDATE sources SET webhook_secret_hash=? WHERE id=?").run(webhookSecretHash(secret), id); const publicOrigin = (process.env.NEXT_PUBLIC_APP_URL || new URL(request.url).origin).replace(/\/$/, ""); diff --git a/app/dashboard/webhook-control.tsx b/app/dashboard/webhook-control.tsx index 9c544a1..daa2237 100644 --- a/app/dashboard/webhook-control.tsx +++ b/app/dashboard/webhook-control.tsx @@ -3,17 +3,17 @@ import { useState } from "react"; export function WebhookControl({ sourceId, configured, automatic = false }: { sourceId: number; configured: boolean; automatic?: boolean }) { - const [url, setUrl] = useState(""); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); + const [url, setUrl] = useState(""); const [error, setError] = useState(""); const [notice, setNotice] = useState(""); const [busy, setBusy] = useState(false); async function generate() { - setBusy(true); setError(""); + setBusy(true); setError(""); setNotice(""); try { const response = await fetch(`/api/sources/${sourceId}/webhook`, { method: "POST", headers: { Accept: "application/json" } }); - const body = await response.json(); if (!response.ok) throw new Error(body.error || "無法產生 webhook URL"); setUrl(body.url); + const body = await response.json(); if (!response.ok) throw new Error(body.error || "無法產生 webhook URL"); if (body.rotated) setNotice("Webhook 已安全輪替。"); else setUrl(body.url); } catch (reason) { setError(reason instanceof Error ? reason.message : "無法產生 webhook URL"); } finally { setBusy(false); } } async function copy() { if (url) await navigator.clipboard.writeText(url); } - if (automatic) return
Webhook:已由 Memos 自動設定並驗證簽章。為避免讓遠端設定失效,此處不提供手動重產網址。
Webhook:已由 Memos 自動設定並驗證簽章。
{notice &&{notice}
}{error &&{error}
}Webhook:{configured ? "已設定" : "尚未設定"}
{url ? <> event.currentTarget.select()} />請立即複製到 Memos;重新整理後完整密鑰不會再顯示。
> : } {error &&{error}
} diff --git a/lib/memos.ts b/lib/memos.ts index 343c32f..73f7230 100644 --- a/lib/memos.ts +++ b/lib/memos.ts @@ -10,6 +10,10 @@ export async function createUserWebhook(baseUrl: string, token: string, userName 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 deleteUserWebhook(baseUrl: string, token: string, userName: string, webhookName: string) { + const user = userName.split("/").at(-1), webhook = webhookName.split("/").at(-1); if (!user || !webhook) throw new Error("Invalid Memos webhook"); + await request(`${base(baseUrl)}/users/${encodeURIComponent(user)}/webhooks/${encodeURIComponent(webhook)}`, token, { method: "DELETE" }); +} export async function verifyMemos(baseUrl: string, token: string) { await request(`${base(baseUrl)}/memos?pageSize=1`, token); } export async function getMemosIdentity(baseUrl: string, token: string) { let user: MemosIdentity;