229 lines
6.5 KiB
TypeScript
229 lines
6.5 KiB
TypeScript
import "server-only";
|
|
|
|
import { count, desc, eq } from "drizzle-orm";
|
|
|
|
import { getDb, type Database } from "@/db";
|
|
import { auditLog as auditLogTable, type AuditLogValue } from "@/db/schema";
|
|
import { securityLog } from "@/lib/security/http";
|
|
|
|
export const AUDIT_ACTIONS = [
|
|
"comment.hidden",
|
|
"comment.restored",
|
|
"guide.created",
|
|
"guide.overview.saved",
|
|
"guide.weapon.saved",
|
|
"guide.artifact.saved",
|
|
"guide.constellations.saved",
|
|
"guide.team.saved",
|
|
"guide.extra.created",
|
|
"guide.extra.saved",
|
|
"guide.extra.deleted",
|
|
"guide.extra.reordered",
|
|
"guide.trashed",
|
|
"guide.restored",
|
|
"guide.published",
|
|
"guide.unpublished",
|
|
"guide.permanently_deleted",
|
|
"glossary_alias.created",
|
|
"glossary_alias.deleted",
|
|
"media.uploaded",
|
|
"media.deleted",
|
|
"admin_account.created",
|
|
"admin_account.removed",
|
|
"catalog_sync.requested",
|
|
"catalog_sync.cancel_requested",
|
|
"catalog_sync.completed",
|
|
"catalog_sync.unchanged",
|
|
"catalog_sync.cancelled",
|
|
"catalog_sync.failed",
|
|
"stygian.synced",
|
|
"stygian.recommendations.saved",
|
|
"stygian.activated",
|
|
] as const;
|
|
|
|
export const AUDIT_TARGET_TYPES = [
|
|
"comment",
|
|
"guide",
|
|
"extra_section",
|
|
"glossary_alias",
|
|
"media",
|
|
"admin_account",
|
|
"catalog_sync",
|
|
"stygian_schedule",
|
|
] as const;
|
|
|
|
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
|
export type AuditTargetType = (typeof AUDIT_TARGET_TYPES)[number];
|
|
export type AuditMetadata = Record<string, AuditLogValue>;
|
|
|
|
export interface AuditActor {
|
|
id: string;
|
|
label: string;
|
|
}
|
|
|
|
export interface AuditLogInput {
|
|
action: AuditAction;
|
|
targetType: AuditTargetType;
|
|
targetId: string;
|
|
scope?: string;
|
|
resultingVersion?: number;
|
|
metadata?: AuditMetadata;
|
|
}
|
|
|
|
type AuditWriter = Pick<Database, "insert">;
|
|
|
|
export function auditActor(user: {
|
|
id: string;
|
|
name?: string | null;
|
|
email?: string | null;
|
|
}): AuditActor {
|
|
return {
|
|
id: user.id,
|
|
label: (user.name?.trim() || user.email?.trim() || user.id).slice(0, 160),
|
|
};
|
|
}
|
|
|
|
export async function writeAuditLog(
|
|
writer: AuditWriter,
|
|
actor: AuditActor,
|
|
event: AuditLogInput,
|
|
): Promise<void> {
|
|
await writer.insert(auditLogTable).values({
|
|
authorId: actor.id,
|
|
author: actor.label,
|
|
text: AUDIT_ACTION_LABELS[event.action],
|
|
details: {
|
|
target: {
|
|
type: event.targetType,
|
|
id: event.targetId,
|
|
},
|
|
...(event.scope ? { scope: event.scope } : {}),
|
|
...(event.resultingVersion
|
|
? { version: event.resultingVersion }
|
|
: {}),
|
|
...event.metadata,
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function writeAuditLogBestEffort(
|
|
actor: AuditActor,
|
|
event: AuditLogInput,
|
|
): Promise<void> {
|
|
try {
|
|
await writeAuditLog(getDb(), actor, event);
|
|
} catch {
|
|
securityLog("audit-write-failed", {
|
|
actorId: actor.id,
|
|
targetId: event.targetId,
|
|
});
|
|
}
|
|
}
|
|
|
|
export interface AuditLogFilters {
|
|
actorId?: string;
|
|
page?: number;
|
|
pageSize?: number;
|
|
}
|
|
|
|
export async function listAuditLogs(filters: AuditLogFilters = {}) {
|
|
const page = Math.max(1, Math.floor(filters.page ?? 1));
|
|
const pageSize = Math.max(1, Math.min(100, Math.floor(filters.pageSize ?? 50)));
|
|
const where = filters.actorId
|
|
? eq(auditLogTable.authorId, filters.actorId)
|
|
: undefined;
|
|
const db = getDb();
|
|
const [events, [total]] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(auditLogTable)
|
|
.where(where)
|
|
.orderBy(desc(auditLogTable.time), desc(auditLogTable.id))
|
|
.limit(pageSize)
|
|
.offset((page - 1) * pageSize),
|
|
db
|
|
.select({ value: count() })
|
|
.from(auditLogTable)
|
|
.where(where),
|
|
]);
|
|
return {
|
|
events,
|
|
page,
|
|
pageSize,
|
|
total: total.value,
|
|
pageCount: Math.max(1, Math.ceil(total.value / pageSize)),
|
|
};
|
|
}
|
|
|
|
export async function listAuditActors() {
|
|
const rows = await getDb()
|
|
.selectDistinctOn([auditLogTable.authorId], {
|
|
id: auditLogTable.authorId,
|
|
label: auditLogTable.author,
|
|
})
|
|
.from(auditLogTable)
|
|
.orderBy(auditLogTable.authorId, desc(auditLogTable.id));
|
|
const actors = rows.filter(
|
|
(row): row is { id: string; label: string } =>
|
|
Boolean(row.id && row.label),
|
|
);
|
|
return actors.sort((left, right) =>
|
|
left.label.localeCompare(right.label, "th"),
|
|
);
|
|
}
|
|
|
|
export const AUDIT_TARGET_LABELS: Record<
|
|
AuditTargetType,
|
|
string
|
|
> = {
|
|
comment: "Comment",
|
|
guide: "Guide",
|
|
extra_section: "Extra section",
|
|
glossary_alias: "Glossary",
|
|
media: "Media",
|
|
admin_account: "Admin account",
|
|
catalog_sync: "Catalog sync",
|
|
stygian_schedule: "Stygian schedule",
|
|
};
|
|
|
|
export const AUDIT_ACTION_LABELS: Record<AuditAction, string> = {
|
|
"comment.hidden": "ซ่อน Comment",
|
|
"comment.restored": "กู้คืน Comment",
|
|
"guide.created": "สร้าง Guide",
|
|
"guide.overview.saved": "บันทึก Overview",
|
|
"guide.weapon.saved": "บันทึก Weapons",
|
|
"guide.artifact.saved": "บันทึก Artifacts",
|
|
"guide.constellations.saved": "บันทึก Constellations",
|
|
"guide.team.saved": "บันทึก Team",
|
|
"guide.extra.created": "สร้าง Extra section",
|
|
"guide.extra.saved": "บันทึก Extra section",
|
|
"guide.extra.deleted": "ลบ Extra section",
|
|
"guide.extra.reordered": "เรียง Extra sections",
|
|
"guide.trashed": "ย้าย Guide ไปถังขยะ",
|
|
"guide.restored": "กู้คืน Guide",
|
|
"guide.published": "เผยแพร่ Guide",
|
|
"guide.unpublished": "ยกเลิกเผยแพร่ Guide",
|
|
"guide.permanently_deleted": "ลบ Guide ถาวร",
|
|
"glossary_alias.created": "เพิ่ม Glossary shortcut",
|
|
"glossary_alias.deleted": "ลบ Glossary shortcut",
|
|
"media.uploaded": "อัปโหลด Media",
|
|
"media.deleted": "ลบ Media",
|
|
"admin_account.created": "สร้างบัญชี Admin",
|
|
"admin_account.removed": "ลบบัญชี Admin",
|
|
"catalog_sync.requested": "เริ่ม Catalog sync",
|
|
"catalog_sync.cancel_requested": "ขอยกเลิก Catalog sync",
|
|
"catalog_sync.completed": "Catalog sync สำเร็จ",
|
|
"catalog_sync.unchanged": "Catalog เป็นเวอร์ชันล่าสุด",
|
|
"catalog_sync.cancelled": "ยกเลิก Catalog sync แล้ว",
|
|
"catalog_sync.failed": "Catalog sync ล้มเหลว",
|
|
"stygian.synced": "Sync Stygian archive",
|
|
"stygian.recommendations.saved": "บันทึกคำแนะนำ Stygian",
|
|
"stygian.activated": "เผยแพร่ Stygian schedule",
|
|
};
|
|
|
|
export function isAuditTargetType(
|
|
value: string | undefined,
|
|
): value is AuditTargetType {
|
|
return AUDIT_TARGET_TYPES.some((type) => type === value);
|
|
}
|