mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-21 07:45:52 +00:00
feat: 新增 cloud-artifacts 包(Cloudflare Worker HTML artifact 托管)
POST /upload 鉴权上传 HTML 到 R2 返回 hash URL,GET /<7d|30d>/<id>.html 由 Worker 代理读取并直出 text/html。R2 lifecycle rule 自动 7/30 天删除。 独立服务,不被主 CLI 引用(类似 packages/remote-control-server/ 定位)。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
1
packages/cloud-artifacts/.dev.vars.example
Normal file
1
packages/cloud-artifacts/.dev.vars.example
Normal file
@@ -0,0 +1 @@
|
||||
TOKEN=replace-with-your-bearer-token
|
||||
171
packages/cloud-artifacts/.gitignore
vendored
Normal file
171
packages/cloud-artifacts/.gitignore
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
\*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
\*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
\*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
\*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
.cache/
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.\*
|
||||
|
||||
# wrangler project
|
||||
|
||||
.dev.vars*
|
||||
!.dev.vars.example
|
||||
.env*
|
||||
!.env.example
|
||||
.wrangler/
|
||||
|
||||
# wrangler types 生成物(每次 wrangler types / dev / deploy 后会刷新,含 Cloudflare 运行时类型,体积大、会触发 biome lint)
|
||||
worker-configuration.d.ts
|
||||
|
||||
150
packages/cloud-artifacts/README.md
Normal file
150
packages/cloud-artifacts/README.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# cloud-artifacts
|
||||
|
||||
独立的 Cloudflare Worker + R2 服务,用于托管 HTML artifact。
|
||||
|
||||
服务端(CLI / RCS 后台)通过单一 bearer token 上传 HTML,得到一个公开可访问的 CDN URL。**POST 上传和 GET 访问都走 Worker**,URL 形如 `https://<worker-domain>/<ttl-prefix>/<id>.html`,文件到期由 R2 lifecycle rule 自动删除。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
客户端 --POST /upload--> Worker (鉴权 + 校验 + R2 put) --返回 {id, url, expiresAt}
|
||||
客户端 --GET html-------> Worker (R2 get → text/html) --直出 html,带 Cache-Control: max-age=86400
|
||||
↑
|
||||
R2 lifecycle rule: 7d/ 删 7 天,30d/ 删 30 天
|
||||
```
|
||||
|
||||
- Worker 处理 `POST /upload` 和 `GET /<prefix>/<id>.html`
|
||||
- R2 key 形如 `7d/<id>.html` 或 `30d/<id>.html`
|
||||
- URL 形如 `https://<worker-domain>/7d/<id>.html`,hash 本身是秘密(21 字符 nanoId,126 bit 熵)
|
||||
|
||||
## 部署
|
||||
|
||||
前置:本机已 `npx wrangler login` 登录目标 Cloudflare 账号,且账号下有一个用于 Worker custom domain 的域名(zone)。
|
||||
|
||||
```bash
|
||||
cd packages/cloud-artifacts
|
||||
bun install # 在 monorepo 根执行也行(workspace 自动识别)
|
||||
|
||||
cp .dev.vars.example .dev.vars # 填本地 dev 用的 TOKEN(仅 wrangler dev 读)
|
||||
bun run setup # 创建 bucket + 加 lifecycle rule + 设生产 TOKEN secret
|
||||
|
||||
# 绑定 Worker custom domain(在 Cloudflare dashboard):
|
||||
# Workers & Pages > cloud-artifacts > Settings > Domains & Routes > Add > Custom Domain
|
||||
# 填入你的 domain(如 artifacts.example.com)
|
||||
|
||||
# 改 wrangler.toml 中 [vars] PUBLIC_URL 为上一步的 domain(如 https://artifacts.example.com)
|
||||
|
||||
bun run deploy
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `POST /upload`
|
||||
|
||||
| Header / Query | 必填 | 说明 |
|
||||
|----------------|------|------|
|
||||
| `Authorization: Bearer <TOKEN>` | 是 | 与 Worker secret `TOKEN` 完全相等 |
|
||||
| `Content-Type: text/html` | 是 | 不接受其他类型 |
|
||||
| `?ttl=7\|30` | 否 | 默认 7,**只允许 7 或 30**(与 R2 lifecycle prefix 对应) |
|
||||
| `?hash=<custom-id>` | 否 | 自定义 ID,校验 `^[A-Za-z0-9_-]{1,128}$`;指定时覆盖同 ID 旧版本 |
|
||||
| body | 是 | 原始 HTML(`--data-binary @file.html`),≤10MB |
|
||||
|
||||
成功 200:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "V1StGXR8_Z5jdHi6B-myT",
|
||||
"url": "https://<worker-domain>/7d/V1StGXR8_Z5jdHi6B-myT.html",
|
||||
"expiresAt": "2026-06-27T10:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
错误(统一 `{ "error": "<code>" }`):
|
||||
|
||||
| 状态码 | error code | 触发条件 |
|
||||
|--------|------------|----------|
|
||||
| 400 | `invalid_ttl` | `ttl` 非 7 或 30 |
|
||||
| 400 | `invalid_hash` | `hash` 不匹配 `^[A-Za-z0-9_-]{1,128}$` |
|
||||
| 401 | `unauthorized` | 缺 Authorization / token 不匹配 |
|
||||
| 404 | `not_found` | 非 `/upload` 路径或 GET 路径不匹配 `/<7d\|30d>/<id>.html` |
|
||||
| 413 | `payload_too_large` | body > 10MB |
|
||||
| 415 | `unsupported_media_type` | Content-Type 非 `text/html` |
|
||||
|
||||
### `GET /<ttl-prefix>/<id>.html`
|
||||
|
||||
由 Worker 处理:解析路径 → R2 get → 返回 `text/html; charset=utf-8` + `Cache-Control: public, max-age=86400`。任何人拿到 URL 都可访问,hash 即秘密。
|
||||
|
||||
`ttl-prefix` 只能是 `7d` 或 `30d`(其他路径返回 404)。
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
# 上传(默认随机 ID + 7 天)
|
||||
echo '<h1>hello</h1>' > /tmp/t.html
|
||||
curl -X POST "https://<worker-domain>/upload" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: text/html" \
|
||||
--data-binary @/tmp/t.html
|
||||
# -> {"id":"V1StGXR8_Z5jdHi6B-myT","url":"https://<worker-domain>/7d/V1StGXR8_Z5jdHi6B-myT.html","expiresAt":"..."}
|
||||
|
||||
# 自定义 hash + 30 天(再次上传同 hash 覆盖)
|
||||
curl -X POST "https://<worker-domain>/upload?ttl=30&hash=my-report" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: text/html" \
|
||||
--data-binary @/tmp/report.html
|
||||
|
||||
# 访问(公开 URL,走 Worker → R2)
|
||||
curl "https://<worker-domain>/7d/V1StGXR8_Z5jdHi6B-myT.html"
|
||||
```
|
||||
|
||||
## 覆盖语义
|
||||
|
||||
指定 `?hash=` 时:
|
||||
|
||||
1. 校验 hash 字符集(`^[A-Za-z0-9_-]{1,128}$`)
|
||||
2. 删除 `7d/<hash>.html` 和 `30d/<hash>.html` 两个 key(R2 delete 不存在的 key 不报错,零成本)
|
||||
3. 按 `?ttl=` 写入新 key
|
||||
4. 返回新的 `expiresAt`
|
||||
|
||||
不指定 `?hash=` 时:用 `nanoid(21)` 随机 ID,几乎不可能碰撞,不做碰撞检查。
|
||||
|
||||
## TTL 落地
|
||||
|
||||
R2 不支持 per-object TTL,本服务用 prefix + R2 lifecycle rule 模拟:
|
||||
|
||||
- bucket 配两条 rule:prefix `7d/` 删 7 天前对象、prefix `30d/` 删 30 天前对象
|
||||
- 由 `scripts/setup.sh` 调 `wrangler r2 bucket lifecycle add` 自动配置
|
||||
- Worker 完全不参与过期处理,零额外代码
|
||||
- 因此 `?ttl=` 只能取 `7` 或 `30`,对应这两个 prefix(其他值会写到无 lifecycle 的 prefix → 永久存储,故拒绝)
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
cp .dev.vars.example .dev.vars
|
||||
# 编辑 .dev.vars 填 TOKEN
|
||||
|
||||
bun run dev # wrangler dev,启动本地 Miniflare + 本地 R2 模拟
|
||||
curl -X POST "http://localhost:8787/upload" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: text/html" \
|
||||
--data-binary @/tmp/t.html
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
`scripts/test.sh` 覆盖 7 个错误用例 + 3 个成功用例 + R2 写入验证:
|
||||
|
||||
```bash
|
||||
WORKER_URL=https://<worker-domain> \
|
||||
TOKEN=<your-token> \
|
||||
bash scripts/test.sh
|
||||
```
|
||||
|
||||
## 依赖
|
||||
|
||||
- `wrangler` ^4 — Cloudflare Workers CLI
|
||||
- `nanoid` ^5 — ID 生成(纯 ESM,Worker 兼容)
|
||||
|
||||
## 不被主 CLI 引用
|
||||
|
||||
这是独立 Cloudflare Worker 服务,类似 `packages/remote-control-server/` 的定位。Monorepo 根 `package.json` 的 `workspaces: ["packages/*", ...]` 自动识别本包,但主 CLI 不会 import 它。
|
||||
19
packages/cloud-artifacts/package.json
Normal file
19
packages/cloud-artifacts/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "cloud-artifacts",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Cloudflare Worker + R2 HTML artifact host (POST /upload → hash URL)",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"setup": "bash scripts/setup.sh",
|
||||
"cf-typegen": "wrangler types"
|
||||
},
|
||||
"dependencies": {
|
||||
"nanoid": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.0",
|
||||
"wrangler": "^4.0.0"
|
||||
}
|
||||
}
|
||||
30
packages/cloud-artifacts/scripts/setup.sh
Executable file
30
packages/cloud-artifacts/scripts/setup.sh
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BUCKET="${BUCKET:-cloud-artifacts}"
|
||||
|
||||
echo "==> Creating R2 bucket: $BUCKET"
|
||||
npx wrangler r2 bucket create "$BUCKET" || echo "(already exists or creation deferred)"
|
||||
|
||||
echo "==> Adding lifecycle rule: prefix '7d/' -> expire after 7 days"
|
||||
npx wrangler r2 bucket lifecycle add "$BUCKET" delete-7d "7d/" --expire-days 7 --force
|
||||
|
||||
echo "==> Adding lifecycle rule: prefix '30d/' -> expire after 30 days"
|
||||
npx wrangler r2 bucket lifecycle add "$BUCKET" delete-30d "30d/" --expire-days 30 --force
|
||||
|
||||
echo "==> Setting secret TOKEN (paste value, then Enter)"
|
||||
npx wrangler secret put TOKEN
|
||||
|
||||
cat <<'NEXT'
|
||||
|
||||
==> Done. Remaining manual steps:
|
||||
|
||||
1. Bind a custom domain to the Worker (POST + GET 都走 Worker,单一域名):
|
||||
Dashboard: Workers & Pages > cloud-artifacts > Settings > Domains & Routes > Add > Custom Domain
|
||||
填入你的 domain(如 artifacts.example.com),Cloudflare 会自动加 DNS 记录和 SSL。
|
||||
|
||||
2. Update wrangler.toml [vars] PUBLIC_URL 为上一步的 domain(带 https://,如 https://artifacts.example.com)。
|
||||
|
||||
3. Deploy:
|
||||
bun run deploy
|
||||
NEXT
|
||||
144
packages/cloud-artifacts/scripts/test.sh
Executable file
144
packages/cloud-artifacts/scripts/test.sh
Executable file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
# cloud-artifacts 端到端测试脚本
|
||||
# 用法:
|
||||
# WORKER_URL=https://cloud-artifacts.claude-code-best.workers.dev \
|
||||
# TOKEN=cloud-artifacts \
|
||||
# bash scripts/test.sh
|
||||
#
|
||||
# 如本机连不上 workers.dev,可通过代理:
|
||||
# HTTPS_PROXY=http://127.0.0.1:7890 bash scripts/test.sh ...
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
WORKER_URL="${WORKER_URL:-https://cloud-artifacts.claude-code-best.workers.dev}"
|
||||
TOKEN="${TOKEN:-cloud-artifacts}"
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# 颜色
|
||||
G=$'\033[32m'; R=$'\033[31m'; Y=$'\033[33m'; D=$'\033[0m'
|
||||
|
||||
# 准备测试 html
|
||||
echo '<!doctype html><title>t</title><h1>hello v1</h1>' > "$TMP/v1.html"
|
||||
echo '<!doctype html><title>t</title><h1>hello v2 (overwritten)</h1>' > "$TMP/v2.html"
|
||||
|
||||
# 11MB 的 html(用于 413 测试)
|
||||
yes '<p>x</p>' | head -c 11000000 > "$TMP/big.html"
|
||||
|
||||
pass=0; fail=0
|
||||
expect() {
|
||||
local label="$1" want_code="$2" resp="$3" code="$4" body="$5"
|
||||
if [[ "$code" == "$want_code" ]]; then
|
||||
printf "${G}✓ %s -> HTTP %s${D}\n" "$label" "$code"
|
||||
[[ -n "$resp" ]] && printf " body: %s\n" "$body"
|
||||
pass=$((pass+1))
|
||||
else
|
||||
printf "${R}✗ %s -> HTTP %s (expected %s)${D}\n" "$label" "$code" "$want_code"
|
||||
printf " body: %s\n" "$body"
|
||||
fail=$((fail+1))
|
||||
fi
|
||||
}
|
||||
|
||||
call() {
|
||||
local label="$1" want="$2"
|
||||
shift 2
|
||||
curl -sS -o "$TMP/resp" -w "%{http_code}" "$@" > "$TMP/code"
|
||||
expect "$label" "$want" "" "$(cat "$TMP/code")" "$(cat "$TMP/resp")"
|
||||
}
|
||||
|
||||
echo "===== 错误用例 ====="
|
||||
|
||||
# 1. 401 未授权
|
||||
call "no token" 401 \
|
||||
-X POST "$WORKER_URL/upload" \
|
||||
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
|
||||
|
||||
# 2. 401 token 错
|
||||
call "wrong token" 401 \
|
||||
-X POST "$WORKER_URL/upload" \
|
||||
-H "Authorization: Bearer wrong" \
|
||||
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
|
||||
|
||||
# 3. 415 错误 MIME
|
||||
call "wrong content-type" 415 \
|
||||
-X POST "$WORKER_URL/upload" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" --data-binary '{"x":1}'
|
||||
|
||||
# 4. 400 invalid_ttl
|
||||
call "ttl=999" 400 \
|
||||
-X POST "$WORKER_URL/upload?ttl=999" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
|
||||
|
||||
# 5. 400 invalid_ttl (负数)
|
||||
call "ttl=0" 400 \
|
||||
-X POST "$WORKER_URL/upload?ttl=0" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
|
||||
|
||||
# 6. 400 invalid_hash
|
||||
call "hash=bad/slash" 400 \
|
||||
-X POST "$WORKER_URL/upload?hash=bad/slash" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
|
||||
|
||||
# 7. 413 payload_too_large (11MB > 10MB)
|
||||
call "11MB body" 413 \
|
||||
-X POST "$WORKER_URL/upload" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: text/html" --data-binary "@$TMP/big.html"
|
||||
|
||||
# 8. 404 not_found (错路径)
|
||||
call "wrong path" 404 \
|
||||
-X POST "$WORKER_URL/notupload" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
|
||||
|
||||
echo
|
||||
echo "===== 成功用例 ====="
|
||||
|
||||
# 9. 200 随机 ID + 7 天(默认)
|
||||
echo "--- 默认上传(随机 ID + 7 天)---"
|
||||
curl -sS -o "$TMP/resp" -w "HTTP %{http_code}\n" \
|
||||
-X POST "$WORKER_URL/upload" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
|
||||
cat "$TMP/resp"; echo
|
||||
RANDOM_ID=$(python3 -c "import json,sys;print(json.load(open('$TMP/resp'))['id'])" 2>/dev/null || echo "")
|
||||
[[ -n "$RANDOM_ID" ]] && printf "${G}随机 ID: %s${D}\n" "$RANDOM_ID"
|
||||
|
||||
# 10. 200 自定义 hash + 30 天
|
||||
echo "--- 自定义 hash + 30 天 ---"
|
||||
curl -sS -o "$TMP/resp" -w "HTTP %{http_code}\n" \
|
||||
-X POST "$WORKER_URL/upload?ttl=30&hash=test-artifact-v1" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
|
||||
cat "$TMP/resp"; echo
|
||||
|
||||
# 11. 覆盖(同 hash)
|
||||
echo "--- 覆盖:同 hash 上传 v2 ---"
|
||||
curl -sS -o "$TMP/resp" -w "HTTP %{http_code}\n" \
|
||||
-X POST "$WORKER_URL/upload?ttl=30&hash=test-artifact-v1" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: text/html" --data-binary "@$TMP/v2.html"
|
||||
cat "$TMP/resp"; echo
|
||||
|
||||
echo
|
||||
echo "===== R2 写入验证(不走 CDN,走 Cloudflare API) ====="
|
||||
|
||||
# 用 wrangler r2 object get 验证文件实际写入了 R2
|
||||
if [[ -n "$RANDOM_ID" ]]; then
|
||||
echo "--- 验证随机 ID 文件存在: 7d/$RANDOM_ID.html ---"
|
||||
npx wrangler r2 object get "cloud-artifacts/7d/$RANDOM_ID.html" --remote --file "$TMP/got.html" 2>&1 | tail -5
|
||||
echo "下载内容:" ; cat "$TMP/got.html" 2>/dev/null
|
||||
fi
|
||||
|
||||
echo "--- 验证覆盖后 test-artifact-v1 是 v2 内容 ---"
|
||||
npx wrangler r2 object get "cloud-artifacts/30d/test-artifact-v1.html" --remote --file "$TMP/got2.html" 2>&1 | tail -5
|
||||
echo "下载内容:" ; cat "$TMP/got2.html" 2>/dev/null
|
||||
|
||||
echo
|
||||
echo "===== 汇总 ====="
|
||||
printf "${G}pass=%d${D} ${R}fail=%d${D}\n" "$pass" "$fail"
|
||||
[[ "$fail" -gt 0 ]] && exit 1 || exit 0
|
||||
119
packages/cloud-artifacts/src/index.ts
Normal file
119
packages/cloud-artifacts/src/index.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
// TOKEN 通过 `wrangler secret put TOKEN` 注入,wrangler types 不为 secret 生成类型
|
||||
// 所以这里显式扩展全局 Env(与 worker-configuration.d.ts 合并)
|
||||
declare global {
|
||||
interface Env {
|
||||
TOKEN: string
|
||||
}
|
||||
}
|
||||
|
||||
const HASH_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
|
||||
const TTL_PREFIXES = ['7d', '30d']
|
||||
const ALLOWED_TTLS = [7, 30]
|
||||
const HTML_CONTENT_TYPE = 'text/html; charset=utf-8'
|
||||
// GET /<prefix>/<id>.html —— prefix 与 lifecycle rule 对应,限制只能是 7d 或 30d
|
||||
const GET_PATH_PATTERN = /^\/(7d|30d)\/([A-Za-z0-9_-]{1,128})\.html$/
|
||||
|
||||
export default {
|
||||
async fetch(req: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(req.url)
|
||||
|
||||
if (req.method === 'GET') {
|
||||
return handleGet(url, env)
|
||||
}
|
||||
if (url.pathname === '/upload' && req.method === 'POST') {
|
||||
return handleUpload(req, env, url)
|
||||
}
|
||||
return json({ error: 'not_found' }, 404)
|
||||
},
|
||||
} satisfies ExportedHandler<Env>
|
||||
|
||||
// GET /7d/<id>.html 或 /30d/<id>.html —— 从 R2 读,返回 text/html
|
||||
async function handleGet(url: URL, env: Env): Promise<Response> {
|
||||
const match = GET_PATH_PATTERN.exec(url.pathname)
|
||||
if (!match) {
|
||||
return json({ error: 'not_found' }, 404)
|
||||
}
|
||||
const [, prefix, id] = match
|
||||
const obj = await env.BUCKET.get(`${prefix}/${id}.html`)
|
||||
if (obj === null) {
|
||||
return new Response('Not Found', { status: 404 })
|
||||
}
|
||||
const headers = new Headers()
|
||||
obj.writeHttpMetadata(headers)
|
||||
headers.set('content-type', HTML_CONTENT_TYPE)
|
||||
headers.set('cache-control', 'public, max-age=86400')
|
||||
return new Response(obj.body, { headers, status: 200 })
|
||||
}
|
||||
|
||||
async function handleUpload(
|
||||
req: Request,
|
||||
env: Env,
|
||||
url: URL,
|
||||
): Promise<Response> {
|
||||
const auth = req.headers.get('authorization') ?? ''
|
||||
const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''
|
||||
if (!env.TOKEN || !token || token !== env.TOKEN) {
|
||||
return json({ error: 'unauthorized' }, 401)
|
||||
}
|
||||
|
||||
const contentType = (req.headers.get('content-type') ?? '').toLowerCase()
|
||||
if (!contentType.startsWith('text/html')) {
|
||||
return json({ error: 'unsupported_media_type' }, 415)
|
||||
}
|
||||
|
||||
const maxBytes = Number.parseInt(env.MAX_BYTES, 10) || 10 * 1024 * 1024
|
||||
const declaredLength = Number.parseInt(
|
||||
req.headers.get('content-length') ?? '',
|
||||
10,
|
||||
)
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
||||
return json({ error: 'payload_too_large' }, 413)
|
||||
}
|
||||
|
||||
const defaultTtl = Number.parseInt(env.DEFAULT_TTL_DAYS, 10) || 7
|
||||
const ttlParam = url.searchParams.get('ttl')
|
||||
const ttl = ttlParam === null ? defaultTtl : Number.parseInt(ttlParam, 10)
|
||||
if (!Number.isFinite(ttl) || !ALLOWED_TTLS.includes(ttl)) {
|
||||
return json({ error: 'invalid_ttl' }, 400)
|
||||
}
|
||||
|
||||
const hashParam = url.searchParams.get('hash')
|
||||
let id: string
|
||||
if (hashParam !== null) {
|
||||
if (!HASH_PATTERN.test(hashParam)) {
|
||||
return json({ error: 'invalid_hash' }, 400)
|
||||
}
|
||||
id = hashParam
|
||||
// 覆盖:先删所有 ttl prefix 下可能的旧 key(R2 delete 不存在的 key 不报错)
|
||||
await Promise.all(
|
||||
TTL_PREFIXES.map(p => env.BUCKET.delete(`${p}/${id}.html`)),
|
||||
)
|
||||
} else {
|
||||
id = nanoid(21)
|
||||
}
|
||||
|
||||
const body = await req.arrayBuffer()
|
||||
if (body.byteLength > maxBytes) {
|
||||
return json({ error: 'payload_too_large' }, 413)
|
||||
}
|
||||
|
||||
const key = `${ttl}d/${id}.html`
|
||||
await env.BUCKET.put(key, body, {
|
||||
httpMetadata: { contentType: HTML_CONTENT_TYPE },
|
||||
})
|
||||
|
||||
const expiresAt = new Date(Date.now() + ttl * 24 * 60 * 60 * 1000)
|
||||
return json(
|
||||
{ id, url: `${env.PUBLIC_URL}/${key}`, expiresAt: expiresAt.toISOString() },
|
||||
200,
|
||||
)
|
||||
}
|
||||
|
||||
function json(body: unknown, status: number): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
17
packages/cloud-artifacts/tsconfig.json
Normal file
17
packages/cloud-artifacts/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["worker-configuration.d.ts", "src/**/*.ts"]
|
||||
}
|
||||
16
packages/cloud-artifacts/wrangler.toml
Normal file
16
packages/cloud-artifacts/wrangler.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
name = "cloud-artifacts"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2026-06-20"
|
||||
|
||||
[[r2_buckets]]
|
||||
binding = "BUCKET"
|
||||
bucket_name = "cloud-artifacts"
|
||||
|
||||
[vars]
|
||||
PUBLIC_URL = "https://artifacts.claude-code-best.win"
|
||||
DEFAULT_TTL_DAYS = "7"
|
||||
MAX_TTL_DAYS = "30"
|
||||
MAX_BYTES = "10485760"
|
||||
|
||||
[observability]
|
||||
enabled = true
|
||||
Reference in New Issue
Block a user