feat: add OSS harness file upload

This commit is contained in:
YBF
2026-09-11 01:01:03 +08:00
parent 93779ed4f4
commit 6bb8677849
19 changed files with 1152 additions and 40 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ HOST=127.0.0.1
PORT=7878
DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/trade_message_center
# Chrome 扩展下载签名。TMC_PACKAGE_VERSION 由 workspace 命令从根 package.json 注入。
# Chrome 扩展临时下载签名及本地 Mind 联调页的 server-proxy OSS 上传。TMC_PACKAGE_VERSION 由 workspace 命令从根 package.json 注入AccessKey 只能留在 server 侧环境文件
OSS_BUCKET=sinanpilot-bucket
OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
OSS_ACCESS_KEY_ID=
@@ -28,8 +28,8 @@ apps/
│ │ └── schema/index.ts
│ ├── http/
│ │ ├── chrome-extension-download.ts
│ │ ├── harness.ts
│ │ ├── health.ts
│ │ ├── oss-harness-upload.ts
│ │ ├── onetalk/
│ │ │ ├── contract.ts
│ │ │ ├── read.ts
@@ -41,6 +41,7 @@ apps/
│ │ │ └── summary.ts
│ ├── oss/
│ │ ├── chrome-extension-download.ts
│ │ ├── harness-upload.ts
│ │ └── v1-signed-url.ts
│ ├── onetalk/
│ │ ├── index.ts
@@ -65,6 +66,7 @@ apps/
└── test/
├── app.test.ts
├── chrome-extension-download.test.ts
├── oss-harness-upload.test.ts
└── oss-v1-signed-url.test.ts
```
@@ -93,9 +95,10 @@ OneTalk 业务服务、Bright 读取路由、独立历史 cursor 和 repository
- `http/onetalk/public-context.ts` 只拥有 public Bright scope/Cookie 授权、精确 Origin CORS 与 cutover epoch;列表/详情/历史查询仍委派给 `read-service.ts`,不直接访问 Drizzle。
- `http/onetalk/read.ts` 是 public messages 与 internal summary 共用的 history 输入、error mapping 和 reply flow 唯一 owner`http/onetalk/public.ts` 只组合 public list/detail/messages 与 OPTIONS,三个 handler 分别归 `conversation-list.ts``conversation.ts``messages.ts`
- `http/onetalk/summary.ts` 只注册 internal `7777` history route,复用 `http/onetalk/read.ts` 的 history 输入/错误映射与 `read-service.ts`;它不读取授权头、Cookie 或配置,不注册 CORS、WebSocket、列表/详情或 public health。
- `http/harness.ts` 只提供本地原生 HTML 联调页;页面通过 Bright HTTP/WS 访问服务,不复制 repository 或 Mind legacy 读取
- 本地原生 HTML 联调页归独立 `apps/mind-test-harness/`;server 不提供或导入页面,只提供其可调用的 Bright HTTP/WS 路由
- `http/chrome-extension-download.ts` 只返回当前 workspace 版本的扩展临时下载链接;对象路径和 V1 签名委托给 `oss/chrome-extension-download.ts`,不代理 ZIP 或记录签名。缺少签名凭据时稳定返回 `503 extension_download_unavailable`
- `src/oss/v1-signed-url.ts` 只拥有无网络副作用的 OSS V1 GET 签名、URL 边界校验和 query 拼接;它不读取 `process.env`、不记录 secret、不增加路由或 OSS 网络调用。调用者未来接入时仍须通过 `src/config.ts` 映射环境变量。
- `src/oss/v1-signed-url.ts` 只拥有无网络副作用的 OSS V1 GET/PUT 签名、object URL、URL 边界校验和 query 拼接;它不读取 `process.env`、不记录 secret、不增加路由或 OSS 网络调用。调用者须通过 `src/config.ts` 映射环境变量。
- `src/oss/harness-upload.ts` 只拥有本地 harness 的随机对象 key、签名 PUT、成功后的 GET 及可注入 fetch/UUID/clock`http/oss-harness-upload.ts` 只拥有 raw-binary 输入、25 MiB 上限、精确 Mind Origin CORS 和稳定 HTTP error mapping。
- `src/oss/chrome-extension-download.ts` 只拥有扩展发布包的 OSS 对象路径与签名组合;目录和文件名使用完整版本但不添加 `v` 前缀。
- `onetalk/history.ts` 只拥有不透明、绑定账号/会话的 `(sentAtMs, messageId)` keyset cursor 编解码;同步 anchor 不得复用为读取 cursor。
- `onetalk/read-cursor.ts` 只拥有绑定账号/查询或时间窗/asOf/keyset 的不透明列表/历史 cursor 编解码;同步 anchor 不得复用为读取 cursor。`onetalk/history.ts` 仅服务既有同步历史形状。
@@ -63,6 +63,8 @@ type OssV1SigningConfig = {
type OssV1SignedUrlOptions = {
now?: () => number;
method?: "GET" | "PUT";
contentType?: string;
};
createOssV1SignedUrl(
@@ -75,8 +77,9 @@ createOssV1SignedUrl(
### 3. Contracts
- `config` 是调用者显式传入的 typed 值;signer 不读取 `process.env`。未来的 `OSS_BUCKET``OSS_ENDPOINT``OSS_ACCESS_KEY_ID``OSS_ACCESS_KEY_SECRET` 只能由 `src/config.ts` 在启动边界映射。
- 输入只接受 `https://<bucket>.<endpoint-host>/<object-key>`;固定生成 `GET`1800 秒有效期和 `Expires`/`OSSAccessKeyId`/`Signature` 三个 query 参数。
- StringToSign 为 `GET\n\n\n<Expires>\n/<bucket>/<decoded-object-key>`使用 HMAC-SHA1 + Base64pathname percent-decode 一次后签名,再按 RFC 3986 编码输出路径,`%2F``/` 按解码后的 key 语义等价。
- 输入只接受 `https://<bucket>.<endpoint-host>/<object-key>`;固定生成 1800 秒有效期和 `Expires`/`OSSAccessKeyId`/`Signature` 三个 query 参数。无 options 或无 method 时保持 `GET` 行为不变。
- `GET` StringToSign 为 `GET\n\n\n<Expires>\n/<bucket>/<decoded-object-key>`server-proxy 的 `PUT` 必须显式传入未经 trim 且无 CR/LF 的实际 `Content-Type`StringToSign 为 `PUT\n\n<Content-Type>\n<Expires>\n/<bucket>/<decoded-object-key>`。两者均使用 HMAC-SHA1 + Base64pathname percent-decode 一次后签名,再按 RFC 3986 编码输出路径,`%2F``/` 按解码后的 key 语义等价。
- `createOssV1ObjectUrl(objectKey, config)` 只为已经验证的 key 生成所属 bucket 的 HTTPS object URL;它仍不发起网络请求。
- `OSS_REGION` 不进入 signer 配置或 V1 StringToSignAccessKey Secret 只进入 HMAC,不进入 URL、错误、日志或测试输出。
### 4. Validation & Error Matrix
@@ -88,6 +91,7 @@ createOssV1SignedUrl(
| object URL 含首尾空白、原始 `?`/`#`、反斜杠、TAB/LF/CR 或 dot segment | 在 `new URL` 前显式失败 |
| percent encoding、UTF-8、空 key、1024+ bytes、leading slash/backslash 或 decoded dot segment 不合规 | 稳定 object-key 错误 |
| `now` 不是非负 safe-integer 毫秒,或 options.now 不是函数 | 稳定 clock 错误;不读取第二次时间 |
| method 非 `GET`/`PUT`GET 带 Content-TypePUT 的 Content-Type 缺失、空白、首尾空白或含 CR/LF | 稳定 method/content-type 错误;不能签出与实际 header 不同的 URL |
| clock callback 抛错 | 原异常传播,不使用当前时间替代 |
### 5. Good/Base/Bad Cases
@@ -98,7 +102,7 @@ createOssV1SignedUrl(
### 6. Tests Required
- 固定向量断言 Expires、完整 Base64 Signature 和 query 参数固定顺序;oracle 独立构造 canonical resource,不调用生产 signer
- 固定 GET/PUT 向量断言 Expires、完整 Base64 Signature 和 query 参数固定顺序;PUT oracle 必须包含实际 Content-Type,且 GET 的历史向量不变
- 路径矩阵断言空格、Unicode、`+``%2B``%2F``%25``%3F``%23` 的解码后签名与 RFC3986 输出路径,且 `%2F``/` 等价。
- 失败矩阵覆盖 runtime config、bucket/endpoint/port、userinfo、query/fragment、TAB/LF/CR、dot/backslash、malformed percent、UTF-8 byte 边界、clock 类型/边界/单次调用和 secret/no-network/no-log。
@@ -117,3 +121,68 @@ const parsed = new URL(objectUrl);
const objectKey = decodeURIComponent(parsed.pathname.slice(1));
return signDecodedObjectKey(objectKey);
```
## Scenario: OSS Harness server-proxy upload
### 1. Scope / Trigger
- Trigger:本地 Mind 联调页需要把用户选择的文件上传至 OSS,并取得后续图片/附件发送可用的临时 HTTPS 地址。
- Scope:浏览器只请求 Bright`config.ts``OSS_*` 唯一环境 owner`oss/harness-upload.ts` 是 PUT→GET 网络顺序 owner`http/oss-harness-upload.ts` 只拥有二进制 HTTP/CORS/错误映射,`mind-test-harness` 只拥有页面状态。
### 2. Signatures
```text
POST /api/oss/harness-upload?fileName=<encoded>&mimeType=<encoded>
Content-Type: application/octet-stream
body: raw bytes
-> { fileName, mimeType, sizeBytes, downloadUrl }
```
```ts
createOssHarnessUploadService(config, { fetch, uuid, now }).upload({ fileName, mimeType, body });
```
### 3. Contracts
- 四项 `OSS_BUCKET``OSS_ENDPOINT``OSS_ACCESS_KEY_ID``OSS_ACCESS_KEY_SECRET` 全部缺失时 `ossHarnessUpload` 不可用;任何部分配置在启动边界失败。`TMC_PACKAGE_VERSION` 缺失只禁用扩展下载,不能阻断 harness upload。
- object key 固定为 `harness-uploads/<uuid>/<fileName>`,不得使用扩展发布目录。server 使用固定 30 分钟的签名 PUT,携带与 StringToSign 相同的 Content-Type;仅 PUT 2xx 后才签发同一 key 的 GET `downloadUrl`
- HTTP 只接受非空 basename、非空/无 CRLF 的 MIME、非空且不超过 25 MiB 的二进制 body,以及与 `MIND_PAGE_ORIGIN` 精确匹配的 Origin。OPTIONS 只允许该 Origin 的 `POST,OPTIONS``content-type`
- 页面上传期间禁用重复提交;成功地址可复制,图片填入 image URL,所有文件填入附件 URL、filename 和 MIME。AccessKey Secret、signed PUT URL、上游 body 与内部异常均不得进入页面、响应或日志。
### 4. Validation & Error Matrix
| Condition | Result |
| --- | --- |
| 缺少完整 OSS 配置 | `503 oss_upload_unavailable` |
| Origin、OPTIONS method/header 不匹配 | POST `403 oss_upload_origin_not_allowed`preflight `403` |
| Content-Type 非 `application/octet-stream`,或 fileName/MIME/body 为空、非法或超过 25 MiB | `400 oss_upload_invalid_request` |
| OSS PUT 非 2xx、fetch reject 或网络失败 | `502 oss_upload_failed`;不返回 upload URL 或上游内容 |
| PUT 成功 | `200 { fileName, mimeType, sizeBytes, downloadUrl }` |
### 5. Good/Base/Bad Cases
- GoodMind 页面将 File 原始 bytes 交给 Brightservice 生成唯一 `harness-uploads/` keyPUT 成功后才返回 signed GET URL。
- Base:没有本地凭据时,页面显示稳定 unavailable 错误;单元/HTTP 测试使用注入的 fetch 和固定 UUID/clock。
- Bad:浏览器持有 AccessKey 或直传 OSS、使用公开/未签名 URL fallback、把上传对象放进 `chrome-extension/`,或将 PUT 的 Content-Type 与签名不一致。
### 6. Tests Required
- signer 测试锁住历史 GET 向量及 PUT+Content-Type HMAC vectorservice 测试断言 key、PUT header/body、成功 GET URL、非 2xx 与 transport error。
- Fastify inject 测试覆盖 preflight、精确 Origin、unavailable、invalid input、成功响应和失败脱敏;`createApp` 测试确认组合路由。
- harness 至少执行 TypeScript 与内嵌 script 语法检查;真实 PUT/GET smoke 只有本地完整 `OSS_*` 凭据可用时执行,并如实记录。
### 7. Wrong vs Correct
```ts
// Wrong: exposes credentials to the page and makes OSS browser CORS a requirement.
await fetch(ossUrl, { method: "PUT", headers: { authorization: accessKeySecret }, body: file });
```
```ts
// Correct: the page sends bytes only to Bright; server owns the signed PUT and GET lifecycle.
const response = await fetch("/api/oss/harness-upload?fileName=report.pdf&mimeType=application%2Fpdf", {
method: "POST",
headers: { "content-type": "application/octet-stream" },
body: file,
});
```
@@ -0,0 +1,5 @@
{"file":".trellis/spec/project/architecture.md","reason":"Review module ownership and ensure no UI or OSS behavior crosses package boundaries."}
{"file":".trellis/spec/project/async-state-boundaries.md","reason":"Review duplicate uploads, in-flight UI state and post-await terminal behavior."}
{"file":".trellis/spec/server/backend/quality-guidelines.md","reason":"Review server tests, strict typing, secret handling and full validation commands."}
{"file":".trellis/spec/server/backend/error-handling.md","reason":"Review HTTP statuses, stable error codes and response redaction."}
{"file":".trellis/spec/mind-test-harness/development/boundary.md","reason":"Review that the hand-operated upload UI remains scoped to the local harness."}
@@ -0,0 +1,52 @@
# OSS 文件上传与联调页技术设计
## Scope and data flow
此能力只服务本地 Mind 联调页,采用 server proxy,不存在浏览器直传 OSS:
```text
mind-test-harness page
-- POST binary --> Bright /api/oss/harness-upload
-- server-side signed PUT --> OSS harness-uploads/<uuid>/<fileName>
<-- { fileName, mimeType, sizeBytes, downloadUrl } -- Bright
```
浏览器永远看不到 AccessKey。OSS 请求从 Bright server 发出,因此不配置、不依赖 OSS 的浏览器 CORS/Origin。页面到 Bright 的既有本地跨源边界继续使用已配置的 `MIND_PAGE_ORIGIN`,不引入新环境变量或 Origin allowlist。
## Module ownership
- `apps/server/src/oss/v1-signed-url.ts`OSS V1 URL canonicalization、HMAC 和 GET/PUT 签名的唯一 owner。扩展现有固定 GET 行为时,保留它作为无 options 的默认值;`PUT` 只有 server upload service 使用,并将实际 `Content-Type` 纳入 StringToSign。
- `apps/server/src/oss/harness-upload.ts`:生成 `harness-uploads/<uuid>/<fileName>` key,调用 V1 PUT signer、执行 server-side `fetch`、在 PUT 成功后生成 GET 下载 URL;注入 `fetch`、UUID 与时钟以供确定性测试。
- `apps/server/src/config.ts`:唯一把 `OSS_BUCKET``OSS_ENDPOINT``OSS_ACCESS_KEY_ID``OSS_ACCESS_KEY_SECRET` 映射为可选 `ossHarnessUpload` typed 配置的地方。四项均缺失时能力不可用;部分缺失显式失败。既有扩展下载配置保持独立。
- `apps/server/src/http/oss-harness-upload.ts`:唯一拥有 HTTP body/query 校验、稳定 HTTP 错误码和本地 Mind 页面 CORS preflight 的边界;不拥有签名与网络上传逻辑。
- `apps/mind-test-harness/src/harness.ts`:文件选择、请求生命周期、结果展示与填充既有图片/附件 URL 字段;不解释 OSS credentials 或签名。
## HTTP contract
`POST /api/oss/harness-upload?fileName=<encoded>&mimeType=<encoded>`
- body:原始二进制,`Content-Type: application/octet-stream`;服务端使用固定上限接收 Buffer,拒绝空 body 和超过上限的文件。
- `fileName`:非空 basename;不可包含路径分隔符、控制字符或 `.`/`..`。保留原始扩展名,保证已有图片 URL 输入可从返回地址 pathname 识别图片类型。
- `mimeType`:非空、无 CR/LF 的 MIME 字符串;页面将 `File.type` 为空时规范为 `application/octet-stream`
- success 200`{ fileName, mimeType, sizeBytes, downloadUrl }``downloadUrl` 是现有 30 分钟 V1 GET 临时地址,才是页面展示和用于后续发送的地址;不返回 access key/secret。
- failures:未配置 `503 oss_upload_unavailable`;非法文件名、MIME、空 body 或大小超限 `400` 的稳定 code;OSS 返回失败或连接失败 `502 oss_upload_failed`。响应不透传 OSS body、签名 URL、secret 或内部异常。
## OSS upload behavior
1. server 依据已校验的 filename 生成 `harness-uploads/<uuid>/<fileName>`,不占用 `chrome-extension/` 发布路径。
2. V1 PUT URL 的 StringToSign 使用 `PUT` 与实际上传的 Content-Type;请求携带相同 Content-Type。阿里云文档说明,签入预签名 URL 的 headers 必须由 PUT 请求原样携带,否则会 403。
3. server `fetch` signed PUT。成功后,为同一对象使用既有 V1 GET signer 生成 `downloadUrl`;下载 URL 到期后需要再次上传或另行生成,不承诺公开对象 URL。
4. 本地开发由现有 `dev-entry.ts` 自动加载未提交的 `.env.local` 等文件。不会修改生产 CI/部署变量传递;该任务是本地联调能力。
## Compatibility and deferred items
- `createOssV1SignedUrl()` 的无 options 调用继续是 30 分钟 GET,扩展下载路由的输出不变。
- 不新增 OSS SDK、multipart 依赖、OSS CORS 规则、STS、V4 migration、对象列表/删除接口、持久化记录或 OneTalk SDK 自动发送。
- 当前 endpoint 是否可为目标 bucket 成功执行 V1 PUT,取决于本地真实凭据、RAM `oss:PutObject` 权限和 OSS endpoint policy;单元测试不伪装成真实上传验证。
## Verification design
- V1 signer:固定 HMAC oracle 覆盖 GET 不变与 PUT+Content-Type 向量,断言方法或 MIME 失配/非法值显式失败。
- upload service:注入 fetch,断言 PUT URL、方法、Content-Type、对象路径、成功 GET 地址和 OSS 非 2xx/throw 的稳定失败;不使用真实密钥。
- HTTPFastify inject 覆盖未配置、preflight、非法 query/body、成功响应与上游失败,且断言响应不含 secret/signed PUT URL。
- harnessTypeScript 检查;手工 smoke 选择文件、上传、复制/展示 URL,并确认其自动填入既有 image/file 表单。
@@ -0,0 +1,6 @@
{"file":".trellis/spec/project/architecture.md","reason":"Cross-package ownership, composition-root and module-boundary rules for the server route and harness UI."}
{"file":".trellis/spec/project/async-state-boundaries.md","reason":"The upload flow has browser and server awaits; this defines lifecycle and terminal-result ownership."}
{"file":".trellis/spec/project/missing-values.md","reason":"OSS configuration must have one environment owner and explicit missing-value behavior."}
{"file":".trellis/spec/server/backend/quality-guidelines.md","reason":"Server TypeScript, HTTP boundary, secret handling and required verification rules."}
{"file":".trellis/spec/server/backend/error-handling.md","reason":"Stable HTTP failure contracts and no-leak error boundary guidance."}
{"file":".trellis/spec/mind-test-harness/development/boundary.md","reason":"Owns the separate local harness and its Bright request boundary."}
@@ -0,0 +1,28 @@
# OSS 文件上传与联调页执行计划
## Implementation checklist
1. 先对将修改的 OSS signer、`loadConfig``createApp` 与 local harness symbols 执行 GitNexus upstream impact;发现 HIGH/CRITICAL 先报告后再修改。
2. 扩展 `apps/server/src/oss/v1-signed-url.ts` 的单一 V1 signer:保持无 options 的 GET 输出不变,并增加经校验的 PUT + Content-Type 签名支持及 object URL/key 复用能力;补固定向量和失败矩阵。
3.`apps/server/src/oss/harness-upload.ts` 实现对象 key、PUT、GET 地址与可注入依赖;补 focused service tests。
4. 扩展 `apps/server/src/config.ts` 的可选 `ossHarnessUpload` 配置,复用现有 `OSS_*`,不让业务模块读取 `process.env`;补 config tests。更新 `.env.example`/README 中 OSS 变量同时用于本地 harness upload 的描述,不填入真实值。
5. 新增 `apps/server/src/http/oss-harness-upload.ts`:注册固定 binary content parser、POST/OPTIONS、既有 Mind 页面跨源响应、稳定错误响应;在 `createApp` 组合路由;补 Fastify inject tests。
6.`apps/mind-test-harness/src/harness.ts` 添加上传面板、in-flight 禁用、成功/失败状态、临时地址展示/复制及对 image/file URL 的自动填充;不添加新的配置或 credential 字段。
7. 运行 focused tests、server test/typecheck/build、harness typecheck、format check,最后手工启动 server+harness;只有本地环境实际设置凭据时才做真实上传/下载 smoke。
8. 运行 `git diff --check` 与 GitNexus `detect_changes()`,复核没有影响发布下载、授权、WebSocket 或非预期执行流程。
## Validation commands
```bash
pnpm --filter @trade-message-center/server test
pnpm --filter @trade-message-center/server typecheck
pnpm --filter @trade-message-center/server build
pnpm --filter @trade-message-center/mind-test-harness typecheck
pnpm format:check
git diff --check
```
## Risk and rollback
- 最大风险是本地 credentials/RAM 权限或 OSS endpoint policy 拒绝 PUT;接口应返回稳定失败,不能返回虚假的地址。回滚只需移除 upload route 的注册及其新增模块,既有 GET download signer 和扩展发布路径不变。
- 对 signer 的改动是共享基础能力;固定 GET 回归向量必须保持。若不能保证,则撤回 GET/PUT 泛化,改用独立实现前先重新评审。
@@ -0,0 +1,54 @@
# OSS 文件上传与联调页
## Goal
让操作者能在 Bright OneTalk 联调页选择本地文件并上传到已配置的阿里云 OSS,取得可用于后续图片或附件发送的 HTTPS 地址;OSS 长期凭据始终只保留在服务端。
## Confirmed facts
- 服务端已有 `apps/server/src/oss/v1-signed-url.ts`:只生成 30 分钟有效的 OSS V1 `GET` 下载地址,凭据由调用方显式传入;它不读环境变量、不访问网络,也不会输出 AccessKey Secret。
- `apps/server/src/config.ts` 目前仅在四个 `OSS_*` 变量齐备时创建 `chromeExtensionDownload` 配置;`apps/server/src/app.ts` 只挂载现有的扩展下载路由,没有通用上传 HTTP 契约。
- 独立的联调页归 `apps/mind-test-harness/src/harness.ts` 所有,而不是 `apps/server`;页面现有提示明确要求操作者自行准备 HTTPS media URL,且不会上传或读取媒体。
- 既有 V1 signer 的任务明确把 `PUT`、Content-Type/MD5 签名、HTTP 路由和实际 OSS 网络请求留作后续工作。本任务必须新增独立上传契约,不能把 GET 签名语义混入上传实现。
- 已配置的默认 endpoint 是 `https://oss-cn-hangzhou.aliyuncs.com`;目标 bucket 的访问控制、CORS 规则、V1 PUT 是否可用与生产部署是否已把 OSS 变量传入运行时,尚未做真实外部验证。
- `apps/server/src/dev-entry.ts` 已按 `.env``.env.local``.env.development``.env.development.local` 的既有优先级自动加载根目录环境文件;本地没有 `.env`,且本次未读取或输出任何密钥值。
## Confirmed user decisions
- 上传方法与 OSS 凭据使用只能存在于 server 边界,浏览器不保留密钥。
- 本地 OSS 凭据放在未提交的本地环境文件中,并复用现有的开发启动自动注入机制;不新增另一套配置文件或手工传参方式。
- 这是联调页能力:浏览器把文件交给 Bright server,再由 Bright server 上传 OSS;不采用浏览器直传 OSS,也不新增或要求 OSS CORS/Origin 配置。
## Requirements
### R1. Server-owned OSS upload capability
`apps/server/src/oss/` 建立上传所需的唯一 OSS 签名/对象命名能力;由 `src/config.ts` 集中读取运行时环境变量,AccessKey Secret 不得进入浏览器、响应、错误或日志。签名、输入校验和 HTTP 路由职责清晰分离。
### R2. Explicit server-proxy upload HTTP contract
服务端提供给联调页调用的稳定上传 API:接收文件二进制、在 server 内生成独占的 `harness-uploads/` 对象 key、用本地凭据上传 OSS,并返回临时可使用的 HTTPS 下载地址。契约须规定输入、允许的方法、对象 key 的生成规则、成功响应、稳定错误码和未配置 OSS 时的失败行为;不得用未签名地址、默认凭据或伪成功降级。
### R3. Mind test harness upload entry
`apps/mind-test-harness` 的 Bright OneTalk 联调页增加文件选择、上传状态、错误状态和成功后的地址展示。页面不接触 AccessKey;成功地址可复制,并能填充既有图片/附件 URL 输入以继续联调发送。
### R4. Upload safety and verification
服务端在入口校验文件元数据与对象 key;上传地址仅限预期 bucket/endpoint,时效固定且短暂。为 signer、配置、HTTP 契约和页面交互补充无真实密钥的确定性测试;真实 OSS 上传仅在本地目标环境可用时作为额外 smoke 验证,如实标注结果。
## Tentative scope boundaries
- 不改动现有 Chrome 扩展发布包的 `chrome-extension/<version>/...zip` 对象路径或下载路由。
- 不把 OSS 密钥写入联调页、Chrome 扩展、README 示例值、测试输出或日志。
- 不配置或依赖 OSS 浏览器 CORS/OriginOSS 请求只从 Bright server 发出。
- 不在本任务把上传能力自动接入 OneTalk SDK;本任务输出由联调页可直接使用的 URL。
## Acceptance criteria
- [ ] 操作者在联调页选择一个文件后,Bright server 能将其上传到 `harness-uploads/` 下的独立 OSS 对象,并看到可复制的临时 HTTPS 使用地址。
- [ ] 成功地址可无手工重输地带入既有图片或附件发送输入;文件名和 MIME 信息保持一致。
- [ ] 服务端使用集中配置且从不把 AccessKey Secret 返回给浏览器、写入错误或日志。
- [ ] 缺失配置、非法元数据、签名/上传失败和过期地址均有稳定、可测试的失败结果,不出现伪成功。
- [ ] 新增单元/HTTP/页面测试不依赖真实 OSS 凭据;格式、类型、构建和测试检查通过。
- [ ] 若实际目标环境可用,记录一次真实上传与生成地址访问的 smoke 结果;若未执行,明确列为未验证项。
@@ -0,0 +1,26 @@
{
"id": "oss-file-upload-harness",
"name": "oss-file-upload-harness",
"title": "OSS 文件上传与联调页",
"description": "在服务端提供 OSS 文件上传能力,并在 Mind 联调页提供上传入口和访问地址展示。",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "ybf",
"assignee": "ybf",
"createdAt": "2026-09-11",
"completedAt": null,
"branch": "main",
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
+10 -8
View File
@@ -88,17 +88,17 @@ cp .env.example .env.local
server 开发入口和迁移命令按以下顺序读取文件,后面的文件覆盖前面的同名变量:
`.env``.env.local``.env.development`
`.env``.env.local``.env.development``.env.development.local`
### 必填基础配置
| 变量 | 说明 |
| -------------------------------------------- | --------------------------------------------------------------- |
| `HOST` | server 监听地址,例如 `127.0.0.1`。 |
| `PORT` | server 监听端口,必须是 `1-65535` 的整数,例如 `7878`。 |
| `DATABASE_URL` | Bright PostgreSQL 连接串;启动 server 和执行 migration 都需要。 |
| `OSS_BUCKET``OSS_ENDPOINT` | Chrome 扩展发布包所在的 OSS Bucket 与 HTTPS Endpoint。 |
| `OSS_ACCESS_KEY_ID``OSS_ACCESS_KEY_SECRET` | 仅服务端使用的 OSS V1 临时下载链接签名凭据。 |
| 变量 | 说明 |
| -------------------------------------------- | -------------------------------------------------------------------- |
| `HOST` | server 监听地址,例如 `127.0.0.1` |
| `PORT` | server 监听端口,必须是 `1-65535` 的整数,例如 `7878` |
| `DATABASE_URL` | Bright PostgreSQL 连接串;启动 server 和执行 migration 都需要。 |
| `OSS_BUCKET``OSS_ENDPOINT` | Chrome 扩展发布包及本地联调上传所用的 OSS Bucket 与 HTTPS Endpoint。 |
| `OSS_ACCESS_KEY_ID``OSS_ACCESS_KEY_SECRET` | 仅服务端使用的 OSS V1 下载签名与联调页 server-proxy PUT 凭据。 |
本地默认配置如下:
@@ -112,6 +112,8 @@ DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/trade_message_center
`pnpm dev` 只以 `NODE_ENV=development` 启动 Bright server 与 Chrome 扩展。`mind-test-harness` 是顺道使用的低优先级手工工具,必须另开终端运行 `pnpm dev:harness`;它的失败只报告给该命令,绝不阻断主开发流程。启动后,其 Mind 授权模拟默认监听 `127.0.0.1:8787`,独立 Bright 联调页默认监听 `127.0.0.1:8788`。Bright 通过 `MIND_AUTH_BASE_URL` 访问模拟的两个授权接口,不再需要 `ONETALK_DEV_*` 身份 fixture;该工具不连接 Mind 数据库,也不属于生产授权实现。
联调页的“上传本地文件到 OSS”把原始文件 POST 给 Bright,再由 server 使用本地环境中的 `OSS_*` 凭据 PUT 到 `harness-uploads/<uuid>/<fileName>`;浏览器不会接触 AccessKey,也不依赖 OSS 浏览器 CORS。成功后页面展示 30 分钟有效的 HTTPS 地址,并自动填入图片或附件发送字段。运行时必须让 `MIND_PAGE_ORIGIN` 与联调页 Origin 精确一致;若四项 `OSS_*` 都未配置,接口返回 `503 oss_upload_unavailable`,不会伪造地址。
本地 `.env.local` 至少应将 `MIND_AUTH_BASE_URL` 指向 mock,并将 `MIND_PAGE_ORIGIN` 指向 Bright 页面地址:
```dotenv
+120 -1
View File
@@ -49,6 +49,9 @@ export const createOneTalkHarnessHtml = (
.file-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; }
.empty { color: #68737d; padding: 24px 8px; text-align: center; }
.hint { color: #68737d; font-size: 13px; }
.hint[data-state="error"] { color: #9b1c1c; }
.hint[data-state="ok"] { color: #176b35; }
.hint[data-state="warn"] { color: #7a4f01; }
</style>
</head>
<body>
@@ -84,6 +87,20 @@ export const createOneTalkHarnessHtml = (
<p class="hint">HTTP 列表和消息分页都只保存并回传 Bright 返回的 opaque cursor。同步锚点不会作为页面游标。Cookie 快捷入口仅写当前页面同源的开发 Cookie,生产 HttpOnly Cookie 仍由 Mind 登录设置。</p>
</section>
<section class="panel" aria-labelledby="upload-heading">
<h2 id="upload-heading">上传本地文件到 OSS</h2>
<div class="grid">
<label>本地文件<input id="upload-file" type="file"></label>
<label>临时 HTTPS 地址<input id="upload-download-url" autocomplete="off" readonly spellcheck="false" placeholder="上传成功后显示"></label>
</div>
<div class="actions">
<button id="upload-file-button" type="button">上传文件</button>
<button id="copy-upload-url" class="secondary" type="button" disabled>复制临时地址</button>
</div>
<p id="upload-status" class="hint" data-state="idle" aria-live="polite">选择文件后由 Bright server 上传;页面不会读取或保存 OSS 凭据。</p>
<p class="hint">上传成功后,图片会填入“已上传图片 OSS URL”;所有文件都会填入附件地址、文件名和 MIME,可继续发送联调。</p>
</section>
<section class="panel" aria-labelledby="status-heading">
<h2 id="status-heading">连接状态</h2>
<div class="status-grid">
@@ -135,7 +152,9 @@ export const createOneTalkHarnessHtml = (
requestSequence: 0,
recovering: false,
pluginOnline: false,
pendingSendRequestId: null
pendingSendRequestId: null,
uploadInFlight: false,
uploadedUrl: ''
};
const element = (id) => document.getElementById(id);
const fields = {
@@ -157,6 +176,11 @@ export const createOneTalkHarnessHtml = (
fileName: element('file-name'),
fileMimeType: element('file-mime-type'),
sendFile: element('send-file'),
uploadFile: element('upload-file'),
uploadFileButton: element('upload-file-button'),
copyUploadUrl: element('copy-upload-url'),
uploadDownloadUrl: element('upload-download-url'),
uploadStatus: element('upload-status'),
historyStatus: element('history-status'),
connectionStatus: element('connection-status'),
cookieStatus: element('cookie-status'),
@@ -173,6 +197,17 @@ export const createOneTalkHarnessHtml = (
target.querySelector('span').textContent = text;
};
const setUploadStatus = (text, stateName) => {
fields.uploadStatus.dataset.state = stateName;
fields.uploadStatus.textContent = text;
};
const updateUploadAvailability = () => {
fields.uploadFile.disabled = state.uploadInFlight;
fields.uploadFileButton.disabled = state.uploadInFlight || !fields.uploadFile.files || fields.uploadFile.files.length !== 1;
fields.copyUploadUrl.disabled = state.uploadedUrl === '';
};
const appendWsFrame = (direction, serialized) => {
const item = document.createElement('li');
const heading = document.createElement('strong');
@@ -324,6 +359,17 @@ export const createOneTalkHarnessHtml = (
const isConversationResponse = (value) => isRecord(value) && hasExactKeys(value, ['conversation', 'scope']) && isScope(value.scope) && isConversation(value.conversation);
const isMessagesResponse = (value) => isRecord(value) && hasExactKeys(value, ['conversationId', 'messages', 'page', 'scope']) && isScope(value.scope) && isNonBlankString(value.conversationId) && Array.isArray(value.messages) && value.messages.every((message) => isCenterMessage(message) && message.conversationId === value.conversationId) && isPage(value.page);
const isReadErrorResponse = (value) => isRecord(value) && hasExactKeys(value, ['error']) && isRecord(value.error) && hasExactKeys(value.error, ['code']) && ['auth_required', 'authorization_rejected', 'authorization_unavailable', 'authorization_version_changed', 'binding_revoked', 'conversation_not_found', 'database_unavailable', 'history_incomplete', 'internal_error', 'invalid_cursor', 'invalid_limit', 'invalid_time_range', 'scope_mismatch'].includes(value.error.code);
const isUploadResponse = (value) => isRecord(value)
&& hasExactKeys(value, ['downloadUrl', 'fileName', 'mimeType', 'sizeBytes'])
&& isNonBlankString(value.fileName)
&& isNonBlankString(value.mimeType)
&& isNonNegativeInteger(value.sizeBytes)
&& mediaSourceUrl(value.downloadUrl) !== null;
const isUploadErrorResponse = (value) => isRecord(value)
&& hasExactKeys(value, ['error'])
&& isRecord(value.error)
&& hasExactKeys(value.error, ['code'])
&& ['oss_upload_failed', 'oss_upload_invalid_request', 'oss_upload_origin_not_allowed', 'oss_upload_unavailable'].includes(value.error.code);
const isSyncStatus = (value) => isRecord(value) && hasExactKeys(value, ['anchorAdvanced', 'conversationId', 'historyComplete', 'latestMessageId', 'messageCount', 'mode', 'syncPhase', 'syncResult']) && isNonBlankString(value.conversationId) && (value.mode === 'full' || value.mode === 'incremental') && (value.syncPhase === 'initial' || value.syncPhase === 'incremental') && ['succeeded', 'succeeded_with_anomalies', 'failed', 'incomplete'].includes(value.syncResult) && (value.latestMessageId === null || isNonBlankString(value.latestMessageId)) && typeof value.historyComplete === 'boolean' && isNonNegativeInteger(value.messageCount) && typeof value.anchorAdvanced === 'boolean';
const isPermissions = (value) => Array.isArray(value) && value.length > 0 && value.length === new Set(value).size && value.every((permission) => permission === 'read' || permission === 'send');
@@ -394,6 +440,13 @@ export const createOneTalkHarnessHtml = (
return url + '?' + query.toString();
};
const uploadUrlFor = (fileName, mimeType) => {
const url = new URL('/api/oss/harness-upload', brightBaseUrl);
url.searchParams.set('fileName', fileName);
url.searchParams.set('mimeType', mimeType);
return url.toString();
};
const readResponse = async (url, requestHeaders = {}) => {
const response = await fetch(url, { headers: requestHeaders, credentials: 'include' });
let body = null;
@@ -527,6 +580,10 @@ export const createOneTalkHarnessHtml = (
invalid_time_range: '消息时间窗无效',
history_incomplete: '历史尚未完整' + (Number.isSafeInteger(error.retryAfter) ? ';请在 ' + error.retryAfter + ' 秒后重试' : ''),
internal_error: 'Bright 读取发生未预期错误',
oss_upload_failed: 'OSS 上传失败',
oss_upload_invalid_request: '上传文件或元数据无效',
oss_upload_origin_not_allowed: '当前页面 Origin 不被 Bright 允许上传',
oss_upload_unavailable: 'Bright 未配置 OSS 上传能力',
invalid_response: 'Bright 响应不符合读取契约',
http_error: 'Bright 返回了未知 HTTP 错误'
};
@@ -574,6 +631,64 @@ export const createOneTalkHarnessHtml = (
setStatus(fields.cookieStatus, '已写入当前页面 Cookie', 'ok');
};
const uploadFile = async () => {
const file = fields.uploadFile.files?.[0];
if (!file) {
setUploadStatus('请先选择一个本地文件', 'error');
return;
}
const mimeType = file.type || 'application/octet-stream';
state.uploadInFlight = true;
state.uploadedUrl = '';
fields.uploadDownloadUrl.value = '';
setUploadStatus('上传中…', 'warn');
updateUploadAvailability();
try {
const response = await fetch(uploadUrlFor(file.name, mimeType), {
method: 'POST',
headers: { 'content-type': 'application/octet-stream' },
credentials: 'include',
body: file
});
let body = null;
try { body = await response.json(); } catch (_) { body = null; }
if (!response.ok) {
const code = isUploadErrorResponse(body) ? body.error.code : 'http_error';
const error = new Error(code);
error.code = code;
throw error;
}
if (!isUploadResponse(body)) throw new Error('invalid_response');
state.uploadedUrl = body.downloadUrl;
fields.uploadDownloadUrl.value = body.downloadUrl;
fields.fileSourceUrl.value = body.downloadUrl;
fields.fileName.value = body.fileName;
fields.fileMimeType.value = body.mimeType;
if (body.mimeType.startsWith('image/') && imageContentFromUrl(body.downloadUrl)) {
fields.imageSourceUrl.value = body.downloadUrl;
}
setUploadStatus('上传成功:已填入图片或附件发送字段', 'ok');
} catch (error) {
setUploadStatus(errorText(error), 'error');
} finally {
state.uploadInFlight = false;
updateUploadAvailability();
}
};
const copyUploadUrl = async () => {
if (!state.uploadedUrl) return;
fields.uploadDownloadUrl.focus();
fields.uploadDownloadUrl.select();
try {
if (!navigator.clipboard?.writeText) throw new Error('clipboard_unavailable');
await navigator.clipboard.writeText(state.uploadedUrl);
setUploadStatus('临时地址已复制', 'ok');
} catch (_) {
setUploadStatus('浏览器未授予剪贴板权限;地址已选中,可手动复制', 'warn');
}
};
const wsUrl = () => {
const url = new URL('/ws/mind', brightBaseUrl);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -883,6 +998,9 @@ export const createOneTalkHarnessHtml = (
fields.loadConversations.addEventListener('click', () => loadConversations(false));
fields.loadMoreConversations.addEventListener('click', () => loadConversations(true));
fields.writeCookie.addEventListener('click', writeCookie);
fields.uploadFile.addEventListener('change', updateUploadAvailability);
fields.uploadFileButton.addEventListener('click', uploadFile);
fields.copyUploadUrl.addEventListener('click', copyUploadUrl);
fields.account.addEventListener('input', resetForAccountOrQueryChange);
fields.conversationQuery.addEventListener('input', resetForAccountOrQueryChange);
fields.conversation.addEventListener('change', () => {
@@ -934,6 +1052,7 @@ export const createOneTalkHarnessHtml = (
}
});
setPluginStatus('offline');
updateUploadAvailability();
renderMessages();
})();
</script>
+8
View File
@@ -15,6 +15,7 @@ import {
import { installChromeExtensionDownloadRoute } from "./http/chrome-extension-download.ts";
import { installHealthRoute } from "./http/health.ts";
import { installOneTalkReadRoutes } from "./http/onetalk/public.ts";
import { installOssHarnessUploadRoute } from "./http/oss-harness-upload.ts";
import {
createOneTalkReadRepository,
createOneTalkReadService,
@@ -36,6 +37,7 @@ import type {
} from "./onetalk/index.ts";
import type { OneTalkDiagnosticsSink } from "./websocket/diagnostics.ts";
import { createOneTalkCutoverPolicy, type OneTalkCutoverPolicy } from "./cutover-policy.ts";
import type { OssHarnessUploadService } from "./oss/harness-upload.ts";
export type AppDependencies = {
database?: DatabaseConnection;
@@ -49,6 +51,7 @@ export type AppDependencies = {
onOneTalkDiagnostic?: OneTalkDiagnosticsSink;
onMindAuthorizationDiagnostic?: MindAuthorizationDiagnosticsSink;
cutoverPolicy?: OneTalkCutoverPolicy;
ossHarnessUploadService?: OssHarnessUploadService;
};
const authorizationFor = (
@@ -99,6 +102,11 @@ export const createApp = (
installHealthRoute(app);
installChromeExtensionDownloadRoute(app, config.chromeExtensionDownload);
installOssHarnessUploadRoute(app, {
config: config.ossHarnessUpload,
mindPageOrigin,
service: dependencies.ossHarnessUploadService,
});
const oneTalkRegistry = installWebsocket(app, {
authorization,
service: oneTalkService,
+32 -11
View File
@@ -4,6 +4,7 @@ import {
validateChromeExtensionDownloadConfig,
type ChromeExtensionDownloadConfig,
} from "./oss/chrome-extension-download.ts";
import { validateOssV1SigningConfig, type OssV1SigningConfig } from "./oss/v1-signed-url.ts";
export type ServerConfig = {
host: string;
@@ -13,9 +14,14 @@ export type ServerConfig = {
heartbeatIntervalMs?: number;
heartbeatTimeoutMs?: number;
chromeExtensionDownload?: ChromeExtensionDownloadConfig;
ossHarnessUpload?: OssHarnessUploadConfig;
mindAuthorization?: MindAuthorizationConfig;
};
export type OssHarnessUploadConfig = {
signing: OssV1SigningConfig;
};
export type MindAuthorizationConfig = {
baseUrl: string;
mindPageOrigin: string;
@@ -137,22 +143,35 @@ const parseMindAuthorization = (
};
};
const parseOssSigning = (
environment: Record<string, string | undefined>,
): OssV1SigningConfig | undefined => {
const values = [
environment.OSS_BUCKET?.trim(),
environment.OSS_ENDPOINT?.trim(),
environment.OSS_ACCESS_KEY_ID?.trim(),
environment.OSS_ACCESS_KEY_SECRET?.trim(),
];
if (values.every((value) => !value)) return undefined;
return validateOssV1SigningConfig({
bucket: readRequired(environment, "OSS_BUCKET"),
endpoint: readRequired(environment, "OSS_ENDPOINT"),
accessKeyId: readRequired(environment, "OSS_ACCESS_KEY_ID"),
accessKeySecret: readRequired(environment, "OSS_ACCESS_KEY_SECRET"),
});
};
const parseChromeExtensionDownload = (
environment: Record<string, string | undefined>,
signing: OssV1SigningConfig | undefined,
): ChromeExtensionDownloadConfig | undefined => {
const accessKeyId = environment.OSS_ACCESS_KEY_ID?.trim();
const accessKeySecret = environment.OSS_ACCESS_KEY_SECRET?.trim();
if (!accessKeyId && !accessKeySecret) return undefined;
if (signing === undefined || environment.TMC_PACKAGE_VERSION === undefined) return undefined;
return validateChromeExtensionDownloadConfig({
packageVersion: readRequired(environment, "TMC_PACKAGE_VERSION"),
signing: {
bucket: readRequired(environment, "OSS_BUCKET"),
endpoint: readRequired(environment, "OSS_ENDPOINT"),
accessKeyId,
accessKeySecret,
},
signing,
});
};
@@ -160,13 +179,15 @@ const parseChromeExtensionDownload = (
export const loadConfig = (environment: Record<string, string | undefined>): ServerConfig => {
const normalizedEnvironment = parseEnvironment(environment.NODE_ENV);
const heartbeat = parseHeartbeatConfig(environment);
const ossSigning = parseOssSigning(environment);
return {
host: readRequired(environment, "HOST"),
port: parsePort(readRequired(environment, "PORT")),
databaseUrl: readRequired(environment, "DATABASE_URL"),
environment: normalizedEnvironment,
...heartbeat,
chromeExtensionDownload: parseChromeExtensionDownload(environment),
chromeExtensionDownload: parseChromeExtensionDownload(environment, ossSigning),
ossHarnessUpload: ossSigning === undefined ? undefined : { signing: ossSigning },
mindAuthorization: parseMindAuthorization(
environment,
normalizedEnvironment === "development" || environment.NODE_ENV?.trim() === "test",
+167
View File
@@ -0,0 +1,167 @@
// 提供 Mind 联调页的二进制 OSS 上传 HTTP 边界
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import type { OssHarnessUploadConfig } from "../config.ts";
import {
createOssHarnessUploadService,
OssHarnessUploadFailedError,
type OssHarnessUploadService,
} from "../oss/harness-upload.ts";
export const OSS_HARNESS_UPLOAD_ROUTE = "/api/oss/harness-upload";
export const MAX_OSS_HARNESS_UPLOAD_BYTES = 25 * 1024 * 1024;
type UploadQuery = {
fileName?: string;
mimeType?: string;
};
type ParsedUploadBody = Buffer | "too_large";
type OssHarnessUploadErrorCode =
| "oss_upload_failed"
| "oss_upload_invalid_request"
| "oss_upload_origin_not_allowed"
| "oss_upload_unavailable";
export type OssHarnessUploadRouteOptions = {
config?: OssHarnessUploadConfig;
mindPageOrigin?: string;
service?: OssHarnessUploadService;
};
const sendError = (
reply: FastifyReply,
statusCode: 400 | 403 | 502 | 503,
code: OssHarnessUploadErrorCode,
): FastifyReply => reply.code(statusCode).send({ error: { code } });
const isAllowedOrigin = (request: FastifyRequest, mindPageOrigin: string | undefined): boolean => {
return mindPageOrigin !== undefined && request.headers.origin === mindPageOrigin;
};
const isAllowedPreflight = (
request: FastifyRequest,
mindPageOrigin: string | undefined,
): boolean => {
const requestedMethod = request.headers["access-control-request-method"];
const requestedHeaders = request.headers["access-control-request-headers"];
return (
mindPageOrigin !== undefined &&
request.headers.origin === mindPageOrigin &&
requestedMethod === "POST" &&
(requestedHeaders === undefined ||
(typeof requestedHeaders === "string" &&
requestedHeaders
.split(",")
.map((header) => header.trim().toLowerCase())
.every((header) => header === "content-type")))
);
};
const applyCors = (reply: FastifyReply, request: FastifyRequest, mindPageOrigin?: string): void => {
if (mindPageOrigin === undefined || request.headers.origin !== mindPageOrigin) return;
reply.header("access-control-allow-origin", mindPageOrigin);
reply.header("access-control-allow-credentials", "true");
reply.header("vary", "Origin");
};
const isFileName = (value: unknown): value is string =>
typeof value === "string" &&
value.trim() !== "" &&
value !== "." &&
value !== ".." &&
!/[\\/\u0000-\u001f\u007f]/u.test(value);
const isMimeType = (value: unknown): value is string =>
typeof value === "string" && value.trim() === value && value !== "" && !/[\r\n]/u.test(value);
const isBinaryUploadRequest = (request: FastifyRequest): boolean =>
request.method === "POST" &&
request.url.split("?", 1)[0] === OSS_HARNESS_UPLOAD_ROUTE &&
request.headers["content-type"] === "application/octet-stream";
const parseUploadBody = (
payload: NodeJS.ReadableStream,
done: (error: Error | null, body?: ParsedUploadBody) => void,
): void => {
const chunks: Buffer[] = [];
let sizeBytes = 0;
payload.on("data", (chunk: Buffer | string) => {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
sizeBytes += buffer.byteLength;
if (sizeBytes <= MAX_OSS_HARNESS_UPLOAD_BYTES) chunks.push(buffer);
});
payload.once("error", (error) => done(error));
payload.once("end", () => {
done(null, sizeBytes > MAX_OSS_HARNESS_UPLOAD_BYTES ? "too_large" : Buffer.concat(chunks));
});
};
const installBinaryParser = (app: FastifyInstance): void => {
app.addContentTypeParser("application/octet-stream", (request, payload, done) => {
void request;
parseUploadBody(payload, done);
});
};
/** 注册 server-proxy OSS 上传与仅限 Mind 联调页的 CORS 预检接口。 */
export const installOssHarnessUploadRoute = (
app: FastifyInstance,
options: OssHarnessUploadRouteOptions,
): void => {
installBinaryParser(app);
const service =
options.service ??
(options.config === undefined ? undefined : createOssHarnessUploadService(options.config));
app.addHook("onRequest", async (request, reply) => {
if (!isBinaryUploadRequest(request)) {
const path = request.url.split("?", 1)[0];
if (request.method === "POST" && path === OSS_HARNESS_UPLOAD_ROUTE) {
return sendError(reply, 400, "oss_upload_invalid_request");
}
}
});
app.options(OSS_HARNESS_UPLOAD_ROUTE, async (request, reply) => {
if (!isAllowedPreflight(request, options.mindPageOrigin)) return reply.code(403).send();
applyCors(reply, request, options.mindPageOrigin);
reply.header("access-control-allow-methods", "POST,OPTIONS");
reply.header("access-control-allow-headers", "content-type");
return reply.code(204).send();
});
app.post<{ Querystring: UploadQuery }>(OSS_HARNESS_UPLOAD_ROUTE, async (request, reply) => {
if (!isAllowedOrigin(request, options.mindPageOrigin)) {
return sendError(reply, 403, "oss_upload_origin_not_allowed");
}
applyCors(reply, request, options.mindPageOrigin);
if (service === undefined) return sendError(reply, 503, "oss_upload_unavailable");
const body = request.body as ParsedUploadBody;
if (
!isFileName(request.query.fileName) ||
!isMimeType(request.query.mimeType) ||
body === "too_large" ||
!Buffer.isBuffer(body) ||
body.byteLength === 0
) {
return sendError(reply, 400, "oss_upload_invalid_request");
}
try {
return await service.upload({
fileName: request.query.fileName,
mimeType: request.query.mimeType,
body,
});
} catch (error) {
if (error instanceof OssHarnessUploadFailedError) {
return sendError(reply, 502, "oss_upload_failed");
}
return sendError(reply, 502, "oss_upload_failed");
}
});
};
+92
View File
@@ -0,0 +1,92 @@
// 执行 Mind 联调页的服务端 OSS 文件上传
import { randomUUID } from "node:crypto";
import type { OssHarnessUploadConfig } from "../config.ts";
import {
createOssV1ObjectUrl,
createOssV1SignedUrl,
validateOssV1SigningConfig,
} from "./v1-signed-url.ts";
export type OssHarnessUploadInput = {
fileName: string;
mimeType: string;
body: Buffer;
};
export type OssHarnessUploadResult = {
fileName: string;
mimeType: string;
sizeBytes: number;
downloadUrl: string;
};
export type OssHarnessUploadService = {
upload: (input: OssHarnessUploadInput) => Promise<OssHarnessUploadResult>;
};
export type OssHarnessUploadDependencies = {
fetch?: typeof globalThis.fetch;
uuid?: () => string;
now?: () => number;
};
/** 表示可安全映射为稳定 HTTP 失败的 OSS PUT 失败。 */
export class OssHarnessUploadFailedError extends Error {
public constructor() {
super("OSS harness upload failed");
}
}
const objectKeyFor = (fileName: string, uuid: () => string): string =>
`harness-uploads/${uuid()}/${fileName}`;
const uploadObject = async (
input: OssHarnessUploadInput,
config: OssHarnessUploadConfig,
dependencies: Required<OssHarnessUploadDependencies>,
): Promise<OssHarnessUploadResult> => {
const objectUrl = createOssV1ObjectUrl(
objectKeyFor(input.fileName, dependencies.uuid),
config.signing,
);
const uploadUrl = createOssV1SignedUrl(objectUrl, config.signing, {
now: dependencies.now,
method: "PUT",
contentType: input.mimeType,
});
try {
const response = await dependencies.fetch(uploadUrl, {
method: "PUT",
headers: { "content-type": input.mimeType },
body: input.body,
});
if (!response.ok) throw new OssHarnessUploadFailedError();
} catch {
throw new OssHarnessUploadFailedError();
}
return {
fileName: input.fileName,
mimeType: input.mimeType,
sizeBytes: input.body.byteLength,
downloadUrl: createOssV1SignedUrl(objectUrl, config.signing, { now: dependencies.now }),
};
};
/** 创建唯一拥有 OSS PUT 与临时 GET 地址生成顺序的上传服务。 */
export const createOssHarnessUploadService = (
config: OssHarnessUploadConfig,
dependencies: OssHarnessUploadDependencies = {},
): OssHarnessUploadService => {
const validConfig = { signing: validateOssV1SigningConfig(config.signing) };
const resolvedDependencies: Required<OssHarnessUploadDependencies> = {
fetch: dependencies.fetch ?? globalThis.fetch,
uuid: dependencies.uuid ?? randomUUID,
now: dependencies.now ?? Date.now,
};
return { upload: (input) => uploadObject(input, validConfig, resolvedDependencies) };
};
+58 -3
View File
@@ -11,6 +11,8 @@ export type OssV1SigningConfig = {
export type OssV1SignedUrlOptions = {
now?: () => number;
method?: "GET" | "PUT";
contentType?: string;
};
type ConfigField = keyof OssV1SigningConfig;
@@ -20,6 +22,11 @@ type OssEndpoint = {
port: string;
};
type OssV1Request = {
method: "GET" | "PUT";
contentType: string;
};
const SIGNED_URL_TTL_SECONDS = 1_800;
const BUCKET_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{1,61}[a-z0-9])$/;
const DOT_SPELLING_PATTERN = /%2e/gi;
@@ -168,6 +175,22 @@ const decodeObjectKey = (pathname: string): string => {
return objectKey;
};
const validateObjectKey = (objectKey: unknown): string => {
if (typeof objectKey !== "string") return fail("Invalid OSS object key");
if (
Buffer.byteLength(objectKey, "utf8") < 1 ||
Buffer.byteLength(objectKey, "utf8") > 1_023 ||
objectKey.startsWith("/") ||
objectKey.startsWith("\\") ||
objectKey.includes("\\") ||
WHATWG_STRIPPED_CONTROL_PATTERN.test(objectKey) ||
objectKey.split("/").some((segment) => segment === "." || segment === "..")
) {
return fail("Invalid OSS object key");
}
return objectKey;
};
const encodePathSegment = (segment: string): string =>
encodeURIComponent(segment).replace(
/[!'()*]/g,
@@ -192,16 +215,47 @@ const readNow = (options: OssV1SignedUrlOptions | undefined): number => {
return now;
};
const parseRequest = (options: OssV1SignedUrlOptions | undefined): OssV1Request => {
const method = options?.method ?? "GET";
if (method !== "GET" && method !== "PUT") return fail("Invalid OSS signing method");
const contentType = options?.contentType;
if (method === "GET") {
if (contentType !== undefined) return fail("Invalid OSS content type");
return { method, contentType: "" };
}
if (
typeof contentType !== "string" ||
contentType.trim() === "" ||
contentType.trim() !== contentType ||
contentType.includes("\r") ||
contentType.includes("\n")
) {
return fail("Invalid OSS content type");
}
return { method, contentType };
};
const createSignature = (
config: OssV1SigningConfig,
objectKey: string,
expires: number,
request: OssV1Request,
): string => {
const stringToSign = `GET\n\n\n${expires}\n/${config.bucket}/${objectKey}`;
const stringToSign = `${request.method}\n\n${request.contentType}\n${expires}\n/${config.bucket}/${objectKey}`;
return createHmac("sha1", config.accessKeySecret).update(stringToSign, "utf8").digest("base64");
};
/** 为指定 OSS 对象生成固定时效的 V1 GET 下载地址。 */
/** 根据已经验证的 key 生成所属 bucket 的 OSS 对象地址。 */
export const createOssV1ObjectUrl = (objectKey: string, config: OssV1SigningConfig): string => {
const validConfig = validateOssV1SigningConfig(config);
const endpoint = parseEndpoint(validConfig.endpoint);
const validObjectKey = validateObjectKey(objectKey);
const port = endpoint.port === "" ? "" : `:${endpoint.port}`;
return `https://${validConfig.bucket}.${endpoint.hostname}${port}${encodeObjectPath(validObjectKey)}`;
};
/** 为指定 OSS 对象生成固定时效的 V1 GET 或 server-side PUT 签名地址。 */
export const createOssV1SignedUrl = (
objectUrl: string,
config: OssV1SigningConfig,
@@ -211,8 +265,9 @@ export const createOssV1SignedUrl = (
const endpoint = parseEndpoint(validConfig.endpoint);
const parsedObjectUrl = parseObjectUrl(objectUrl, validConfig.bucket, endpoint);
const objectKey = decodeObjectKey(parsedObjectUrl.pathname);
const request = parseRequest(options);
const expires = Math.floor(readNow(options) / 1_000) + SIGNED_URL_TTL_SECONDS;
const signature = createSignature(validConfig, objectKey, expires);
const signature = createSignature(validConfig, objectKey, expires, request);
const query = new URLSearchParams();
query.append("Expires", expires.toString());
+74 -7
View File
@@ -105,18 +105,33 @@ test("requires strict production Mind authorization configuration", () => {
};
assert.equal(loadConfig(base).mindAuthorization?.timeoutMs, 3000);
assert.equal(loadConfig(base).chromeExtensionDownload?.packageVersion, "1.2.3");
assert.equal(loadConfig(base).ossHarnessUpload?.signing.bucket, "example-bucket");
assert.throws(
() => loadConfig({ ...base, TMC_PACKAGE_VERSION: "1.2" }),
/Invalid Chrome extension version/,
);
assert.equal(
loadConfig({
...base,
OSS_ACCESS_KEY_ID: undefined,
OSS_ACCESS_KEY_SECRET: undefined,
}).chromeExtensionDownload,
undefined,
assert.throws(
() =>
loadConfig({
...base,
OSS_ACCESS_KEY_ID: undefined,
OSS_ACCESS_KEY_SECRET: undefined,
}),
/Missing OSS_ACCESS_KEY_ID/,
);
const harnessOnly = loadConfig({ ...base, TMC_PACKAGE_VERSION: undefined });
assert.equal(harnessOnly.chromeExtensionDownload, undefined);
assert.equal(harnessOnly.ossHarnessUpload?.signing.accessKeyId, "test-access-key-id");
const withoutOss = loadConfig({
...base,
OSS_BUCKET: undefined,
OSS_ENDPOINT: undefined,
OSS_ACCESS_KEY_ID: undefined,
OSS_ACCESS_KEY_SECRET: undefined,
TMC_PACKAGE_VERSION: undefined,
});
assert.equal(withoutOss.chromeExtensionDownload, undefined);
assert.equal(withoutOss.ossHarnessUpload, undefined);
assert.throws(
() => loadConfig({ ...base, ONETALK_PLUGIN_ORIGINS: "chrome-extension://" }),
/Invalid ONETALK_PLUGIN_ORIGINS/,
@@ -317,6 +332,58 @@ test("returns a stable error when download signing is not configured", async ()
}
});
test("composes the Mind-only OSS upload route with its injected service", async () => {
const database = createDatabaseStub();
const app = createApp(
{
...testConfig,
ossHarnessUpload: { signing: testConfig.chromeExtensionDownload.signing },
mindAuthorization: {
baseUrl: "http://127.0.0.1:8787",
mindPageOrigin: "http://127.0.0.1:8788",
pluginOrigins: ["chrome-extension://extension-id"],
timeoutMs: 100,
},
},
{
database: database.connection,
ossHarnessUploadService: {
upload: async (input) => ({
fileName: input.fileName,
mimeType: input.mimeType,
sizeBytes: input.body.byteLength,
downloadUrl:
"https://example-bucket.oss.example.com/harness-uploads/fixed-id/file.txt?Expires=1",
}),
},
},
);
try {
await app.ready();
const response = await app.inject({
method: "POST",
url: "/api/oss/harness-upload?fileName=file.txt&mimeType=text%2Fplain",
headers: {
origin: "http://127.0.0.1:8788",
"content-type": "application/octet-stream",
},
payload: "body",
});
assert.equal(response.statusCode, 200);
assert.deepEqual(response.json(), {
fileName: "file.txt",
mimeType: "text/plain",
sizeBytes: 4,
downloadUrl:
"https://example-bucket.oss.example.com/harness-uploads/fixed-id/file.txt?Expires=1",
});
} finally {
await app.close();
}
assert.equal(database.getCloseCount(), 1);
});
test("registers the websocket server without crashing the app", async () => {
const database = createDatabaseStub();
const app = createApp(testConfig, { database: database.connection });
+273
View File
@@ -0,0 +1,273 @@
// 验证 Mind 联调页的 OSS 上传服务与 HTTP 契约
import assert from "node:assert/strict";
import { createHmac } from "node:crypto";
import test from "node:test";
import Fastify from "fastify";
import type { OssHarnessUploadConfig } from "../src/config.ts";
import {
installOssHarnessUploadRoute,
OSS_HARNESS_UPLOAD_ROUTE,
} from "../src/http/oss-harness-upload.ts";
import {
createOssHarnessUploadService,
OssHarnessUploadFailedError,
type OssHarnessUploadService,
} from "../src/oss/harness-upload.ts";
const FIXED_NOW_MS = 1_700_000_000_999;
const FIXED_EXPIRES = 1_700_001_800;
const FIXED_DOWNLOAD_NOW_MS = 1_700_000_010_999;
const FIXED_DOWNLOAD_EXPIRES = 1_700_001_810;
const TEST_SECRET = "test-only-secret-value";
const TEST_CONFIG: OssHarnessUploadConfig = {
signing: {
bucket: "example-bucket",
endpoint: "https://oss.example.com",
accessKeyId: "test-access-key-id",
accessKeySecret: TEST_SECRET,
},
};
const MIND_PAGE_ORIGIN = "http://127.0.0.1:8788";
test("uploads one unique harness object with a signed PUT and returns a signed GET URL", async () => {
const requests: Array<{ url: URL; init: RequestInit }> = [];
const clocks = [FIXED_NOW_MS, FIXED_DOWNLOAD_NOW_MS];
const service = createOssHarnessUploadService(TEST_CONFIG, {
fetch: async (input, init) => {
requests.push({ url: new URL(input.toString()), init: init ?? {} });
return new Response(null, { status: 200 });
},
now: () => {
const now = clocks.shift();
if (now === undefined) throw new Error("unexpected clock call");
return now;
},
uuid: () => "fixed-upload-id",
});
const result = await service.upload({
fileName: "报价.pdf",
mimeType: "application/pdf",
body: Buffer.from("test file"),
});
assert.equal(requests.length, 1);
const request = requests[0];
assert.equal(request?.init.method, "PUT");
assert.equal(new Headers(request?.init.headers).get("content-type"), "application/pdf");
assert.deepEqual(request?.init.body, Buffer.from("test file"));
assert.equal(request?.url.pathname, "/harness-uploads/fixed-upload-id/%E6%8A%A5%E4%BB%B7.pdf");
assert.equal(request?.url.searchParams.get("Expires"), FIXED_EXPIRES.toString());
assert.equal(
request?.url.searchParams.get("Signature"),
createHmac("sha1", TEST_SECRET)
.update(
"PUT\n\napplication/pdf\n1700001800\n/example-bucket/harness-uploads/fixed-upload-id/报价.pdf",
"utf8",
)
.digest("base64"),
);
assert.deepEqual(result, {
fileName: "报价.pdf",
mimeType: "application/pdf",
sizeBytes: 9,
downloadUrl: result.downloadUrl,
});
assert.equal(new URL(result.downloadUrl).pathname, request?.url.pathname);
assert.equal(
new URL(result.downloadUrl).searchParams.get("Expires"),
FIXED_DOWNLOAD_EXPIRES.toString(),
);
assert.equal(
new URL(result.downloadUrl).searchParams.get("Signature"),
createHmac("sha1", TEST_SECRET)
.update(
"GET\n\n\n1700001810\n/example-bucket/harness-uploads/fixed-upload-id/报价.pdf",
"utf8",
)
.digest("base64"),
);
assert.deepEqual(clocks, []);
assert.equal(result.downloadUrl.includes(TEST_SECRET), false);
});
test("maps OSS non-success and transport failures to the stable upload error", async () => {
const input = {
fileName: "report.txt",
mimeType: "text/plain",
body: Buffer.from("body"),
};
for (const fetch of [
async () => new Response(null, { status: 403 }),
async () => {
throw new Error("network unavailable");
},
]) {
const service = createOssHarnessUploadService(TEST_CONFIG, {
fetch,
now: () => FIXED_NOW_MS,
uuid: () => "fixed-upload-id",
});
await assert.rejects(() => service.upload(input), OssHarnessUploadFailedError);
}
});
const createRouteApp = (options: { service?: OssHarnessUploadService; configured?: boolean }) => {
const app = Fastify({ logger: false });
installOssHarnessUploadRoute(app, {
...(options.configured === false ? {} : { config: TEST_CONFIG }),
mindPageOrigin: MIND_PAGE_ORIGIN,
service: options.service,
});
return app;
};
test("serves the CORS preflight and forwards a validated binary upload", async () => {
const inputs: unknown[] = [];
const app = createRouteApp({
service: {
upload: async (input) => {
inputs.push(input);
return {
fileName: input.fileName,
mimeType: input.mimeType,
sizeBytes: input.body.byteLength,
downloadUrl:
"https://example-bucket.oss.example.com/harness-uploads/fixed-id/image.png?Expires=1",
};
},
},
});
try {
await app.ready();
const preflight = await app.inject({
method: "OPTIONS",
url: OSS_HARNESS_UPLOAD_ROUTE,
headers: {
origin: MIND_PAGE_ORIGIN,
"access-control-request-method": "POST",
"access-control-request-headers": "content-type",
},
});
assert.equal(preflight.statusCode, 204);
assert.equal(preflight.headers["access-control-allow-origin"], MIND_PAGE_ORIGIN);
assert.equal(preflight.headers["access-control-allow-methods"], "POST,OPTIONS");
const response = await app.inject({
method: "POST",
url: `${OSS_HARNESS_UPLOAD_ROUTE}?fileName=image.png&mimeType=image%2Fpng`,
headers: {
origin: MIND_PAGE_ORIGIN,
"content-type": "application/octet-stream",
},
payload: Buffer.from([1, 2, 3]),
});
assert.equal(response.statusCode, 200);
assert.deepEqual(response.json(), {
fileName: "image.png",
mimeType: "image/png",
sizeBytes: 3,
downloadUrl:
"https://example-bucket.oss.example.com/harness-uploads/fixed-id/image.png?Expires=1",
});
assert.equal(response.headers["access-control-allow-origin"], MIND_PAGE_ORIGIN);
assert.deepEqual(inputs, [
{ fileName: "image.png", mimeType: "image/png", body: Buffer.from([1, 2, 3]) },
]);
} finally {
await app.close();
}
});
test("returns unavailable when OSS upload is not configured", async () => {
const app = createRouteApp({ configured: false });
try {
await app.ready();
const response = await app.inject({
method: "POST",
url: `${OSS_HARNESS_UPLOAD_ROUTE}?fileName=file.txt&mimeType=text%2Fplain`,
headers: { origin: MIND_PAGE_ORIGIN, "content-type": "application/octet-stream" },
payload: "body",
});
assert.equal(response.statusCode, 503);
assert.deepEqual(response.json(), { error: { code: "oss_upload_unavailable" } });
} finally {
await app.close();
}
});
test("does not call the upload service for invalid or unexpected-origin requests", async () => {
let calls = 0;
const service: OssHarnessUploadService = {
upload: async () => {
calls += 1;
throw new Error("must not be called");
},
};
const app = createRouteApp({ service });
try {
await app.ready();
const wrongOrigin = await app.inject({
method: "POST",
url: `${OSS_HARNESS_UPLOAD_ROUTE}?fileName=file.txt&mimeType=text%2Fplain`,
headers: {
origin: "https://untrusted.example.com",
"content-type": "application/octet-stream",
},
payload: "body",
});
assert.equal(wrongOrigin.statusCode, 403);
assert.deepEqual(wrongOrigin.json(), { error: { code: "oss_upload_origin_not_allowed" } });
const invalid = await app.inject({
method: "POST",
url: `${OSS_HARNESS_UPLOAD_ROUTE}?fileName=dir%2Ffile.txt&mimeType=text%2Fplain`,
headers: { origin: MIND_PAGE_ORIGIN, "content-type": "application/octet-stream" },
payload: "body",
});
assert.equal(invalid.statusCode, 400);
assert.deepEqual(invalid.json(), { error: { code: "oss_upload_invalid_request" } });
const missingContentType = await app.inject({
method: "POST",
url: `${OSS_HARNESS_UPLOAD_ROUTE}?fileName=file.txt&mimeType=text%2Fplain`,
headers: { origin: MIND_PAGE_ORIGIN },
payload: "body",
});
assert.equal(missingContentType.statusCode, 400);
assert.deepEqual(missingContentType.json(), {
error: { code: "oss_upload_invalid_request" },
});
assert.equal(calls, 0);
} finally {
await app.close();
}
});
test("redacts upstream failures from the HTTP response", async () => {
const app = createRouteApp({
service: { upload: async () => Promise.reject(new OssHarnessUploadFailedError()) },
});
try {
await app.ready();
const response = await app.inject({
method: "POST",
url: `${OSS_HARNESS_UPLOAD_ROUTE}?fileName=file.txt&mimeType=text%2Fplain`,
headers: { origin: MIND_PAGE_ORIGIN, "content-type": "application/octet-stream" },
payload: "body",
});
assert.equal(response.statusCode, 502);
assert.deepEqual(response.json(), { error: { code: "oss_upload_failed" } });
assert.equal(response.body.includes("OSS harness upload failed"), false);
} finally {
await app.close();
}
});
+68 -3
View File
@@ -5,7 +5,11 @@ import { createHmac } from "node:crypto";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createOssV1SignedUrl, type OssV1SigningConfig } from "../src/oss/v1-signed-url.ts";
import {
createOssV1ObjectUrl,
createOssV1SignedUrl,
type OssV1SigningConfig,
} from "../src/oss/v1-signed-url.ts";
const FIXED_NOW_MS = 1_700_000_000_999;
const FIXED_EXPIRES = 1_700_001_800;
@@ -19,8 +23,13 @@ const FIXED_CONFIG: OssV1SigningConfig = {
accessKeySecret: "test-only-secret-value",
};
const createOracleSignature = (objectKey: string, expires = FIXED_EXPIRES): string => {
const stringToSign = `GET\n\n\n${expires}\n/${FIXED_CONFIG.bucket}/${objectKey}`;
const createOracleSignature = (
objectKey: string,
expires = FIXED_EXPIRES,
method: "GET" | "PUT" = "GET",
contentType = "",
): string => {
const stringToSign = `${method}\n\n${contentType}\n${expires}\n/${FIXED_CONFIG.bucket}/${objectKey}`;
return createHmac("sha1", FIXED_CONFIG.accessKeySecret)
.update(stringToSign, "utf8")
.digest("base64");
@@ -62,6 +71,62 @@ test("creates the fixed OSS V1 GET signing vector with ordered query parameters"
);
});
test("creates a PUT signing vector with the exact uploaded Content-Type", () => {
const objectUrl = createOssV1ObjectUrl("harness-uploads/fixed-id/报价 100%.pdf", FIXED_CONFIG);
const signedUrl = createOssV1SignedUrl(objectUrl, FIXED_CONFIG, {
now: () => FIXED_NOW_MS,
method: "PUT",
contentType: "application/pdf",
});
const parsed = new URL(signedUrl);
assert.equal(
objectUrl,
"https://example-bucket.oss.example.com/harness-uploads/fixed-id/%E6%8A%A5%E4%BB%B7%20100%25.pdf",
);
assert.equal(parsed.pathname, "/harness-uploads/fixed-id/%E6%8A%A5%E4%BB%B7%20100%25.pdf");
assert.equal(
parsed.searchParams.get("Signature"),
createOracleSignature(
"harness-uploads/fixed-id/报价 100%.pdf",
FIXED_EXPIRES,
"PUT",
"application/pdf",
),
);
});
test("rejects unsupported signing methods and invalid PUT Content-Type values", () => {
assert.throws(
() =>
createOssV1SignedUrl(FIXED_OBJECT_URL, FIXED_CONFIG, {
method: "DELETE" as "GET",
}),
/Invalid OSS signing method/,
);
for (const contentType of [
undefined,
"",
" ",
" text/plain",
"text/plain ",
"text/plain\r\nX-Test: value",
]) {
assert.throws(
() =>
createOssV1SignedUrl(FIXED_OBJECT_URL, FIXED_CONFIG, {
method: "PUT",
contentType,
}),
/Invalid OSS content type/,
);
}
assert.throws(
() => createOssV1ObjectUrl("/leading-slash", FIXED_CONFIG),
/Invalid OSS object key/,
);
});
test("normalizes decoded object keys before signing and RFC3986 path encoding", () => {
const specialObjectUrl =
"https://example-bucket.oss.example.com/space%20folder/%E4%BD%A0%E5%A5%BD%2B100%25%3F%23%5Cmid.txt";