mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
feat: add OSS harness file upload
This commit is contained in:
+1
-1
@@ -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 + Base64;pathname 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 + Base64;pathname percent-decode 一次后签名,再按 RFC 3986 编码输出路径,`%2F` 与 `/` 按解码后的 key 语义等价。
|
||||
- `createOssV1ObjectUrl(objectKey, config)` 只为已经验证的 key 生成所属 bucket 的 HTTPS object URL;它仍不发起网络请求。
|
||||
- `OSS_REGION` 不进入 signer 配置或 V1 StringToSign;AccessKey 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-Type;PUT 的 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
|
||||
|
||||
- Good:Mind 页面将 File 原始 bytes 交给 Bright;service 生成唯一 `harness-uploads/` key,PUT 成功后才返回 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 vector;service 测试断言 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 的稳定失败;不使用真实密钥。
|
||||
- HTTP:Fastify inject 覆盖未配置、preflight、非法 query/body、成功响应与上游失败,且断言响应不含 secret/signed PUT URL。
|
||||
- harness:TypeScript 检查;手工 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/Origin;OSS 请求只从 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": {}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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) };
|
||||
};
|
||||
@@ -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());
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user