Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions online-submit/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# cardputer.cc 在线提交:网页直接上传 .deb

利用自有域名 `cardputer.cc` + 一个 Cloudflare Worker,实现「开发者在网页上直接
上传 `.deb` → 自动校验 → 自动开发布 PR → 管理员审核上架」。浏览器上传不再受
GitHub 的 CORS 限制,因为文件是 POST 到我们自己的 `api.cardputer.cc`,
它返回什么 CORS 头由我们说了算;与 GitHub 的所有交互都发生在 Worker
服务端(服务端调用不受 CORS 约束)。

```
浏览器 (cardputer.cc/submit.html)
│ ① GET /auth/login → GitHub OAuth(仅公开身份,不要仓库权限)
│ ② POST /api/submit(multipart:.deb + 源码仓库 URL)
Cloudflare Worker (api.cardputer.cc)
│ ③ 校验(大小 / ar 魔数 / 文件名)+ 计算 sha256
│ ④ 用 BOT_TOKEN 把 .deb 存到 packages 仓库 web-upload-buffer Release
│ ⑤ repository_dispatch "web-submission"(带 login/sha256/url/源码仓库)
CardputerZero/packages Actions (process-web-submission.yml)
│ ⑥ 完整校验:sha256、control 字段、.desktop、Maintainer 邮箱 == 提交者、
│ 版本必须高于线上;克隆源码仓库读取 app-builder.json store 段 + 截图
│ ⑦ 通过 → 自动开发布 PR(@提交者);失败 → 开 issue @提交者说明原因
管理员审核合并 → update-index.yml 提升 .deb 进 apt-pool → APT 索引重建 → 上架
```

产物格式(`meta.json` + `*.deb.release.json` manifest + 截图)与 `czdev publish`
完全一致,两条通道并存,审核管线共用。

## 目录

| 路径 | 部署到哪 |
|------|----------|
| `worker/` | Cloudflare Workers(`wrangler deploy`) |
| `packages-workflow/process-web-submission.yml` | `CardputerZero/packages` 的 `.github/workflows/` |
| `site/submit.html` | `CardputerZero/cardputerzero.github.io` 根目录(或并入 hub SPA) |

## 部署步骤

### 1. 域名接入 Cloudflare(免费版即可)

1. Cloudflare 控制台 → Add site → `cardputer.cc`,按提示把域名的 NS 记录
切到 Cloudflare(在注册商处修改)。
2. 站点主域:GitHub Pages 侧设置 custom domain 为 `cardputer.cc`
(`cardputerzero.github.io` 仓库 → Settings → Pages → Custom domain,
会自动提交 CNAME 文件),Cloudflare 里按 GitHub 文档加 A/AAAA 或 CNAME 记录。
> 也可以继续用 `cardputerzero.github.io` 访问站点,只把 API 放在
> `api.cardputer.cc`,则 `wrangler.toml` 的 `ALLOWED_ORIGIN` / `RETURN_URL`
> 改成 Pages 域名即可。

### 2. 创建 GitHub OAuth App(用于网页登录)

GitHub → Settings → Developer settings → OAuth Apps → New:

- Homepage URL: `https://cardputer.cc`
- Authorization callback URL: `https://api.cardputer.cc/auth/callback`

记下 Client ID / Client Secret。

### 3. 准备 bot token

建议用机器人账号(或组织管理员)创建 fine-grained PAT:

- Repository access: 仅 `CardputerZero/packages`
- Permissions: **Contents: Read and write**(上传 Release 资产 + 触发 dispatch)

### 4. 部署 Worker

```bash
cd online-submit/worker
npm i -g wrangler # 如未安装
wrangler login
wrangler secret put GITHUB_CLIENT_ID
wrangler secret put GITHUB_CLIENT_SECRET
wrangler secret put BOT_TOKEN
wrangler secret put SESSION_SECRET # openssl rand -hex 32
wrangler deploy
```

`wrangler.toml` 已配置 `api.cardputer.cc` 自定义域(zone 在 Cloudflare 上时
自动签发证书并接管路由)。

