From ba1fe54ef85093614ebbfe61d7f290d62c855bb8 Mon Sep 17 00:00:00 2001 From: xiaojunnuo Date: Mon, 25 May 2026 23:05:23 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E4=BC=98=E5=8C=96=E7=A7=81=E6=9C=89?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E4=B8=8A=E4=BC=A0=E5=92=8C=E6=9F=A5=E7=9C=8B?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- .../libs/lib-server/src/basic/constants.ts | 2 + .../system/basic/service/file-service.test.ts | 43 ++++++++++++++ .../src/system/basic/service/file-service.ts | 4 +- .../src/views/certd/wallet/crud-withdraw.tsx | 8 ++- .../src/views/certd/wallet/index.vue | 43 +++++++++++--- .../views/sys/suite/invite/crud-withdraw.tsx | 10 +++- .../src/controller/basic/file-controller.ts | 31 +++++++--- .../src/middleware/authority.test.ts | 56 +++++++++++++++++++ .../certd-server/src/middleware/authority.ts | 56 +++++++++++-------- 10 files changed, 212 insertions(+), 43 deletions(-) create mode 100644 packages/libs/lib-server/src/system/basic/service/file-service.test.ts create mode 100644 packages/ui/certd-server/src/middleware/authority.test.ts diff --git a/AGENTS.md b/AGENTS.md index 43cc830b9..cd4f1545c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,6 +177,7 @@ Certd 是一个支持私有化部署的 SSL/TLS 证书自动化管理平台。 - 优先沿用现有模块、插件、服务模式,再考虑新增抽象。 - `packages/ui/certd-server/data/`、`logs/`、生成的 metadata/dist 等通常视为运行时或构建产物,除非任务明确要求处理它们。 - 注意本地数据和配置里可能包含凭据、证书材料等敏感信息。 +- 使用 `/basic/file/upload` 上传文件后,接口返回的是临时缓存 key。业务保存表单或设置时,后端必须调用 `FileService.saveFile(userId, key, "public" | "private")` 转成永久文件 key 后再入库/入设置;不要直接保存 `tmpfile_key_...`,否则后续回显或下载会失效。 - 本仓库代码注释优先使用中文,尤其是解释业务规则、兼容逻辑、协议细节和隐藏风险时;除非文件已有明确英文注释风格或引用外部英文术语,否则不要新增英文说明性注释。 - 代码可读性优先于短写法。遇到包含业务分支的复杂三元表达式、内联对象、链式调用或条件组合时,优先拆成命名清晰的中间变量、独立分支或小函数,让读代码的人能一眼看出业务意图;不要为了少写几行把逻辑压成难读的一坨。 @@ -232,4 +233,3 @@ Get-ChildItem packages\ui\certd-client\src\views\certd - 优先对改动包运行聚焦的测试;后端可按包运行单元测试,前端优先使用 Prettier/ESLint 做改动文件验证。只有跨包影响明显时再考虑全 monorepo 构建。 - 不要主动运行 `pnpm install` 安装依赖:用户会事先准备好 `node_modules`。如果 `pnpm install` 或 `test:unit` 因缺少依赖、TTY 或网络问题失败,立即停止尝试,告知用户解决环境问题。 - diff --git a/packages/libs/lib-server/src/basic/constants.ts b/packages/libs/lib-server/src/basic/constants.ts index 407683b8c..73de33205 100644 --- a/packages/libs/lib-server/src/basic/constants.ts +++ b/packages/libs/lib-server/src/basic/constants.ts @@ -8,6 +8,8 @@ export const Constants = { guest: '_guest_', //无需登录 anonymous: '_guest_', + //无需登录,有 token 时解析当前用户 + guestOptionalAuth: '_guestOptionalAuth_', //仅需要登录 authOnly: '_authOnly_', //仅需要登录 diff --git a/packages/libs/lib-server/src/system/basic/service/file-service.test.ts b/packages/libs/lib-server/src/system/basic/service/file-service.test.ts new file mode 100644 index 000000000..b5dfca3aa --- /dev/null +++ b/packages/libs/lib-server/src/system/basic/service/file-service.test.ts @@ -0,0 +1,43 @@ +/// +/// + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { FileService } from "./file-service.js"; + +function createUploadFile(key: string) { + const uploadRootDir = "./data/upload"; + const filePath = path.join(uploadRootDir, key); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, "test"); + return filePath; +} + +describe("FileService.getFile", () => { + let cwd: string; + let oldCwd: string; + + beforeEach(() => { + oldCwd = process.cwd(); + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "certd-file-service-")); + process.chdir(cwd); + }); + + afterEach(() => { + process.chdir(oldCwd); + fs.rmSync(cwd, { recursive: true, force: true }); + }); + + it("allows admin to read another user's private file", () => { + const service = new FileService(); + const userIdMd5 = Buffer.from(Buffer.from("2").toString("base64")).toString("hex"); + const key = `/private/${userIdMd5}/2026_05_25/qr.png`; + const expectedPath = createUploadFile(key); + + const filePath = service.getFile(key, 1, true); + + assert.equal(filePath, expectedPath); + }); +}); diff --git a/packages/libs/lib-server/src/system/basic/service/file-service.ts b/packages/libs/lib-server/src/system/basic/service/file-service.ts index deb1a3012..4dc782f8e 100644 --- a/packages/libs/lib-server/src/system/basic/service/file-service.ts +++ b/packages/libs/lib-server/src/system/basic/service/file-service.ts @@ -56,7 +56,7 @@ export class FileService { return key; } - getFile(key: string, userId?: number) { + getFile(key: string, userId?: number, allowAnyPrivateUser = false) { if (!key) { throw new ParamException('参数错误'); } @@ -70,7 +70,7 @@ export class FileService { const keyArr = key.split('/'); const permission = keyArr[1]; const userIdMd5 = keyArr[2]; - if (permission !== 'public') { + if (permission !== 'public' && !allowAnyPrivateUser) { //非公开文件需要验证用户 const userIdStr = Buffer.from(Buffer.from(userIdMd5, 'hex').toString('base64')).toString(); const userIdInt: number = parseInt(userIdStr, 10); diff --git a/packages/ui/certd-client/src/views/certd/wallet/crud-withdraw.tsx b/packages/ui/certd-client/src/views/certd/wallet/crud-withdraw.tsx index ae90e3328..77d0bbe8c 100644 --- a/packages/ui/certd-client/src/views/certd/wallet/crud-withdraw.tsx +++ b/packages/ui/certd-client/src/views/certd/wallet/crud-withdraw.tsx @@ -1,6 +1,12 @@ import { CreateCrudOptionsRet, dict, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud"; import * as api from "./api"; import PriceInput from "/@/views/sys/suite/product/price-input.vue"; +import { useUserStore } from "/@/store/user"; + +function buildPrivateFileUrl(key: string) { + const userStore = useUserStore(); + return `/api/basic/file/download?token=${userStore.getToken}&key=${encodeURIComponent(key)}`; +} export default function (): CreateCrudOptionsRet { const pageRequest = async (query: UserPageQuery): Promise => { @@ -59,7 +65,7 @@ export default function (): CreateCrudOptionsRet { if (!value) { return "-"; } - return ; + return ; }, }, }, diff --git a/packages/ui/certd-client/src/views/certd/wallet/index.vue b/packages/ui/certd-client/src/views/certd/wallet/index.vue index 03dfb90a0..26e3dc082 100644 --- a/packages/ui/certd-client/src/views/certd/wallet/index.vue +++ b/packages/ui/certd-client/src/views/certd/wallet/index.vue @@ -14,9 +14,11 @@
提现操作
- 提现设置 - - 申请提现 + 提现设置 +
+ +
+ 申请提现
@@ -62,6 +64,10 @@ function moneyText(amount: number) { return `¥ ${amountToYuan(amount)}`; } +function buildPrivateFileUrl(key: string) { + return `/api/basic/file/download?token=${userStore.getToken}&key=${encodeURIComponent(key)}`; +} + const summaryCards = computed(() => [ { key: "availableAmount", @@ -139,6 +145,7 @@ async function openWithdrawSetting() { type: "cropper-uploader", form: { col: { span: 24 }, + show: compute(({ form }) => form.channel !== "bank"), component: { vModel: "modelValue", valueType: "key", @@ -160,7 +167,7 @@ async function openWithdrawSetting() { }, }, buildUrl(key: string) { - return `/api/basic/file/download?key=` + key; + return buildPrivateFileUrl(key); }, }, }, @@ -176,6 +183,9 @@ async function openWithdrawSetting() { }, }, async onSubmit(form: any) { + if (form.channel === "bank") { + form.qrCode = ""; + } await api.SaveWithdrawSetting(form); notification.success({ message: "保存成功" }); }, @@ -303,13 +313,26 @@ onActivated(async () => { .wallet-action-content { display: flex; align-items: center; + flex: 1; flex-wrap: wrap; justify-content: flex-start; gap: 10px; + min-width: 0; } - .withdraw-amount-input { - width: 240px; + .wallet-action-button { + flex: none; + } + + .withdraw-amount-field { + flex: 0 1 280px; + min-width: 240px; + } + + .withdraw-amount-field, + .withdraw-amount-input, + .withdraw-amount-input.ant-input-number-group-wrapper { + width: 100%; } .wallet-tabs { @@ -359,9 +382,15 @@ onActivated(async () => { justify-content: flex-start; } - .withdraw-amount-input { + .wallet-action-button, + .withdraw-amount-field { width: 100%; } + + .withdraw-amount-field { + flex-basis: auto; + min-width: 0; + } } } diff --git a/packages/ui/certd-client/src/views/sys/suite/invite/crud-withdraw.tsx b/packages/ui/certd-client/src/views/sys/suite/invite/crud-withdraw.tsx index 5152c925e..3ad104717 100644 --- a/packages/ui/certd-client/src/views/sys/suite/invite/crud-withdraw.tsx +++ b/packages/ui/certd-client/src/views/sys/suite/invite/crud-withdraw.tsx @@ -3,6 +3,12 @@ import { notification } from "ant-design-vue"; import * as api from "./api"; import PriceInput from "/@/views/sys/suite/product/price-input.vue"; import { useFormDialog } from "/@/use/use-dialog"; +import { useUserStore } from "/@/store/user"; + +function buildPrivateFileUrl(key: string) { + const userStore = useUserStore(); + return `/api/basic/file/download?token=${userStore.getToken}&key=${encodeURIComponent(key)}`; +} export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOptionsRet { const { openFormDialog } = useFormDialog(); @@ -19,7 +25,7 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti {row.account || "-"} {row.bankName || "-"} - {row.qrCode ? : -} + {row.qrCode ? : -} {row.amount / 100} 元 @@ -157,7 +163,7 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti if (!value) { return "-"; } - return ; + return ; }, }, }, diff --git a/packages/ui/certd-server/src/controller/basic/file-controller.ts b/packages/ui/certd-server/src/controller/basic/file-controller.ts index 1fb6bd203..c96747f47 100644 --- a/packages/ui/certd-server/src/controller/basic/file-controller.ts +++ b/packages/ui/certd-server/src/controller/basic/file-controller.ts @@ -1,9 +1,10 @@ import { Controller, Fields, Files, Get, Inject, Post, Provide, Query } from '@midwayjs/core'; -import { BaseController, Constants, FileService, UploadFileItem, uploadTmpFileCacheKey } from '@certd/lib-server'; +import { BaseController, Constants, FileService, PermissionException, UploadFileItem, uploadTmpFileCacheKey } from '@certd/lib-server'; import send from 'koa-send'; import { nanoid } from 'nanoid'; import { cache } from '@certd/basic'; import { UploadFileInfo } from '@midwayjs/upload'; +import { AuthService } from '../../modules/sys/authority/service/auth-service.js'; const imageExtSet = new Set(['.apng', '.avif', '.bmp', '.gif', '.ico', '.jpeg', '.jpg', '.png', '.svg', '.webp']); const imageCacheSeconds = 3 * 24 * 60 * 60; @@ -32,6 +33,9 @@ export class FileController extends BaseController { @Inject() fileService: FileService; + @Inject() + authService: AuthService; + @Post('/upload', { description: Constants.per.authOnly }) async upload(@Files() files: UploadFileInfo[], @Fields() fields: any) { console.log('files', files, fields); @@ -52,17 +56,30 @@ export class FileController extends BaseController { }); } - @Get('/download', { description: Constants.per.guest }) + @Get('/download', { description: Constants.per.guestOptionalAuth }) async download(@Query('key') key: string) { - let userId: any = null; - if (!key.startsWith('/public')) { - userId = this.getUserId(); - } - const filePath = this.fileService.getFile(key, userId); + const filePath = this.getDownloadFilePath(key); const sendOptions = getImageDownloadOptions(filePath); if (!sendOptions) { this.ctx.response.attachment(filePath); } await send(this.ctx, filePath, sendOptions); } + + private getDownloadFilePath(key: string) { + const isPrivateFile = !key.startsWith('/public'); + const userId = isPrivateFile ? this.getUserId() : null; + try { + return this.fileService.getFile(key, userId); + } catch (e) { + if (!(e instanceof PermissionException) || !isPrivateFile || !this.authService.isAdmin(this.ctx)) { + throw e; + } + const adminFilePath = this.fileService.getFile(key, userId, true); + if (!isImageFile(adminFilePath)) { + throw e; + } + return adminFilePath; + } + } } diff --git a/packages/ui/certd-server/src/middleware/authority.test.ts b/packages/ui/certd-server/src/middleware/authority.test.ts new file mode 100644 index 000000000..67a7a4f67 --- /dev/null +++ b/packages/ui/certd-server/src/middleware/authority.test.ts @@ -0,0 +1,56 @@ +/// +/// + +import assert from "node:assert/strict"; +import jwt from "jsonwebtoken"; +import { Constants } from "@certd/lib-server"; +import { AuthorityMiddleware } from "./authority.js"; + +function createMiddleware(permission: string) { + const middleware = new AuthorityMiddleware(); + middleware.secret = "test-secret"; + middleware.webRouterService = { + async getMatchedRouterInfo() { + return { description: permission }; + }, + } as any; + return middleware; +} + +function createCtx(token?: string) { + return { + path: "/api/basic/file/download", + method: "GET", + query: token ? { token } : {}, + headers: {}, + get() { + return ""; + }, + } as any; +} + +describe("AuthorityMiddleware guestOptionalAuth", () => { + it("continues without user when token is not provided", async () => { + const middleware = createMiddleware(Constants.per.guestOptionalAuth); + const ctx = createCtx(); + let called = false; + + await middleware.resolve()(ctx, async () => { + called = true; + }); + + assert.equal(called, true); + assert.equal(ctx.user, undefined); + }); + + it("sets user when token is provided", async () => { + const middleware = createMiddleware(Constants.per.guestOptionalAuth); + const token = jwt.sign({ id: 1, roles: [1] }, middleware.secret); + const ctx = createCtx(token); + + await middleware.resolve()(ctx, async () => {}); + + assert.equal(ctx.user.id, 1); + assert.deepEqual(ctx.user.roles, [1]); + }); +}); diff --git a/packages/ui/certd-server/src/middleware/authority.ts b/packages/ui/certd-server/src/middleware/authority.ts index 6452aa1dc..78d189487 100644 --- a/packages/ui/certd-server/src/middleware/authority.ts +++ b/packages/ui/certd-server/src/middleware/authority.ts @@ -52,29 +52,7 @@ export class AuthorityMiddleware implements IWebMiddleware { return; } - let token = ctx.get('Authorization') || ''; - token = token.replace('Bearer ', '').trim(); - if (!token) { - //尝试从cookie中获取token - const cookie = ctx.headers.cookie; - if (cookie) { - const items = cookie.split(';'); - for (const item of items) { - if (!item || !item.trim()) { - continue; - } - const [key, value] = item.split('='); - if (key.trim() === 'certd_token') { - token = value.trim(); - break; - } - } - } - } - if (!token) { - //尝试从query中获取token - token = (ctx.query.token as string) || ''; - } + const token = this.getTokenFromRequest(ctx); if (token) { try { @@ -84,6 +62,10 @@ export class AuthorityMiddleware implements IWebMiddleware { return this.notAuth(ctx); } } else { + if (permission === Constants.per.guestOptionalAuth) { + await next(); + return; + } //找找openKey const openKey = await this.doOpenHandler(ctx); if (!openKey) { @@ -101,6 +83,10 @@ export class AuthorityMiddleware implements IWebMiddleware { await next(); return; } + if (permission === Constants.per.guestOptionalAuth) { + await next(); + return; + } const pass = await this.authService.checkPermission(ctx, permission); if (!pass) { @@ -123,6 +109,30 @@ export class AuthorityMiddleware implements IWebMiddleware { return; } + private getTokenFromRequest(ctx: IMidwayKoaContext) { + let token = ctx.get('Authorization') || ''; + token = token.replace('Bearer ', '').trim(); + if (token) { + return token; + } + + const cookie = ctx.headers.cookie; + if (cookie) { + const items = cookie.split(';'); + for (const item of items) { + if (!item || !item.trim()) { + continue; + } + const [key, value] = item.split('='); + if (key.trim() === 'certd_token') { + return value.trim(); + } + } + } + + return (ctx.query.token as string) || ''; + } + async doOpenHandler(ctx: IMidwayKoaContext) { //开放接口 const openKey = ctx.get('x-certd-token') || '';