From 2af99a4b43d884c1b0a22af058e3b5bca1a43ec5 Mon Sep 17 00:00:00 2001 From: tangsongdayo Date: Sun, 19 Jul 2026 05:52:48 +0800 Subject: [PATCH] feat: add privacy retention controls --- .env.example | 4 ++++ CHANGELOG.md | 1 + app/account/page.tsx | 4 +++- app/api/auth/delete/route.ts | 8 ++++++++ docs/OPERATIONS.md | 6 ++++++ worker/index.ts | 9 ++++++++- 6 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 app/api/auth/delete/route.ts diff --git a/.env.example b/.env.example index d22af39..1976ac4 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,10 @@ UPLOAD_ALLOWED_TYPES=image/jpeg,image/png,image/gif,image/webp,application/pdf,t VIRUS_SCAN_URL= VIRUS_SCAN_REQUIRED=0 SYNC_INTERVAL_MINUTES=60 +# Optional retention windows in days; 0 keeps data indefinitely. Expired unused invites are removed after 30 days. +NOTIFICATION_RETENTION_DAYS=0 +READING_HISTORY_RETENTION_DAYS=0 +AUDIT_RETENTION_DAYS=365 # Optional: create the first Memos source for the bootstrap admin. SEED_MEMOS_NAME= SEED_MEMOS_URL= diff --git a/CHANGELOG.md b/CHANGELOG.md index 763b888..83920e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ ### Added +- 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. - Added expiring, one-time source invitation links with viewer and editor roles; only owners and editors can publish to a shared Memos source. diff --git a/app/account/page.tsx b/app/account/page.tsx index 3ed5e89..a1599b8 100644 --- a/app/account/page.tsx +++ b/app/account/page.tsx @@ -1,7 +1,9 @@ import { redirect } from "next/navigation"; import { getSession } from "@/lib/auth"; +import { db } from "@/lib/db"; export default async function Account({ searchParams }: { searchParams: Promise<{ error?: string; updated?: string }> }) { const user = await getSession(); if (!user) redirect("/login"); const query = await searchParams; - return <>

帳號設定

{query.error &&

{query.error}

}{query.updated &&

密碼已更新。

}

帳號:{user.username}

變更密碼

; + const ownedSources = db.prepare("SELECT count(*) AS count FROM sources WHERE user_id=?").get(user.id) as { count: number }; + return <>

帳號設定

{query.error &&

{query.error}

}{query.updated &&

密碼已更新。

}

帳號:{user.username}

變更密碼

刪除帳號與個人資料

{ownedSources.count ?

你仍是 {ownedSources.count} 個來源的建立者。請先轉移建立者或刪除來源,才能刪除帳號。

:

此操作會移除你的 Hub 帳號、你建立的貼文與個人資料,且無法復原。

}
; } diff --git a/app/api/auth/delete/route.ts b/app/api/auth/delete/route.ts new file mode 100644 index 0000000..3d7b039 --- /dev/null +++ b/app/api/auth/delete/route.ts @@ -0,0 +1,8 @@ +import bcrypt from "bcryptjs"; +import { NextResponse } from "next/server"; +import { clearSession, requireUser } from "@/lib/auth"; +import { audit } from "@/lib/audit"; +import { db } from "@/lib/db"; +import { externalUrl } from "@/lib/http"; +import { requireSameOrigin } from "@/lib/security"; +export async function POST(request: Request) { try { requireSameOrigin(request); const user = await requireUser(); if (user.role === "admin") throw new Error("管理員帳號不可自行刪除"); const form = await request.formData(); if (String(form.get("confirmation")) !== "DELETE") throw new Error("請輸入 DELETE 確認"); const account = db.prepare("SELECT password_hash FROM users WHERE id=?").get(user.id) as { password_hash: string } | undefined; if (!account || !(await bcrypt.compare(String(form.get("currentPassword") || ""), account.password_hash))) throw new Error("目前密碼不正確"); const owned = db.prepare("SELECT count(*) AS count FROM sources WHERE user_id=?").get(user.id) as { count: number }; if (owned.count) throw new Error("請先處理你建立的來源"); audit(user.id, "account.delete", "user", user.id); db.prepare("DELETE FROM users WHERE id=?").run(user.id); await clearSession(); return NextResponse.redirect(externalUrl(request, "/?account=deleted")); } catch (error) { return NextResponse.redirect(externalUrl(request, "/account?error=" + encodeURIComponent(error instanceof Error ? error.message : "delete"))); } } diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index fc42071..282e82b 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -52,3 +52,9 @@ Hub 原生附件預設只接受圖片、PDF、純文字與 Markdown。若要串 資料庫 schema 由 `lib/db.ts` 管理。每個欄位 migration 在 `schema_migrations` 表中記錄版本與套用時間,啟動 Web 或 Worker 時會自動執行尚未套用的安全 migration。 升級 Mebbling 前請先執行備份。若新版本在測試環境正常運作,再升級正式資料;不支援直接以舊程式碼讀取已升級 schema 的保證。 + +## 資料保存與刪除 + +`NOTIFICATION_RETENTION_DAYS`、`READING_HISTORY_RETENTION_DAYS` 與 `AUDIT_RETENTION_DAYS` 可設定保存天數;`0` 代表無限期保存。Worker 每日清理一次。到期或已使用的邀請連結會自動移除。 + +使用者可在「帳號設定」以目前密碼與 `DELETE` 文字確認刪除帳號及個人資料。若帳號仍擁有來源,必須先轉移或刪除來源,避免誤刪共享內容。 diff --git a/worker/index.ts b/worker/index.ts index a4a5735..c3cd8c8 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -104,4 +104,11 @@ function schedule() { db.prepare(`INSERT INTO sync_jobs(source_id,kind,trigger) SELECT id,'pull','scheduled' FROM sources WHERE is_enabled=1 AND COALESCE(last_synced_at,'1970-01-01') < datetime('now', ?) AND NOT EXISTS (SELECT 1 FROM sync_jobs j WHERE j.source_id=sources.id AND j.kind='pull' AND j.status IN ('queued','running'))`).run(`-${interval} minutes`); } -setInterval(() => { schedule(); void run(); }, 5000); schedule(); void run(); +function retention() { + const days = (name: string) => Math.max(0, Number(process.env[name] || 0) || 0); + db.prepare("DELETE FROM source_invites WHERE expires_at { if (value) db.prepare(`DELETE FROM ${table} WHERE ${column} { schedule(); if (Date.now() - lastRetention > 86_400_000) { retention(); lastRetention = Date.now(); } void run(); }, 5000); schedule(); retention(); void run();