feat: 发布 v0.5.0 加密存储与匿名同步
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
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 };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user