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
+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),
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user