Files
MindScope/lib/anonymous-store.ts

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 };
});
}