feat: add privacy retention controls
This commit is contained in:
@@ -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=
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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></>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))); } }
|
||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user