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

+9 -3
View File
@@ -5,25 +5,28 @@
"dev": "node scripts/dev.cjs",
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "tsx --test test/calculation.test.ts",
"test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/zip.test.ts",
"db:generate": "prisma generate",
"db:migrate": "node scripts/database.cjs deploy",
"db:status": "node scripts/database.cjs status",
"test:integration": "tsx --test test/integration.test.ts"
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts"
},
"dependencies": {
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@prisma/client": "6.19.0",
"archiver": "^8.0.0",
"bcryptjs": "^3.0.0",
"cookie-parser": "^1.4.7",
"decimal.js": "^10.6.0",
"dotenv": "^17.2.0",
"express": "5.1.0",
"helmet": "^8.1.0",
"multer": "^2.4.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"yauzl": "^3.4.0",
"zod": "^4.1.0"
},
"devDependencies": {
@@ -33,6 +36,9 @@
"mysql2": "^3.15.0",
"prisma": "6.19.0",
"tsx": "^4.20.0",
"typescript": "^5.9.0"
"typescript": "^5.9.0",
"@types/archiver": "^8.0.0",
"@types/yauzl": "^3.4.0",
"@types/multer": "^2.3.0"
}
}
@@ -0,0 +1,10 @@
ALTER TABLE `User` ADD COLUMN `showSidebar` BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN `idleMinutes` INTEGER NOT NULL DEFAULT 30;
ALTER TABLE `Session` ADD COLUMN `lastActivity` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
ADD COLUMN `revealUntil` DATETIME(3) NULL,
ADD COLUMN `backupDigest` CHAR(64) NULL,
ADD COLUMN `backupExpiresAt` DATETIME(3) NULL;
ALTER TABLE `Position` ADD COLUMN `hidden` BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE `Revision` MODIFY COLUMN `effectiveDate` DATETIME(3) NOT NULL;
-- Existing date-only entries represent midnight in Asia/Hong_Kong.
UPDATE `Revision` SET `effectiveDate` = DATE_SUB(`effectiveDate`, INTERVAL 8 HOUR);
+8 -1
View File
@@ -10,6 +10,8 @@ model User {
username String @unique @db.VarChar(64)
passwordHash String @db.VarChar(255)
baseCurrency String @default("CNY") @db.Char(3)
showSidebar Boolean @default(true)
idleMinutes Int @default(30)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
positions Position[]
@@ -21,6 +23,10 @@ model Session {
userId String @db.Char(36)
user User @relation(fields:[userId],references:[id],onDelete:Cascade)
expiresAt DateTime
lastActivity DateTime @default(now())
revealUntil DateTime?
backupDigest String? @db.Char(64)
backupExpiresAt DateTime?
@@index([userId])
}
model Position {
@@ -35,6 +41,7 @@ model Position {
currency String @db.Char(3)
notes String @db.Text
archived Boolean @default(false)
hidden Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
revisions Revision[]
@@ -49,7 +56,7 @@ model Revision {
positionId String @db.Char(36)
position Position @relation(fields:[positionId],references:[id],onDelete:Cascade)
amount Decimal @db.Decimal(24,8)
effectiveDate DateTime @db.Date
effectiveDate DateTime @db.DateTime(3)
notes String @db.Text
reason String @db.VarChar(20)
createdAt DateTime @default(now())
+35 -4
View File
@@ -19,7 +19,7 @@ import { randomBytes, createHash } from 'node:crypto';
import { hash, compare } from 'bcryptjs';
import { Database } from './database';
import { credentials } from './validation';
export type UserRequest = Request & { userId: string };
export type UserRequest = Request & { userId: string; sessionId: string; revealed: boolean };
const Public = () => SetMetadata('public', true);
const digest = (s: string) => createHash('sha256').update(s).digest('hex');
@Injectable()
@@ -55,7 +55,16 @@ export class AuthService {
async user(token: unknown) {
if (typeof token !== 'string' || !/^[a-f0-9]{64}$/.test(token)) return null;
const s = await this.db.session.findUnique({ where: { id: digest(token) } });
return s && s.expiresAt > new Date() ? s.userId : null;
if (!s || s.expiresAt <= new Date()) return null;
const u = await this.db.user.findUniqueOrThrow({
where: { id: s.userId },
select: { idleMinutes: true },
});
if (u.idleMinutes && Date.now() - +s.lastActivity >= u.idleMinutes * 60000) {
await this.db.session.deleteMany({ where: { id: s.id } });
throw new UnauthorizedException('长时间无操作,已自动退出登录');
}
return s;
}
async logout(req: Request, res: Response) {
if (typeof req.cookies?.wp_session === 'string')
@@ -84,7 +93,9 @@ export class AuthGuard implements CanActivate {
if (this.reflector.get<boolean>('public', ctx.getHandler())) return true;
const id = await this.auth.user(req.cookies?.wp_session);
if (!id) throw new UnauthorizedException('请先登录');
req.userId = id;
req.userId = id.userId;
req.sessionId = id.id;
req.revealed = !!id.revealUntil && +id.revealUntil > Date.now();
return true;
}
}
@@ -129,9 +140,29 @@ export class AuthController {
@Get('auth/me') async me(@Req() req: UserRequest) {
return this.db.user.findUniqueOrThrow({
where: { id: req.userId },
select: { username: true, baseCurrency: true },
select: { username: true, baseCurrency: true, showSidebar: true, idleMinutes: true },
});
}
@Post('auth/activity') async activity(@Req() r: UserRequest) {
await this.db.session.update({
where: { id: r.sessionId },
data: { lastActivity: new Date() },
});
return { ok: true };
}
@Post('auth/reveal') async reveal(@Req() r: UserRequest, @Body() b: unknown) {
this.auth.limit(r);
const { password } = credentials.pick({ password: true }).parse(b);
const u = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
if (!(await compare(password, u.passwordHash))) throw new ForbiddenException('密码错误');
const revealUntil = new Date(Date.now() + 5 * 60000);
await this.db.session.update({ where: { id: r.sessionId }, data: { revealUntil } });
return { revealUntil };
}
@Post('auth/lock') async lock(@Req() r: UserRequest) {
await this.db.session.update({ where: { id: r.sessionId }, data: { revealUntil: null } });
return { ok: true };
}
@Post('auth/logout') async logout(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
+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 },
);
}
}
+7 -4
View File
@@ -24,6 +24,8 @@ export type Rate = {
source: string;
};
export const day = (d: Date) => d.toISOString().slice(0, 10);
export const businessTime = (d: Date) => new Date(+d + 8 * 3600000).toISOString().slice(0, 16);
export const businessDay = (d: Date) => businessTime(d).slice(0, 10);
export function compareRevisions(a: Holding['revisions'][number], b: Holding['revisions'][number]) {
return +a.effectiveDate - +b.effectiveDate || (a.sequence || 0) - (b.sequence || 0);
}
@@ -39,7 +41,8 @@ export function history(p: Holding) {
name: p.name,
kind: p.kind,
currency: p.currency,
date: day(r.effectiveDate),
date: businessDay(r.effectiveDate),
time: businessTime(r.effectiveDate),
before: before.toFixed(),
after: after.toFixed(),
delta: after.minus(before).toFixed(),
@@ -63,7 +66,7 @@ export function totals(positions: Holding[], rates: Rate[], base: string, date:
const missing = new Set<string>();
const items = positions.map((p) => {
const rev = p.revisions
.filter((r) => day(r.effectiveDate) <= date)
.filter((r) => businessDay(r.effectiveDate) <= date)
.sort((a, b) => compareRevisions(b, a))[0],
amount = new Decimal(rev?.amount.toString() || '0'),
fx = rateAt(rates, p.currency, base, date);
@@ -98,7 +101,7 @@ export function totals(positions: Holding[], rates: Rate[], base: string, date:
export function overview(positions: Holding[], rates: Rate[], base: string, date: string) {
const dates = [
...new Set([
...positions.flatMap((p) => p.revisions.map((r) => day(r.effectiveDate))),
...positions.flatMap((p) => p.revisions.map((r) => businessDay(r.effectiveDate))),
...rates.filter((r) => r.baseCurrency === base).map((r) => day(r.date)),
date,
]),
@@ -135,7 +138,7 @@ export function overview(positions: Holding[], rates: Rate[], base: string, date
trend,
recent: positions
.flatMap(history)
.sort((a, b) => b.date.localeCompare(a.date) || b.sequence - a.sequence)
.sort((a, b) => b.time.localeCompare(a.time) || b.sequence - a.sequence)
.slice(0, 20),
};
}
+22 -15
View File
@@ -13,7 +13,7 @@ import {
} from '@nestjs/common';
import { Database } from './database';
import { UserRequest } from './auth';
import { positionInput, positionMeta, revisionInput, today } from './validation';
import { positionInput, positionMeta, revisionInput, today, toBusinessDate } from './validation';
import { history, overview } from './calculation';
import { z } from 'zod';
import { Prisma } from '@prisma/client';
@@ -24,9 +24,9 @@ export class PortfolioController {
private db: Database,
private fx: RatesService,
) {}
private async own(userId: string, id: string) {
private async own(userId: string, id: string, revealed = false) {
const p = await this.db.position.findFirst({
where: { id, userId },
where: { id, userId, ...(revealed ? {} : { hidden: false }) },
include: {
revisions: { orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }] },
outgoing: true,
@@ -37,7 +37,7 @@ export class PortfolioController {
}
@Get('positions') async list(@Req() r: UserRequest) {
const rows = await this.db.position.findMany({
where: { userId: r.userId },
where: { userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
include: {
revisions: { orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }] },
outgoing: true,
@@ -52,7 +52,7 @@ export class PortfolioController {
}));
}
@Get('positions/:id') async detail(@Req() r: UserRequest, @Param('id') id: string) {
const p = await this.own(r.userId, id);
const p = await this.own(r.userId, id, r.revealed);
return { ...p, userId: undefined, history: history(p) };
}
@Post('positions') async create(@Req() r: UserRequest, @Body() b: unknown) {
@@ -63,7 +63,12 @@ export class PortfolioController {
...meta,
userId: r.userId,
revisions: {
create: { amount, effectiveDate: new Date(date), notes: v.notes, reason: 'initial' },
create: {
amount,
effectiveDate: toBusinessDate(date),
notes: v.notes,
reason: 'initial',
},
},
},
select: { id: true },
@@ -77,7 +82,7 @@ export class PortfolioController {
@Body() b: unknown,
) {
const v = positionMeta.parse(b),
p = await this.own(r.userId, id);
p = await this.own(r.userId, id, r.revealed);
if (
p.kind === 'account' &&
['credit_card', 'loan'].includes(v.category) &&
@@ -95,13 +100,15 @@ export class PortfolioController {
const v = revisionInput.parse(b);
return this.db.$transaction(
async (tx) => {
const p = await tx.position.findFirst({ where: { id, userId: r.userId } });
const p = await tx.position.findFirst({
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
});
if (!p) throw new NotFoundException('项目不存在');
if (p.archived) throw new ConflictException('请先恢复归档项目');
if (v.reason === 'repayment') {
if (p.side !== 'liability') throw new BadRequestException('还款记录只能用于负债');
const prior = await tx.revision.findFirst({
where: { positionId: p.id, effectiveDate: { lte: new Date(v.date) } },
where: { positionId: p.id, effectiveDate: { lte: toBusinessDate(v.date) } },
orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }],
});
if (!prior || new Prisma.Decimal(v.amount).gt(prior.amount))
@@ -111,7 +118,7 @@ export class PortfolioController {
data: {
positionId: p.id,
amount: v.amount,
effectiveDate: new Date(v.date),
effectiveDate: toBusinessDate(v.date),
notes: v.notes,
reason: v.reason,
},
@@ -127,7 +134,7 @@ export class PortfolioController {
@Body() b: unknown,
) {
const v = revisionInput.parse(b),
p = await this.own(r.userId, id);
p = await this.own(r.userId, id, r.revealed);
if (p.archived) throw new ConflictException('请先恢复归档项目');
if (!p.revisions.some((x) => x.id === revisionId))
throw new NotFoundException('历史记录不存在');
@@ -135,7 +142,7 @@ export class PortfolioController {
where: { id: revisionId },
data: {
amount: v.amount,
effectiveDate: new Date(v.date),
effectiveDate: toBusinessDate(v.date),
notes: v.notes,
reason: 'correction',
},
@@ -156,7 +163,7 @@ export class PortfolioController {
return this.db.$transaction(
async (tx) => {
const source = await tx.position.findFirst({
where: { id, userId: r.userId, kind: 'debt' },
where: { id, userId: r.userId, kind: 'debt', ...(r.revealed ? {} : { hidden: false }) },
});
if (!source) throw new NotFoundException('债务不存在');
const count = await tx.position.count({
@@ -180,11 +187,11 @@ export class PortfolioController {
select: { baseCurrency: true },
}),
this.db.position.findMany({
where: { userId: r.userId },
where: { userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
include: { revisions: true },
}),
this.db.exchangeRate.findMany({ where: { userId: r.userId } }),
]);
return overview(positions, rates, user.baseCurrency, today());
return { ...overview(positions, rates, user.baseCurrency, today()), revealed: r.revealed };
}
}
+49 -35
View File
@@ -4,7 +4,6 @@ import {
Get,
Patch,
Post,
Put,
Req,
Body,
OnModuleInit,
@@ -13,7 +12,7 @@ import {
} from '@nestjs/common';
import { Database } from './database';
import { UserRequest } from './auth';
import { currency, rateInput, date, rateValue, today } from './validation';
import { currency, date, rateValue, today } from './validation';
import { z } from 'zod';
import Decimal from 'decimal.js';
// Fixed public request; no user currency choices, identifiers or amounts leave the server.
@@ -115,28 +114,44 @@ export class RatesService implements OnModuleInit, OnModuleDestroy {
source: 'frankfurter',
};
});
await this.db.$transaction(async (tx) => {
for (const v of data) {
const { rate, source, ...key } = v;
const existing = await tx.exchangeRate.findUnique({
where: { userId_currency_baseCurrency_date: key },
await this.db.$transaction(
async (tx) => {
// A clear or currency change during the network request must not recreate stale rates.
const currentUser = await tx.user.findUniqueOrThrow({ where: { id: userId } });
const currentPositions = await tx.position.findMany({
where: { userId },
select: { currency: true },
distinct: ['currency'],
});
if (existing?.source === 'manual') continue;
await tx.exchangeRate.upsert({
where: { userId_currency_baseCurrency_date: key },
create: v,
update: { rate, source },
});
}
});
const message = '已保存最新可用日汇率;休市日可能沿用上一工作日。同日手动汇率已保留。';
if (
currentUser.baseCurrency !== u.baseCurrency ||
JSON.stringify(currentPositions.map((p) => p.currency).sort()) !==
JSON.stringify(ps.map((p) => p.currency).sort())
)
return;
for (const v of data) {
const { rate, source, ...key } = v;
const existing = await tx.exchangeRate.findUnique({
where: { userId_currency_baseCurrency_date: key },
});
if (existing?.source === 'manual') continue;
await tx.exchangeRate.upsert({
where: { userId_currency_baseCurrency_date: key },
create: v,
update: { rate, source },
});
}
},
{ isolationLevel: 'Serializable' },
);
const message = '已保存最新可用日汇率;休市日可能沿用上一工作日。同日历史导入汇率已保留。';
this.outcomes.set(userId, { state: 'ok', attemptedAt: new Date().toISOString(), message });
return { message };
} catch {
this.outcomes.set(userId, {
state: 'error',
attemptedAt: new Date().toISOString(),
message: '自动汇率更新失败,原币和已有汇率已保留,请重试或手动录入',
message: '自动汇率更新失败,原币和已有汇率已保留,请稍后重试',
});
throw new BadGatewayException('汇率更新失败,原币金额和已有汇率已保留;可稍后重试或手动录入');
} finally {
@@ -153,10 +168,16 @@ export class SettingsController {
@Get('settings') async settings(@Req() r: UserRequest) {
const u = await this.db.user.findUniqueOrThrow({
where: { id: r.userId },
select: { username: true, baseCurrency: true },
select: { username: true, baseCurrency: true, showSidebar: true, idleMinutes: true },
});
return {
...u,
lastActivity: (await this.db.session.findUniqueOrThrow({ where: { id: r.sessionId } }))
.lastActivity,
revealed: r.revealed,
revealUntil: r.revealed
? (await this.db.session.findUniqueOrThrow({ where: { id: r.sessionId } })).revealUntil
: null,
fxStatus: this.fx.status(r.userId),
rates: await this.db.exchangeRate.findMany({
where: { userId: r.userId },
@@ -166,26 +187,19 @@ export class SettingsController {
};
}
@Patch('settings') async update(@Req() r: UserRequest, @Body() b: unknown) {
const { baseCurrency } = z.object({ baseCurrency: currency }).strict().parse(b);
await this.db.user.update({ where: { id: r.userId }, data: { baseCurrency } });
const data = z
.object({
baseCurrency: currency.optional(),
showSidebar: z.boolean().optional(),
idleMinutes: z.number().int().min(0).max(1440).optional(),
})
.strict()
.refine((v) => Object.keys(v).length > 0)
.parse(b);
await this.db.user.update({ where: { id: r.userId }, data });
this.fx.invalidate(r.userId);
return { ok: true };
}
@Put('rates') async manual(@Req() r: UserRequest, @Body() b: unknown) {
const v = rateInput.parse(b),
key = {
userId: r.userId,
currency: v.currency,
baseCurrency: v.baseCurrency,
date: new Date(v.date),
};
await this.db.exchangeRate.upsert({
where: { userId_currency_baseCurrency_date: key },
create: { ...key, rate: v.rate, source: 'manual' },
update: { rate: v.rate, source: 'manual' },
});
return { ok: true };
}
@Post('rates/refresh') async refresh(@Req() r: UserRequest) {
return this.fx.refresh(r.userId);
}
+16 -2
View File
@@ -29,6 +29,19 @@ export const date = z
Number.isFinite(+d) && d.toISOString().slice(0, 10) === s && s >= '1900-01-01' && s <= today()
);
}, '日期无效或在未来');
export const businessDate = z.string().refine((s) => {
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return date.safeParse(s).success;
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(s)) return false;
const d = new Date(s + ':00+08:00');
return (
Number.isFinite(+d) &&
new Date(+d + 8 * 3600000).toISOString().slice(0, 16) === s &&
s >= '1900-01-01' &&
+d <= Date.now()
);
}, '业务时间无效或在未来(北京时间)');
export const toBusinessDate = (s: string) =>
new Date((s.length === 10 ? s + 'T00:00' : s) + ':00+08:00');
export const amount = z
.string()
.regex(/^(0|[1-9]\d{0,15})(\.\d{1,8})?$/, '金额须为非负十进制字符串,最多 16 位整数和 8 位小数');
@@ -40,7 +53,7 @@ export const notes = z.string().max(2000).default('');
export const revisionInput = z
.object({
amount,
date,
date: businessDate,
notes,
reason: z
.enum(['initial', 'balance', 'valuation', 'repayment', 'correction'])
@@ -53,6 +66,7 @@ export const positionMeta = z
category: z.string().trim().min(1).max(40),
notes,
archived: z.boolean().default(false),
hidden: z.boolean().default(false),
})
.strict();
export const positionInput = positionMeta
@@ -61,7 +75,7 @@ export const positionInput = positionMeta
side: z.enum(['asset', 'liability']),
currency,
amount,
date,
date: businessDate,
})
.strict()
.superRefine((p, c) => {
+171
View File
@@ -0,0 +1,171 @@
import { ZipArchive } from 'archiver';
import * as yauzl from 'yauzl';
import { createHash } from 'node:crypto';
import { BadRequestException } from '@nestjs/common';
import { z } from 'zod';
import type { Backup } from './backup';
export const MAX_UPLOAD_BYTES = 512 * 1024 * 1024;
const MAX_EXPANDED_BYTES = 1024 * 1024 * 1024;
const files = [
'settings.json',
'currencies.json',
'accounts.json',
'assets.json',
'debts.json',
'history.json',
'links.json',
'rates.json',
] as const;
const sha = (s: Buffer | string) => createHash('sha256').update(s).digest('hex');
export function packBackup(b: Backup) {
const metadata = b.positions.map(({ revisions, ...p }) => p);
const data: Record<string, unknown> = {
'settings.json': { baseCurrency: b.baseCurrency, preferences: b.preferences },
'currencies.json': b.currencies,
'accounts.json': metadata.filter((p) => p.kind === 'account'),
'assets.json': metadata.filter((p) => p.kind === 'asset'),
'debts.json': metadata.filter((p) => p.kind === 'debt'),
'history.json': b.positions.flatMap((p) =>
p.revisions.map((r) => ({ ...r, positionId: p.id })),
),
'links.json': b.links,
'rates.json': b.rates,
};
const contents = Object.fromEntries(
files.map((name) => [name, JSON.stringify(data[name], null, 2)]),
);
contents['manifest.json'] = JSON.stringify(
{
format: 'worthpath',
version: 3,
exportedAt: b.exportedAt,
files: files.map((name) => ({ name, sha256: sha(contents[name]) })),
},
null,
2,
);
return contents;
}
export function archiveBackup(b: Backup) {
const archive = new ZipArchive({ zlib: { level: 6 } });
for (const [name, contents] of Object.entries(packBackup(b))) archive.append(contents, { name });
return archive;
}
export async function readBackupZip(input: string | Buffer): Promise<unknown> {
const zip = await new Promise<yauzl.ZipFile>((resolve, reject) => {
const callback = (err: Error | null, value?: yauzl.ZipFile) =>
err || !value ? reject(err || Error()) : resolve(value);
const options = { lazyEntries: true, validateEntrySizes: true, strictFileNames: true };
if (typeof input === 'string') yauzl.open(input, options, callback);
else yauzl.fromBuffer(input, options, callback);
}).catch(() => {
throw new BadRequestException('ZIP 文件无效或已损坏');
});
try {
const contents = await new Promise<Map<string, Buffer>>((resolve, reject) => {
const result = new Map<string, Buffer>();
let expanded = 0;
zip.on('error', reject);
zip.on('end', () => resolve(result));
zip.on('entry', (entry: yauzl.Entry) => {
if (
![...files, 'manifest.json'].includes(entry.fileName as any) ||
result.has(entry.fileName) ||
entry.isEncrypted()
) {
reject(Error());
zip.close();
return;
}
expanded += entry.uncompressedSize;
if (expanded > MAX_EXPANDED_BYTES) {
reject(Error('size'));
zip.close();
return;
}
zip.openReadStream(entry, (err, stream) => {
if (err || !stream) {
reject(err || Error());
zip.close();
return;
}
const chunks: Buffer[] = [];
let size = 0;
stream.on('error', reject);
stream.on('data', (chunk: Buffer) => {
size += chunk.length;
if (size > entry.uncompressedSize) {
stream.destroy(Error());
} else chunks.push(chunk);
});
stream.on('end', () => {
result.set(entry.fileName, Buffer.concat(chunks));
zip.readEntry();
});
});
});
zip.readEntry();
});
if (contents.size !== files.length + 1) throw Error();
const parse = (name: string) =>
JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(contents.get(name)!));
const manifest = z
.object({
format: z.literal('worthpath'),
version: z.literal(3),
exportedAt: z.iso.datetime(),
files: z
.array(
z.object({ name: z.enum(files), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict(),
)
.length(files.length),
})
.strict()
.parse(parse('manifest.json'));
if (new Set(manifest.files.map((f) => f.name)).size !== files.length) throw Error();
for (const f of manifest.files) if (sha(contents.get(f.name)!) !== f.sha256) throw Error();
const settings = z
.object({
baseCurrency: z.string(),
preferences: z
.object({ showSidebar: z.boolean(), idleMinutes: z.number().int().min(0).max(1440) })
.strict()
.optional(),
})
.strict()
.parse(parse('settings.json'));
const positions = (['accounts.json', 'assets.json', 'debts.json'] as const).flatMap(
(name, index) => {
const rows = z.array(z.record(z.string(), z.unknown())).parse(parse(name));
if (rows.some((p) => p.kind !== ['account', 'asset', 'debt'][index] || 'revisions' in p))
throw Error();
return rows;
},
);
const histories = z.array(z.record(z.string(), z.unknown())).parse(parse('history.json'));
const ids = new Set(positions.map((p) => p.id));
const grouped = new Map<unknown, unknown[]>();
for (const { positionId, ...r } of histories) {
if (!ids.has(positionId)) throw Error();
const list = grouped.get(positionId) || [];
list.push(r);
grouped.set(positionId, list);
}
return {
format: 'worthpath',
version: 2,
exportedAt: manifest.exportedAt,
...settings,
currencies: parse('currencies.json'),
positions: positions.map((p) => ({ ...p, revisions: grouped.get(p.id) || [] })),
links: parse('links.json'),
rates: parse('rates.json'),
};
} catch {
throw new BadRequestException(
'备份 ZIP 无效:请检查 JSON 文件、完整性及文件大小(上传 512 MB,解压总计 1 GB;不限制记录条数)',
);
} finally {
zip.close();
}
}
+12 -1
View File
@@ -2,7 +2,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { history, overview, totals, type Holding, type Rate } from '../src/calculation';
import { positionInput, date, amount } from '../src/validation';
import { positionInput, date, amount, businessDate, toBusinessDate } from '../src/validation';
import { validateBackup } from '../src/backup';
import { RatesService } from '../src/rates';
import { Database } from '../src/database';
@@ -34,6 +34,15 @@ test('decimal totals and liability sign', () => {
b.revisions[0].amount = '0.2';
assert.equal(totals([a, b], [], 'CNY', '2026-09-01').net, '99.90');
});
test('minute history uses Hong Kong day boundaries and rejects invalid local times', () => {
const a = p();
a.revisions = [{ ...rev('8', '2026-09-01'), effectiveDate: toBusinessDate('2026-09-02T00:01') }];
assert.equal(history(a)[0].time, '2026-09-02T00:01');
assert.equal(totals([a], [], 'CNY', '2026-09-01').net, '0.00');
assert.equal(totals([a], [], 'CNY', '2026-09-02').net, '8.00');
assert.equal(businessDate.safeParse('2026-02-30T09:17').success, false);
assert.equal(businessDate.safeParse('2026-09-01T25:17').success, false);
});
test('missing FX explicitly incomplete', () => {
const v = totals([p('asset', 'USD')], [], 'CNY', '2026-09-01');
assert.equal(v.complete, false);
@@ -118,6 +127,8 @@ test('public FX uses a fixed request, preserves decimal tokens, manual rates and
position: { findMany: async () => [{ currency: 'USD' }] },
$transaction: async (fn: any) =>
fn({
user: { findUniqueOrThrow: async () => ({ baseCurrency: 'CNY' }) },
position: { findMany: async () => [{ currency: 'USD' }] },
exchangeRate: {
findUnique: async () => (manual ? { source: 'manual' } : null),
upsert: async (v: any) => writes.push(v.create),
+19 -13
View File
@@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
import { randomBytes, randomUUID } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import { today } from '../src/validation';
import { readBackupZip } from '../src/zip';
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api',
origin = process.env.WEB_ORIGIN!;
test('real MySQL: authentication, isolation, history, backup and atomic failures', async () => {
@@ -21,7 +22,9 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
});
return {
status: res.status,
data: await res.json(),
data: res.headers.get('content-type')?.includes('application/zip')
? ((await readBackupZip(Buffer.from(await res.arrayBuffer()))) as any)
: await res.json(),
cookie: res.headers.get('set-cookie')?.split(';')[0] || '',
};
}
@@ -137,19 +140,22 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
a.cookie,
)
).status,
200,
);
assert.equal(
(
await call(
'/rates',
'PUT',
{ currency: 'USD', baseCurrency: 'CNY', rate: '7', date: today() },
a.cookie,
)
).status,
200,
404,
);
const owner = await db.user.findUniqueOrThrow({ where: { username: a.username } });
for (const businessDay of ['2026-09-01', today()]) {
const key = {
userId: owner.id,
currency: 'USD',
baseCurrency: 'CNY',
date: new Date(businessDay),
};
await db.exchangeRate.upsert({
where: { userId_currency_baseCurrency_date: key },
create: { ...key, rate: '7', source: 'manual' },
update: { rate: '7', source: 'manual' },
});
}
let o = (await call('/overview', 'GET', undefined, a.cookie)).data;
assert.equal(o.complete, true);
assert.equal(o.net, '550.10');
+272
View File
@@ -0,0 +1,272 @@
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomBytes, randomUUID, createHash } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import { readBackupZip } from '../src/zip';
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
test('privacy, minute history, backup-gated clear and idle sessions remain user scoped', async () => {
const db = new PrismaClient(),
names: string[] = [];
async function call(path: string, method = 'GET', data?: unknown, cookie = '') {
const res = await fetch(base + path, {
method,
headers: {
Origin: process.env.WEB_ORIGIN!,
Cookie: cookie,
...(data ? { 'Content-Type': 'application/json' } : {}),
},
body: data ? JSON.stringify(data) : undefined,
});
return {
status: res.status,
data: res.headers.get('content-type')?.includes('application/zip')
? ((await readBackupZip(Buffer.from(await res.arrayBuffer()))) as any)
: await res.json(),
cookie: res.headers.get('set-cookie')?.split(';')[0] || '',
};
}
async function account() {
const username = 'wp_privacy_' + randomUUID(),
password = randomBytes(18).toString('hex');
names.push(username);
const r = await call('/auth/register', 'POST', { username, password });
assert.equal(r.status, 201);
const u = await db.user.findUniqueOrThrow({ where: { username } });
return { ...r, username, password, id: u.id };
}
const sessionId = (cookie: string) =>
createHash('sha256').update(cookie.split('=')[1]).digest('hex');
try {
const a = await account(),
b = await account();
async function position(cookie: string, hidden: boolean, amount: string) {
const r = await call(
'/positions',
'POST',
{
kind: 'account',
side: 'asset',
category: 'bank',
name: 'Temporary privacy acceptance',
currency: 'CNY',
amount,
date: '2026-09-01T09:17',
hidden,
},
cookie,
);
assert.equal(r.status, 201);
return r.data.id as string;
}
const visible = await position(a.cookie, false, '20'),
hidden = await position(a.cookie, true, '80');
await position(b.cookie, false, '7');
assert.equal((await call('/overview', 'GET', undefined, a.cookie)).data.net, '20.00');
assert.equal((await call('/positions', 'GET', undefined, a.cookie)).data.length, 1);
assert.equal((await call('/positions/' + hidden, 'GET', undefined, a.cookie)).status, 404);
assert.equal(
(
await call(
'/positions/' + hidden + '/revisions',
'POST',
{ amount: '90', date: '2026-09-01T09:18' },
a.cookie,
)
).status,
404,
);
assert.equal(
(await call('/auth/reveal', 'POST', { password: b.password }, a.cookie)).status,
403,
);
assert.equal(
(await call('/auth/reveal', 'POST', { password: a.password, userId: b.id }, a.cookie)).status,
400,
);
assert.equal(
(await call('/auth/reveal', 'POST', { password: a.password }, a.cookie)).status,
201,
);
assert.equal((await call('/overview', 'GET', undefined, a.cookie)).data.net, '100.00');
assert.equal((await call('/overview', 'GET', undefined, b.cookie)).data.net, '7.00');
const otherSession = await call('/auth/login', 'POST', {
username: a.username,
password: a.password,
});
assert.equal(
(await call('/overview', 'GET', undefined, otherSession.cookie)).data.net,
'20.00',
);
assert.equal(
(
await call(
'/positions/' + hidden + '/revisions',
'POST',
{ amount: '95', date: '2026-09-01T09:18' },
a.cookie,
)
).status,
201,
);
const history = (await call('/positions/' + hidden, 'GET', undefined, a.cookie)).data.history;
assert.deepEqual(
history.map((h: { time: string }) => h.time),
['2026-09-01T09:17', '2026-09-01T09:18'],
);
assert.equal(history[1].delta, '15');
const backup = (await call('/backup', 'GET', undefined, a.cookie)).data;
assert.equal(backup.version, 2);
assert.equal(backup.positions.find((p: { id: string }) => p.id === hidden).hidden, true);
assert.equal(
backup.positions.find((p: { id: string }) => p.id === hidden).revisions[0].date,
'2026-09-01T09:17',
);
const c = await account();
const download = await fetch(base + '/backup', { headers: { Cookie: a.cookie } });
assert.match(download.headers.get('content-disposition')!, /\.zip/);
const bytes = await download.arrayBuffer();
async function upload(cookie: string, content: ArrayBuffer | string) {
const form = new FormData();
form.append('file', new Blob([content]), 'backup.zip');
const res = await fetch(base + '/backup/upload', {
method: 'POST',
headers: { Origin: process.env.WEB_ORIGIN!, Cookie: cookie },
body: form,
});
return { status: res.status, data: await res.json() };
}
assert.equal((await upload(c.cookie, 'invalid zip')).status, 400);
assert.equal(await db.position.count({ where: { userId: c.id } }), 0);
const uploaded = await upload(c.cookie, bytes);
assert.equal(uploaded.status, 201);
assert.equal(
(
await call(
'/backup/import-file',
'POST',
{ confirmed: true, token: uploaded.data.token },
b.cookie,
)
).status,
400,
);
assert.equal(
(
await call(
'/backup/import-file',
'POST',
{ confirmed: false, token: uploaded.data.token },
c.cookie,
)
).status,
400,
);
assert.equal(
(
await call(
'/backup/import-file',
'POST',
{ confirmed: true, token: uploaded.data.token },
c.cookie,
)
).status,
201,
);
assert.equal(
(
await call(
'/backup/import-file',
'POST',
{ confirmed: true, token: uploaded.data.token },
c.cookie,
)
).status,
400,
);
assert.equal((await call('/overview', 'GET', undefined, c.cookie)).data.net, '20.00');
assert.equal(
(await call('/backup/import', 'POST', { confirmed: true, backup }, b.cookie)).status,
201,
);
assert.equal((await call('/overview', 'GET', undefined, b.cookie)).data.net, '27.00');
await call('/auth/reveal', 'POST', { password: b.password }, b.cookie);
assert.equal((await call('/overview', 'GET', undefined, b.cookie)).data.net, '122.00');
await db.session.update({
where: { id: sessionId(a.cookie) },
data: { revealUntil: new Date(Date.now() - 1000) },
});
assert.equal((await call('/overview', 'GET', undefined, a.cookie)).data.net, '20.00');
assert.equal(
(await call('/backup/clear', 'POST', { confirmation: '确定清空' }, otherSession.cookie))
.status,
400,
);
assert.equal(
(await call('/backup/clear', 'POST', { confirmation: '清空' }, a.cookie)).status,
400,
);
await call(
'/positions/' + visible + '/revisions',
'POST',
{ amount: '21', date: '2026-09-01T10:12' },
a.cookie,
);
assert.equal(
(await call('/backup/clear', 'POST', { confirmation: '确定清空' }, a.cookie)).status,
409,
);
assert.equal(await db.position.count({ where: { userId: a.id } }), 2);
await call('/backup', 'GET', undefined, a.cookie);
assert.equal(
(await call('/backup/clear', 'POST', { confirmation: '确定清空', userId: b.id }, a.cookie))
.status,
400,
);
assert.equal(
(await call('/backup/clear', 'POST', { confirmation: '确定清空' }, a.cookie)).status,
201,
);
assert.equal(await db.position.count({ where: { userId: a.id } }), 0);
assert.equal(await db.exchangeRate.count({ where: { userId: a.id } }), 0);
assert.equal(await db.position.count({ where: { userId: b.id } }), 3);
assert.equal((await call('/auth/me', 'GET', undefined, a.cookie)).status, 200);
assert.equal(
(await call('/settings', 'PATCH', { showSidebar: false, idleMinutes: 1 }, a.cookie)).status,
200,
);
const prefs = (await call('/settings', 'GET', undefined, a.cookie)).data;
assert.equal(prefs.showSidebar, false);
assert.equal(prefs.idleMinutes, 1);
assert.equal((await call('/settings', 'PATCH', { idleMinutes: -1 }, a.cookie)).status, 400);
await db.session.update({
where: { id: sessionId(a.cookie) },
data: { lastActivity: new Date(Date.now() - 61000) },
});
assert.equal((await call('/auth/me', 'GET', undefined, a.cookie)).status, 401);
assert.equal((await call('/auth/activity', 'POST', undefined, a.cookie)).status, 401);
assert.equal((await call('/auth/me', 'GET', undefined, b.cookie)).status, 200);
const legacy = {
...backup,
version: 1,
positions: backup.positions.map((p: any) => {
const { hidden, ...rest } = p;
return {
...rest,
revisions: p.revisions.map((r: any) => ({ ...r, date: r.date.slice(0, 10) })),
};
}),
};
assert.equal((await call('/auth/me', 'GET', undefined, otherSession.cookie)).status, 200);
const login = await call('/auth/login', 'POST', { username: a.username, password: a.password });
assert.equal(
(await call('/backup/import', 'POST', { confirmed: true, backup: legacy }, login.cookie))
.status,
201,
);
assert.equal((await call('/positions', 'GET', undefined, login.cookie)).data.length, 2);
} finally {
for (const username of names) await db.user.deleteMany({ where: { username } });
await db.$disconnect();
}
});
+95
View File
@@ -0,0 +1,95 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { ZipArchive } from 'archiver';
import { validateBackup } from '../src/backup';
import { packBackup, readBackupZip } from '../src/zip';
const empty = () =>
validateBackup({
format: 'worthpath',
version: 2,
exportedAt: new Date().toISOString(),
baseCurrency: 'CNY',
preferences: { showSidebar: false, idleMinutes: 9 },
currencies: ['CNY'],
positions: [],
rates: [],
links: [],
});
async function archive(contents: Record<string, string>) {
const zip = new ZipArchive({ zlib: { level: 1 } }),
chunks: Buffer[] = [];
const done = new Promise<Buffer>((resolve, reject) => {
zip.on('data', (chunk) => chunks.push(chunk));
zip.on('end', () => resolve(Buffer.concat(chunks)));
zip.on('error', reject);
});
for (const [name, data] of Object.entries(contents)) zip.append(data, { name });
await zip.finalize();
return done;
}
test('ZIP contains separate JSON files and restores settings without authentication data', async () => {
const b = empty(),
contents = packBackup(b);
assert.deepEqual(Object.keys(contents).sort(), [
'accounts.json',
'assets.json',
'currencies.json',
'debts.json',
'history.json',
'links.json',
'manifest.json',
'rates.json',
'settings.json',
]);
assert.doesNotMatch(JSON.stringify(contents), /password|token|session|userId/i);
assert.deepEqual(validateBackup(await readBackupZip(await archive(contents))), b);
});
test('ZIP rejects incomplete files, tampering, unknown entries and invalid entry names', async () => {
const contents = packBackup(empty());
await assert.rejects(async () =>
readBackupZip(await archive({ ...contents, 'settings.json': '{}' })),
);
const missing = { ...contents };
delete missing['history.json'];
await assert.rejects(async () => readBackupZip(await archive(missing)));
await assert.rejects(async () =>
readBackupZip(await archive({ ...contents, 'unexpected.json': '[]' })),
);
await assert.rejects(() => readBackupZip(Buffer.from('invalid zip')));
});
test('backup accepts over 1000 positions, 10000 revisions per position and 20000 total revisions', () => {
const stamp = new Date().toISOString();
const position = (count: number) => ({
id: randomUUID(),
name: 'count acceptance',
kind: 'asset',
side: 'asset',
category: 'other',
currency: 'CNY',
notes: '',
archived: false,
hidden: false,
createdAt: stamp,
updatedAt: stamp,
revisions: Array.from({ length: count }, (_, n) => ({
id: randomUUID(),
sequence: n + 1,
amount: '1',
date: '2026-09-01T09:17',
notes: '',
reason: 'valuation',
createdAt: stamp,
updatedAt: stamp,
})),
});
const b = validateBackup({
...empty(),
positions: [position(10001), ...Array.from({ length: 1000 }, () => position(11))],
});
assert.equal(b.positions.length, 1001);
assert.equal(
b.positions.reduce((n, p) => n + p.revisions.length, 0),
21001,
);
});
+310 -108
View File
@@ -23,6 +23,8 @@ import {
currencies,
money,
today,
nowMinute,
displayTime,
type User,
type Position,
type Overview,
@@ -214,7 +216,11 @@ export default function App() {
baseCurrency: string;
currentBaseCurrency: string;
} | null>(null);
const [clearStep, setClearStep] = useState(0);
const activityAt = useRef(0);
function clearAccount() {
setClearStep(0);
activityAt.current = 0;
sessionGeneration.current++;
loadGeneration.current++;
setUser(null);
@@ -253,11 +259,20 @@ export default function App() {
>('/settings'),
]);
if (session !== sessionGeneration.current || request !== loadGeneration.current) return;
setPositions(p);
if (o.revealed !== !!s.revealed) {
setPositions([]);
setOverview(null);
setSelected(null);
setModal(null);
void load();
return;
}
if (!activityAt.current && s.lastActivity) activityAt.current = +new Date(s.lastActivity);
setPositions(s.revealed ? p : p.filter((position) => !position.hidden));
setOverview(o);
setRates(s.rates);
setFxStatus(s.fxStatus);
setUser({ username: s.username, baseCurrency: s.baseCurrency });
setUser(s);
} catch (e) {
if (session === sessionGeneration.current && request === loadGeneration.current) report(e);
} finally {
@@ -281,12 +296,61 @@ export default function App() {
useEffect(() => {
window.scrollTo({ top: 0 });
}, [page, selected]);
useEffect(() => {
if (!user) return;
const session = sessionGeneration.current;
let lastSent = 0,
pending = false,
revealExpired = false;
const activity = () => {
if (!document.hidden) {
activityAt.current = Date.now();
if (Date.now() - lastSent > 15000 && !pending) {
pending = true;
lastSent = Date.now();
void api('/auth/activity', 'POST')
.catch((e) => {
if (session === sessionGeneration.current) report(e);
})
.finally(() => {
pending = false;
});
}
}
};
const check = window.setInterval(() => {
if (
user.idleMinutes &&
activityAt.current &&
Date.now() - activityAt.current >= user.idleMinutes * 60000
) {
clearAccount();
setError('长时间无操作,已自动退出登录');
void api('/auth/logout', 'POST').catch(() => {});
} else if (!revealExpired && user.revealUntil && Date.now() >= +new Date(user.revealUntil)) {
revealExpired = true;
setPositions([]);
setOverview(null);
setSelected(null);
setModal(null);
void load();
}
}, 1000);
const events = ['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart'];
events.forEach((e) => window.addEventListener(e, activity, { passive: true }));
return () => {
window.clearInterval(check);
events.forEach((e) => window.removeEventListener(e, activity));
};
}, [user?.idleMinutes, user?.revealUntil, !!user]);
async function act(work: () => Promise<unknown>, message: string) {
const session = sessionGeneration.current;
setBusy(true);
setError('');
setSuccess('');
try {
const result = await work();
if (session !== sessionGeneration.current) return;
setSuccess(
result &&
typeof result === 'object' &&
@@ -298,7 +362,7 @@ export default function App() {
setModal(null);
await load();
} catch (e) {
report(e);
if (session === sessionGeneration.current) report(e);
} finally {
setBusy(false);
}
@@ -318,6 +382,7 @@ export default function App() {
setPage('overview');
setSelected(null);
setModal(null);
activityAt.current = Date.now();
setUser(u);
await load();
} catch (e) {
@@ -449,46 +514,64 @@ export default function App() {
['settings', Settings],
] as const;
return (
<div className="app">
<aside className="sidebar">
<div className="brand">
<span>W</span> WorthPath
</div>
<p className="nav-caption">我的资产空间</p>
<nav>
{nav.map(([key, Icon]) => (
<button
key={key}
className={page === key ? 'active' : ''}
onClick={() => {
setPage(key);
setSelected(null);
}}
>
<Icon size={19} />
<span>{labels[key]}</span>
</button>
))}
</nav>
<div className="side-bottom">
<div className="avatar">{user.username.slice(0, 1).toUpperCase()}</div>
<div>
<strong>{user.username}</strong>
<small>个人账户 · {user.baseCurrency}</small>
<div className={'app' + (user.showSidebar === false ? ' sidebar-hidden' : '')}>
{user.showSidebar !== false && (
<aside className="sidebar">
<div className="brand">
<span>W</span> WorthPath
</div>
<button
className="icon"
aria-label="退出登录"
disabled={busy}
onClick={() => void logout()}
>
<LogOut size={18} />
</button>
</div>
</aside>
<p className="nav-caption">我的资产空间</p>
<nav>
{nav.map(([key, Icon]) => (
<button
key={key}
className={page === key ? 'active' : ''}
onClick={() => {
setPage(key);
setSelected(null);
}}
>
<Icon size={19} />
<span>{labels[key]}</span>
</button>
))}
</nav>
<div className="side-bottom">
<div className="avatar">{user.username.slice(0, 1).toUpperCase()}</div>
<div>
<strong>{user.username}</strong>
<small>个人账户 · {user.baseCurrency}</small>
</div>
<button
className="icon"
aria-label="退出登录"
disabled={busy}
onClick={() => void logout()}
>
<LogOut size={18} />
</button>
</div>
</aside>
)}
<main>
<header className="topbar">
<span>个人财务 / {labels[page]}</span>
{user.showSidebar === false && (
<select
aria-label="页面导航"
value={page}
onChange={(e) => {
setPage(e.target.value);
setSelected(null);
}}
>
{nav.map(([key]) => (
<option key={key} value={key}>
{labels[key]}
</option>
))}
</select>
)}
<div>
<span className="currency-pill">本位币 {user.baseCurrency}</span>
<button
@@ -502,6 +585,24 @@ export default function App() {
</div>
</header>
<div className="content">
<div className="privacy-toolbar">
<span>
{overview?.revealed
? '当前包含隐藏项目 · 验证 5 分钟后自动锁定'
: '当前不包含隐藏账户、资产和债务,净资产按此范围计算'}
</span>
<button
className="secondary"
disabled={busy}
onClick={() =>
overview?.revealed
? void act(() => api('/auth/lock', 'POST'), '隐藏项目已锁定')
: setModal({ kind: 'reveal' })
}
>
{overview?.revealed ? '锁定隐藏项目' : '显示隐藏资产(验证密码)'}
</button>
</div>
<div className="page-heading">
<div>
<p className="eyebrow">WORTHPATH / {today()}</p>
@@ -691,6 +792,7 @@ export default function App() {
{p.archived ? ' · 已归档' : ''}
</span>
<h2>{money(p.amount, p.currency)}</h2>
{p.hidden && <span className="badge">隐藏项目</span>}
<p className="muted">
本位币:
{overview?.items.find((i) => i.id === p.id)?.converted !== null
@@ -718,6 +820,7 @@ export default function App() {
category: p.category,
notes: p.notes,
archived: !p.archived,
hidden: p.hidden,
}),
p.archived ? '项目已恢复' : '项目已归档',
)
@@ -830,7 +933,7 @@ export default function App() {
<HistoryTable
rows={positions
.flatMap((p) => p.history)
.sort((a, b) => b.date.localeCompare(a.date) || b.sequence - a.sequence)}
.sort((a, b) => b.time.localeCompare(a.time) || b.sequence - a.sequence)}
open={(id) => {
setSelected(id);
setPage(positions.find((p) => p.id === id)?.kind || 'account');
@@ -857,11 +960,40 @@ export default function App() {
e.preventDefault();
const f = new FormData(e.currentTarget);
void act(
() => api('/settings', 'PATCH', { baseCurrency: f.get('baseCurrency') }),
'本位币已更新,请检查换算汇率',
() =>
api('/settings', 'PATCH', {
baseCurrency: f.get('baseCurrency'),
showSidebar: f.get('showSidebar') === 'true',
idleMinutes: Number(f.get('idleMinutes')),
}),
'个人设置已保存',
);
}}
>
<Field label="左侧菜单栏">
<select
name="showSidebar"
key={String(user.showSidebar)}
defaultValue={String(user.showSidebar)}
>
<option value="true">显示</option>
<option value="false">隐藏(顶部仍可切换页面)</option>
</select>
</Field>
<Field label="无操作自动退出(分钟)">
<input
name="idleMinutes"
type="number"
min="0"
max="1440"
required
defaultValue={user.idleMinutes}
key={user.idleMinutes}
/>
</Field>
<p className="muted">
0 为关闭;最长 1440 分钟。到时退出登录并清除页面数据,浏览器标签页保留。
</p>
<Field label="本位币">
<select
name="baseCurrency"
@@ -895,7 +1027,7 @@ export default function App() {
</p>
)}
<p className="muted">
服务器每小时检查当日是否已尝试更新。外部请求固定下载公共汇率表,不发送个人数据。失败保留已有汇率,可手动录入。
服务器每小时检查当日是否已尝试更新。外部请求固定下载公共汇率表,不发送个人数据。失败保留已有汇率,可稍后重试。
</p>
<button
className="secondary"
@@ -910,9 +1042,6 @@ export default function App() {
<RefreshCw size={16} />
立即更新
</button>
<button className="text" onClick={() => setModal({ kind: 'rate' })}>
手动录入 / 更正
</button>
<p className="muted">公共参考汇率可能与银行成交价不同;休市日日期可能滞后。</p>
</section>
</div>
@@ -937,30 +1066,85 @@ export default function App() {
</td>
<td>{r.rate}</td>
<td>{r.date.slice(0, 10)}</td>
<td>{r.source === 'manual' ? '手动' : 'Frankfurter'}</td>
<td>{r.source === 'manual' ? '历史导入' : 'Frankfurter'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<Empty
title="没有保存的外币汇率"
body="添加外币项目后更新,或手动录入带日期的汇率。"
/>
<Empty title="没有保存的外币汇率" body="添加外币项目后更新并保存自动汇率。" />
)}
</section>
<section className="panel">
<h2>清空本账号数据</h2>
<p className="muted">
清除本账号全部账户、资产、债务、历史和汇率(包括隐藏项目);保留登录账号及个人设置。请先下载备份并确认文件已保存。
</p>
{clearStep === 0 ? (
<a
className="secondary"
href="/api/backup"
download
onClick={() => setClearStep(1)}
>
<Download size={16} />
第一步:下载备份
</a>
) : clearStep === 1 ? (
<button
className="secondary"
disabled={busy}
onClick={async () => {
try {
const s = await api<{ ready: boolean }>('/backup/clear-status');
if (!s.ready) throw new Error('备份尚未完成,请重新下载');
setClearStep(2);
} catch (e) {
report(e);
}
}}
>
备份已保存,进入下一步
</button>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
const confirmation = new FormData(e.currentTarget).get('confirmation');
void act(async () => {
await api('/backup/clear', 'POST', { confirmation });
setClearStep(0);
setSelected(null);
setBackup(null);
setPreview(null);
}, '本账号数据已清空');
}}
>
<Field label="输入“确定清空”以确认">
<input name="confirmation" required pattern="确定清空" autoComplete="off" />
</Field>
<button className="danger" disabled={busy}>
清空本账号数据
</button>
</form>
)}
{clearStep > 0 && (
<button className="text" onClick={() => setClearStep(0)}>
取消清空 / 重新下载
</button>
)}
<hr />
<h2>数据备份与恢复</h2>
<p className="muted">
可读的版本化
JSON,包括项目、历史、关联、币种和汇率;不包含任何认证凭据。备份含个人财务信息,请妥善保管。
ZIP 内分文件保存可读
JSON,包括全部账户、资产、债务、历史、关联、币种、设置和汇率,不限制记录条数;不包含任何认证凭据。备份含个人财务信息,请妥善保管。
</p>
<div className="actions">
<a
className="secondary"
href="/api/backup"
download={'worthpath-' + today() + '.json'}
download={'worthpath-' + today() + '.zip'}
onClick={() => setSuccess('备份下载请求已发起,请检查浏览器下载列表')}
>
<Download size={16} />
@@ -971,7 +1155,7 @@ export default function App() {
选择备份并验证
<input
type="file"
accept=".json,application/json"
accept=".zip,application/zip,.json,application/json"
disabled={busy}
onChange={async (e) => {
setBackup(null);
@@ -982,15 +1166,20 @@ export default function App() {
const uploadSession = sessionGeneration.current;
setBusy(true);
try {
if (file.size > 8 * 1024 * 1024) throw new Error('文件不能超过 8 MB');
const b = JSON.parse(await file.text()),
result = await api<NonNullable<typeof preview>>(
'/backup/preview',
'POST',
b,
);
if (file.size > 512 * 1024 * 1024)
throw new Error('ZIP 文件不能超过 512 MB(不限制记录条数)');
const form = new FormData();
form.append('file', file);
const response = await fetch('/api/backup/upload', {
method: 'POST',
body: form,
credentials: 'same-origin',
});
const result = await response.json();
if (!response.ok)
throw new ApiError(result.message || 'ZIP 验证失败', response.status);
if (uploadSession !== sessionGeneration.current) return;
setBackup(b);
setBackup(result.token);
setPreview(result);
} catch (err) {
if (uploadSession === sessionGeneration.current) report(err);
@@ -1018,7 +1207,10 @@ export default function App() {
disabled={busy}
onClick={() =>
void act(async () => {
await api('/backup/import', 'POST', { confirmed: true, backup });
await api('/backup/import-file', 'POST', {
confirmed: true,
token: backup,
});
setPreview(null);
setBackup(null);
}, '备份已完整导入')
@@ -1046,14 +1238,14 @@ export default function App() {
{modal && (
<Modal
title={
modal.kind === 'edit'
? '编辑项目'
: modal.kind === 'revision'
? '更新余额 / 估值'
: modal.kind === 'correction'
? '更正历史记录'
: modal.kind === 'rate'
? '保存日汇率'
modal.kind === 'reveal'
? '验证密码以显示隐藏资产'
: modal.kind === 'edit'
? '编辑项目'
: modal.kind === 'revision'
? '更新余额 / 估值'
: modal.kind === 'correction'
? '更正历史记录'
: modal.kind === 'links'
? '管理债务关联'
: '新增' + labels[modal.kind]
@@ -1069,7 +1261,12 @@ export default function App() {
v = Object.fromEntries(f.entries()),
kind = modal.kind,
mp = modal.p;
if (kind === 'links') {
if (kind === 'reveal') {
void act(
() => api('/auth/reveal', 'POST', { password: f.get('password') }),
'隐藏项目已解锁 5 分钟',
);
} else if (kind === 'links') {
void act(
() =>
api('/positions/' + mp!.id + '/links', 'PUT', {
@@ -1077,8 +1274,6 @@ export default function App() {
}),
'关联已更新',
);
} else if (kind === 'rate') {
void act(() => api('/rates', 'PUT', v), '汇率已保存,同日已有汇率已更正');
} else if (kind === 'revision' || kind === 'correction') {
void act(
() =>
@@ -1091,7 +1286,12 @@ export default function App() {
);
} else if (kind === 'edit') {
void act(
() => api('/positions/' + mp!.id, 'PATCH', { ...v, archived: mp!.archived }),
() =>
api('/positions/' + mp!.id, 'PATCH', {
...v,
archived: mp!.archived,
hidden: f.get('hidden') === 'on',
}),
'项目资料已保存',
);
} else {
@@ -1101,12 +1301,36 @@ export default function App() {
(kind === 'account' && ['credit_card', 'loan'].includes(category))
? 'liability'
: 'asset';
void act(() => api('/positions', 'POST', { ...v, kind, side }), '项目已添加');
void act(
() =>
api('/positions', 'POST', {
...v,
kind,
side,
hidden: f.get('hidden') === 'on',
}),
'项目已添加',
);
}
}}
>
{modal.kind === 'reveal' && (
<Field label="登录密码">
<input
name="password"
type="password"
autoComplete="current-password"
required
maxLength={72}
/>
</Field>
)}
{['account', 'asset', 'debt', 'edit'].includes(modal.kind) && (
<>
<label className="check-line">
<input name="hidden" type="checkbox" defaultChecked={modal.p?.hidden} />
隐藏此项目(密码验证后可查看和编辑)
</label>
<Field label="名称">
<input
name="name"
@@ -1133,7 +1357,7 @@ export default function App() {
)}
</>
)}
{['account', 'asset', 'debt', 'rate'].includes(modal.kind) && (
{['account', 'asset', 'debt'].includes(modal.kind) && (
<Field label="原币币种">
<select name="currency" defaultValue={user.baseCurrency}>
{currencies.map((c) => (
@@ -1142,26 +1366,6 @@ export default function App() {
</select>
</Field>
)}
{modal.kind === 'rate' && (
<>
<Field label="换算到本位币">
<select name="baseCurrency" defaultValue={user.baseCurrency}>
{currencies.map((c) => (
<option key={c}>{c}</option>
))}
</select>
</Field>
<Field label="1 单位原币 = 多少本位币">
<input
name="rate"
inputMode="decimal"
required
pattern="(0|[1-9][0-9]{0,11})(\.[0-9]{1,12})?"
/>
</Field>
<p className="muted">保存将更正同币种、同日期的已有汇率,历史总额会重新计算。</p>
</>
)}
{['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && (
<Field label={'变更后的绝对金额' + (modal.p ? '(' + modal.p.currency + ')' : '')}>
<input
@@ -1174,17 +1378,15 @@ export default function App() {
/>
</Field>
)}
{['account', 'asset', 'debt', 'revision', 'correction', 'rate'].includes(
modal.kind,
) && (
<Field label="业务日期">
{['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && (
<Field label={'业务时间(北京时间,精确到分钟)'}>
<input
type="date"
type={'datetime-local'}
name="date"
required
min="1900-01-01"
max={today()}
defaultValue={modal.h?.date || today()}
min={'1900-01-01T00:00'}
max={nowMinute()}
defaultValue={modal.h?.time || nowMinute()}
/>
</Field>
)}
@@ -1233,7 +1435,7 @@ export default function App() {
)}
</div>
) : (
modal.kind !== 'rate' && (
modal.kind !== 'reveal' && (
<Field label="备注">
<textarea
name="notes"
@@ -1302,7 +1504,7 @@ function HistoryTable({
h.name
)}
<small>
{h.date} ·{' '}
{displayTime(h.time)} ·{' '}
{
(
{
+14 -1
View File
@@ -17,11 +17,20 @@ export async function api<T>(path: string, method = 'GET', data?: unknown): Prom
if (!res.ok) throw new ApiError(body.message || '操作失败', res.status);
return body;
}
export type User = { username: string; baseCurrency: string };
export type User = {
username: string;
baseCurrency: string;
showSidebar: boolean;
idleMinutes: number;
revealed?: boolean;
revealUntil?: string | null;
lastActivity?: string;
};
export type History = {
id: string;
sequence: number;
createdAt: string | null;
time: string;
positionId: string;
name: string;
kind: string;
@@ -43,6 +52,7 @@ export type Position = {
amount: string;
notes: string;
archived: boolean;
hidden: boolean;
history: History[];
outgoing: { targetId: string }[];
};
@@ -58,6 +68,7 @@ export type Total = {
};
export type Overview = Total & {
baseCurrency: string;
revealed: boolean;
trend: Total[];
recent: History[];
items: {
@@ -85,6 +96,8 @@ export function today() {
day: '2-digit',
}).format(new Date());
}
export const nowMinute = () => new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 16);
export const displayTime = (s: string) => s.replace('T', ' ');
export function money(v: string, currency: string) {
const [whole, frac] = v.split('.');
return `${currency} ${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}${frac !== undefined ? '.' + frac : ''}`;
+36
View File
@@ -234,6 +234,42 @@ strong {
display: flex;
min-height: 100vh;
}
.sidebar-hidden main {
margin-left: 0;
padding-bottom: 0;
width: 100%;
}
.privacy-toolbar {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
font-size: 13px;
color: #637b75;
}
.danger {
background: #ae3232;
color: white;
padding: 12px 18px;
border: 0;
border-radius: 10px;
cursor: pointer;
}
.check-line {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.check-line input {
width: auto;
}
.sidebar-hidden .topbar select {
max-width: 150px;
width: auto;
}
.sidebar {
width: 228px;
position: fixed;