feat: audit source operations

This commit is contained in:
2026-07-19 05:51:08 +08:00
parent 808deaba97
commit c6079bbb47
10 changed files with 24 additions and 6 deletions
+1
View File
@@ -24,6 +24,7 @@
### Added ### 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 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.
- Added resumable, page-token-based Memos imports with configurable batch size and first-import limit; posts are only hidden after a complete scan. - Added resumable, page-token-based Memos imports with configurable batch size and first-import limit; posts are only hidden after a complete scan.
+2
View File
@@ -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 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 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 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[]; 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 <><h1></h1>{query.updated && <p></p>}{query.error && <p className="error"></p>} return <><h1></h1>{query.updated && <p></p>}{query.error && <p className="error"></p>}
<section className="card"><h2></h2>{reports.length ? <ul className="job-list">{reports.map((report) => <li key={report.id}><strong> #{report.post_id}</strong> · @{report.username || "已刪除使用者"}<br />{report.reason}<br /><span className="meta">{report.content.slice(0, 180)} · {new Date(report.created_at + "Z").toLocaleString("zh-TW")}</span><div className="row"><form action="/api/admin/moderate" method="post"><input type="hidden" name="action" value="hide-post" /><input type="hidden" name="id" value={report.post_id} /><button className="danger"></button></form><form action="/api/admin/moderate" method="post"><input type="hidden" name="action" value="resolve-report" /><input type="hidden" name="id" value={report.id} /><button></button></form></div></li>)}</ul> : <p className="muted"></p>}</section> <section className="card"><h2></h2>{reports.length ? <ul className="job-list">{reports.map((report) => <li key={report.id}><strong> #{report.post_id}</strong> · @{report.username || "已刪除使用者"}<br />{report.reason}<br /><span className="meta">{report.content.slice(0, 180)} · {new Date(report.created_at + "Z").toLocaleString("zh-TW")}</span><div className="row"><form action="/api/admin/moderate" method="post"><input type="hidden" name="action" value="hide-post" /><input type="hidden" name="id" value={report.post_id} /><button className="danger"></button></form><form action="/api/admin/moderate" method="post"><input type="hidden" name="action" value="resolve-report" /><input type="hidden" name="id" value={report.id} /><button></button></form></div></li>)}</ul> : <p className="muted"></p>}</section>
<section className="card"><h2>使</h2><ul className="job-list">{users.map((account) => <li key={account.id}><strong>@{account.username}</strong> · <span className="tag">{account.role}</span> · {account.disabled ? "已停權" : "正常"}<div className="row">{account.id !== user.id && <form action="/api/admin/moderate" method="post"><input type="hidden" name="action" value={account.disabled ? "enable-user" : "disable-user"} /><input type="hidden" name="id" value={account.id} /><button className={account.disabled ? "" : "danger"}>{account.disabled ? "解除停權" : "停權"}</button></form>}<form action="/api/admin/moderate" method="post"><input type="hidden" name="action" value="reset-password" /><input type="hidden" name="id" value={account.id} /><input name="password" type="password" minLength={10} required placeholder="管理員重設密碼" /><button></button></form></div></li>)}</ul></section> <section className="card"><h2>使</h2><ul className="job-list">{users.map((account) => <li key={account.id}><strong>@{account.username}</strong> · <span className="tag">{account.role}</span> · {account.disabled ? "已停權" : "正常"}<div className="row">{account.id !== user.id && <form action="/api/admin/moderate" method="post"><input type="hidden" name="action" value={account.disabled ? "enable-user" : "disable-user"} /><input type="hidden" name="id" value={account.id} /><button className={account.disabled ? "" : "danger"}>{account.disabled ? "解除停權" : "停權"}</button></form>}<form action="/api/admin/moderate" method="post"><input type="hidden" name="action" value="reset-password" /><input type="hidden" name="id" value={account.id} /><input name="password" type="password" minLength={10} required placeholder="管理員重設密碼" /><button></button></form></div></li>)}</ul></section>
<section className="card"><h2></h2>{failures.length ? <ul className="job-list">{failures.map((item) => <li key={`${item.id}-${item.source_id}`}><strong>{item.name}</strong> #{item.source_id} · {item.kind || "source"} · <span className="tag">{item.status || "error"}</span><br /><span className="error">{item.last_error || "來源處於錯誤狀態"}</span></li>)}</ul> : <p className="muted"></p>}</section> <section className="card"><h2></h2>{failures.length ? <ul className="job-list">{failures.map((item) => <li key={`${item.id}-${item.source_id}`}><strong>{item.name}</strong> #{item.source_id} · {item.kind || "source"} · <span className="tag">{item.status || "error"}</span><br /><span className="error">{item.last_error || "來源處於錯誤狀態"}</span></li>)}</ul> : <p className="muted"></p>}</section>
<section className="card"><h2></h2>{errors.length ? <ul className="job-list">{errors.map((error, index) => <li key={index}><strong>{error.scope}</strong> · <span className="error">{error.message}</span><br /><span className="meta">{new Date(error.created_at + "Z").toLocaleString("zh-TW")}</span></li>)}</ul> : <p className="muted"></p>}</section> <section className="card"><h2></h2>{errors.length ? <ul className="job-list">{errors.map((error, index) => <li key={index}><strong>{error.scope}</strong> · <span className="error">{error.message}</span><br /><span className="meta">{new Date(error.created_at + "Z").toLocaleString("zh-TW")}</span></li>)}</ul> : <p className="muted"></p>}</section>
<section className="card"><h2></h2>{audits.length ? <ul className="job-list">{audits.map((item, index) => <li key={index}><strong>{item.action}</strong> · @{item.username || "system"} · {item.target_type} #{item.target_id || "—"}<br /><span className="meta">{new Date(item.created_at + "Z").toLocaleString("zh-TW")}</span></li>)}</ul> : <p className="muted"></p>}</section>
<section className="card"><h2></h2><ul className="job-list">{sources.map((source) => <li key={source.id}><strong>{source.name}</strong> · <span className="tag">{source.is_enabled ? source.sync_status : "disabled"}</span> · {source.member_count}<br /><span className="meta">#{source.id} · {source.base_url} · {source.last_synced_at || "尚未完成"}</span></li>)}</ul></section> <section className="card"><h2></h2><ul className="job-list">{sources.map((source) => <li key={source.id}><strong>{source.name}</strong> · <span className="tag">{source.is_enabled ? source.sync_status : "disabled"}</span> · {source.member_count}<br /><span className="meta">#{source.id} · {source.base_url} · {source.last_synced_at || "尚未完成"}</span></li>)}</ul></section>
</>; </>;
} }
+2
View File
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth"; import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { audit } from "@/lib/audit";
import { externalUrl } from "@/lib/http"; import { externalUrl } from "@/lib/http";
import { requireSameOrigin } from "@/lib/security"; import { requireSameOrigin } from "@/lib/security";
import bcrypt from "bcryptjs"; 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 === "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 if (action === "resolve-report") db.prepare("UPDATE reports SET resolved=1 WHERE id=?").run(id);
else throw new Error("Invalid action"); else throw new Error("Invalid action");
audit(admin.id, `admin.${action}`, "admin-target", id);
return NextResponse.redirect(externalUrl(request, "/admin?updated=1")); return NextResponse.redirect(externalUrl(request, "/admin?updated=1"));
} catch { return NextResponse.redirect(externalUrl(request, "/admin?error=moderation")); } } catch { return NextResponse.redirect(externalUrl(request, "/admin?error=moderation")); }
} }
+2 -1
View File
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth"; import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { audit } from "@/lib/audit";
import { externalUrl } from "@/lib/http"; import { externalUrl } from "@/lib/http";
import { requireSameOrigin } from "@/lib/security"; 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"))); } }
+2 -2
View File
@@ -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"; 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 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)));}} 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)));}}
+2 -1
View File
@@ -2,7 +2,8 @@ import { createHash, randomBytes } from "node:crypto";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth"; import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { audit } from "@/lib/audit";
import { requireSameOrigin } from "@/lib/security"; import { requireSameOrigin } from "@/lib/security";
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { 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 }); }
} }
+2 -1
View File
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth"; import { requireUser } from "@/lib/auth";
import { decrypt } from "@/lib/crypto"; import { decrypt } from "@/lib/crypto";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { audit } from "@/lib/audit";
import { externalUrl } from "@/lib/http"; import { externalUrl } from "@/lib/http";
import { getMemosIdentity, verifyMemos } from "@/lib/memos"; import { getMemosIdentity, verifyMemos } from "@/lib/memos";
import { queuePull } from "@/lib/sync"; 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); }); 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(); remove();
} else throw new Error("Unknown source action"); } 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"))); } } catch (error) { return NextResponse.redirect(externalUrl(req, "/dashboard?error=" + encodeURIComponent(error instanceof Error ? error.message : "source"))); }
} }
+4
View File
@@ -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));
}
+6
View File
@@ -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, used_at TEXT, created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP 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 ( CREATE TABLE IF NOT EXISTS tag_aliases (
alias TEXT PRIMARY KEY COLLATE NOCASE, canonical TEXT NOT NULL COLLATE NOCASE, alias TEXT PRIMARY KEY COLLATE NOCASE, canonical TEXT NOT NULL COLLATE NOCASE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP 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("INSERT OR IGNORE INTO schema_migrations(version) VALUES(35)").run();
db.prepare("UPDATE source_members SET role='editor' WHERE role='member'").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(36)").run();
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(37)").run();
const admin = process.env.ADMIN_USERNAME; const admin = process.env.ADMIN_USERNAME;
const adminPassword = process.env.ADMIN_PASSWORD; const adminPassword = process.env.ADMIN_PASSWORD;
+1 -1
View File
@@ -16,7 +16,7 @@ test("applies tracked migrations and deduplicates active pull jobs", async () =>
const { queuePull } = await import("../lib/sync"); const { queuePull } = await import("../lib/sync");
const { notify } = await import("../lib/notifications"); const { notify } = await import("../lib/notifications");
const migrations = db.prepare("SELECT version FROM schema_migrations ORDER BY version").all() as { version: number }[]; 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 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); 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); assert.equal(queuePull(sourceId, "manual"), true);