import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { pbkdf2Sync, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; import { EncryptedAssessmentRecord } from '@/lib/assessment-types'; const STORE_PATH = path.join(process.cwd(), 'data', 'anonymous-store.json'); const HASH_ITERATIONS = 120_000; const KEY_LENGTH = 32; export interface AnonymousProfile { id: string; codeName: string; passwordHash: string; salt: string; createdAt: string; updatedAt: string; lastSeenAt: string; } export interface PublicAnonymousProfile { id: string; codeName: string; createdAt: string; updatedAt: string; lastSeenAt: string; } interface AnonymousStore { version: 2; profiles: AnonymousProfile[]; records: EncryptedAssessmentRecord[]; } export class AnonymousStoreError extends Error { constructor( message: string, public status = 400, ) { super(message); } } function normalizeCodeName(codeName: string) { return codeName.trim().replace(/\s+/g, ' ').slice(0, 40); } function assertCredentials(codeName: string, password: string) { const normalized = normalizeCodeName(codeName); if (normalized.length < 2) { throw new AnonymousStoreError('代号至少需要 2 个字符'); } if (password.length < 4 || password.length > 128) { throw new AnonymousStoreError('恢复口令需要 4 到 128 个字符'); } return normalized; } function hashPassword(password: string, salt: string) { return pbkdf2Sync(password, salt, HASH_ITERATIONS, KEY_LENGTH, 'sha256').toString('hex'); } function verifyPassword(password: string, profile: AnonymousProfile) { const expected = Buffer.from(profile.passwordHash, 'hex'); const actual = Buffer.from(hashPassword(password, profile.salt), 'hex'); return expected.length === actual.length && timingSafeEqual(expected, actual); } function toPublicProfile(profile: AnonymousProfile): PublicAnonymousProfile { return { id: profile.id, codeName: profile.codeName, createdAt: profile.createdAt, updatedAt: profile.updatedAt, lastSeenAt: profile.lastSeenAt, }; } async function loadStore(): Promise { try { const raw = await readFile(STORE_PATH, 'utf8'); const parsed = JSON.parse(raw) as Partial; return { version: 2, profiles: Array.isArray(parsed.profiles) ? parsed.profiles : [], records: Array.isArray(parsed.records) ? parsed.records.filter((record) => record?.encrypted === true) : [], }; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return { version: 2, profiles: [], records: [] }; } throw error; } } async function saveStore(store: AnonymousStore) { await mkdir(path.dirname(STORE_PATH), { recursive: true }); const tempPath = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`; await writeFile(tempPath, JSON.stringify(store, null, 2), 'utf8'); await rename(tempPath, STORE_PATH); } async function withStore(callback: (store: AnonymousStore) => Promise | T) { const store = await loadStore(); const result = await callback(store); await saveStore(store); return result; } export async function loginAnonymousProfile(codeName: string, password: string) { const normalized = assertCredentials(codeName, password); return withStore((store) => { const now = new Date().toISOString(); const existing = store.profiles.find( (profile) => profile.codeName.toLowerCase() === normalized.toLowerCase(), ); if (existing) { if (!verifyPassword(password, existing)) { throw new AnonymousStoreError('代号或恢复口令不正确', 401); } existing.lastSeenAt = now; existing.updatedAt = now; return { profile: toPublicProfile(existing), records: store.records .filter((record) => record.profileId === existing.id) .sort((a, b) => b.completedAt.localeCompare(a.completedAt)), }; } const salt = randomBytes(16).toString('hex'); const profile: AnonymousProfile = { id: `anon_${randomUUID()}`, codeName: normalized, passwordHash: hashPassword(password, salt), salt, createdAt: now, updatedAt: now, lastSeenAt: now, }; store.profiles.push(profile); return { profile: toPublicProfile(profile), records: [] }; }); } export async function addAnonymousRecord( codeName: string, password: string, encryptedRecord: EncryptedAssessmentRecord, ) { const { profile } = await loginAnonymousProfile(codeName, password); return withStore((store) => { const now = new Date().toISOString(); const serverRecord: EncryptedAssessmentRecord = { ...encryptedRecord, id: encryptedRecord.id.startsWith('server_') ? encryptedRecord.id : `server_${encryptedRecord.id}`, profileId: profile.id, }; const existingIndex = store.records.findIndex( (item) => item.id === serverRecord.id && item.profileId === profile.id, ); if (existingIndex >= 0) { store.records[existingIndex] = serverRecord; } else { store.records.push(serverRecord); } const owner = store.profiles.find((item) => item.id === profile.id); if (owner) { owner.updatedAt = now; owner.lastSeenAt = now; } return serverRecord; }); } export async function deleteAnonymousProfile(codeName: string, password: string) { const { profile } = await loginAnonymousProfile(codeName, password); return withStore((store) => { store.profiles = store.profiles.filter((item) => item.id !== profile.id); store.records = store.records.filter((item) => item.profileId !== profile.id); return { ok: true }; }); }