feat: add reusable private and shared account icons

This commit is contained in:
陈煜 committed 2026-10-01 18:55:15 +08:00
1 parent d2b2681698
commit a4e9d56b8a
44 files changed
+1443 -20

No files matched your search

+9 -2
View File
@@ -8,6 +8,7 @@ if (!(Test-Path apps/api/.env)) { Copy-Item apps/api/.env.example apps/api/.env
# 仅在本地 .env 设置 DATABASE_URL,先检查数据库是否存在
pnpm db:generate
pnpm db:migrate
pnpm --filter @worthpath/api icons:seed # 追加预置共享图标,重复执行不覆盖已有记录
pnpm dev
```
@@ -35,10 +36,16 @@ pnpm check:staged # git add 后,提交前检查本地凭证和禁止提交的
汇率使用 [Frankfurter 公共日汇率 API](https://frankfurter.dev/)。请求使用固定币种表,不发送用户选择、身份或金额。进程运行时每小时检查,每天尝试一次;添加项目、修改本位币会重新触发检查。失败保留原币和历史汇率,显示缺失或实际使用日期,可以点击重试。自动更新保留已有同日历史导入汇率。首次使用不会自动补齐早期历史汇率,缺失日期不绘制完整总额;原币和已有汇率始终保留。
备份 v1 最多 8 MB / 1000 项目 / 20000 条历史,导入先预览、再明确确认。首版只追加新项目并重建关系,不覆盖已有项目;按项目 ID 和导入来源识别重复;同日汇率冲突会拒绝整次导入。已有本位币保留,空空间恢复备份本位币。文件包含财务数据,应由用户妥善保存。
新版 ZIP 备份不限制记录条数,导入先预览、再明确确认。首版只追加新项目并重建关系,不覆盖已有项目;按项目 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,用于保护服务器资源。
备份下载为 ZIP:manifest、settings、currencies、accounts、assets、debts、history、links、rates、icons 各一个 JSON 文件,包含隐藏项目及全部历史,不含密码或会话凭据。导入先上传并预览,再确认追加;旧 JSON 备份继续兼容。不限制项目、历史、关联或汇率条数;上传文件最大 512 MB,ZIP 解压总计最大 1 GB,用于保护服务器资源。
内网穿透临时测试:本地 `apps/api/.env` 可设置 `WEB_ORIGIN=*` 并重启 API,允许来自任意 HTTP/HTTPS 站点的浏览器写入请求,仍需登录身份并校验数据归属。缺失或无效 Origin 仍拒绝;`NODE_ENV=production` 禁止此通配符。测试结束后恢复准确的站点地址。
账户图标:新增或编辑账户时选择可复用图标;设置页面提供图标库及中文名称搜索。直接上传默认私有,仅当前用户能检索、读取和使用;勾选共享并明确确认公开后,所有登录用户均可搜索复用,名称必须包含中文。支持静态 PNG/JPG/WebP,单张最大 2 MB,转为最长边 256 像素的 PNG 并去除图片元数据。同一用户相同图片和可见范围会复用现有图标。账户图标通过外键关联,不复制图片。
预置 17 家银行及支付宝、微信、京东金融共 20 个图标,资源及来源清单在 `apps/api/assets/icons`;银行来自公开银行标识库,支付平台来自官方网站资源。图标版权与商标归相应品牌所有,用于识别账户,不代表品牌合作或授权。运行 `pnpm --filter @worthpath/api icons:seed` 初始化共享库,不会覆盖已有图标。可离线使用已提交的 PNG,无需访问外部图标网站。
ZIP 格式 v4 增加 icons.json(图标名称、图片、内容校验值),包含自己的全部图标及账户引用的共享图标。导入会重建关联并将图标恢复为私有,相同图片复用,避免自动公开;旧 v3 ZIP 和旧 JSON 仍可导入。清空个人数据会删除私有图标,已发布共享图标保留供其他用户使用。
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+122
View File
@@ -0,0 +1,122 @@
[
{
"name": "中国工商银行",
"source": "https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/中国工商银行@3x.png",
"file": "01.png",
"sha256": "6a49f1e01332575c2fd5b8f3892d2969f6a254733a7731badae6b6c51ded6108"
},
{
"name": "中国农业银行",
"source": "https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/中国农业银行@3x.png",
"file": "02.png",
"sha256": "cc023287b5a6d0187f9e8198a1791507b0655d593ee1e619e5b48f5cb351484d"
},
{
"name": "中国建设银行",
"source": "https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/中国建设银行@3x.png",
"file": "03.png",
"sha256": "c1a1711a94ebb7e49d7494cb1d502a40f4bec8d30dc1e9f1cbd215c65fe233db"
},
{
"name": "中国银行",
"source": "https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/中国银行@3x.png",
"file": "04.png",
"sha256": "718978d8d444321d356810841919aa81da590e5faf23fe1a0cad6e471dbcb024"
},
{
"name": "交通银行",
"source": "https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/交通银行@3x.png",
"file": "05.png",
"sha256": "75b5e40cae526194715688b6b8c63684093d4cccbcd498ebc4fe3a730a616e6c"
},
{
"name": "招商银行",
"source": "https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/招商银行@3x.png",
"file": "06.png",
"sha256": "abf071f561028bac84e7bd07f53314623c6983810d328b6f7990e6ca11736017"
},
{
"name": "中信银行",
"source": "https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/中信银行@3x.png",
"file": "07.png",
"sha256": "51c873fcdabfab733700b48e772f3e03f2cb0918fe7a36a90a064297c018c530"
},
{
"name": "中国光大银行",
"source": "https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/中国光大银行@3x.png",
"file": "08.png",
"sha256": "791d54ef3f49fdd5466c09f2cec955585f3af22bd2593f95ac259548c22fcfbc"
},
{
"name": "中国民生银行",
"source": "https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/中国民生银行@3x.png",
"file": "09.png",
"sha256": "a466c4469559b966d7096449aa155227b1e0b8965a883f544067d8361b7c0a9d"
},
{
"name": "兴业银行",
"source": "https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/兴业银行@3x.png",
"file": "10.png",
"sha256": "05b860edad339b114bb38ac954da2c49f41f3f3e8ddc6d8a5c939dc2fc747e07"
},
{
"name": "平安银行",
"source": "https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/平安银行@3x.png",
"file": "11.png",
"sha256": "7e057cd3f23fd5df3fca54e1dc0b253f9a61005f52e7f933087820f63aade94e"
},
{
"name": "上海浦东发展银行",
"source": "https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/上海浦东发展银行@3x.png",
"file": "12.png",
"sha256": "b205b7adaa577df7f367d4644717a0e3d6fbdba45a77590c2a0a5e9f2fe3c91f"
},
{
"name": "广发银行",
"source": "https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/广发银行@3x.png",
"file": "13.png",
"sha256": "1a3c1f7f2c738eabbb330bb3eb1a5888e69f731fd9f7f62729366172ee773919"
},
{
"name": "华夏银行",
"source": "https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/华夏银行@3x.png",
"file": "14.png",
"sha256": "1547c7d7a2436e80c39d197be26ffb77dc35728f69260608b0dc3bff2743be6f"
},
{
"name": "北京银行",
"source": "https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/北京银行@3x.png",
"file": "15.png",
"sha256": "d59af4b650391832e3a2a4f3d852b6dde6c791a5842a1ce570f9c2990157c6dd"
},
{
"name": "上海银行",
"source": "https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/上海银行@3x.png",
"file": "16.png",
"sha256": "0ea8f0650db5ca04c7869cc46a2043606cf0038d701f22f07c40cd3e6b70519e"
},
{
"name": "浙商银行",
"source": "https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/浙商银行@3x.png",
"file": "17.png",
"sha256": "8237233ee5ee7487ace0f84dce2832037ec13e2f004dcf6ed65652d783961656"
},
{
"name": "支付宝",
"source": "https://i.alipayobjects.com/common/favicon/favicon.ico",
"file": "18.png",
"sha256": "b662de58b15b34d1bf4d2a8bc546f7062a3faaa22acd2ecbbbad70f1e7f37a39"
},
{
"name": "微信",
"source": "https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.ico",
"file": "19.png",
"sha256": "a62d7d84bd02b1718106d294d1f2c8387f9967239696c1e8b446201b63f34dc7"
},
{
"name": "京东金融",
"source": "https://jr.jd.com/logo.png",
"file": "20.png",
"sha256": "8dc9703f571e605df552dc7eb14ae074d9ddab6b27a9140b610e87b1c2a5f693"
}
]
+7 -5
View File
@@ -9,7 +9,8 @@
"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:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts",
"icons:seed": "node scripts/seed-icons.cjs"
},
"dependencies": {
"@nestjs/common": "^11.0.0",
@@ -26,19 +27,20 @@
"multer": "^2.4.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"sharp": "^0.35.5",
"yauzl": "^3.4.0",
"zod": "^4.1.0"
},
"devDependencies": {
"@types/archiver": "^8.0.0",
"@types/cookie-parser": "^1.4.9",
"@types/express": "^5.0.0",
"@types/multer": "^2.3.0",
"@types/node": "^24.0.0",
"@types/yauzl": "^3.4.0",
"mysql2": "^3.15.0",
"prisma": "6.19.0",
"tsx": "^4.20.0",
"typescript": "^5.9.0",
"@types/archiver": "^8.0.0",
"@types/yauzl": "^3.4.0",
"@types/multer": "^2.3.0"
"typescript": "^5.9.0"
}
}
@@ -0,0 +1,16 @@
CREATE TABLE `Icon` (
`id` CHAR(36) NOT NULL,
`ownerId` CHAR(36) NULL,
`name` VARCHAR(100) NOT NULL,
`shared` BOOLEAN NOT NULL DEFAULT false,
`hash` CHAR(64) NOT NULL,
`data` MEDIUMBLOB NOT NULL,
`source` VARCHAR(500) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE INDEX `Icon_ownerId_hash_shared_key` (`ownerId`, `hash`, `shared`),
INDEX `Icon_shared_name_idx` (`shared`, `name`),
CONSTRAINT `Icon_ownerId_fkey` FOREIGN KEY (`ownerId`) REFERENCES `User` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `Position` ADD COLUMN `iconId` CHAR(36) NULL;
ALTER TABLE `Position` ADD CONSTRAINT `Position_iconId_fkey` FOREIGN KEY (`iconId`) REFERENCES `Icon` (`id`) ON DELETE SET NULL ON UPDATE CASCADE;
+18
View File
@@ -17,6 +17,7 @@ model User {
positions Position[]
rates ExchangeRate[]
sessions Session[]
icons Icon[]
}
model Session {
id String @id @db.Char(64)
@@ -44,6 +45,8 @@ model Position {
hidden Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
iconId String? @db.Char(36)
icon Icon? @relation(fields:[iconId],references:[id],onDelete:SetNull)
revisions Revision[]
outgoing PositionLink[] @relation("Source")
incoming PositionLink[] @relation("Target")
@@ -82,3 +85,18 @@ model ExchangeRate {
source String @db.VarChar(30)
@@unique([userId,currency,baseCurrency,date])
}
model Icon {
id String @id @default(uuid()) @db.Char(36)
ownerId String? @db.Char(36)
owner User? @relation(fields:[ownerId],references:[id],onDelete:SetNull)
name String @db.VarChar(100)
shared Boolean @default(false)
hash String @db.Char(64)
data Bytes @db.MediumBlob
source String? @db.VarChar(500)
createdAt DateTime @default(now())
positions Position[]
@@unique([ownerId,hash,shared])
@@index([shared,name])
}
+14
View File
@@ -0,0 +1,14 @@
"""Optional catalog maintenance: convert downloaded ICO files using Pillow.
Normal installs use the committed PNG assets and do not need Python.
Run after download-icons.cjs, before icons:seed.
"""
from pathlib import Path
from PIL import Image
folder = Path(__file__).resolve().parent.parent / 'assets' / 'icons'
for source in folder.glob('*.ico'):
with Image.open(source) as image:
image.convert('RGBA').save(source.with_suffix('.png'))
source.unlink()
print('Catalog ICO sources converted to PNG.')
+1 -1
View File
@@ -3,7 +3,7 @@ const tsc = require.resolve('typescript/bin/tsc');
if (spawnSync(process.execPath, [tsc], { stdio: 'inherit' }).status !== 0) process.exit(1);
const children = [
spawn(process.execPath, [tsc, '--watch', '--preserveWatchOutput'], { stdio: 'inherit' }),
spawn(process.execPath, ['--watch', 'dist/main.js'], { stdio: 'inherit' }),
spawn(process.execPath, ['--watch', '--watch-path=dist', 'dist/main.js'], { stdio: 'inherit' }),
];
function stop() {
for (const p of children) p.kill();
+70
View File
@@ -0,0 +1,70 @@
// Explicit, fixed public sources only. No user account information is sent.
const fs = require('node:fs/promises');
const path = require('node:path');
const sharp = require('sharp');
const { createHash } = require('node:crypto');
const dir = path.join(__dirname, '../assets/icons');
async function main() {
await fs.mkdir(dir, { recursive: true });
const response = await fetch('https://api.github.com/repos/cellier/bank-icon-cn/contents/png/72');
if (!response.ok) throw Error();
const banks = await response.json();
const names = [
'中国工商银行',
'中国农业银行',
'中国建设银行',
'中国银行',
'交通银行',
'招商银行',
'中信银行',
'中国光大银行',
'中国民生银行',
'兴业银行',
'平安银行',
'上海浦东发展银行',
'广发银行',
'华夏银行',
'北京银行',
'上海银行',
'浙商银行',
];
const sources = names.map((name) => ({
name,
source: banks.find((b) => b.name === name + '@3x.png')?.download_url,
}));
sources.push(
{ name: '支付宝', source: 'https://i.alipayobjects.com/common/favicon/favicon.ico' },
{ name: '微信', source: 'https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.ico' },
{ name: '京东金融', source: 'https://jr.jd.com/logo.png' },
);
const manifest = [];
for (const item of sources) {
if (!item.source) throw Error('Missing catalog source');
const res = await fetch(item.source, { signal: AbortSignal.timeout(20000) });
if (!res.ok) throw Error('Public icon download failed');
const raw = Buffer.from(await res.arrayBuffer());
const file =
String(manifest.length + 1).padStart(2, '0') +
(item.source.endsWith('.ico') ? '.ico' : '.png');
if (file.endsWith('.ico')) await fs.writeFile(path.join(dir, file), raw);
else
await fs.writeFile(
path.join(dir, file),
await sharp(raw)
.resize(256, 256, { fit: 'inside', withoutEnlargement: true })
.png()
.toBuffer(),
);
manifest.push({
...item,
file: file.replace('.ico', '.png'),
sha256: createHash('sha256').update(raw).digest('hex'),
});
}
await fs.writeFile(path.join(dir, 'sources.json'), JSON.stringify(manifest, null, 2) + '\n');
console.log(`Downloaded ${manifest.length} public icons; convert ICO sources before seeding.`);
}
main().catch(() => {
console.error('Icon source download failed; existing database icons were not changed.');
process.exitCode = 1;
});
+41
View File
@@ -0,0 +1,41 @@
require('dotenv/config');
const { PrismaClient } = require('@prisma/client');
const { readFile } = require('node:fs/promises');
const { join } = require('node:path');
const { createHash } = require('node:crypto');
const sharp = require('sharp');
const db = new PrismaClient();
async function main() {
const dir = join(__dirname, '../assets/icons');
const sources = JSON.parse(await readFile(join(dir, 'sources.json'), 'utf8'));
// Deterministic IDs make repeated runs safe, without overwriting existing records.
for (const item of sources) {
const data = await sharp(await readFile(join(dir, item.file)))
.resize(256, 256, { fit: 'inside', withoutEnlargement: true })
.png()
.toBuffer();
const hex = createHash('sha256')
.update('worthpath-builtin:' + item.name)
.digest('hex');
const id = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
await db.icon.upsert({
where: { id },
create: {
id,
name: item.name,
shared: true,
data,
source: item.source,
hash: createHash('sha256').update(data).digest('hex'),
},
update: {},
});
}
console.log(`Shared icon catalog ready: ${sources.length} icons.`);
}
main()
.catch(() => {
console.error('Icon seeding failed; check local database configuration.');
process.exitCode = 1;
})
.finally(() => db.$disconnect());
+62 -1
View File
@@ -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,
+134
View File
@@ -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
View File
@@ -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() {
+4
View File
@@ -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 };
}
+1
View File
@@ -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
View File
@@ -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(
+199
View File
@@ -0,0 +1,199 @@
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomBytes, randomUUID } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import sharp from 'sharp';
import { readBackupZip } from '../src/zip';
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
test('private and shared icons, account reuse and complete ZIP restoration preserve isolation', async () => {
const db = new PrismaClient(),
names: string[] = [],
publicIds: string[] = [];
async function call(path: string, cookie = '', method = 'GET', data?: unknown) {
const res = await fetch(base + path, {
method,
headers: {
Cookie: cookie,
Origin: process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!,
...(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)
: res.headers.get('content-type')?.includes('application/json')
? await res.json()
: Buffer.from(await res.arrayBuffer()),
cookie: res.headers.get('set-cookie')?.split(';')[0] || '',
};
}
async function account() {
const username = 'wp_icons_' + randomUUID();
names.push(username);
const r = await call('/auth/register', '', 'POST', {
username,
password: randomBytes(18).toString('hex'),
});
assert.equal(r.status, 201);
return { ...r, id: (await db.user.findUniqueOrThrow({ where: { username } })).id };
}
const image = await sharp({
create: { width: 400, height: 400, channels: 4, background: '#097c71' },
})
.png()
.toBuffer();
async function upload(
cookie: string,
name: string,
shared = false,
confirmed = false,
content = image,
) {
const f = new FormData();
f.append('file', new Blob([new Uint8Array(content)], { type: 'image/png' }), 'icon.png');
f.append('name', name);
f.append('shared', String(shared));
if (confirmed) f.append('confirmed', 'true');
const r = await fetch(base + '/icons/upload', {
method: 'POST',
headers: { Cookie: cookie, Origin: 'http://localhost:5173' },
body: f,
});
return { status: r.status, data: await r.json() };
}
try {
const a = await account(),
b = await account();
assert.equal((await call('/icons')).status, 401);
const own = await upload(a.cookie, '我的银行');
assert.equal(own.status, 201);
assert.equal((await upload(a.cookie, '同一图片')).data.id, own.data.id);
assert.equal((await call('/icons/' + own.data.id + '/image', b.cookie)).status, 404);
assert.equal(
(await call('/icons?q=' + encodeURIComponent('我的银行'), b.cookie)).data.total,
0,
);
const privateImage = await call('/icons/' + own.data.id + '/image', a.cookie);
assert.equal(privateImage.status, 200);
assert.equal((await sharp(privateImage.data).metadata()).width, 256);
assert.equal((await upload(a.cookie, 'English', true, true)).status, 400);
assert.equal((await upload(a.cookie, '共享银行', true)).status, 400);
assert.equal(
(await upload(a.cookie, '坏图标', false, false, Buffer.from('<svg onload="alert(1)"/>')))
.status,
400,
);
const shared = await upload(a.cookie, '共享测试银行', true, true);
assert.equal(shared.status, 201);
publicIds.push(shared.data.id);
assert.equal((await call('/icons/' + shared.data.id + '/image', b.cookie)).status, 200);
assert.equal(
(await call('/icons?q=' + encodeURIComponent('共享测试银行'), b.cookie)).data.items.some(
(i: any) => i.id === shared.data.id,
),
true,
);
const meta = {
kind: 'account',
side: 'asset',
category: 'bank',
name: '图标关联验收',
currency: 'CNY',
amount: '10',
date: '2026-09-01T10:35',
};
assert.equal(
(await call('/positions', b.cookie, 'POST', { ...meta, iconId: own.data.id })).status,
400,
);
const p = await call('/positions', a.cookie, 'POST', { ...meta, iconId: own.data.id });
assert.equal(p.status, 201);
assert.equal(
(await call('/positions', a.cookie, 'POST', { ...meta, iconId: own.data.id })).status,
201,
);
const other = await call('/positions', b.cookie, 'POST', { ...meta, iconId: shared.data.id });
assert.equal(other.status, 201);
assert.equal(
(
await call('/positions/' + other.data.id, b.cookie, 'PATCH', {
name: meta.name,
category: 'bank',
iconId: own.data.id,
})
).status,
400,
);
assert.equal(
(await db.position.findUniqueOrThrow({ where: { id: other.data.id } })).iconId,
shared.data.id,
);
assert.equal(
(
await call('/positions/' + p.data.id, a.cookie, 'PATCH', {
name: meta.name,
category: 'bank',
})
).status,
200,
);
assert.equal(
(await db.position.findUniqueOrThrow({ where: { id: p.data.id } })).iconId,
own.data.id,
);
const backup = await call('/backup', a.cookie);
assert.equal(backup.status, 200);
assert.equal(
backup.data.icons.some((i: any) => i.id === own.data.id),
true,
);
assert.doesNotMatch(JSON.stringify(backup.data), /passwordHash|sessionId|ownerId|token/i);
const broken = structuredClone(backup.data);
broken.icons[0].image = 'invalid';
assert.equal((await call('/backup/preview', b.cookie, 'POST', broken)).status, 400);
const before = await db.icon.count();
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup: broken })).status,
400,
);
assert.equal(await db.icon.count(), before);
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup: backup.data }))
.status,
201,
);
const imported = await db.position.findMany({
where: { userId: b.id, importedFromId: { not: null } },
include: { icon: true },
});
assert.equal(imported.length, 2);
assert.equal(imported[0].iconId, imported[1].iconId);
assert.equal(imported[0].icon?.ownerId, b.id);
assert.equal(imported[0].icon?.shared, false);
// Backup receipt remains valid; only temporary account A is cleared.
assert.equal(
(await call('/backup/clear', a.cookie, 'POST', { confirmation: '确定清空' })).status,
201,
);
assert.equal(await db.icon.count({ where: { id: own.data.id } }), 0);
assert.equal((await call('/icons/' + shared.data.id + '/image', b.cookie)).status, 200);
assert.equal(
(await db.position.findUniqueOrThrow({ where: { id: other.data.id } })).iconId,
shared.data.id,
);
assert.equal((await db.icon.count({ where: { ownerId: b.id, shared: false } })) > 0, true);
} finally {
const users = await db.user.findMany({
where: { username: { in: names } },
select: { id: true },
});
await db.icon.deleteMany({
where: { OR: [{ ownerId: { in: users.map((u) => u.id) } }, { id: { in: publicIds } }] },
});
await db.user.deleteMany({ where: { username: { in: names } } });
await db.$disconnect();
}
});
+14
View File
@@ -12,6 +12,7 @@ const empty = () =>
baseCurrency: 'CNY',
preferences: { showSidebar: false, idleMinutes: 9 },
currencies: ['CNY'],
icons: [],
positions: [],
rates: [],
links: [],
@@ -37,6 +38,7 @@ test('ZIP contains separate JSON files and restores settings without authenticat
'currencies.json',
'debts.json',
'history.json',
'icons.json',
'links.json',
'manifest.json',
'rates.json',
@@ -93,3 +95,15 @@ test('backup accepts over 1000 positions, 10000 revisions per position and 20000
21001,
);
});
test('legacy v3 ZIP remains readable without icons', async () => {
const contents = packBackup(empty());
delete contents['icons.json'];
const manifest = JSON.parse(contents['manifest.json']);
manifest.version = 3;
manifest.files = manifest.files.filter((f: { name: string }) => f.name !== 'icons.json');
contents['manifest.json'] = JSON.stringify(manifest);
const restored = validateBackup(await readBackupZip(await archive(contents)));
assert.equal(restored.icons, undefined);
assert.deepEqual(restored.positions, []);
});
+24 -4
View File
@@ -33,6 +33,7 @@ import {
type Total,
} from './api';
import './style.css';
import { IconLibrary, iconUrl } from './IconLibrary';
const labels: Record<string, string> = {
overview: '资产总览',
account: '账户',
@@ -606,7 +607,12 @@ export default function App() {
<div className="page-heading">
<div>
<p className="eyebrow">WORTHPATH / {today()}</p>
<h1>{p ? p.name : labels[page]}</h1>
<h1>
{p?.iconId && (
<img className="detail-icon" src={iconUrl(p.iconId)} alt="账户图标" />
)}
{p ? p.name : labels[page]}
</h1>
<p className="muted">
{page === 'overview'
? '你的财富全貌,从这里开始。'
@@ -889,7 +895,9 @@ export default function App() {
>
<div>
<span className="position-icon">
{p.kind === 'asset' ? (
{p.iconId ? (
<img src={iconUrl(p.iconId)} alt="账户图标" />
) : p.kind === 'asset' ? (
<Landmark />
) : p.kind === 'debt' ? (
<HandCoins />
@@ -943,6 +951,9 @@ export default function App() {
)}
{page === 'settings' && (
<>
<section className="panel">
<IconLibrary />
</section>
<div className="settings-grid">
<section className="panel">
<div className="panel-title">
@@ -1079,7 +1090,7 @@ export default function App() {
<section className="panel">
<h2>清空本账号数据</h2>
<p className="muted">
清除本账号全部账户、资产、债务、历史和汇率(包括隐藏项目);保留登录账号及个人设置。请先下载备份并确认文件已保存。
清除本账号全部账户、资产、债务、历史、私有图标和汇率(包括隐藏项目);保留登录账号、个人设置和已发布的共享图标。请先下载备份并确认文件已保存。
</p>
{clearStep === 0 ? (
<a
@@ -1138,7 +1149,7 @@ export default function App() {
<h2>数据备份与恢复</h2>
<p className="muted">
ZIP 内分文件保存可读
JSON,包括全部账户、资产、债务、历史、关联、币种、设置和汇率,不限制记录条数;不包含任何认证凭据。备份含个人财务信息,请妥善保管。
JSON,包括全部账户、资产、债务、历史、关联、币种、设置、汇率及图标,不限制记录条数;不包含任何认证凭据。备份含个人财务信息,请妥善保管。
</p>
<div className="actions">
<a
@@ -1289,6 +1300,7 @@ export default function App() {
() =>
api('/positions/' + mp!.id, 'PATCH', {
...v,
...(f.has('iconId') ? { iconId: f.get('iconId') || null } : {}),
archived: mp!.archived,
hidden: f.get('hidden') === 'on',
}),
@@ -1305,6 +1317,7 @@ export default function App() {
() =>
api('/positions', 'POST', {
...v,
...(f.has('iconId') ? { iconId: f.get('iconId') || null } : {}),
kind,
side,
hidden: f.get('hidden') === 'on',
@@ -1331,6 +1344,13 @@ export default function App() {
<input name="hidden" type="checkbox" defaultChecked={modal.p?.hidden} />
隐藏此项目(密码验证后可查看和编辑)
</label>
{(modal.kind === 'account' || modal.p?.kind === 'account') && (
<IconLibrary
key={modal.p?.id || 'new-account'}
picker
initialId={modal.p?.iconId}
/>
)}
<Field label="名称">
<input
name="name"
+235
View File
@@ -0,0 +1,235 @@
import { useEffect, useState, useRef } from 'react';
import { api } from './api';
type Icon = { id: string; name: string; shared: boolean; source: string | null };
export const iconUrl = (id: string) => '/api/icons/' + encodeURIComponent(id) + '/image';
export function IconLibrary({
initialId,
picker = false,
}: {
initialId?: string | null;
picker?: boolean;
}) {
const [selected, select] = useState(initialId || ''),
[q, search] = useState(''),
[page, setPage] = useState(1),
[items, setItems] = useState<Icon[]>([]),
[total, setTotal] = useState(0),
[loading, setLoading] = useState(false),
[name, setName] = useState(''),
[shared, setShared] = useState(false),
[confirmed, setConfirmed] = useState(false),
[busy, setBusy] = useState(false),
[error, setError] = useState(''),
[notice, setNotice] = useState(''),
[refresh, setRefresh] = useState(0);
const file = useRef<HTMLInputElement>(null);
useEffect(() => {
let active = true;
setLoading(true);
const timer = setTimeout(() => {
void api<{ items: Icon[]; total: number }>(
'/icons?q=' + encodeURIComponent(q) + '&page=' + page,
)
.then((v) => {
if (active) {
setItems(v.items);
setTotal(v.total);
}
})
.catch((e) => {
if (active) setError(e.message);
})
.finally(() => {
if (active) setLoading(false);
});
}, 250);
return () => {
active = false;
clearTimeout(timer);
};
}, [q, page, refresh]);
async function upload() {
const image = file.current?.files?.[0];
setError('');
setNotice('');
if (!image) {
setError('请选择图片');
return;
}
if (!name.trim() || (shared && (!/\p{Script=Han}/u.test(name) || !confirmed))) {
setError('请填写名称;共享图标须含中文并确认公开');
return;
}
if (image.size > 2 * 1024 * 1024) {
setError('图片不能超过 2 MB');
return;
}
setBusy(true);
try {
const form = new FormData();
form.append('file', image);
form.append('name', name);
form.append('shared', String(shared));
if (shared) form.append('confirmed', 'true');
const res = await fetch('/api/icons/upload', {
method: 'POST',
body: form,
credentials: 'same-origin',
});
const v = await res.json();
if (!res.ok) throw Error(v.message || '上传失败');
if (picker) select(v.id);
setNotice(
shared ? '图标已发布,所有登录用户均可搜索和使用' : '图标已存入我的图标,仅自己可见',
);
setName('');
setConfirmed(false);
if (file.current) file.current.value = '';
search('');
setPage(1);
setRefresh((n) => n + 1);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<section
className="icon-library"
aria-label="账户图标库"
onKeyDown={(e) => {
if (
e.key === 'Enter' &&
e.target instanceof HTMLInputElement &&
e.target.type !== 'checkbox'
)
e.preventDefault();
}}
>
<h2>{picker ? '选择账户图标' : '图标库'}</h2>
<p className="muted">
搜索共享图标和我的图标,选择后可在多个账户复用。直接上传默认仅自己可见。
</p>
{picker && (
<div className="icon-selection">
<input type="hidden" name="iconId" value={selected} />
{selected ? (
<>
<img src={iconUrl(selected)} alt="当前账户图标" />
<span>已选择图标</span>
<button type="button" className="text" onClick={() => select('')}>
移除图标
</button>
</>
) : (
<span>使用默认账户图标</span>
)}
</div>
)}
<label>
搜索图标名称
<input
type="search"
value={q}
placeholder="例如:工商银行、支付宝"
onChange={(e) => {
search(e.target.value);
setPage(1);
}}
maxLength={100}
/>
</label>
{loading ? (
<p role="status">正在加载图标…</p>
) : (
<>
<div className="icon-grid">
{items.map((i) =>
picker ? (
<button
type="button"
className={'icon-option' + (selected === i.id ? ' selected' : '')}
key={i.id}
aria-pressed={picker ? selected === i.id : undefined}
onClick={() => select(i.id)}
>
<img src={iconUrl(i.id)} alt="" loading="lazy" />
<span>{i.name}</span>
<small>{i.shared ? '共享' : '仅自己'}</small>
</button>
) : (
<div className="icon-option" key={i.id}>
<img src={iconUrl(i.id)} alt="" loading="lazy" />
<span>{i.name}</span>
<small>{i.shared ? '共享' : '仅自己'}</small>
</div>
),
)}
</div>
{!items.length && <p>没有匹配图标,可以在下方上传。</p>}
<div className="icon-pagination">
<span>共 {total} 个图标</span>
<button
className="secondary"
type="button"
disabled={page === 1}
onClick={() => setPage((n) => n - 1)}
>
上一页
</button>
<button
className="secondary"
type="button"
disabled={page * 60 >= total}
onClick={() => setPage((n) => n + 1)}
>
下一页
</button>
</div>
</>
)}
<details className="icon-upload">
<summary>上传我的图标 / 导入共享图标</summary>
<label>
图标名称(共享时必须含中文)
<input value={name} onChange={(e) => setName(e.target.value)} maxLength={100} />
</label>
<label>
图片文件(PNG、JPG、WebP,最大 2 MB)
<input ref={file} type="file" accept="image/png,image/jpeg,image/webp" />
</label>
<label className="check-line">
<input
type="checkbox"
checked={shared}
onChange={(e) => {
setShared(e.target.checked);
setConfirmed(false);
}}
/>
发布到共享图标库
</label>
{shared && (
<label className="check-line">
<input
type="checkbox"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
/>
确认此图片可公开,所有用户均可搜索并复用;已发布图标会保留供他人使用
</label>
)}
<button className="primary" type="button" disabled={busy} onClick={() => void upload()}>
{busy ? '正在上传…' : shared ? '导入共享图标' : '上传私有图标'}
</button>
</details>
{error && (
<p role="alert" className="icon-error">
{error}
</p>
)}
{notice && <p role="status">{notice}</p>}
</section>
);
}
+1
View File
@@ -43,6 +43,7 @@ export type History = {
reason: string;
};
export type Position = {
iconId?: string | null;
id: string;
kind: string;
side: string;
+95
View File
@@ -901,3 +901,98 @@ footer {
font-size: 11px;
}
}
.position-icon img {
width: 32px;
height: 32px;
object-fit: contain;
}
.icon-library {
display: grid;
gap: 12px;
min-width: 0;
}
.icon-library h2,
.icon-library p {
margin: 0;
}
.icon-library label {
display: grid;
gap: 6px;
}
.icon-library .check-line {
display: flex;
}
.icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 8px;
max-height: 340px;
overflow: auto;
padding: 4px;
}
.icon-option {
background: var(--surface, #fff);
color: inherit;
border: 1px solid #dce3e5;
padding: 10px 6px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
min-width: 0;
}
.icon-option img {
width: 40px;
height: 40px;
object-fit: contain;
}
.icon-option span {
font-size: 12px;
overflow-wrap: anywhere;
}
.icon-option small {
color: #64748b;
}
.icon-option:disabled {
opacity: 1;
cursor: default;
}
.icon-option.selected {
border: 2px solid #087e70;
background: #effbf7;
}
.icon-selection,
.icon-pagination {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.icon-selection img {
width: 40px;
height: 40px;
object-fit: contain;
}
.icon-upload {
border-top: 1px solid #dce3e5;
padding-top: 12px;
}
.icon-upload summary {
cursor: pointer;
margin-bottom: 10px;
}
.icon-upload label {
margin-bottom: 10px;
}
.icon-error {
color: #b42318;
}
.detail-icon {
width: 42px;
height: 42px;
object-fit: contain;
vertical-align: middle;
margin-right: 12px;
}
+6
View File
@@ -33,3 +33,9 @@
- 手动汇率写入 API 返回 404,界面入口已移除;已有历史汇率未删除。
内网穿透测试更新:支持本地 `WEB_ORIGIN=*`;HTTP/HTTPS 来源探测均通过来源校验,缺失或 null 来源拒绝。精确来源模式及生产禁止通配符的单元校验通过。此轮 14 项单元测试、2 项数据库集成测试、类型检查和构建通过;未执行外网穿透链路端到端验收。
## 账户图标更新
新增迁移 005_account_icons,不修改既有迁移或财务数据;预置 20 个图标,使用固定 ID 幂等初始化。构建/类型检查和 ZIP v3/v4 单元回归通过。新增真实数据库集成覆盖私有图标跨用户不可检索/读取/赋值、共享中文名称和公开确认、图片规范化与恶意格式拒绝、相同图片多账户复用、ZIP 图标内容/关联恢复、损坏图标整次导入拒绝、清空保留他人引用共享图标。测试仅清理随机验收用户和他们上传的图标。
桌面浏览器实际验收:中文检索支付宝、选择图标后保存账户、私有图片上传及共享中文名称/公开确认发布成功。390×844 手机布局无横向溢出,全部图标正常加载。原生 ZIP 下载包含 10 个 JSON 文件,manifest v4,图标内容和账户关联均存在。开发进程改为只监听 dist,避免首次加载图片处理依赖导致不必要的自动重启。
+6
View File
@@ -25,3 +25,9 @@ Revision 保存按业务日期生效的绝对金额,每次金额更新新增
安全清空:先通过认证下载备份,在当前 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 已删除,既有历史汇率保留。
## 账户图标模块
Icon 存储 name、ownerId、shared、SHA-256、规范静态 PNG 的 MediumBlob 和可选公开来源;Position.iconId 外键 SetNull,多个账户共享同一图片。所有图标接口使用已验证身份,读取/检索/赋值均限定 shared=true 或 ownerId=当前用户,响应不返回 ownerId。个人上传默认私有;共享发布须中文名称和明确公开确认。拒绝 SVG、动图、损坏图片、超限像素/文件,重编码移除元数据。内置图标固定 ID 追加初始化;在线请求不会发送用户财务数据。
备份逻辑增加可选 icons 数组(旧格式缺失可兼容),v4 ZIP 将图标内容放入 icons.json;内容校验和解码在导入事务前完成,图标在事务内以当前用户私有范围重建,账户关联重映射。清空删除私有图标,公开共享图标不因发布者清空而消失。用户删除时图标 ownerId SetNull,不影响他人已引用的公共图标。
+334
View File
@@ -56,6 +56,9 @@ importers:
rxjs:
specifier: ^7.8.2
version: 7.8.2
sharp:
specifier: ^0.35.5
version: 0.35.5(@types/node@24.19.0)
yauzl:
specifier: ^3.4.0
version: 3.4.0
@@ -210,6 +213,9 @@ packages:
'@borewit/text-codec@0.2.2':
resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
'@emnapi/runtime@1.11.3':
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
'@esbuild/aix-ppc64@0.28.2':
resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
engines: {node: '>=18'}
@@ -366,6 +372,168 @@ packages:
cpu: [x64]
os: [win32]
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.35.5':
resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.35.5':
resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [darwin]
'@img/sharp-freebsd-wasm32@0.35.5':
resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==}
engines: {node: '>=20.9.0'}
os: [freebsd]
'@img/sharp-libvips-darwin-arm64@1.3.4':
resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.3.4':
resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.3.4':
resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.3.4':
resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.3.4':
resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.3.4':
resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.3.4':
resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.3.4':
resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.3.4':
resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.3.4':
resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.35.5':
resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.35.5':
resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==}
engines: {node: '>=20.9.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.35.5':
resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==}
engines: {node: '>=20.9.0'}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.35.5':
resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==}
engines: {node: '>=20.9.0'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.35.5':
resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==}
engines: {node: '>=20.9.0'}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.35.5':
resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.35.5':
resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.35.5':
resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.35.5':
resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==}
engines: {node: '>=20.9.0'}
'@img/sharp-webcontainers-wasm32@0.35.5':
resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==}
engines: {node: '>=20.9.0'}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.35.5':
resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.35.5':
resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==}
engines: {node: ^20.9.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.35.5':
resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [win32]
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -916,6 +1084,10 @@ packages:
destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
@@ -1377,6 +1549,11 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
send@1.2.1:
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
engines: {node: '>= 18'}
@@ -1388,6 +1565,15 @@ packages:
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
sharp@0.35.5:
resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==}
engines: {node: '>=20.9.0'}
peerDependencies:
'@types/node': '*'
peerDependenciesMeta:
'@types/node':
optional: true
side-channel-list@1.0.1:
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
engines: {node: '>= 0.4'}
@@ -1680,6 +1866,11 @@ snapshots:
'@borewit/text-codec@0.2.2': {}
'@emnapi/runtime@1.11.3':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.28.2':
optional: true
@@ -1758,6 +1949,112 @@ snapshots:
'@esbuild/win32-x64@0.28.2':
optional: true
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.35.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.3.4
optional: true
'@img/sharp-darwin-x64@0.35.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.3.4
optional: true
'@img/sharp-freebsd-wasm32@0.35.5':
dependencies:
'@img/sharp-wasm32': 0.35.5
optional: true
'@img/sharp-libvips-darwin-arm64@1.3.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.3.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.3.4':
optional: true
'@img/sharp-libvips-linux-arm@1.3.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.3.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.3.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.3.4':
optional: true
'@img/sharp-libvips-linux-x64@1.3.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.3.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.3.4':
optional: true
'@img/sharp-linux-arm64@0.35.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.3.4
optional: true
'@img/sharp-linux-arm@0.35.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.3.4
optional: true
'@img/sharp-linux-ppc64@0.35.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.3.4
optional: true
'@img/sharp-linux-riscv64@0.35.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.3.4
optional: true
'@img/sharp-linux-s390x@0.35.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.3.4
optional: true
'@img/sharp-linux-x64@0.35.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.3.4
optional: true
'@img/sharp-linuxmusl-arm64@0.35.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.3.4
optional: true
'@img/sharp-linuxmusl-x64@0.35.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.3.4
optional: true
'@img/sharp-wasm32@0.35.5':
dependencies:
'@emnapi/runtime': 1.11.3
optional: true
'@img/sharp-webcontainers-wasm32@0.35.5':
dependencies:
'@img/sharp-wasm32': 0.35.5
optional: true
'@img/sharp-win32-arm64@0.35.5':
optional: true
'@img/sharp-win32-ia32@0.35.5':
optional: true
'@img/sharp-win32-x64@0.35.5':
optional: true
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
@@ -2256,6 +2553,8 @@ snapshots:
destr@2.0.5: {}
detect-libc@2.1.2: {}
dotenv@16.6.1: {}
dotenv@17.4.2: {}
@@ -2774,6 +3073,8 @@ snapshots:
semver@6.3.1: {}
semver@7.8.5: {}
send@1.2.1:
dependencies:
debug: 4.4.3
@@ -2801,6 +3102,39 @@ snapshots:
setprototypeof@1.2.0: {}
sharp@0.35.5(@types/node@24.19.0):
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.5
optionalDependencies:
'@img/sharp-darwin-arm64': 0.35.5
'@img/sharp-darwin-x64': 0.35.5
'@img/sharp-freebsd-wasm32': 0.35.5
'@img/sharp-libvips-darwin-arm64': 1.3.4
'@img/sharp-libvips-darwin-x64': 1.3.4
'@img/sharp-libvips-linux-arm': 1.3.4
'@img/sharp-libvips-linux-arm64': 1.3.4
'@img/sharp-libvips-linux-ppc64': 1.3.4
'@img/sharp-libvips-linux-riscv64': 1.3.4
'@img/sharp-libvips-linux-s390x': 1.3.4
'@img/sharp-libvips-linux-x64': 1.3.4
'@img/sharp-libvips-linuxmusl-arm64': 1.3.4
'@img/sharp-libvips-linuxmusl-x64': 1.3.4
'@img/sharp-linux-arm': 0.35.5
'@img/sharp-linux-arm64': 0.35.5
'@img/sharp-linux-ppc64': 0.35.5
'@img/sharp-linux-riscv64': 0.35.5
'@img/sharp-linux-s390x': 0.35.5
'@img/sharp-linux-x64': 0.35.5
'@img/sharp-linuxmusl-arm64': 0.35.5
'@img/sharp-linuxmusl-x64': 0.35.5
'@img/sharp-webcontainers-wasm32': 0.35.5
'@img/sharp-win32-arm64': 0.35.5
'@img/sharp-win32-ia32': 0.35.5
'@img/sharp-win32-x64': 0.35.5
'@types/node': 24.19.0
side-channel-list@1.0.1:
dependencies:
es-errors: 1.3.0