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 && 密碼已更新。
}>;
+ 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 && 密碼已更新。
}刪除帳號與個人資料
{ownedSources.count ? 你仍是 {ownedSources.count} 個來源的建立者。請先轉移建立者或刪除來源,才能刪除帳號。
: }>;
}
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();