### 5. 安装 packages 侧 workflow

把 `packages-workflow/process-web-submission.yml` 复制到
`CardputerZero/packages` 的 `.github/workflows/`,并在仓库建一个
`web-submission-failed` label(失败反馈 issue 用)。

### 6. 部署提交页

把 `site/submit.html` 放到 `cardputerzero.github.io` 仓库根目录。如站点没
挂 `cardputer.cc` 主域,把文件顶部 `const API` 保持指向
`https://api.cardputer.cc` 即可(Worker 的 `ALLOWED_ORIGIN` 填站点实际来源)。

## 安全设计

- **身份**:OAuth 空 scope,只读公开资料;Worker 用 HMAC 签名的 HttpOnly
cookie 维持 24h 会话,不保存用户 token。
- **信任边界**:用户上传内容一律视为不可信——Worker 只做魔数/大小/文件名
检查;语义校验全部在 packages 仓库 Actions 里用 `dpkg-deb` 完成(与
`validate-pr.yml` 同一套规则),`.deb` 永不被执行。
- **身份绑定**:包的 `Maintainer` 邮箱必须等于提交者的
`<login>@users.noreply.github.com` 或其 GitHub 公开邮箱,与 czdev/PR
通道的规则一致,防止冒名顶替或覆盖他人包。
- **payload 传递**:workflow 中所有来自 dispatch payload 的值只经 env 传入
shell,杜绝模板注入。
- **上限**:Worker 默认 64 MB(`MAX_SIZE_MB`),Cloudflare 免费版请求体上限
100 MB。
- 可选加固:Cloudflare 侧对 `/api/submit` 配 rate limiting 规则防刷。

## 与 OSS 的关系

如果更希望文件落在阿里云 OSS(国内直传快):OSS bucket 支持配置 CORS 允许
浏览器直传,但生成上传签名同样需要一个服务端(函数计算/STS),之后还要一跳
回调 GitHub API。整体链路比 Worker 方案多一层,且 packages 管线最终仍要从
URL 拉取 `.deb` 校验。因此推荐先用本方案(.deb 直接进 GitHub Release,与现有
apt-pool 分发/OSS 镜像同步逻辑无缝衔接);将来如需国内上传加速,只需把
Worker 的第④步换成"签名直传 OSS + manifest url 指向 OSS"即可,其余不变。
187 changes: 187 additions & 0 deletions online-submit/packages-workflow/process-web-submission.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# Copy to CardputerZero/packages: .github/workflows/process-web-submission.yml
#
# Handles `repository_dispatch` events sent by the cardputer.cc upload Worker
# (online-submit/worker in CardputerZero-AppBuilder). Downloads the uploaded
# .deb from the web-upload-buffer release, runs the same validation as
# validate-pr.yml, collects store metadata from the developer's source repo,
# and opens a publish PR for maintainer review. On failure it opens an issue
# mentioning the submitter so they get actionable feedback.

name: Process Web Submission

on:
repository_dispatch:
types: [web-submission]

permissions:
contents: write
issues: write
pull-requests: write

jobs:
process:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install tools
run: sudo apt-get update && sudo apt-get install -y dpkg-dev jq

