feat: add privacy controls, minute history and ZIP backups

This commit is contained in:
陈煜 committed 2026-10-01 17:35:11 +08:00
1 parent ba0d5201c9
commit 2a650853ee
21 files changed
+1729 -226

No files matched your search

+194 -29
View File
@@ -7,15 +7,28 @@ import {
Res,
BadRequestException,
ConflictException,
UploadedFile,
UseInterceptors,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { Response } from 'express';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { tmpdir } from 'node:os';
import { unlink, open, readFile, readdir, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { archiveBackup, readBackupZip, MAX_UPLOAD_BYTES } from './zip';
import { z } from 'zod';
import { Prisma } from '@prisma/client';
import Decimal from 'decimal.js';
import { Database } from './database';
import { UserRequest } from './auth';
import { positionInput, positionMeta, currency, revisionInput, rateInput } from './validation';
import { day } from './calculation';
import { createHash } from 'node:crypto';
import { toBusinessDate } from './validation';
import { day, businessTime } from './calculation';
const timestamp = z.iso
.datetime()
.refine(
@@ -40,31 +53,30 @@ const record = positionMeta
updatedAt: timestamp,
}),
)
.min(1)
.max(10000),
.min(1),
})
.strict();
const backupSchema = z
.object({
format: z.literal('worthpath'),
version: z.literal(1),
version: z.union([z.literal(1), z.literal(2)]),
exportedAt: z.iso.datetime(),
baseCurrency: currency,
currencies: z.array(currency).max(10),
positions: z.array(record).max(1000),
links: z
.array(z.object({ sourceId: z.string().uuid(), targetId: z.string().uuid() }).strict())
.max(20000),
rates: z.array(rateInput.safeExtend({ source: z.enum(['manual', 'frankfurter']) })).max(20000),
preferences: z
.object({ showSidebar: z.boolean(), idleMinutes: z.number().int().min(0).max(1440) })
.strict()
.optional(),
positions: z.array(record),
links: z.array(z.object({ sourceId: z.string().uuid(), targetId: z.string().uuid() }).strict()),
rates: z.array(rateInput.safeExtend({ source: z.enum(['manual', 'frankfurter']) })),
})
.strict();
type Backup = z.infer<typeof backupSchema>;
export type Backup = z.infer<typeof backupSchema>;
export function validateBackup(raw: unknown) {
const b = backupSchema.parse(raw),
ids = new Map(b.positions.map((p) => [p.id, p]));
if (ids.size !== b.positions.length) throw new BadRequestException('重复项目 ID');
if (b.positions.reduce((n, p) => n + p.revisions.length, 0) > 20000)
throw new BadRequestException('单次备份最多 20000 条历史');
const origins = b.positions.map((p) => p.importedFromId || p.id);
if (new Set(origins).size !== origins.length) throw new BadRequestException('备份内包含重复项目');
const revisionIds = new Set<string>();
@@ -77,6 +89,7 @@ export function validateBackup(raw: unknown) {
currency: p.currency,
notes: p.notes,
archived: p.archived,
hidden: p.hidden,
amount: '0',
date: p.revisions[0].date,
});
@@ -110,19 +123,60 @@ export function validateBackup(raw: unknown) {
return b;
}
@Controller('api/backup')
export class BackupController {
export class BackupController implements OnModuleDestroy, OnModuleInit {
private uploads = new Map<
string,
{ sessionId: string; userId: string; path: string; expires: number }
>();
private cleaner = setInterval(() => void this.prune(), 60000).unref();
private async prune(all = false) {
for (const [token, v] of this.uploads)
if (all || v.expires < Date.now()) {
this.uploads.delete(token);
await unlink(v.path).catch(() => {});
}
// Remove only this application's expired uploads, including files left by a restart.
for (const name of await readdir(tmpdir()).catch(() => [])) {
if (!/^worthpath-import-[a-f0-9-]{36}\.zip$/.test(name)) continue;
const path = join(tmpdir(), name),
info = await stat(path).catch(() => null);
if (info && Date.now() - info.mtimeMs > 15 * 60000) await unlink(path).catch(() => {});
}
}
async onModuleDestroy() {
clearInterval(this.cleaner);
await this.prune(true);
}
private async uploadedData(path: string) {
const handle = await open(path, 'r');
const prefix = Buffer.alloc(2);
try {
await handle.read(prefix, 0, 2, 0);
} finally {
await handle.close();
}
return prefix.toString() === 'PK'
? readBackupZip(path)
: JSON.parse(await readFile(path, 'utf8'));
}
constructor(private db: Database) {}
private async data(userId: string): Promise<Backup> {
const [user, ps, rates] = await this.db.$transaction([
this.db.user.findUniqueOrThrow({
private async data(
userId: string,
client: Database | Prisma.TransactionClient = this.db,
): Promise<Backup> {
const [user, ps, rates] = await Promise.all([
client.user.findUniqueOrThrow({
where: { id: userId },
select: { baseCurrency: true },
select: { baseCurrency: true, showSidebar: true, idleMinutes: true },
}),
this.db.position.findMany({
client.position.findMany({
where: { userId },
include: { revisions: true, outgoing: true },
include: {
revisions: { orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }] },
outgoing: true,
},
}),
this.db.exchangeRate.findMany({ where: { userId } }),
client.exchangeRate.findMany({ where: { userId } }),
]);
const positions = ps.map((p) => ({
id: p.id,
@@ -134,13 +188,14 @@ export class BackupController {
currency: p.currency,
notes: p.notes,
archived: p.archived,
hidden: p.hidden,
createdAt: p.createdAt.toISOString(),
updatedAt: p.updatedAt.toISOString(),
revisions: p.revisions.map((r) => ({
id: r.id,
sequence: r.sequence,
amount: r.amount.toString(),
date: day(r.effectiveDate),
date: businessTime(r.effectiveDate),
notes: r.notes,
reason: r.reason,
createdAt: r.createdAt.toISOString(),
@@ -149,9 +204,10 @@ export class BackupController {
}));
return backupSchema.parse({
format: 'worthpath',
version: 1,
version: 2,
exportedAt: new Date().toISOString(),
baseCurrency: user.baseCurrency,
preferences: { showSidebar: user.showSidebar, idleMinutes: user.idleMinutes },
currencies: [
...new Set([
user.baseCurrency,
@@ -173,13 +229,118 @@ export class BackupController {
});
}
@Get() async download(@Req() r: UserRequest, @Res() res: Response) {
const b = await this.data(r.userId);
const b = await this.db.$transaction(
async (tx) => {
const b = await this.data(r.userId, tx);
await tx.session.update({
where: { id: r.sessionId },
data: {
backupDigest: this.fingerprint(b),
backupExpiresAt: new Date(Date.now() + 10 * 60000),
},
});
return b;
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
);
res.setHeader(
'Content-Disposition',
`attachment; filename="worthpath-${b.exportedAt.slice(0, 10)}.json"`,
`attachment; filename="worthpath-${b.exportedAt.slice(0, 10)}.zip"`,
);
res.setHeader('Cache-Control', 'no-store');
res.type('application/json').send(JSON.stringify(b, null, 2));
res.type('application/zip');
const archive = archiveBackup(b);
archive.on('error', () => res.destroy());
archive.pipe(res);
await archive.finalize().catch(() => res.destroy());
}
@Post('upload')
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
destination: tmpdir(),
filename: (_req, _file, cb) => cb(null, 'worthpath-import-' + randomUUID() + '.zip'),
}),
limits: { files: 1, fileSize: MAX_UPLOAD_BYTES, fields: 0 },
}),
)
async upload(@Req() r: UserRequest, @UploadedFile() file?: Express.Multer.File) {
if (!file) throw new BadRequestException('请选择 ZIP 备份文件');
try {
const b = validateBackup(await this.uploadedData(file.path));
const result = await this.preview(r, b);
for (const [token, v] of this.uploads)
if (v.userId === r.userId) {
this.uploads.delete(token);
await unlink(v.path).catch(() => {});
}
const token = randomUUID();
this.uploads.set(token, {
sessionId: r.sessionId,
userId: r.userId,
path: file.path,
expires: Date.now() + 15 * 60000,
});
return { ...result, token };
} catch (e) {
await unlink(file.path).catch(() => {});
throw e;
}
}
async onModuleInit() {
await this.prune();
}
@Post('import-file') async importFile(@Req() r: UserRequest, @Body() raw: unknown) {
const { token } = z
.object({ confirmed: z.literal(true), token: z.string().uuid() })
.strict()
.parse(raw);
const v = this.uploads.get(token);
if (!v || v.userId !== r.userId || v.sessionId !== r.sessionId || v.expires < Date.now())
throw new BadRequestException('导入预览已失效,请重新选择备份');
this.uploads.delete(token);
try {
return await this.restore(r, { confirmed: true, backup: await this.uploadedData(v.path) });
} finally {
await unlink(v.path).catch(() => {});
}
}
private fingerprint(b: Backup) {
const { exportedAt, ...data } = structuredClone(b);
data.positions.sort((a, b) => a.id.localeCompare(b.id));
for (const p of data.positions) p.revisions.sort((a, b) => a.id.localeCompare(b.id));
data.links.sort((a, b) => (a.sourceId + a.targetId).localeCompare(b.sourceId + b.targetId));
data.rates.sort((a, b) =>
(a.currency + a.baseCurrency + a.date).localeCompare(b.currency + b.baseCurrency + b.date),
);
data.currencies.sort();
return createHash('sha256').update(JSON.stringify(data)).digest('hex');
}
@Get('clear-status') async clearStatus(@Req() r: UserRequest) {
const s = await this.db.session.findUniqueOrThrow({ where: { id: r.sessionId } });
return { ready: !!s.backupDigest && !!s.backupExpiresAt && +s.backupExpiresAt > Date.now() };
}
@Post('clear') async clear(@Req() r: UserRequest, @Body() raw: unknown) {
z.object({ confirmation: z.literal('确定清空') })
.strict()
.parse(raw);
return this.db.$transaction(
async (tx) => {
const s = await tx.session.findUniqueOrThrow({ where: { id: r.sessionId } });
if (!s.backupDigest || !s.backupExpiresAt || +s.backupExpiresAt <= Date.now())
throw new BadRequestException('请先下载当前账号备份,再进入下一步(10 分钟内有效)');
if (this.fingerprint(await this.data(r.userId, tx)) !== s.backupDigest)
throw new ConflictException('数据已变化,请重新下载备份');
await tx.position.deleteMany({ where: { userId: r.userId } });
await tx.exchangeRate.deleteMany({ where: { userId: r.userId } });
await tx.session.updateMany({
where: { userId: r.userId },
data: { backupDigest: null, backupExpiresAt: null, revealUntil: null },
});
return { ok: true };
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable, timeout: 300000 },
);
}
@Post('preview') async preview(@Req() r: UserRequest, @Body() raw: unknown) {
const b = validateBackup(raw),
@@ -192,7 +353,7 @@ export class BackupController {
baseCurrency: b.baseCurrency,
currentBaseCurrency: existing.baseCurrency,
message:
'只追加新项目并重建关联,相同汇率保留。不会覆盖已有项目。已有本位币保留,空账户恢复备份本位币。确认后以事务导入。',
'只追加新项目并重建关联,相同汇率保留。不会覆盖已有项目。已有本位币保留,空账户恢复备份本位币和个人设置。确认后以事务导入。',
};
}
private conflicts(b: Backup, existing: Backup) {
@@ -250,6 +411,7 @@ export class BackupController {
currency: p.currency,
notes: p.notes,
archived: p.archived,
hidden: p.hidden,
createdAt: new Date(p.createdAt),
updatedAt: new Date(p.updatedAt),
revisions: {
@@ -262,7 +424,7 @@ export class BackupController {
)
.map((v) => ({
amount: v.amount,
effectiveDate: new Date(v.date),
effectiveDate: toBusinessDate(v.date),
notes: v.notes,
reason: v.reason,
createdAt: new Date(v.createdAt),
@@ -291,10 +453,13 @@ export class BackupController {
});
}
if (!ps.length && !rs.length)
await tx.user.update({ where: { id: r.userId }, data: { baseCurrency: b.baseCurrency } });
await tx.user.update({
where: { id: r.userId },
data: { baseCurrency: b.baseCurrency, ...b.preferences },
});
return { ok: true, positions: b.positions.length };
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable, timeout: 30000 },
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable, timeout: 300000 },
);
}
}