feat: add reusable private and shared account icons
This commit is contained in:
1 parent
d2b2681698
commit
a4e9d56b8a
44 files changed
+1443
-20
No files matched your search
+62
-1
@@ -28,6 +28,7 @@ import { UserRequest } from './auth';
|
||||
import { positionInput, positionMeta, currency, revisionInput, rateInput } from './validation';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { toBusinessDate } from './validation';
|
||||
import { iconName, validateStoredIcon } from './icons';
|
||||
import { day, businessTime } from './calculation';
|
||||
const timestamp = z.iso
|
||||
.datetime()
|
||||
@@ -67,6 +68,19 @@ const backupSchema = z
|
||||
.object({ showSidebar: z.boolean(), idleMinutes: z.number().int().min(0).max(1440) })
|
||||
.strict()
|
||||
.optional(),
|
||||
icons: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string().uuid(),
|
||||
name: iconName,
|
||||
shared: z.boolean(),
|
||||
image: z.string().max(3 * 1024 * 1024),
|
||||
hash: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
})
|
||||
.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']) })),
|
||||
@@ -79,6 +93,12 @@ export function validateBackup(raw: unknown) {
|
||||
if (ids.size !== b.positions.length) throw new BadRequestException('重复项目 ID');
|
||||
const origins = b.positions.map((p) => p.importedFromId || p.id);
|
||||
if (new Set(origins).size !== origins.length) throw new BadRequestException('备份内包含重复项目');
|
||||
const iconIds = new Set((b.icons || []).map((i) => i.id));
|
||||
if (
|
||||
iconIds.size !== (b.icons || []).length ||
|
||||
b.positions.some((p) => p.iconId && !iconIds.has(p.iconId))
|
||||
)
|
||||
throw new BadRequestException('图标关联无效');
|
||||
const revisionIds = new Set<string>();
|
||||
for (const p of b.positions) {
|
||||
positionInput.parse({
|
||||
@@ -180,6 +200,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
]);
|
||||
const positions = ps.map((p) => ({
|
||||
id: p.id,
|
||||
iconId: p.iconId,
|
||||
importedFromId: p.importedFromId,
|
||||
name: p.name,
|
||||
kind: p.kind,
|
||||
@@ -202,6 +223,17 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
})),
|
||||
}));
|
||||
const icons = await client.icon.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ownerId: userId },
|
||||
{
|
||||
id: { in: ps.flatMap((p) => (p.iconId ? [p.iconId] : [])) },
|
||||
OR: [{ shared: true }, { ownerId: userId }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return backupSchema.parse({
|
||||
format: 'worthpath',
|
||||
version: 2,
|
||||
@@ -215,6 +247,13 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
...rates.flatMap((r) => [r.currency, r.baseCurrency]),
|
||||
]),
|
||||
],
|
||||
icons: icons.map((i) => ({
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
shared: i.shared,
|
||||
hash: i.hash,
|
||||
image: Buffer.from(i.data).toString('base64'),
|
||||
})),
|
||||
positions,
|
||||
links: ps.flatMap((p) =>
|
||||
p.outgoing.map((l) => ({ sourceId: l.sourceId, targetId: l.targetId })),
|
||||
@@ -314,6 +353,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
(a.currency + a.baseCurrency + a.date).localeCompare(b.currency + b.baseCurrency + b.date),
|
||||
);
|
||||
data.currencies.sort();
|
||||
data.icons?.sort((a, b) => a.id.localeCompare(b.id));
|
||||
return createHash('sha256').update(JSON.stringify(data)).digest('hex');
|
||||
}
|
||||
@Get('clear-status') async clearStatus(@Req() r: UserRequest) {
|
||||
@@ -332,6 +372,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
if (this.fingerprint(await this.data(r.userId, tx)) !== s.backupDigest)
|
||||
throw new ConflictException('数据已变化,请重新下载备份');
|
||||
await tx.position.deleteMany({ where: { userId: r.userId } });
|
||||
await tx.icon.deleteMany({ where: { ownerId: r.userId, shared: false } });
|
||||
await tx.exchangeRate.deleteMany({ where: { userId: r.userId } });
|
||||
await tx.session.updateMany({
|
||||
where: { userId: r.userId },
|
||||
@@ -345,15 +386,17 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
@Post('preview') async preview(@Req() r: UserRequest, @Body() raw: unknown) {
|
||||
const b = validateBackup(raw),
|
||||
existing = await this.data(r.userId);
|
||||
for (const i of b.icons || []) await validateStoredIcon(i.image, i.hash);
|
||||
this.conflicts(b, existing);
|
||||
return {
|
||||
positions: b.positions.length,
|
||||
revisions: b.positions.reduce((n, p) => n + p.revisions.length, 0),
|
||||
rates: b.rates.length,
|
||||
icons: (b.icons || []).length,
|
||||
baseCurrency: b.baseCurrency,
|
||||
currentBaseCurrency: existing.baseCurrency,
|
||||
message:
|
||||
'只追加新项目并重建关联,相同汇率保留。不会覆盖已有项目。已有本位币保留,空账户恢复备份本位币和个人设置。确认后以事务导入。',
|
||||
'只追加新项目并重建关联,相同汇率保留。不会覆盖已有项目。已有本位币保留,空账户恢复备份本位币和个人设置。确认后以事务导入,备份中的图标恢复为私有,不会自动发布到共享库。',
|
||||
};
|
||||
}
|
||||
private conflicts(b: Backup, existing: Backup) {
|
||||
@@ -379,6 +422,8 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
.strict()
|
||||
.parse(raw),
|
||||
b = validateBackup(backup);
|
||||
const iconData = new Map<string, Buffer>();
|
||||
for (const i of b.icons || []) iconData.set(i.id, await validateStoredIcon(i.image, i.hash));
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
const ps = await tx.position.findMany({
|
||||
@@ -398,12 +443,28 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
rates: rs.map((v) => ({ ...v, rate: v.rate.toString(), date: day(v.date) })),
|
||||
} as unknown as Backup;
|
||||
this.conflicts(b, existing);
|
||||
const iconMapping = new Map<string, string>();
|
||||
for (const i of b.icons || []) {
|
||||
const row = await tx.icon.upsert({
|
||||
where: { ownerId_hash_shared: { ownerId: r.userId, hash: i.hash, shared: false } },
|
||||
create: {
|
||||
ownerId: r.userId,
|
||||
name: i.name,
|
||||
hash: i.hash,
|
||||
shared: false,
|
||||
data: new Uint8Array(iconData.get(i.id)!),
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
iconMapping.set(i.id, row.id);
|
||||
}
|
||||
const mapping = new Map<string, string>();
|
||||
for (const p of b.positions) {
|
||||
const row = await tx.position.create({
|
||||
data: {
|
||||
userId: r.userId,
|
||||
importedFromId: p.importedFromId || p.id,
|
||||
iconId: p.iconId ? iconMapping.get(p.iconId) : null,
|
||||
name: p.name,
|
||||
kind: p.kind,
|
||||
side: p.side,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
Controller,
|
||||
Injectable,
|
||||
Get,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
Param,
|
||||
Body,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { Response } from 'express';
|
||||
import sharp from 'sharp';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import { Database } from './database';
|
||||
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) {
|
||||
if (!data.length || data.length > 2 * 1024 * 1024)
|
||||
throw new BadRequestException('图标不能超过 2 MB');
|
||||
try {
|
||||
const image = sharp(data, { limitInputPixels: 16000000, animated: false });
|
||||
const meta = await image.metadata();
|
||||
if (!['png', 'jpeg', 'webp'].includes(meta.format || '') || (meta.pages || 1) > 1)
|
||||
throw Error();
|
||||
return await image
|
||||
.rotate()
|
||||
.resize(256, 256, { fit: 'inside', withoutEnlargement: true })
|
||||
.png()
|
||||
.toBuffer();
|
||||
} catch {
|
||||
throw new BadRequestException('请选择有效的静态 PNG、JPG 或 WebP 图片');
|
||||
}
|
||||
}
|
||||
export async function validateStoredIcon(image: string, hash: string) {
|
||||
const data = Buffer.from(image, 'base64');
|
||||
if (data.toString('base64') !== image || iconHash(data) !== hash)
|
||||
throw new BadRequestException('图标内容或校验值无效');
|
||||
await normalizeIcon(data);
|
||||
const meta = await sharp(data).metadata();
|
||||
if (meta.format !== 'png' || !meta.width || !meta.height || meta.width > 256 || meta.height > 256)
|
||||
throw new BadRequestException('备份图标必须是规范的 PNG');
|
||||
return data;
|
||||
}
|
||||
@Injectable()
|
||||
export class IconsService {
|
||||
constructor(private db: Database) {}
|
||||
async requireVisible(userId: string, id?: string | null) {
|
||||
if (
|
||||
id &&
|
||||
!(await this.db.icon.findFirst({
|
||||
where: { id, OR: [{ shared: true }, { ownerId: userId }] },
|
||||
select: { id: true },
|
||||
}))
|
||||
)
|
||||
throw new BadRequestException('图标不存在或无权使用');
|
||||
}
|
||||
}
|
||||
@Controller('api/icons')
|
||||
export class IconsController {
|
||||
constructor(private db: Database) {}
|
||||
@Get() async list(@Req() r: UserRequest, @Query('q') q = '', @Query('page') page = '1') {
|
||||
const query = z.string().trim().max(100).parse(q);
|
||||
const index = z.coerce.number().int().min(1).max(100000).parse(page);
|
||||
const where = { OR: [{ shared: true }, { ownerId: r.userId }], name: { contains: query } };
|
||||
const [items, total] = await this.db.$transaction([
|
||||
this.db.icon.findMany({
|
||||
where,
|
||||
select: { id: true, name: true, shared: true, source: true },
|
||||
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||
skip: (index - 1) * 60,
|
||||
take: 60,
|
||||
}),
|
||||
this.db.icon.count({ where }),
|
||||
]);
|
||||
return { items, total, page: index };
|
||||
}
|
||||
@Get(':id/image') async image(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const icon = await this.db.icon.findFirst({
|
||||
where: { id, OR: [{ shared: true }, { ownerId: r.userId }] },
|
||||
});
|
||||
if (!icon) throw new NotFoundException('图标不存在');
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.send(Buffer.from(icon.data));
|
||||
}
|
||||
@Post('upload')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 2 * 1024 * 1024, files: 1, fields: 3 },
|
||||
}),
|
||||
)
|
||||
async upload(
|
||||
@Req() r: UserRequest,
|
||||
@Body() raw: unknown,
|
||||
@UploadedFile() file?: Express.Multer.File,
|
||||
) {
|
||||
const v = z
|
||||
.object({
|
||||
name: iconName,
|
||||
shared: z.enum(['true', 'false']).default('false'),
|
||||
confirmed: z.literal('true').optional(),
|
||||
})
|
||||
.strict()
|
||||
.parse(raw);
|
||||
const shared = v.shared === 'true';
|
||||
if (shared && (!/\p{Script=Han}/u.test(v.name) || v.confirmed !== 'true'))
|
||||
throw new BadRequestException('共享图标须填写中文名称并明确确认公开给所有用户');
|
||||
if (!file) throw new BadRequestException('请选择图标文件');
|
||||
const data = await normalizeIcon(file.buffer),
|
||||
hash = iconHash(data);
|
||||
const icon = await this.db.icon.upsert({
|
||||
where: { ownerId_hash_shared: { ownerId: r.userId, hash, shared } },
|
||||
create: { name: v.name, ownerId: r.userId, shared, hash, data },
|
||||
update: {},
|
||||
select: { id: true, name: true, shared: true, source: true },
|
||||
});
|
||||
return icon;
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -8,6 +8,7 @@ import { json } from 'express';
|
||||
import { AuthController, AuthGuard, AuthService } from './auth';
|
||||
import { PortfolioController } from './portfolio';
|
||||
import { BackupController } from './backup';
|
||||
import { IconsController, IconsService } from './icons';
|
||||
import { Database } from './database';
|
||||
import { RatesService, SettingsController } from './rates';
|
||||
import { ZodError } from 'zod';
|
||||
@@ -41,8 +42,20 @@ class SafeErrors implements ExceptionFilter {
|
||||
}
|
||||
}
|
||||
@Module({
|
||||
providers: [Database, AuthService, RatesService, { provide: APP_GUARD, useClass: AuthGuard }],
|
||||
controllers: [AuthController, PortfolioController, SettingsController, BackupController],
|
||||
providers: [
|
||||
Database,
|
||||
AuthService,
|
||||
RatesService,
|
||||
IconsService,
|
||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||
],
|
||||
controllers: [
|
||||
IconsController,
|
||||
AuthController,
|
||||
PortfolioController,
|
||||
SettingsController,
|
||||
BackupController,
|
||||
],
|
||||
})
|
||||
class AppModule {}
|
||||
async function bootstrap() {
|
||||
|
||||
@@ -17,12 +17,14 @@ import { positionInput, positionMeta, revisionInput, today, toBusinessDate } fro
|
||||
import { history, overview } from './calculation';
|
||||
import { z } from 'zod';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { IconsService } from './icons';
|
||||
import { RatesService } from './rates';
|
||||
@Controller('api')
|
||||
export class PortfolioController {
|
||||
constructor(
|
||||
private db: Database,
|
||||
private fx: RatesService,
|
||||
private icons: IconsService,
|
||||
) {}
|
||||
private async own(userId: string, id: string, revealed = false) {
|
||||
const p = await this.db.position.findFirst({
|
||||
@@ -58,6 +60,7 @@ export class PortfolioController {
|
||||
@Post('positions') async create(@Req() r: UserRequest, @Body() b: unknown) {
|
||||
const v = positionInput.parse(b),
|
||||
{ amount, date, ...meta } = v;
|
||||
await this.icons.requireVisible(r.userId, meta.iconId);
|
||||
const created = await this.db.position.create({
|
||||
data: {
|
||||
...meta,
|
||||
@@ -89,6 +92,7 @@ export class PortfolioController {
|
||||
p.side !== 'liability'
|
||||
)
|
||||
throw new BadRequestException('信用卡和贷款账户必须为负债');
|
||||
await this.icons.requireVisible(r.userId, v.iconId);
|
||||
await this.db.position.update({ where: { id: p.id }, data: v });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ export const revisionInput = z
|
||||
.strict();
|
||||
export const positionMeta = z
|
||||
.object({
|
||||
iconId: z.string().uuid().nullable().optional(),
|
||||
name: z.string().trim().min(1).max(100),
|
||||
category: z.string().trim().min(1).max(40),
|
||||
notes,
|
||||
|
||||
+15
-5
@@ -15,6 +15,7 @@ const files = [
|
||||
'history.json',
|
||||
'links.json',
|
||||
'rates.json',
|
||||
'icons.json',
|
||||
] as const;
|
||||
const sha = (s: Buffer | string) => createHash('sha256').update(s).digest('hex');
|
||||
export function packBackup(b: Backup) {
|
||||
@@ -30,6 +31,7 @@ export function packBackup(b: Backup) {
|
||||
),
|
||||
'links.json': b.links,
|
||||
'rates.json': b.rates,
|
||||
'icons.json': b.icons || [],
|
||||
};
|
||||
const contents = Object.fromEntries(
|
||||
files.map((name) => [name, JSON.stringify(data[name], null, 2)]),
|
||||
@@ -37,7 +39,7 @@ export function packBackup(b: Backup) {
|
||||
contents['manifest.json'] = JSON.stringify(
|
||||
{
|
||||
format: 'worthpath',
|
||||
version: 3,
|
||||
version: 4,
|
||||
exportedAt: b.exportedAt,
|
||||
files: files.map((name) => ({ name, sha256: sha(contents[name]) })),
|
||||
},
|
||||
@@ -106,23 +108,30 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
|
||||
});
|
||||
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),
|
||||
version: z.union([z.literal(3), z.literal(4)]),
|
||||
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),
|
||||
.min(files.length - 1)
|
||||
.max(files.length),
|
||||
})
|
||||
.strict()
|
||||
.parse(parse('manifest.json'));
|
||||
if (new Set(manifest.files.map((f) => f.name)).size !== files.length) throw Error();
|
||||
const expected = manifest.version === 3 ? files.filter((f) => f !== 'icons.json') : [...files];
|
||||
if (
|
||||
contents.size !== expected.length + 1 ||
|
||||
new Set(manifest.files.map((f) => f.name)).size !== expected.length ||
|
||||
expected.some((name) => !manifest.files.some((f) => f.name === name))
|
||||
)
|
||||
throw Error();
|
||||
for (const f of manifest.files) if (sha(contents.get(f.name)!) !== f.sha256) throw Error();
|
||||
const settings = z
|
||||
.object({
|
||||
@@ -160,6 +169,7 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
|
||||
positions: positions.map((p) => ({ ...p, revisions: grouped.get(p.id) || [] })),
|
||||
links: parse('links.json'),
|
||||
rates: parse('rates.json'),
|
||||
...(manifest.version === 4 ? { icons: parse('icons.json') } : {}),
|
||||
};
|
||||
} catch {
|
||||
throw new BadRequestException(
|
||||
|
||||
Reference in new issue
Block a user