- name: Download and validate .deb
id: validate
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# client_payload values are produced by the trusted Worker, but they
# still only ever flow through env vars, never shell interpolation.
DEB_URL: ${{ github.event.client_payload.url }}
WANT_SHA: ${{ github.event.client_payload.sha256 }}
SUBMITTER: ${{ github.event.client_payload.login }}
run: |
set -euo pipefail
ERR=""
curl -fL --retry 3 --max-time 600 -o /tmp/pkg.deb "$DEB_URL" || ERR="- ❌ could not download the uploaded .deb\n"
if [ -f /tmp/pkg.deb ]; then
GOT=$(sha256sum /tmp/pkg.deb | awk '{print $1}')
[ "$GOT" = "$WANT_SHA" ] || ERR="${ERR}- ❌ sha256 mismatch (got \`$GOT\`)\n"
PKG=$(dpkg-deb -f /tmp/pkg.deb Package 2>/dev/null || true)
VER=$(dpkg-deb -f /tmp/pkg.deb Version 2>/dev/null || true)
ARCH=$(dpkg-deb -f /tmp/pkg.deb Architecture 2>/dev/null || true)
MAINT=$(dpkg-deb -f /tmp/pkg.deb Maintainer 2>/dev/null || true)
[ -n "$PKG" ] || ERR="${ERR}- ❌ not a valid .deb\n"
echo "$PKG" | grep -qP '^[a-z0-9][a-z0-9.+\-]+$' || ERR="${ERR}- ❌ invalid package name '$PKG'\n"
dpkg-deb -c /tmp/pkg.deb > /tmp/contents.txt 2>/dev/null || true
grep -q '\.desktop$' /tmp/contents.txt || ERR="${ERR}- ❌ missing .desktop file (all apps must include one)\n"

# Maintainer email must match the authenticated submitter.
EMAIL=$(echo "$MAINT" | grep -oP '<\K[^>]+' || echo "$MAINT")
NOREPLY="${SUBMITTER}@users.noreply.github.com"
PUB=$(gh api "users/$SUBMITTER" --jq '.email // empty' 2>/dev/null || true)
if [ "$EMAIL" != "$NOREPLY" ] && { [ -z "$PUB" ] || [ "$EMAIL" != "$PUB" ]; }; then
ERR="${ERR}- ❌ Maintainer \`$EMAIL\` does not match submitter \`$SUBMITTER\` (expected \`$NOREPLY\` or your public GitHub email)\n"
fi

