feat: add privacy controls, minute history and ZIP backups
This commit is contained in:
1 parent
ba0d5201c9
commit
2a650853ee
21 files changed
+1729
-226
No files matched your search
@@ -29,12 +29,14 @@ pnpm db:status
|
||||
pnpm check:staged # git add 后,提交前检查本地凭证和禁止提交的文件
|
||||
```
|
||||
|
||||
当前功能:注册登录和退出、资产/负债账户、独立资产、独立债务和关联、业务日期余额历史与更正、本位币、每日参考汇率与手动汇率、净资产趋势和变化归因、用户 JSON 备份与事务追加恢复。
|
||||
当前功能:注册登录和退出、资产/负债账户、独立资产、独立债务和关联、分钟余额历史与更正、隐藏项目密码核验、本位币和自动日汇率、净资产趋势和变化归因、分文件 ZIP 备份与事务追加恢复、侧栏及无操作退出设置、安全清空本账号数据。
|
||||
|
||||
金额原币保留,金额/汇率使用十进制字符串和 MySQL Decimal。归档只停止金额编辑,仍参与统计;需要归零时先更新余额。每次金额更新新增独立历史,同日按记录顺序保留;每日趋势使用当日最后余额,已有错误记录可单独更正。币种与资产负债属性创建后固定。详情同时显示原币和本位币金额。
|
||||
|
||||
汇率使用 [Frankfurter 公共日汇率 API](https://frankfurter.dev/)。请求使用固定币种表,不发送用户选择、身份或金额。进程运行时每小时检查,每天尝试一次;添加项目、修改本位币会重新触发检查。失败保留原币和历史汇率,显示缺失或实际使用日期,可以手动重试。自动更新不会覆盖同日手动汇率。首次使用不会自动补齐早期历史汇率,缺失日期不绘制完整总额,可手动补录。
|
||||
汇率使用 [Frankfurter 公共日汇率 API](https://frankfurter.dev/)。请求使用固定币种表,不发送用户选择、身份或金额。进程运行时每小时检查,每天尝试一次;添加项目、修改本位币会重新触发检查。失败保留原币和历史汇率,显示缺失或实际使用日期,可以点击重试。自动更新保留已有同日历史导入汇率。首次使用不会自动补齐早期历史汇率,缺失日期不绘制完整总额;原币和已有汇率始终保留。
|
||||
|
||||
备份 v1 最多 8 MB / 1000 项目 / 20000 条历史,导入先预览、再明确确认。首版只追加新项目并重建关系,不覆盖已有项目;按项目 ID 和导入来源识别重复;同日汇率冲突会拒绝整次导入。已有本位币保留,空空间恢复备份本位币。文件包含财务数据,应由用户妥善保存。
|
||||
|
||||
设计见 [数据模型与结构](docs/architecture.md),已验证范围与后续工作见 [首版验收](docs/acceptance.md)。生产需要 HTTPS、COOKIE_SECURE=true、WEB_ORIGIN 为准确站点地址;前端构建由反向代理托管并代理 /api。API 生产启动:在 `apps/api` 中执行 `node dist/main.js`。生产认证限速需迁移到共享存储,数据库用户需最小权限,部署与运行监控尚未配置。
|
||||
|
||||
备份下载为 ZIP:manifest、settings、currencies、accounts、assets、debts、history、links、rates 各一个 JSON 文件,包含隐藏项目及全部历史,不含密码或会话凭据。导入先上传并预览,再确认追加;旧 JSON 备份继续兼容。不限制项目、历史、关联或汇率条数;上传文件最大 512 MB,ZIP 解压总计最大 1 GB,用于保护服务器资源。
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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 : ''}`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+15
-5
@@ -5,19 +5,29 @@
|
||||
已验证:
|
||||
|
||||
- 后端和前端类型检查、生产构建通过。
|
||||
- 9 项单元测试通过:Decimal 精度、负债符号、缺失汇率、历史更正、汇率/余额归因、大额金额、输入和备份校验、固定公共汇率请求与失败保护。
|
||||
- 13 项单元测试通过:Decimal 精度、负债符号、缺失汇率、历史更正、汇率/余额归因、大额金额、输入和备份校验、固定公共汇率请求与失败保护。
|
||||
- 真实 MySQL 集成测试通过:注册、密码哈希、登录退出、会话失效、同源写入限制、双用户资源与历史隔离、客户端 userId 拒绝、信用卡负债、债务关联不重复求和、余额变化、同日多次更新、更正、还款金额和负债类型校验、归档金额编辑限制。
|
||||
- 备份不含认证数据;预览验证、明确确认、恢复后 ID 重建与关联保留、重复导入拒绝、已导入项目修改后重复导入仍拒绝、两请求并发导入仅一次成功,失败没有留下部分项目。
|
||||
- 三次可追踪迁移已应用,数据库结构处于最新状态,未运行 reset/db push。
|
||||
- 浏览器验收:注册、空状态、账户创建、同日连续余额更新、外币资产、独立债务与关联、手动历史汇率、总览趋势及变化归因、手机退出登录。编辑过程中撤销临时会话后,服务端拒绝保存,界面清除旧弹窗和草稿;重新登录后未恢复旧草稿,数据库原名称不变。
|
||||
- 浏览器真实下载 JSON 备份,选择重复文件显示拒绝说明;另一临时用户中预览并明确确认恢复成功。
|
||||
- 四次可追踪迁移已应用,数据库结构处于最新状态,未运行 reset/db push。
|
||||
- 浏览器验收:注册、空状态、账户创建、同日连续余额更新、外币资产、独立债务与关联、历史汇率、总览趋势及变化归因、手机退出登录。编辑过程中撤销临时会话后,服务端拒绝保存,界面清除旧弹窗和草稿;重新登录后未恢复旧草稿,数据库原名称不变。
|
||||
- 浏览器真实下载版本化备份,选择重复文件显示拒绝说明;另一临时用户中预览并明确确认恢复成功。
|
||||
- 公共汇率实际请求成功并保存日期和十进制汇率,原币未改变。
|
||||
- 390×844 手机、742px 中间视口、1280×800 桌面布局检查;手机和桌面未出现页面横向溢出,宽历史表格单独滚动。
|
||||
- 临时测试用户及其项目已清理,网站没有预置演示资产。验收截图使用明确标为“验收临时”的项目。验收期间出现的非测试用户数据已保留,清理仅针对明确创建的临时用户。
|
||||
- 每次提交前扫描本地环境中的凭证与连接元数据,环境文件、数据库文件、依赖和构建产物未纳入提交。
|
||||
|
||||
首版限制:每次更新保留独立绝对余额记录,同日多次更新与备份恢复顺序已验证;更正替换该日记录,暂未另存更正操作的审计副本。归档项目仍计入财富,需要结清时先更新为零。首版支持十种常见币种;汇率为参考价,首次使用不会自动抓取全量历史。历史汇率缺失时该日期标记不完整,用户可手动补录。备份仅追加,不能直接覆盖恢复到已有空间。列表暂不分页;适合个人规模数据。
|
||||
首版限制:每次更新保留独立绝对余额记录,同日多次更新与备份恢复顺序已验证;更正替换该日记录,暂未另存更正操作的审计副本。归档项目仍计入财富,需要结清时先更新为零。首版支持十种常见币种;汇率为参考价,首次使用不会自动抓取全量历史。历史汇率缺失时该日期标记不完整,保留原币并提示换算不完整。备份仅追加,不能直接覆盖恢复到已有空间。列表暂不分页;适合个人规模数据。
|
||||
|
||||
尚未验证或未交付:连续多日无人值守运行、生产 HTTPS 与反向代理部署、多实例共享认证限速、运行告警、真实手机浏览器与触摸设备矩阵、大规模数据性能、密码找回/变更、家庭共享。开发服务在本机可启动,当前交付不等同于生产上线。
|
||||
|
||||
下一步建议:基于真实少量资产试用,完善筛选和分页、键盘焦点管理及历史更正审计;随后建立最小权限数据库账号、HTTPS 部署和共享限速,进行实际设备验收。家庭共享与报表作为后续独立模块。
|
||||
|
||||
## 功能更新验收
|
||||
|
||||
- 临时账号集成测试验证隐藏账户默认不进入列表、详情及总额;密码错误拒绝,另一会话和其他用户不继承查看权限,授权过期重新排除。
|
||||
- 分钟业务时间和变更额、v2 隐藏状态备份恢复及 v1 旧格式兼容通过。
|
||||
- 未下载备份、错误确认文字、伪造 userId、备份后数据变化均拒绝清空;成功仅删除当前临时用户数据,其他用户保留,当前用户仍可登录。
|
||||
- 用户侧栏和定时退出设置持久化,非法分钟数拒绝;过期会话及活动续期请求均被拒绝。
|
||||
- 浏览器已验证隐藏账户、密码解锁、分钟展示、侧栏隐藏后的手机导航、真实备份下载及清空下一步、一分钟无操作退出;未对实际用户执行清空操作。
|
||||
- ZIP 分文件内容及摘要校验、损坏/缺失/未知文件拒绝、真实上传与当前会话令牌隔离、确认后恢复已通过。超过 1000 个项目、单项目 10000 条历史、总计 20000 条历史的校验通过。未做 512 MB 边界和超大规模恢复压力测试。
|
||||
- 手动汇率写入 API 返回 404,界面入口已移除;已有历史汇率未删除。
|
||||
@@ -10,12 +10,18 @@ Revision 保存按业务日期生效的绝对金额,每次金额更新新增
|
||||
|
||||
认证采用 bcrypt 密码哈希和 HttpOnly 随机会话 Cookie;数据库只保存会话令牌 SHA-256 摘要。所有资源查询由会话用户范围限定。写入要求同源 Origin,登录限速;生产必须 HTTPS 并启用安全 Cookie。
|
||||
|
||||
备份采用 version=1 JSON,包含项目、历史、关系、币种、本位币、汇率和导入来源,不包含用户认证数据。首版导入只追加完整的新项目并重映射 ID;项目 ID 和 importedFromId 识别重复,用户 + importedFromId 有唯一索引,项目修改后仍拒绝原备份重复导入;不同 ID 的同名项目允许共存。汇率冲突拒绝;已有本位币不自动更改,空空间恢复备份本位币。完整验证后以 Serializable 事务写入,不修改已有项目。总览和导出使用数据库事务读取一致的数据视图。
|
||||
备份采用 version=3 ZIP(多个可读 JSON 文件),包含项目、历史、关系、币种、本位币、汇率和导入来源,不包含用户认证数据。首版导入只追加完整的新项目并重映射 ID;项目 ID 和 importedFromId 识别重复,用户 + importedFromId 有唯一索引,项目修改后仍拒绝原备份重复导入;不同 ID 的同名项目允许共存。汇率冲突拒绝;已有本位币不自动更改,空空间恢复备份本位币。完整验证后以 Serializable 事务写入,不修改已有项目。总览和导出使用数据库事务读取一致的数据视图。
|
||||
|
||||
页面:注册登录、总览、账户/资产/债务列表及详情与编辑、历史、本位币/汇率设置、备份与导入。空数据无演示金额。
|
||||
|
||||
数据库使用 Prisma 可追踪 SQL 迁移,部署仅 migrate deploy,禁止 db push/reset。扩展家庭共享时可以引入空间和成员权限,现阶段严格按用户隔离。
|
||||
|
||||
代码边界:`auth.ts` 负责身份与 Cookie;`portfolio.ts` 负责持有项目、历史和关系;`calculation.ts` 是无数据库依赖的十进制计算;`rates.ts` 负责公共汇率获取、失败状态和手动设置;`backup.ts` 负责格式验证与原子恢复;`database.ts` 管理数据库生命周期;`validation.ts` 集中定义输入约束。前端 `api.ts` 定义服务访问及数据类型,`App.tsx` 组合各功能流程,共用金额编辑和历史展示,CSS 定义桌面侧栏及手机底栏布局。
|
||||
代码边界:`auth.ts` 负责身份与 Cookie;`portfolio.ts` 负责持有项目、历史和关系;`calculation.ts` 是无数据库依赖的十进制计算;`rates.ts` 负责公共汇率获取、失败状态和个人设置;`backup.ts` 负责格式验证与原子恢复;`database.ts` 管理数据库生命周期;`validation.ts` 集中定义输入约束。前端 `api.ts` 定义服务访问及数据类型,`App.tsx` 组合各功能流程,共用金额编辑和历史展示,CSS 定义桌面侧栏及手机底栏布局。
|
||||
|
||||
时间:业务日期按香港时区的当日边界验证,数据库 DATE 保存业务日期;创建和更新时间使用 UTC 时间戳。汇率定时检查在 API 进程内执行;更新尝试状态和认证限速当前在内存中,重启后重新初始化。多实例部署时需改为共享限速和独立调度任务。
|
||||
时间:业务时间按北京时间精确到分钟,数据库 DATETIME(3) 保存对应 UTC 时间,显示按 Asia/Hong_Kong;旧日期记录迁移为原业务日的 00:00,不推测实际操作时刻。汇率仍使用 DATE。历史按时间及 sequence 排序,趋势按业务日汇总当日最后余额。创建和更新时间使用 UTC 时间戳。汇率定时检查在 API 进程内执行;更新尝试状态和认证限速当前在内存中,重启后重新初始化。多实例部署时需改为共享限速和独立调度任务。
|
||||
|
||||
隐私及设置:Position.hidden 对账户、资产和债务统一生效。默认列表、详情、历史及总览均在服务端过滤隐藏项目,净资产按显示范围计算。登录密码核验成功后仅当前 Session 获得 5 分钟查看权限,到期前端清除敏感视图,服务端每次请求检查授权期限;手动锁定和退出可立即撤销。备份始终包含该用户全部项目(包括隐藏项目)。showSidebar 和 idleMinutes 为用户设置,默认显示侧栏、30 分钟无操作退出;0 关闭定时退出,最大 1440 分钟。真实交互触发节流的活动请求,后台加载不延长会话;服务端检查 lastActivity,前端计时器退出登录并清除页面数据,保留浏览器标签页。侧栏隐藏后顶部导航保留设置入口。
|
||||
|
||||
安全清空:先通过认证下载备份,在当前 Session 记录备份内容摘要及 10 分钟有效期。下一步必须输入精确短语“确定清空”。Serializable 事务中重新校验当前数据摘要;变化后必须重新下载。清空仅删除当前用户的项目(级联历史/关联)及汇率,保留登录身份和个人设置,撤销该用户所有会话的查看及备份确认状态。汇率请求返回后再核对当前币种,避免清空期间正在执行的网络请求重建旧汇率。
|
||||
|
||||
备份现使用 version=3 ZIP:manifest.json 保存格式版本、导出时间及各数据文件 SHA-256;settings/currencies/accounts/assets/debts/history/links/rates 分别保存完整数据。业务时间与 hidden 保留。旧版 v1/v2 JSON 文件仍可上传,v1 缺少 hidden 时视为未隐藏。恢复仍只追加,按业务时间和 sequence 重建顺序;空空间恢复本位币和界面/退出偏好。取消项目数、历史数、关联数和汇率数上限;文件上传最多 512 MB,ZIP 解压总计 1 GB,拒绝未知/重复路径、缺失文件、加密 ZIP、摘要不符和格式错误,不向文件系统解压。预览文件暂存在系统临时目录,15 分钟有效,确认导入令牌绑定当前用户及会话,导入/失败/过期后清理。数据库事务最长 5 分钟以容纳较大恢复。手动汇率入口和写入 API 已删除,既有历史汇率保留。
|
||||
Generated
+422
@@ -26,6 +26,9 @@ importers:
|
||||
'@prisma/client':
|
||||
specifier: 6.19.0
|
||||
version: 6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3)
|
||||
archiver:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
bcryptjs:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.3
|
||||
@@ -44,25 +47,40 @@ importers:
|
||||
helmet:
|
||||
specifier: ^8.1.0
|
||||
version: 8.3.0
|
||||
multer:
|
||||
specifier: ^2.4.0
|
||||
version: 2.4.0
|
||||
reflect-metadata:
|
||||
specifier: ^0.2.2
|
||||
version: 0.2.2
|
||||
rxjs:
|
||||
specifier: ^7.8.2
|
||||
version: 7.8.2
|
||||
yauzl:
|
||||
specifier: ^3.4.0
|
||||
version: 3.4.0
|
||||
zod:
|
||||
specifier: ^4.1.0
|
||||
version: 4.6.5
|
||||
devDependencies:
|
||||
'@types/archiver':
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
'@types/cookie-parser':
|
||||
specifier: ^1.4.9
|
||||
version: 1.4.10(@types/express@5.0.6)
|
||||
'@types/express':
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.6
|
||||
'@types/multer':
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0
|
||||
'@types/node':
|
||||
specifier: ^24.0.0
|
||||
version: 24.19.0
|
||||
'@types/yauzl':
|
||||
specifier: ^3.4.0
|
||||
version: 3.4.0
|
||||
mysql2:
|
||||
specifier: ^3.15.0
|
||||
version: 3.24.5(@types/node@24.19.0)
|
||||
@@ -593,6 +611,9 @@ packages:
|
||||
'@tokenizer/token@0.3.0':
|
||||
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
|
||||
|
||||
'@types/archiver@8.0.0':
|
||||
resolution: {integrity: sha512-YpXPbEuv9+eUIPPQWUPahj3cvs9isWRuF+J4z+KbdYVDO3rWorWQFxUVHnwPu2AgKwvgpki5F2VMX0Xx+mX45A==}
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||
|
||||
@@ -628,6 +649,9 @@ packages:
|
||||
'@types/http-errors@2.0.5':
|
||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
||||
|
||||
'@types/multer@2.3.0':
|
||||
resolution: {integrity: sha512-i7STIm9V4K2MPH4ZYMtrZAwNxs3kglk2LgleaTuB9pUXgADBcQYQuYMd7+6xgTIxfoggIkFA/DkkvycdZpIARA==}
|
||||
|
||||
'@types/node@24.19.0':
|
||||
resolution: {integrity: sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==}
|
||||
|
||||
@@ -645,18 +669,28 @@ packages:
|
||||
'@types/react@19.3.0':
|
||||
resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
|
||||
|
||||
'@types/readdir-glob@1.1.5':
|
||||
resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==}
|
||||
|
||||
'@types/send@1.2.1':
|
||||
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
|
||||
|
||||
'@types/serve-static@2.2.0':
|
||||
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
|
||||
|
||||
'@types/yauzl@3.4.0':
|
||||
resolution: {integrity: sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==}
|
||||
|
||||
'@vitejs/plugin-react@5.2.0':
|
||||
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
peerDependencies:
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
|
||||
abort-controller@3.0.0:
|
||||
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||
engines: {node: '>=6.5'}
|
||||
|
||||
accepts@2.0.0:
|
||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -664,10 +698,69 @@ packages:
|
||||
append-field@1.0.0:
|
||||
resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
|
||||
|
||||
archiver@8.0.0:
|
||||
resolution: {integrity: sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
async@3.2.6:
|
||||
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
|
||||
|
||||
aws-ssl-profiles@1.1.2:
|
||||
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
|
||||
b4a@1.9.0:
|
||||
resolution: {integrity: sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==}
|
||||
peerDependencies:
|
||||
react-native-b4a: '*'
|
||||
peerDependenciesMeta:
|
||||
react-native-b4a:
|
||||
optional: true
|
||||
|
||||
balanced-match@4.0.4:
|
||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
bare-events@2.9.2:
|
||||
resolution: {integrity: sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==}
|
||||
peerDependencies:
|
||||
bare-abort-controller: '*'
|
||||
peerDependenciesMeta:
|
||||
bare-abort-controller:
|
||||
optional: true
|
||||
|
||||
bare-fs@4.8.2:
|
||||
resolution: {integrity: sha512-+ZI68KHMUvosXfKbg/UOHK0tbCdRnegbvPEdEcZ3Nd6TetieQsJPRXBRXPdLyy8+3VSEbPXtsumTpEtt78xv9w==}
|
||||
engines: {bare: '>=1.28.0'}
|
||||
peerDependencies:
|
||||
bare-buffer: '*'
|
||||
peerDependenciesMeta:
|
||||
bare-buffer:
|
||||
optional: true
|
||||
|
||||
bare-path@3.1.2:
|
||||
resolution: {integrity: sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==}
|
||||
|
||||
bare-stream@2.13.4:
|
||||
resolution: {integrity: sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==}
|
||||
peerDependencies:
|
||||
bare-abort-controller: '*'
|
||||
bare-buffer: '*'
|
||||
bare-events: '*'
|
||||
peerDependenciesMeta:
|
||||
bare-abort-controller:
|
||||
optional: true
|
||||
bare-buffer:
|
||||
optional: true
|
||||
bare-events:
|
||||
optional: true
|
||||
|
||||
bare-url@2.5.4:
|
||||
resolution: {integrity: sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==}
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
baseline-browser-mapping@2.11.26:
|
||||
resolution: {integrity: sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
@@ -681,11 +774,22 @@ packages:
|
||||
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
brace-expansion@5.0.12:
|
||||
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
browserslist@4.29.3:
|
||||
resolution: {integrity: sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==}
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
buffer-crc32@1.0.0:
|
||||
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
buffer@6.0.3:
|
||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||
|
||||
busboy@1.6.0:
|
||||
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
|
||||
engines: {node: '>=10.16.0'}
|
||||
@@ -723,6 +827,10 @@ packages:
|
||||
citty@0.2.2:
|
||||
resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==}
|
||||
|
||||
compress-commons@7.0.1:
|
||||
resolution: {integrity: sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
confbox@0.2.4:
|
||||
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
|
||||
|
||||
@@ -763,10 +871,22 @@ packages:
|
||||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
cors@2.8.6:
|
||||
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
crc-32@1.2.2:
|
||||
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
|
||||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
|
||||
crc32-stream@7.0.1:
|
||||
resolution: {integrity: sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
@@ -853,6 +973,17 @@ packages:
|
||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
event-target-shim@5.0.1:
|
||||
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
events-universal@1.0.1:
|
||||
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
|
||||
|
||||
events@3.3.0:
|
||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
|
||||
express@5.1.0:
|
||||
resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==}
|
||||
engines: {node: '>= 18'}
|
||||
@@ -868,6 +999,9 @@ packages:
|
||||
resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
fast-fifo@1.3.2:
|
||||
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
|
||||
|
||||
fast-safe-stringify@2.1.1:
|
||||
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
|
||||
|
||||
@@ -963,6 +1097,13 @@ packages:
|
||||
is-property@1.0.2:
|
||||
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
|
||||
|
||||
is-stream@4.0.1:
|
||||
resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
isarray@1.0.0:
|
||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||
|
||||
iterare@1.2.1:
|
||||
resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -984,6 +1125,10 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
lazystream@1.0.1:
|
||||
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
|
||||
engines: {node: '>= 0.6.3'}
|
||||
|
||||
load-esm@1.0.3:
|
||||
resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==}
|
||||
engines: {node: '>=13.2.0'}
|
||||
@@ -1035,6 +1180,10 @@ packages:
|
||||
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
minimatch@10.2.6:
|
||||
resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -1068,6 +1217,10 @@ packages:
|
||||
resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
normalize-path@3.0.0:
|
||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
nypm@0.6.10:
|
||||
resolution: {integrity: sha512-W72Hrj1petq+b3Hk2aAC+9zswetlIFTnDW4s5djseZh2nYqBbyQLOtj472HwcbcWykPBUW1WpWpjOd4nK6gMRw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1101,6 +1254,9 @@ packages:
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
pend@1.2.0:
|
||||
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
|
||||
|
||||
perfect-debounce@1.0.0:
|
||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||
|
||||
@@ -1133,6 +1289,13 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
process@0.11.10:
|
||||
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
proxy-addr@2.0.8:
|
||||
resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -1168,6 +1331,17 @@ packages:
|
||||
resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||
|
||||
readable-stream@4.7.0:
|
||||
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
|
||||
readdir-glob@3.0.0:
|
||||
resolution: {integrity: sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
readdirp@4.1.2:
|
||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||
engines: {node: '>= 14.18.0'}
|
||||
@@ -1187,6 +1361,12 @@ packages:
|
||||
rxjs@7.8.2:
|
||||
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
|
||||
|
||||
safe-buffer@5.1.2:
|
||||
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||
|
||||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
@@ -1240,10 +1420,28 @@ packages:
|
||||
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
streamx@2.28.1:
|
||||
resolution: {integrity: sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==}
|
||||
|
||||
string_decoder@1.1.1:
|
||||
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
strtok3@10.3.5:
|
||||
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tar-stream@3.2.1:
|
||||
resolution: {integrity: sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==}
|
||||
|
||||
teex@1.0.1:
|
||||
resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==}
|
||||
|
||||
text-decoder@1.2.7:
|
||||
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
|
||||
|
||||
tinyexec@1.3.1:
|
||||
resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1302,6 +1500,9 @@ packages:
|
||||
peerDependencies:
|
||||
browserslist: '>= 4.21.0'
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
vary@1.1.2:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -1352,6 +1553,14 @@ packages:
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
yauzl@3.4.0:
|
||||
resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
zip-stream@7.0.5:
|
||||
resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
zod@4.6.5:
|
||||
resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==}
|
||||
|
||||
@@ -1733,6 +1942,11 @@ snapshots:
|
||||
|
||||
'@tokenizer/token@0.3.0': {}
|
||||
|
||||
'@types/archiver@8.0.0':
|
||||
dependencies:
|
||||
'@types/node': 24.19.0
|
||||
'@types/readdir-glob': 1.1.5
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.9
|
||||
@@ -1784,6 +1998,10 @@ snapshots:
|
||||
|
||||
'@types/http-errors@2.0.5': {}
|
||||
|
||||
'@types/multer@2.3.0':
|
||||
dependencies:
|
||||
'@types/express': 5.0.6
|
||||
|
||||
'@types/node@24.19.0':
|
||||
dependencies:
|
||||
undici-types: 7.24.6
|
||||
@@ -1800,6 +2018,10 @@ snapshots:
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/readdir-glob@1.1.5':
|
||||
dependencies:
|
||||
'@types/node': 24.19.0
|
||||
|
||||
'@types/send@1.2.1':
|
||||
dependencies:
|
||||
'@types/node': 24.19.0
|
||||
@@ -1809,6 +2031,10 @@ snapshots:
|
||||
'@types/http-errors': 2.0.5
|
||||
'@types/node': 24.19.0
|
||||
|
||||
'@types/yauzl@3.4.0':
|
||||
dependencies:
|
||||
'@types/node': 24.19.0
|
||||
|
||||
'@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@24.19.0)(jiti@2.7.0)(tsx@4.23.15))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
@@ -1821,6 +2047,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
abort-controller@3.0.0:
|
||||
dependencies:
|
||||
event-target-shim: 5.0.1
|
||||
|
||||
accepts@2.0.0:
|
||||
dependencies:
|
||||
mime-types: 3.0.2
|
||||
@@ -1828,8 +2058,61 @@ snapshots:
|
||||
|
||||
append-field@1.0.0: {}
|
||||
|
||||
archiver@8.0.0:
|
||||
dependencies:
|
||||
async: 3.2.6
|
||||
buffer-crc32: 1.0.0
|
||||
is-stream: 4.0.1
|
||||
lazystream: 1.0.1
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 4.7.0
|
||||
readdir-glob: 3.0.0
|
||||
tar-stream: 3.2.1
|
||||
zip-stream: 7.0.5
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
- react-native-b4a
|
||||
|
||||
async@3.2.6: {}
|
||||
|
||||
aws-ssl-profiles@1.1.2: {}
|
||||
|
||||
b4a@1.9.0: {}
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
bare-events@2.9.2: {}
|
||||
|
||||
bare-fs@4.8.2:
|
||||
dependencies:
|
||||
bare-events: 2.9.2
|
||||
bare-path: 3.1.2
|
||||
bare-stream: 2.13.4(bare-events@2.9.2)
|
||||
bare-url: 2.5.4
|
||||
fast-fifo: 1.3.2
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
|
||||
bare-path@3.1.2: {}
|
||||
|
||||
bare-stream@2.13.4(bare-events@2.9.2):
|
||||
dependencies:
|
||||
b4a: 1.9.0
|
||||
streamx: 2.28.1
|
||||
teex: 1.0.1
|
||||
optionalDependencies:
|
||||
bare-events: 2.9.2
|
||||
transitivePeerDependencies:
|
||||
- react-native-b4a
|
||||
|
||||
bare-url@2.5.4:
|
||||
dependencies:
|
||||
bare-path: 3.1.2
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
baseline-browser-mapping@2.11.26: {}
|
||||
|
||||
bcryptjs@3.0.3: {}
|
||||
@@ -1848,6 +2131,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
brace-expansion@5.0.12:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
browserslist@4.29.3:
|
||||
dependencies:
|
||||
baseline-browser-mapping: 2.11.26
|
||||
@@ -1856,6 +2143,13 @@ snapshots:
|
||||
node-releases: 2.0.57
|
||||
update-browserslist-db: 1.3.3(browserslist@4.29.3)
|
||||
|
||||
buffer-crc32@1.0.0: {}
|
||||
|
||||
buffer@6.0.3:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
busboy@1.6.0:
|
||||
dependencies:
|
||||
streamsearch: 1.1.0
|
||||
@@ -1899,6 +2193,14 @@ snapshots:
|
||||
|
||||
citty@0.2.2: {}
|
||||
|
||||
compress-commons@7.0.1:
|
||||
dependencies:
|
||||
crc-32: 1.2.2
|
||||
crc32-stream: 7.0.1
|
||||
is-stream: 4.0.1
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 4.7.0
|
||||
|
||||
confbox@0.2.4: {}
|
||||
|
||||
confbox@0.3.1: {}
|
||||
@@ -1924,11 +2226,20 @@ snapshots:
|
||||
|
||||
cookie@0.7.2: {}
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
cors@2.8.6:
|
||||
dependencies:
|
||||
object-assign: 4.1.1
|
||||
vary: 1.1.2
|
||||
|
||||
crc-32@1.2.2: {}
|
||||
|
||||
crc32-stream@7.0.1:
|
||||
dependencies:
|
||||
crc-32: 1.2.2
|
||||
readable-stream: 4.7.0
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
debug@4.4.3:
|
||||
@@ -2011,6 +2322,16 @@ snapshots:
|
||||
|
||||
etag@1.8.1: {}
|
||||
|
||||
event-target-shim@5.0.1: {}
|
||||
|
||||
events-universal@1.0.1:
|
||||
dependencies:
|
||||
bare-events: 2.9.2
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
|
||||
events@3.3.0: {}
|
||||
|
||||
express@5.1.0:
|
||||
dependencies:
|
||||
accepts: 2.0.0
|
||||
@@ -2082,6 +2403,8 @@ snapshots:
|
||||
dependencies:
|
||||
pure-rand: 6.1.0
|
||||
|
||||
fast-fifo@1.3.2: {}
|
||||
|
||||
fast-safe-stringify@2.1.1: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.7):
|
||||
@@ -2182,6 +2505,10 @@ snapshots:
|
||||
|
||||
is-property@1.0.2: {}
|
||||
|
||||
is-stream@4.0.1: {}
|
||||
|
||||
isarray@1.0.0: {}
|
||||
|
||||
iterare@1.2.1: {}
|
||||
|
||||
jiti@2.7.0: {}
|
||||
@@ -2192,6 +2519,10 @@ snapshots:
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
lazystream@1.0.1:
|
||||
dependencies:
|
||||
readable-stream: 2.3.8
|
||||
|
||||
load-esm@1.0.3: {}
|
||||
|
||||
long@5.3.2: {}
|
||||
@@ -2226,6 +2557,10 @@ snapshots:
|
||||
dependencies:
|
||||
mime-db: 1.54.0
|
||||
|
||||
minimatch@10.2.6:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.12
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
multer@2.4.0:
|
||||
@@ -2259,6 +2594,8 @@ snapshots:
|
||||
|
||||
node-releases@2.0.57: {}
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
nypm@0.6.10:
|
||||
dependencies:
|
||||
citty: 0.2.2
|
||||
@@ -2285,6 +2622,8 @@ snapshots:
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
pend@1.2.0: {}
|
||||
|
||||
perfect-debounce@1.0.0: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
@@ -2314,6 +2653,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- magicast
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
process@0.11.10: {}
|
||||
|
||||
proxy-addr@2.0.8:
|
||||
dependencies:
|
||||
forwarded: 0.2.0
|
||||
@@ -2349,6 +2692,28 @@ snapshots:
|
||||
|
||||
react@19.3.0: {}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
dependencies:
|
||||
core-util-is: 1.0.3
|
||||
inherits: 2.0.4
|
||||
isarray: 1.0.0
|
||||
process-nextick-args: 2.0.1
|
||||
safe-buffer: 5.1.2
|
||||
string_decoder: 1.1.1
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
readable-stream@4.7.0:
|
||||
dependencies:
|
||||
abort-controller: 3.0.0
|
||||
buffer: 6.0.3
|
||||
events: 3.3.0
|
||||
process: 0.11.10
|
||||
string_decoder: 1.3.0
|
||||
|
||||
readdir-glob@3.0.0:
|
||||
dependencies:
|
||||
minimatch: 10.2.6
|
||||
|
||||
readdirp@4.1.2: {}
|
||||
|
||||
reflect-metadata@0.2.2: {}
|
||||
@@ -2399,6 +2764,10 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
safe-buffer@5.1.2: {}
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
scheduler@0.28.0: {}
|
||||
@@ -2468,10 +2837,51 @@ snapshots:
|
||||
|
||||
streamsearch@1.1.0: {}
|
||||
|
||||
streamx@2.28.1:
|
||||
dependencies:
|
||||
events-universal: 1.0.1
|
||||
fast-fifo: 1.3.2
|
||||
text-decoder: 1.2.7
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
|
||||
string_decoder@1.1.1:
|
||||
dependencies:
|
||||
safe-buffer: 5.1.2
|
||||
|
||||
string_decoder@1.3.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
strtok3@10.3.5:
|
||||
dependencies:
|
||||
'@tokenizer/token': 0.3.0
|
||||
|
||||
tar-stream@3.2.1:
|
||||
dependencies:
|
||||
b4a: 1.9.0
|
||||
bare-fs: 4.8.2
|
||||
fast-fifo: 1.3.2
|
||||
streamx: 2.28.1
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
- react-native-b4a
|
||||
|
||||
teex@1.0.1:
|
||||
dependencies:
|
||||
streamx: 2.28.1
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
|
||||
text-decoder@1.2.7:
|
||||
dependencies:
|
||||
b4a: 1.9.0
|
||||
transitivePeerDependencies:
|
||||
- react-native-b4a
|
||||
|
||||
tinyexec@1.3.1: {}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
@@ -2524,6 +2934,8 @@ snapshots:
|
||||
escalade: 3.2.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
vite@7.3.6(@types/node@24.19.0)(jiti@2.7.0)(tsx@4.23.15):
|
||||
@@ -2544,4 +2956,14 @@ snapshots:
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yauzl@3.4.0:
|
||||
dependencies:
|
||||
pend: 1.2.0
|
||||
|
||||
zip-stream@7.0.5:
|
||||
dependencies:
|
||||
compress-commons: 7.0.1
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 4.7.0
|
||||
|
||||
zod@4.6.5: {}
|
||||
Reference in new issue
Block a user