From c6079bbb4780cfc17a4a4fba98b5c479808f2ee9 Mon Sep 17 00:00:00 2001 From: tangsongdayo Date: Sun, 19 Jul 2026 05:51:08 +0800 Subject: [PATCH] feat: audit source operations --- CHANGELOG.md | 1 + app/admin/page.tsx | 2 ++ app/api/admin/moderate/route.ts | 2 ++ app/api/invites/accept/route.ts | 3 ++- app/api/posts/route.ts | 4 ++-- app/api/sources/[id]/invites/route.ts | 3 ++- app/api/sources/[id]/manage/route.ts | 3 ++- lib/audit.ts | 4 ++++ lib/db.ts | 6 ++++++ tests/sync.test.ts | 2 +- 10 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 lib/audit.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bbd4e53..763b888 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ ### Added +- 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. - Added resumable, page-token-based Memos imports with configurable batch size and first-import limit; posts are only hidden after a complete scan. diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 6105cec..1c03108 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -10,12 +10,14 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis const reports = db.prepare("SELECT r.id,r.reason,r.created_at,p.id AS post_id,p.content,u.username FROM reports r JOIN posts p ON p.id=r.post_id LEFT JOIN users u ON u.id=r.reporter_id WHERE r.resolved=0 ORDER BY r.created_at LIMIT 100").all() as any[]; const users = db.prepare("SELECT id,username,role,disabled,created_at FROM users ORDER BY created_at DESC LIMIT 100").all() as any[]; const errors = db.prepare("SELECT scope,message,created_at FROM error_events ORDER BY id DESC LIMIT 30").all() as any[]; + const audits = db.prepare("SELECT a.action,a.target_type,a.target_id,a.metadata_json,a.created_at,u.username FROM audit_events a LEFT JOIN users u ON u.id=a.actor_user_id ORDER BY a.id DESC LIMIT 50").all() as any[]; const sources = db.prepare("SELECT s.id,s.name,s.base_url,s.sync_status,s.last_synced_at,s.is_enabled,count(sm.user_id) AS member_count FROM sources s LEFT JOIN source_members sm ON sm.source_id=s.id GROUP BY s.id ORDER BY s.id DESC").all() as any[]; return <>

管理員

{query.updated &&

管理操作已完成。

}{query.error &&

管理操作未完成。

}

待審核檢舉

{reports.length ? :

沒有待審核檢舉。

}

使用者

失敗或異常工作

{failures.length ? :

沒有同步異常。

}

最近系統錯誤

{errors.length ? :

尚無記錄。

}
+

稽核紀錄

{audits.length ? :

尚無稽核紀錄。

}

所有來源

