mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
feat(server): add Chrome extension download endpoint
This commit is contained in:
@@ -6,6 +6,12 @@ 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 注入。
|
||||
OSS_BUCKET=sinanpilot-bucket
|
||||
OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
|
||||
OSS_ACCESS_KEY_ID=
|
||||
OSS_ACCESS_KEY_SECRET=
|
||||
|
||||
# Mind authorization upstream. For local `pnpm dev`, point this at the Mind HTTP mock.
|
||||
MIND_AUTH_BASE_URL=https://mind.example.com
|
||||
# Bright API 的 CORS 白名单
|
||||
|
||||
@@ -51,6 +51,10 @@ jobs:
|
||||
MIND_PAGE_ORIGIN: http://127.0.0.1:7878
|
||||
ONETALK_PLUGIN_ORIGINS: chrome-extension://ogdbffjakeeidblabkeakakdecfbcmlf
|
||||
MIND_AUTH_TIMEOUT_MS: "3000"
|
||||
OSS_BUCKET: sinanpilot-bucket
|
||||
OSS_ENDPOINT: https://oss-cn-hangzhou.aliyuncs.com
|
||||
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
|
||||
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
|
||||
VITE_BRIGHT_WEBSOCKET_URL: ${{ vars.VITE_BRIGHT_WEBSOCKET_URL }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -77,6 +81,7 @@ jobs:
|
||||
version="$(node scripts/package-version.mjs)"
|
||||
printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT"
|
||||
printf 'PACKAGE_VERSION=%s\n' "$version" >> "$GITHUB_ENV"
|
||||
printf 'TMC_PACKAGE_VERSION=%s\n' "$version" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Verify CI authorization environment
|
||||
shell: bash
|
||||
@@ -213,6 +218,11 @@ jobs:
|
||||
HOST: 0.0.0.0
|
||||
PORT: 7878
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
PACKAGE_VERSION: ${{ needs.quality.outputs.extension_version }}
|
||||
OSS_BUCKET: sinanpilot-bucket
|
||||
OSS_ENDPOINT: https://oss-cn-hangzhou.aliyuncs.com
|
||||
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
|
||||
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
|
||||
MIND_AUTH_BASE_URL: ${{ vars.MIND_AUTH_BASE_URL }}
|
||||
MIND_PAGE_ORIGIN: ${{ vars.MIND_PAGE_ORIGIN }}
|
||||
ONETALK_PLUGIN_ORIGINS: chrome-extension://ogdbffjakeeidblabkeakakdecfbcmlf
|
||||
@@ -258,6 +268,11 @@ jobs:
|
||||
: "${HOST:?HOST is required}"
|
||||
: "${PORT:?PORT is required}"
|
||||
: "${DATABASE_URL:?DATABASE_URL is required}"
|
||||
: "${PACKAGE_VERSION:?PACKAGE_VERSION is required}"
|
||||
: "${OSS_BUCKET:?OSS_BUCKET is required}"
|
||||
: "${OSS_ENDPOINT:?OSS_ENDPOINT is required}"
|
||||
: "${OSS_ACCESS_KEY_ID:?OSS_ACCESS_KEY_ID is required}"
|
||||
: "${OSS_ACCESS_KEY_SECRET:?OSS_ACCESS_KEY_SECRET is required}"
|
||||
: "${MIND_AUTH_BASE_URL:?MIND_AUTH_BASE_URL is required}"
|
||||
: "${MIND_PAGE_ORIGIN:?MIND_PAGE_ORIGIN is required}"
|
||||
: "${ONETALK_PLUGIN_ORIGINS:?ONETALK_PLUGIN_ORIGINS is required}"
|
||||
@@ -286,14 +301,20 @@ jobs:
|
||||
|
||||
build_state_path="$BUILD_PATH/.ci"
|
||||
database_file="$build_state_path/database_url"
|
||||
oss_access_key_id_file="$build_state_path/oss_access_key_id"
|
||||
oss_access_key_secret_file="$build_state_path/oss_access_key_secret"
|
||||
runtime_env_file="$build_state_path/runtime.env"
|
||||
target_key_file="$build_state_path/target_key"
|
||||
ssh "${build_ssh_options[@]}" "$BUILD_USER@$BUILD_HOST" \
|
||||
"umask 077; mkdir -p $(quote_for_shell "$build_state_path"); cat > $(quote_for_shell "$database_file")" <<< "$DATABASE_URL"
|
||||
ssh "${build_ssh_options[@]}" "$BUILD_USER@$BUILD_HOST" \
|
||||
"umask 077; cat > $(quote_for_shell "$oss_access_key_id_file")" <<< "$OSS_ACCESS_KEY_ID"
|
||||
ssh "${build_ssh_options[@]}" "$BUILD_USER@$BUILD_HOST" \
|
||||
"umask 077; cat > $(quote_for_shell "$oss_access_key_secret_file")" <<< "$OSS_ACCESS_KEY_SECRET"
|
||||
ssh "${build_ssh_options[@]}" "$BUILD_USER@$BUILD_HOST" \
|
||||
"umask 077; cat > $(quote_for_shell "$target_key_file")" <<< "$DEPLOY_SSH_KEY"
|
||||
|
||||
build_env="BUILD_PATH=$(quote_for_shell "$BUILD_PATH") SOURCE_TAG=$(quote_for_shell "$SOURCE_TAG") IMAGE_NAME=$(quote_for_shell "$IMAGE_NAME") IMAGE_TAG=$(quote_for_shell "$IMAGE_TAG") HOST=$(quote_for_shell "$HOST") PORT=$(quote_for_shell "$PORT") MIND_AUTH_BASE_URL=$(quote_for_shell "$MIND_AUTH_BASE_URL") MIND_PAGE_ORIGIN=$(quote_for_shell "$MIND_PAGE_ORIGIN") ONETALK_PLUGIN_ORIGINS=$(quote_for_shell "$ONETALK_PLUGIN_ORIGINS") MIND_AUTH_TIMEOUT_MS=$(quote_for_shell "$MIND_AUTH_TIMEOUT_MS") DEPLOY_HOST=$(quote_for_shell "$DEPLOY_HOST") DEPLOY_PORT=$(quote_for_shell "${DEPLOY_PORT:-}") DEPLOY_USER=$(quote_for_shell "$DEPLOY_USER") DEPLOY_PATH=$(quote_for_shell "$DEPLOY_PATH") DEPLOY_CONTAINER_NAME=$(quote_for_shell "$DEPLOY_CONTAINER_NAME") DEPLOY_HOST_PORT=$(quote_for_shell "$DEPLOY_HOST_PORT") DATABASE_FILE=$(quote_for_shell "$database_file") RUNTIME_ENV_FILE=$(quote_for_shell "$runtime_env_file") TARGET_KEY_FILE=$(quote_for_shell "$target_key_file")"
|
||||
build_env="BUILD_PATH=$(quote_for_shell "$BUILD_PATH") SOURCE_TAG=$(quote_for_shell "$SOURCE_TAG") IMAGE_NAME=$(quote_for_shell "$IMAGE_NAME") IMAGE_TAG=$(quote_for_shell "$IMAGE_TAG") HOST=$(quote_for_shell "$HOST") PORT=$(quote_for_shell "$PORT") PACKAGE_VERSION=$(quote_for_shell "$PACKAGE_VERSION") OSS_BUCKET=$(quote_for_shell "$OSS_BUCKET") OSS_ENDPOINT=$(quote_for_shell "$OSS_ENDPOINT") MIND_AUTH_BASE_URL=$(quote_for_shell "$MIND_AUTH_BASE_URL") MIND_PAGE_ORIGIN=$(quote_for_shell "$MIND_PAGE_ORIGIN") ONETALK_PLUGIN_ORIGINS=$(quote_for_shell "$ONETALK_PLUGIN_ORIGINS") MIND_AUTH_TIMEOUT_MS=$(quote_for_shell "$MIND_AUTH_TIMEOUT_MS") DEPLOY_HOST=$(quote_for_shell "$DEPLOY_HOST") DEPLOY_PORT=$(quote_for_shell "${DEPLOY_PORT:-}") DEPLOY_USER=$(quote_for_shell "$DEPLOY_USER") DEPLOY_PATH=$(quote_for_shell "$DEPLOY_PATH") DEPLOY_CONTAINER_NAME=$(quote_for_shell "$DEPLOY_CONTAINER_NAME") DEPLOY_HOST_PORT=$(quote_for_shell "$DEPLOY_HOST_PORT") DATABASE_FILE=$(quote_for_shell "$database_file") OSS_ACCESS_KEY_ID_FILE=$(quote_for_shell "$oss_access_key_id_file") OSS_ACCESS_KEY_SECRET_FILE=$(quote_for_shell "$oss_access_key_secret_file") RUNTIME_ENV_FILE=$(quote_for_shell "$runtime_env_file") TARGET_KEY_FILE=$(quote_for_shell "$target_key_file")"
|
||||
ssh "${build_ssh_options[@]}" "$BUILD_USER@$BUILD_HOST" \
|
||||
"$build_env bash -s" <<'BUILD_SCRIPT'
|
||||
set -euo pipefail
|
||||
@@ -312,7 +333,7 @@ jobs:
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
rm -f "$DATABASE_FILE" "$RUNTIME_ENV_FILE" "$TARGET_KEY_FILE"
|
||||
rm -f "$DATABASE_FILE" "$OSS_ACCESS_KEY_ID_FILE" "$OSS_ACCESS_KEY_SECRET_FILE" "$RUNTIME_ENV_FILE" "$TARGET_KEY_FILE"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
@@ -333,11 +354,18 @@ jobs:
|
||||
"$BUILD_PATH"
|
||||
|
||||
database_url="$(<"$DATABASE_FILE")"
|
||||
oss_access_key_id="$(<"$OSS_ACCESS_KEY_ID_FILE")"
|
||||
oss_access_key_secret="$(<"$OSS_ACCESS_KEY_SECRET_FILE")"
|
||||
{
|
||||
write_env_value NODE_ENV production
|
||||
write_env_value HOST "$HOST"
|
||||
write_env_value PORT "$PORT"
|
||||
write_env_value DATABASE_URL "$database_url"
|
||||
write_env_value TMC_PACKAGE_VERSION "$PACKAGE_VERSION"
|
||||
write_env_value OSS_BUCKET "$OSS_BUCKET"
|
||||
write_env_value OSS_ENDPOINT "$OSS_ENDPOINT"
|
||||
write_env_value OSS_ACCESS_KEY_ID "$oss_access_key_id"
|
||||
write_env_value OSS_ACCESS_KEY_SECRET "$oss_access_key_secret"
|
||||
write_env_value MIND_AUTH_BASE_URL "$MIND_AUTH_BASE_URL"
|
||||
write_env_value MIND_PAGE_ORIGIN "$MIND_PAGE_ORIGIN"
|
||||
write_env_value ONETALK_PLUGIN_ORIGINS "$ONETALK_PLUGIN_ORIGINS"
|
||||
|
||||
@@ -26,10 +26,12 @@ apps/
|
||||
│ │ ├── migrate.ts
|
||||
│ │ └── schema/index.ts
|
||||
│ ├── http/
|
||||
│ │ ├── chrome-extension-download.ts
|
||||
│ │ ├── harness.ts
|
||||
│ │ ├── health.ts
|
||||
│ │ └── onetalk.ts
|
||||
│ ├── oss/
|
||||
│ │ ├── chrome-extension-download.ts
|
||||
│ │ └── v1-signed-url.ts
|
||||
│ ├── onetalk/
|
||||
│ │ ├── index.ts
|
||||
@@ -53,6 +55,7 @@ apps/
|
||||
│ └── meta/_journal.json
|
||||
└── test/
|
||||
├── app.test.ts
|
||||
├── chrome-extension-download.test.ts
|
||||
└── oss-v1-signed-url.test.ts
|
||||
```
|
||||
|
||||
@@ -80,7 +83,9 @@ OneTalk 业务服务、Bright 读取路由、独立历史 cursor 和 repository
|
||||
- `websocket/registry.ts` 只管理已认证连接和 publish-after-commit 的 Mind 发布,不创建消息事实或 outbox。
|
||||
- `http/onetalk.ts` 只做 Bright scope/授权/参数边界和响应映射;列表/详情/历史查询委派给 `read-service.ts`,不直接访问 Drizzle。
|
||||
- `http/harness.ts` 只提供本地原生 HTML 联调页;页面通过 Bright HTTP/WS 访问服务,不复制 repository 或 Mind legacy 读取。
|
||||
- `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/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` 仅服务既有同步历史形状。
|
||||
- 配置只由 `src/config.ts` 在启动边界解析;业务模块不得直接读取 `process.env`。
|
||||
|
||||
@@ -35,11 +35,13 @@ server 开发入口和迁移命令按以下顺序读取文件,后面的文件
|
||||
|
||||
### 必填基础配置
|
||||
|
||||
| 变量 | 说明 |
|
||||
| -------------- | --------------------------------------------------------------- |
|
||||
| `HOST` | server 监听地址,例如 `127.0.0.1`。 |
|
||||
| `PORT` | server 监听端口,必须是 `1-65535` 的整数,例如 `7878`。 |
|
||||
| `DATABASE_URL` | Bright PostgreSQL 连接串;启动 server 和执行 migration 都需要。 |
|
||||
| 变量 | 说明 |
|
||||
| -------------------------------------------- | --------------------------------------------------------------- |
|
||||
| `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 临时下载链接签名凭据。 |
|
||||
|
||||
本地默认配置如下:
|
||||
|
||||
@@ -111,6 +113,19 @@ VITE_BRIGHT_WEBSOCKET_URL=ws://127.0.0.1:7878/ws/plugin
|
||||
|
||||
只有以 `VITE_` 开头的变量会暴露给扩展代码,数据库连接串和 binding 等服务端敏感配置不得使用该前缀。
|
||||
|
||||
### Chrome 扩展下载链接
|
||||
|
||||
`GET /api/downloads/chrome-extension` 返回当前 workspace 完整版本的临时 OSS 下载地址:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "0.6.1",
|
||||
"downloadUrl": "https://sinanpilot-bucket.oss-cn-hangzhou.aliyuncs.com/chrome-extension/0.6.1/trade-message-center-chrome-extension-0.6.1.zip?..."
|
||||
}
|
||||
```
|
||||
|
||||
版本由根 `package.json` 的 `version` 经 CI 注入为 `TMC_PACKAGE_VERSION`,接口和发布目录都不使用 `v` 前缀。OSS 签名参数由服务端签名器计算;访问密钥只保存在服务端运行时环境中。未配置 OSS 访问密钥时,接口明确返回 `503 extension_download_unavailable`,不影响本地其它 server 功能。
|
||||
|
||||
## Workspace 版本
|
||||
|
||||
根目录 `package.json` 的 `version` 是 workspace 发布版本的唯一编辑点。根目录的 `dev`、`dev:extension`、`build`、`typecheck`、`test` 和 `format:check` 会自动先执行版本镜像同步,再检查一致性;修改根版本后可以直接运行这些命令,不会因为子包镜像尚未更新而中断。
|
||||
@@ -212,7 +227,7 @@ OSS Bucket、Endpoint 和 Region 已直接写在 `.github/workflows/release_ci.y
|
||||
|
||||
- Secrets:`OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`。
|
||||
|
||||
上传账号只需要目标 Bucket 对应版本目录的写权限。Git tag 仍用于触发发布和定位源码,但不会作为扩展发布版本;版本目录和文件名使用根 `package.json` 的 `version`。现有同版本覆盖语义保持不变。
|
||||
上传账号只需要目标 Bucket 对应版本目录的写权限。Git tag 仍用于触发发布和定位源码,但不会作为扩展发布版本;版本目录和文件名使用根 `package.json` 的 `version`,均不添加 `v` 前缀。现有同版本覆盖语义保持不变。
|
||||
|
||||
## Server 数据库迁移
|
||||
|
||||
|
||||
@@ -79,8 +79,27 @@ test("publishes the SHA artifact under the quality job version path", async () =
|
||||
publish,
|
||||
/destination="oss:\/\/\$\{OSS_BUCKET\}\/chrome-extension\/\$\{PACKAGE_VERSION\}\/\$\{archive_name\}"/u,
|
||||
);
|
||||
assert.doesNotMatch(publish, /chrome-extension\/v\$\{PACKAGE_VERSION\}/u);
|
||||
assert.match(publish, /unzip -p "\$archive" manifest\.json/u);
|
||||
assert.match(publish, /typeof manifest\.version !== "string"/u);
|
||||
assert.match(publish, /\[\[ "\$manifest_version" != "\$PACKAGE_VERSION" \]\]/u);
|
||||
assert.doesNotMatch(publish, /GITHUB_REF_NAME|release_version/u);
|
||||
});
|
||||
|
||||
test("deploys the validated root version and OSS signer configuration to the server", async () => {
|
||||
const deploy = blockFromHeading(await readWorkflow(), " deploy:");
|
||||
|
||||
assert.match(
|
||||
deploy,
|
||||
/PACKAGE_VERSION:\s+\$\{\{ needs\.quality\.outputs\.extension_version \}\}/u,
|
||||
);
|
||||
assert.match(deploy, /write_env_value TMC_PACKAGE_VERSION "\$PACKAGE_VERSION"/u);
|
||||
for (const key of [
|
||||
"OSS_BUCKET",
|
||||
"OSS_ENDPOINT",
|
||||
"OSS_ACCESS_KEY_ID",
|
||||
"OSS_ACCESS_KEY_SECRET",
|
||||
]) {
|
||||
assert.match(deploy, new RegExp(`write_env_value ${key} `, "u"));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createMindAuthorizationReader,
|
||||
type MindAuthorizationDiagnosticsSink,
|
||||
} from "./mind-authorization.ts";
|
||||
import { installChromeExtensionDownloadRoute } from "./http/chrome-extension-download.ts";
|
||||
import { installHealthRoute } from "./http/health.ts";
|
||||
import { installOneTalkHarnessRoute } from "./http/harness.ts";
|
||||
import { installOneTalkReadRoutes } from "./http/onetalk.ts";
|
||||
@@ -86,6 +87,7 @@ export const createApp = (
|
||||
createOneTalkReadService(createOneTalkReadRepository(database.db));
|
||||
|
||||
installHealthRoute(app);
|
||||
installChromeExtensionDownloadRoute(app, config.chromeExtensionDownload);
|
||||
installOneTalkHarnessRoute(app);
|
||||
const oneTalkRegistry = installWebsocket(app, {
|
||||
authorization,
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
// 服务端环境配置解析与校验
|
||||
|
||||
import {
|
||||
validateChromeExtensionDownloadConfig,
|
||||
type ChromeExtensionDownloadConfig,
|
||||
} from "./oss/chrome-extension-download.ts";
|
||||
|
||||
export type ServerConfig = {
|
||||
host: string;
|
||||
port: number;
|
||||
databaseUrl: string;
|
||||
environment: ServerEnvironment;
|
||||
chromeExtensionDownload?: ChromeExtensionDownloadConfig;
|
||||
mindAuthorization?: MindAuthorizationConfig;
|
||||
};
|
||||
|
||||
@@ -107,6 +113,25 @@ const parseMindAuthorization = (
|
||||
};
|
||||
};
|
||||
|
||||
const parseChromeExtensionDownload = (
|
||||
environment: Record<string, string | undefined>,
|
||||
): ChromeExtensionDownloadConfig | undefined => {
|
||||
const accessKeyId = environment.OSS_ACCESS_KEY_ID?.trim();
|
||||
const accessKeySecret = environment.OSS_ACCESS_KEY_SECRET?.trim();
|
||||
|
||||
if (!accessKeyId && !accessKeySecret) return undefined;
|
||||
|
||||
return validateChromeExtensionDownloadConfig({
|
||||
packageVersion: readRequired(environment, "TMC_PACKAGE_VERSION"),
|
||||
signing: {
|
||||
bucket: readRequired(environment, "OSS_BUCKET"),
|
||||
endpoint: readRequired(environment, "OSS_ENDPOINT"),
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** 读取并校验服务端启动配置。 */
|
||||
export const loadConfig = (environment: Record<string, string | undefined>): ServerConfig => {
|
||||
const normalizedEnvironment = parseEnvironment(environment.NODE_ENV);
|
||||
@@ -115,6 +140,7 @@ export const loadConfig = (environment: Record<string, string | undefined>): Ser
|
||||
port: parsePort(readRequired(environment, "PORT")),
|
||||
databaseUrl: readRequired(environment, "DATABASE_URL"),
|
||||
environment: normalizedEnvironment,
|
||||
chromeExtensionDownload: parseChromeExtensionDownload(environment),
|
||||
mindAuthorization: parseMindAuthorization(
|
||||
environment,
|
||||
normalizedEnvironment === "development" || environment.NODE_ENV?.trim() === "test",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// 提供 Chrome 扩展下载链接 HTTP 接口
|
||||
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
import {
|
||||
createChromeExtensionDownloadUrl,
|
||||
type ChromeExtensionDownloadConfig,
|
||||
} from "../oss/chrome-extension-download.ts";
|
||||
|
||||
export const CHROME_EXTENSION_DOWNLOAD_ROUTE = "/api/downloads/chrome-extension";
|
||||
|
||||
export type ChromeExtensionDownloadResponse = {
|
||||
version: string;
|
||||
downloadUrl: string;
|
||||
};
|
||||
|
||||
export type ChromeExtensionDownloadUnavailableResponse = {
|
||||
error: {
|
||||
code: "extension_download_unavailable";
|
||||
};
|
||||
};
|
||||
|
||||
/** 注册返回当前 Chrome 扩展临时下载链接的接口。 */
|
||||
export const installChromeExtensionDownloadRoute = (
|
||||
app: FastifyInstance,
|
||||
config: ChromeExtensionDownloadConfig | undefined,
|
||||
): void => {
|
||||
app.get(CHROME_EXTENSION_DOWNLOAD_ROUTE, async (_request, reply) => {
|
||||
if (!config) {
|
||||
return reply.code(503).send({
|
||||
error: { code: "extension_download_unavailable" },
|
||||
} satisfies ChromeExtensionDownloadUnavailableResponse);
|
||||
}
|
||||
|
||||
return {
|
||||
version: config.packageVersion,
|
||||
downloadUrl: createChromeExtensionDownloadUrl(config),
|
||||
} satisfies ChromeExtensionDownloadResponse;
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
// 生成 Chrome 扩展发布包下载地址
|
||||
|
||||
import {
|
||||
createOssV1SignedUrl,
|
||||
validateOssV1SigningConfig,
|
||||
type OssV1SignedUrlOptions,
|
||||
type OssV1SigningConfig,
|
||||
} from "./v1-signed-url.ts";
|
||||
|
||||
export type ChromeExtensionDownloadConfig = {
|
||||
packageVersion: string;
|
||||
signing: OssV1SigningConfig;
|
||||
};
|
||||
|
||||
const PACKAGE_VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
||||
const MAX_PACKAGE_VERSION_COMPONENT = 65_535;
|
||||
|
||||
const fail = (message: string): never => {
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const parsePackageVersion = (value: unknown): string => {
|
||||
if (typeof value !== "string" || !PACKAGE_VERSION_PATTERN.test(value)) {
|
||||
return fail("Invalid Chrome extension version: expected three numeric components");
|
||||
}
|
||||
|
||||
const components = value.split(".").map(Number);
|
||||
if (components.some((component) => component > MAX_PACKAGE_VERSION_COMPONENT)) {
|
||||
return fail(
|
||||
`Invalid Chrome extension version: components must be between 0 and ${MAX_PACKAGE_VERSION_COMPONENT}`,
|
||||
);
|
||||
}
|
||||
if (components.every((component) => component === 0)) {
|
||||
return fail("Invalid Chrome extension version: must not be 0.0.0");
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
/** 校验扩展发布包下载所需的版本与 OSS 签名配置。 */
|
||||
export const validateChromeExtensionDownloadConfig = (
|
||||
config: unknown,
|
||||
): ChromeExtensionDownloadConfig => {
|
||||
if (!isRecord(config)) return fail("Invalid Chrome extension download config");
|
||||
|
||||
return {
|
||||
packageVersion: parsePackageVersion(config.packageVersion),
|
||||
signing: validateOssV1SigningConfig(config.signing),
|
||||
};
|
||||
};
|
||||
|
||||
const archiveNameFor = (packageVersion: string): string =>
|
||||
`trade-message-center-chrome-extension-${packageVersion}.zip`;
|
||||
|
||||
const objectUrlFor = (config: ChromeExtensionDownloadConfig): string => {
|
||||
const endpoint = new URL(config.signing.endpoint);
|
||||
const objectKey = `chrome-extension/${config.packageVersion}/${archiveNameFor(config.packageVersion)}`;
|
||||
|
||||
return `https://${config.signing.bucket}.${endpoint.host}/${objectKey}`;
|
||||
};
|
||||
|
||||
/** 根据 workspace 版本生成扩展发布包的临时下载地址。 */
|
||||
export const createChromeExtensionDownloadUrl = (
|
||||
config: ChromeExtensionDownloadConfig,
|
||||
options?: OssV1SignedUrlOptions,
|
||||
): string => {
|
||||
const validConfig = validateChromeExtensionDownloadConfig(config);
|
||||
return createOssV1SignedUrl(objectUrlFor(validConfig), validConfig.signing, options);
|
||||
};
|
||||
@@ -92,7 +92,8 @@ const parseEndpoint = (endpoint: string): OssEndpoint => {
|
||||
return { hostname: url.hostname, port: url.port };
|
||||
};
|
||||
|
||||
const validateConfig = (config: unknown): OssV1SigningConfig => {
|
||||
/** 校验 OSS V1 签名所需的运行时配置。 */
|
||||
export const validateOssV1SigningConfig = (config: unknown): OssV1SigningConfig => {
|
||||
const bucket = requireConfigString(config, "bucket");
|
||||
const endpoint = requireConfigString(config, "endpoint");
|
||||
const accessKeyId = requireConfigString(config, "accessKeyId");
|
||||
@@ -206,7 +207,7 @@ export const createOssV1SignedUrl = (
|
||||
config: OssV1SigningConfig,
|
||||
options?: OssV1SignedUrlOptions,
|
||||
): string => {
|
||||
const validConfig = validateConfig(config);
|
||||
const validConfig = validateOssV1SigningConfig(config);
|
||||
const endpoint = parseEndpoint(validConfig.endpoint);
|
||||
const parsedObjectUrl = parseObjectUrl(objectUrl, validConfig.bucket, endpoint);
|
||||
const objectKey = decodeObjectKey(parsedObjectUrl.pathname);
|
||||
|
||||
@@ -12,6 +12,15 @@ const testConfig = {
|
||||
port: 3000,
|
||||
databaseUrl: "postgres://test:test@localhost:5432/test",
|
||||
environment: "non_development" as const,
|
||||
chromeExtensionDownload: {
|
||||
packageVersion: "1.2.3",
|
||||
signing: {
|
||||
bucket: "example-bucket",
|
||||
endpoint: "https://oss.example.com",
|
||||
accessKeyId: "test-access-key-id",
|
||||
accessKeySecret: "test-only-secret-value",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const createDatabaseStub = (): {
|
||||
@@ -42,12 +51,30 @@ test("requires strict production Mind authorization configuration", () => {
|
||||
HOST: "127.0.0.1",
|
||||
PORT: "3000",
|
||||
DATABASE_URL: "postgres://test:test@localhost:5432/test",
|
||||
TMC_PACKAGE_VERSION: "1.2.3",
|
||||
OSS_BUCKET: "example-bucket",
|
||||
OSS_ENDPOINT: "https://oss.example.com",
|
||||
OSS_ACCESS_KEY_ID: "test-access-key-id",
|
||||
OSS_ACCESS_KEY_SECRET: "test-only-secret-value",
|
||||
NODE_ENV: "production",
|
||||
MIND_AUTH_BASE_URL: "https://mind.example.com",
|
||||
MIND_PAGE_ORIGIN: "https://mind.example.com",
|
||||
ONETALK_PLUGIN_ORIGINS: "chrome-extension://extension-id",
|
||||
};
|
||||
assert.equal(loadConfig(base).mindAuthorization?.timeoutMs, 3000);
|
||||
assert.equal(loadConfig(base).chromeExtensionDownload?.packageVersion, "1.2.3");
|
||||
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, ONETALK_PLUGIN_ORIGINS: "chrome-extension://" }),
|
||||
/Invalid ONETALK_PLUGIN_ORIGINS/,
|
||||
@@ -67,6 +94,11 @@ test("allows loopback HTTP Mind authorization only in development and test", ()
|
||||
HOST: "127.0.0.1",
|
||||
PORT: "3000",
|
||||
DATABASE_URL: "postgres://test:test@localhost:5432/test",
|
||||
TMC_PACKAGE_VERSION: "1.2.3",
|
||||
OSS_BUCKET: "example-bucket",
|
||||
OSS_ENDPOINT: "https://oss.example.com",
|
||||
OSS_ACCESS_KEY_ID: "test-access-key-id",
|
||||
OSS_ACCESS_KEY_SECRET: "test-only-secret-value",
|
||||
MIND_AUTH_BASE_URL: "http://127.0.0.1:8787",
|
||||
MIND_PAGE_ORIGIN: "http://127.0.0.1:3000",
|
||||
ONETALK_PLUGIN_ORIGINS: "chrome-extension://extension-id",
|
||||
@@ -107,6 +139,11 @@ test("normalizes only NODE_ENV=development as the development environment", () =
|
||||
HOST: "127.0.0.1",
|
||||
PORT: "3000",
|
||||
DATABASE_URL: "postgres://test:test@localhost:5432/test",
|
||||
TMC_PACKAGE_VERSION: "1.2.3",
|
||||
OSS_BUCKET: "example-bucket",
|
||||
OSS_ENDPOINT: "https://oss.example.com",
|
||||
OSS_ACCESS_KEY_ID: "test-access-key-id",
|
||||
OSS_ACCESS_KEY_SECRET: "test-only-secret-value",
|
||||
MIND_AUTH_BASE_URL: "https://mind.example.com",
|
||||
MIND_PAGE_ORIGIN: "https://mind.example.com",
|
||||
ONETALK_PLUGIN_ORIGINS: "chrome-extension://extension-id",
|
||||
@@ -152,6 +189,11 @@ test("does not read the removed development authorization fixture fields", () =>
|
||||
HOST: "127.0.0.1",
|
||||
PORT: "3000",
|
||||
DATABASE_URL: "postgres://test:test@localhost:5432/test",
|
||||
TMC_PACKAGE_VERSION: "1.2.3",
|
||||
OSS_BUCKET: "example-bucket",
|
||||
OSS_ENDPOINT: "https://oss.example.com",
|
||||
OSS_ACCESS_KEY_ID: "test-access-key-id",
|
||||
OSS_ACCESS_KEY_SECRET: "test-only-secret-value",
|
||||
NODE_ENV: "development",
|
||||
MIND_AUTH_BASE_URL: "http://127.0.0.1:8787",
|
||||
MIND_PAGE_ORIGIN: "http://127.0.0.1:3000",
|
||||
@@ -184,6 +226,55 @@ test("serves health and closes injected database resources", async () => {
|
||||
assert.equal(database.getCloseCount(), 1);
|
||||
});
|
||||
|
||||
test("returns the configured whole-version Chrome extension download URL", async () => {
|
||||
const database = createDatabaseStub();
|
||||
const app = createApp(testConfig, { database: database.connection });
|
||||
|
||||
try {
|
||||
await app.ready();
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/downloads/chrome-extension",
|
||||
});
|
||||
const body = response.json() as { version: string; downloadUrl: string };
|
||||
const downloadUrl = new URL(body.downloadUrl);
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(body.version, "1.2.3");
|
||||
assert.equal(
|
||||
downloadUrl.pathname,
|
||||
"/chrome-extension/1.2.3/trade-message-center-chrome-extension-1.2.3.zip",
|
||||
);
|
||||
assert.equal(downloadUrl.searchParams.get("OSSAccessKeyId"), "test-access-key-id");
|
||||
assert.equal(body.downloadUrl.includes("test-only-secret-value"), false);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("returns a stable error when download signing is not configured", async () => {
|
||||
const database = createDatabaseStub();
|
||||
const app = createApp(
|
||||
{ ...testConfig, chromeExtensionDownload: undefined },
|
||||
{ database: database.connection },
|
||||
);
|
||||
|
||||
try {
|
||||
await app.ready();
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/downloads/chrome-extension",
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 503);
|
||||
assert.deepEqual(response.json(), {
|
||||
error: { code: "extension_download_unavailable" },
|
||||
});
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("registers the websocket server without crashing the app", async () => {
|
||||
const database = createDatabaseStub();
|
||||
const app = createApp(testConfig, { database: database.connection });
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// 验证 Chrome 扩展发布包下载地址
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { createHmac } from "node:crypto";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createChromeExtensionDownloadUrl,
|
||||
type ChromeExtensionDownloadConfig,
|
||||
} from "../src/oss/chrome-extension-download.ts";
|
||||
|
||||
const FIXED_NOW_MS = 1_700_000_000_000;
|
||||
const FIXED_EXPIRES = 1_700_001_800;
|
||||
const TEST_CONFIG: ChromeExtensionDownloadConfig = {
|
||||
packageVersion: "1.2.3",
|
||||
signing: {
|
||||
bucket: "example-bucket",
|
||||
endpoint: "https://oss.example.com",
|
||||
accessKeyId: "test-access-key-id",
|
||||
accessKeySecret: "test-only-secret-value",
|
||||
},
|
||||
};
|
||||
|
||||
test("uses the whole package version without a v prefix in the OSS object path", () => {
|
||||
const downloadUrl = createChromeExtensionDownloadUrl(TEST_CONFIG, { now: () => FIXED_NOW_MS });
|
||||
const parsed = new URL(downloadUrl);
|
||||
const objectKey = "chrome-extension/1.2.3/trade-message-center-chrome-extension-1.2.3.zip";
|
||||
const expectedSignature = createHmac("sha1", TEST_CONFIG.signing.accessKeySecret)
|
||||
.update(`GET\n\n\n${FIXED_EXPIRES}\n/${TEST_CONFIG.signing.bucket}/${objectKey}`, "utf8")
|
||||
.digest("base64");
|
||||
|
||||
assert.equal(parsed.origin, "https://example-bucket.oss.example.com");
|
||||
assert.equal(parsed.pathname, `/${objectKey}`);
|
||||
assert.deepEqual(Array.from(parsed.searchParams.entries()), [
|
||||
["Expires", FIXED_EXPIRES.toString()],
|
||||
["OSSAccessKeyId", TEST_CONFIG.signing.accessKeyId],
|
||||
["Signature", expectedSignature],
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects versions outside the shared package-version contract", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
createChromeExtensionDownloadUrl({
|
||||
...TEST_CONFIG,
|
||||
packageVersion: "v1.2.3",
|
||||
}),
|
||||
/Invalid Chrome extension version/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user