diff --git a/README.md b/README.md index 7417bfc..8d93d9b 100644 --- a/README.md +++ b/README.md @@ -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 仍可导入。清空个人数据会删除私有图标,已发布共享图标保留供其他用户使用。 diff --git a/apps/api/assets/icons/01.png b/apps/api/assets/icons/01.png new file mode 100644 index 0000000..ada6b08 Binary files /dev/null and b/apps/api/assets/icons/01.png differ diff --git a/apps/api/assets/icons/02.png b/apps/api/assets/icons/02.png new file mode 100644 index 0000000..4bd01e2 Binary files /dev/null and b/apps/api/assets/icons/02.png differ diff --git a/apps/api/assets/icons/03.png b/apps/api/assets/icons/03.png new file mode 100644 index 0000000..8750346 Binary files /dev/null and b/apps/api/assets/icons/03.png differ diff --git a/apps/api/assets/icons/04.png b/apps/api/assets/icons/04.png new file mode 100644 index 0000000..44df52d Binary files /dev/null and b/apps/api/assets/icons/04.png differ diff --git a/apps/api/assets/icons/05.png b/apps/api/assets/icons/05.png new file mode 100644 index 0000000..979cfa2 Binary files /dev/null and b/apps/api/assets/icons/05.png differ diff --git a/apps/api/assets/icons/06.png b/apps/api/assets/icons/06.png new file mode 100644 index 0000000..246d748 Binary files /dev/null and b/apps/api/assets/icons/06.png differ diff --git a/apps/api/assets/icons/07.png b/apps/api/assets/icons/07.png new file mode 100644 index 0000000..f84c826 Binary files /dev/null and b/apps/api/assets/icons/07.png differ diff --git a/apps/api/assets/icons/08.png b/apps/api/assets/icons/08.png new file mode 100644 index 0000000..0c664b8 Binary files /dev/null and b/apps/api/assets/icons/08.png differ diff --git a/apps/api/assets/icons/09.png b/apps/api/assets/icons/09.png new file mode 100644 index 0000000..eb70575 Binary files /dev/null and b/apps/api/assets/icons/09.png differ diff --git a/apps/api/assets/icons/10.png b/apps/api/assets/icons/10.png new file mode 100644 index 0000000..84af9f6 Binary files /dev/null and b/apps/api/assets/icons/10.png differ diff --git a/apps/api/assets/icons/11.png b/apps/api/assets/icons/11.png new file mode 100644 index 0000000..7c55c77 Binary files /dev/null and b/apps/api/assets/icons/11.png differ diff --git a/apps/api/assets/icons/12.png b/apps/api/assets/icons/12.png new file mode 100644 index 0000000..fbf6c64 Binary files /dev/null and b/apps/api/assets/icons/12.png differ diff --git a/apps/api/assets/icons/13.png b/apps/api/assets/icons/13.png new file mode 100644 index 0000000..b3217d9 Binary files /dev/null and b/apps/api/assets/icons/13.png differ diff --git a/apps/api/assets/icons/14.png b/apps/api/assets/icons/14.png new file mode 100644 index 0000000..53be6dd Binary files /dev/null and b/apps/api/assets/icons/14.png differ diff --git a/apps/api/assets/icons/15.png b/apps/api/assets/icons/15.png new file mode 100644 index 0000000..dc10355 Binary files /dev/null and b/apps/api/assets/icons/15.png differ diff --git a/apps/api/assets/icons/16.png b/apps/api/assets/icons/16.png new file mode 100644 index 0000000..27e7892 Binary files /dev/null and b/apps/api/assets/icons/16.png differ diff --git a/apps/api/assets/icons/17.png b/apps/api/assets/icons/17.png new file mode 100644 index 0000000..4a40642 Binary files /dev/null and b/apps/api/assets/icons/17.png differ diff --git a/apps/api/assets/icons/18.png b/apps/api/assets/icons/18.png new file mode 100644 index 0000000..4aa2c67 Binary files /dev/null and b/apps/api/assets/icons/18.png differ diff --git a/apps/api/assets/icons/19.png b/apps/api/assets/icons/19.png new file mode 100644 index 0000000..b2b4c07 Binary files /dev/null and b/apps/api/assets/icons/19.png differ diff --git a/apps/api/assets/icons/20.png b/apps/api/assets/icons/20.png new file mode 100644 index 0000000..86a0a87 Binary files /dev/null and b/apps/api/assets/icons/20.png differ diff --git a/apps/api/assets/icons/sources.json b/apps/api/assets/icons/sources.json new file mode 100644 index 0000000..2c35ef8 --- /dev/null +++ b/apps/api/assets/icons/sources.json @@ -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" + } +] diff --git a/apps/api/package.json b/apps/api/package.json index ee52984..254ddd4 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" } } diff --git a/apps/api/prisma/migrations/005_account_icons/migration.sql b/apps/api/prisma/migrations/005_account_icons/migration.sql new file mode 100644 index 0000000..9992806 --- /dev/null +++ b/apps/api/prisma/migrations/005_account_icons/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 53c9019..6c426f4 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) +} diff --git a/apps/api/scripts/convert-icons.py b/apps/api/scripts/convert-icons.py new file mode 100644 index 0000000..c46c552 --- /dev/null +++ b/apps/api/scripts/convert-icons.py @@ -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.') diff --git a/apps/api/scripts/dev.cjs b/apps/api/scripts/dev.cjs index 7b6a272..942028d 100644 --- a/apps/api/scripts/dev.cjs +++ b/apps/api/scripts/dev.cjs @@ -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(); diff --git a/apps/api/scripts/download-icons.cjs b/apps/api/scripts/download-icons.cjs new file mode 100644 index 0000000..855170c --- /dev/null +++ b/apps/api/scripts/download-icons.cjs @@ -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; +}); diff --git a/apps/api/scripts/seed-icons.cjs b/apps/api/scripts/seed-icons.cjs new file mode 100644 index 0000000..d5da16e --- /dev/null +++ b/apps/api/scripts/seed-icons.cjs @@ -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()); diff --git a/apps/api/src/backup.ts b/apps/api/src/backup.ts index 9dcdfea..21c0e36 100644 --- a/apps/api/src/backup.ts +++ b/apps/api/src/backup.ts @@ -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(); 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(); + 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(); + 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(); 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, diff --git a/apps/api/src/icons.ts b/apps/api/src/icons.ts new file mode 100644 index 0000000..8ef85a3 --- /dev/null +++ b/apps/api/src/icons.ts @@ -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; + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index d266178..6d6d464 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -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() { diff --git a/apps/api/src/portfolio.ts b/apps/api/src/portfolio.ts index 488f7a2..9690249 100644 --- a/apps/api/src/portfolio.ts +++ b/apps/api/src/portfolio.ts @@ -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 }; } diff --git a/apps/api/src/validation.ts b/apps/api/src/validation.ts index 1c0175c..b406f7c 100644 --- a/apps/api/src/validation.ts +++ b/apps/api/src/validation.ts @@ -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, diff --git a/apps/api/src/zip.ts b/apps/api/src/zip.ts index 5cc869f..9d9f496 100644 --- a/apps/api/src/zip.ts +++ b/apps/api/src/zip.ts @@ -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 { }); 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 { 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( diff --git a/apps/api/test/icons.test.ts b/apps/api/test/icons.test.ts new file mode 100644 index 0000000..a66b7e2 --- /dev/null +++ b/apps/api/test/icons.test.ts @@ -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(''))) + .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(); + } +}); diff --git a/apps/api/test/zip.test.ts b/apps/api/test/zip.test.ts index fd82136..b79ad49 100644 --- a/apps/api/test/zip.test.ts +++ b/apps/api/test/zip.test.ts @@ -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, []); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 457fb33..bfe2ee9 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -33,6 +33,7 @@ import { type Total, } from './api'; import './style.css'; +import { IconLibrary, iconUrl } from './IconLibrary'; const labels: Record = { overview: '资产总览', account: '账户', @@ -606,7 +607,12 @@ export default function App() {

