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
+2
View File
@@ -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")); }
}
+2 -1
View File
@@ -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"))); } }
+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";
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)));}}
+2 -1
View File
@@ -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 }); }
}
+2 -1
View File
@@ -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"))); }
}