; } diff --git a/app/api/admin/moderate/route.ts b/app/api/admin/moderate/route.ts index bd2837f..084a9a3 100644 --- a/app/api/admin/moderate/route.ts +++ b/app/api/admin/moderate/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { db } from "@/lib/db"; +import { audit } from "@/lib/audit"; import { externalUrl } from "@/lib/http"; import { requireSameOrigin } from "@/lib/security"; import bcrypt from "bcryptjs"; @@ -16,6 +17,7 @@ export async function POST(request: Request) { else if (action === "reset-password") { const password = String(form.get("password") || ""); if (password.length < 10) throw new Error("Invalid password"); db.prepare("UPDATE users SET password_hash=? WHERE id=?").run(await bcrypt.hash(password, 12), id); } else if (action === "resolve-report") db.prepare("UPDATE reports SET resolved=1 WHERE id=?").run(id); else throw new Error("Invalid action"); + audit(admin.id, `admin.${action}`, "admin-target", id); return NextResponse.redirect(externalUrl(request, "/admin?updated=1")); } catch { return NextResponse.redirect(externalUrl(request, "/admin?error=moderation")); } } diff --git a/app/api/invites/accept/route.ts b/app/api/invites/accept/route.ts index db0a91a..c7c0bc1 100644 --- a/app/api/invites/accept/route.ts +++ b/app/api/invites/accept/route.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { db } from "@/lib/db"; +import { audit } from "@/lib/audit"; import { externalUrl } from "@/lib/http"; import { requireSameOrigin } from "@/lib/security"; -export async function POST(request: Request) { try { requireSameOrigin(request); const user = await requireUser(); const form = await request.formData(); const token = String(form.get("token") || ""); const hash = createHash("sha256").update(token).digest("hex"); const invite = db.prepare("SELECT id,source_id,role FROM source_invites WHERE token_hash=? AND used_at IS NULL AND expires_at>CURRENT_TIMESTAMP").get(hash) as { id: number; source_id: number; role: string } | undefined; if (!invite) throw new Error("邀請不存在、已使用或已過期"); db.transaction(() => { db.prepare("INSERT INTO source_members(source_id,user_id,role) VALUES(?,?,?) ON CONFLICT(source_id,user_id) DO UPDATE SET role=excluded.role").run(invite.source_id, user.id, invite.role); db.prepare("UPDATE source_invites SET used_at=CURRENT_TIMESTAMP WHERE id=?").run(invite.id); })(); return NextResponse.redirect(externalUrl(request, "/dashboard?source=joined")); } catch (error) { return NextResponse.redirect(externalUrl(request, "/dashboard?error=" + encodeURIComponent(error instanceof Error ? error.message : "invite"))); } } +export async function POST(request: Request) { try { requireSameOrigin(request); const user = await requireUser(); const form = await request.formData(); const token = String(form.get("token") || ""); const hash = createHash("sha256").update(token).digest("hex"); const invite = db.prepare("SELECT id,source_id,role FROM source_invites WHERE token_hash=? AND used_at IS NULL AND expires_at>CURRENT_TIMESTAMP").get(hash) as { id: number; source_id: number; role: string } | undefined; if (!invite) throw new Error("邀請不存在、已使用或已過期"); db.transaction(() => { db.prepare("INSERT INTO source_members(source_id,user_id,role) VALUES(?,?,?) ON CONFLICT(source_id,user_id) DO UPDATE SET role=excluded.role").run(invite.source_id, user.id, invite.role); db.prepare("UPDATE source_invites SET used_at=CURRENT_TIMESTAMP WHERE id=?").run(invite.id); })(); audit(user.id, "source.invite.accept", "source", invite.source_id, { role: invite.role }); return NextResponse.redirect(externalUrl(request, "/dashboard?source=joined")); } catch (error) { return NextResponse.redirect(externalUrl(request, "/dashboard?error=" + encodeURIComponent(error instanceof Error ? error.message : "invite"))); } } diff --git a/app/api/posts/route.ts b/app/api/posts/route.ts index cc2ccf5..10ae8bc 100644 --- a/app/api/posts/route.ts +++ b/app/api/posts/route.ts @@ -1,2 +1,2 @@ -import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { db } from "@/lib/db"; import { externalUrl } from "@/lib/http"; import { mkdir, writeFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { extname, join } from "node:path"; import { requireSameOrigin } from "@/lib/security"; import { validateUpload } from "@/lib/uploads"; -export async function POST(req:Request){const json=req.headers.get("accept")?.includes("application/json");try{requireSameOrigin(req);const user=await requireUser();const f=await req.formData();const content=String(f.get("content")||"").trim();const visibility=String(f.get("visibility")||"PUBLIC");const sourceId=Number(f.get("sourceId"));const tags=String(f.get("tags")||"").split(/\s*,\s*/).filter(Boolean).map(t=>t.replace(/^#/,""));if(!content||!['PRIVATE','PROTECTED','PUBLIC'].includes(visibility)||!sourceId)throw new Error("Invalid post");const source=db.prepare("SELECT s.id FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE s.id=? AND sm.user_id=? AND s.is_enabled=1").get(sourceId,user.id);if(!source)throw new Error("Source not available");const files=f.getAll('attachments').filter((x):x is File=>x instanceof File&&x.size>0);if(files.length>10)throw new Error("最多可上傳 10 個附件");const attachments:any[]=[];await mkdir(join(process.cwd(),'public','uploads'),{recursive:true});for(const file of files){await validateUpload(file);const id=randomUUID()+extname(file.name);await writeFile(join(process.cwd(),'public','uploads',id),Buffer.from(await file.arrayBuffer()));attachments.push({name:file.name,url:`/uploads/${id}`,type:file.type,size:file.size});}const out=db.prepare("INSERT INTO posts(source_id,author_id,content,visibility,tags_json,attachments_json,origin,sync_status) VALUES(?,?,?,?,?,?,'hub','queued')").run(sourceId,user.id,content,visibility,JSON.stringify(tags),JSON.stringify(attachments));db.prepare("INSERT INTO sync_jobs(source_id,kind,payload_json,trigger) VALUES(?, 'push', ?, 'manual')").run(sourceId,JSON.stringify({postId:out.lastInsertRowid}));if(json)return NextResponse.json({id:Number(out.lastInsertRowid)},{status:201});return NextResponse.redirect(externalUrl(req,`/posts/${out.lastInsertRowid}`));}catch(e){const message=e instanceof Error?e.message:'post';if(json)return NextResponse.json({error:message},{status:400});return NextResponse.redirect(externalUrl(req,'/dashboard?error='+encodeURIComponent(message)));}} +import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { db } from "@/lib/db"; import { audit } from "@/lib/audit"; import { externalUrl } from "@/lib/http"; import { mkdir, writeFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { extname, join } from "node:path"; import { requireSameOrigin } from "@/lib/security"; import { validateUpload } from "@/lib/uploads"; +export async function POST(req:Request){const json=req.headers.get("accept")?.includes("application/json");try{requireSameOrigin(req);const user=await requireUser();const f=await req.formData();const content=String(f.get("content")||"").trim();const visibility=String(f.get("visibility")||"PUBLIC");const sourceId=Number(f.get("sourceId"));const tags=String(f.get("tags")||"").split(/\s*,\s*/).filter(Boolean).map(t=>t.replace(/^#/,""));if(!content||!['PRIVATE','PROTECTED','PUBLIC'].includes(visibility)||!sourceId)throw new Error("Invalid post");const source=db.prepare("SELECT s.id FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE s.id=? AND sm.user_id=? AND sm.role IN ('owner','editor') AND s.is_enabled=1").get(sourceId,user.id);if(!source)throw new Error("Source not available");const files=f.getAll('attachments').filter((x):x is File=>x instanceof File&&x.size>0);if(files.length>10)throw new Error("最多可上傳 10 個附件");const attachments:any[]=[];await mkdir(join(process.cwd(),'public','uploads'),{recursive:true});for(const file of files){await validateUpload(file);const id=randomUUID()+extname(file.name);await writeFile(join(process.cwd(),'public','uploads',id),Buffer.from(await file.arrayBuffer()));attachments.push({name:file.name,url:`/uploads/${id}`,type:file.type,size:file.size});}const out=db.prepare("INSERT INTO posts(source_id,author_id,content,visibility,tags_json,attachments_json,origin,sync_status) VALUES(?,?,?,?,?,?,'hub','queued')").run(sourceId,user.id,content,visibility,JSON.stringify(tags),JSON.stringify(attachments));audit(user.id,"post.create","post",Number(out.lastInsertRowid),{sourceId,visibility});db.prepare("INSERT INTO sync_jobs(source_id,kind,payload_json,trigger) VALUES(?, 'push', ?, 'manual')").run(sourceId,JSON.stringify({postId:out.lastInsertRowid}));if(json)return NextResponse.json({id:Number(out.lastInsertRowid)},{status:201});return NextResponse.redirect(externalUrl(req,`/posts/${out.lastInsertRowid}`));}catch(e){const message=e instanceof Error?e.message:'post';if(json)return NextResponse.json({error:message},{status:400});return NextResponse.redirect(externalUrl(req,'/dashboard?error='+encodeURIComponent(message)));}} diff --git a/app/api/sources/[id]/invites/route.ts b/app/api/sources/[id]/invites/route.ts index 22702e5..fe94c63 100644 --- a/app/api/sources/[id]/invites/route.ts +++ b/app/api/sources/[id]/invites/route.ts @@ -2,7 +2,8 @@ import { createHash, randomBytes } from "node:crypto"; import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { db } from "@/lib/db"; +import { audit } from "@/lib/audit"; 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 FROM sources WHERE id=? AND user_id=?").get(id, user.id); if (!source) return NextResponse.json({ error: "Not found" }, { status: 404 }); const { role } = await request.json() as { role?: string }; if (!['viewer','editor'].includes(role || '')) return NextResponse.json({ error: "Invalid role" }, { status: 400 }); const token = randomBytes(24).toString("base64url"); const hash = createHash("sha256").update(token).digest("hex"); db.prepare("INSERT INTO source_invites(source_id,token_hash,role,expires_at,created_by) VALUES(?,?,?,datetime('now','+7 days'),?)").run(id, hash, role, user.id); const origin = (process.env.NEXT_PUBLIC_APP_URL || new URL(request.url).origin).replace(/\/$/, ""); return NextResponse.json({ url: `${origin}/invite/${token}` }); } catch { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + try { requireSameOrigin(request); const user = await requireUser(); const { id: rawId } = await params; const id = Number(rawId); const source = db.prepare("SELECT id FROM sources WHERE id=? AND user_id=?").get(id, user.id); if (!source) return NextResponse.json({ error: "Not found" }, { status: 404 }); const { role } = await request.json() as { role?: string }; if (!['viewer','editor'].includes(role || '')) return NextResponse.json({ error: "Invalid role" }, { status: 400 }); const token = randomBytes(24).toString("base64url"); const hash = createHash("sha256").update(token).digest("hex"); db.prepare("INSERT INTO source_invites(source_id,token_hash,role,expires_at,created_by) VALUES(?,?,?,datetime('now','+7 days'),?)").run(id, hash, role, user.id); audit(user.id, "source.invite.create", "source", id, { role }); const origin = (process.env.NEXT_PUBLIC_APP_URL || new URL(request.url).origin).replace(/\/$/, ""); return NextResponse.json({ url: `${origin}/invite/${token}` }); } catch { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } } diff --git a/app/api/sources/[id]/manage/route.ts b/app/api/sources/[id]/manage/route.ts index 446f690..0e3eb18 100644 --- a/app/api/sources/[id]/manage/route.ts +++ b/app/api/sources/[id]/manage/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { decrypt } from "@/lib/crypto"; import { db } from "@/lib/db"; +import { audit } from "@/lib/audit"; import { externalUrl } from "@/lib/http"; import { getMemosIdentity, verifyMemos } from "@/lib/memos"; import { queuePull } from "@/lib/sync"; @@ -51,6 +52,6 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str const remove = db.transaction(() => { db.prepare("DELETE FROM posts WHERE source_id=? AND origin='memos'").run(id); db.prepare("UPDATE posts SET source_id=NULL WHERE source_id=? AND origin='hub'").run(id); db.prepare("DELETE FROM sources WHERE id=?").run(id); }); remove(); } else throw new Error("Unknown source action"); - return NextResponse.redirect(externalUrl(req, "/dashboard?source=updated")); + audit(user.id, `source.${action}`, "source", id); return NextResponse.redirect(externalUrl(req, "/dashboard?source=updated")); } catch (error) { return NextResponse.redirect(externalUrl(req, "/dashboard?error=" + encodeURIComponent(error instanceof Error ? error.message : "source"))); } } diff --git a/lib/audit.ts b/lib/audit.ts new file mode 100644 index 0000000..4a046a0 --- /dev/null +++ b/lib/audit.ts @@ -0,0 +1,4 @@ +import { db } from "@/lib/db"; +export function audit(actorUserId: number | null, action: string, targetType: string, targetId?: string | number, metadata: unknown = {}) { + db.prepare("INSERT INTO audit_events(actor_user_id,action,target_type,target_id,metadata_json) VALUES(?,?,?,?,?)").run(actorUserId, action, targetType, targetId == null ? null : String(targetId), JSON.stringify(metadata)); +} diff --git a/lib/db.ts b/lib/db.ts index 0b31a30..7a97f77 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -94,6 +94,11 @@ CREATE TABLE IF NOT EXISTS source_invites ( used_at TEXT, created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS audit_events ( + id INTEGER PRIMARY KEY, actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, target_type TEXT NOT NULL, target_id TEXT, metadata_json TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); CREATE TABLE IF NOT EXISTS tag_aliases ( alias TEXT PRIMARY KEY COLLATE NOCASE, canonical TEXT NOT NULL COLLATE NOCASE, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP @@ -145,6 +150,7 @@ applyColumnMigration(34, "posts", "last_seen_sync_run", "ALTER TABLE posts ADD C db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(35)").run(); db.prepare("UPDATE source_members SET role='editor' WHERE role='member'").run(); db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(36)").run(); +db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(37)").run(); const admin = process.env.ADMIN_USERNAME; const adminPassword = process.env.ADMIN_PASSWORD; diff --git a/tests/sync.test.ts b/tests/sync.test.ts index 144ff0a..4fd2b58 100644 --- a/tests/sync.test.ts +++ b/tests/sync.test.ts @@ -16,7 +16,7 @@ test("applies tracked migrations and deduplicates active pull jobs", async () => const { queuePull } = await import("../lib/sync"); const { notify } = await import("../lib/notifications"); const migrations = db.prepare("SELECT version FROM schema_migrations ORDER BY version").all() as { version: number }[]; - assert.deepEqual(migrations.map((item) => item.version), Array.from({ length: 36 }, (_, index) => index + 1)); + assert.deepEqual(migrations.map((item) => item.version), Array.from({ length: 37 }, (_, index) => index + 1)); const userId = Number(db.prepare("INSERT INTO users(username,password_hash) VALUES('sync-test','hash')").run().lastInsertRowid); const sourceId = Number(db.prepare("INSERT INTO sources(user_id,name,base_url,token_encrypted,is_enabled) VALUES(?,?,?,?,1)").run(userId, "Test", "https://example.test", "encrypted").lastInsertRowid); assert.equal(queuePull(sourceId, "manual"), true);