feat: customize sessions and overview with faster account workflows
This commit is contained in:
1 parent
9a133e82e9
commit
425b45c91a
40 files changed
+1656
-335
No files matched your search
Binary file not shown.
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 182 KiB |
@@ -115,9 +115,10 @@
|
||||
},
|
||||
{
|
||||
"name": "京东金融",
|
||||
"source": "https://jr.jd.com/logo.png",
|
||||
"source": "https://is1-ssl.mzstatic.com/image/thumb/Purple211/v4/7d/74/af/7d74af96-af89-ae14-cbf5-5bbd89859ab7/AppIcon-0-1x_U007epad-0-1-0-85-220-0.png/512x512bb.jpg",
|
||||
"file": "20.png",
|
||||
"sha256": "8dc9703f571e605df552dc7eb14ae074d9ddab6b27a9140b610e87b1c2a5f693"
|
||||
"sha256": "9274fde6f793cf49f239fe3995cc959f33d71a02f93866e416ae5f5a533b05de",
|
||||
"preserveWhite": true
|
||||
},
|
||||
{
|
||||
"name": "汇丰香港",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "node scripts/database.cjs deploy",
|
||||
"db:status": "node scripts/database.cjs status",
|
||||
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts test/update-integration.test.ts test/record-edit.test.ts test/security-backup.test.ts test/credit-balance.test.ts test/group-order.test.ts",
|
||||
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts test/update-integration.test.ts test/record-edit.test.ts test/security-backup.test.ts test/credit-balance.test.ts test/group-order.test.ts test/settings-plans.test.ts",
|
||||
"test:performance": "tsx scripts/performance.ts after",
|
||||
"icons:seed": "node scripts/seed-icons.cjs"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE User
|
||||
ADD COLUMN sessionHours INTEGER NOT NULL DEFAULT 168 COMMENT '登录有效时长(小时),范围 1 至 720',
|
||||
ADD COLUMN requireHiddenPassword BOOLEAN NOT NULL DEFAULT true COMMENT '查看隐藏项目是否需要再次验证密码',
|
||||
ADD COLUMN overviewCards JSON NULL COMMENT '总览显示的卡片键列表,NULL 表示默认全部显示';
|
||||
@@ -17,6 +17,12 @@ model User {
|
||||
baseCurrency String @default("CNY") @db.Char(3)
|
||||
/// 隐藏菜单标识列表
|
||||
hiddenMenus String @default("") @db.VarChar(128)
|
||||
/// 登录有效时长,单位小时,范围 1 至 720
|
||||
sessionHours Int @default(168)
|
||||
/// 查看隐藏项目是否需要再次验证密码
|
||||
requireHiddenPassword Boolean @default(true)
|
||||
/// 总览显示的卡片键列表,空值表示默认全部显示
|
||||
overviewCards Json?
|
||||
/// 账户分组显示顺序;空值表示沿用默认顺序
|
||||
accountGroupOrder Json?
|
||||
/// 是否显示备注
|
||||
|
||||
@@ -11,7 +11,10 @@ async function main() {
|
||||
const sources = JSON.parse(await readFile(join(dir, 'sources.json'), 'utf8'));
|
||||
// Update only deterministic built-in image IDs; preserve user uploads and financial data.
|
||||
for (const item of sources) {
|
||||
const data = await normalizeIcon(await readFile(join(dir, item.file)));
|
||||
const data = await normalizeIcon(
|
||||
await readFile(join(dir, item.file)),
|
||||
item.preserveWhite === true,
|
||||
);
|
||||
const hex = createHash('sha256')
|
||||
.update('worthpath-builtin:' + item.name)
|
||||
.digest('hex');
|
||||
|
||||
+20
-8
@@ -20,7 +20,7 @@ import { Request, Response } from 'express';
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
import { hash, compare } from 'bcryptjs';
|
||||
import { Database } from './database';
|
||||
import { credentials, credentialChange } from './validation';
|
||||
import { credentials, credentialChange, defaultOverviewCards } from './validation';
|
||||
import { Prisma } from '@prisma/client';
|
||||
export type UserRequest = Request & { userId: string; sessionId: string; revealed: boolean };
|
||||
const Public = () => SetMetadata('public', true);
|
||||
@@ -58,8 +58,9 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
async issue(userId: string, res: Response) {
|
||||
const user = await this.db.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
const token = randomBytes(32).toString('hex'),
|
||||
expiresAt = new Date(Date.now() + 7 * 86400000);
|
||||
expiresAt = new Date(Date.now() + user.sessionHours * 3600000);
|
||||
await this.db.session.create({ data: { id: digest(token), userId, expiresAt } });
|
||||
this.cookie(token, expiresAt, res);
|
||||
}
|
||||
@@ -171,12 +172,17 @@ export class AuthController {
|
||||
showNotes: true,
|
||||
idleMinutes: true,
|
||||
accountGroupOrder: true,
|
||||
sessionHours: true,
|
||||
requireHiddenPassword: true,
|
||||
overviewCards: true,
|
||||
},
|
||||
});
|
||||
const session = await this.db.session.findUniqueOrThrow({ where: { id: req.sessionId } });
|
||||
return {
|
||||
...user,
|
||||
accountGroupOrder: user.accountGroupOrder || [],
|
||||
overviewCards: user.overviewCards ?? [...defaultOverviewCards],
|
||||
sessionExpiresAt: session.expiresAt,
|
||||
hiddenMenus: user.hiddenMenus.split(',').filter(Boolean),
|
||||
revealed: req.revealed,
|
||||
revealUntil: session.revealUntil,
|
||||
@@ -197,7 +203,7 @@ export class AuthController {
|
||||
throw new BadRequestException('请填写新的账号或密码');
|
||||
const passwordHash = v.newPassword ? await hash(v.newPassword, 12) : user.passwordHash;
|
||||
const token = randomBytes(32).toString('hex'),
|
||||
expiresAt = new Date(Date.now() + 7 * 86400000);
|
||||
expiresAt = new Date(Date.now() + user.sessionHours * 3600000);
|
||||
await this.db.serial(async (tx) => {
|
||||
await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id = ${r.userId} FOR UPDATE`);
|
||||
const current = await tx.user.findUniqueOrThrow({ where: { id: r.userId } });
|
||||
@@ -222,11 +228,17 @@ export class AuthController {
|
||||
}
|
||||
@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 } });
|
||||
const revealUntil = await this.db.serial(async (tx) => {
|
||||
await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id = ${r.userId} FOR UPDATE`);
|
||||
const u = await tx.user.findUniqueOrThrow({ where: { id: r.userId } });
|
||||
if (u.requireHiddenPassword) {
|
||||
const { password } = credentials.pick({ password: true }).parse(b);
|
||||
if (!(await compare(password, u.passwordHash))) throw new ForbiddenException('密码错误');
|
||||
}
|
||||
const until = new Date(Date.now() + 5 * 60000);
|
||||
await tx.session.update({ where: { id: r.sessionId }, data: { revealUntil: until } });
|
||||
return until;
|
||||
});
|
||||
return { revealUntil };
|
||||
}
|
||||
@Post('auth/lock') async lock(@Req() r: UserRequest) {
|
||||
|
||||
@@ -36,6 +36,9 @@ import {
|
||||
rateInput,
|
||||
hiddenMenus,
|
||||
accountGroupOrder,
|
||||
sessionHours,
|
||||
overviewCards,
|
||||
defaultOverviewCards,
|
||||
transferInput,
|
||||
} from './validation';
|
||||
import { createHash } from 'node:crypto';
|
||||
@@ -79,6 +82,9 @@ const backupSchema = z
|
||||
showSidebar: z.boolean().optional(),
|
||||
hiddenMenus: hiddenMenus.optional(),
|
||||
accountGroupOrder: accountGroupOrder.optional(),
|
||||
sessionHours: sessionHours.optional(),
|
||||
requireHiddenPassword: z.boolean().optional(),
|
||||
overviewCards: overviewCards.optional(),
|
||||
showNotes: z.boolean().optional(),
|
||||
idleMinutes: z.number().int().min(0).max(1440),
|
||||
})
|
||||
@@ -309,6 +315,9 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
showNotes: true,
|
||||
idleMinutes: true,
|
||||
accountGroupOrder: true,
|
||||
sessionHours: true,
|
||||
requireHiddenPassword: true,
|
||||
overviewCards: true,
|
||||
},
|
||||
}),
|
||||
client.position.findMany({
|
||||
@@ -369,6 +378,9 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
showNotes: user.showNotes,
|
||||
idleMinutes: user.idleMinutes,
|
||||
accountGroupOrder: accountGroupOrder.parse(user.accountGroupOrder || []),
|
||||
sessionHours: user.sessionHours,
|
||||
requireHiddenPassword: user.requireHiddenPassword,
|
||||
overviewCards: overviewCards.parse(user.overviewCards ?? [...defaultOverviewCards]),
|
||||
},
|
||||
currencies: [
|
||||
...new Set([
|
||||
@@ -717,8 +729,13 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
hiddenMenus: b.preferences?.hiddenMenus?.join(','),
|
||||
showNotes: b.preferences?.showNotes,
|
||||
accountGroupOrder: b.preferences?.accountGroupOrder,
|
||||
sessionHours: b.preferences?.sessionHours,
|
||||
requireHiddenPassword: b.preferences?.requireHiddenPassword,
|
||||
overviewCards: b.preferences?.overviewCards,
|
||||
},
|
||||
});
|
||||
if (!ps.length && !rs.length && b.preferences?.requireHiddenPassword !== undefined)
|
||||
await tx.session.updateMany({ where: { userId: r.userId }, data: { revealUntil: null } });
|
||||
return { ok: true, positions: b.positions.length };
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable, timeout: 300000 },
|
||||
|
||||
@@ -24,7 +24,7 @@ import { UserRequest } from './auth';
|
||||
|
||||
export const iconName = z.string().trim().min(1).max(100);
|
||||
export const iconHash = (data: Buffer) => createHash('sha256').update(data).digest('hex');
|
||||
export async function normalizeIcon(data: Buffer) {
|
||||
export async function normalizeIcon(data: Buffer, preserveWhite = false) {
|
||||
if (!data.length || data.length > 2 * 1024 * 1024)
|
||||
throw new BadRequestException('图标不能超过 2 MB');
|
||||
try {
|
||||
@@ -43,7 +43,7 @@ export async function normalizeIcon(data: Buffer) {
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
const low = Math.min(pixels[i], pixels[i + 1], pixels[i + 2]);
|
||||
const high = Math.max(pixels[i], pixels[i + 1], pixels[i + 2]);
|
||||
if (low >= 245 && high - low <= 8) pixels[i + 3] = 0;
|
||||
if (!preserveWhite && low >= 245 && high - low <= 8) pixels[i + 3] = 0;
|
||||
}
|
||||
return await sharp(pixels, { raw: { width: info.width, height: info.height, channels: 4 } })
|
||||
.png()
|
||||
@@ -106,7 +106,9 @@ export class IconsController {
|
||||
if (!icon) throw new NotFoundException('图标不存在');
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.send(await normalizeIcon(Buffer.from(icon.data)));
|
||||
// Uploads, built-in seeding and imports already validate stored PNG data.
|
||||
// Preserve essential white artwork rather than applying the cutout twice.
|
||||
res.send(Buffer.from(icon.data));
|
||||
}
|
||||
@Post('upload')
|
||||
@UseInterceptors(
|
||||
|
||||
+4
-10
@@ -12,6 +12,7 @@ import {
|
||||
currency,
|
||||
hiddenMenus,
|
||||
accountGroupOrder,
|
||||
settingsInput,
|
||||
} from './validation';
|
||||
export function setupOpenApi(app: INestApplication) {
|
||||
const document = SwaggerModule.createDocument(
|
||||
@@ -30,7 +31,7 @@ export function setupOpenApi(app: INestApplication) {
|
||||
'POST /api/auth/register': credentials,
|
||||
'POST /api/auth/login': credentials,
|
||||
'PATCH /api/auth/credentials': credentialChange,
|
||||
'POST /api/auth/reveal': credentials.pick({ password: true }),
|
||||
'POST /api/auth/reveal': z.object({ password: credentials.shape.password.optional() }).strict(),
|
||||
'POST /api/positions': positionInput,
|
||||
'PATCH /api/positions/{id}': positionMeta,
|
||||
'POST /api/positions/{id}/revisions': revisionInput,
|
||||
@@ -41,16 +42,9 @@ export function setupOpenApi(app: INestApplication) {
|
||||
'POST /api/transfers': transferInput,
|
||||
'PUT /api/transfers/{id}': transferInput,
|
||||
'POST /api/schedules': scheduleInput,
|
||||
'PUT /api/schedules/{id}': scheduleInput,
|
||||
'PATCH /api/schedules/{id}': z.object({ enabled: z.boolean() }).strict(),
|
||||
'PATCH /api/settings': z
|
||||
.object({
|
||||
baseCurrency: currency.optional(),
|
||||
hiddenMenus: hiddenMenus.optional(),
|
||||
accountGroupOrder: accountGroupOrder.optional(),
|
||||
showNotes: z.boolean().optional(),
|
||||
idleMinutes: z.number().int().min(0).max(1440).optional(),
|
||||
})
|
||||
.strict(),
|
||||
'PATCH /api/settings': settingsInput,
|
||||
'POST /api/backup/clear': z.object({ confirmation: z.literal('确定清空') }),
|
||||
'POST /api/backup/import-file': z.object({
|
||||
token: z.string().uuid(),
|
||||
|
||||
+37
-17
@@ -3,6 +3,7 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Patch,
|
||||
Res,
|
||||
Post,
|
||||
Req,
|
||||
Body,
|
||||
@@ -12,8 +13,16 @@ import {
|
||||
BadGatewayException,
|
||||
} from '@nestjs/common';
|
||||
import { Database } from './database';
|
||||
import { UserRequest } from './auth';
|
||||
import { currency, date, rateValue, today, hiddenMenus, accountGroupOrder } from './validation';
|
||||
import { AuthService, UserRequest } from './auth';
|
||||
import { Response } from 'express';
|
||||
import {
|
||||
currency,
|
||||
date,
|
||||
rateValue,
|
||||
today,
|
||||
settingsInput,
|
||||
defaultOverviewCards,
|
||||
} from './validation';
|
||||
import { z } from 'zod';
|
||||
import Decimal from 'decimal.js';
|
||||
// Fixed public request; no user currency choices, identifiers or amounts leave the server.
|
||||
@@ -165,6 +174,7 @@ export class SettingsController {
|
||||
constructor(
|
||||
private db: Database,
|
||||
private fx: RatesService,
|
||||
private auth: AuthService,
|
||||
) {}
|
||||
@Get('settings') async settings(@Req() r: UserRequest, @Query('rates') includeRates?: string) {
|
||||
const showRates = z.enum(['true', 'false']).optional().parse(includeRates) === 'true';
|
||||
@@ -177,11 +187,17 @@ export class SettingsController {
|
||||
showNotes: true,
|
||||
idleMinutes: true,
|
||||
accountGroupOrder: true,
|
||||
sessionHours: true,
|
||||
requireHiddenPassword: true,
|
||||
overviewCards: true,
|
||||
},
|
||||
});
|
||||
return {
|
||||
...u,
|
||||
accountGroupOrder: u.accountGroupOrder || [],
|
||||
overviewCards: u.overviewCards ?? [...defaultOverviewCards],
|
||||
sessionExpiresAt: (await this.db.session.findUniqueOrThrow({ where: { id: r.sessionId } }))
|
||||
.expiresAt,
|
||||
hiddenMenus: u.hiddenMenus.split(',').filter(Boolean),
|
||||
lastActivity: (await this.db.session.findUniqueOrThrow({ where: { id: r.sessionId } }))
|
||||
.lastActivity,
|
||||
@@ -200,22 +216,26 @@ export class SettingsController {
|
||||
: [],
|
||||
};
|
||||
}
|
||||
@Patch('settings') async update(@Req() r: UserRequest, @Body() b: unknown) {
|
||||
const data = z
|
||||
.object({
|
||||
baseCurrency: currency.optional(),
|
||||
hiddenMenus: hiddenMenus.optional(),
|
||||
accountGroupOrder: accountGroupOrder.optional(),
|
||||
showNotes: 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: { ...data, hiddenMenus: data.hiddenMenus?.join(',') },
|
||||
@Patch('settings') async update(
|
||||
@Req() r: UserRequest,
|
||||
@Body() b: unknown,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const data = settingsInput.parse(b);
|
||||
const expiresAt =
|
||||
data.sessionHours === undefined
|
||||
? undefined
|
||||
: new Date(Date.now() + data.sessionHours * 3600000);
|
||||
await this.db.serial(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: r.userId },
|
||||
data: { ...data, hiddenMenus: data.hiddenMenus?.join(',') },
|
||||
});
|
||||
if (data.requireHiddenPassword !== undefined)
|
||||
await tx.session.updateMany({ where: { userId: r.userId }, data: { revealUntil: null } });
|
||||
if (expiresAt) await tx.session.update({ where: { id: r.sessionId }, data: { expiresAt } });
|
||||
});
|
||||
if (expiresAt) this.auth.cookie(r.cookies.wp_session, expiresAt, res);
|
||||
this.fx.invalidate(r.userId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
+56
-23
@@ -3,6 +3,7 @@ import {
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
@@ -86,35 +87,67 @@ export class SchedulesController {
|
||||
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
|
||||
const v = scheduleInput.parse(body);
|
||||
return this.db.serial(async (tx) => {
|
||||
const ids = [v.sourceId, ...(v.targetId ? [v.targetId] : [])].sort();
|
||||
await tx.$queryRaw(
|
||||
Prisma.sql`SELECT id FROM Position WHERE userId = ${r.userId} AND id IN (${Prisma.join(ids)}) ORDER BY id FOR UPDATE`,
|
||||
);
|
||||
const accounts = await tx.position.findMany({
|
||||
where: {
|
||||
userId: r.userId,
|
||||
id: { in: ids },
|
||||
kind: 'account',
|
||||
side: 'asset',
|
||||
archived: false,
|
||||
...(r.revealed ? {} : { hidden: false }),
|
||||
},
|
||||
});
|
||||
if (accounts.length !== ids.length) throw new BadRequestException('请选择启用的资产账户');
|
||||
if (v.operation === 'expense' && v.targetId)
|
||||
throw new BadRequestException('支出计划无需转入账户');
|
||||
if (
|
||||
v.operation === 'transfer' &&
|
||||
accounts[0].currency === accounts[1].currency &&
|
||||
!new Decimal(v.amount).eq(v.received)
|
||||
)
|
||||
throw new BadRequestException('同币种转出与到账金额必须一致');
|
||||
await this.validateAccounts(tx, r, v);
|
||||
return tx.schedule.create({
|
||||
data: { ...v, userId: r.userId, nextAt: new Date(v.nextAt + ':00+08:00') },
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
}
|
||||
private async validateAccounts(
|
||||
tx: Prisma.TransactionClient,
|
||||
r: UserRequest,
|
||||
v: z.infer<typeof scheduleInput>,
|
||||
) {
|
||||
const ids = [v.sourceId, ...(v.targetId ? [v.targetId] : [])].sort();
|
||||
await tx.$queryRaw(
|
||||
Prisma.sql`SELECT id FROM Position WHERE userId = ${r.userId} AND id IN (${Prisma.join(ids)}) ORDER BY id FOR UPDATE`,
|
||||
);
|
||||
const accounts = await tx.position.findMany({
|
||||
where: {
|
||||
userId: r.userId,
|
||||
id: { in: ids },
|
||||
kind: 'account',
|
||||
side: 'asset',
|
||||
archived: false,
|
||||
...(r.revealed ? {} : { hidden: false }),
|
||||
},
|
||||
});
|
||||
if (accounts.length !== ids.length) throw new BadRequestException('请选择启用的资产账户');
|
||||
if (v.operation === 'expense' && v.targetId)
|
||||
throw new BadRequestException('支出计划无需转入账户');
|
||||
if (
|
||||
v.operation === 'transfer' &&
|
||||
accounts[0].currency === accounts[1].currency &&
|
||||
!new Decimal(v.amount).eq(v.received)
|
||||
)
|
||||
throw new BadRequestException('同币种转出与到账金额必须一致');
|
||||
}
|
||||
@Put(':id') async edit(@Req() r: UserRequest, @Param('id') id: string, @Body() body: unknown) {
|
||||
const v = scheduleInput.parse(body);
|
||||
return this.db.serial(async (tx) => {
|
||||
await tx.$queryRaw(
|
||||
Prisma.sql`SELECT id FROM Schedule WHERE id = ${id} AND userId = ${r.userId} FOR UPDATE`,
|
||||
);
|
||||
const ids = await this.visible(r, tx);
|
||||
const row = await tx.schedule.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: r.userId,
|
||||
sourceId: { in: ids },
|
||||
OR: [{ targetId: null }, { targetId: { in: ids } }],
|
||||
},
|
||||
});
|
||||
if (!row) throw new NotFoundException('计划不存在');
|
||||
if (row.completed) throw new BadRequestException('一次性计划已完成,请新建计划');
|
||||
await this.validateAccounts(tx, r, v);
|
||||
await tx.schedule.update({
|
||||
where: { id },
|
||||
data: { ...v, nextAt: new Date(v.nextAt + ':00+08:00') },
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@Patch(':id') async toggle(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
|
||||
@@ -187,3 +187,31 @@ export const accountGroupOrder = z
|
||||
.array(z.string().trim().max(60))
|
||||
.max(1000)
|
||||
.refine((v) => new Set(v).size === v.length, '分组不可重复');
|
||||
|
||||
export const defaultOverviewCards = [
|
||||
'assets',
|
||||
'liabilities',
|
||||
'net',
|
||||
'trend',
|
||||
'composition',
|
||||
'recent',
|
||||
'attribution',
|
||||
] as const;
|
||||
export const overviewCards = z
|
||||
.array(z.enum(defaultOverviewCards))
|
||||
.max(7)
|
||||
.refine((v) => new Set(v).size === v.length, '卡片不可重复');
|
||||
export const sessionHours = z.number().int().min(1).max(720);
|
||||
export const settingsInput = z
|
||||
.object({
|
||||
baseCurrency: currency.optional(),
|
||||
hiddenMenus: hiddenMenus.optional(),
|
||||
accountGroupOrder: accountGroupOrder.optional(),
|
||||
showNotes: z.boolean().optional(),
|
||||
idleMinutes: z.number().int().min(0).max(1440).optional(),
|
||||
sessionHours: sessionHours.optional(),
|
||||
requireHiddenPassword: z.boolean().optional(),
|
||||
overviewCards: overviewCards.optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((v) => Object.keys(v).length > 0);
|
||||
+4
-1
@@ -4,7 +4,7 @@ import { createHash } from 'node:crypto';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
import type { Backup } from './backup';
|
||||
import { accountGroupOrder } from './validation';
|
||||
import { accountGroupOrder, sessionHours, overviewCards } from './validation';
|
||||
export const MAX_UPLOAD_BYTES = 512 * 1024 * 1024;
|
||||
const MAX_EXPANDED_BYTES = 1024 * 1024 * 1024;
|
||||
const files = [
|
||||
@@ -151,6 +151,9 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
|
||||
showSidebar: z.boolean().optional(),
|
||||
hiddenMenus: z.array(z.string()).optional(),
|
||||
accountGroupOrder: accountGroupOrder.optional(),
|
||||
sessionHours: sessionHours.optional(),
|
||||
requireHiddenPassword: z.boolean().optional(),
|
||||
overviewCards: overviewCards.optional(),
|
||||
showNotes: z.boolean().optional(),
|
||||
idleMinutes: z.number().int().min(0).max(1440),
|
||||
})
|
||||
|
||||
@@ -277,7 +277,10 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
|
||||
const backup = (await call('/backup', 'GET', undefined, a.cookie)).data;
|
||||
assert.equal(backup.positions.length, 4);
|
||||
assert.equal(backup.links.length, 2);
|
||||
assert.doesNotMatch(JSON.stringify(backup), /password|token|userId|session/i);
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(backup),
|
||||
/"(?:password|passwordHash|token|userId|session|sessionId|sessions|cookie)"\s*:/i,
|
||||
);
|
||||
assert.equal((await call('/backup/preview', 'POST', backup, a.cookie)).status, 409);
|
||||
assert.equal((await call('/backup/preview', 'POST', backup, b.cookie)).status, 201);
|
||||
assert.equal(
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import 'dotenv/config';
|
||||
import { test } from 'node:test';
|
||||
import { request } from 'node:http';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { hash } from 'bcryptjs';
|
||||
import { readBackupZip } from '../src/zip';
|
||||
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
|
||||
const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
|
||||
const password = 'Fixture-session-only-42!';
|
||||
const db = new PrismaClient();
|
||||
async function fixture() {
|
||||
const u = await db.user.create({
|
||||
data: {
|
||||
username: 'wp_settings_' + randomUUID(),
|
||||
passwordHash: await hash(password, 4),
|
||||
idleMinutes: 0,
|
||||
},
|
||||
});
|
||||
const token = randomBytes(32).toString('hex');
|
||||
const id = createHash('sha256').update(token).digest('hex');
|
||||
await db.session.create({
|
||||
data: { id, userId: u.id, expiresAt: new Date(Date.now() + 86400000) },
|
||||
});
|
||||
return { ...u, sessionId: id, cookie: 'wp_session=' + token };
|
||||
}
|
||||
// Give this fixture suite its own loopback source address so independent auth
|
||||
// scenarios do not consume the existing suite's per-IP production rate limit.
|
||||
async function call(path: string, cookie: string, method = 'GET', body?: unknown) {
|
||||
const data = body === undefined ? undefined : JSON.stringify(body);
|
||||
return new Promise<{ status: number; data: any; cookie: string | null }>((resolve, reject) => {
|
||||
const req = request(
|
||||
new URL(base + path),
|
||||
{
|
||||
method,
|
||||
localAddress: '127.0.0.2',
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
Origin: origin,
|
||||
...(data === undefined
|
||||
? {}
|
||||
: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('error', reject);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve({
|
||||
status: res.statusCode!,
|
||||
data: JSON.parse(Buffer.concat(chunks).toString()),
|
||||
cookie: res.headers['set-cookie']?.[0] ?? null,
|
||||
});
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end(data);
|
||||
});
|
||||
}
|
||||
test('session duration boundaries, privacy isolation and card settings survive backups', async () => {
|
||||
const a = await fixture(),
|
||||
b = await fixture(),
|
||||
c = await fixture();
|
||||
try {
|
||||
const initial = await call('/auth/me', a.cookie);
|
||||
assert.equal(initial.data.sessionHours, 168);
|
||||
assert.equal(initial.data.requireHiddenPassword, true);
|
||||
assert.equal(initial.data.overviewCards.length, 7);
|
||||
for (const sessionHours of [0, 721, 1.5, '24'])
|
||||
assert.equal((await call('/settings', a.cookie, 'PATCH', { sessionHours })).status, 400);
|
||||
for (const overviewCards of [['unknown'], ['net', 'net'], [1]])
|
||||
assert.equal((await call('/settings', a.cookie, 'PATCH', { overviewCards })).status, 400);
|
||||
assert.equal((await call('/auth/reveal', a.cookie, 'POST', {})).status, 400);
|
||||
assert.equal(
|
||||
(await call('/auth/reveal', a.cookie, 'POST', { password: 'Incorrect-password-42' })).status,
|
||||
403,
|
||||
);
|
||||
assert.equal((await call('/auth/reveal', a.cookie, 'POST', { password })).status, 201);
|
||||
const secondToken = randomBytes(32).toString('hex');
|
||||
const secondId = createHash('sha256').update(secondToken).digest('hex');
|
||||
const oldExpiry = new Date(Date.now() + 36000000);
|
||||
await db.session.create({
|
||||
data: {
|
||||
id: secondId,
|
||||
userId: a.id,
|
||||
expiresAt: oldExpiry,
|
||||
revealUntil: new Date(Date.now() + 60000),
|
||||
},
|
||||
});
|
||||
const result = await call('/settings', a.cookie, 'PATCH', {
|
||||
sessionHours: 1,
|
||||
requireHiddenPassword: false,
|
||||
overviewCards: ['net', 'recent'],
|
||||
});
|
||||
assert.equal(result.status, 200);
|
||||
assert.match(result.cookie!, /HttpOnly/);
|
||||
assert.match(result.cookie!, /Expires=/);
|
||||
const current = await db.session.findUniqueOrThrow({ where: { id: a.sessionId } });
|
||||
assert.ok(Math.abs(+current.expiresAt - Date.now() - 3600000) < 5000);
|
||||
assert.equal(current.revealUntil, null);
|
||||
const other = await db.session.findUniqueOrThrow({ where: { id: secondId } });
|
||||
assert.ok(Math.abs(+other.expiresAt - +oldExpiry) < 1000);
|
||||
assert.equal(other.revealUntil, null);
|
||||
assert.equal((await call('/auth/me', b.cookie)).data.requireHiddenPassword, true);
|
||||
assert.equal((await call('/auth/reveal', a.cookie, 'POST', {})).status, 201);
|
||||
assert.equal((await call('/auth/me', a.cookie)).data.revealed, true);
|
||||
await call('/settings', a.cookie, 'PATCH', { requireHiddenPassword: true });
|
||||
assert.equal((await call('/auth/me', a.cookie)).data.revealed, false);
|
||||
assert.equal((await call('/auth/reveal', a.cookie, 'POST', {})).status, 400);
|
||||
await call('/settings', a.cookie, 'PATCH', { sessionHours: 720, requireHiddenPassword: false });
|
||||
const login = await call('/auth/login', '', 'POST', { username: a.username, password });
|
||||
assert.equal(login.status, 201);
|
||||
const freshCookie = login.cookie!.split(';')[0];
|
||||
const me = (await call('/auth/me', freshCookie)).data;
|
||||
assert.ok(Math.abs(+new Date(me.sessionExpiresAt) - Date.now() - 720 * 3600000) < 5000);
|
||||
const zip = await fetch(base + '/backup', { headers: { Cookie: a.cookie } });
|
||||
assert.equal(zip.status, 200);
|
||||
const backup: any = await readBackupZip(Buffer.from(await zip.arrayBuffer()));
|
||||
assert.deepEqual(backup.preferences.overviewCards, ['net', 'recent']);
|
||||
assert.equal(backup.preferences.sessionHours, 720);
|
||||
assert.equal(backup.preferences.requireHiddenPassword, false);
|
||||
assert.equal(
|
||||
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
|
||||
201,
|
||||
);
|
||||
const imported = (await call('/settings', b.cookie)).data;
|
||||
assert.equal(imported.sessionHours, 720);
|
||||
assert.equal(imported.requireHiddenPassword, false);
|
||||
assert.deepEqual(imported.overviewCards, ['net', 'recent']);
|
||||
delete backup.preferences.sessionHours;
|
||||
delete backup.preferences.requireHiddenPassword;
|
||||
delete backup.preferences.overviewCards;
|
||||
assert.equal(
|
||||
(await call('/backup/import', c.cookie, 'POST', { confirmed: true, backup })).status,
|
||||
201,
|
||||
);
|
||||
const legacy = (await call('/settings', c.cookie)).data;
|
||||
assert.equal(legacy.sessionHours, 168);
|
||||
assert.equal(legacy.requireHiddenPassword, true);
|
||||
assert.equal(legacy.overviewCards.length, 7);
|
||||
assert.equal((await call('/settings', a.cookie, 'PATCH', { overviewCards: [] })).status, 200);
|
||||
assert.deepEqual((await call('/auth/me', a.cookie)).data.overviewCards, []);
|
||||
await db.session.update({
|
||||
where: { id: a.sessionId },
|
||||
data: { expiresAt: new Date(Date.now() - 1000) },
|
||||
});
|
||||
assert.equal((await call('/auth/me', a.cookie)).status, 401);
|
||||
} finally {
|
||||
await db.user.deleteMany({ where: { id: { in: [a.id, b.id, c.id] } } });
|
||||
}
|
||||
});
|
||||
test('plan edits validate accounts, preserve history and reject hidden or foreign plans', async () => {
|
||||
const a = await fixture(),
|
||||
b = await fixture();
|
||||
try {
|
||||
const position = async (name: string, hidden = false) => {
|
||||
const r = await call('/positions', a.cookie, 'POST', {
|
||||
name,
|
||||
kind: 'account',
|
||||
side: 'asset',
|
||||
category: 'cash',
|
||||
currency: 'CNY',
|
||||
amount: '100',
|
||||
date: '2026-09-01T10:00',
|
||||
hidden,
|
||||
});
|
||||
assert.equal(r.status, 201);
|
||||
return r.data.id;
|
||||
};
|
||||
const source = await position('Source'),
|
||||
target = await position('Target'),
|
||||
hidden = await position('Hidden', true);
|
||||
const payload = {
|
||||
name: 'Original',
|
||||
operation: 'expense',
|
||||
sourceId: source,
|
||||
targetId: null,
|
||||
amount: '10',
|
||||
received: '0',
|
||||
nextAt: '2026-10-01T10:00',
|
||||
intervalDays: 30,
|
||||
notes: 'initial',
|
||||
};
|
||||
const created = await call('/schedules', a.cookie, 'POST', payload);
|
||||
assert.equal(created.status, 201);
|
||||
const id = created.data.id;
|
||||
const run = await call('/schedules/run', a.cookie, 'POST', {});
|
||||
assert.equal(run.data.executed, 1);
|
||||
const records = await db.revision.findMany({
|
||||
where: { positionId: source },
|
||||
orderBy: { sequence: 'asc' },
|
||||
});
|
||||
const updated = {
|
||||
...payload,
|
||||
name: 'Edited transfer',
|
||||
operation: 'transfer',
|
||||
targetId: target,
|
||||
amount: '20.00000001',
|
||||
received: '20.00000001',
|
||||
nextAt: '2027-01-01T09:00',
|
||||
intervalDays: 7,
|
||||
notes: 'new memo',
|
||||
};
|
||||
assert.equal((await call('/schedules/' + id, b.cookie, 'PUT', updated)).status, 404);
|
||||
assert.equal(
|
||||
(await call('/schedules/' + id, a.cookie, 'PUT', { ...updated, targetId: hidden })).status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(await call('/schedules/' + id, a.cookie, 'PUT', { ...updated, received: '21' })).status,
|
||||
400,
|
||||
);
|
||||
assert.equal((await call('/schedules/' + id, a.cookie, 'PUT', updated)).status, 200);
|
||||
const plans = (await call('/schedules', a.cookie)).data;
|
||||
const plan = plans.find((p: any) => p.id === id);
|
||||
assert.equal(plan.name, updated.name);
|
||||
assert.equal(plan.nextAt, updated.nextAt);
|
||||
assert.equal(plan.received, updated.received);
|
||||
assert.equal(plan.notes, updated.notes);
|
||||
assert.equal(plan.enabled, true);
|
||||
assert.deepEqual(
|
||||
await db.revision.findMany({ where: { positionId: source }, orderBy: { sequence: 'asc' } }),
|
||||
records,
|
||||
);
|
||||
const done = await call('/schedules', a.cookie, 'POST', {
|
||||
...payload,
|
||||
name: 'Once',
|
||||
intervalDays: 0,
|
||||
});
|
||||
await call('/schedules/run', a.cookie, 'POST', {});
|
||||
assert.equal((await call('/schedules/' + done.data.id, a.cookie, 'PUT', updated)).status, 400);
|
||||
await db.position.update({ where: { id: source }, data: { hidden: true } });
|
||||
assert.equal((await call('/schedules/' + id, a.cookie, 'PUT', updated)).status, 404);
|
||||
} finally {
|
||||
await db.user.deleteMany({ where: { id: { in: [a.id, b.id] } } });
|
||||
await db.$disconnect();
|
||||
}
|
||||
});
|
||||
@@ -285,6 +285,11 @@ test('icon processing creates transparent white cutouts and preserves brand colo
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
const preserved = await sharp(await normalizeIcon(input, true))
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
assert.deepEqual([...preserved], [...pixels]);
|
||||
assert.equal(output[3], 0);
|
||||
assert.deepEqual([...output.subarray(4)], [10, 120, 60, 255]);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user