186 lines
5.6 KiB
TypeScript
186 lines
5.6 KiB
TypeScript
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<AnonymousStore> {
|
|
try {
|
|
const raw = await readFile(STORE_PATH, 'utf8');
|
|
const parsed = JSON.parse(raw) as Partial<AnonymousStore>;
|
|
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<T>(callback: (store: AnonymousStore) => Promise<T> | 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 };
|
|
});
|
|
}
|