feat: add privacy retention controls

This commit is contained in:
2026-07-19 05:52:48 +08:00
parent c6079bbb47
commit 2af99a4b43
6 changed files with 30 additions and 2 deletions
+4
View File
@@ -10,6 +10,10 @@ UPLOAD_ALLOWED_TYPES=image/jpeg,image/png,image/gif,image/webp,application/pdf,t
VIRUS_SCAN_URL= VIRUS_SCAN_URL=
VIRUS_SCAN_REQUIRED=0 VIRUS_SCAN_REQUIRED=0
SYNC_INTERVAL_MINUTES=60 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. # Optional: create the first Memos source for the bootstrap admin.
SEED_MEMOS_NAME= SEED_MEMOS_NAME=
SEED_MEMOS_URL= SEED_MEMOS_URL=
+1
View File
@@ -24,6 +24,7 @@
### Added ### 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 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 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. - Added expiring, one-time source invitation links with viewer and editor roles; only owners and editors can publish to a shared Memos source.
+3 -1
View File
@@ -1,7 +1,9 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth"; import { getSession } from "@/lib/auth";
import { db } from "@/lib/db";
export default async function Account({ searchParams }: { searchParams: Promise<{ error?: string; updated?: string }> }) { export default async function Account({ searchParams }: { searchParams: Promise<{ error?: string; updated?: string }> }) {
const user = await getSession(); if (!user) redirect("/login"); const query = await searchParams; const user = await getSession(); if (!user) redirect("/login"); const query = await searchParams;
return <><h1></h1>{query.error && <p className="error">{query.error}</p>}{query.updated && <p></p>}<section className="card"><p className="meta">{user.username}</p><h2></h2><form action="/api/auth/password" method="post"><label><input name="currentPassword" type="password" autoComplete="current-password" required /></label><label><input name="newPassword" type="password" autoComplete="new-password" minLength={10} required /></label><label><input name="confirmPassword" type="password" autoComplete="new-password" minLength={10} required /></label><button></button></form></section></>; const ownedSources = db.prepare("SELECT count(*) AS count FROM sources WHERE user_id=?").get(user.id) as { count: number };
return <><h1></h1>{query.error && <p className="error">{query.error}</p>}{query.updated && <p></p>}<section className="card"><p className="meta">{user.username}</p><h2></h2><form action="/api/auth/password" method="post"><label><input name="currentPassword" type="password" autoComplete="current-password" required /></label><label><input name="newPassword" type="password" autoComplete="new-password" minLength={10} required /></label><label><input name="confirmPassword" type="password" autoComplete="new-password" minLength={10} required /></label><button></button></form></section><section className="card"><h2></h2>{ownedSources.count ? <p className="error"> {ownedSources.count} </p> : <form action="/api/auth/delete" method="post"><p className="meta"> Hub </p><label><input name="currentPassword" type="password" autoComplete="current-password" required /></label><label> DELETE <input name="confirmation" required /></label><button className="danger"></button></form>}</section></>;
} }
+8
View File
@@ -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"))); } }
+6
View File
@@ -52,3 +52,9 @@ Hub 原生附件預設只接受圖片、PDF、純文字與 Markdown。若要串
資料庫 schema 由 `lib/db.ts` 管理。每個欄位 migration 在 `schema_migrations` 表中記錄版本與套用時間,啟動 Web 或 Worker 時會自動執行尚未套用的安全 migration。 資料庫 schema 由 `lib/db.ts` 管理。每個欄位 migration 在 `schema_migrations` 表中記錄版本與套用時間,啟動 Web 或 Worker 時會自動執行尚未套用的安全 migration。
升級 Mebbling 前請先執行備份。若新版本在測試環境正常運作,再升級正式資料;不支援直接以舊程式碼讀取已升級 schema 的保證。 升級 Mebbling 前請先執行備份。若新版本在測試環境正常運作,再升級正式資料;不支援直接以舊程式碼讀取已升級 schema 的保證。
## 資料保存與刪除
`NOTIFICATION_RETENTION_DAYS``READING_HISTORY_RETENTION_DAYS``AUDIT_RETENTION_DAYS` 可設定保存天數;`0` 代表無限期保存。Worker 每日清理一次。到期或已使用的邀請連結會自動移除。
使用者可在「帳號設定」以目前密碼與 `DELETE` 文字確認刪除帳號及個人資料。若帳號仍擁有來源,必須先轉移或刪除來源,避免誤刪共享內容。
+8 -1
View File
@@ -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`); 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<CURRENT_TIMESTAMP AND (used_at IS NOT NULL OR expires_at<datetime('now','-30 days'))").run();
const cleanup = (table: string, column: string, value: number) => { if (value) db.prepare(`DELETE FROM ${table} WHERE ${column}<datetime('now', ?)`).run(`-${value} days`); };
cleanup("notifications", "created_at", days("NOTIFICATION_RETENTION_DAYS")); cleanup("reading_history", "last_read_at", days("READING_HISTORY_RETENTION_DAYS")); cleanup("audit_events", "created_at", days("AUDIT_RETENTION_DAYS"));
}
let lastRetention = 0; setInterval(() => { schedule(); if (Date.now() - lastRetention > 86_400_000) { retention(); lastRetention = Date.now(); } void run(); }, 5000); schedule(); retention(); void run();