WORTHPATH / {today()}

-

{p ? p.name : labels[page]}

+

+ {p?.iconId && ( + 账户图标 + )} + {p ? p.name : labels[page]} +

{page === 'overview' ? '你的财富全貌,从这里开始。' @@ -889,7 +895,9 @@ export default function App() { >

- {p.kind === 'asset' ? ( + {p.iconId ? ( + 账户图标 + ) : p.kind === 'asset' ? ( ) : p.kind === 'debt' ? ( @@ -943,6 +951,9 @@ export default function App() { )} {page === 'settings' && ( <> +
+ +
@@ -1079,7 +1090,7 @@ export default function App() {

清空本账号数据

- 清除本账号全部账户、资产、债务、历史和汇率(包括隐藏项目);保留登录账号及个人设置。请先下载备份并确认文件已保存。 + 清除本账号全部账户、资产、债务、历史、私有图标和汇率(包括隐藏项目);保留登录账号、个人设置和已发布的共享图标。请先下载备份并确认文件已保存。

{clearStep === 0 ? ( 数据备份与恢复

ZIP 内分文件保存可读 - JSON,包括全部账户、资产、债务、历史、关联、币种、设置和汇率,不限制记录条数;不包含任何认证凭据。备份含个人财务信息,请妥善保管。 + JSON,包括全部账户、资产、债务、历史、关联、币种、设置、汇率及图标,不限制记录条数;不包含任何认证凭据。备份含个人财务信息,请妥善保管。

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() { 隐藏此项目(密码验证后可查看和编辑) + {(modal.kind === 'account' || modal.p?.kind === 'account') && ( + + )} '/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([]), + [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(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 ( +
{ + if ( + e.key === 'Enter' && + e.target instanceof HTMLInputElement && + e.target.type !== 'checkbox' + ) + e.preventDefault(); + }} + > +

{picker ? '选择账户图标' : '图标库'}

+

+ 搜索共享图标和我的图标,选择后可在多个账户复用。直接上传默认仅自己可见。 +

+ {picker && ( +
+ + {selected ? ( + <> + 当前账户图标 + 已选择图标 + + + ) : ( + 使用默认账户图标 + )} +
+ )} + + {loading ? ( +

正在加载图标…

+ ) : ( + <> +
+ {items.map((i) => + picker ? ( + + ) : ( +
+ + {i.name} + {i.shared ? '共享' : '仅自己'} +
+ ), + )} +
+ {!items.length &&

没有匹配图标,可以在下方上传。

} +
+ 共 {total} 个图标 + + +
+ + )} +
+ 上传我的图标 / 导入共享图标 + + + + {shared && ( + + )} + +
+ {error && ( +

+ {error} +

+ )} + {notice &&

{notice}

} +
+ ); +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 83b7374..36dd8c7 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -43,6 +43,7 @@ export type History = { reason: string; }; export type Position = { + iconId?: string | null; id: string; kind: string; side: string; diff --git a/apps/web/src/style.css b/apps/web/src/style.css index e25365d..1141791 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -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; +} diff --git a/docs/acceptance.md b/docs/acceptance.md index 1c92719..242af38 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -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,避免首次加载图片处理依赖导致不必要的自动重启。 diff --git a/docs/architecture.md b/docs/architecture.md index 1c9d743..c7c70b0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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,不影响他人已引用的公共图标。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6bc543..1096679 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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