# Version must be newer than the latest manifest on main.
EXISTING=""
for m in pool/main/"$PKG"/*.deb.release.json; do
[ -f "$m" ] || continue
v=$(jq -r '.version // empty' "$m")
if [ -n "$v" ] && { [ -z "$EXISTING" ] || dpkg --compare-versions "$v" gt "$EXISTING"; }; then EXISTING="$v"; fi
done
if [ -n "$EXISTING" ] && dpkg --compare-versions "$VER" le "$EXISTING"; then
ERR="${ERR}- ❌ version $VER is not newer than published $EXISTING\n"
fi
{ echo "pkg=$PKG"; echo "ver=$VER"; echo "arch=$ARCH"; } >> "$GITHUB_OUTPUT"
fi
printf 'errors<<EOM\n%b\nEOM\n' "$ERR" >> "$GITHUB_OUTPUT"

- name: Collect store metadata from source repo
id: meta
if: steps.validate.outputs.errors == ''
env:
SOURCE_REPO: ${{ github.event.client_payload.source_repo }}
PKG: ${{ steps.validate.outputs.pkg }}
run: |
set -uo pipefail
ERR=""
git clone --depth=1 "$SOURCE_REPO" /tmp/src 2>/dev/null || ERR="- ❌ could not clone source repo\n"
if [ -z "$ERR" ]; then
python3 - <<'EOF' || ERR="- ❌ $(cat /tmp/meta-error.txt 2>/dev/null || echo metadata collection failed)\n"
import json, os, shutil, sys
from pathlib import Path

def fail(msg):
Path("/tmp/meta-error.txt").write_text(msg)
sys.exit(1)

pkg = os.environ["PKG"]
for mf in Path("/tmp/src").rglob("app-builder.json"):
try:
raw = json.loads(mf.read_text())
except Exception:
continue
if raw.get("package_name") != pkg or "store" not in raw:
continue
store, app_dir = raw["store"], mf.parent
dest = Path(f"pool/main/{pkg}")
dest.mkdir(parents=True, exist_ok=True)
meta = {"title": raw.get("app_name", ""),
"summary": store.get("summary", ""),
"categories": store.get("categories", []),
"screenshots": store.get("screenshots", [])}
for k in ("description", "locales", "license", "source_repo", "icon", "permissions"):
if store.get(k):
meta[k] = store[k]
if not meta["screenshots"]:
fail("app-builder.json store section has no screenshots (at least one 320x170 screenshot required)")
shots = dest / "screenshots"
shots.mkdir(exist_ok=True)
for s in meta["screenshots"]:
src = app_dir / s
if not src.is_file():
fail(f"screenshot not found in source repo: {s}")
shutil.copy2(src, shots / src.name)
if store.get("icon") and (app_dir / store["icon"]).is_file():
shutil.copy2(app_dir / store["icon"], dest / Path(store["icon"]).name)
(dest / "meta.json").write_text(json.dumps(meta, indent=2, ensure_ascii=False))
sys.exit(0)
fail(f"no app-builder.json with package_name={pkg} and a store section found in the source repo")
EOF
fi
printf 'errors<<EOM\n%b\nEOM\n' "$ERR" >> "$GITHUB_OUTPUT"

- name: Create publish PR
id: pr
if: steps.validate.outputs.errors == '' && steps.meta.outputs.errors == ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEB_URL: ${{ github.event.client_payload.url }}
WANT_SHA: ${{ github.event.client_payload.sha256 }}
SIZE: ${{ github.event.client_payload.size }}
SUBMITTER: ${{ github.event.client_payload.login }}
PKG: ${{ steps.validate.outputs.pkg }}
VER: ${{ steps.validate.outputs.ver }}
ARCH: ${{ steps.validate.outputs.arch }}
run: |
set -euo pipefail
NAME="${PKG}_${VER}_${ARCH}.deb"
ASSET_NAME="${NAME//\~/.}"
jq -n --arg f "$ASSET_NAME" --arg u "$DEB_URL" --arg s "$WANT_SHA" \
--arg p "$PKG" --arg v "$VER" --arg a "$ARCH" --argjson z "$SIZE" \
'{filename:$f,url:$u,sha256:$s,size:$z,package:$p,version:$v,architecture:$a}' \
> "pool/main/$PKG/$ASSET_NAME.release.json"
BRANCH="publish/${PKG}-${VER}-web-${{ github.run_id }}"
git config user.name "cardputerzero-bot"
git config user.email "cardputerzero-bot@users.noreply.github.com"
git checkout -b "$BRANCH"
git add "pool/main/$PKG"
git commit -m "publish: $PKG $VER ($ARCH) via web submission by @$SUBMITTER"
git push origin "$BRANCH"
gh pr create --base main --head "$BRANCH" \
--title "publish: $PKG $VER (web submission by @$SUBMITTER)" \
--body "$(printf 'Submitted by @%s via cardputer.cc.\n\n| Field | Value |\n|---|---|\n| Version | %s |\n| Architecture | %s |\n| SHA-256 | `%s` |\n| Binary | [%s](%s) |\n\nValidated by [this run](%s/%s/actions/runs/%s). On merge, update-index promotes the binary into the apt-pool release.' \
"$SUBMITTER" "$VER" "$ARCH" "$WANT_SHA" "$ASSET_NAME" "$DEB_URL" "${{ github.server_url }}" "${{ github.repository }}" "${{ github.run_id }}")" \
> /tmp/pr-url.txt
echo "url=$(tail -1 /tmp/pr-url.txt)" >> "$GITHUB_OUTPUT"

- name: Report failure to the submitter
if: always() && (steps.validate.outputs.errors != '' || steps.meta.outputs.errors != '' || failure())
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SUBMITTER: ${{ github.event.client_payload.login }}
FILENAME: ${{ github.event.client_payload.filename }}
ERR1: ${{ steps.validate.outputs.errors }}
ERR2: ${{ steps.meta.outputs.errors }}
run: |
{
echo "@$SUBMITTER your web submission of \`$FILENAME\` failed validation:"
echo
printf '%b%b' "$ERR1" "$ERR2"
echo
echo "Details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo
echo "Fix the package and upload again at https://cardputer.cc/submit.html"
} > /tmp/issue.md
gh issue create \
--title "web submission failed: $FILENAME" \
--label "web-submission-failed" \
--body-file /tmp/issue.md
Loading
Loading