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 ? {reports.map((report) => - 貼文 #{report.post_id} · 檢舉者 @{report.username || "已刪除使用者"}
{report.reason}
{report.content.slice(0, 180)} · {new Date(report.created_at + "Z").toLocaleString("zh-TW")} )}
: 沒有待審核檢舉。
}
失敗或異常工作
{failures.length ? {failures.map((item) => - {item.name}(來源 #{item.source_id}) · {item.kind || "source"} · {item.status || "error"}
{item.last_error || "來源處於錯誤狀態"} )}
: 沒有同步異常。
}
最近系統錯誤
{errors.length ? {errors.map((error, index) => - {error.scope} · {error.message}
{new Date(error.created_at + "Z").toLocaleString("zh-TW")} )}
: 尚無記錄。
}
+ 稽核紀錄
{audits.length ? {audits.map((item, index) => - {item.action} · @{item.username || "system"} · {item.target_type} #{item.target_id || "—"}
{new Date(item.created_at + "Z").toLocaleString("zh-TW")} )}
: 尚無稽核紀錄。
}
所有來源
{sources.map((source) => - {source.name} · {source.is_enabled ? source.sync_status : "disabled"} · 成員 {source.member_count}
#{source.id} · {source.base_url} · 上次同步:{source.last_synced_at || "尚未完成"} )}
>;
}
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);