From 7b9c72c6c192810de48ee56cf90ab118f8a2f5b8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Jul 2026 14:22:09 +0000 Subject: [PATCH] feat: add cardputer.cc web submission stack (worker + workflow + page) Direct .deb upload from the browser, enabled by the self-owned domain: a Cloudflare Worker on api.cardputer.cc accepts POST uploads (CORS is under our control, bypassing GitHub's uploads endpoint restriction), authenticates developers via a zero-scope GitHub OAuth login, stores the binary in the packages repo's web-upload-buffer release with a bot token, and fires a repository_dispatch. A ready-to-copy workflow for CardputerZero/packages runs the same validation as validate-pr.yml (sha256, control fields, .desktop, maintainer email binding, version monotonicity), collects store metadata from the developer's app-builder.json, and opens the publish PR (or a feedback issue on failure). Includes the static submit page and a Chinese deployment guide. Co-authored-by: eggfly --- online-submit/README.md | 118 ++++++ .../process-web-submission.yml | 187 ++++++++++ online-submit/site/submit.html | 171 +++++++++ online-submit/worker/src/index.js | 353 ++++++++++++++++++ online-submit/worker/wrangler.toml | 24 ++ 5 files changed, 853 insertions(+) create mode 100644 online-submit/README.md create mode 100644 online-submit/packages-workflow/process-web-submission.yml create mode 100644 online-submit/site/submit.html create mode 100644 online-submit/worker/src/index.js create mode 100644 online-submit/worker/wrangler.toml diff --git a/online-submit/README.md b/online-submit/README.md new file mode 100644 index 0000000..0f94812 --- /dev/null +++ b/online-submit/README.md @@ -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` 邮箱必须等于提交者的 + `@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"即可,其余不变。 diff --git a/online-submit/packages-workflow/process-web-submission.yml b/online-submit/packages-workflow/process-web-submission.yml new file mode 100644 index 0000000..90a2bd4 --- /dev/null +++ b/online-submit/packages-workflow/process-web-submission.yml @@ -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<> "$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<> "$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 diff --git a/online-submit/site/submit.html b/online-submit/site/submit.html new file mode 100644 index 0000000..7b87a8f --- /dev/null +++ b/online-submit/site/submit.html @@ -0,0 +1,171 @@ + + + + + + + 提交应用 · CardputerZero AppStore + + + +
+

提交应用到 AppStore

+

上传 .deb → 自动校验 → 生成发布 PR → 管理员审核上架

+ + + + + + +
+ + + + diff --git a/online-submit/worker/src/index.js b/online-submit/worker/src/index.js new file mode 100644 index 0000000..a846274 --- /dev/null +++ b/online-submit/worker/src/index.js @@ -0,0 +1,353 @@ +/** + * cardputer.cc submission API — Cloudflare Worker. + * + * Accepts .deb uploads from the static store site and hands them to the + * CardputerZero/packages review pipeline: + * + * browser ──POST /api/submit──▶ this Worker ──▶ buffer Release asset + * │ (packages repo) + * └──▶ repository_dispatch "web-submission" + * → Actions validates + opens the + * publish PR for maintainer review + * + * Identity: GitHub OAuth web flow (empty scope — public profile only). The + * user's OAuth token is used once to read the login and is never stored; + * uploads happen with the bot token (BOT_TOKEN secret). + * + * Routes: + * GET /auth/login redirect to GitHub OAuth + * GET /auth/callback OAuth code exchange, sets session cookie + * GET /auth/logout clears the session + * GET /api/me current session {login} or 401 + * POST /api/submit multipart form: deb=, source_repo= + * + * Required secrets (wrangler secret put ...): + * GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET — OAuth App for the login flow + * BOT_TOKEN — PAT with contents:write on TARGET_OWNER/TARGET_REPO + * SESSION_SECRET — random string for HMAC-signing session cookies + * + * Vars (wrangler.toml): ALLOWED_ORIGIN, RETURN_URL, TARGET_OWNER, + * TARGET_REPO, BUFFER_TAG, MAX_SIZE_MB. + */ + +const SESSION_COOKIE = "cz_session"; +const STATE_COOKIE = "cz_oauth_state"; +const SESSION_TTL_SECS = 24 * 3600; +const USER_AGENT = "cardputer-submit-worker/1.0"; + +export default { + async fetch(request, env) { + const url = new URL(request.url); + try { + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: cors(env) }); + } + if (url.pathname === "/auth/login" && request.method === "GET") { + return authLogin(url, env); + } + if (url.pathname === "/auth/callback" && request.method === "GET") { + return authCallback(request, url, env); + } + if (url.pathname === "/auth/logout" && request.method === "GET") { + return authLogout(env); + } + if (url.pathname === "/api/me" && request.method === "GET") { + return apiMe(request, env); + } + if (url.pathname === "/api/submit" && request.method === "POST") { + return apiSubmit(request, env); + } + return json(env, 404, { error: "not_found" }); + } catch (err) { + console.error(err.stack || String(err)); + return json(env, 500, { error: "internal", detail: String(err.message || err) }); + } + }, +}; + +/* ---------------------------------- auth --------------------------------- */ + +function authLogin(url, env) { + const state = crypto.randomUUID(); + const target = new URL("https://github.com/login/oauth/authorize"); + target.searchParams.set("client_id", env.GITHUB_CLIENT_ID); + target.searchParams.set("redirect_uri", `${url.origin}/auth/callback`); + target.searchParams.set("state", state); + // Empty scope on purpose: we only need the public login for identity. + return new Response(null, { + status: 302, + headers: { + Location: target.toString(), + "Set-Cookie": setCookie(STATE_COOKIE, state, 600), + }, + }); +} + +async function authCallback(request, url, env) { + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const cookies = parseCookies(request); + if (!code || !state || cookies[STATE_COOKIE] !== state) { + return json(env, 400, { error: "bad_oauth_state" }); + } + + const tokenResp = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ + client_id: env.GITHUB_CLIENT_ID, + client_secret: env.GITHUB_CLIENT_SECRET, + code, + }), + }); + const tokenData = await tokenResp.json(); + if (!tokenData.access_token) { + return json(env, 502, { error: "oauth_exchange_failed", detail: tokenData.error || "" }); + } + + const userResp = await fetch("https://api.github.com/user", { + headers: { + authorization: `Bearer ${tokenData.access_token}`, + accept: "application/vnd.github+json", + "user-agent": USER_AGENT, + }, + }); + if (!userResp.ok) { + return json(env, 502, { error: "user_lookup_failed" }); + } + const user = await userResp.json(); + + const session = await makeSession(env, user.login); + const headers = new Headers({ Location: env.RETURN_URL }); + headers.append("Set-Cookie", setCookie(SESSION_COOKIE, session, SESSION_TTL_SECS)); + headers.append("Set-Cookie", setCookie(STATE_COOKIE, "", 0)); + return new Response(null, { status: 302, headers }); +} + +function authLogout(env) { + return new Response(null, { + status: 302, + headers: { + Location: env.RETURN_URL, + "Set-Cookie": setCookie(SESSION_COOKIE, "", 0), + }, + }); +} + +async function apiMe(request, env) { + const login = await readSession(request, env); + if (!login) return json(env, 401, { error: "not_logged_in" }); + return json(env, 200, { login }); +} + +/* --------------------------------- submit -------------------------------- */ + +async function apiSubmit(request, env) { + const login = await readSession(request, env); + if (!login) return json(env, 401, { error: "not_logged_in" }); + + const form = await request.formData(); + const file = form.get("deb"); + const sourceRepo = String(form.get("source_repo") || "").trim(); + + if (!(file && typeof file.arrayBuffer === "function" && file.name)) { + return json(env, 400, { error: "missing_deb_file" }); + } + if (!/^https:\/\/[\w.-]+\/[\w.-]+\/[\w.-]+\/?$/.test(sourceRepo)) { + return json(env, 400, { + error: "bad_source_repo", + detail: "source_repo must be a public https git URL like https://github.com/you/app", + }); + } + const fileName = file.name; + if (!/^[a-z0-9][a-z0-9.+~_-]*\.deb$/.test(fileName)) { + return json(env, 400, { + error: "bad_filename", + detail: "expected __.deb", + }); + } + + const maxBytes = Number(env.MAX_SIZE_MB || "64") * 1024 * 1024; + const buf = await file.arrayBuffer(); + if (buf.byteLength === 0) return json(env, 400, { error: "empty_file" }); + if (buf.byteLength > maxBytes) { + return json(env, 413, { error: "too_large", detail: `limit is ${env.MAX_SIZE_MB || "64"} MB` }); + } + + // .deb files are `ar` archives: magic "!\n". + const magic = new TextDecoder().decode(new Uint8Array(buf, 0, 8)); + if (magic !== "!\n") { + return json(env, 400, { error: "not_a_deb", detail: "file is not a Debian package" }); + } + + const sha256 = hex(await crypto.subtle.digest("SHA-256", buf)); + + // Namespace the buffer asset by uploader; the workflow renames to the + // canonical __.deb when writing the manifest. + const assetName = `${login}__${fileName}`.replace(/~/g, "."); + + const release = await ensureBufferRelease(env); + await deleteExistingAsset(env, release, assetName); + + const uploadUrl = + `https://uploads.github.com/repos/${env.TARGET_OWNER}/${env.TARGET_REPO}` + + `/releases/${release.id}/assets?name=${encodeURIComponent(assetName)}`; + const uploadResp = await fetch(uploadUrl, { + method: "POST", + headers: { + ...botHeaders(env), + "content-type": "application/octet-stream", + }, + body: buf, + }); + if (!uploadResp.ok) { + return json(env, 502, { error: "upload_failed", detail: await safeText(uploadResp) }); + } + const asset = await uploadResp.json(); + + const dispatchResp = await gh(env, `/repos/${env.TARGET_OWNER}/${env.TARGET_REPO}/dispatches`, { + method: "POST", + body: JSON.stringify({ + event_type: "web-submission", + client_payload: { + login, + filename: fileName, + asset_name: assetName, + url: asset.browser_download_url, + sha256, + size: buf.byteLength, + source_repo: sourceRepo, + }, + }), + }); + if (dispatchResp.status !== 204) { + return json(env, 502, { error: "dispatch_failed", detail: await safeText(dispatchResp) }); + } + + return json(env, 200, { + ok: true, + login, + sha256, + message: "Submitted. Validation runs in CI; a publish PR will mention you when it opens.", + track_url: `https://github.com/${env.TARGET_OWNER}/${env.TARGET_REPO}/pulls?q=is%3Apr+${encodeURIComponent(login)}`, + }); +} + +async function ensureBufferRelease(env) { + const tag = env.BUFFER_TAG || "web-upload-buffer"; + const existing = await gh(env, `/repos/${env.TARGET_OWNER}/${env.TARGET_REPO}/releases/tags/${tag}`); + if (existing.ok) return existing.json(); + if (existing.status !== 404) { + throw new Error(`release lookup failed: ${existing.status}`); + } + const created = await gh(env, `/repos/${env.TARGET_OWNER}/${env.TARGET_REPO}/releases`, { + method: "POST", + body: JSON.stringify({ + tag_name: tag, + name: "web upload buffer", + prerelease: true, + body: "Holds .deb files uploaded via cardputer.cc pending review.", + }), + }); + if (!created.ok) throw new Error(`release create failed: ${created.status}`); + return created.json(); +} + +async function deleteExistingAsset(env, release, assetName) { + const found = (release.assets || []).find((a) => a.name === assetName); + if (!found) return; + await gh(env, `/repos/${env.TARGET_OWNER}/${env.TARGET_REPO}/releases/assets/${found.id}`, { + method: "DELETE", + }); +} + +/* --------------------------------- helpers ------------------------------- */ + +function cors(env) { + return { + "Access-Control-Allow-Origin": env.ALLOWED_ORIGIN, + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + Vary: "Origin", + }; +} + +function json(env, status, obj) { + return new Response(JSON.stringify(obj), { + status, + headers: { "content-type": "application/json; charset=utf-8", ...cors(env) }, + }); +} + +function botHeaders(env) { + return { + authorization: `Bearer ${env.BOT_TOKEN}`, + accept: "application/vnd.github+json", + "user-agent": USER_AGENT, + }; +} + +function gh(env, path, init = {}) { + return fetch(`https://api.github.com${path}`, { + ...init, + headers: { ...botHeaders(env), ...(init.headers || {}) }, + }); +} + +async function safeText(resp) { + try { + return (await resp.text()).slice(0, 500); + } catch { + return ""; + } +} + +function setCookie(name, value, maxAge) { + return `${name}=${value}; Max-Age=${maxAge}; Path=/; Secure; HttpOnly; SameSite=Lax`; +} + +function parseCookies(request) { + const out = {}; + for (const part of (request.headers.get("cookie") || "").split(";")) { + const idx = part.indexOf("="); + if (idx > 0) out[part.slice(0, idx).trim()] = part.slice(idx + 1).trim(); + } + return out; +} + +async function makeSession(env, login) { + const exp = Math.floor(Date.now() / 1000) + SESSION_TTL_SECS; + const payload = `${login}.${exp}`; + return `${payload}.${await hmac(env.SESSION_SECRET, payload)}`; +} + +async function readSession(request, env) { + const raw = parseCookies(request)[SESSION_COOKIE]; + if (!raw) return null; + const idx = raw.lastIndexOf("."); + if (idx <= 0) return null; + const payload = raw.slice(0, idx); + const sig = raw.slice(idx + 1); + if (sig !== (await hmac(env.SESSION_SECRET, payload))) return null; + const sep = payload.lastIndexOf("."); + const login = payload.slice(0, sep); + const exp = Number(payload.slice(sep + 1)); + if (!login || !Number.isFinite(exp) || exp < Date.now() / 1000) return null; + return login; +} + +async function hmac(secret, message) { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + return hex(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message))); +} + +function hex(buf) { + return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} diff --git a/online-submit/worker/wrangler.toml b/online-submit/worker/wrangler.toml new file mode 100644 index 0000000..b2a6ba8 --- /dev/null +++ b/online-submit/worker/wrangler.toml @@ -0,0 +1,24 @@ +name = "cardputer-submit" +main = "src/index.js" +compatibility_date = "2026-07-01" + +# Attach the API to your own domain (zone must be on Cloudflare): +routes = [ + { pattern = "api.cardputer.cc", custom_domain = true }, +] + +[vars] +# Origin of the static store site allowed to call this API. +ALLOWED_ORIGIN = "https://cardputer.cc" +# Where to send the browser back after login/logout. +RETURN_URL = "https://cardputer.cc/submit.html" +TARGET_OWNER = "CardputerZero" +TARGET_REPO = "packages" +BUFFER_TAG = "web-upload-buffer" +MAX_SIZE_MB = "64" + +# Secrets (set via `wrangler secret put `): +# GITHUB_CLIENT_ID OAuth App client id (callback: https://api.cardputer.cc/auth/callback) +# GITHUB_CLIENT_SECRET OAuth App client secret +# BOT_TOKEN fine-grained PAT, contents:write on CardputerZero/packages +# SESSION_SECRET long random string, e.g. `openssl rand -hex 32`