diff --git a/Cargo.lock b/Cargo.lock index 6281dfb53f..c47aeb5947 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1008,6 +1008,7 @@ dependencies = [ "regex", "reqwest", "rusqlite", + "semver", "serde", "serde_json", "serde_yaml", @@ -1089,6 +1090,7 @@ dependencies = [ "serde", "serde_json", "sha1", + "sha2", "similar", "tar", "tauri", @@ -1513,6 +1515,47 @@ dependencies = [ "zip", ] +[[package]] +name = "bitfun-skin-market-server" +version = "0.2.15" +dependencies = [ + "anyhow", + "axum", + "bitfun-skin-market-service", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "bitfun-skin-market-service" +version = "0.2.15" +dependencies = [ + "anyhow", + "axum", + "base64 0.22.1", + "bitfun-product-domains", + "chrono", + "hex", + "hmac", + "image 0.25.10", + "reqwest", + "semver", + "serde", + "serde_json", + "sha2", + "sqlx", + "tempfile", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tracing", + "url", + "uuid", + "zip", +] + [[package]] name = "bitfun-static-hook-support" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index 9c4e133cd4..d7ec057196 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "src/apps/server", "src/apps/relay-server", "src/apps/miniapp-market-server", + "src/apps/skin-market-server", "src/crates/interfaces/acp", "src/crates/interfaces/sdk-host", "src/crates/adapters/agent-runtime-ipc", @@ -21,6 +22,7 @@ members = [ "src/crates/services/services-core", "src/crates/services/services-integrations", "src/crates/services/miniapp-market-service", + "src/crates/services/skin-market-service", "src/crates/services/relay-service", "src/crates/services/page-function-runtime", "src/crates/services/terminal", diff --git a/deploy/miniapp-market/nginx-market.openbitfun.com.conf b/deploy/miniapp-market/nginx-market.openbitfun.com.conf index dd8e99ff82..75a7b18ba9 100644 --- a/deploy/miniapp-market/nginx-market.openbitfun.com.conf +++ b/deploy/miniapp-market/nginx-market.openbitfun.com.conf @@ -36,12 +36,58 @@ server { proxy_read_timeout 60s; } + location = /skin { + return 308 https://market.openbitfun.com/skin/; + } + + location ^~ /skin/ { + client_max_body_size 100m; + error_page 413 = @skin_payload_too_large; + error_page 429 = @skin_rate_limited; + limit_req zone=skin_market_api burst=30 nodelay; + limit_req zone=skin_market_upload burst=2 nodelay; + limit_req_status 429; + limit_conn skin_market_connections 8; + limit_conn_status 429; + + proxy_pass http://127.0.0.1:9720; + proxy_http_version 1.1; + proxy_request_buffering off; + proxy_buffering on; + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto https; + # The http-level Skin config accepts XFF only from the explicit WAF + # peer set. Forward one normalized address instead of the caller chain. + proxy_set_header X-Real-IP $skin_market_client_address; + proxy_set_header X-Forwarded-For $skin_market_client_address; + proxy_set_header X-Request-ID $request_id; + + proxy_connect_timeout 5s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } + location @market_payload_too_large { default_type application/json; add_header X-Request-ID $request_id always; return 413 '{"error":{"code":"payload_too_large","message":"Uploads must not exceed 25 MiB.","requestId":"$request_id"}}'; } + location @skin_payload_too_large { + default_type application/json; + add_header X-Request-ID $request_id always; + return 413 '{"error":{"code":"payload_too_large","message":"Appearance uploads must not exceed 96 MiB.","requestId":"$request_id"}}'; + } + + location @skin_rate_limited { + internal; + default_type application/json; + add_header X-Request-ID $request_id always; + return 429 '{"error":{"code":"rate_limited","message":"Too many Skin market requests. Retry shortly.","requestId":"$request_id"}}'; + } + location / { return 404; } diff --git a/deploy/skin-market/README.md b/deploy/skin-market/README.md new file mode 100644 index 0000000000..8017a5c086 --- /dev/null +++ b/deploy/skin-market/README.md @@ -0,0 +1,255 @@ +# BitFun Skin market deployment + +This directory deploys the Appearance package catalog exposed to users as the +**Skin market** at `https://market.openbitfun.com/skin/`. + +It is intentionally isolated from the production MiniApp market: + +| Resource | Skin market | +| --- | --- | +| Container | `bitfun-skin-market` | +| Loopback origin | `127.0.0.1:9720` | +| Checkout | `/srv/bitfun-skin-market/app` | +| SQLite | `/srv/bitfun-skin-market/data/market.sqlite` | +| Artifacts | `/srv/bitfun-skin-market/artifacts` | +| Backups | `/srv/bitfun-skin-market/backups` | +| Root-only environment | `/etc/bitfun-skin-market/market.env` | +| Deploy ref | `refs/heads/skin-market-deploy` | + +The Skin server has no OAuth client or credential database. Desktop clients +reuse the MiniApp market credential vault and Skin forwards each Bearer token +to the configured MiniApp `/me` endpoint. Web contribution and review routes +use the MiniApp auth broker and a `/skin`-scoped alias of that broker's Web +session. Unsafe browser requests require the matching CSRF alias. Never copy +the MiniApp OAuth secret into the Skin environment. + +## Agent safety contract + +1. Deploy only a committed, explicit commit. Never build a dirty checkout or a + floating branch name. +2. Do not read, copy or print `/etc/bitfun-skin-market/market.env`. +3. Record the previous commit and verify a current SQLite backup before a + backend or schema deployment. +4. Do not recreate or modify the MiniApp, Relay, API or website containers. +5. Use `--force-with-lease` for the dedicated deploy ref; never unconditional + force. +6. Run `nginx -t` before reload. Install only the market vhost changed by this + deployment and the namespaced Skin rate-limit zones. +7. Report success only after container health, image revision, origin and public + health all match the target commit. +8. Do not automatically roll an older binary over an incompatible migration. + +## Local release checks + +Choose the smallest relevant checks, then create one explicit commit: + +```bash +pnpm run fmt:rs +cargo test -p bitfun-product-domains --no-default-features --features appearance-market +cargo test -p bitfun-skin-market-service +cargo check -p bitfun-skin-market-server +pnpm run type-check:skin-market +pnpm run test:skin-market +pnpm run build:skin-market +git diff --check +``` + +Record the immutable target: + +```bash +DEPLOY_COMMIT="$(git rev-parse HEAD)" +git show --stat --oneline "$DEPLOY_COMMIT" +``` + +## First installation + +Create only the dedicated roots; UID/GID `10002` owns live data, never secrets: + +```bash +ssh lwb 'set -eu +install -d -m 0755 /srv/bitfun-skin-market +install -d -o 10002 -g 10002 -m 0750 \ + /srv/bitfun-skin-market/data /srv/bitfun-skin-market/artifacts +install -d -m 0750 /srv/bitfun-skin-market/backups +install -d -m 0700 /etc/bitfun-skin-market +test ! -e /srv/bitfun-skin-market/app +git clone --no-hardlinks /srv/bitfun-miniapp-market/app /srv/bitfun-skin-market/app +git -C /srv/bitfun-skin-market/app checkout --detach +git -C /srv/bitfun-skin-market/app status --short' +``` + +Create the environment with a controlled editor, using +`market.env.example` as a field list. Generate the download hash secret on the +server; do not pass it as a command argument or paste it into logs: + +```bash +ssh -t lwb 'umask 077; vi /etc/bitfun-skin-market/market.env' +ssh lwb 'chmod 600 /etc/bitfun-skin-market/market.env; \ + stat -c "%U:%G %a %n" /etc/bitfun-skin-market/market.env' +``` + +Production identity verification should use +`https://market.openbitfun.com/miniapp/api/v1/me`. Public HTTPS avoids coupling +the two independent Compose networks. Skin forwards either the Bearer +Authorization header or the exact Skin session and CSRF aliases required for +the request; it never proxies the browser's full Cookie header. + +Install backup jobs after the checkout contains this deployment, but do not +start the timer until the Skin container has created a healthy UID-10002-owned +database: + +```bash +ssh lwb 'set -eu +install -m 0750 /srv/bitfun-skin-market/app/deploy/skin-market/backup.sh \ + /usr/local/sbin/bitfun-skin-market-backup +install -m 0750 /srv/bitfun-skin-market/app/deploy/skin-market/restore-drill.sh \ + /usr/local/sbin/bitfun-skin-market-restore-drill +install -m 0644 /srv/bitfun-skin-market/app/deploy/skin-market/bitfun-skin-market-backup.service \ + /etc/systemd/system/bitfun-skin-market-backup.service +install -m 0644 /srv/bitfun-skin-market/app/deploy/skin-market/bitfun-skin-market-backup.timer \ + /etc/systemd/system/bitfun-skin-market-backup.timer +systemctl daemon-reload' +``` + +## Deploy an exact commit + +Inspect the target and existing deployment first. On an initial install, the +container inspect/health commands are expected not to find a container: + +```bash +ssh lwb 'set -eu +git -C /srv/bitfun-skin-market/app status --short +git -C /srv/bitfun-skin-market/app rev-parse HEAD +docker inspect --format "{{.Config.Image}} {{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{end}}" bitfun-skin-market 2>/dev/null || true +curl -fsS http://127.0.0.1:9720/skin/api/v1/health 2>/dev/null || true +stat -c "%U:%G %a %n" /etc/bitfun-skin-market/market.env' +``` + +For an existing deployment, save the rollback target and confirm or create +today's backup before changing the checkout: + +```bash +PREVIOUS_COMMIT="$(ssh lwb 'git -C /srv/bitfun-skin-market/app rev-parse HEAD')" +ssh lwb 'set -eu +if test -f /srv/bitfun-skin-market/data/market.sqlite; then + today="$(date -u +%F)" + target="/srv/bitfun-skin-market/backups/daily/${today}" + if test -d "$target"; then + (cd "$target" && sha256sum -c SHA256SUMS) + test "$(sqlite3 "$target/market.sqlite" "PRAGMA integrity_check;")" = ok + else + /usr/local/sbin/bitfun-skin-market-backup + fi +fi' +``` + +Send only the explicit commit to the dedicated ref: + +```bash +if REMOTE_REF_COMMIT="$(ssh lwb 'git -C /srv/bitfun-skin-market/app \ + rev-parse refs/heads/skin-market-deploy 2>/dev/null')"; then + DEPLOY_LEASE="refs/heads/skin-market-deploy:${REMOTE_REF_COMMIT}" +else + # An empty expected value means the initial push succeeds only while the + # deploy ref is still absent; it remains a compare-and-swap operation. + DEPLOY_LEASE="refs/heads/skin-market-deploy:" +fi +git push \ + --force-with-lease="${DEPLOY_LEASE}" \ + ssh://lwb/srv/bitfun-skin-market/app \ + "${DEPLOY_COMMIT}:refs/heads/skin-market-deploy" +ssh lwb "set -eu +test -z \"\$(git -C /srv/bitfun-skin-market/app status --porcelain)\" +git -C /srv/bitfun-skin-market/app checkout --detach '$DEPLOY_COMMIT' +test \"\$(git -C /srv/bitfun-skin-market/app rev-parse HEAD)\" = '$DEPLOY_COMMIT'" +``` + +Build while any existing container continues serving, then recreate only Skin: + +```bash +ssh lwb "set -eu +cd /srv/bitfun-skin-market/app +test \"\$(git rev-parse HEAD)\" = '$DEPLOY_COMMIT' +export MARKET_GIT_COMMIT='$DEPLOY_COMMIT' +docker compose -f deploy/skin-market/docker-compose.yml build skin-market +docker compose -f deploy/skin-market/docker-compose.yml \ + up -d --no-build --force-recreate skin-market" +``` + +Wait for health and verify revision plus both network paths: + +```bash +ssh lwb 'set -eu +for attempt in $(seq 1 45); do + status="$(docker inspect --format \ + "{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}" \ + bitfun-skin-market)" + test "$status" = healthy && exit 0 + sleep 2 +done +docker inspect --format "{{json .State}}" bitfun-skin-market +exit 1' + +DEPLOYED_COMMIT="$(ssh lwb 'docker inspect --format \ + "{{index .Config.Labels \"org.opencontainers.image.revision\"}}" \ + bitfun-skin-market')" +test "$DEPLOYED_COMMIT" = "$DEPLOY_COMMIT" +ssh lwb 'curl -fsS http://127.0.0.1:9720/skin/api/v1/health' +``` + +Create and drill the first backup only after the container is healthy, then +enable the timer: + +```bash +ssh lwb 'set -eu +/usr/local/sbin/bitfun-skin-market-backup +today="$(date -u +%F)" +/usr/local/sbin/bitfun-skin-market-restore-drill \ + "/srv/bitfun-skin-market/backups/daily/${today}" +systemctl enable --now bitfun-skin-market-backup.timer' +``` + +## Nginx route + +The shared market vhost is owned in +`deploy/miniapp-market/nginx-market.openbitfun.com.conf`. It keeps MiniApp at +25 MiB and grants only `/skin/` the 100 MiB transport ceiling. Skin application +validation remains 96 MiB. Install this file only when its diff is part of the +target commit: + +The trusted WAF CIDRs in `nginx-skin-market-http.conf` are an explicit trust +boundary, not a generic proxy list. Before each deployment, compare them with +Huawei WAF's current `ShowSourceIp` response (or the console's copied source-IP +list) and the origin firewall allowlist. Stop if any of the three sets differ. + +```bash +ssh lwb 'set -eu +install -m 0644 \ + /srv/bitfun-skin-market/app/deploy/skin-market/nginx-skin-market-http.conf \ + /etc/nginx/conf.d/skin-market.conf +install -m 0644 \ + /srv/bitfun-skin-market/app/deploy/miniapp-market/nginx-market.openbitfun.com.conf \ + /etc/nginx/sites-available/market.openbitfun.com.conf +nginx -t +systemctl reload nginx +curl -fsS http://127.0.0.1:9720/skin/api/v1/health' +curl -fsS https://market.openbitfun.com/skin/api/v1/health +curl -fsS https://market.openbitfun.com/skin/ >/dev/null +``` + +The domain and wildcard TLS certificate already cover this path; no new DNS +record is needed. Verify any upstream WAF body-size policy before publishing a +package near the 96 MiB application limit. + +## Rollback + +For a backward-compatible release, detach the dedicated checkout at +`$PREVIOUS_COMMIT`, build if its image is absent, and recreate only +`skin-market`. Repeat all health and revision checks. Use a leased push to move +`skin-market-deploy` back afterwards. If the new binary applied a migration an +older binary cannot read, stop and design an explicit data rollback; restoring +a backup can discard submissions created after that snapshot. + +Daily backups retain 14 snapshots and Sunday snapshots retain 8 weeks. The +restore drill validates same-host recovery only; it is not off-site disaster +recovery. diff --git a/deploy/skin-market/backup.sh b/deploy/skin-market/backup.sh new file mode 100755 index 0000000000..a860a98ec3 --- /dev/null +++ b/deploy/skin-market/backup.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly MARKET_ROOT="/srv/bitfun-skin-market" +readonly DATA_ROOT="${MARKET_ROOT}/data" +readonly ARTIFACT_ROOT="${MARKET_ROOT}/artifacts" +readonly BACKUP_ROOT="${MARKET_ROOT}/backups" +readonly DAILY_ROOT="${BACKUP_ROOT}/daily" +readonly WEEKLY_ROOT="${BACKUP_ROOT}/weekly" +readonly TODAY="$(date -u +%F)" +readonly WEEK="$(date -u +%G-W%V)" +readonly DAILY_TARGET="${DAILY_ROOT}/${TODAY}" +readonly DATABASE="${DATA_ROOT}/market.sqlite" + +if [[ "${MARKET_ROOT}" != "/srv/bitfun-skin-market" ]]; then + echo "Unexpected backup root" >&2 + exit 1 +fi + +install -d -m 0750 "${DAILY_ROOT}" "${WEEKLY_ROOT}" +if [[ ! -f "${DATABASE}" ]]; then + echo "Skin market database does not exist; refusing to create an empty backup source" >&2 + exit 1 +fi +if [[ "$(stat -c %u "${DATABASE}")" != "10002" ]]; then + echo "Skin market database must be owned by runtime UID 10002" >&2 + exit 1 +fi +if [[ "$(sqlite3 "${DATABASE}" "PRAGMA quick_check;")" != "ok" ]]; then + echo "Skin market source database failed quick_check" >&2 + exit 1 +fi +if [[ -e "${DAILY_TARGET}" ]]; then + if [[ ! -d "${DAILY_TARGET}" ]]; then + echo "Daily backup target exists but is not a directory" >&2 + exit 1 + fi + ( + cd "${DAILY_TARGET}" + sha256sum -c SHA256SUMS + ) + if [[ "$(sqlite3 "${DAILY_TARGET}/market.sqlite" "PRAGMA integrity_check;")" != "ok" ]]; then + echo "Existing daily backup failed integrity_check" >&2 + exit 1 + fi + echo "Verified existing Skin market backup: ${DAILY_TARGET}" + exit 0 +fi + +readonly STAGING_TARGET="$(mktemp -d "${DAILY_ROOT}/.${TODAY}.staging.XXXXXX")" +cleanup_staging() { + if [[ -d "${STAGING_TARGET}" && "${STAGING_TARGET}" == "${DAILY_ROOT}/.${TODAY}.staging."* ]]; then + rm -rf -- "${STAGING_TARGET}" + fi +} +trap cleanup_staging EXIT + +sqlite3 "${DATABASE}" ".timeout 10000" ".backup '${STAGING_TARGET}/market.sqlite'" + +readonly INTEGRITY="$(sqlite3 "${STAGING_TARGET}/market.sqlite" "PRAGMA integrity_check;")" +if [[ "${INTEGRITY}" != "ok" ]]; then + echo "SQLite integrity check failed: ${INTEGRITY}" >&2 + exit 1 +fi + +tar -C "${ARTIFACT_ROOT}" -czf "${STAGING_TARGET}/artifacts.tar.gz" . +( + cd "${STAGING_TARGET}" + sha256sum market.sqlite artifacts.tar.gz > SHA256SUMS +) +mv "${STAGING_TARGET}" "${DAILY_TARGET}" +trap - EXIT + +if [[ "$(date -u +%u)" == "7" ]]; then + readonly WEEKLY_TARGET="${WEEKLY_ROOT}/${WEEK}" + if [[ ! -e "${WEEKLY_TARGET}" ]]; then + install -d -m 0750 "${WEEKLY_TARGET}" + cp --reflink=auto "${DAILY_TARGET}/market.sqlite" "${WEEKLY_TARGET}/market.sqlite" + cp --reflink=auto "${DAILY_TARGET}/artifacts.tar.gz" "${WEEKLY_TARGET}/artifacts.tar.gz" + cp "${DAILY_TARGET}/SHA256SUMS" "${WEEKLY_TARGET}/SHA256SUMS" + fi +fi + +prune_backups() { + local root="$1" + local keep="$2" + local candidate + + while IFS= read -r candidate; do + [[ "${candidate}" == "${root}/"* ]] || { + echo "Refusing to prune unexpected path: ${candidate}" >&2 + exit 1 + } + rm -rf -- "${candidate}" + done < <(find "${root}" -mindepth 1 -maxdepth 1 -type d -print | sort -r | tail -n "+$((keep + 1))") +} + +prune_backups "${DAILY_ROOT}" 14 +prune_backups "${WEEKLY_ROOT}" 8 + +echo "Skin market backup completed: ${DAILY_TARGET}" diff --git a/deploy/skin-market/bitfun-skin-market-backup.service b/deploy/skin-market/bitfun-skin-market-backup.service new file mode 100644 index 0000000000..15140e81d9 --- /dev/null +++ b/deploy/skin-market/bitfun-skin-market-backup.service @@ -0,0 +1,12 @@ +[Unit] +Description=Back up BitFun Skin market data +After=docker.service + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/bitfun-skin-market-backup +User=root +Group=root +Nice=10 +IOSchedulingClass=best-effort +IOSchedulingPriority=7 diff --git a/deploy/skin-market/bitfun-skin-market-backup.timer b/deploy/skin-market/bitfun-skin-market-backup.timer new file mode 100644 index 0000000000..461d43d765 --- /dev/null +++ b/deploy/skin-market/bitfun-skin-market-backup.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Daily BitFun Skin market backup + +[Timer] +OnCalendar=*-*-* 03:37:00 UTC +Persistent=true +RandomizedDelaySec=900 +Unit=bitfun-skin-market-backup.service + +[Install] +WantedBy=timers.target diff --git a/deploy/skin-market/docker-compose.yml b/deploy/skin-market/docker-compose.yml new file mode 100644 index 0000000000..81256df159 --- /dev/null +++ b/deploy/skin-market/docker-compose.yml @@ -0,0 +1,41 @@ +services: + skin-market: + build: + context: ../.. + dockerfile: src/apps/skin-market-server/Dockerfile + args: + CARGO_BUILD_JOBS: ${MARKET_CARGO_BUILD_JOBS:-} + MARKET_GIT_COMMIT: ${MARKET_GIT_COMMIT:?Set MARKET_GIT_COMMIT to the deployed commit} + image: bitfun-skin-market:${MARKET_GIT_COMMIT} + container_name: bitfun-skin-market + restart: unless-stopped + user: "10002:10002" + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + ports: + - "127.0.0.1:9720:9720" + env_file: + - /etc/bitfun-skin-market/market.env + environment: + SKIN_MARKET_BIND: 0.0.0.0:9720 + SKIN_MARKET_DATABASE_PATH: /app/data/market.sqlite + SKIN_MARKET_ARTIFACT_DIR: /app/artifacts + SKIN_MARKET_WEB_DIR: /app/web + volumes: + - /srv/bitfun-skin-market/data:/app/data + - /srv/bitfun-skin-market/artifacts:/app/artifacts + tmpfs: + - /tmp:rw,noexec,nosuid,nodev,size=64m + mem_limit: 1g + cpus: 1.5 + healthcheck: + test: + - CMD-SHELL + - curl -fsS http://127.0.0.1:9720/skin/api/v1/health || exit 1 + interval: 20s + timeout: 5s + retries: 5 + start_period: 15s diff --git a/deploy/skin-market/market.env.example b/deploy/skin-market/market.env.example new file mode 100644 index 0000000000..1de17426d6 --- /dev/null +++ b/deploy/skin-market/market.env.example @@ -0,0 +1,5 @@ +SKIN_MARKET_PUBLIC_BASE_URL=https://market.openbitfun.com/skin +SKIN_MARKET_IDENTITY_ME_URL=https://market.openbitfun.com/miniapp/api/v1/me +SKIN_MARKET_DOWNLOAD_HASH_SECRET=replace-with-at-least-32-random-characters +SKIN_MARKET_PUBLIC_BROWSE=true +RUST_LOG=bitfun_skin_market=info,tower_http=info diff --git a/deploy/skin-market/nginx-skin-market-http.conf b/deploy/skin-market/nginx-skin-market-http.conf new file mode 100644 index 0000000000..a4f9dc6a3b --- /dev/null +++ b/deploy/skin-market/nginx-skin-market-http.conf @@ -0,0 +1,31 @@ +# Loaded in Nginx's http context. Keep these networks synchronized with both +# the Huawei WAF control-plane source-IP list and the origin firewall. XFF is +# considered only when the direct peer belongs to this explicit set. +geo $skin_market_trusted_waf_peer { + default 0; + 190.92.193.0/24 1; + 159.138.94.0/24 1; + 101.44.169.0/24 1; + 2405:f080:110::/120 1; +} + +map "$skin_market_trusted_waf_peer:$http_x_forwarded_for" $skin_market_client_address { + "~^1:\s*(?[0-9]{1,3}(?:\.[0-9]{1,3}){3})(?:\s*,|\s*$)" $skin_market_client_ipv4; + "~^1:\s*(?[0-9A-Fa-f:]{2,45})(?:\s*,|\s*$)" $skin_market_client_ipv6; + default $remote_addr; +} + +map $uri $skin_market_api_limit_key { + default ""; + ~^/skin/api/v1/submissions/[^/]+/package$ ""; + ~^/skin/api/v1/ $skin_market_client_address; +} + +map $uri $skin_market_upload_limit_key { + default ""; + ~^/skin/api/v1/submissions/[^/]+/package$ $skin_market_client_address; +} + +limit_req_zone $skin_market_api_limit_key zone=skin_market_api:10m rate=10r/s; +limit_req_zone $skin_market_upload_limit_key zone=skin_market_upload:10m rate=6r/m; +limit_conn_zone $skin_market_client_address zone=skin_market_connections:10m; diff --git a/deploy/skin-market/restore-drill.sh b/deploy/skin-market/restore-drill.sh new file mode 100755 index 0000000000..b08ac9f82f --- /dev/null +++ b/deploy/skin-market/restore-drill.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly BACKUP_ROOT="/srv/bitfun-skin-market/backups" +readonly SOURCE="${1:-}" + +if [[ -z "${SOURCE}" || "${SOURCE}" != "${BACKUP_ROOT}/"* || ! -d "${SOURCE}" ]]; then + echo "Usage: $0 /srv/bitfun-skin-market/backups/{daily|weekly}/" >&2 + exit 2 +fi + +readonly DRILL_DIR="$(mktemp -d "${BACKUP_ROOT}/restore-drill.XXXXXX")" +cleanup() { + [[ "${DRILL_DIR}" == "${BACKUP_ROOT}/restore-drill."* ]] && rm -rf -- "${DRILL_DIR}" +} +trap cleanup EXIT + +( + cd "${SOURCE}" + sha256sum -c SHA256SUMS +) +cp "${SOURCE}/market.sqlite" "${DRILL_DIR}/market.sqlite" +mkdir "${DRILL_DIR}/artifacts" +tar -C "${DRILL_DIR}/artifacts" -xzf "${SOURCE}/artifacts.tar.gz" + +readonly INTEGRITY="$(sqlite3 "${DRILL_DIR}/market.sqlite" "PRAGMA integrity_check;")" +readonly TABLE_COUNT="$(sqlite3 "${DRILL_DIR}/market.sqlite" \ + "SELECT count(*) FROM sqlite_master WHERE type IN ('table','view');")" +readonly ARTIFACT_COUNT="$(find "${DRILL_DIR}/artifacts" -type f | wc -l | tr -d ' ')" + +if [[ "${INTEGRITY}" != "ok" || "${TABLE_COUNT}" -lt 1 ]]; then + echo "Restore drill failed" >&2 + exit 1 +fi + +sqlite3 -separator '|' "${DRILL_DIR}/market.sqlite" \ + "SELECT package_sha256, package_size FROM releases + UNION + SELECT package_sha256, package_size FROM submissions + WHERE package_sha256 IS NOT NULL;" > "${DRILL_DIR}/package-references" +sqlite3 "${DRILL_DIR}/market.sqlite" \ + "SELECT preview_sha256 FROM releases + UNION + SELECT preview_sha256 FROM submissions + WHERE preview_sha256 IS NOT NULL;" > "${DRILL_DIR}/preview-references" + +REFERENCE_COUNT=0 +while IFS='|' read -r sha256 expected_size; do + [[ "${sha256}" =~ ^[0-9a-f]{64}$ && "${expected_size}" =~ ^[0-9]+$ ]] || { + echo "Restore drill found an invalid package reference" >&2 + exit 1 + } + package="${DRILL_DIR}/artifacts/packages/${sha256:0:2}/${sha256}.bitfun-appearance" + [[ -f "${package}" && "$(stat -c %s "${package}")" == "${expected_size}" ]] || { + echo "Restore drill found a missing or incorrectly sized package artifact" >&2 + exit 1 + } + [[ "$(sha256sum "${package}" | awk '{print $1}')" == "${sha256}" ]] || { + echo "Restore drill found a package artifact hash mismatch" >&2 + exit 1 + } + REFERENCE_COUNT=$((REFERENCE_COUNT + 1)) +done < "${DRILL_DIR}/package-references" + +while IFS= read -r sha256; do + [[ "${sha256}" =~ ^[0-9a-f]{64}$ ]] || { + echo "Restore drill found an invalid preview reference" >&2 + exit 1 + } + preview="${DRILL_DIR}/artifacts/previews/${sha256:0:2}/${sha256}.webp" + [[ -f "${preview}" ]] || { + echo "Restore drill found a missing preview artifact" >&2 + exit 1 + } + [[ "$(sha256sum "${preview}" | awk '{print $1}')" == "${sha256}" ]] || { + echo "Restore drill found a preview artifact hash mismatch" >&2 + exit 1 + } + REFERENCE_COUNT=$((REFERENCE_COUNT + 1)) +done < "${DRILL_DIR}/preview-references" + +echo "Restore drill passed: tables=${TABLE_COUNT}, artifacts=${ARTIFACT_COUNT}" +echo "Verified database artifact references: ${REFERENCE_COUNT}" +echo "This drill validates same-host backups only; it is not off-site disaster recovery." diff --git a/docs/architecture/appearance-package-system.md b/docs/architecture/appearance-package-system.md index 4f89442307..d2e5f6bad9 100644 --- a/docs/architecture/appearance-package-system.md +++ b/docs/architecture/appearance-package-system.md @@ -417,6 +417,37 @@ IndexedDB 前完成相同 preflight;覆盖活动包时,存储、目录或后 情况。配置引用的导入包暂时不可用时,只在当前窗口回退到 `system` 并标记 `degraded`,不能把共享 配置静默改写为 `system`。 +## Skin 市场边界 + +产品在目录、设置入口和公网 URL 中把 Appearance 包称为 **Skin**;这只是产品文案,不新增 +Skin runtime、manifest 或文件格式。公网入口固定为 `market.openbitfun.com/skin/`,稳定 API 为 +`/skin/api/v1`,下载物仍是 `.bitfun-appearance`,根 manifest 仍是 `appearance.json` 和 +`schema: bitfun.appearance`。 + +市场稳定 DTO 和发布状态机由 `bitfun-product-domains::appearance_market` 拥有。独立 +`bitfun-skin-market` 服务使用自己的 SQLite、content-addressed package/preview artifacts、审核日志、 +retention 和备份;不与 MiniApp listing/release/submission 表共用 namespace。公开 Web 站只读。 +Desktop 投稿复用 MiniApp 市场系统 keyring 中的 GitHub Desktop token,Skin 服务只把 Bearer token +转发给受控的 MiniApp `/me` endpoint 做身份和管理员校验,不保存 OAuth secret、refresh token 或 +Cookie。 + +安装事务固定为:读取 listing 的不可变 release 元数据,下载 package,核对 release SHA-256 和字节 +大小,由 WebView 的 `AppearancePackageParser` 完成完整 schema/registry/media preflight,再调用 +`AppearanceService.importPackage()` 写入 IndexedDB。Rust/Tauri adapter 只负责网络和受限临时文件, +不能直接激活 Appearance 或复制一套 Style IR validator。市场来源 sidecar 绑定 package ID、listing、 +release、package SHA 和安装时间;本地文件覆盖同 ID 市场包时保留 lineage 但设置 `localOverride`,并在 +UI 明确显示为本地版本,不能继续伪装成已安装的市场 release 或静默自动更新。 + +`appearance.selection` 随正式 GlobalConfig 跨设备同步,package 字节仍只存在当前设备。snapshot 必须 +同时保留 persisted selection 和当前设备 availability:目标包缺失时本窗口回退 `system`,但不得覆写 +共享 selection;以后从市场安装同 package ID 时立即 rehydrate 并应用 persisted selection。浏览和 +安装是当前设备 UI 行为,与 workspace 本地/远程无关,绝不能把 package 发送到 SSH workspace。 + +Agent 的 outward-facing `PublishAppearance` 只接受用户明确指定的本机绝对 +`.bitfun-appearance` 路径,在上传前执行本地 package envelope 校验,并复用 Desktop 登录与投稿编排。 +远程 SSH workspace 中它必须 fail closed;Agent Runtime 不得扫描目录、猜测用户许可,或假装能读取 +WebView IndexedDB 中已安装的包。 + 编译器解析真实 RGB/HSL/hex 颜色并产生对比度诊断。诊断当前不阻止提交,但必须出现在 snapshot 和结构化日志中;未来可以按产品无障碍策略升级为 gate。 @@ -426,7 +457,7 @@ snapshot 和结构化日志中;未来可以按产品无障碍策略升级为 g 导入限制: -- ZIP 最大 96 MiB,解压后最大 128 MiB,最多 64 个文件; +- ZIP 最大 96 MiB,解压后最大 128 MiB,最多 64 个文件;市场预览还受 4 MiB、1600 万像素解码预算约束; - `appearance.json` 最大 256 KiB,静态图片最大 16 MiB,视频最大 64 MiB,preview 图片最大 4 MiB; - 只允许 manifest 明确声明的包内图片和背景视频; - 图片仅 PNG、JPEG、WebP、GIF,视频仅 MP4、WebM,均按文件头识别 MIME; diff --git a/package.json b/package.json index 40e609e629..b89ec90db4 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "dev:web": "pnpm --dir src/web-ui dev", "dev:mobile-web": "pnpm --dir src/mobile-web dev", "dev:miniapp-market": "pnpm --dir src/miniapp-market-web dev", + "dev:skin-market": "pnpm --dir src/skin-market-web dev", "dev:mobile-web:host": "pnpm --dir src/mobile-web dev --host", "preview:mobile-web": "pnpm --dir src/mobile-web preview", "lint:web": "pnpm --dir src/web-ui run lint", @@ -56,6 +57,9 @@ "build:miniapp-market": "pnpm --dir src/miniapp-market-web build", "type-check:miniapp-market": "pnpm --dir src/miniapp-market-web type-check", "test:miniapp-market": "pnpm --dir src/miniapp-market-web test", + "build:skin-market": "pnpm --dir src/skin-market-web build", + "type-check:skin-market": "pnpm --dir src/skin-market-web type-check", + "test:skin-market": "pnpm --dir src/skin-market-web test", "prepare:mobile-web": "node scripts/mobile-web-build.cjs", "frontend:build-all": "node scripts/frontend-build-all.mjs", "preview": "pnpm --dir src/web-ui preview", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fb3f55214..769d91a40a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -178,6 +178,37 @@ importers: specifier: ^8.19.0 version: 8.19.0 + src/skin-market-web: + dependencies: + '@phosphor-icons/react': + specifier: ^2.1.10 + version: 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.0 + version: 18.3.27 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.27) + '@vitejs/plugin-react': + specifier: ^4.6.0 + version: 4.7.0(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + typescript: + specifier: ~5.8.3 + version: 5.8.3 + vite: + specifier: ^7.0.4 + version: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: + specifier: ^4.1.0 + version: 4.1.0(@types/node@22.19.7)(jsdom@29.0.1(@noble/hashes@2.0.1))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + src/web-ui: dependencies: '@monaco-editor/react': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b3f05ce5c4..d49e3153d8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,5 +2,6 @@ packages: - "src/web-ui" - "src/mobile-web" - "src/miniapp-market-web" + - "src/skin-market-web" - "BitFun-Installer" - "tests/e2e" diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index 66af03cabb..99029708aa 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -206,6 +206,7 @@ const REQWEST_PACKAGE_PROFILES = new Map([ 'bitfun-cli', 'bitfun-desktop', 'bitfun-miniapp-market-service', + 'bitfun-skin-market-service', ].map((packageName) => [packageName, { dependencyFeatures: [...REQWEST_TRANSPORT_FEATURES, 'rustls'], optional: false, diff --git a/scripts/core-boundaries/rules/crate-layout.mjs b/scripts/core-boundaries/rules/crate-layout.mjs index abdd3faff8..f89af9bd12 100644 --- a/scripts/core-boundaries/rules/crate-layout.mjs +++ b/scripts/core-boundaries/rules/crate-layout.mjs @@ -23,6 +23,7 @@ export const crateLayoutRules = [ { crateName: 'services-core', layer: 'services', path: 'src/crates/services/services-core' }, { crateName: 'services-integrations', layer: 'services', path: 'src/crates/services/services-integrations' }, { crateName: 'miniapp-market-service', layer: 'services', path: 'src/crates/services/miniapp-market-service' }, + { crateName: 'skin-market-service', layer: 'services', path: 'src/crates/services/skin-market-service' }, { crateName: 'relay-service', layer: 'services', path: 'src/crates/services/relay-service' }, { crateName: 'page-function-runtime', layer: 'services', path: 'src/crates/services/page-function-runtime' }, { crateName: 'terminal', layer: 'services', path: 'src/crates/services/terminal' }, diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 9fe35cfe36..a96cda7025 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -101,6 +101,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'md5', ownerFeatures: ['product-full'] }, { depName: 'reqwest', ownerFeatures: ['ai-adapter-runtime', 'product-full'] }, { depName: 'rusqlite', ownerFeatures: ['product-full'] }, + { depName: 'semver', ownerFeatures: ['product-full'] }, { depName: 'serde_yaml', ownerFeatures: ['workspace-runtime'] }, { depName: 'similar', ownerFeatures: ['product-full'] }, { depName: 'terminal-core', ownerFeatures: ['terminal'] }, @@ -128,7 +129,7 @@ export const optionalDependencyFeatureOwnerRules = [ }, { depName: 'bitfun-agent-runtime', ownerFeatures: ['deep-research', 'hook-import'] }, { depName: 'bitfun-core-types', ownerFeatures: ['speech'] }, - { depName: 'bitfun-product-domains', ownerFeatures: ['canvas-runtime', 'function-agents', 'hook-import', 'miniapp-runtime', 'plugin-source'] }, + { depName: 'bitfun-product-domains', ownerFeatures: ['canvas-runtime', 'function-agents', 'hook-import', 'miniapp-market', 'miniapp-runtime', 'plugin-source'] }, { depName: 'bitfun-runtime-ports', ownerFeatures: ['git', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'script-tool-runtime'] }, { depName: 'bitfun-services-core', @@ -144,7 +145,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'git2', ownerFeatures: ['git'] }, { depName: 'hex', ownerFeatures: ['hook-import', 'mcp', 'miniapp-market', 'plugin-source', 'remote-connect'] }, { depName: 'hostname', ownerFeatures: ['remote-connect'] }, - { depName: 'image', ownerFeatures: ['remote-connect'] }, + { depName: 'image', ownerFeatures: ['miniapp-market', 'remote-connect'] }, { depName: 'local-ip-address', ownerFeatures: ['remote-connect'] }, { depName: 'libc', ownerFeatures: ['plugin-source'] }, { depName: 'mac_address', ownerFeatures: ['remote-connect'] }, @@ -188,8 +189,8 @@ export const optionalDependencyFeatureOwnerRules = [ dependencies: [ { depName: 'dirs', ownerFeatures: ['miniapp'] }, { depName: 'log', ownerFeatures: ['function-agents'] }, - { depName: 'hex', ownerFeatures: ['external-sources', 'miniapp', 'plugin-source'] }, - { depName: 'sha2', ownerFeatures: ['external-sources', 'miniapp', 'plugin-source'] }, + { depName: 'hex', ownerFeatures: ['appearance-market', 'external-sources', 'miniapp', 'plugin-source'] }, + { depName: 'sha2', ownerFeatures: ['appearance-market', 'external-sources', 'miniapp', 'plugin-source'] }, { depName: 'which', ownerFeatures: ['miniapp'] }, ], }, @@ -502,6 +503,6 @@ export const ownerCrateFeatureAssemblyRules = [ { manifestPath: 'src/crates/contracts/product-domains/Cargo.toml', reason: 'product-domains must keep product domain feature groups explicit and default-light', - requiredProductFullFeatures: ['plugin-source', 'miniapp', 'function-agents', 'external-sources'], + requiredProductFullFeatures: ['appearance-market', 'plugin-source', 'miniapp', 'function-agents', 'external-sources'], }, ]; diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 9020689cdb..b7a2ec8746 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -18,6 +18,7 @@ export const requiredContentRules = [ 'src/apps/desktop/Cargo.toml', 'src/crates/adapters/ai-adapters/Cargo.toml', 'src/crates/services/miniapp-market-service/Cargo.toml', + 'src/crates/services/skin-market-service/Cargo.toml', ].map((path) => ({ path, reason: 'first-party Reqwest client owners must select the repository TLS backend explicitly', diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 4f6b0a0620..816b78b3c7 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -817,6 +817,7 @@ export function runManifestParserSelfTest({ 'src/apps/desktop/Cargo.toml', 'src/crates/adapters/ai-adapters/Cargo.toml', 'src/crates/services/miniapp-market-service/Cargo.toml', + 'src/crates/services/skin-market-service/Cargo.toml', ]) { const reqwestRule = requiredContentRules.find((rule) => rule.path === path); const reqwestRuleText = reqwestRule?.patterns @@ -933,6 +934,14 @@ export function runManifestParserSelfTest({ throw new Error(`services-integrations plugin-source must own optional dependency ${dep}`); } } + for (const dep of ['bitfun-product-domains', 'image']) { + const owner = servicesOptionalOwnerRule?.dependencies.find( + (dependency) => dependency.depName === dep, + ); + if (!owner?.ownerFeatures.includes('miniapp-market')) { + throw new Error(`services-integrations miniapp-market must own optional dependency ${dep}`); + } + } for (const dep of ['sha2', 'windows']) { const owner = servicesOptionalOwnerRule?.dependencies.find( (dependency) => dependency.depName === dep, diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index e0eb53b7e5..c9bf14efcc 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -21,6 +21,14 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "restart_app", "check_for_updates", "install_update", + "appearance_market_browse", + "appearance_market_download_release", + "appearance_market_get_listing", + "appearance_market_get_review_submission", + "appearance_market_list_review_submissions", + "appearance_market_list_submissions", + "appearance_market_review_submission", + "appearance_market_withdraw_submission", "account_login", "account_finalize_login", "account_logout", diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 934dacbde3..30caf999b4 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -23,7 +23,7 @@ bitfun-core = { path = "../../crates/assembly/core", default-features = false, f bitfun-relay-service = { path = "../../crates/services/relay-service" } bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime" } bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports" } -bitfun-product-domains = { path = "../../crates/contracts/product-domains", default-features = false } +bitfun-product-domains = { path = "../../crates/contracts/product-domains", default-features = false, features = ["appearance-market"] } bitfun-services-integrations = { path = "../../crates/services/services-integrations", default-features = false, features = ["canvas-runtime", "miniapp-market", "speech"] } bitfun-core-types = { path = "../../crates/contracts/core-types" } bitfun-agent-tools = { path = "../../crates/execution/tool-contracts" } @@ -80,6 +80,7 @@ async-trait = { workspace = true } axum = { workspace = true } tower-http = { workspace = true } sha1 = { workspace = true } +sha2 = { workspace = true } screenshots = { workspace = true } enigo = { workspace = true } image = { workspace = true } diff --git a/src/apps/desktop/src/api/appearance_market_api.rs b/src/apps/desktop/src/api/appearance_market_api.rs new file mode 100644 index 0000000000..31863d478a --- /dev/null +++ b/src/apps/desktop/src/api/appearance_market_api.rs @@ -0,0 +1,263 @@ +//! Read-only Appearance marketplace commands and reviewed package download. +//! +//! Package installation remains in the WebView-owned Appearance runtime. The +//! desktop boundary fetches public metadata and returns reviewed ZIP bytes as +//! a raw IPC response after enforcing release identity, version, size and hash. + +use bitfun_product_domains::appearance_market::{ + validate_appearance_market_slug, AppearanceAdminSubmissionDetail, AppearanceCursorPage, + AppearanceMarketListingDetail, AppearanceMarketListingSummary, AppearanceMarketRelease, + AppearanceMarketSubmission, AppearanceMarketSubmissionStatus, AppearanceReviewDecision, + AppearanceReviewDecisionRequest, APPEARANCE_MARKET_MAX_PACKAGE_BYTES, +}; +use bitfun_services_integrations::appearance_market::{ + AppearanceMarketBrowseRequest, AppearanceMarketClient, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppearanceMarketSlugRequest { + pub slug: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppearanceMarketDownloadRequest { + pub slug: String, + pub release_number: u32, + pub package_id: String, + pub package_version: String, + pub package_sha256: String, + pub package_size: u64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppearanceMarketSubmissionIdRequest { + pub submission_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppearanceMarketReviewRequest { + pub submission_id: String, + pub decision: AppearanceReviewDecision, + #[serde(default)] + pub reason: String, +} + +#[tauri::command] +pub async fn appearance_market_browse( + request: AppearanceMarketBrowseRequest, +) -> Result, String> { + let client = AppearanceMarketClient::from_environment() + .await + .map_err(market_error)?; + client.browse(&request).await.map_err(market_error) +} + +#[tauri::command] +pub async fn appearance_market_get_listing( + request: AppearanceMarketSlugRequest, +) -> Result { + validate_slug(&request.slug)?; + let client = AppearanceMarketClient::from_environment() + .await + .map_err(market_error)?; + client.listing(&request.slug).await.map_err(market_error) +} + +#[tauri::command] +pub async fn appearance_market_download_release( + request: AppearanceMarketDownloadRequest, +) -> Result { + validate_slug(&request.slug)?; + let client = AppearanceMarketClient::from_environment() + .await + .map_err(market_error)?; + let detail = client.listing(&request.slug).await.map_err(market_error)?; + let release = find_release(&detail, request.release_number)?; + if release.listing_id != detail.summary.listing_id + || detail.summary.package_id != request.package_id + || release.package_version != request.package_version + || release.package_sha256 != request.package_sha256 + || release.package_size != request.package_size + { + return Err( + "The Appearance release changed after it was opened. Refresh Skin Market and try again." + .to_string(), + ); + } + validate_minimum_bitfun_version(&release.min_bitfun_version)?; + if release.yanked { + return Err("This Appearance release has been yanked and cannot be installed.".to_string()); + } + if release.package_size > APPEARANCE_MARKET_MAX_PACKAGE_BYTES { + return Err("The reviewed Appearance package exceeds the 96 MiB limit.".to_string()); + } + + let bytes = client + .download_release(&detail.summary.slug, release.release_number) + .await + .map_err(market_error)?; + verify_downloaded_package(&bytes, release.package_size, &release.package_sha256)?; + + // `tauri::ipc::Response` preserves a raw binary response. Returning Vec + // directly would otherwise be serialized as a large JSON number array. + Ok(tauri::ipc::Response::new(bytes)) +} + +#[tauri::command] +pub async fn appearance_market_list_submissions() -> Result, String> +{ + let mut client = AppearanceMarketClient::from_environment() + .await + .map_err(market_error)?; + client.list_submissions().await.map_err(market_error) +} + +#[tauri::command] +pub async fn appearance_market_withdraw_submission( + request: AppearanceMarketSubmissionIdRequest, +) -> Result { + let mut client = AppearanceMarketClient::from_environment() + .await + .map_err(market_error)?; + client + .withdraw_submission(&request.submission_id) + .await + .map_err(market_error) +} + +#[tauri::command] +pub async fn appearance_market_list_review_submissions( +) -> Result, String> { + let mut client = AppearanceMarketClient::from_environment() + .await + .map_err(market_error)?; + client + .list_admin_submissions(AppearanceMarketSubmissionStatus::Submitted) + .await + .map_err(market_error) +} + +#[tauri::command] +pub async fn appearance_market_get_review_submission( + request: AppearanceMarketSubmissionIdRequest, +) -> Result { + let mut client = AppearanceMarketClient::from_environment() + .await + .map_err(market_error)?; + client + .admin_submission(&request.submission_id) + .await + .map_err(market_error) +} + +#[tauri::command] +pub async fn appearance_market_review_submission( + request: AppearanceMarketReviewRequest, +) -> Result { + let mut client = AppearanceMarketClient::from_environment() + .await + .map_err(market_error)?; + client + .review_submission( + &request.submission_id, + &AppearanceReviewDecisionRequest { + decision: request.decision, + reason: request.reason, + }, + ) + .await + .map_err(market_error) +} + +fn validate_slug(slug: &str) -> Result<(), String> { + if validate_appearance_market_slug(slug) { + Ok(()) + } else { + Err("Invalid Appearance market listing slug.".to_string()) + } +} + +fn find_release( + detail: &AppearanceMarketListingDetail, + release_number: u32, +) -> Result<&AppearanceMarketRelease, String> { + detail + .releases + .iter() + .find(|release| release.release_number == release_number) + .ok_or_else(|| "Appearance market release not found.".to_string()) +} + +fn validate_minimum_bitfun_version(minimum: &str) -> Result<(), String> { + let minimum = semver::Version::parse(minimum) + .map_err(|_| "The release declares an invalid minimum BitFun version.".to_string())?; + let current = semver::Version::parse(env!("CARGO_PKG_VERSION")) + .map_err(|_| "The current BitFun version is invalid.".to_string())?; + if current < minimum { + return Err(format!( + "This Appearance requires BitFun {minimum} or newer. Current version: {current}." + )); + } + Ok(()) +} + +fn verify_downloaded_package( + bytes: &[u8], + expected_size: u64, + expected_sha256: &str, +) -> Result<(), String> { + let actual_size = bytes.len() as u64; + if actual_size > APPEARANCE_MARKET_MAX_PACKAGE_BYTES { + return Err("The downloaded Appearance package exceeds the 96 MiB limit.".to_string()); + } + if actual_size != expected_size { + return Err( + "The downloaded Appearance package size does not match the reviewed release." + .to_string(), + ); + } + let actual_sha256 = format!("{:x}", Sha256::digest(bytes)); + if !actual_sha256.eq_ignore_ascii_case(expected_sha256) { + return Err( + "The downloaded Appearance package hash does not match the reviewed release." + .to_string(), + ); + } + Ok(()) +} + +fn market_error(error: impl Serialize + std::fmt::Display) -> String { + serde_json::to_string(&error).unwrap_or_else(|_| error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verifies_reviewed_download_size_and_hash() { + let bytes = b"reviewed appearance"; + let sha256 = format!("{:x}", Sha256::digest(bytes)); + assert!(verify_downloaded_package(bytes, bytes.len() as u64, &sha256).is_ok()); + assert!(verify_downloaded_package(bytes, 1, &sha256) + .unwrap_err() + .contains("size")); + assert!( + verify_downloaded_package(bytes, bytes.len() as u64, &"0".repeat(64)) + .unwrap_err() + .contains("hash") + ); + } + + #[test] + fn rejects_invalid_slugs_before_network_access() { + assert!(validate_slug("tokyo-night").is_ok()); + assert!(validate_slug("../secret").is_err()); + } +} diff --git a/src/apps/desktop/src/api/miniapp_market_api.rs b/src/apps/desktop/src/api/miniapp_market_api.rs index d9c112d884..002bc12c83 100644 --- a/src/apps/desktop/src/api/miniapp_market_api.rs +++ b/src/apps/desktop/src/api/miniapp_market_api.rs @@ -17,16 +17,51 @@ use bitfun_product_domains::miniapp::market::{ }; use bitfun_services_integrations::miniapp_market::{ submit_installed_app, validate_market_package, DesktopAuthPollRequest, DesktopAuthPollResponse, - DesktopAuthStart, FavoriteAggregate, MarketBrowseRequest, MarketClient, MarketMe, - RatingAggregate, ValidatedMarketPackage, + FavoriteAggregate, MarketBrowseRequest, MarketClient, MarketMe, RatingAggregate, + ValidatedMarketPackage, }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use std::time::{SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Emitter, Manager, State, WebviewWindow}; use tokio::io::AsyncWriteExt; +use tokio::sync::Mutex; const MARKET_UPLOAD_PROGRESS_EVENT: &str = "miniapp-market-upload-progress"; +const MARKET_ACCOUNT_CHANGED_EVENT: &str = "miniapp-market-account-changed"; + +#[derive(Debug, Clone)] +struct PendingDesktopAuth { + request: DesktopAuthPollRequest, + expires_at: i64, +} + +fn pending_desktop_auth() -> &'static Mutex> { + static PENDING: OnceLock>> = OnceLock::new(); + PENDING.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopAuthStartView { + pub transaction_id: String, + pub authorization_url: String, + pub expires_at: i64, + pub poll_interval_seconds: u32, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopAuthPollViewRequest { + pub transaction_id: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DesktopAuthPollViewResponse { + pub status: String, +} #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -152,24 +187,69 @@ pub async fn miniapp_market_get_listing( } #[tauri::command] -pub async fn miniapp_market_auth_start() -> Result { +pub async fn miniapp_market_auth_start() -> Result { let client = MarketClient::from_environment() .await .map_err(market_error)?; - client.start_desktop_auth().await.map_err(market_error) + let started = client.start_desktop_auth().await.map_err(market_error)?; + let mut pending = pending_desktop_auth().lock().await; + let now = unix_now(); + pending.retain(|_, transaction| transaction.expires_at > now); + pending.insert( + started.transaction_id.clone(), + PendingDesktopAuth { + request: DesktopAuthPollRequest { + transaction_id: started.transaction_id.clone(), + transaction_secret: started.transaction_secret, + }, + expires_at: started.expires_at, + }, + ); + Ok(DesktopAuthStartView { + transaction_id: started.transaction_id, + authorization_url: started.authorization_url, + expires_at: started.expires_at, + poll_interval_seconds: started.poll_interval_seconds, + }) } #[tauri::command] pub async fn miniapp_market_auth_poll( - request: DesktopAuthPollRequest, -) -> Result { + app: AppHandle, + request: DesktopAuthPollViewRequest, +) -> Result { + let pending = { + let mut transactions = pending_desktop_auth().lock().await; + let Some(pending) = transactions.get(&request.transaction_id).cloned() else { + return Err("Desktop market authorization transaction was not found.".to_string()); + }; + if pending.expires_at <= unix_now() { + transactions.remove(&request.transaction_id); + return Ok(DesktopAuthPollViewResponse { + status: "expired".to_string(), + }); + } + pending + }; let mut client = MarketClient::from_environment() .await .map_err(market_error)?; - client - .poll_desktop_auth(&request) + let response: DesktopAuthPollResponse = client + .poll_desktop_auth(&pending.request) .await - .map_err(market_error) + .map_err(market_error)?; + if matches!(response.status.as_str(), "authorized" | "expired") { + pending_desktop_auth() + .lock() + .await + .remove(&request.transaction_id); + } + if response.status == "authorized" { + emit_market_account_changed(&app, "signed-in"); + } + Ok(DesktopAuthPollViewResponse { + status: response.status, + }) } #[tauri::command] @@ -181,11 +261,13 @@ pub async fn miniapp_market_me() -> Result, String> { } #[tauri::command] -pub async fn miniapp_market_logout() -> Result<(), String> { +pub async fn miniapp_market_logout(app: AppHandle) -> Result<(), String> { let mut client = MarketClient::from_environment() .await .map_err(market_error)?; - client.logout().await.map_err(market_error) + client.logout().await.map_err(market_error)?; + emit_market_account_changed(&app, "signed-out"); + Ok(()) } #[tauri::command] @@ -728,6 +810,45 @@ fn emit_upload_progress( ); } +fn emit_market_account_changed(app: &AppHandle, status: &'static str) { + let _ = app.emit( + MARKET_ACCOUNT_CHANGED_EVENT, + serde_json::json!({ "status": status }), + ); +} + +fn unix_now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0) +} + fn market_error(error: impl Serialize + std::fmt::Display) -> String { serde_json::to_string(&error).unwrap_or_else(|_| error.to_string()) } + +#[cfg(test)] +mod tests { + use super::{DesktopAuthPollViewResponse, DesktopAuthStartView}; + + #[test] + fn desktop_auth_views_never_serialize_oauth_secrets() { + let started = serde_json::to_value(DesktopAuthStartView { + transaction_id: "transaction-1".to_string(), + authorization_url: "https://github.com/login/oauth/authorize".to_string(), + expires_at: 123, + poll_interval_seconds: 3, + }) + .unwrap(); + assert!(started.get("transactionSecret").is_none()); + + let polled = serde_json::to_value(DesktopAuthPollViewResponse { + status: "authorized".to_string(), + }) + .unwrap(); + assert!(polled.get("tokens").is_none()); + assert!(polled.get("accessToken").is_none()); + assert!(polled.get("refreshToken").is_none()); + } +} diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 8b471682bc..f11a5ef845 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -4,6 +4,7 @@ pub mod acp_client_api; pub mod agentic_api; pub mod announcement_api; pub mod app_state; +pub mod appearance_market_api; pub mod browser_api; pub mod browser_control_api; pub mod btw_api; diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index 3ee3077b59..ca58998d4f 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -43,6 +43,14 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "restart_app", "check_for_updates", "install_update", + "appearance_market_browse", + "appearance_market_download_release", + "appearance_market_get_listing", + "appearance_market_get_review_submission", + "appearance_market_list_review_submissions", + "appearance_market_list_submissions", + "appearance_market_review_submission", + "appearance_market_withdraw_submission", // Account identity / peer mode control (stay on controller) "account_login", "account_finalize_login", @@ -449,6 +457,22 @@ mod tests { ); } + #[test] + fn appearance_market_stays_on_the_controller_device() { + for command in [ + "appearance_market_browse", + "appearance_market_get_listing", + "appearance_market_download_release", + "appearance_market_get_review_submission", + "appearance_market_list_review_submissions", + "appearance_market_list_submissions", + "appearance_market_review_submission", + "appearance_market_withdraw_submission", + ] { + assert!(is_local_only_command(command), "{command}"); + } + } + #[test] fn only_the_final_detach_drains_peer_permission_requests() { let mut state = control_state(&["controller-a", "controller-b"], &["request-1"]); diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 7a28e610cd..52c9c3bee2 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -147,6 +147,38 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "append_flow_chat_diagnostics", RemoteWorkspacePolicy::LocalOnly, ), + ( + "appearance_market_browse", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "appearance_market_download_release", + RemoteWorkspacePolicy::LocalOnly, + ), + ( + "appearance_market_get_listing", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "appearance_market_get_review_submission", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "appearance_market_list_review_submissions", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "appearance_market_list_submissions", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "appearance_market_review_submission", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "appearance_market_withdraw_submission", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), ( "account_token_expired", RemoteWorkspacePolicy::WorkspaceAgnostic, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 7adfa5a95f..06be1162d3 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1688,6 +1688,14 @@ pub async fn run() { api::miniapp_market_api::miniapp_market_import_package, api::miniapp_market_api::miniapp_market_inspect_package, api::miniapp_market_api::miniapp_market_submit_installed, + api::appearance_market_api::appearance_market_browse, + api::appearance_market_api::appearance_market_get_listing, + api::appearance_market_api::appearance_market_download_release, + api::appearance_market_api::appearance_market_list_submissions, + api::appearance_market_api::appearance_market_withdraw_submission, + api::appearance_market_api::appearance_market_list_review_submissions, + api::appearance_market_api::appearance_market_get_review_submission, + api::appearance_market_api::appearance_market_review_submission, api::miniapp_api::miniapp_ai_complete, api::miniapp_api::miniapp_ai_chat, api::miniapp_api::miniapp_ai_cancel, diff --git a/src/apps/skin-market-server/Cargo.toml b/src/apps/skin-market-server/Cargo.toml new file mode 100644 index 0000000000..f134b1bc1e --- /dev/null +++ b/src/apps/skin-market-server/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "bitfun-skin-market-server" +version.workspace = true +authors.workspace = true +edition.workspace = true +description = "BitFun appearance marketplace HTTP server" + +[[bin]] +name = "bitfun-skin-market-server" +path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +axum = { workspace = true } +bitfun-skin-market-service = { path = "../../crates/services/skin-market-service" } +tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "signal"] } +tracing = { workspace = true } +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } + +[lints] +workspace = true diff --git a/src/apps/skin-market-server/Dockerfile b/src/apps/skin-market-server/Dockerfile new file mode 100644 index 0000000000..fe90a46c9d --- /dev/null +++ b/src/apps/skin-market-server/Dockerfile @@ -0,0 +1,75 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:22-bookworm-slim AS web-builder + +WORKDIR /build +RUN corepack enable && corepack prepare pnpm@10.15.0 --activate + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +COPY src/skin-market-web/package.json src/skin-market-web/package.json +RUN --mount=type=cache,id=bitfun-skin-market-pnpm,target=/root/.local/share/pnpm/store,sharing=locked \ + pnpm install --frozen-lockfile --ignore-scripts --filter bitfun-skin-market-web + +COPY src/skin-market-web/ src/skin-market-web/ +COPY src/shared/appearance-market-contract-fixtures/ src/shared/appearance-market-contract-fixtures/ +RUN pnpm --dir src/skin-market-web build + +FROM rust:1.96-bookworm AS rust-builder + +WORKDIR /build +ARG CARGO_BUILD_JOBS= +ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \ + CARGO_TERM_COLOR=always + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + cmake \ + libssl-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +COPY Cargo.toml Cargo.lock ./ +COPY src/ src/ + +RUN --mount=type=cache,id=bitfun-skin-market-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \ + --mount=type=cache,id=bitfun-skin-market-cargo-git,target=/usr/local/cargo/git,sharing=locked \ + --mount=type=cache,id=bitfun-skin-market-target,target=/build/target,sharing=locked \ + { [ -n "${CARGO_BUILD_JOBS:-}" ] || unset CARGO_BUILD_JOBS; } \ + && cargo build --locked --release -p bitfun-skin-market-server \ + && cp target/release/bitfun-skin-market-server /tmp/bitfun-skin-market-server \ + && strip /tmp/bitfun-skin-market-server + +FROM debian:bookworm-slim AS runtime + +ARG MARKET_GIT_COMMIT=unknown +LABEL org.opencontainers.image.title="BitFun Skin Market" \ + org.opencontainers.image.revision="${MARKET_GIT_COMMIT}" + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl libssl3 \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --gid 10002 market \ + && useradd --uid 10002 --gid 10002 --no-create-home --shell /usr/sbin/nologin market + +WORKDIR /app +COPY --from=rust-builder /tmp/bitfun-skin-market-server /app/bitfun-skin-market-server +COPY --from=web-builder /build/src/skin-market-web/dist/ /app/web/ + +RUN mkdir -p /app/data /app/artifacts \ + && chown -R market:market /app/data /app/artifacts + +USER 10002:10002 + +ENV SKIN_MARKET_BIND=0.0.0.0:9720 \ + SKIN_MARKET_DATABASE_PATH=/app/data/market.sqlite \ + SKIN_MARKET_ARTIFACT_DIR=/app/artifacts \ + SKIN_MARKET_WEB_DIR=/app/web + +EXPOSE 9720 + +HEALTHCHECK --interval=20s --timeout=5s --start-period=15s --retries=5 \ + CMD curl -fsS http://127.0.0.1:9720/skin/api/v1/health || exit 1 + +ENTRYPOINT ["/app/bitfun-skin-market-server"] diff --git a/src/apps/skin-market-server/Dockerfile.dockerignore b/src/apps/skin-market-server/Dockerfile.dockerignore new file mode 100644 index 0000000000..a76076aec2 --- /dev/null +++ b/src/apps/skin-market-server/Dockerfile.dockerignore @@ -0,0 +1,25 @@ +** + +!Cargo.toml +!Cargo.lock +!package.json +!pnpm-lock.yaml +!pnpm-workspace.yaml +!.npmrc + +!src/ +!src/**/ +!src/**/Cargo.toml +!src/**/build.rs +!src/**/src/** +!src/crates/services/skin-market-service/migrations/** +!src/skin-market-web/** +!src/shared/appearance-market-contract-fixtures/** + +**/node_modules +**/dist +!src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/dist/ +!src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/dist/ui.bundle.js +**/target +src/mobile-web/dist +src/web-ui/public/monaco-editor diff --git a/src/apps/skin-market-server/src/main.rs b/src/apps/skin-market-server/src/main.rs new file mode 100644 index 0000000000..8046993335 --- /dev/null +++ b/src/apps/skin-market-server/src/main.rs @@ -0,0 +1,47 @@ +use anyhow::Context; +use bitfun_skin_market_service::build_skin_market_router; +use bitfun_skin_market_service::config::SkinMarketConfig; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .json() + .with_env_filter( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("bitfun_skin_market=info,tower_http=info")), + ) + .init(); + + let config = SkinMarketConfig::from_env()?; + let bind = config.bind; + let app = build_skin_market_router(config).await?; + let listener = tokio::net::TcpListener::bind(bind) + .await + .with_context(|| format!("failed to bind appearance market server on {bind}"))?; + tracing::info!(address = %bind, "Appearance market server started"); + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await + .context("Appearance market server stopped unexpectedly") +} + +async fn shutdown_signal() { + let ctrl_c = async { + let _ = tokio::signal::ctrl_c().await; + }; + #[cfg(unix)] + let terminate = async { + use tokio::signal::unix::{signal, SignalKind}; + if let Ok(mut signal) = signal(SignalKind::terminate()) { + signal.recv().await; + } + }; + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {} + _ = terminate => {} + } +} diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index beeaa4dd4e..5d534ad31d 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -40,6 +40,7 @@ dashmap = { workspace = true, optional = true } indexmap = { workspace = true, optional = true } reqwest = { workspace = true, optional = true } +semver = { workspace = true, optional = true } # Debug Log HTTP Server axum = { workspace = true, optional = true } @@ -151,6 +152,7 @@ product-full = [ "dep:image", "dep:md5", "dep:reqwest", + "dep:semver", "dep:rusqlite", "dep:similar", "dep:tokio-tungstenite", diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs index 944a91d40c..eb4135dc44 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs @@ -45,6 +45,7 @@ impl ClawMode { "InitMiniApp".to_string(), "FinalizeMiniApp".to_string(), "PublishMiniApp".to_string(), + "PublishAppearance".to_string(), "PageDeploy".to_string(), "PagePublish".to_string(), ], diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs index 281e0da177..d94da2f301 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs @@ -56,6 +56,7 @@ impl CoworkMode { "InitMiniApp".to_string(), "FinalizeMiniApp".to_string(), "PublishMiniApp".to_string(), + "PublishAppearance".to_string(), ], } } diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index e1fa6c4e1b..1b976ce94e 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -135,6 +135,7 @@ pub fn shared_coding_mode_tools() -> Vec { "InitMiniApp".to_string(), "FinalizeMiniApp".to_string(), "PublishMiniApp".to_string(), + "PublishAppearance".to_string(), "PageDeploy".to_string(), "PagePublish".to_string(), ]; diff --git a/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md b/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md index 208f98e985..1c1e6fb396 100644 --- a/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md +++ b/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md @@ -46,6 +46,7 @@ Notes: | `InitMiniApp` | Direct | None | - | | `FinalizeMiniApp` | Direct | None | - | | `PublishMiniApp` | Direct | None | - | +| `PublishAppearance` | Direct | None | - | | `ControlHub` | Deferred | `ComputerUse` | Direct | | `ComputerUse` | Deferred | `ComputerUse` | Direct | | `Playbook` | Deferred | None | - | diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/appearance_publish_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/appearance_publish_tool.rs new file mode 100644 index 0000000000..3ce1888a6d --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/appearance_publish_tool.rs @@ -0,0 +1,545 @@ +//! PublishAppearance tool — submit a local `.bitfun-appearance` archive to +//! the public Skin market for human review. + +use crate::agentic::tools::framework::{PermissionIntent, Tool, ToolResult, ToolUseContext}; +use crate::infrastructure::events::{emit_global_event, BackendEvent}; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use bitfun_product_domains::appearance_market::{ + validate_appearance_market_slug, AppearanceMarketLicense, + AppearanceMarketSubmissionDraftRequest, +}; +use bitfun_services_integrations::appearance_market::{ + resolve_appearance_release_target, submit_appearance_package, suggest_appearance_slug, + AppearanceMarketClient, AppearanceReleaseTarget, +}; +use bitfun_services_integrations::miniapp_market::DesktopAuthPollRequest; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +pub struct PublishAppearanceTool; + +impl PublishAppearanceTool { + pub fn new() -> Self { + Self + } +} + +impl Default for PublishAppearanceTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for PublishAppearanceTool { + fn name(&self) -> &str { + "PublishAppearance" + } + + async fn description(&self) -> BitFunResult { + Ok(r#"Submit a local `.bitfun-appearance` package to the BitFun Skin market for human review. + +The product calls catalog entries "Skins", but package/runtime identifiers always remain Appearance: `appearance.json`, schema `bitfun.appearance`, and extension `.bitfun-appearance`. Pass an absolute local path to a completed archive. The package itself supplies the public name, description, author, version, mode and preview; the tool derives the marketplace slug and next release number from submission history. + +The caller must explicitly provide the package's SPDX license expression. If the package is not under a standard SPDX license, provide `custom_license_url` instead. The package is validated locally before upload and validated again by the server. If sign-in is required, the tool returns a GitHub authorization URL; show it to the user and call this tool again after authorization. + +This is an outward-facing publication action. Use it only when the user explicitly asks to publish or submit a Skin/Appearance package. It reads files from the local desktop host and is intentionally unavailable for remote SSH workspaces."#.to_string()) + } + + fn short_description(&self) -> String { + "Submit an Appearance package to the Skin market for review.".to_string() + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["package_path"], + "properties": { + "package_path": { + "type": "string", + "description": "Absolute local path to a `.bitfun-appearance` archive. Remote SSH workspace paths are not supported." + }, + "slug": { + "type": "string", + "description": "Optional marketplace slug (3-63 lowercase ASCII letters, digits and hyphens). Immutable after first approval." + }, + "changelog": { + "type": "string", + "description": "What changed in this release. Defaults to Initial release or General updates and improvements." + }, + "license_spdx": { + "type": "string", + "description": "SPDX license expression confirmed by the user, for example MIT or Apache-2.0. Provide this or custom_license_url." + }, + "custom_license_url": { + "type": "string", + "description": "HTTPS URL to custom license terms confirmed by the user. Provide this only when an SPDX expression is not appropriate." + }, + "repository_url": { + "type": "string", + "description": "Optional public HTTPS repository URL for the Skin source." + }, + "min_bitfun_version": { + "type": "string", + "description": "Minimum compatible BitFun semantic version. Defaults to this running client version." + } + } + }) + } + + fn is_readonly(&self) -> bool { + false + } + + async fn is_available_in_context(&self, context: Option<&ToolUseContext>) -> bool { + context.is_none_or(|context| !context.is_remote()) + } + + fn permission_intents( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + let package_path = input + .get("package_path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("unknown"); + let slug = input + .get("slug") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(""); + let license = input + .get("license_spdx") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| format!("SPDX: {value}")) + .or_else(|| { + input + .get("custom_license_url") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| format!("Custom: {value}")) + }) + .unwrap_or_else(|| "".to_string()); + let repository = input + .get("repository_url") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(""); + let min_version = input + .get("min_bitfun_version") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(""); + let changelog = input + .get("changelog") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(""); + let resource = format!( + "appearance:{slug}; release=next; package={package_path}; license={license}; repository={repository}; min-version={min_version}" + ); + let mut intent = PermissionIntent::new("custom_tool", vec![resource]); + // Marketplace publication is an outward account mutation. Keep every + // submission per-call so a remembered grant cannot hide changed release + // metadata or allow a later resubmission of the same local archive. + intent.save_resources.clear(); + intent.display_metadata.insert( + "permissionScope".to_string(), + Value::String("account".to_string()), + ); + intent + .display_metadata + .insert("requiresFreshApproval".to_string(), Value::Bool(true)); + intent.display_metadata.insert( + "appearanceOperation".to_string(), + Value::String("submit-for-review".to_string()), + ); + intent.display_metadata.insert( + "appearancePackagePath".to_string(), + Value::String(package_path.to_string()), + ); + intent.display_metadata.insert( + "appearanceSlug".to_string(), + Value::String(slug.to_string()), + ); + intent.display_metadata.insert( + "appearanceRelease".to_string(), + Value::String("next".to_string()), + ); + intent + .display_metadata + .insert("appearanceLicense".to_string(), Value::String(license)); + intent.display_metadata.insert( + "appearanceRepository".to_string(), + Value::String(repository.to_string()), + ); + intent.display_metadata.insert( + "appearanceMinBitFunVersion".to_string(), + Value::String(min_version.to_string()), + ); + intent.display_metadata.insert( + "appearanceChangelog".to_string(), + Value::String(changelog.to_string()), + ); + Ok(vec![intent]) + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + if context.is_remote() { + return Err(BitFunError::validation( + "PublishAppearance reads a package from the local desktop host and cannot use a remote SSH workspace path. Export the .bitfun-appearance package to the local device, switch to a local workspace, and call the tool again.", + )); + } + let package_path = required_local_package_path(input)?; + let (license, repository_url) = publication_metadata(input)?; + let fallback_name = package_path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("appearance"); + let fallback_seed = package_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("package"); + let slug = input + .get("slug") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| suggest_appearance_slug(fallback_name, fallback_seed)); + if !validate_appearance_market_slug(&slug) { + return Err(BitFunError::validation( + "slug must contain 3-63 lowercase ASCII letters, digits, or hyphens and cannot start with a hyphen.", + )); + } + + let mut client = AppearanceMarketClient::from_environment() + .await + .map_err(|error| BitFunError::tool(format!("Skin market unavailable: {error}")))?; + let me = client.me().await.map_err(|error| { + BitFunError::tool(format!("Skin market sign-in check failed: {error}")) + })?; + let Some(me) = me else { + let start = client.start_desktop_auth().await.map_err(|error| { + BitFunError::tool(format!( + "Could not start Skin market GitHub sign-in: {error}" + )) + })?; + let authorization_url = start.authorization_url.clone(); + let poll_request = DesktopAuthPollRequest { + transaction_id: start.transaction_id, + transaction_secret: start.transaction_secret, + }; + let interval = Duration::from_secs(start.poll_interval_seconds.max(1) as u64); + let expires_at = start.expires_at; + tokio::spawn(async move { + loop { + if unix_now() >= expires_at { + break; + } + tokio::time::sleep(interval).await; + match client.poll_desktop_auth(&poll_request).await { + Ok(response) + if matches!(response.status.as_str(), "authorized" | "expired") => + { + break; + } + Ok(_) => continue, + Err(_) => break, + } + } + }); + return Ok(vec![ToolResult::Result { + data: json!({ + "status": "sign_in_required", + "authorization_url": authorization_url, + "expires_at": expires_at, + }), + result_for_assistant: Some(format!( + "The user is not signed in to the Skin market. Show this GitHub authorization link and ask them to open it in a browser: {authorization_url}\nBitFun is polling in the background. After authorization, call PublishAppearance again with the same arguments." + )), + image_attachments: None, + }]); + }; + + let submissions = client.list_submissions().await.map_err(|error| { + BitFunError::tool(format!("Could not load Skin submission history: {error}")) + })?; + let (listing_id, release_number) = match resolve_appearance_release_target( + &submissions, + &slug, + ) { + AppearanceReleaseTarget::NewListing => (None, 1), + AppearanceReleaseTarget::ExistingListing { + listing_id, + next_release, + } => (Some(listing_id), next_release), + AppearanceReleaseTarget::PendingReview { + submission_id, + release_number, + } => { + return Ok(vec![ToolResult::Result { + data: json!({ + "status": "pending_review", + "slug": slug, + "submission_id": submission_id, + "release_number": release_number, + }), + result_for_assistant: Some(format!( + "Skin '{slug}' release {release_number} is already under review (submission {submission_id}). Wait for review or withdraw that draft before publishing again." + )), + image_attachments: None, + }]); + } + }; + let changelog = input + .get("changelog") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| { + if release_number == 1 { + "Initial release.".to_string() + } else { + "General updates and improvements.".to_string() + } + }); + if changelog.len() > 2_000 { + return Err(BitFunError::validation( + "changelog must be no longer than 2,000 bytes.", + )); + } + let min_bitfun_version = input + .get("min_bitfun_version") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(crate::VERSION) + .to_string(); + semver::Version::parse(&min_bitfun_version).map_err(|_| { + BitFunError::validation("min_bitfun_version must use semantic version syntax.") + })?; + let draft = AppearanceMarketSubmissionDraftRequest { + listing_id, + slug: slug.clone(), + release_number, + min_bitfun_version, + changelog, + license, + repository_url, + }; + + let (progress_tx, mut progress_rx) = + tokio::sync::mpsc::unbounded_channel::<(Option, &'static str, u32, u32)>(); + let forwarder = tokio::spawn(async move { + while let Some((submission_id, phase, completed, total)) = progress_rx.recv().await { + let _ = emit_global_event(BackendEvent::Custom { + event_name: "appearance-market-upload-progress".to_string(), + payload: json!({ + "submissionId": submission_id, + "phase": phase, + "completed": completed, + "total": total, + }), + }) + .await; + } + }); + let mut progress = |submission_id: Option<&str>, phase: &'static str, completed, total| { + let _ = progress_tx.send((submission_id.map(str::to_string), phase, completed, total)); + }; + let result = + submit_appearance_package(&mut client, &package_path, &draft, &mut progress).await; + drop(progress_tx); + let _ = forwarder.await; + let submission = result.map_err(|error| { + let hint = match error.code.as_str() { + "slug_taken" => " Pass a different `slug` and retry.", + "authentication_required" => { + " Call PublishAppearance again to start GitHub sign-in." + } + "invalid_release_number" => { + " Retry once; the release number is derived from submission history." + } + _ => "", + }; + BitFunError::tool(format!( + "Publishing Skin failed ({}): {}{hint}", + error.code, error + )) + })?; + let name = submission + .name + .as_deref() + .unwrap_or(submission.slug.as_str()); + Ok(vec![ToolResult::Result { + data: json!({ + "status": "submitted", + "submission_id": submission.submission_id, + "slug": submission.slug, + "release_number": submission.release_number, + "package_id": submission.package_id, + "name": submission.name, + }), + result_for_assistant: Some(format!( + "Skin '{name}' was submitted for review as '{}' release {} (signed in as {}).", + submission.slug, submission.release_number, me.user.login + )), + image_attachments: None, + }]) + } +} + +fn required_local_package_path(input: &Value) -> BitFunResult { + let raw = input + .get("package_path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| BitFunError::validation("package_path is required."))?; + let path = Path::new(raw); + if !path.is_absolute() { + return Err(BitFunError::validation( + "package_path must be an absolute local path.", + )); + } + if !raw.to_ascii_lowercase().ends_with(".bitfun-appearance") { + return Err(BitFunError::validation( + "package_path must end in .bitfun-appearance.", + )); + } + Ok(path.to_path_buf()) +} + +fn publication_metadata(input: &Value) -> BitFunResult<(AppearanceMarketLicense, Option)> { + let spdx = optional_text(input, "license_spdx"); + let custom_url = optional_https_url(input, "custom_license_url")?; + if spdx.is_none() == custom_url.is_none() { + return Err(BitFunError::validation( + "Provide exactly one of license_spdx or custom_license_url, confirmed by the user.", + )); + } + if spdx.as_ref().is_some_and(|value| { + value.len() > 120 + || !value.chars().all(|character| { + character.is_ascii_alphanumeric() + || matches!(character, '-' | '.' | '+' | '(' | ')' | ' ' | ':') + }) + }) { + return Err(BitFunError::validation( + "license_spdx is not a supported SPDX expression shape.", + )); + } + let repository_url = optional_https_url(input, "repository_url")?; + Ok(( + AppearanceMarketLicense { + spdx_expression: spdx, + custom_url, + }, + repository_url, + )) +} + +fn optional_text(input: &Value, field: &str) -> Option { + input + .get(field) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn optional_https_url(input: &Value, field: &str) -> BitFunResult> { + let Some(value) = optional_text(input, field) else { + return Ok(None); + }; + let parsed = reqwest::Url::parse(&value) + .map_err(|_| BitFunError::validation(format!("{field} must be a valid HTTPS URL.")))?; + if parsed.scheme() != "https" || parsed.host_str().is_none() { + return Err(BitFunError::validation(format!( + "{field} must be a valid HTTPS URL." + ))); + } + Ok(Some(value)) +} + +fn unix_now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|value| value.as_secs() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agentic::tools::framework::{Tool, ToolExposure, ToolUseContext}; + + #[test] + fn publish_appearance_is_direct_and_requires_a_package() { + let tool = PublishAppearanceTool::new(); + assert_eq!(tool.default_exposure(), ToolExposure::Direct); + assert_eq!(tool.input_schema()["required"], json!(["package_path"])); + } + + #[test] + fn permission_identity_requires_fresh_approval_for_submission_metadata() { + let tool = PublishAppearanceTool::new(); + let context = ToolUseContext::for_tool_listing(None, None); + let intents = tool + .permission_intents( + &json!({ + "package_path": "/tmp/calm.bitfun-appearance", + "slug": "calm-skin", + "license_spdx": "MIT", + "repository_url": "https://example.com/calm", + "min_bitfun_version": "1.2.3", + "changelog": "Initial release" + }), + &context, + ) + .unwrap(); + assert_eq!( + intents[0].resources, + ["appearance:calm-skin; release=next; package=/tmp/calm.bitfun-appearance; license=SPDX: MIT; repository=https://example.com/calm; min-version=1.2.3".to_string()] + ); + assert!(intents[0].save_resources.is_empty()); + assert_eq!( + intents[0].display_metadata.get("requiresFreshApproval"), + Some(&Value::Bool(true)) + ); + assert_eq!( + intents[0].display_metadata.get("appearanceChangelog"), + Some(&Value::String("Initial release".to_string())) + ); + } + + #[test] + fn publication_requires_exactly_one_license_source() { + assert!(publication_metadata(&json!({})).is_err()); + assert!(publication_metadata(&json!({ + "license_spdx": "MIT", + "custom_license_url": "https://example.com/license" + })) + .is_err()); + assert!(publication_metadata(&json!({"license_spdx": "MIT"})).is_ok()); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs index c58d48f444..def364bd93 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs @@ -2,6 +2,7 @@ pub mod agent_wait_tool; pub mod analyze_image_tool; +pub mod appearance_publish_tool; pub mod ask_user_question_tool; pub mod bash_tool; #[cfg(feature = "canvas-runtime")] @@ -53,6 +54,7 @@ pub mod worktree_tool; pub use crate::agentic::tools::product_runtime::GetToolSpecTool; pub use agent_wait_tool::AgentWaitTool; pub use analyze_image_tool::AnalyzeImageTool; +pub use appearance_publish_tool::PublishAppearanceTool; pub use ask_user_question_tool::AskUserQuestionTool; pub use bash_tool::BashTool; #[cfg(feature = "canvas-runtime")] diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs index 105babd6a8..b345b23a31 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs @@ -69,6 +69,7 @@ impl StaticToolProviderFactory for ProductConcreteToolFactory { "InitMiniApp" => Some(Arc::new(InitMiniAppTool::new())), "FinalizeMiniApp" => Some(Arc::new(FinalizeMiniAppTool::new())), "PublishMiniApp" => Some(Arc::new(PublishMiniAppTool::new())), + "PublishAppearance" => Some(Arc::new(PublishAppearanceTool::new())), "PageDeploy" => Some(Arc::new(PageDeployTool::new())), "PagePublish" => Some(Arc::new(PagePublishTool::new())), "ControlHub" => Some(Arc::new(ControlHubTool::new())), diff --git a/src/crates/assembly/core/src/agentic/tools/registry.rs b/src/crates/assembly/core/src/agentic/tools/registry.rs index de46029383..4bc1e3a696 100644 --- a/src/crates/assembly/core/src/agentic/tools/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/registry.rs @@ -546,6 +546,7 @@ mod tests { "InitMiniApp", "FinalizeMiniApp", "PublishMiniApp", + "PublishAppearance", "PageDeploy", "PagePublish", "ControlHub", @@ -703,6 +704,7 @@ mod tests { assert!(!registry.is_tool_deferred("InitMiniApp")); assert!(!registry.is_tool_deferred("FinalizeMiniApp")); assert!(!registry.is_tool_deferred("PublishMiniApp")); + assert!(!registry.is_tool_deferred("PublishAppearance")); } #[test] diff --git a/src/crates/contracts/product-domains/Cargo.toml b/src/crates/contracts/product-domains/Cargo.toml index 9c2656e9c5..b61dce9bad 100644 --- a/src/crates/contracts/product-domains/Cargo.toml +++ b/src/crates/contracts/product-domains/Cargo.toml @@ -55,11 +55,12 @@ url = { workspace = true, optional = true } [features] default = [] +appearance-market = ["hex", "sha2"] plugin-source = ["hex", "sha2"] miniapp = ["dirs", "hex", "sha2", "which"] function-agents = ["log"] external-sources = ["hex", "hmac", "sha2", "url"] -product-full = ["plugin-source", "miniapp", "function-agents", "external-sources"] +product-full = ["appearance-market", "plugin-source", "miniapp", "function-agents", "external-sources"] [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/src/crates/contracts/product-domains/src/appearance_market.rs b/src/crates/contracts/product-domains/src/appearance_market.rs new file mode 100644 index 0000000000..a029fb5a03 --- /dev/null +++ b/src/crates/contracts/product-domains/src/appearance_market.rs @@ -0,0 +1,315 @@ +//! Appearance marketplace contracts and pure publication policy. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +pub const APPEARANCE_MARKET_API_VERSION: &str = "v1"; +pub const APPEARANCE_MARKET_PACKAGE_CONTENT_TYPE: &str = "application/vnd.bitfun.appearance+zip"; +pub const APPEARANCE_MARKET_MAX_PACKAGE_BYTES: u64 = 96 * 1024 * 1024; +pub const APPEARANCE_MARKET_MAX_UNCOMPRESSED_BYTES: u64 = 128 * 1024 * 1024; +pub const APPEARANCE_MARKET_MAX_MANIFEST_BYTES: u64 = 256 * 1024; +pub const APPEARANCE_MARKET_MAX_PREVIEW_BYTES: u64 = 4 * 1024 * 1024; +pub const APPEARANCE_MARKET_MAX_PREVIEW_PIXELS: u64 = 16_000_000; +pub const APPEARANCE_MARKET_MAX_ENTRIES: usize = 64; +pub const APPEARANCE_MARKET_DEFAULT_PAGE_SIZE: u32 = 20; +pub const APPEARANCE_MARKET_MAX_PAGE_SIZE: u32 = 50; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AppearanceMarketSubmissionStatus { + Draft, + Submitted, + Approved, + Rejected, + Withdrawn, +} + +impl AppearanceMarketSubmissionStatus { + pub fn can_transition_to(self, next: Self) -> bool { + matches!( + (self, next), + (Self::Draft, Self::Submitted) + | (Self::Draft, Self::Withdrawn) + | (Self::Submitted, Self::Approved) + | (Self::Submitted, Self::Rejected) + | (Self::Submitted, Self::Withdrawn) + ) + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AppearanceMarketSort { + #[default] + Newest, + Downloads, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AppearancePackageMode { + Light, + Dark, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppearanceMarketUserSummary { + pub github_id: i64, + pub login: String, + pub avatar_url: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppearanceMarketListingSummary { + pub listing_id: String, + pub slug: String, + pub package_id: String, + pub name: String, + pub description: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub author: Option, + pub mode: AppearancePackageMode, + pub package_version: String, + pub latest_release: u32, + pub min_bitfun_version: String, + pub required_capabilities: Vec, + pub owner: AppearanceMarketUserSummary, + pub preview_url: String, + pub download_count: u64, + pub published_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppearanceMarketListingDetail { + #[serde(flatten)] + pub summary: AppearanceMarketListingSummary, + pub changelog: String, + pub license: AppearanceMarketLicense, + #[serde(skip_serializing_if = "Option::is_none")] + pub repository_url: Option, + pub releases: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppearanceMarketRelease { + pub release_id: String, + pub listing_id: String, + pub release_number: u32, + pub package_version: String, + pub min_bitfun_version: String, + pub package_sha256: String, + pub package_size: u64, + pub review_bundle_hash: String, + pub published_at: i64, + pub yanked: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppearanceMarketLicense { + #[serde(skip_serializing_if = "Option::is_none")] + pub spdx_expression: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub custom_url: Option, +} + +impl AppearanceMarketLicense { + pub fn is_declared(&self) -> bool { + self.spdx_expression + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || self + .custom_url + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppearanceMarketSubmission { + pub submission_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_id: Option, + pub slug: String, + pub release_number: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub package_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub author: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub package_version: Option, + pub min_bitfun_version: String, + pub required_capabilities: Vec, + pub changelog: String, + pub license: AppearanceMarketLicense, + #[serde(skip_serializing_if = "Option::is_none")] + pub repository_url: Option, + pub status: AppearanceMarketSubmissionStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub package_sha256: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub package_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub preview_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rejection_reason: Option, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppearanceMarketSubmissionDraftRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_id: Option, + pub slug: String, + pub release_number: u32, + pub min_bitfun_version: String, + pub changelog: String, + pub license: AppearanceMarketLicense, + #[serde(skip_serializing_if = "Option::is_none")] + pub repository_url: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppearanceAdminSubmissionDetail { + pub submission: AppearanceMarketSubmission, + #[serde(skip_serializing_if = "Option::is_none")] + pub manifest: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub package_sha256: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub preview_sha256: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub review_bundle_hash: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppearanceMarketPackageMeta { + pub package_id: String, + pub name: String, + #[serde(default)] + pub description: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub author: Option, + pub mode: AppearancePackageMode, + pub package_version: String, + #[serde(default)] + pub required_capabilities: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AppearanceReviewDecision { + Approve, + Reject, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppearanceReviewDecisionRequest { + pub decision: AppearanceReviewDecision, + #[serde(default)] + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppearanceCursorPage { + pub items: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +pub fn validate_appearance_market_slug(slug: &str) -> bool { + let bytes = slug.as_bytes(); + if !(3..=63).contains(&bytes.len()) { + return false; + } + if !bytes[0].is_ascii_lowercase() && !bytes[0].is_ascii_digit() { + return false; + } + bytes + .iter() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-') +} + +pub fn compute_appearance_review_bundle_hash( + package_sha256: &str, + canonical_metadata_json: &str, + preview_sha256: &str, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(package_sha256.as_bytes()); + hasher.update([0]); + hasher.update(canonical_metadata_json.as_bytes()); + hasher.update([0]); + hasher.update(preview_sha256.as_bytes()); + hex::encode(hasher.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slug_contract_is_strict_and_stable() { + assert!(validate_appearance_market_slug("tokyo-night")); + assert!(validate_appearance_market_slug("skin-1")); + assert!(!validate_appearance_market_slug("AB")); + assert!(!validate_appearance_market_slug("-leading")); + assert!(!validate_appearance_market_slug("contains_underscore")); + } + + #[test] + fn submission_state_machine_rejects_republication() { + assert!(AppearanceMarketSubmissionStatus::Draft + .can_transition_to(AppearanceMarketSubmissionStatus::Submitted)); + assert!(AppearanceMarketSubmissionStatus::Submitted + .can_transition_to(AppearanceMarketSubmissionStatus::Approved)); + assert!(!AppearanceMarketSubmissionStatus::Approved + .can_transition_to(AppearanceMarketSubmissionStatus::Submitted)); + } + + #[test] + fn review_hash_binds_the_preview() { + let first = compute_appearance_review_bundle_hash("package", "{}", "preview-one"); + let second = compute_appearance_review_bundle_hash("package", "{}", "preview-two"); + assert_ne!(first, second); + } + + #[test] + fn package_mode_uses_the_web_manifest_spelling() { + assert_eq!( + serde_json::to_string(&AppearancePackageMode::Dark).unwrap(), + "\"dark\"" + ); + } + + #[test] + fn listing_detail_json_contract_matches_the_shared_typescript_fixture() { + let fixture = include_str!( + "../../../../shared/appearance-market-contract-fixtures/listing-detail.json" + ); + let listing: AppearanceMarketListingDetail = serde_json::from_str(fixture).unwrap(); + let serialized = serde_json::to_value(&listing).unwrap(); + let expected: serde_json::Value = serde_json::from_str(fixture).unwrap(); + + assert_eq!(serialized, expected); + assert_eq!(listing.summary.package_id, "community.ocean-night"); + assert_eq!(listing.releases[0].package_version, "2.1.0"); + } +} diff --git a/src/crates/contracts/product-domains/src/lib.rs b/src/crates/contracts/product-domains/src/lib.rs index a190cf9948..164bc519c5 100644 --- a/src/crates/contracts/product-domains/src/lib.rs +++ b/src/crates/contracts/product-domains/src/lib.rs @@ -6,6 +6,9 @@ pub mod canvas; pub mod tool_permissions; +#[cfg(feature = "appearance-market")] +pub mod appearance_market; + #[cfg(feature = "external-sources")] pub mod external_integration_policy; diff --git a/src/crates/execution/tool-provider-groups/src/lib.rs b/src/crates/execution/tool-provider-groups/src/lib.rs index f5b3fa20a3..5f781e8dca 100644 --- a/src/crates/execution/tool-provider-groups/src/lib.rs +++ b/src/crates/execution/tool-provider-groups/src/lib.rs @@ -183,6 +183,7 @@ const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ "InitMiniApp", "FinalizeMiniApp", "PublishMiniApp", + "PublishAppearance", "PageDeploy", "PagePublish", "ControlHub", @@ -402,6 +403,7 @@ mod tests { "InitMiniApp", "FinalizeMiniApp", "PublishMiniApp", + "PublishAppearance", "PageDeploy", "PagePublish", "ControlHub", diff --git a/src/crates/services/AGENTS.md b/src/crates/services/AGENTS.md index 6a27902474..3962917229 100644 --- a/src/crates/services/AGENTS.md +++ b/src/crates/services/AGENTS.md @@ -14,6 +14,7 @@ OS/network capabilities. | `services-core` | Reusable local service primitives, managed process-tree lifecycle, filesystem helpers, LSP plugin registry rules, session storage layout/indexing/deletion, metadata store CRUD/index rebuild, metadata construction/counter/index/field mutation/lineage rules, and JSON file IO without product assembly decisions | [AGENTS.md](services-core/AGENTS.md) | | `services-integrations` | Concrete MCP, git, remote, file-watch, MiniApp runtime, review-platform provider service, product-domain port implementations, and platform-neutral Remote Connect primitives | [AGENTS.md](services-integrations/AGENTS.md) | | `miniapp-market-service` | Concrete SQLite, artifact storage, GitHub OAuth, package validation, and HTTP behavior for the MiniApp market | [README.md](miniapp-market-service/README.md) | +| `skin-market-service` | Concrete SQLite, artifact storage, Appearance package validation, review, and HTTP behavior for the Skin market | [README.md](skin-market-service/README.md) | | `relay-service` | Reusable Remote Connect relay state, storage, and HTTP/WebSocket routes shared by standalone and embedded hosts | [AGENTS.md](relay-service/AGENTS.md) | | `page-function-runtime` | Embedded JS Page Function runtime (rquickjs) for BitFun Pages | [AGENTS.md](page-function-runtime/AGENTS.md) | | `terminal` | PTY, shell integration, and terminal session infrastructure | [AGENTS.md](terminal/AGENTS.md) | diff --git a/src/crates/services/miniapp-market-service/README.md b/src/crates/services/miniapp-market-service/README.md index 7adde41afc..95567b92e8 100644 --- a/src/crates/services/miniapp-market-service/README.md +++ b/src/crates/services/miniapp-market-service/README.md @@ -50,6 +50,9 @@ link、重复/大小写冲突路径和超限解压。 - GitHub token 只用于读取公开 `{id,login,avatar_url}`,随后丢弃,不能下发给 Web 或桌面客户端。 +- MiniApp 服务是 MiniApp 与 Skin 两个市场唯一的 GitHub 身份权威。Web 登录会为 + `/miniapp` 与 `/skin` 签发同一服务端 session 的独立 Path-scoped Cookie;Skin + 不保存 OAuth secret,退出登录必须撤销 session 并清除两组 Cookie。 - 管理员身份每次请求按 GitHub 数字 ID 计算,不能依赖客户端声明。 - `MARKET_WEB_SUBMISSIONS_ENABLED=false` 时,所有投稿写路由会在读取请求体前 拒绝 Web Cookie 会话;Desktop Bearer 投稿、投稿历史读取和 Web 管理员审核 diff --git a/src/crates/services/miniapp-market-service/src/auth.rs b/src/crates/services/miniapp-market-service/src/auth.rs index 72451ebaa8..2bc852f17c 100644 --- a/src/crates/services/miniapp-market-service/src/auth.rs +++ b/src/crates/services/miniapp-market-service/src/auth.rs @@ -15,6 +15,10 @@ use uuid::Uuid; const WEB_SESSION_COOKIE: &str = "bitfun_market_session"; const CSRF_COOKIE: &str = "bitfun_market_csrf"; +const SKIN_SESSION_COOKIE: &str = "bitfun_skin_session"; +const SKIN_CSRF_COOKIE: &str = "bitfun_skin_csrf"; +const MINIAPP_COOKIE_PATH: &str = "/miniapp"; +const SKIN_COOKIE_PATH: &str = "/skin"; const OAUTH_FLOW_MINUTES: i64 = 10; const WEB_SESSION_DAYS: i64 = 7; const ACCESS_TOKEN_MINUTES: i64 = 15; @@ -38,12 +42,36 @@ pub(crate) enum RequestAuthKind { Web { session_token: String, csrf_hash: String, + expires_at: i64, + surface: WebSessionSurface, }, Bearer { family_id: String, }, } +#[derive(Debug, Clone, Copy)] +pub(crate) enum WebSessionSurface { + MiniApp, + Skin, +} + +impl WebSessionSurface { + fn session_cookie(self) -> &'static str { + match self { + Self::MiniApp => WEB_SESSION_COOKIE, + Self::Skin => SKIN_SESSION_COOKIE, + } + } + + fn csrf_cookie(self) -> &'static str { + match self { + Self::MiniApp => CSRF_COOKIE, + Self::Skin => SKIN_CSRF_COOKIE, + } + } +} + #[derive(Debug, Clone)] pub(crate) enum CompletedOAuth { Web { @@ -142,15 +170,21 @@ impl AuthService { kind: RequestAuthKind::Bearer { family_id }, })); } - if let Some(token) = cookie_value(headers, WEB_SESSION_COOKIE) { - let Some((user, csrf_hash)) = self.db.web_session_user(&token).await? else { - return Ok(None); + for surface in [WebSessionSurface::MiniApp, WebSessionSurface::Skin] { + let Some(token) = cookie_value(headers, surface.session_cookie()) else { + continue; + }; + let Some((user, csrf_hash, expires_at)) = self.db.web_session_user(&token).await? + else { + continue; }; return Ok(Some(RequestAuth { user, kind: RequestAuthKind::Web { session_token: token, csrf_hash, + expires_at, + surface, }, })); } @@ -164,14 +198,17 @@ impl AuthService { } pub(crate) fn require_csrf(&self, headers: &HeaderMap, auth: &RequestAuth) -> MarketResult<()> { - let RequestAuthKind::Web { csrf_hash, .. } = &auth.kind else { + let RequestAuthKind::Web { + csrf_hash, surface, .. + } = &auth.kind + else { return Ok(()); }; let header_token = headers .get("x-csrf-token") .and_then(|value| value.to_str().ok()) .unwrap_or_default(); - let cookie_token = cookie_value(headers, CSRF_COOKIE).unwrap_or_default(); + let cookie_token = cookie_value(headers, surface.csrf_cookie()).unwrap_or_default(); if header_token.is_empty() || header_token != cookie_token || token_hash(header_token) != *csrf_hash @@ -488,29 +525,72 @@ impl AuthService { } else { "" }; - append_set_cookie( - headers, - &format!( - "{WEB_SESSION_COOKIE}={session_token}; Path=/miniapp; Max-Age={max_age}; HttpOnly; SameSite=Lax{secure}" - ), - )?; - append_set_cookie( - headers, - &format!( - "{CSRF_COOKIE}={csrf_token}; Path=/miniapp; Max-Age={max_age}; SameSite=Lax{secure}" - ), - ) + for (session_cookie, csrf_cookie, path) in [ + (WEB_SESSION_COOKIE, CSRF_COOKIE, MINIAPP_COOKIE_PATH), + (SKIN_SESSION_COOKIE, SKIN_CSRF_COOKIE, SKIN_COOKIE_PATH), + ] { + append_set_cookie( + headers, + &format!( + "{session_cookie}={session_token}; Path={path}; Max-Age={max_age}; HttpOnly; SameSite=Lax{secure}" + ), + )?; + append_set_cookie( + headers, + &format!( + "{csrf_cookie}={csrf_token}; Path={path}; Max-Age={max_age}; SameSite=Lax{secure}" + ), + )?; + } + Ok(()) + } + + pub(crate) fn append_shared_account_cookies( + &self, + response_headers: &mut HeaderMap, + request_headers: &HeaderMap, + auth: &RequestAuth, + ) -> MarketResult<()> { + let RequestAuthKind::Web { + session_token, + csrf_hash, + expires_at, + surface, + } = &auth.kind + else { + return Ok(()); + }; + let Some(csrf_token) = cookie_value(request_headers, surface.csrf_cookie()) else { + return Ok(()); + }; + if token_hash(&csrf_token) != *csrf_hash { + return Ok(()); + } + self.append_web_session_cookies(response_headers, session_token, &csrf_token, *expires_at) } pub(crate) fn append_clear_cookies(&self, headers: &mut HeaderMap) -> MarketResult<()> { - append_set_cookie( - headers, - &format!("{WEB_SESSION_COOKIE}=; Path=/miniapp; Max-Age=0; HttpOnly; SameSite=Lax"), - )?; - append_set_cookie( - headers, - &format!("{CSRF_COOKIE}=; Path=/miniapp; Max-Age=0; SameSite=Lax"), - ) + let secure = if self.config.public_base_url.starts_with("https://") { + "; Secure" + } else { + "" + }; + for (session_cookie, csrf_cookie, path) in [ + (WEB_SESSION_COOKIE, CSRF_COOKIE, MINIAPP_COOKIE_PATH), + (SKIN_SESSION_COOKIE, SKIN_CSRF_COOKIE, SKIN_COOKIE_PATH), + ] { + append_set_cookie( + headers, + &format!( + "{session_cookie}=; Path={path}; Max-Age=0; HttpOnly; SameSite=Lax{secure}" + ), + )?; + append_set_cookie( + headers, + &format!("{csrf_cookie}=; Path={path}; Max-Age=0; SameSite=Lax{secure}"), + )?; + } + Ok(()) } async fn exchange_github_code(&self, code: &str, verifier: &str) -> MarketResult { @@ -581,10 +661,41 @@ fn random_token(bytes: usize) -> String { } fn safe_return_to(value: &str) -> String { - if value.starts_with("/miniapp/") && !value.starts_with("//") { + const FALLBACK: &str = "/miniapp/"; + if value.len() > 2_048 + || !value.starts_with('/') + || value.starts_with("//") + || value.contains('\\') + || value.chars().any(char::is_control) + { + return FALLBACK.to_string(); + } + let encoded_path = value + .split(['?', '#']) + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + if ["%00", "%0a", "%0d", "%2f", "%5c"] + .iter() + .any(|encoded| encoded_path.contains(encoded)) + { + return FALLBACK.to_string(); + } + let Ok(base) = Url::parse("https://market.openbitfun.com/") else { + return FALLBACK.to_string(); + }; + let Ok(target) = base.join(value) else { + return FALLBACK.to_string(); + }; + let path = target.path(); + if target.origin() == base.origin() + && (matches!(path, "/miniapp" | "/skin") + || path.starts_with("/miniapp/") + || path.starts_with("/skin/")) + { value.to_string() } else { - "/miniapp/".to_string() + FALLBACK.to_string() } } @@ -675,6 +786,24 @@ mod tests { assert_eq!(replay.code, "invalid_oauth_state"); } + #[test] + fn oauth_return_target_accepts_only_market_surfaces() { + assert_eq!( + safe_return_to("/skin/appearances/ocean-night?q=dark"), + "/skin/appearances/ocean-night?q=dark" + ); + assert_eq!( + safe_return_to("/miniapp/apps/reviewed-app"), + "/miniapp/apps/reviewed-app" + ); + assert_eq!(safe_return_to("//attacker.invalid/skin/"), "/miniapp/"); + assert_eq!(safe_return_to("/skin/../admin"), "/miniapp/"); + assert_eq!(safe_return_to("/skin/%2e%2e/admin"), "/miniapp/"); + assert_eq!(safe_return_to("/skin/%2F%2Fattacker.invalid"), "/miniapp/"); + assert_eq!(safe_return_to("/skin/%5c%5cattacker"), "/miniapp/"); + assert_eq!(safe_return_to("/unrelated"), "/miniapp/"); + } + #[tokio::test] async fn web_csrf_requires_matching_cookie_header_and_session_hash() { let temporary = tempfile::tempdir().unwrap(); @@ -691,6 +820,8 @@ mod tests { kind: RequestAuthKind::Web { session_token: "session".to_string(), csrf_hash: token_hash("csrf-value"), + expires_at: (Utc::now() + Duration::hours(1)).timestamp(), + surface: WebSessionSurface::MiniApp, }, }; let mut headers = HeaderMap::new(); @@ -710,6 +841,48 @@ mod tests { .as_u16(), 403 ); + + let mut response_headers = HeaderMap::new(); + service + .append_shared_account_cookies(&mut response_headers, &headers, &auth) + .unwrap(); + let cookies = response_headers + .get_all(header::SET_COOKIE) + .iter() + .map(|value| value.to_str().unwrap()) + .collect::>(); + assert_eq!(cookies.len(), 4); + assert!(cookies + .iter() + .any(|cookie| { cookie.starts_with("bitfun_market_session=session; Path=/miniapp;") })); + assert!(cookies + .iter() + .any(|cookie| { cookie.starts_with("bitfun_market_csrf=csrf-value; Path=/miniapp;") })); + assert!(cookies + .iter() + .any(|cookie| { cookie.starts_with("bitfun_skin_session=session; Path=/skin;") })); + assert!(cookies + .iter() + .any(|cookie| { cookie.starts_with("bitfun_skin_csrf=csrf-value; Path=/skin;") })); + assert!(cookies.iter().all(|cookie| cookie.contains("SameSite=Lax"))); + assert!(cookies.iter().all(|cookie| cookie.contains("Secure"))); + assert!(cookies.iter().all(|cookie| !cookie.contains("Domain="))); + assert!(cookies + .iter() + .filter(|cookie| cookie.contains("_session=")) + .all(|cookie| cookie.contains("HttpOnly"))); + + let mut clear_headers = HeaderMap::new(); + service.append_clear_cookies(&mut clear_headers).unwrap(); + let clear_cookies = clear_headers + .get_all(header::SET_COOKIE) + .iter() + .map(|value| value.to_str().unwrap()) + .collect::>(); + assert_eq!(clear_cookies.len(), 4); + assert!(clear_cookies + .iter() + .all(|cookie| cookie.contains("Max-Age=0"))); } #[tokio::test] diff --git a/src/crates/services/miniapp-market-service/src/db.rs b/src/crates/services/miniapp-market-service/src/db.rs index 73a78bb850..3c5e4b9e49 100644 --- a/src/crates/services/miniapp-market-service/src/db.rs +++ b/src/crates/services/miniapp-market-service/src/db.rs @@ -158,9 +158,9 @@ impl Database { pub(crate) async fn web_session_user( &self, token: &str, - ) -> MarketResult> { + ) -> MarketResult> { let row = sqlx::query( - "SELECT u.id, u.github_id, u.login, u.avatar_url, s.csrf_hash + "SELECT u.id, u.github_id, u.login, u.avatar_url, s.csrf_hash, s.expires_at FROM web_sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ? AND s.expires_at > ?", @@ -172,7 +172,8 @@ impl Database { .map_err(MarketError::internal)?; Ok(row.map(|row| { let csrf_hash = row.get::("csrf_hash"); - (user_from_row(row), csrf_hash) + let expires_at = row.get::("expires_at"); + (user_from_row(row), csrf_hash, expires_at) })) } diff --git a/src/crates/services/miniapp-market-service/src/routes.rs b/src/crates/services/miniapp-market-service/src/routes.rs index 008feb6acb..bc1ff08023 100644 --- a/src/crates/services/miniapp-market-service/src/routes.rs +++ b/src/crates/services/miniapp-market-service/src/routes.rs @@ -164,7 +164,7 @@ pub(crate) fn api_router(state: Arc) -> Router { .route("/auth/desktop/poll", post(poll_desktop_auth)) .route("/auth/refresh", post(refresh_tokens)) .route("/auth/logout", post(logout)) - .route("/me", get(me)) + .route("/me", get(me).post(verify_write_identity)) .route( "/submissions", post(create_submission).get(list_my_submissions), @@ -525,15 +525,34 @@ async fn refresh_tokens( )) } -async fn me( +async fn me(State(state): State>, headers: HeaderMap) -> MarketResult { + let auth = state.auth.require_auth(&headers).await?; + let mut response = identity_response(&state, &auth); + state + .auth + .append_shared_account_cookies(response.headers_mut(), &headers, &auth)?; + Ok(response) +} + +async fn verify_write_identity( State(state): State>, headers: HeaderMap, -) -> MarketResult> { +) -> MarketResult { let auth = state.auth.require_auth(&headers).await?; - Ok(Json(MeResponse { + state.auth.require_csrf(&headers, &auth)?; + Ok(identity_response(&state, &auth)) +} + +fn identity_response(state: &MarketState, auth: &RequestAuth) -> Response { + let mut response = Json(MeResponse { is_admin: state.auth.is_admin(&auth.user), - user: auth.user.profile, - })) + user: auth.user.profile.clone(), + }) + .into_response(); + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response } async fn logout( diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index 6fd6df86a7..16eb09f199 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -170,12 +170,14 @@ miniapp-runtime = [ "which", ] miniapp-market = [ + "bitfun-product-domains/appearance-market", "chrono", "dep:apple-native-keyring-store", "dep:keyring-core", "dep:windows-native-keyring-store", "dep:zbus-secret-service-keyring-store", "hex", + "image", "miniapp-runtime", "reqwest", "reqwest/rustls", diff --git a/src/crates/services/services-integrations/src/appearance_market/client.rs b/src/crates/services/services-integrations/src/appearance_market/client.rs new file mode 100644 index 0000000000..7746e83a97 --- /dev/null +++ b/src/crates/services/services-integrations/src/appearance_market/client.rs @@ -0,0 +1,437 @@ +use bitfun_product_domains::appearance_market::{ + AppearanceAdminSubmissionDetail, AppearanceCursorPage, AppearanceMarketListingDetail, + AppearanceMarketListingSummary, AppearanceMarketSort, AppearanceMarketSubmission, + AppearanceMarketSubmissionDraftRequest, AppearanceMarketSubmissionStatus, + AppearanceReviewDecisionRequest, APPEARANCE_MARKET_MAX_PACKAGE_BYTES, + APPEARANCE_MARKET_PACKAGE_CONTENT_TYPE, +}; +use reqwest::{RequestBuilder, Response}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +use crate::miniapp_market::{ + DesktopAuthPollRequest, DesktopAuthPollResponse, DesktopAuthStart, MarketClient, + MarketClientError, MarketMe, +}; + +const DEFAULT_APPEARANCE_MARKET_API_URL: &str = "https://market.openbitfun.com/skin/api/v1"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppearanceMarketBrowseRequest { + #[serde(default)] + pub query: String, + pub mode: Option, + #[serde(default)] + pub sort: AppearanceMarketSort, + pub cursor: Option, + pub limit: Option, +} + +#[derive(Debug)] +pub struct AppearanceMarketClient { + base_url: String, + client: reqwest::Client, + identity: MarketClient, +} + +impl AppearanceMarketClient { + pub async fn from_environment() -> Result { + let base_url = std::env::var("BITFUN_APPEARANCE_MARKET_API_URL") + .unwrap_or_else(|_| DEFAULT_APPEARANCE_MARKET_API_URL.to_string()); + let identity = MarketClient::from_environment().await?; + Self::with_identity(base_url, identity) + } + + pub async fn new( + base_url: impl Into, + identity_base_url: impl Into, + ) -> Result { + let identity = MarketClient::new(identity_base_url).await?; + Self::with_identity(base_url, identity) + } + + fn with_identity( + base_url: impl Into, + identity: MarketClient, + ) -> Result { + let base_url = base_url.into().trim_end_matches('/').to_string(); + validate_market_url(&base_url)?; + let client = reqwest::Client::builder() + .user_agent(format!("BitFun-Desktop/{}", env!("CARGO_PKG_VERSION"))) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| local_error("market_client_init_failed", error.to_string()))?; + Ok(Self { + base_url, + client, + identity, + }) + } + + pub async fn browse( + &self, + request: &AppearanceMarketBrowseRequest, + ) -> Result, MarketClientError> { + let mut query = vec![ + ("sort", sort_value(request.sort).to_string()), + ("limit", request.limit.unwrap_or(20).to_string()), + ]; + if !request.query.trim().is_empty() { + query.push(("q", request.query.trim().to_string())); + } + if let Some(mode) = request + .mode + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty() && *value != "all") + { + query.push(("mode", mode.to_string())); + } + if let Some(cursor) = request.cursor.as_ref().filter(|value| !value.is_empty()) { + query.push(("cursor", cursor.clone())); + } + self.json(self.client.get(self.url("/listings")).query(&query)) + .await + } + + pub async fn listing( + &self, + slug: &str, + ) -> Result { + self.json( + self.client + .get(self.url(&format!("/listings/{}", urlencoding::encode(slug)))), + ) + .await + } + + pub async fn download_release( + &self, + slug: &str, + release_number: u32, + ) -> Result, MarketClientError> { + let response = self + .client + .get(self.url(&format!( + "/listings/{}/releases/{release_number}/download", + urlencoding::encode(slug) + ))) + .send() + .await + .map_err(transport_error)?; + let mut response = checked_response(response).await?; + if response + .content_length() + .is_some_and(|length| length > APPEARANCE_MARKET_MAX_PACKAGE_BYTES) + { + return Err(local_error( + "package_too_large", + "The Appearance market download exceeds 96 MiB.", + )); + } + let mut bytes = Vec::with_capacity( + response + .content_length() + .unwrap_or_default() + .min(APPEARANCE_MARKET_MAX_PACKAGE_BYTES) as usize, + ); + while let Some(chunk) = response.chunk().await.map_err(transport_error)? { + if bytes.len().saturating_add(chunk.len()) + > APPEARANCE_MARKET_MAX_PACKAGE_BYTES as usize + { + return Err(local_error( + "package_too_large", + "The Appearance market download exceeds 96 MiB.", + )); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) + } + + pub async fn me(&mut self) -> Result, MarketClientError> { + self.identity.me().await + } + + pub async fn start_desktop_auth(&self) -> Result { + self.identity.start_desktop_auth().await + } + + pub async fn poll_desktop_auth( + &mut self, + request: &DesktopAuthPollRequest, + ) -> Result { + self.identity.poll_desktop_auth(request).await + } + + pub async fn list_submissions( + &mut self, + ) -> Result, MarketClientError> { + let mut submissions = Vec::new(); + let mut cursor: Option = None; + for _ in 0..100 { + let mut query = vec![("limit", "50".to_string())]; + if let Some(value) = &cursor { + query.push(("cursor", value.clone())); + } + let request = self + .authorized(self.client.get(self.url("/submissions")).query(&query)) + .await?; + let page: AppearanceCursorPage = self.json(request).await?; + submissions.extend(page.items); + cursor = page.next_cursor; + if cursor.is_none() { + return Ok(submissions); + } + } + Err(local_error( + "submission_history_too_large", + "Skin submission history exceeds the client pagination safety limit.", + )) + } + + pub async fn create_submission( + &mut self, + draft: &AppearanceMarketSubmissionDraftRequest, + ) -> Result { + let request = self + .authorized(self.client.post(self.url("/submissions"))) + .await?; + self.json(request.json(draft)).await + } + + pub async fn upload_submission_package( + &mut self, + submission_id: &str, + bytes: Vec, + ) -> Result { + let request = self + .authorized(self.client.put(self.url(&format!( + "/submissions/{}/package", + urlencoding::encode(submission_id) + )))) + .await?; + self.json( + request + .header( + reqwest::header::CONTENT_TYPE, + APPEARANCE_MARKET_PACKAGE_CONTENT_TYPE, + ) + .body(bytes), + ) + .await + } + + pub async fn submit_submission( + &mut self, + submission_id: &str, + ) -> Result { + let request = self + .authorized(self.client.post(self.url(&format!( + "/submissions/{}/submit", + urlencoding::encode(submission_id) + )))) + .await?; + self.json(request).await + } + + pub async fn withdraw_submission( + &mut self, + submission_id: &str, + ) -> Result { + let request = self + .authorized(self.client.delete(self.url(&format!( + "/submissions/{}", + urlencoding::encode(submission_id) + )))) + .await?; + self.json(request).await + } + + pub async fn list_admin_submissions( + &mut self, + status: AppearanceMarketSubmissionStatus, + ) -> Result, MarketClientError> { + let mut submissions = Vec::new(); + let mut cursor: Option = None; + for _ in 0..100 { + let mut query = vec![ + ("status", submission_status_value(status).to_string()), + ("limit", "50".to_string()), + ]; + if let Some(value) = &cursor { + query.push(("cursor", value.clone())); + } + let request = self + .authorized( + self.client + .get(self.url("/admin/submissions")) + .query(&query), + ) + .await?; + let page: AppearanceCursorPage = self.json(request).await?; + submissions.extend(page.items); + cursor = page.next_cursor; + if cursor.is_none() { + return Ok(submissions); + } + } + Err(local_error( + "review_queue_too_large", + "Skin review queue exceeds the client pagination safety limit.", + )) + } + + pub async fn admin_submission( + &mut self, + submission_id: &str, + ) -> Result { + let request = self + .authorized(self.client.get(self.url(&format!( + "/admin/submissions/{}", + urlencoding::encode(submission_id) + )))) + .await?; + self.json(request).await + } + + pub async fn review_submission( + &mut self, + submission_id: &str, + decision: &AppearanceReviewDecisionRequest, + ) -> Result { + let request = self + .authorized(self.client.post(self.url(&format!( + "/admin/submissions/{}/decision", + urlencoding::encode(submission_id) + )))) + .await?; + self.json(request.json(decision)).await + } + + async fn authorized( + &mut self, + request: RequestBuilder, + ) -> Result { + let token = self.identity.access_token().await?.ok_or_else(|| { + local_error( + "authentication_required", + "Sign in with GitHub to continue.", + ) + })?; + Ok(request.bearer_auth(token)) + } + + async fn json( + &self, + request: RequestBuilder, + ) -> Result { + let response = request.send().await.map_err(transport_error)?; + decode_json(checked_response(response).await?).await + } + + fn url(&self, path: &str) -> String { + format!("{}{}", self.base_url, path) + } +} + +fn validate_market_url(url: &str) -> Result<(), MarketClientError> { + let parsed = reqwest::Url::parse(url) + .map_err(|error| local_error("invalid_market_url", error.to_string()))?; + let local_http = parsed.scheme() == "http" + && parsed + .host_str() + .is_some_and(|host| matches!(host, "127.0.0.1" | "localhost" | "::1")); + if parsed.scheme() != "https" && !local_http { + return Err(local_error( + "invalid_market_url", + "The Appearance market API must use HTTPS.", + )); + } + Ok(()) +} + +fn sort_value(sort: AppearanceMarketSort) -> &'static str { + match sort { + AppearanceMarketSort::Newest => "newest", + AppearanceMarketSort::Downloads => "downloads", + } +} + +fn submission_status_value(status: AppearanceMarketSubmissionStatus) -> &'static str { + match status { + AppearanceMarketSubmissionStatus::Draft => "draft", + AppearanceMarketSubmissionStatus::Submitted => "submitted", + AppearanceMarketSubmissionStatus::Approved => "approved", + AppearanceMarketSubmissionStatus::Rejected => "rejected", + AppearanceMarketSubmissionStatus::Withdrawn => "withdrawn", + } +} + +#[derive(Debug, Deserialize)] +struct ErrorEnvelope { + error: ErrorBody, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ErrorBody { + code: String, + message: String, + request_id: Option, +} + +async fn checked_response(response: Response) -> Result { + if response.status().is_success() { + Ok(response) + } else { + Err(response_error(response).await) + } +} + +async fn decode_json(response: Response) -> Result { + response + .json() + .await + .map_err(|error| local_error("invalid_market_response", error.to_string())) +} + +async fn response_error(response: Response) -> MarketClientError { + let status = response.status(); + match response.json::().await { + Ok(envelope) => MarketClientError { + code: envelope.error.code, + message: envelope.error.message, + request_id: envelope.error.request_id, + }, + Err(_) => local_error( + "market_request_failed", + format!("The Appearance market returned HTTP {status}."), + ), + } +} + +fn transport_error(error: reqwest::Error) -> MarketClientError { + local_error("market_unavailable", error.to_string()) +} + +fn local_error(code: impl Into, message: impl Into) -> MarketClientError { + MarketClientError { + code: code.into(), + message: message.into(), + request_id: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_non_local_plain_http() { + let error = validate_market_url("http://example.com/skin/api/v1").unwrap_err(); + assert_eq!(error.code, "invalid_market_url"); + assert!(validate_market_url("http://127.0.0.1:9720/skin/api/v1").is_ok()); + } +} diff --git a/src/crates/services/services-integrations/src/appearance_market/mod.rs b/src/crates/services/services-integrations/src/appearance_market/mod.rs new file mode 100644 index 0000000000..74823d6f9f --- /dev/null +++ b/src/crates/services/services-integrations/src/appearance_market/mod.rs @@ -0,0 +1,15 @@ +//! Concrete Appearance marketplace client, package validation and submission helpers. + +mod client; +mod package; +mod submit; + +pub use client::{AppearanceMarketBrowseRequest, AppearanceMarketClient}; +pub use package::{ + validate_appearance_market_package, AppearanceMarketPackageError, + ValidatedAppearanceMarketPackage, +}; +pub use submit::{ + resolve_appearance_release_target, submit_appearance_package, suggest_appearance_slug, + AppearanceReleaseTarget, AppearanceSubmitProgress, +}; diff --git a/src/crates/services/services-integrations/src/appearance_market/package.rs b/src/crates/services/services-integrations/src/appearance_market/package.rs new file mode 100644 index 0000000000..70af161424 --- /dev/null +++ b/src/crates/services/services-integrations/src/appearance_market/package.rs @@ -0,0 +1,803 @@ +use bitfun_product_domains::appearance_market::{ + AppearanceMarketPackageMeta, AppearancePackageMode, APPEARANCE_MARKET_MAX_ENTRIES, + APPEARANCE_MARKET_MAX_MANIFEST_BYTES, APPEARANCE_MARKET_MAX_PACKAGE_BYTES, + APPEARANCE_MARKET_MAX_PREVIEW_BYTES, APPEARANCE_MARKET_MAX_PREVIEW_PIXELS, + APPEARANCE_MARKET_MAX_UNCOMPRESSED_BYTES, +}; +use image::ImageReader; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::io::{Cursor, Read}; +use zip::ZipArchive; + +const MANIFEST_PATH: &str = "appearance.json"; +const MAX_IMAGE_BYTES: u64 = 16 * 1024 * 1024; +const MAX_VIDEO_BYTES: u64 = 64 * 1024 * 1024; +const MAX_IMAGE_DIMENSION: u32 = 16_384; +const MAX_DIRECTORY_ENTRIES: usize = 64; +const SUPPORTED_CAPABILITIES: &[&str] = &[ + "components.v1", + "scenes.v1", + "renderers.v1", + "assets.v1", + "background-media.v1", +]; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("{message}")] +pub struct AppearanceMarketPackageError { + pub code: &'static str, + pub message: String, +} + +impl AppearanceMarketPackageError { + fn new(code: &'static str, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } +} + +#[derive(Debug, Clone)] +pub struct ValidatedAppearancePreview { + pub bytes: Vec, + pub mime_type: String, + pub sha256: String, + pub width: u32, + pub height: u32, +} + +#[derive(Debug, Clone)] +pub struct ValidatedAppearanceMarketPackage { + pub sha256: String, + pub size: u64, + pub meta: AppearanceMarketPackageMeta, + pub preview: ValidatedAppearancePreview, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AppearanceManifest { + schema: String, + schema_version: u32, + id: String, + name: String, + #[serde(default)] + author: Option, + #[serde(default)] + description: String, + version: String, + mode: AppearancePackageMode, + preview: AssetReference, + #[serde(default)] + background_media: Option, + #[serde(default)] + required_capabilities: Vec, + #[serde(default)] + assets: BTreeMap, + #[serde(default)] + integrity: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BackgroundMedia { + kind: String, + asset_id: String, + poster_asset_id: String, + #[serde(default)] + fit: Option, + #[serde(default)] + position: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AssetReference { + kind: String, + asset_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PackageAsset { + kind: String, + mime_type: String, + source: PackageSource, +} + +#[derive(Debug, Deserialize)] +struct PackageSource { + kind: String, + path: String, +} + +#[derive(Debug, Default, Deserialize)] +struct IntegrityManifest { + #[serde(default)] + sha256: BTreeMap, +} + +pub fn validate_appearance_market_package( + bytes: &[u8], +) -> Result { + if bytes.is_empty() { + return Err(error("empty_package", "The Appearance package is empty.")); + } + if bytes.len() as u64 > APPEARANCE_MARKET_MAX_PACKAGE_BYTES { + return Err(error( + "package_too_large", + "The compressed Appearance package exceeds 96 MiB.", + )); + } + + let mut archive = ZipArchive::new(Cursor::new(bytes)).map_err(|source| { + error( + "invalid_package", + format!("The Appearance package is not a valid ZIP archive: {source}"), + ) + })?; + if archive.is_empty() || archive.len() > APPEARANCE_MARKET_MAX_ENTRIES + MAX_DIRECTORY_ENTRIES { + return Err(error( + "invalid_entry_count", + "The Appearance package contains too many archive entries.", + )); + } + + let mut seen = HashSet::new(); + let mut entry_sizes = HashMap::new(); + let mut directory_count = 0; + let mut total_uncompressed = 0_u64; + for index in 0..archive.len() { + let entry = archive.by_index(index).map_err(|source| { + error( + "invalid_package", + format!("The Appearance package contains an invalid ZIP entry: {source}"), + ) + })?; + if entry.encrypted() { + return Err(error( + "encrypted_entry_forbidden", + "Encrypted Appearance package entries are not allowed.", + )); + } + if entry.unix_mode().is_some_and(|mode| { + let file_type = mode & 0o170000; + file_type != 0 && file_type != 0o100000 && file_type != 0o040000 + }) { + return Err(error( + "non_regular_entry_forbidden", + "Appearance packages may contain regular files only.", + )); + } + let name = safe_entry_name(&entry)?; + if entry.is_dir() { + directory_count += 1; + if entry.size() != 0 || directory_count > MAX_DIRECTORY_ENTRIES { + return Err(error( + "invalid_entry_count", + "Appearance package directories must be empty entries and are limited to 64.", + )); + } + continue; + } + if !entry.is_file() { + return Err(error( + "non_regular_entry_forbidden", + "Appearance packages may contain files and directory markers only.", + )); + } + if !seen.insert(name.to_ascii_lowercase()) { + return Err(error( + "duplicate_package_path", + format!("The Appearance package contains a duplicate path: {name}"), + )); + } + total_uncompressed = total_uncompressed.saturating_add(entry.size()); + if total_uncompressed > APPEARANCE_MARKET_MAX_UNCOMPRESSED_BYTES { + return Err(error( + "package_expansion_too_large", + "The Appearance package expands beyond 128 MiB.", + )); + } + entry_sizes.insert(name, entry.size()); + } + if entry_sizes.is_empty() || entry_sizes.len() > APPEARANCE_MARKET_MAX_ENTRIES { + return Err(error( + "invalid_entry_count", + format!( + "Appearance packages must contain between 1 and {} files.", + APPEARANCE_MARKET_MAX_ENTRIES + ), + )); + } + + let manifest_size = entry_sizes.get(MANIFEST_PATH).copied().ok_or_else(|| { + error( + "missing_manifest", + format!("The Appearance package is missing {MANIFEST_PATH}."), + ) + })?; + if manifest_size > APPEARANCE_MARKET_MAX_MANIFEST_BYTES { + return Err(error( + "manifest_too_large", + "The Appearance manifest exceeds 256 KiB.", + )); + } + let manifest_bytes = read_entry(&mut archive, MANIFEST_PATH)?; + let raw_manifest: serde_json::Value = + serde_json::from_slice(&manifest_bytes).map_err(|source| { + error( + "invalid_manifest", + format!("appearance.json is not valid JSON: {source}"), + ) + })?; + for field in [ + "globals", + "materials", + "components", + "scenes", + "renderers", + "backgroundMedia", + ] { + if let Some(value) = raw_manifest.get(field) { + reject_remote_or_executable_values(value, &format!("$.{field}"))?; + } + } + let manifest: AppearanceManifest = serde_json::from_value(raw_manifest).map_err(|source| { + error( + "invalid_manifest", + format!("appearance.json does not match the Appearance package contract: {source}"), + ) + })?; + validate_manifest_metadata(&manifest)?; + + let mut declared_paths = HashMap::new(); + for (asset_id, asset) in &manifest.assets { + validate_identifier(asset_id, "asset id")?; + if asset.source.kind != "package" { + return Err(error( + "external_asset_forbidden", + format!("Appearance asset {asset_id} must use a package source."), + )); + } + validate_package_path(&asset.source.path)?; + if declared_paths + .insert(asset.source.path.clone(), asset_id.as_str()) + .is_some() + { + return Err(error( + "duplicate_asset_path", + format!( + "Multiple Appearance assets declare the package path {}.", + asset.source.path + ), + )); + } + if !entry_sizes.contains_key(&asset.source.path) { + return Err(error( + "missing_asset", + format!( + "Declared Appearance asset is missing: {}", + asset.source.path + ), + )); + } + } + for path in entry_sizes.keys() { + if path != MANIFEST_PATH && !declared_paths.contains_key(path) { + return Err(error( + "undeclared_package_entry", + format!("The Appearance package contains an undeclared file: {path}"), + )); + } + } + + for (asset_id, asset) in &manifest.assets { + let size = entry_sizes[&asset.source.path]; + let max_size = match asset.kind.as_str() { + "image" => MAX_IMAGE_BYTES, + "video" => MAX_VIDEO_BYTES, + _ => { + return Err(error( + "invalid_asset_kind", + format!("Appearance asset {asset_id} has an unsupported kind."), + )); + } + }; + if size == 0 || size > max_size { + return Err(error( + "invalid_asset_size", + format!( + "Appearance asset {} has an invalid size.", + asset.source.path + ), + )); + } + } + + if manifest.preview.kind != "asset" { + return Err(error( + "invalid_preview", + "The marketplace preview must reference a packaged image asset.", + )); + } + let preview_asset = manifest + .assets + .get(&manifest.preview.asset_id) + .ok_or_else(|| { + error( + "invalid_preview", + "The marketplace preview asset is missing.", + ) + })?; + if preview_asset.kind != "image" { + return Err(error( + "invalid_preview", + "The marketplace preview must reference an image asset.", + )); + } + if entry_sizes[&preview_asset.source.path] > APPEARANCE_MARKET_MAX_PREVIEW_BYTES { + return Err(error( + "preview_too_large", + "The marketplace preview exceeds 4 MiB.", + )); + } + + let mut preview = None; + for (asset_id, asset) in &manifest.assets { + let asset_bytes = read_entry(&mut archive, &asset.source.path)?; + validate_asset_magic(asset_id, asset, &asset_bytes)?; + let digest = hex::encode(Sha256::digest(&asset_bytes)); + if let Some(expected) = manifest + .integrity + .as_ref() + .and_then(|integrity| integrity.sha256.get(asset_id)) + { + if !is_sha256(expected) || digest != expected.to_ascii_lowercase() { + return Err(error( + "asset_integrity_mismatch", + format!("Appearance asset integrity mismatch: {}", asset.source.path), + )); + } + } + if asset_id == &manifest.preview.asset_id { + let (width, height) = image_dimensions(&asset_bytes, &asset.mime_type)?; + preview = Some(ValidatedAppearancePreview { + bytes: asset_bytes, + mime_type: asset.mime_type.clone(), + sha256: digest, + width, + height, + }); + } + } + if let Some(integrity) = &manifest.integrity { + for asset_id in integrity.sha256.keys() { + if !manifest.assets.contains_key(asset_id) { + return Err(error( + "unknown_integrity_asset", + format!("Appearance integrity references unknown asset {asset_id}."), + )); + } + } + } + + Ok(ValidatedAppearanceMarketPackage { + sha256: hex::encode(Sha256::digest(bytes)), + size: bytes.len() as u64, + meta: AppearanceMarketPackageMeta { + package_id: manifest.id, + name: manifest.name, + description: manifest.description, + author: manifest.author, + mode: manifest.mode, + package_version: manifest.version, + required_capabilities: manifest.required_capabilities, + }, + preview: preview.expect("validated preview asset must be loaded"), + }) +} + +fn validate_manifest_metadata( + manifest: &AppearanceManifest, +) -> Result<(), AppearanceMarketPackageError> { + if manifest.schema != "bitfun.appearance" || manifest.schema_version != 1 { + return Err(error( + "unsupported_manifest", + "The package must use bitfun.appearance schema version 1.", + )); + } + validate_identifier(&manifest.id, "package id")?; + if manifest.name.trim().is_empty() || manifest.name.len() > 100 { + return Err(error( + "invalid_name", + "Appearance names must contain between 1 and 100 bytes.", + )); + } + if manifest + .author + .as_ref() + .is_some_and(|value| value.len() > 100) + || manifest.description.len() > 500 + { + return Err(error( + "invalid_metadata", + "Appearance author or description metadata is too long.", + )); + } + semver::Version::parse(&manifest.version).map_err(|_| { + error( + "invalid_version", + "The Appearance version must use semantic version syntax.", + ) + })?; + let mut seen_capabilities = HashSet::new(); + for capability in &manifest.required_capabilities { + if !SUPPORTED_CAPABILITIES.contains(&capability.as_str()) + || !seen_capabilities.insert(capability) + { + return Err(error( + "unsupported_capability", + format!("Unsupported or duplicate Appearance capability: {capability}"), + )); + } + } + if let Some(background) = &manifest.background_media { + let video = manifest.assets.get(&background.asset_id); + let poster = manifest.assets.get(&background.poster_asset_id); + if background.kind != "video" + || !matches!(video, Some(asset) if asset.kind == "video") + || !matches!(poster, Some(asset) if asset.kind == "image") + || !manifest + .required_capabilities + .iter() + .any(|value| value == "background-media.v1") + || background + .fit + .as_deref() + .is_some_and(|value| !matches!(value, "cover" | "contain")) + || background.position.as_deref().is_some_and(|value| { + !matches!(value, "center" | "top" | "right" | "bottom" | "left") + }) + { + return Err(error( + "invalid_background_media", + "Appearance background media must reference one video and one image poster.", + )); + } + } + for (asset_id, asset) in &manifest.assets { + if asset.kind == "video" + && manifest + .background_media + .as_ref() + .is_none_or(|background| background.asset_id != *asset_id) + { + return Err(error( + "unused_video_asset", + "Video assets are allowed only as the selected top-level background media.", + )); + } + } + Ok(()) +} + +fn validate_identifier(value: &str, label: &str) -> Result<(), AppearanceMarketPackageError> { + let bytes = value.as_bytes(); + let valid = !bytes.is_empty() + && bytes.len() <= 100 + && bytes[0].is_ascii_lowercase() + && bytes.iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(*byte, b'.' | b'-') + }) + && !matches!(bytes.last(), Some(b'.' | b'-')) + && !value.contains("..") + && !value.contains("--") + && !value.contains(".-") + && !value.contains("-."); + if valid { + Ok(()) + } else { + Err(error( + "invalid_identifier", + format!("The Appearance {label} is not a lowercase dotted or dashed identifier."), + )) + } +} + +fn safe_entry_name( + entry: &zip::read::ZipFile<'_, R>, +) -> Result { + let name = entry.name(); + validate_package_path(name)?; + entry + .enclosed_name() + .and_then(|path| path.to_str().map(str::to_owned)) + .ok_or_else(|| { + error( + "unsafe_package_path", + "Appearance package paths must use UTF-8.", + ) + }) +} + +fn validate_package_path(path: &str) -> Result<(), AppearanceMarketPackageError> { + let bytes = path.as_bytes(); + let valid = !bytes.is_empty() + && bytes.len() <= 240 + && bytes[0].is_ascii_alphanumeric() + && !path.starts_with('/') + && !path.contains("..") + && !path.contains('\\') + && bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'_' | b'.' | b'/' | b'-')); + if valid { + Ok(()) + } else { + Err(error( + "unsafe_package_path", + format!("Unsafe Appearance package path: {path}"), + )) + } +} + +fn read_entry( + archive: &mut ZipArchive, + path: &str, +) -> Result, AppearanceMarketPackageError> { + let mut entry = archive.by_name(path).map_err(|_| { + error( + "missing_package_entry", + format!("The Appearance package is missing {path}."), + ) + })?; + let mut bytes = Vec::with_capacity(entry.size().min(4 * 1024 * 1024) as usize); + entry.read_to_end(&mut bytes).map_err(|source| { + error( + "invalid_package_entry", + format!("Could not read Appearance package entry {path}: {source}"), + ) + })?; + Ok(bytes) +} + +fn validate_asset_magic( + asset_id: &str, + asset: &PackageAsset, + bytes: &[u8], +) -> Result<(), AppearanceMarketPackageError> { + let actual = sniff_media_type(bytes).ok_or_else(|| { + error( + "unsupported_asset_format", + format!("Appearance asset {asset_id} has an unsupported file format."), + ) + })?; + if actual != asset.mime_type { + return Err(error( + "asset_mime_mismatch", + format!("Appearance asset {asset_id} does not match its declared MIME type."), + )); + } + let expected_kind = if actual.starts_with("image/") { + "image" + } else { + "video" + }; + if asset.kind != expected_kind { + return Err(error( + "asset_kind_mismatch", + format!("Appearance asset {asset_id} does not match its declared kind."), + )); + } + Ok(()) +} + +fn sniff_media_type(bytes: &[u8]) -> Option { + if bytes.len() >= 24 && bytes[0] == 0x89 && &bytes[1..4] == b"PNG" { + Some("image/png".to_string()) + } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + Some("image/gif".to_string()) + } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { + Some("image/webp".to_string()) + } else if bytes.starts_with(&[0xff, 0xd8]) { + Some("image/jpeg".to_string()) + } else if bytes.len() >= 12 && &bytes[4..8] == b"ftyp" { + Some("video/mp4".to_string()) + } else if bytes.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]) + && bytes + .windows(4) + .take(4096) + .any(|window| window.eq_ignore_ascii_case(b"webm")) + { + Some("video/webm".to_string()) + } else { + None + } +} + +fn image_dimensions( + bytes: &[u8], + mime_type: &str, +) -> Result<(u32, u32), AppearanceMarketPackageError> { + let reader = ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .map_err(|source| error("invalid_preview", source.to_string()))?; + let guessed = reader + .format() + .and_then(|format| format.to_mime_type().split('/').nth(1).map(str::to_string)); + if guessed.is_none() { + return Err(error( + "invalid_preview", + "The Appearance preview image format could not be detected.", + )); + } + let (width, height) = reader + .into_dimensions() + .map_err(|source| error("invalid_preview", source.to_string()))?; + let pixels = u64::from(width).saturating_mul(u64::from(height)); + if width == 0 + || height == 0 + || width > MAX_IMAGE_DIMENSION + || height > MAX_IMAGE_DIMENSION + || pixels > APPEARANCE_MARKET_MAX_PREVIEW_PIXELS + { + return Err(error( + "preview_dimensions_too_large", + "The Appearance preview dimensions exceed the marketplace limit.", + )); + } + if !mime_type.starts_with("image/") { + return Err(error( + "invalid_preview", + "The Appearance preview must use an image MIME type.", + )); + } + Ok((width, height)) +} + +fn reject_remote_or_executable_values( + value: &serde_json::Value, + path: &str, +) -> Result<(), AppearanceMarketPackageError> { + match value { + serde_json::Value::String(text) => { + let normalized = text.to_ascii_lowercase(); + if normalized.contains("http://") + || normalized.contains("https://") + || normalized.contains("javascript:") + || normalized.contains("data:") + || normalized.contains("url(") + || normalized.contains(" { + for (index, item) in items.iter().enumerate() { + reject_remote_or_executable_values(item, &format!("{path}[{index}]"))?; + } + } + serde_json::Value::Object(fields) => { + for (key, item) in fields { + reject_remote_or_executable_values(item, &format!("{path}.{key}"))?; + } + } + _ => {} + } + Ok(()) +} + +fn is_sha256(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn error(code: &'static str, message: impl Into) -> AppearanceMarketPackageError { + AppearanceMarketPackageError::new(code, message) +} + +#[cfg(test)] +mod tests { + use super::*; + use zip::write::SimpleFileOptions; + use zip::{CompressionMethod, ZipWriter}; + + fn package(manifest: serde_json::Value, files: &[(&str, &[u8])]) -> Vec { + let cursor = Cursor::new(Vec::new()); + let mut writer = ZipWriter::new(cursor); + let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); + writer.start_file(MANIFEST_PATH, options).unwrap(); + std::io::Write::write_all( + &mut writer, + serde_json::to_string(&manifest).unwrap().as_bytes(), + ) + .unwrap(); + writer.add_directory("assets/", options).unwrap(); + for (path, bytes) in files { + writer.start_file(*path, options).unwrap(); + std::io::Write::write_all(&mut writer, bytes).unwrap(); + } + writer.finish().unwrap().into_inner() + } + + fn png() -> Vec { + let image = image::RgbaImage::from_pixel(2, 2, image::Rgba([20, 120, 220, 255])); + let mut bytes = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut bytes, image::ImageFormat::Png) + .unwrap(); + bytes.into_inner() + } + + fn manifest() -> serde_json::Value { + serde_json::json!({ + "schema": "bitfun.appearance", + "schemaVersion": 1, + "id": "market.test", + "name": "Market Test", + "description": "A safe test appearance", + "version": "1.2.3", + "mode": "dark", + "preview": { "kind": "asset", "assetId": "hero" }, + "requiredCapabilities": ["assets.v1"], + "assets": { + "hero": { + "kind": "image", + "mimeType": "image/png", + "source": { "kind": "package", "path": "assets/hero.png" } + } + } + }) + } + + #[test] + fn validates_a_safe_marketplace_package() { + let png = png(); + let bytes = package(manifest(), &[("assets/hero.png", &png)]); + let validated = validate_appearance_market_package(&bytes).unwrap(); + assert_eq!(validated.meta.package_id, "market.test"); + assert_eq!(validated.preview.width, 2); + assert_eq!(validated.preview.mime_type, "image/png"); + } + + #[test] + fn requires_a_packaged_preview() { + let mut manifest = manifest(); + manifest.as_object_mut().unwrap().remove("preview"); + let png = png(); + let bytes = package(manifest, &[("assets/hero.png", &png)]); + let error = validate_appearance_market_package(&bytes).unwrap_err(); + assert_eq!(error.code, "invalid_manifest"); + } + + #[test] + fn rejects_undeclared_files() { + let png = png(); + let bytes = package( + manifest(), + &[("assets/hero.png", &png), ("hidden.js", b"alert(1)")], + ); + let error = validate_appearance_market_package(&bytes).unwrap_err(); + assert_eq!(error.code, "undeclared_package_entry"); + } + + #[test] + fn rejects_remote_manifest_content() { + let mut manifest = manifest(); + manifest["globals"] = serde_json::json!({ + "colors": { "remote": "url(https://example.com/tracker.png)" } + }); + let png = png(); + let bytes = package(manifest, &[("assets/hero.png", &png)]); + let error = validate_appearance_market_package(&bytes).unwrap_err(); + assert_eq!(error.code, "remote_or_executable_content_forbidden"); + } +} diff --git a/src/crates/services/services-integrations/src/appearance_market/submit.rs b/src/crates/services/services-integrations/src/appearance_market/submit.rs new file mode 100644 index 0000000000..7004b95530 --- /dev/null +++ b/src/crates/services/services-integrations/src/appearance_market/submit.rs @@ -0,0 +1,275 @@ +//! Shared Appearance submission orchestration for the desktop adapter and the +//! PublishAppearance agent tool. The public product calls these "Skins" while +//! the package and runtime contract deliberately remain Appearance. + +use super::client::AppearanceMarketClient; +use super::package::validate_appearance_market_package; +use crate::miniapp_market::MarketClientError; +use bitfun_product_domains::appearance_market::{ + AppearanceMarketSubmission, AppearanceMarketSubmissionDraftRequest, + AppearanceMarketSubmissionStatus, APPEARANCE_MARKET_MAX_PACKAGE_BYTES, +}; +use std::path::Path; + +pub type AppearanceSubmitProgress<'a> = + &'a mut (dyn FnMut(Option<&str>, &'static str, u32, u32) + Send); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AppearanceReleaseTarget { + NewListing, + ExistingListing { + listing_id: String, + next_release: u32, + }, + PendingReview { + submission_id: String, + release_number: u32, + }, +} + +pub fn resolve_appearance_release_target( + submissions: &[AppearanceMarketSubmission], + slug: &str, +) -> AppearanceReleaseTarget { + if let Some(pending) = submissions.iter().find(|submission| { + submission.slug == slug && submission.status == AppearanceMarketSubmissionStatus::Submitted + }) { + return AppearanceReleaseTarget::PendingReview { + submission_id: pending.submission_id.clone(), + release_number: pending.release_number, + }; + } + let listing_id = submissions + .iter() + .filter(|submission| submission.slug == slug) + .find_map(|submission| submission.listing_id.clone()); + let latest_approved = submissions + .iter() + .filter(|submission| { + submission.slug == slug + && submission.status == AppearanceMarketSubmissionStatus::Approved + }) + .map(|submission| submission.release_number) + .max(); + match (listing_id, latest_approved) { + (Some(listing_id), latest) => AppearanceReleaseTarget::ExistingListing { + listing_id, + next_release: latest.unwrap_or(0) + 1, + }, + _ => AppearanceReleaseTarget::NewListing, + } +} + +pub fn suggest_appearance_slug(name: &str, fallback_seed: &str) -> String { + let mut value = String::new(); + let mut last_hyphen = true; + for character in name.chars().flat_map(char::to_lowercase) { + if character.is_ascii_alphanumeric() { + value.push(character); + last_hyphen = false; + } else if !last_hyphen { + value.push('-'); + last_hyphen = true; + } + if value.len() >= 63 { + break; + } + } + let value = value.trim_matches('-').to_string(); + if value.len() >= 3 { + return value; + } + let seed = fallback_seed + .chars() + .filter(char::is_ascii_alphanumeric) + .take(8) + .collect::() + .to_ascii_lowercase(); + format!("appearance-{seed}") +} + +pub async fn submit_appearance_package( + client: &mut AppearanceMarketClient, + package_path: &Path, + draft: &AppearanceMarketSubmissionDraftRequest, + progress: AppearanceSubmitProgress<'_>, +) -> Result { + let metadata = tokio::fs::metadata(package_path).await.map_err(|source| { + submission_error( + "invalid_package_path", + format!( + "Could not read Appearance package metadata for {}: {source}", + package_path.display() + ), + ) + })?; + if !metadata.is_file() || metadata.len() == 0 { + return Err(submission_error( + "invalid_package_path", + "The Appearance package path must point to a non-empty regular file.", + )); + } + if metadata.len() > APPEARANCE_MARKET_MAX_PACKAGE_BYTES { + return Err(submission_error( + "package_too_large", + "The compressed Appearance package exceeds 96 MiB.", + )); + } + let has_expected_extension = package_path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.to_ascii_lowercase().ends_with(".bitfun-appearance")); + if !has_expected_extension { + return Err(submission_error( + "invalid_package_extension", + "Marketplace packages must use the .bitfun-appearance extension.", + )); + } + + let bytes = tokio::fs::read(package_path).await.map_err(|source| { + submission_error( + "package_read_failed", + format!( + "Could not read Appearance package {}: {source}", + package_path.display() + ), + ) + })?; + let validated = validate_appearance_market_package(&bytes).map_err(|source| { + submission_error(source.code, format!("Invalid Appearance package: {source}")) + })?; + progress(None, "validating", 1, 1); + + let submission = client.create_submission(draft).await?; + let submission_id = submission.submission_id.clone(); + progress(Some(&submission_id), "package", 0, 1); + let uploaded = client + .upload_submission_package(&submission_id, bytes) + .await?; + if uploaded.package_sha256.as_deref() != Some(validated.sha256.as_str()) + || uploaded.package_size != Some(validated.size) + || uploaded.package_id.as_deref() != Some(validated.meta.package_id.as_str()) + { + return Err(submission_error( + "uploaded_package_mismatch", + "The Skin market did not confirm the uploaded Appearance package identity.", + )); + } + progress(Some(&submission_id), "package", 1, 1); + let submission = client.submit_submission(&submission_id).await?; + progress(Some(&submission_id), "submitted", 1, 1); + Ok(submission) +} + +fn submission_error(code: impl Into, message: impl Into) -> MarketClientError { + MarketClientError { + code: code.into(), + message: message.into(), + request_id: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_product_domains::appearance_market::{ + AppearanceMarketLicense, AppearancePackageMode, + }; + + fn submission( + slug: &str, + release_number: u32, + status: AppearanceMarketSubmissionStatus, + listing_id: Option<&str>, + ) -> AppearanceMarketSubmission { + AppearanceMarketSubmission { + submission_id: format!("submission-{slug}-{release_number}"), + listing_id: listing_id.map(str::to_string), + slug: slug.to_string(), + release_number, + package_id: Some("sample.appearance".to_string()), + name: Some("Sample Appearance".to_string()), + description: Some("Sample".to_string()), + author: None, + mode: Some(AppearancePackageMode::Dark), + package_version: Some("1.0.0".to_string()), + min_bitfun_version: "0.1.0".to_string(), + required_capabilities: Vec::new(), + changelog: "Initial".to_string(), + license: AppearanceMarketLicense { + spdx_expression: Some("MIT".to_string()), + custom_url: None, + }, + repository_url: None, + status, + package_sha256: None, + package_size: None, + preview_url: None, + rejection_reason: None, + created_at: 0, + updated_at: 0, + } + } + + #[test] + fn release_target_starts_at_one() { + assert_eq!( + resolve_appearance_release_target(&[], "fresh-skin"), + AppearanceReleaseTarget::NewListing + ); + } + + #[test] + fn release_target_increments_approved_releases() { + let history = vec![ + submission( + "calm-dark", + 1, + AppearanceMarketSubmissionStatus::Approved, + Some("listing-1"), + ), + submission( + "calm-dark", + 2, + AppearanceMarketSubmissionStatus::Approved, + Some("listing-1"), + ), + ]; + assert_eq!( + resolve_appearance_release_target(&history, "calm-dark"), + AppearanceReleaseTarget::ExistingListing { + listing_id: "listing-1".to_string(), + next_release: 3, + } + ); + } + + #[test] + fn release_target_blocks_parallel_review() { + let history = vec![submission( + "calm-dark", + 2, + AppearanceMarketSubmissionStatus::Submitted, + Some("listing-1"), + )]; + assert_eq!( + resolve_appearance_release_target(&history, "calm-dark"), + AppearanceReleaseTarget::PendingReview { + submission_id: "submission-calm-dark-2".to_string(), + release_number: 2, + } + ); + } + + #[test] + fn slug_suggestion_is_ascii_and_stable() { + assert_eq!( + suggest_appearance_slug("Ocean Night!", "unused"), + "ocean-night" + ); + assert_eq!( + suggest_appearance_slug("海风", "9ab31c77-extra"), + "appearance-9ab31c77" + ); + } +} diff --git a/src/crates/services/services-integrations/src/lib.rs b/src/crates/services/services-integrations/src/lib.rs index da95fb1b3d..81b53a6afc 100644 --- a/src/crates/services/services-integrations/src/lib.rs +++ b/src/crates/services/services-integrations/src/lib.rs @@ -6,6 +6,9 @@ #[cfg(feature = "announcement")] pub mod announcement; +#[cfg(feature = "miniapp-market")] +pub mod appearance_market; + #[cfg(feature = "browser-control")] pub mod browser_control; diff --git a/src/crates/services/services-integrations/src/miniapp_market/client.rs b/src/crates/services/services-integrations/src/miniapp_market/client.rs index e40ec24270..deb17b9257 100644 --- a/src/crates/services/services-integrations/src/miniapp_market/client.rs +++ b/src/crates/services/services-integrations/src/miniapp_market/client.rs @@ -254,6 +254,17 @@ impl MarketClient { Ok(Some(decode_json(checked_response(response).await?).await?)) } + /// Returns the shared marketplace access token after applying the normal + /// refresh and expiry policy. Appearance Market uses the same desktop + /// identity without creating a second credential vault. + pub(crate) async fn access_token(&mut self) -> Result, MarketClientError> { + self.refresh_if_needed().await?; + Ok(self + .credentials + .as_ref() + .map(|credentials| credentials.access_token.clone())) + } + pub async fn set_rating( &mut self, slug: &str, diff --git a/src/crates/services/skin-market-service/Cargo.toml b/src/crates/services/skin-market-service/Cargo.toml new file mode 100644 index 0000000000..7b35abb31b --- /dev/null +++ b/src/crates/services/skin-market-service/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "bitfun-skin-market-service" +version.workspace = true +authors.workspace = true +edition.workspace = true +description = "BitFun appearance marketplace service" + +[lib] +name = "bitfun_skin_market_service" + +[dependencies] +anyhow = { workspace = true } +axum = { workspace = true } +base64 = { workspace = true } +bitfun-product-domains = { path = "../../contracts/product-domains", features = ["appearance-market"] } +chrono = { workspace = true } +hex = { workspace = true } +image = { workspace = true } +hmac = { workspace = true } +reqwest = { workspace = true, features = ["rustls"] } +semver = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite"] } +tokio = { workspace = true, features = ["fs", "rt", "sync", "time"] } +tokio-util = { workspace = true, features = ["io"] } +tower-http = { version = "0.6.11", features = ["fs"] } +tracing = { workspace = true } +url = { workspace = true } +uuid = { workspace = true } +zip = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "net"] } +tower = { version = "0.5", features = ["util"] } + +[lints] +workspace = true diff --git a/src/crates/services/skin-market-service/README.md b/src/crates/services/skin-market-service/README.md new file mode 100644 index 0000000000..2330322431 --- /dev/null +++ b/src/crates/services/skin-market-service/README.md @@ -0,0 +1,40 @@ +# Skin market service + +This crate owns the concrete HTTP, SQLite, artifact, validation, review and +retention behavior for the public Skin catalog. “Skin” is product copy only; +the package/runtime contract remains Appearance (`.bitfun-appearance`, +`appearance.json`, `bitfun.appearance`). Stable DTOs and pure state policy live +in `bitfun-product-domains::appearance_market`. + +The service is isolated from the MiniApp market database and artifacts. It +does not own OAuth credentials: authenticated Desktop requests carry the +existing MiniApp market Bearer token, which is forwarded only to the configured +MiniApp `/me` endpoint. Browser contribution and review routes use the +MiniApp broker's `/skin`-scoped session aliases. Unsafe requests are verified +with the matching CSRF cookie and header through `POST /me`; unrelated browser +cookies are never forwarded. + +Key invariants: + +- approved releases are immutable; yank/unpublish are explicit moderation + state, never in-place artifact rewrites; +- package SHA, canonical review metadata and normalized preview SHA bind the + review bundle hash; +- only declared package-local raster/video assets are accepted; preview output + is normalized to same-origin WebP; +- listing slugs and package IDs cannot be transferred between owners through + an update submission; +- upload size, expansion, entry count, media dimensions and MIME are bounded + before publication; +- retention removes only unreferenced, expired draft artifacts. + +Focused verification: + +```bash +cargo test -p bitfun-product-domains --no-default-features --features appearance-market +cargo test -p bitfun-skin-market-service +cargo check -p bitfun-skin-market-server +``` + +Production deployment, backup and rollback are documented in +`deploy/skin-market/README.md`. diff --git a/src/crates/services/skin-market-service/migrations/0001_init.sql b/src/crates/services/skin-market-service/migrations/0001_init.sql new file mode 100644 index 0000000000..6460587faf --- /dev/null +++ b/src/crates/services/skin-market-service/migrations/0001_init.sql @@ -0,0 +1,90 @@ +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + github_id INTEGER NOT NULL UNIQUE, + login TEXT NOT NULL, + avatar_url TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE listings ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + package_id TEXT NOT NULL UNIQUE, + owner_user_id INTEGER NOT NULL REFERENCES users(id), + latest_release_id TEXT, + is_published INTEGER NOT NULL DEFAULT 0, + download_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX idx_skin_listings_owner ON listings(owner_user_id); +CREATE INDEX idx_skin_listings_published ON listings(is_published, updated_at DESC); + +CREATE TABLE submissions ( + id TEXT PRIMARY KEY, + listing_id TEXT REFERENCES listings(id), + owner_user_id INTEGER NOT NULL REFERENCES users(id), + slug TEXT NOT NULL, + release_number INTEGER NOT NULL, + draft_json TEXT NOT NULL, + package_meta_json TEXT, + manifest_json TEXT, + status TEXT NOT NULL CHECK(status IN ('draft', 'submitted', 'approved', 'rejected', 'withdrawn')), + package_sha256 TEXT, + package_size INTEGER, + preview_sha256 TEXT, + rejection_reason TEXT, + submitted_at INTEGER, + reviewed_at INTEGER, + reviewer_user_id INTEGER REFERENCES users(id), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX idx_skin_submissions_owner ON submissions(owner_user_id, updated_at DESC); +CREATE INDEX idx_skin_submissions_status ON submissions(status, submitted_at); +CREATE UNIQUE INDEX idx_skin_submissions_active_new_slug + ON submissions(slug) + WHERE listing_id IS NULL AND status IN ('draft', 'submitted'); +CREATE UNIQUE INDEX idx_skin_submissions_active_release + ON submissions(listing_id, release_number) + WHERE listing_id IS NOT NULL AND status IN ('draft', 'submitted'); + +CREATE TABLE releases ( + id TEXT PRIMARY KEY, + listing_id TEXT NOT NULL REFERENCES listings(id), + submission_id TEXT NOT NULL UNIQUE REFERENCES submissions(id), + release_number INTEGER NOT NULL, + draft_json TEXT NOT NULL, + package_meta_json TEXT NOT NULL, + manifest_json TEXT NOT NULL, + package_sha256 TEXT NOT NULL, + package_size INTEGER NOT NULL, + preview_sha256 TEXT NOT NULL, + review_bundle_hash TEXT NOT NULL, + published_at INTEGER NOT NULL, + yanked_at INTEGER, + yank_reason TEXT, + UNIQUE(listing_id, release_number) +); +CREATE INDEX idx_skin_releases_listing ON releases(listing_id, release_number DESC); + +CREATE TABLE download_days ( + listing_id TEXT NOT NULL REFERENCES listings(id) ON DELETE CASCADE, + day TEXT NOT NULL, + visitor_hash TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY(listing_id, day, visitor_hash) +); +CREATE INDEX idx_skin_download_days_listing ON download_days(listing_id); + +CREATE TABLE audit_log ( + id TEXT PRIMARY KEY, + actor_user_id INTEGER REFERENCES users(id), + action TEXT NOT NULL, + target_kind TEXT NOT NULL, + target_id TEXT NOT NULL, + details_json TEXT NOT NULL, + created_at INTEGER NOT NULL +); +CREATE INDEX idx_skin_audit_target ON audit_log(target_kind, target_id, created_at DESC); diff --git a/src/crates/services/skin-market-service/src/artifacts.rs b/src/crates/services/skin-market-service/src/artifacts.rs new file mode 100644 index 0000000000..0727d6af14 --- /dev/null +++ b/src/crates/services/skin-market-service/src/artifacts.rs @@ -0,0 +1,229 @@ +use crate::error::{SkinMarketError, SkinMarketResult}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::SystemTime; +use tokio::sync::{Mutex, OwnedMutexGuard}; +use uuid::Uuid; + +#[derive(Debug, Clone)] +pub(crate) struct ArtifactStore { + root: PathBuf, + mutation_lock: Arc>, +} + +pub(crate) struct ArtifactMutationGuard { + _guard: OwnedMutexGuard<()>, +} + +impl ArtifactStore { + pub(crate) async fn open(root: PathBuf) -> anyhow::Result { + tokio::fs::create_dir_all(root.join("packages")).await?; + tokio::fs::create_dir_all(root.join("previews")).await?; + tokio::fs::create_dir_all(root.join(".tmp")).await?; + Ok(Self { + root, + mutation_lock: Arc::new(Mutex::new(())), + }) + } + + pub(crate) async fn lock_mutations(&self) -> ArtifactMutationGuard { + ArtifactMutationGuard { + _guard: self.mutation_lock.clone().lock_owned().await, + } + } + + fn package_path(&self, sha256: &str) -> PathBuf { + content_path(&self.root.join("packages"), sha256, "bitfun-appearance") + } + + fn preview_path(&self, sha256: &str) -> PathBuf { + content_path(&self.root.join("previews"), sha256, "webp") + } + + pub(crate) async fn put_package( + &self, + _guard: &ArtifactMutationGuard, + sha256: &str, + bytes: &[u8], + ) -> SkinMarketResult<()> { + self.put_atomic(&self.package_path(sha256), bytes).await + } + + pub(crate) async fn put_preview( + &self, + _guard: &ArtifactMutationGuard, + sha256: &str, + bytes: &[u8], + ) -> SkinMarketResult<()> { + self.put_atomic(&self.preview_path(sha256), bytes).await + } + + pub(crate) async fn open_package( + &self, + sha256: &str, + ) -> SkinMarketResult<(tokio::fs::File, u64)> { + let path = self.package_path(sha256); + let file = tokio::fs::File::open(&path).await.map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + SkinMarketError::not_found("Appearance package artifact was not found.") + } else { + SkinMarketError::internal(error) + } + })?; + let size = file + .metadata() + .await + .map_err(SkinMarketError::internal)? + .len(); + Ok((file, size)) + } + + pub(crate) async fn read_preview(&self, sha256: &str) -> SkinMarketResult> { + read_artifact( + self.preview_path(sha256), + "Appearance preview artifact was not found.", + ) + .await + } + + async fn put_atomic(&self, target: &Path, bytes: &[u8]) -> SkinMarketResult<()> { + if target.exists() { + return Ok(()); + } + let parent = target + .parent() + .ok_or_else(|| SkinMarketError::internal("artifact path has no parent"))?; + tokio::fs::create_dir_all(parent) + .await + .map_err(SkinMarketError::internal)?; + let temporary = self + .root + .join(".tmp") + .join(format!("{}.part", Uuid::new_v4())); + tokio::fs::write(&temporary, bytes) + .await + .map_err(SkinMarketError::internal)?; + match tokio::fs::rename(&temporary, target).await { + Ok(()) => Ok(()), + Err(_) if target.exists() => { + let _ = tokio::fs::remove_file(&temporary).await; + Ok(()) + } + Err(error) => { + let _ = tokio::fs::remove_file(&temporary).await; + Err(SkinMarketError::internal(error)) + } + } + } + + pub(crate) async fn remove_package( + &self, + _guard: &ArtifactMutationGuard, + sha256: &str, + ) -> anyhow::Result { + remove_if_exists(&self.package_path(sha256)).await + } + + pub(crate) async fn remove_preview( + &self, + _guard: &ArtifactMutationGuard, + sha256: &str, + ) -> anyhow::Result { + remove_if_exists(&self.preview_path(sha256)).await + } + + pub(crate) async fn package_hashes_older_than( + &self, + cutoff: SystemTime, + ) -> anyhow::Result> { + hashes_older_than(&self.root.join("packages"), "bitfun-appearance", cutoff).await + } + + pub(crate) async fn preview_hashes_older_than( + &self, + cutoff: SystemTime, + ) -> anyhow::Result> { + hashes_older_than(&self.root.join("previews"), "webp", cutoff).await + } + + pub(crate) async fn remove_temporary_older_than( + &self, + _guard: &ArtifactMutationGuard, + cutoff: SystemTime, + ) -> anyhow::Result { + let root = self.root.join(".tmp"); + let mut removed = 0; + let mut entries = tokio::fs::read_dir(&root).await?; + while let Some(entry) = entries.next_entry().await? { + let file_name = entry.file_name(); + if !entry.file_type().await?.is_file() + || !file_name.to_string_lossy().ends_with(".part") + || entry.metadata().await?.modified()? > cutoff + { + continue; + } + if remove_if_exists(&entry.path()).await? { + removed += 1; + } + } + Ok(removed) + } +} + +fn content_path(root: &Path, sha256: &str, extension: &str) -> PathBuf { + root.join(sha256.get(..2).unwrap_or("00")) + .join(format!("{sha256}.{extension}")) +} + +async fn read_artifact(path: PathBuf, not_found: &'static str) -> SkinMarketResult> { + tokio::fs::read(path).await.map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + SkinMarketError::not_found(not_found) + } else { + SkinMarketError::internal(error) + } + }) +} + +async fn remove_if_exists(path: &Path) -> anyhow::Result { + match tokio::fs::remove_file(path).await { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + } +} + +async fn hashes_older_than( + root: &Path, + extension: &str, + cutoff: SystemTime, +) -> anyhow::Result> { + let mut hashes = Vec::new(); + let mut prefixes = tokio::fs::read_dir(root).await?; + while let Some(prefix) = prefixes.next_entry().await? { + if !prefix.file_type().await?.is_dir() { + continue; + } + let mut entries = tokio::fs::read_dir(prefix.path()).await?; + while let Some(entry) = entries.next_entry().await? { + if !entry.file_type().await?.is_file() || entry.metadata().await?.modified()? > cutoff { + continue; + } + let file_name = entry.file_name(); + let Some(file_name) = file_name.to_str() else { + continue; + }; + let Some(hash) = file_name.strip_suffix(&format!(".{extension}")) else { + continue; + }; + if hash.len() == 64 + && hash + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + hashes.push(hash.to_string()); + } + } + } + Ok(hashes) +} diff --git a/src/crates/services/skin-market-service/src/auth.rs b/src/crates/services/skin-market-service/src/auth.rs new file mode 100644 index 0000000000..7c402a90b5 --- /dev/null +++ b/src/crates/services/skin-market-service/src/auth.rs @@ -0,0 +1,261 @@ +use crate::db::{Database, LocalUser}; +use crate::error::{SkinMarketError, SkinMarketResult}; +use axum::http::{header, HeaderMap}; +use bitfun_product_domains::appearance_market::AppearanceMarketUserSummary; +use reqwest::Client; +use serde::Deserialize; +use std::time::Duration; +use url::Url; + +const SKIN_SESSION_COOKIE: &str = "bitfun_skin_session"; +const SKIN_CSRF_COOKIE: &str = "bitfun_skin_csrf"; + +#[derive(Debug, Clone)] +pub(crate) struct IdentityVerifier { + client: Client, + me_url: Url, +} + +#[derive(Debug, Clone)] +pub(crate) struct AuthenticatedIdentity { + pub user: LocalUser, + pub is_admin: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct IdentityResponse { + user: AppearanceMarketUserSummary, + is_admin: bool, +} + +impl IdentityVerifier { + pub(crate) fn new(me_url: Url) -> anyhow::Result { + Ok(Self { + client: Client::builder() + .connect_timeout(Duration::from_secs(3)) + .timeout(Duration::from_secs(5)) + .redirect(reqwest::redirect::Policy::none()) + .build()?, + me_url, + }) + } + + pub(crate) async fn require( + &self, + headers: &HeaderMap, + database: &Database, + ) -> SkinMarketResult { + self.verify(headers, database, false).await + } + + pub(crate) async fn require_write( + &self, + headers: &HeaderMap, + database: &Database, + ) -> SkinMarketResult { + self.verify(headers, database, true).await + } + + async fn verify( + &self, + headers: &HeaderMap, + database: &Database, + write: bool, + ) -> SkinMarketResult { + let authorization = headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .filter(|value| { + value + .strip_prefix("Bearer ") + .is_some_and(|token| !token.trim().is_empty()) + }); + let mut request = if write { + self.client.post(self.me_url.clone()) + } else { + self.client.get(self.me_url.clone()) + }; + if let Some(authorization) = authorization { + request = request.header(reqwest::header::AUTHORIZATION, authorization); + } else { + let session = cookie_value(headers, SKIN_SESSION_COOKIE) + .ok_or_else(|| SkinMarketError::unauthorized("Sign in with GitHub to continue."))?; + let mut cookies = format!("{SKIN_SESSION_COOKIE}={session}"); + if write { + let csrf_cookie = cookie_value(headers, SKIN_CSRF_COOKIE).ok_or_else(|| { + SkinMarketError::forbidden("The CSRF token is missing or invalid.") + })?; + let csrf_header = headers + .get("x-csrf-token") + .and_then(|value| value.to_str().ok()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SkinMarketError::forbidden("The CSRF token is missing or invalid.") + })?; + cookies.push_str(&format!("; {SKIN_CSRF_COOKIE}={csrf_cookie}")); + request = request.header("x-csrf-token", csrf_header); + } + request = request.header(reqwest::header::COOKIE, cookies); + } + let response = request.send().await.map_err(|_| { + SkinMarketError::unavailable("The identity service could not be reached.") + })?; + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err(SkinMarketError::unauthorized( + "The GitHub marketplace session is invalid or expired.", + )); + } + if response.status() == reqwest::StatusCode::FORBIDDEN { + return Err(SkinMarketError::forbidden( + "The CSRF token is missing or invalid.", + )); + } + if !response.status().is_success() { + return Err(SkinMarketError::unavailable( + "The identity service rejected the verification request.", + )); + } + let identity: IdentityResponse = response.json().await.map_err(|_| { + SkinMarketError::unavailable("The identity service returned an invalid response.") + })?; + if identity.user.github_id <= 0 + || identity.user.login.trim().is_empty() + || identity.user.login.len() > 100 + || identity.user.avatar_url.len() > 2_048 + { + return Err(SkinMarketError::unavailable( + "The identity service returned an invalid user profile.", + )); + } + Ok(AuthenticatedIdentity { + user: database.upsert_user(&identity.user).await?, + is_admin: identity.is_admin, + }) + } + + pub(crate) async fn require_admin( + &self, + headers: &HeaderMap, + database: &Database, + ) -> SkinMarketResult { + let identity = self.require(headers, database).await?; + if !identity.is_admin { + return Err(SkinMarketError::forbidden( + "Appearance marketplace administrator access is required.", + )); + } + Ok(identity) + } + + pub(crate) async fn require_admin_write( + &self, + headers: &HeaderMap, + database: &Database, + ) -> SkinMarketResult { + let identity = self.require_write(headers, database).await?; + if !identity.is_admin { + return Err(SkinMarketError::forbidden( + "Appearance marketplace administrator access is required.", + )); + } + Ok(identity) + } +} + +fn cookie_value(headers: &HeaderMap, name: &str) -> Option { + headers + .get(header::COOKIE)? + .to_str() + .ok()? + .split(';') + .map(str::trim) + .filter_map(|cookie| cookie.split_once('=')) + .find_map(|(candidate, value)| (candidate == name).then(|| value.to_string())) + .filter(|value| !value.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{Json, Router}; + + #[tokio::test] + async fn bearer_identity_is_forwarded_for_write_verification() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let app = Router::new().route( + "/me", + axum::routing::post(|headers: HeaderMap| async move { + assert_eq!( + headers.get(header::AUTHORIZATION).unwrap(), + "Bearer test-token" + ); + Json(serde_json::json!({ + "user": {"githubId": 42, "login": "owner", "avatarUrl": "https://example.invalid/a"}, + "isAdmin": true + })) + }), + ); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let temporary = tempfile::tempdir().unwrap(); + let database = Database::open(&temporary.path().join("market.sqlite")) + .await + .unwrap(); + let verifier = + IdentityVerifier::new(Url::parse(&format!("http://{address}/me")).unwrap()).unwrap(); + let mut headers = HeaderMap::new(); + headers.insert(header::AUTHORIZATION, "Bearer test-token".parse().unwrap()); + let identity = verifier + .require_admin_write(&headers, &database) + .await + .unwrap(); + assert!(identity.user.internal_id > 0); + } + + #[tokio::test] + async fn skin_cookie_write_forwards_only_shared_session_and_csrf() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let app = Router::new().route( + "/me", + axum::routing::post(|headers: HeaderMap| async move { + assert_eq!( + headers.get(header::COOKIE).unwrap(), + "bitfun_skin_session=session-token; bitfun_skin_csrf=csrf-token" + ); + assert_eq!(headers.get("x-csrf-token").unwrap(), "csrf-token"); + assert!(headers.get(header::AUTHORIZATION).is_none()); + Json(serde_json::json!({ + "user": {"githubId": 42, "login": "owner", "avatarUrl": "https://example.invalid/a"}, + "isAdmin": true + })) + }), + ); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let temporary = tempfile::tempdir().unwrap(); + let database = Database::open(&temporary.path().join("market.sqlite")) + .await + .unwrap(); + let verifier = + IdentityVerifier::new(Url::parse(&format!("http://{address}/me")).unwrap()).unwrap(); + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + "unrelated=private; bitfun_skin_session=session-token; bitfun_skin_csrf=csrf-token" + .parse() + .unwrap(), + ); + headers.insert("x-csrf-token", "csrf-token".parse().unwrap()); + + let identity = verifier + .require_admin_write(&headers, &database) + .await + .unwrap(); + assert!(identity.user.internal_id > 0); + } +} diff --git a/src/crates/services/skin-market-service/src/config.rs b/src/crates/services/skin-market-service/src/config.rs new file mode 100644 index 0000000000..e6435af74f --- /dev/null +++ b/src/crates/services/skin-market-service/src/config.rs @@ -0,0 +1,114 @@ +use anyhow::{anyhow, Context}; +use std::env; +use std::net::SocketAddr; +use std::path::PathBuf; +use url::Url; + +#[derive(Debug, Clone)] +pub struct SkinMarketConfig { + pub bind: SocketAddr, + pub public_base_url: String, + pub database_path: PathBuf, + pub artifact_dir: PathBuf, + pub web_dir: PathBuf, + pub identity_me_url: Url, + pub download_hash_secret: String, + pub public_browse: bool, +} + +impl SkinMarketConfig { + pub fn from_env() -> anyhow::Result { + let bind = env::var("SKIN_MARKET_BIND") + .unwrap_or_else(|_| "127.0.0.1:9720".to_string()) + .parse() + .context("SKIN_MARKET_BIND must be a socket address")?; + let public_base_url = env::var("SKIN_MARKET_PUBLIC_BASE_URL") + .unwrap_or_else(|_| "http://127.0.0.1:9720/skin".to_string()) + .trim_end_matches('/') + .to_string(); + let public_url = Url::parse(&public_base_url) + .context("SKIN_MARKET_PUBLIC_BASE_URL must be an absolute URL")?; + if !matches!(public_url.scheme(), "http" | "https") { + return Err(anyhow!( + "SKIN_MARKET_PUBLIC_BASE_URL must use http or https" + )); + } + if !public_url.username().is_empty() || public_url.password().is_some() { + return Err(anyhow!( + "SKIN_MARKET_PUBLIC_BASE_URL must not contain credentials" + )); + } + if !cfg!(debug_assertions) && public_url.scheme() != "https" { + return Err(anyhow!( + "SKIN_MARKET_PUBLIC_BASE_URL must use https in release builds" + )); + } + let identity_me_url = Url::parse( + &env::var("SKIN_MARKET_IDENTITY_ME_URL") + .unwrap_or_else(|_| "http://127.0.0.1:9710/miniapp/api/v1/me".to_string()), + ) + .context("SKIN_MARKET_IDENTITY_ME_URL must be an absolute URL")?; + if !matches!(identity_me_url.scheme(), "http" | "https") { + return Err(anyhow!( + "SKIN_MARKET_IDENTITY_ME_URL must use http or https" + )); + } + if !identity_me_url.username().is_empty() || identity_me_url.password().is_some() { + return Err(anyhow!( + "SKIN_MARKET_IDENTITY_ME_URL must not contain credentials" + )); + } + if !cfg!(debug_assertions) && identity_me_url.scheme() != "https" { + return Err(anyhow!( + "SKIN_MARKET_IDENTITY_ME_URL must use https in release builds" + )); + } + let data_dir = PathBuf::from( + env::var("SKIN_MARKET_DATA_DIR") + .unwrap_or_else(|_| "./var/skin-market/data".to_string()), + ); + const DEVELOPMENT_SECRET: &str = "development-only-change-me"; + const EXAMPLE_SECRET: &str = "replace-with-at-least-32-random-characters"; + let download_hash_secret = match env::var("SKIN_MARKET_DOWNLOAD_HASH_SECRET") { + Ok(value) => value, + Err(_) if cfg!(debug_assertions) => DEVELOPMENT_SECRET.to_string(), + Err(_) => { + return Err(anyhow!( + "SKIN_MARKET_DOWNLOAD_HASH_SECRET is required in release builds" + )) + } + }; + if !cfg!(debug_assertions) + && (download_hash_secret.len() < 32 + || download_hash_secret.trim() != download_hash_secret + || matches!( + download_hash_secret.as_str(), + DEVELOPMENT_SECRET | EXAMPLE_SECRET + )) + { + return Err(anyhow!( + "SKIN_MARKET_DOWNLOAD_HASH_SECRET must contain at least 32 non-default characters" + )); + } + Ok(Self { + bind, + public_base_url, + database_path: env::var("SKIN_MARKET_DATABASE_PATH") + .map(PathBuf::from) + .unwrap_or_else(|_| data_dir.join("market.sqlite")), + artifact_dir: PathBuf::from( + env::var("SKIN_MARKET_ARTIFACT_DIR") + .unwrap_or_else(|_| "./var/skin-market/artifacts".to_string()), + ), + web_dir: PathBuf::from( + env::var("SKIN_MARKET_WEB_DIR") + .unwrap_or_else(|_| "./src/skin-market-web/dist".to_string()), + ), + identity_me_url, + download_hash_secret, + public_browse: env::var("SKIN_MARKET_PUBLIC_BROWSE") + .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes")) + .unwrap_or(true), + }) + } +} diff --git a/src/crates/services/skin-market-service/src/db.rs b/src/crates/services/skin-market-service/src/db.rs new file mode 100644 index 0000000000..0b871eb6c6 --- /dev/null +++ b/src/crates/services/skin-market-service/src/db.rs @@ -0,0 +1,103 @@ +use crate::error::{SkinMarketError, SkinMarketResult}; +use bitfun_product_domains::appearance_market::AppearanceMarketUserSummary; +use chrono::Utc; +use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}; +use sqlx::{Pool, Row, Sqlite}; +use std::path::Path; + +const INITIAL_MIGRATION: &str = include_str!("../migrations/0001_init.sql"); + +#[derive(Debug, Clone)] +pub(crate) struct Database { + pool: Pool, +} + +#[derive(Debug, Clone)] +pub(crate) struct LocalUser { + pub internal_id: i64, +} + +impl Database { + pub(crate) async fn open(path: &Path) -> anyhow::Result { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let options = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true) + .foreign_keys(true) + .journal_mode(SqliteJournalMode::Wal) + .synchronous(SqliteSynchronous::Normal) + .busy_timeout(std::time::Duration::from_secs(5)); + let pool = SqlitePoolOptions::new() + .max_connections(8) + .connect_with(options) + .await?; + let database = Self { pool }; + database.migrate().await?; + Ok(database) + } + + pub(crate) fn pool(&self) -> &Pool { + &self.pool + } + + async fn migrate(&self) -> anyhow::Result<()> { + sqlx::query( + "CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL + )", + ) + .execute(&self.pool) + .await?; + let applied: Option<(i64,)> = + sqlx::query_as("SELECT version FROM schema_migrations WHERE version = 1") + .fetch_optional(&self.pool) + .await?; + if applied.is_none() { + let mut transaction = self.pool.begin().await?; + sqlx::raw_sql(INITIAL_MIGRATION) + .execute(&mut *transaction) + .await?; + sqlx::query("INSERT INTO schema_migrations(version, applied_at) VALUES(1, ?)") + .bind(Utc::now().timestamp()) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + } + Ok(()) + } + + pub(crate) async fn upsert_user( + &self, + profile: &AppearanceMarketUserSummary, + ) -> SkinMarketResult { + let now = Utc::now().timestamp(); + sqlx::query( + "INSERT INTO users(github_id, login, avatar_url, created_at, updated_at) + VALUES(?, ?, ?, ?, ?) + ON CONFLICT(github_id) DO UPDATE SET + login = excluded.login, + avatar_url = excluded.avatar_url, + updated_at = excluded.updated_at", + ) + .bind(profile.github_id) + .bind(&profile.login) + .bind(&profile.avatar_url) + .bind(now) + .bind(now) + .execute(&self.pool) + .await + .map_err(SkinMarketError::internal)?; + let row = + sqlx::query("SELECT id, github_id, login, avatar_url FROM users WHERE github_id = ?") + .bind(profile.github_id) + .fetch_one(&self.pool) + .await + .map_err(SkinMarketError::internal)?; + Ok(LocalUser { + internal_id: row.get("id"), + }) + } +} diff --git a/src/crates/services/skin-market-service/src/error.rs b/src/crates/services/skin-market-service/src/error.rs new file mode 100644 index 0000000000..0c6003a9af --- /dev/null +++ b/src/crates/services/skin-market-service/src/error.rs @@ -0,0 +1,111 @@ +use axum::http::{header::HeaderName, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Serialize; +use serde_json::Value; + +pub type SkinMarketResult = Result; + +#[derive(Debug)] +pub struct SkinMarketError { + pub status: StatusCode, + pub code: &'static str, + pub message: String, + pub details: Option, +} + +impl SkinMarketError { + pub fn new(status: StatusCode, code: &'static str, message: impl Into) -> Self { + Self { + status, + code, + message: message.into(), + details: None, + } + } + + pub fn bad_request(code: &'static str, message: impl Into) -> Self { + Self::new(StatusCode::BAD_REQUEST, code, message) + } + + pub fn unauthorized(message: impl Into) -> Self { + Self::new(StatusCode::UNAUTHORIZED, "unauthorized", message) + } + + pub fn forbidden(message: impl Into) -> Self { + Self::new(StatusCode::FORBIDDEN, "forbidden", message) + } + + pub fn not_found(message: impl Into) -> Self { + Self::new(StatusCode::NOT_FOUND, "not_found", message) + } + + pub fn conflict(code: &'static str, message: impl Into) -> Self { + Self::new(StatusCode::CONFLICT, code, message) + } + + pub fn unavailable(message: impl Into) -> Self { + Self::new( + StatusCode::SERVICE_UNAVAILABLE, + "identity_unavailable", + message, + ) + } + + pub fn payload_too_large() -> Self { + Self::new( + StatusCode::PAYLOAD_TOO_LARGE, + "payload_too_large", + "The appearance package exceeds the 96 MiB upload limit.", + ) + } + + pub fn internal(error: impl std::fmt::Display) -> Self { + tracing::error!(error = %error, "Appearance market request failed"); + Self::new( + StatusCode::INTERNAL_SERVER_ERROR, + "internal_error", + "The appearance marketplace could not complete the request.", + ) + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ErrorEnvelope { + error: ErrorBody, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ErrorBody { + code: &'static str, + message: String, + request_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + details: Option, +} + +impl IntoResponse for SkinMarketError { + fn into_response(self) -> Response { + let request_id = crate::request_id::current_or_new(); + let mut response = ( + self.status, + Json(ErrorEnvelope { + error: ErrorBody { + code: self.code, + message: self.message, + request_id: request_id.clone(), + details: self.details, + }, + }), + ) + .into_response(); + if let Ok(value) = HeaderValue::from_str(&request_id) { + response + .headers_mut() + .insert(HeaderName::from_static("x-request-id"), value); + } + response + } +} diff --git a/src/crates/services/skin-market-service/src/lib.rs b/src/crates/services/skin-market-service/src/lib.rs new file mode 100644 index 0000000000..a27c262563 --- /dev/null +++ b/src/crates/services/skin-market-service/src/lib.rs @@ -0,0 +1,664 @@ +//! Concrete HTTP, SQLite, identity verification, and artifact behavior for the appearance market. + +mod artifacts; +mod auth; +pub mod config; +mod db; +mod error; +mod package; +mod request_id; +mod retention; +mod routes; + +use artifacts::ArtifactStore; +use auth::IdentityVerifier; +use axum::extract::Request; +use axum::http::{header, HeaderValue, StatusCode}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::routing::{any, get}; +use axum::Router; +use config::SkinMarketConfig; +use db::Database; +use routes::{api_router, SkinMarketState}; +use std::sync::Arc; +use tokio::sync::Semaphore; +use tower_http::services::{ServeDir, ServeFile}; + +pub use error::{SkinMarketError, SkinMarketResult}; +pub use package::{validate_appearance_package, ValidatedAppearancePackage, ValidatedPreview}; + +pub async fn build_skin_market_router(config: SkinMarketConfig) -> anyhow::Result { + let database = Database::open(&config.database_path).await?; + let artifacts = ArtifactStore::open(config.artifact_dir.clone()).await?; + let cleanup = retention::cleanup(&database, &artifacts).await?; + if cleanup.submissions_scrubbed > 0 + || cleanup.packages_removed > 0 + || cleanup.previews_removed > 0 + || cleanup.temporary_files_removed > 0 + || cleanup.download_rows_compacted > 0 + { + tracing::info!( + submissions_scrubbed = cleanup.submissions_scrubbed, + packages_removed = cleanup.packages_removed, + previews_removed = cleanup.previews_removed, + temporary_files_removed = cleanup.temporary_files_removed, + download_rows_compacted = cleanup.download_rows_compacted, + "Appearance market startup retention cleanup completed" + ); + } + retention::spawn_cleanup_loop(database.clone(), artifacts.clone()); + let identity = IdentityVerifier::new(config.identity_me_url.clone())?; + let state = Arc::new(SkinMarketState { + config: config.clone(), + database, + artifacts, + identity, + upload_permits: Arc::new(Semaphore::new(1)), + }); + let index_file = config.web_dir.join("index.html"); + let static_files = ServeDir::new(&config.web_dir).append_index_html_on_directories(true); + + Ok(Router::new() + .route("/", get(|| async { Redirect::permanent("/skin/") })) + .nest("/skin/api/v1", api_router(state)) + .route("/skin/api", any(unknown_api_version)) + .route("/skin/api/{*rest}", any(unknown_api_version)) + .route_service( + "/skin/appearances/{slug}", + ServeFile::new(index_file.clone()), + ) + .route_service( + "/skin/appearances/{slug}/", + ServeFile::new(index_file.clone()), + ) + .route_service("/skin/submissions", ServeFile::new(index_file.clone())) + .route_service("/skin/submissions/", ServeFile::new(index_file.clone())) + .route_service("/skin/admin", ServeFile::new(index_file.clone())) + .route_service("/skin/admin/", ServeFile::new(index_file)) + .nest_service("/skin", static_files) + .layer(axum::middleware::from_fn(normalize_payload_too_large)) + .layer(axum::middleware::from_fn(security_headers)) + .layer(axum::middleware::from_fn(request_id::middleware))) +} + +async fn normalize_payload_too_large(request: Request, next: Next) -> Response { + let response = next.run(request).await; + if response.status() == StatusCode::PAYLOAD_TOO_LARGE + && !response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("application/json")) + { + SkinMarketError::payload_too_large().into_response() + } else { + response + } +} + +async fn unknown_api_version() -> SkinMarketError { + SkinMarketError::not_found("Unknown Skin market API version or route.") +} + +async fn security_headers(request: Request, next: Next) -> Response { + let mut response = next.run(request).await; + let headers = response.headers_mut(); + headers.insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + headers.insert( + header::REFERRER_POLICY, + HeaderValue::from_static("strict-origin-when-cross-origin"), + ); + headers.insert( + header::X_FRAME_OPTIONS, + HeaderValue::from_static("SAMEORIGIN"), + ); + headers.insert( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static( + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; \ + img-src 'self' data: https://avatars.githubusercontent.com; \ + connect-src 'self'; object-src 'none'; base-uri 'self'; \ + frame-ancestors 'self'; form-action 'self'", + ), + ); + response +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::{to_bytes, Body}; + use axum::http::{header, HeaderMap, HeaderName, Request, StatusCode}; + use axum::routing::get; + use axum::Json; + use serde_json::Value; + use sha2::{Digest, Sha256}; + use std::io::{Cursor, Write}; + use tower::ServiceExt; + use zip::write::SimpleFileOptions; + + async fn test_identity(headers: HeaderMap) -> (StatusCode, Json) { + let token = headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + let (github_id, login, is_admin) = match token { + "Bearer owner-token" => (41, "owner", false), + "Bearer admin-token" => (42, "admin", true), + _ => { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"error": "unauthorized"})), + ); + } + }; + ( + StatusCode::OK, + Json(serde_json::json!({ + "user": { + "githubId": github_id, + "login": login, + "avatarUrl": "https://example.invalid/avatar" + }, + "isAdmin": is_admin + })), + ) + } + + async fn identity_url() -> url::Url { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let app = Router::new().route("/me", get(test_identity).post(test_identity)); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + url::Url::parse(&format!("http://{address}/me")).unwrap() + } + + async fn test_router(temporary: &tempfile::TempDir) -> Router { + let web_dir = temporary.path().join("web"); + tokio::fs::create_dir_all(&web_dir).await.unwrap(); + tokio::fs::write(web_dir.join("index.html"), "skin market") + .await + .unwrap(); + build_skin_market_router(SkinMarketConfig { + bind: "127.0.0.1:0".parse().unwrap(), + public_base_url: "http://localhost/skin".to_string(), + database_path: temporary.path().join("market.sqlite"), + artifact_dir: temporary.path().join("artifacts"), + web_dir, + identity_me_url: identity_url().await, + download_hash_secret: "test-download-secret-at-least-24".to_string(), + public_browse: true, + }) + .await + .unwrap() + } + + fn request(method: &str, uri: &str, token: Option<&str>, body: Body) -> Request { + let mut builder = Request::builder().method(method).uri(uri); + if let Some(token) = token { + builder = builder.header(header::AUTHORIZATION, format!("Bearer {token}")); + } + builder.body(body).unwrap() + } + + async fn json_body(response: Response) -> Value { + serde_json::from_slice( + &to_bytes(response.into_body(), 2 * 1024 * 1024) + .await + .unwrap(), + ) + .unwrap() + } + + fn appearance_package() -> Vec { + let mut preview_output = Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(8, 6) + .write_to(&mut preview_output, image::ImageFormat::Png) + .unwrap(); + let preview = preview_output.into_inner(); + let manifest = serde_json::json!({ + "schema": "bitfun.appearance", + "schemaVersion": 1, + "id": "example.aurora", + "name": "Aurora", + "description": "A safe appearance", + "version": "1.0.0", + "mode": "dark", + "preview": {"kind": "asset", "assetId": "preview"}, + "requiredCapabilities": ["assets.v1"], + "assets": { + "preview": { + "kind": "image", + "mimeType": "image/png", + "source": {"kind": "package", "path": "assets/preview.png"} + } + }, + "integrity": { + "sha256": {"preview": hex::encode(Sha256::digest(&preview))} + } + }); + let cursor = Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + writer + .start_file("appearance.json", SimpleFileOptions::default()) + .unwrap(); + writer + .write_all(&serde_json::to_vec(&manifest).unwrap()) + .unwrap(); + writer + .start_file("assets/preview.png", SimpleFileOptions::default()) + .unwrap(); + writer.write_all(&preview).unwrap(); + writer.finish().unwrap().into_inner() + } + + async fn create_submission(app: &Router, slug: &str) -> String { + let body = serde_json::json!({ + "slug": slug, + "releaseNumber": 1, + "minBitfunVersion": "0.2.15", + "changelog": "Initial release", + "license": {"spdxExpression": "MIT"} + }); + let mut request = request( + "POST", + "/skin/api/v1/submissions", + Some("owner-token"), + Body::from(body.to_string()), + ); + request.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + json_body(response).await["submissionId"] + .as_str() + .unwrap() + .to_string() + } + + #[tokio::test] + async fn bearer_submission_review_and_public_download_flow() { + let temporary = tempfile::tempdir().unwrap(); + let app = test_router(&temporary).await; + let submission_id = create_submission(&app, "aurora-market").await; + let package = appearance_package(); + let mut upload = request( + "PUT", + &format!("/skin/api/v1/submissions/{submission_id}/package"), + Some("owner-token"), + Body::from(package.clone()), + ); + upload.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static( + bitfun_product_domains::appearance_market::APPEARANCE_MARKET_PACKAGE_CONTENT_TYPE, + ), + ); + let response = app.clone().oneshot(upload).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let uploaded = json_body(response).await; + assert_eq!(uploaded["packageId"], "example.aurora"); + let private_preview_path = uploaded["previewUrl"] + .as_str() + .unwrap() + .strip_prefix("http://localhost") + .unwrap() + .to_string(); + assert!(private_preview_path.starts_with("/skin/api/v1/artifacts/previews/")); + + let private_preview = app + .clone() + .oneshot(request( + "GET", + &private_preview_path, + Some("owner-token"), + Body::empty(), + )) + .await + .unwrap(); + assert_eq!(private_preview.status(), StatusCode::OK); + assert_eq!( + private_preview.headers()[header::CACHE_CONTROL], + "private, no-store" + ); + let anonymous_preview = app + .clone() + .oneshot(request("GET", &private_preview_path, None, Body::empty())) + .await + .unwrap(); + assert_eq!(anonymous_preview.status(), StatusCode::UNAUTHORIZED); + + let response = app + .clone() + .oneshot(request( + "POST", + &format!("/skin/api/v1/submissions/{submission_id}/submit"), + Some("owner-token"), + Body::empty(), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(json_body(response).await["status"], "submitted"); + + let mut approve = request( + "POST", + &format!("/skin/api/v1/admin/submissions/{submission_id}/decision"), + Some("admin-token"), + Body::from(r#"{"decision":"approve","reason":""}"#), + ); + approve.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + let response = app.clone().oneshot(approve).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let approved = json_body(response).await; + assert_eq!(approved["submission"]["status"], "approved"); + assert!(approved["reviewBundleHash"].as_str().is_some()); + + let response = app + .clone() + .oneshot(request( + "GET", + "/skin/api/v1/listings?mode=dark", + None, + Body::empty(), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let listing = json_body(response).await; + assert_eq!(listing["items"][0]["slug"], "aurora-market"); + assert_eq!(listing["items"][0]["packageId"], "example.aurora"); + assert!(listing["items"][0]["previewUrl"] + .as_str() + .unwrap() + .starts_with("http://localhost/skin/api/v1/artifacts/previews/")); + + let public_preview = app + .clone() + .oneshot(request("GET", &private_preview_path, None, Body::empty())) + .await + .unwrap(); + assert_eq!(public_preview.status(), StatusCode::OK); + assert_eq!( + public_preview.headers()[header::CACHE_CONTROL], + "public, max-age=31536000, immutable" + ); + + let response = app + .clone() + .oneshot(request( + "GET", + "/skin/api/v1/listings/aurora-market", + None, + Body::empty(), + )) + .await + .unwrap(); + let detail = json_body(response).await; + assert_eq!(detail["releases"][0]["packageVersion"], "1.0.0"); + assert_eq!(detail["releases"][0]["yanked"], false); + + let head = app + .clone() + .oneshot(request( + "HEAD", + "/skin/api/v1/listings/aurora-market/releases/1/download", + None, + Body::empty(), + )) + .await + .unwrap(); + assert_eq!(head.status(), StatusCode::OK); + assert_eq!( + head.headers()[header::CONTENT_LENGTH].to_str().unwrap(), + package.len().to_string() + ); + + let response = app + .clone() + .oneshot(request( + "GET", + "/skin/api/v1/listings/aurora-market/releases/1/download", + None, + Body::empty(), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers()[header::CONTENT_LENGTH].to_str().unwrap(), + package.len().to_string() + ); + assert_eq!( + to_bytes(response.into_body(), package.len() + 1) + .await + .unwrap() + .as_ref(), + package.as_slice() + ); + let response = app + .clone() + .oneshot(request( + "GET", + "/skin/api/v1/listings/aurora-market", + None, + Body::empty(), + )) + .await + .unwrap(); + assert_eq!(json_body(response).await["downloadCount"], 1); + } + + #[tokio::test] + async fn api_errors_are_bounded_and_use_the_versioned_envelope() { + let temporary = tempfile::tempdir().unwrap(); + let app = test_router(&temporary).await; + let health = app + .clone() + .oneshot(request("GET", "/skin/api/v1/health", None, Body::empty())) + .await + .unwrap(); + assert_eq!(health.status(), StatusCode::OK); + + let unsafe_license = serde_json::json!({ + "slug": "unsafe-license", + "releaseNumber": 1, + "minBitfunVersion": "0.2.15", + "changelog": "Initial release", + "license": {"customUrl": "javascript:alert(1)"} + }); + let mut unsafe_license_request = request( + "POST", + "/skin/api/v1/submissions", + Some("owner-token"), + Body::from(unsafe_license.to_string()), + ); + unsafe_license_request.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + let unsafe_license_response = app.clone().oneshot(unsafe_license_request).await.unwrap(); + assert_eq!(unsafe_license_response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + json_body(unsafe_license_response).await["error"]["code"], + "invalid_license" + ); + + let mut invalid_mode_request = request( + "GET", + "/skin/api/v1/listings?mode=sepia", + None, + Body::empty(), + ); + invalid_mode_request.headers_mut().insert( + HeaderName::from_static("x-request-id"), + HeaderValue::from_static("edge-request-123"), + ); + let invalid_mode = app.clone().oneshot(invalid_mode_request).await.unwrap(); + assert_eq!(invalid_mode.status(), StatusCode::BAD_REQUEST); + assert_eq!(invalid_mode.headers()["x-request-id"], "edge-request-123"); + let invalid_mode_body = json_body(invalid_mode).await; + assert_eq!(invalid_mode_body["error"]["code"], "invalid_mode"); + assert_eq!(invalid_mode_body["error"]["requestId"], "edge-request-123"); + + let mut malformed_json = request( + "POST", + "/skin/api/v1/submissions", + Some("owner-token"), + Body::from("{"), + ); + malformed_json.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + let malformed_json = app.clone().oneshot(malformed_json).await.unwrap(); + assert_eq!(malformed_json.status(), StatusCode::BAD_REQUEST); + assert_eq!( + json_body(malformed_json).await["error"]["code"], + "invalid_request" + ); + + let method_not_allowed = app + .clone() + .oneshot(request( + "PATCH", + "/skin/api/v1/listings", + None, + Body::empty(), + )) + .await + .unwrap(); + assert_eq!(method_not_allowed.status(), StatusCode::METHOD_NOT_ALLOWED); + assert_eq!( + json_body(method_not_allowed).await["error"]["code"], + "method_not_allowed" + ); + + let unknown_version = app + .clone() + .oneshot(request("GET", "/skin/api/v2/listings", None, Body::empty())) + .await + .unwrap(); + assert_eq!(unknown_version.status(), StatusCode::NOT_FOUND); + assert_eq!( + json_body(unknown_version).await["error"]["code"], + "not_found" + ); + + let deep_link = app + .clone() + .oneshot(request( + "GET", + "/skin/appearances/aurora-market", + None, + Body::empty(), + )) + .await + .unwrap(); + assert_eq!(deep_link.status(), StatusCode::OK); + for workflow_path in [ + "/skin/appearances/aurora-market/", + "/skin/submissions", + "/skin/submissions/", + "/skin/admin", + "/skin/admin/", + ] { + let response = app + .clone() + .oneshot(request("GET", workflow_path, None, Body::empty())) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{workflow_path}"); + } + let missing_asset = app + .clone() + .oneshot(request("GET", "/skin/missing.js", None, Body::empty())) + .await + .unwrap(); + assert_eq!(missing_asset.status(), StatusCode::NOT_FOUND); + + let submission_id = create_submission(&app, "large-package").await; + let mut oversized = request( + "PUT", + &format!("/skin/api/v1/submissions/{submission_id}/package"), + Some("owner-token"), + Body::empty(), + ); + oversized.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static( + bitfun_product_domains::appearance_market::APPEARANCE_MARKET_PACKAGE_CONTENT_TYPE, + ), + ); + oversized.headers_mut().insert( + header::CONTENT_LENGTH, + HeaderValue::from_str( + &(bitfun_product_domains::appearance_market::APPEARANCE_MARKET_MAX_PACKAGE_BYTES + + 1) + .to_string(), + ) + .unwrap(), + ); + let response = app.clone().oneshot(oversized).await.unwrap(); + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + let body = json_body(response).await; + assert_eq!(body["error"]["code"], "payload_too_large"); + assert!(body["error"]["requestId"].as_str().is_some()); + + let missing = app + .oneshot(request( + "GET", + "/skin/api/v1/not-a-route", + None, + Body::empty(), + )) + .await + .unwrap(); + assert_eq!(missing.status(), StatusCode::NOT_FOUND); + assert_eq!(json_body(missing).await["error"]["code"], "not_found"); + } + + #[tokio::test] + async fn submission_history_uses_stable_cursor_pages() { + let temporary = tempfile::tempdir().unwrap(); + let app = test_router(&temporary).await; + for index in 0..7 { + create_submission(&app, &format!("paged-skin-{index}")).await; + } + + let mut cursor: Option = None; + let mut seen = std::collections::BTreeSet::new(); + loop { + let uri = cursor.as_ref().map_or_else( + || "/skin/api/v1/submissions?limit=3".to_string(), + |cursor| format!("/skin/api/v1/submissions?limit=3&cursor={cursor}"), + ); + let response = app + .clone() + .oneshot(request("GET", &uri, Some("owner-token"), Body::empty())) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let page = json_body(response).await; + for item in page["items"].as_array().unwrap() { + assert!(seen.insert(item["submissionId"].as_str().unwrap().to_string())); + } + cursor = page["nextCursor"].as_str().map(str::to_string); + if cursor.is_none() { + break; + } + } + assert_eq!(seen.len(), 7); + } +} diff --git a/src/crates/services/skin-market-service/src/package.rs b/src/crates/services/skin-market-service/src/package.rs new file mode 100644 index 0000000000..1650e3fb30 --- /dev/null +++ b/src/crates/services/skin-market-service/src/package.rs @@ -0,0 +1,1053 @@ +use crate::error::{SkinMarketError, SkinMarketResult}; +use bitfun_product_domains::appearance_market::{ + AppearanceMarketPackageMeta, AppearancePackageMode, APPEARANCE_MARKET_MAX_ENTRIES, + APPEARANCE_MARKET_MAX_MANIFEST_BYTES, APPEARANCE_MARKET_MAX_PACKAGE_BYTES, + APPEARANCE_MARKET_MAX_PREVIEW_BYTES, APPEARANCE_MARKET_MAX_PREVIEW_PIXELS, + APPEARANCE_MARKET_MAX_UNCOMPRESSED_BYTES, +}; +use image::GenericImageView; +use semver::Version; +use serde::Deserialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{Cursor, Read}; +use zip::ZipArchive; + +const MANIFEST_PATH: &str = "appearance.json"; +const MAX_IMAGE_BYTES: u64 = 16 * 1024 * 1024; +const MAX_VIDEO_BYTES: u64 = 64 * 1024 * 1024; +const MAX_IMAGE_DIMENSION: u32 = 16_384; +const MAX_IMAGE_PIXELS: u64 = 50_000_000; +const MAX_DIRECTORY_ENTRIES: usize = 64; +const SUPPORTED_CAPABILITIES: &[&str] = &[ + "components.v1", + "scenes.v1", + "renderers.v1", + "assets.v1", + "background-media.v1", +]; + +#[derive(Debug, Clone)] +pub struct ValidatedAppearancePackage { + pub sha256: String, + pub size: u64, + pub meta: AppearanceMarketPackageMeta, + pub canonical_manifest_json: String, + pub preview: ValidatedPreview, +} + +#[derive(Debug, Clone)] +pub struct ValidatedPreview { + pub sha256: String, + pub bytes: Vec, + pub width: u32, + pub height: u32, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AppearanceManifest { + schema: String, + schema_version: u32, + id: String, + name: String, + author: Option, + #[serde(default)] + description: String, + version: String, + mode: AppearancePackageMode, + preview: Option, + background_media: Option, + #[serde(default)] + required_capabilities: Vec, + globals: Option, + materials: Option, + components: Option, + scenes: Option, + renderers: Option, + #[serde(default)] + assets: BTreeMap, + integrity: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct AssetReference { + kind: String, + #[serde(rename = "assetId")] + asset_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct BackgroundMedia { + kind: String, + asset_id: String, + poster_asset_id: String, + fit: Option, + position: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)] +enum AssetDefinition { + Image { + #[serde(rename = "mimeType")] + mime_type: String, + source: PackageSource, + }, + Video { + #[serde(rename = "mimeType")] + mime_type: String, + source: PackageSource, + }, +} + +impl AssetDefinition { + fn mime_type(&self) -> &str { + match self { + Self::Image { mime_type, .. } | Self::Video { mime_type, .. } => mime_type, + } + } + + fn source(&self) -> &PackageSource { + match self { + Self::Image { source, .. } | Self::Video { source, .. } => source, + } + } + + fn is_image(&self) -> bool { + matches!(self, Self::Image { .. }) + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PackageSource { + kind: String, + path: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Integrity { + sha256: BTreeMap, +} + +pub fn validate_appearance_package(bytes: &[u8]) -> SkinMarketResult { + if bytes.is_empty() { + return Err(SkinMarketError::bad_request( + "empty_package", + "The appearance package is empty.", + )); + } + if bytes.len() as u64 > APPEARANCE_MARKET_MAX_PACKAGE_BYTES { + return Err(SkinMarketError::bad_request( + "package_too_large", + "The compressed appearance package exceeds 96 MiB.", + )); + } + assert_safe_central_directory(bytes)?; + + let mut archive = ZipArchive::new(Cursor::new(bytes)).map_err(|error| { + SkinMarketError::bad_request("invalid_package", format!("Invalid ZIP archive: {error}")) + })?; + if archive.is_empty() || archive.len() > APPEARANCE_MARKET_MAX_ENTRIES + MAX_DIRECTORY_ENTRIES { + return Err(SkinMarketError::bad_request( + "invalid_package_entry_count", + "The Appearance package contains too many archive entries.", + )); + } + let mut file_names = BTreeSet::new(); + let mut normalized_names = BTreeSet::new(); + let mut directory_names = Vec::new(); + let mut expanded_bytes = 0_u64; + for index in 0..archive.len() { + let entry = archive.by_index(index).map_err(|error| { + SkinMarketError::bad_request("invalid_package", format!("Invalid ZIP entry: {error}")) + })?; + let name = entry.name().to_string(); + validate_archive_path(&name, entry.is_dir())?; + if entry.encrypted() { + return Err(SkinMarketError::bad_request( + "encrypted_package_entry", + "Encrypted appearance package entries are not allowed.", + )); + } + if entry.unix_mode().is_some_and(|mode| { + let kind = mode & 0o170000; + kind != 0 && kind != 0o100000 && kind != 0o040000 + }) { + return Err(SkinMarketError::bad_request( + "non_regular_package_entry", + "Links and non-regular archive entries are not allowed.", + )); + } + if entry.is_dir() { + if entry.size() != 0 || directory_names.len() >= MAX_DIRECTORY_ENTRIES { + return Err(SkinMarketError::bad_request( + "invalid_package_entry_count", + "Appearance package directories must be empty entries and are limited to 64.", + )); + } + directory_names.push(name); + continue; + } + if !entry.is_file() { + return Err(SkinMarketError::bad_request( + "non_regular_package_entry", + "Appearance packages may contain files and directory markers only.", + )); + } + if !normalized_names.insert(name.to_ascii_lowercase()) { + return Err(SkinMarketError::bad_request( + "duplicate_package_path", + format!("The package contains a duplicate path: {name}"), + )); + } + file_names.insert(name); + expanded_bytes = expanded_bytes.saturating_add(entry.size()); + if expanded_bytes > APPEARANCE_MARKET_MAX_UNCOMPRESSED_BYTES { + return Err(SkinMarketError::bad_request( + "package_expansion_too_large", + "The appearance package expands beyond 128 MiB.", + )); + } + } + if file_names.is_empty() || file_names.len() > APPEARANCE_MARKET_MAX_ENTRIES { + return Err(SkinMarketError::bad_request( + "invalid_package_entry_count", + format!( + "Appearance packages must contain between 1 and {} files.", + APPEARANCE_MARKET_MAX_ENTRIES + ), + )); + } + if !file_names.contains(MANIFEST_PATH) { + return Err(SkinMarketError::bad_request( + "missing_manifest", + "The appearance package must contain appearance.json at its root.", + )); + } + + let mut manifest_bytes = Vec::new(); + archive + .by_name(MANIFEST_PATH) + .map_err(|error| SkinMarketError::bad_request("invalid_manifest", error.to_string()))? + .take(APPEARANCE_MARKET_MAX_MANIFEST_BYTES + 1) + .read_to_end(&mut manifest_bytes) + .map_err(|error| SkinMarketError::bad_request("invalid_manifest", error.to_string()))?; + if manifest_bytes.len() as u64 > APPEARANCE_MARKET_MAX_MANIFEST_BYTES { + return Err(SkinMarketError::bad_request( + "manifest_too_large", + "appearance.json exceeds 256 KiB.", + )); + } + let raw_manifest: Value = serde_json::from_slice(&manifest_bytes).map_err(|error| { + SkinMarketError::bad_request( + "invalid_manifest", + format!("appearance.json is not valid JSON: {error}"), + ) + })?; + let manifest: AppearanceManifest = + serde_json::from_value(raw_manifest.clone()).map_err(|error| { + SkinMarketError::bad_request( + "invalid_manifest_schema", + format!("appearance.json does not match the marketplace schema: {error}"), + ) + })?; + validate_manifest(&manifest)?; + + let mut declared_paths = BTreeMap::new(); + let mut normalized_declared_paths = BTreeSet::new(); + for (asset_id, definition) in &manifest.assets { + validate_identifier(asset_id, "asset id")?; + let source = definition.source(); + if source.kind != "package" { + return Err(SkinMarketError::bad_request( + "external_asset_forbidden", + "Appearance assets must use package-local sources.", + )); + } + validate_archive_path(&source.path, false)?; + if !source.path.starts_with("assets/") { + return Err(SkinMarketError::bad_request( + "invalid_asset_path", + "Appearance assets must be stored under assets/.", + )); + } + if !normalized_declared_paths.insert(source.path.to_ascii_lowercase()) { + return Err(SkinMarketError::bad_request( + "duplicate_asset_path", + format!( + "Multiple assets declare the same package path: {}", + source.path + ), + )); + } + declared_paths.insert(source.path.clone(), (asset_id, definition)); + } + + let allowed_paths = std::iter::once(MANIFEST_PATH.to_string()) + .chain(declared_paths.keys().cloned()) + .collect::>(); + if let Some(undeclared) = file_names.difference(&allowed_paths).next() { + return Err(SkinMarketError::bad_request( + "undeclared_package_entry", + format!("The package contains an undeclared file: {undeclared}"), + )); + } + if let Some(missing) = allowed_paths.difference(&file_names).next() { + return Err(SkinMarketError::bad_request( + "missing_asset", + format!("The package is missing a declared asset: {missing}"), + )); + } + for directory in directory_names { + if directory != "assets/" + && !declared_paths + .keys() + .any(|path| path.starts_with(&directory)) + { + return Err(SkinMarketError::bad_request( + "undeclared_package_entry", + format!("The package contains an undeclared directory: {directory}"), + )); + } + } + let preview_id = select_preview_asset(&manifest)?; + let mut preview_source = None; + for (path, (asset_id, definition)) in &declared_paths { + let mut content = Vec::new(); + let limit = if definition.is_image() { + MAX_IMAGE_BYTES + } else { + MAX_VIDEO_BYTES + }; + archive + .by_name(path) + .map_err(|error| SkinMarketError::bad_request("invalid_asset", error.to_string()))? + .take(limit + 1) + .read_to_end(&mut content) + .map_err(|error| SkinMarketError::bad_request("invalid_asset", error.to_string()))?; + if content.is_empty() || content.len() as u64 > limit { + return Err(SkinMarketError::bad_request( + "invalid_asset_size", + format!("Appearance asset has an invalid size: {path}"), + )); + } + validate_asset_mime(path, definition, &content)?; + if let Some(expected) = manifest + .integrity + .as_ref() + .and_then(|integrity| integrity.sha256.get(*asset_id)) + { + let actual = hex::encode(Sha256::digest(&content)); + if actual != expected.to_ascii_lowercase() { + return Err(SkinMarketError::bad_request( + "asset_digest_mismatch", + format!("Appearance asset digest does not match: {path}"), + )); + } + } + if asset_id.as_str() == preview_id { + if content.len() as u64 > APPEARANCE_MARKET_MAX_PREVIEW_BYTES { + return Err(SkinMarketError::bad_request( + "preview_too_large", + "The selected appearance preview exceeds 4 MiB.", + )); + } + preview_source = Some((content, definition.mime_type().to_string())); + } + } + let (preview_bytes, preview_mime) = preview_source.ok_or_else(|| { + SkinMarketError::bad_request( + "preview_required", + "Marketplace appearance packages must contain an image preview.", + ) + })?; + let preview = normalize_preview(&preview_bytes, &preview_mime)?; + let canonical_manifest_json = serde_json::to_string(&canonicalize_json(raw_manifest)) + .map_err(SkinMarketError::internal)?; + + Ok(ValidatedAppearancePackage { + sha256: hex::encode(Sha256::digest(bytes)), + size: bytes.len() as u64, + meta: AppearanceMarketPackageMeta { + package_id: manifest.id, + name: manifest.name, + description: manifest.description, + author: manifest.author, + mode: manifest.mode, + package_version: manifest.version, + required_capabilities: manifest.required_capabilities, + }, + canonical_manifest_json, + preview, + }) +} + +fn validate_manifest(manifest: &AppearanceManifest) -> SkinMarketResult<()> { + if manifest.schema != "bitfun.appearance" || manifest.schema_version != 1 { + return Err(SkinMarketError::bad_request( + "unsupported_manifest_schema", + "Appearance packages must use bitfun.appearance schema version 1.", + )); + } + validate_identifier(&manifest.id, "package id")?; + if manifest.name.trim().is_empty() || manifest.name.chars().count() > 100 { + return Err(SkinMarketError::bad_request( + "invalid_name", + "Appearance names must contain between 1 and 100 characters.", + )); + } + if manifest + .author + .as_ref() + .is_some_and(|value| value.chars().count() > 100) + || manifest.description.chars().count() > 500 + { + return Err(SkinMarketError::bad_request( + "invalid_manifest_text", + "Appearance author and description fields exceed their allowed length.", + )); + } + Version::parse(&manifest.version).map_err(|_| { + SkinMarketError::bad_request( + "invalid_package_version", + "Appearance version must use semantic version syntax.", + ) + })?; + let mut capabilities = BTreeSet::new(); + for capability in &manifest.required_capabilities { + if !SUPPORTED_CAPABILITIES.contains(&capability.as_str()) + || !capabilities.insert(capability) + { + return Err(SkinMarketError::bad_request( + "invalid_required_capability", + format!("Unsupported or duplicate appearance capability: {capability}"), + )); + } + } + for (name, value) in [ + ("globals", manifest.globals.as_ref()), + ("materials", manifest.materials.as_ref()), + ("components", manifest.components.as_ref()), + ("scenes", manifest.scenes.as_ref()), + ("renderers", manifest.renderers.as_ref()), + ] { + if let Some(value) = value { + if !value.is_object() { + return Err(SkinMarketError::bad_request( + "invalid_manifest_section", + format!("Appearance manifest section {name} must be an object."), + )); + } + validate_declarative_value(value, name)?; + } + } + if let Some(reference) = &manifest.preview { + if reference.kind != "asset" { + return Err(SkinMarketError::bad_request( + "invalid_preview", + "Appearance preview must reference a declared image asset.", + )); + } + let asset = manifest.assets.get(&reference.asset_id).ok_or_else(|| { + SkinMarketError::bad_request( + "unknown_preview_asset", + "Appearance preview references an unknown asset.", + ) + })?; + if !asset.is_image() { + return Err(SkinMarketError::bad_request( + "invalid_preview", + "Appearance preview must reference an image asset.", + )); + } + } + if let Some(background) = &manifest.background_media { + if background.kind != "video" + || background + .fit + .as_deref() + .is_some_and(|value| !matches!(value, "cover" | "contain")) + || background.position.as_deref().is_some_and(|value| { + !matches!(value, "center" | "top" | "right" | "bottom" | "left") + }) + { + return Err(SkinMarketError::bad_request( + "invalid_background_media", + "Appearance background media is invalid.", + )); + } + if !manifest + .required_capabilities + .iter() + .any(|value| value == "background-media.v1") + { + return Err(SkinMarketError::bad_request( + "missing_background_media_capability", + "Video backgrounds require background-media.v1.", + )); + } + if !matches!( + manifest.assets.get(&background.asset_id), + Some(AssetDefinition::Video { .. }) + ) || !matches!( + manifest.assets.get(&background.poster_asset_id), + Some(AssetDefinition::Image { .. }) + ) { + return Err(SkinMarketError::bad_request( + "invalid_background_media", + "Video backgrounds require declared video and image poster assets.", + )); + } + } + for (asset_id, asset) in &manifest.assets { + if matches!(asset, AssetDefinition::Video { .. }) + && manifest + .background_media + .as_ref() + .is_none_or(|background| background.asset_id != *asset_id) + { + return Err(SkinMarketError::bad_request( + "unused_video_asset", + "Video assets are allowed only as the selected top-level background media.", + )); + } + } + if let Some(integrity) = &manifest.integrity { + for (asset_id, digest) in &integrity.sha256 { + if !manifest.assets.contains_key(asset_id) + || digest.len() != 64 + || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err(SkinMarketError::bad_request( + "invalid_asset_digest", + "Appearance integrity entries must reference assets with SHA-256 digests.", + )); + } + } + } + Ok(()) +} + +fn validate_identifier(value: &str, field: &str) -> SkinMarketResult<()> { + let bytes = value.as_bytes(); + let valid = !bytes.is_empty() + && bytes.len() <= 100 + && bytes[0].is_ascii_lowercase() + && bytes.iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(*byte, b'.' | b'-') + }) + && !value.contains("..") + && !value.contains("--") + && !value.ends_with(['.', '-']); + if valid { + Ok(()) + } else { + Err(SkinMarketError::bad_request( + "invalid_identifier", + format!("Appearance {field} must be a lowercase dotted or dashed identifier."), + )) + } +} + +fn validate_declarative_value(value: &Value, field: &str) -> SkinMarketResult<()> { + match value { + Value::Object(values) => { + for (key, nested) in values { + let key_lower = key.to_ascii_lowercase(); + if matches!( + key_lower.as_str(), + "css" | "html" | "script" | "javascript" | "selector" | "stylesheet" + ) { + return Err(SkinMarketError::bad_request( + "executable_appearance_content", + format!( + "Appearance section {field} contains forbidden executable styling." + ), + )); + } + validate_declarative_value(nested, field)?; + } + } + Value::Array(values) => { + for nested in values { + validate_declarative_value(nested, field)?; + } + } + Value::String(text) => { + let lower = text.to_ascii_lowercase(); + if lower.contains("http://") + || lower.contains("https://") + || lower.contains("javascript:") + || lower.contains("data:") + || lower.contains("url(") + || lower.contains('<') + { + return Err(SkinMarketError::bad_request( + "external_appearance_content", + format!("Appearance section {field} contains forbidden external content."), + )); + } + } + _ => {} + } + Ok(()) +} + +fn select_preview_asset(manifest: &AppearanceManifest) -> SkinMarketResult { + manifest + .preview + .as_ref() + .map(|reference| reference.asset_id.clone()) + .ok_or_else(|| { + SkinMarketError::bad_request( + "preview_required", + "Marketplace appearance packages must explicitly declare an image preview.", + ) + }) +} + +fn validate_asset_mime( + path: &str, + definition: &AssetDefinition, + bytes: &[u8], +) -> SkinMarketResult<()> { + match definition { + AssetDefinition::Image { mime_type, .. } => { + let format = image::guess_format(bytes).map_err(|_| { + SkinMarketError::bad_request( + "invalid_asset_mime", + format!("Appearance image MIME could not be identified: {path}"), + ) + })?; + let actual = match format { + image::ImageFormat::Png => "image/png", + image::ImageFormat::Jpeg => "image/jpeg", + image::ImageFormat::WebP => "image/webp", + image::ImageFormat::Gif => "image/gif", + _ => { + return Err(SkinMarketError::bad_request( + "unsupported_asset_mime", + format!("Appearance image format is not allowed: {path}"), + )) + } + }; + if actual != mime_type { + return Err(SkinMarketError::bad_request( + "asset_mime_mismatch", + format!("Appearance image MIME does not match its declaration: {path}"), + )); + } + let (width, height) = image::ImageReader::with_format(Cursor::new(bytes), format) + .into_dimensions() + .map_err(|error| { + SkinMarketError::bad_request( + "invalid_image", + format!("Appearance image dimensions are invalid: {error}"), + ) + })?; + if width == 0 + || height == 0 + || width > MAX_IMAGE_DIMENSION + || height > MAX_IMAGE_DIMENSION + || u64::from(width).saturating_mul(u64::from(height)) > MAX_IMAGE_PIXELS + { + return Err(SkinMarketError::bad_request( + "invalid_image_dimensions", + format!("Appearance image dimensions exceed the limit: {path}"), + )); + } + } + AssetDefinition::Video { mime_type, .. } => { + let actual = if bytes.len() >= 12 && &bytes[4..8] == b"ftyp" { + "video/mp4" + } else if bytes.len() >= 4 + && bytes[..4] == [0x1a, 0x45, 0xdf, 0xa3] + && bytes + .windows(4) + .take(4_096) + .any(|window| window.eq_ignore_ascii_case(b"webm")) + { + "video/webm" + } else { + return Err(SkinMarketError::bad_request( + "invalid_asset_mime", + format!("Appearance video MIME could not be identified: {path}"), + )); + }; + if actual != mime_type || !matches!(mime_type.as_str(), "video/mp4" | "video/webm") { + return Err(SkinMarketError::bad_request( + "asset_mime_mismatch", + format!("Appearance video MIME does not match its declaration: {path}"), + )); + } + } + } + Ok(()) +} + +fn normalize_preview(bytes: &[u8], mime_type: &str) -> SkinMarketResult { + let format = match mime_type { + "image/png" => image::ImageFormat::Png, + "image/jpeg" => image::ImageFormat::Jpeg, + "image/webp" => image::ImageFormat::WebP, + "image/gif" => image::ImageFormat::Gif, + _ => { + return Err(SkinMarketError::bad_request( + "invalid_preview", + "The selected appearance preview is not an image.", + )) + } + }; + let (source_width, source_height) = image::ImageReader::with_format(Cursor::new(bytes), format) + .into_dimensions() + .map_err(|error| { + SkinMarketError::bad_request( + "invalid_preview", + format!("The appearance preview dimensions are invalid: {error}"), + ) + })?; + if u64::from(source_width).saturating_mul(u64::from(source_height)) + > APPEARANCE_MARKET_MAX_PREVIEW_PIXELS + { + return Err(SkinMarketError::bad_request( + "preview_dimensions_too_large", + "The marketplace preview exceeds the 16 megapixel decode budget.", + )); + } + let decoded = image::load_from_memory_with_format(bytes, format).map_err(|error| { + SkinMarketError::bad_request( + "invalid_preview", + format!("The appearance preview could not be decoded: {error}"), + ) + })?; + let normalized = if decoded.width() > 1_600 || decoded.height() > 1_600 { + decoded.thumbnail(1_600, 1_600) + } else { + decoded + }; + let (width, height) = normalized.dimensions(); + let mut cursor = Cursor::new(Vec::new()); + normalized + .write_to(&mut cursor, image::ImageFormat::WebP) + .map_err(SkinMarketError::internal)?; + let normalized_bytes = cursor.into_inner(); + Ok(ValidatedPreview { + sha256: hex::encode(Sha256::digest(&normalized_bytes)), + bytes: normalized_bytes, + width, + height, + }) +} + +fn validate_archive_path(path: &str, directory: bool) -> SkinMarketResult<()> { + let candidate = if directory { + path.strip_suffix('/').unwrap_or(path) + } else { + path + }; + let safe = !candidate.is_empty() + && candidate.len() <= 200 + && !candidate.starts_with('/') + && !candidate.contains("..") + && !candidate.contains('\\') + && !candidate.contains(':') + && !candidate.contains("//") + && candidate + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'/' | b'-')); + if safe { + Ok(()) + } else { + Err(SkinMarketError::bad_request( + "unsafe_package_path", + format!("The appearance package contains an unsafe path: {path}"), + )) + } +} + +fn assert_safe_central_directory(bytes: &[u8]) -> SkinMarketResult<()> { + const END: [u8; 4] = *b"PK\x05\x06"; + const CENTRAL: [u8; 4] = *b"PK\x01\x02"; + const ZIP64_LOCATOR: [u8; 4] = *b"PK\x06\x07"; + let search_start = bytes.len().saturating_sub(65_557); + let end = (search_start..bytes.len().saturating_sub(3)) + .rev() + .find(|offset| bytes[*offset..*offset + 4] == END) + .ok_or_else(|| { + SkinMarketError::bad_request( + "invalid_package", + "Appearance archive central directory is missing.", + ) + })?; + if end + 22 > bytes.len() { + return Err(SkinMarketError::bad_request( + "invalid_package", + "Appearance archive central directory is truncated.", + )); + } + if end >= 20 && bytes[end - 20..end - 16] == ZIP64_LOCATOR { + return Err(SkinMarketError::bad_request( + "zip64_forbidden", + "ZIP64 appearance packages are not allowed.", + )); + } + let disk = u16::from_le_bytes([bytes[end + 4], bytes[end + 5]]); + let central_disk = u16::from_le_bytes([bytes[end + 6], bytes[end + 7]]); + let entries = u16::from_le_bytes([bytes[end + 10], bytes[end + 11]]); + let central_size = u32::from_le_bytes(bytes[end + 12..end + 16].try_into().unwrap()); + let central_offset = u32::from_le_bytes(bytes[end + 16..end + 20].try_into().unwrap()); + if usize::from(entries) > APPEARANCE_MARKET_MAX_ENTRIES + MAX_DIRECTORY_ENTRIES { + return Err(SkinMarketError::bad_request( + "invalid_package_entry_count", + "The Appearance package contains too many archive entries.", + )); + } + if disk != 0 + || central_disk != 0 + || entries == u16::MAX + || central_size == u32::MAX + || central_offset == u32::MAX + || usize::try_from(central_offset) + .ok() + .and_then(|offset| offset.checked_add(central_size as usize)) + .is_none_or(|limit| limit > end) + { + return Err(SkinMarketError::bad_request( + "zip64_forbidden", + "Multi-disk and ZIP64 appearance packages are not allowed.", + )); + } + let mut offset = central_offset as usize; + let mut expanded = 0_u64; + for _ in 0..entries { + if offset + 46 > bytes.len() || bytes[offset..offset + 4] != CENTRAL { + return Err(SkinMarketError::bad_request( + "invalid_package", + "Appearance archive central directory entry is invalid.", + )); + } + let flags = u16::from_le_bytes([bytes[offset + 8], bytes[offset + 9]]); + let compressed = u32::from_le_bytes(bytes[offset + 20..offset + 24].try_into().unwrap()); + let uncompressed = u32::from_le_bytes(bytes[offset + 24..offset + 28].try_into().unwrap()); + let name_len = u16::from_le_bytes([bytes[offset + 28], bytes[offset + 29]]) as usize; + let extra_len = u16::from_le_bytes([bytes[offset + 30], bytes[offset + 31]]) as usize; + let comment_len = u16::from_le_bytes([bytes[offset + 32], bytes[offset + 33]]) as usize; + let disk_start = u16::from_le_bytes([bytes[offset + 34], bytes[offset + 35]]); + let local_header_offset = + u32::from_le_bytes(bytes[offset + 42..offset + 46].try_into().unwrap()); + if flags & 1 != 0 + || compressed == u32::MAX + || uncompressed == u32::MAX + || disk_start != 0 + || local_header_offset == u32::MAX + { + return Err(SkinMarketError::bad_request( + "encrypted_or_zip64_entry", + "Encrypted and ZIP64 appearance package entries are not allowed.", + )); + } + let entry_end = offset + .checked_add(46 + name_len + extra_len + comment_len) + .filter(|value| *value <= bytes.len()) + .ok_or_else(|| { + SkinMarketError::bad_request( + "invalid_package", + "Appearance archive central directory entry is truncated.", + ) + })?; + let mut extra_offset = offset + 46 + name_len; + let extra_end = extra_offset + extra_len; + while extra_offset < extra_end { + if extra_offset + 4 > extra_end { + return Err(SkinMarketError::bad_request( + "invalid_package", + "Appearance archive extra data is truncated.", + )); + } + let field_id = u16::from_le_bytes([bytes[extra_offset], bytes[extra_offset + 1]]); + let field_size = + u16::from_le_bytes([bytes[extra_offset + 2], bytes[extra_offset + 3]]) as usize; + extra_offset = extra_offset + .checked_add(4 + field_size) + .filter(|value| *value <= extra_end) + .ok_or_else(|| { + SkinMarketError::bad_request( + "invalid_package", + "Appearance archive extra data is truncated.", + ) + })?; + if field_id == 0x0001 { + return Err(SkinMarketError::bad_request( + "zip64_forbidden", + "ZIP64 appearance package entries are not allowed.", + )); + } + } + expanded = expanded.saturating_add(u64::from(uncompressed)); + if expanded > APPEARANCE_MARKET_MAX_UNCOMPRESSED_BYTES { + return Err(SkinMarketError::bad_request( + "package_expansion_too_large", + "The appearance package expands beyond 128 MiB.", + )); + } + offset = entry_end; + } + Ok(()) +} + +fn canonicalize_json(value: Value) -> Value { + match value { + Value::Object(values) => Value::Object( + values + .into_iter() + .map(|(key, value)| (key, canonicalize_json(value))) + .collect(), + ), + Value::Array(values) => Value::Array(values.into_iter().map(canonicalize_json).collect()), + other => other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use zip::write::SimpleFileOptions; + + fn png() -> Vec { + let mut output = Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(4, 3) + .write_to(&mut output, image::ImageFormat::Png) + .unwrap(); + output.into_inner() + } + + fn package_with_digest( + mut manifest: Value, + extras: &[(&str, Vec)], + digest: Option, + ) -> Vec { + let preview = png(); + manifest["integrity"] = serde_json::json!({ + "sha256": { + "preview": digest.unwrap_or_else(|| hex::encode(Sha256::digest(&preview))) + } + }); + let cursor = Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + writer + .start_file("appearance.json", SimpleFileOptions::default()) + .unwrap(); + writer + .write_all(serde_json::to_vec(&manifest).unwrap().as_slice()) + .unwrap(); + writer + .add_directory("assets/", SimpleFileOptions::default()) + .unwrap(); + writer + .start_file("assets/preview.png", SimpleFileOptions::default()) + .unwrap(); + writer.write_all(&preview).unwrap(); + for (name, content) in extras { + writer + .start_file(*name, SimpleFileOptions::default()) + .unwrap(); + writer.write_all(content).unwrap(); + } + writer.finish().unwrap().into_inner() + } + + fn package(manifest: Value, extras: &[(&str, Vec)]) -> Vec { + package_with_digest(manifest, extras, None) + } + + fn manifest() -> Value { + serde_json::json!({ + "schema": "bitfun.appearance", + "schemaVersion": 1, + "id": "example.aurora", + "name": "Aurora", + "description": "A safe appearance", + "version": "1.2.3", + "mode": "dark", + "preview": { "kind": "asset", "assetId": "preview" }, + "requiredCapabilities": ["assets.v1"], + "assets": { + "preview": { + "kind": "image", + "mimeType": "image/png", + "source": { "kind": "package", "path": "assets/preview.png" } + } + } + }) + } + + #[test] + fn valid_package_is_bound_to_a_normalized_webp_preview() { + let result = validate_appearance_package(&package(manifest(), &[])).unwrap(); + assert_eq!(result.meta.package_id, "example.aurora"); + assert_eq!(result.preview.width, 4); + assert_eq!(result.preview.height, 3); + assert_eq!( + image::guess_format(&result.preview.bytes).unwrap(), + image::ImageFormat::WebP + ); + } + + #[test] + fn package_rejects_unknown_files_schema_and_digest_mismatch() { + assert_eq!( + validate_appearance_package(&package(manifest(), &[("extra.txt", b"x".to_vec())])) + .unwrap_err() + .code, + "undeclared_package_entry" + ); + let mut wrong_schema = manifest(); + wrong_schema["schemaVersion"] = Value::from(2); + assert_eq!( + validate_appearance_package(&package(wrong_schema, &[])) + .unwrap_err() + .code, + "unsupported_manifest_schema" + ); + let wrong_digest = package_with_digest(manifest(), &[], Some("00".repeat(32))); + assert_eq!( + validate_appearance_package(&wrong_digest).unwrap_err().code, + "asset_digest_mismatch" + ); + } + + #[test] + fn package_rejects_invalid_semver_mime_and_external_content() { + let mut invalid = manifest(); + invalid["version"] = Value::from("latest"); + assert_eq!( + validate_appearance_package(&package(invalid, &[])) + .unwrap_err() + .code, + "invalid_package_version" + ); + let mut invalid = manifest(); + invalid["assets"]["preview"]["mimeType"] = Value::from("image/jpeg"); + assert_eq!( + validate_appearance_package(&package(invalid, &[])) + .unwrap_err() + .code, + "asset_mime_mismatch" + ); + let mut invalid = manifest(); + invalid["globals"] = serde_json::json!({ "background": "url(https://invalid/)" }); + assert_eq!( + validate_appearance_package(&package(invalid, &[])) + .unwrap_err() + .code, + "external_appearance_content" + ); + } +} diff --git a/src/crates/services/skin-market-service/src/request_id.rs b/src/crates/services/skin-market-service/src/request_id.rs new file mode 100644 index 0000000000..a3e6108992 --- /dev/null +++ b/src/crates/services/skin-market-service/src/request_id.rs @@ -0,0 +1,43 @@ +use axum::extract::Request; +use axum::http::{header::HeaderName, HeaderValue}; +use axum::middleware::Next; +use axum::response::Response; +use tracing::Instrument; +use uuid::Uuid; + +tokio::task_local! { + static CURRENT_REQUEST_ID: String; +} + +pub(crate) async fn middleware(request: Request, next: Next) -> Response { + let header = HeaderName::from_static("x-request-id"); + let request_id = request + .headers() + .get(&header) + .and_then(|value| value.to_str().ok()) + .filter(|value| valid_request_id(value)) + .map(str::to_string) + .unwrap_or_else(|| Uuid::new_v4().to_string()); + let span = tracing::info_span!("skin_market_request", request_id = %request_id); + let mut response = CURRENT_REQUEST_ID + .scope(request_id.clone(), next.run(request).instrument(span)) + .await; + if let Ok(value) = HeaderValue::from_str(&request_id) { + response.headers_mut().insert(header, value); + } + response +} + +pub(crate) fn current_or_new() -> String { + CURRENT_REQUEST_ID + .try_with(Clone::clone) + .unwrap_or_else(|_| Uuid::new_v4().to_string()) +} + +fn valid_request_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +} diff --git a/src/crates/services/skin-market-service/src/retention.rs b/src/crates/services/skin-market-service/src/retention.rs new file mode 100644 index 0000000000..fea28db07e --- /dev/null +++ b/src/crates/services/skin-market-service/src/retention.rs @@ -0,0 +1,199 @@ +use crate::artifacts::ArtifactStore; +use crate::db::Database; +use chrono::Utc; +use sqlx::Row; +use std::collections::BTreeSet; +use std::time::{Duration, UNIX_EPOCH}; + +const DRAFT_RETENTION_SECONDS: i64 = 7 * 24 * 60 * 60; +const CLOSED_RETENTION_SECONDS: i64 = 90 * 24 * 60 * 60; +const DOWNLOAD_DETAIL_RETENTION_SECONDS: i64 = 90 * 24 * 60 * 60; +const CLEANUP_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); + +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct RetentionResult { + pub submissions_scrubbed: u64, + pub packages_removed: u64, + pub previews_removed: u64, + pub temporary_files_removed: u64, + pub download_rows_compacted: u64, +} + +pub(crate) async fn cleanup( + database: &Database, + artifacts: &ArtifactStore, +) -> anyhow::Result { + cleanup_at(database, artifacts, Utc::now().timestamp()).await +} + +async fn cleanup_at( + database: &Database, + artifacts: &ArtifactStore, + now: i64, +) -> anyhow::Result { + // Keep content-addressed artifact writes plus their database attachment + // atomic with respect to orphan cleanup. Without this lease, cleanup could + // delete a reused hash after upload observes it but before the submission + // row starts referencing it. + let mutation_guard = artifacts.lock_mutations().await; + let draft_cutoff = now - DRAFT_RETENTION_SECONDS; + let closed_cutoff = now - CLOSED_RETENTION_SECONDS; + let candidates = sqlx::query( + "SELECT id, package_sha256, preview_sha256 FROM submissions + WHERE (status = 'draft' AND updated_at <= ?) + OR (status IN ('rejected', 'withdrawn') AND updated_at <= ?)", + ) + .bind(draft_cutoff) + .bind(closed_cutoff) + .fetch_all(database.pool()) + .await?; + let mut packages = BTreeSet::new(); + let mut previews = BTreeSet::new(); + let mut scrubbed = 0; + for row in candidates { + let submission_id: String = row.get("id"); + if let Ok(value) = row.try_get::("package_sha256") { + packages.insert(value); + } + if let Ok(value) = row.try_get::("preview_sha256") { + previews.insert(value); + } + scrubbed += sqlx::query( + "UPDATE submissions SET + package_meta_json = NULL, + manifest_json = NULL, + package_sha256 = NULL, + package_size = NULL, + preview_sha256 = NULL + WHERE id = ? AND ((status = 'draft' AND updated_at <= ?) + OR (status IN ('rejected', 'withdrawn') AND updated_at <= ?))", + ) + .bind(submission_id) + .bind(draft_cutoff) + .bind(closed_cutoff) + .execute(database.pool()) + .await? + .rows_affected(); + } + let mut result = RetentionResult { + submissions_scrubbed: scrubbed, + ..RetentionResult::default() + }; + for hash in packages { + if package_references(database, &hash).await? == 0 + && artifacts.remove_package(&mutation_guard, &hash).await? + { + result.packages_removed += 1; + } + } + for hash in previews { + if preview_references(database, &hash).await? == 0 + && artifacts.remove_preview(&mutation_guard, &hash).await? + { + result.previews_removed += 1; + } + } + let cutoff = UNIX_EPOCH + Duration::from_secs(draft_cutoff.max(0) as u64); + for hash in artifacts.package_hashes_older_than(cutoff).await? { + if package_references(database, &hash).await? == 0 + && artifacts.remove_package(&mutation_guard, &hash).await? + { + result.packages_removed += 1; + } + } + for hash in artifacts.preview_hashes_older_than(cutoff).await? { + if preview_references(database, &hash).await? == 0 + && artifacts.remove_preview(&mutation_guard, &hash).await? + { + result.previews_removed += 1; + } + } + result.temporary_files_removed = artifacts + .remove_temporary_older_than(&mutation_guard, cutoff) + .await?; + result.download_rows_compacted = compact_download_rows(database, now).await?; + Ok(result) +} + +async fn compact_download_rows(database: &Database, now: i64) -> anyhow::Result { + let cutoff = chrono::DateTime::from_timestamp(now - DOWNLOAD_DETAIL_RETENTION_SECONDS, 0) + .ok_or_else(|| anyhow::anyhow!("download retention timestamp is invalid"))? + .format("%Y-%m-%d") + .to_string(); + let mut transaction = database.pool().begin().await?; + sqlx::query( + "UPDATE listings SET download_count = download_count + ( + SELECT COUNT(*) FROM download_days d + WHERE d.listing_id = listings.id AND d.day < ? + ) WHERE EXISTS ( + SELECT 1 FROM download_days d + WHERE d.listing_id = listings.id AND d.day < ? + )", + ) + .bind(&cutoff) + .bind(&cutoff) + .execute(&mut *transaction) + .await?; + let removed = sqlx::query("DELETE FROM download_days WHERE day < ?") + .bind(&cutoff) + .execute(&mut *transaction) + .await? + .rows_affected(); + transaction.commit().await?; + Ok(removed) +} + +async fn package_references(database: &Database, hash: &str) -> anyhow::Result { + Ok(sqlx::query_scalar( + "SELECT (SELECT COUNT(*) FROM submissions WHERE package_sha256 = ?) + + (SELECT COUNT(*) FROM releases WHERE package_sha256 = ?)", + ) + .bind(hash) + .bind(hash) + .fetch_one(database.pool()) + .await?) +} + +async fn preview_references(database: &Database, hash: &str) -> anyhow::Result { + Ok(sqlx::query_scalar( + "SELECT (SELECT COUNT(*) FROM submissions WHERE preview_sha256 = ?) + + (SELECT COUNT(*) FROM releases WHERE preview_sha256 = ?)", + ) + .bind(hash) + .bind(hash) + .fetch_one(database.pool()) + .await?) +} + +pub(crate) fn spawn_cleanup_loop(database: Database, artifacts: ArtifactStore) { + tokio::spawn(async move { + let mut interval = tokio::time::interval(CLEANUP_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + interval.tick().await; + loop { + interval.tick().await; + match cleanup(&database, &artifacts).await { + Ok(result) + if result.submissions_scrubbed > 0 + || result.packages_removed > 0 + || result.previews_removed > 0 + || result.temporary_files_removed > 0 + || result.download_rows_compacted > 0 => + { + tracing::info!( + submissions_scrubbed = result.submissions_scrubbed, + packages_removed = result.packages_removed, + previews_removed = result.previews_removed, + temporary_files_removed = result.temporary_files_removed, + download_rows_compacted = result.download_rows_compacted, + "Appearance market retention cleanup completed" + ); + } + Ok(_) => {} + Err(error) => { + tracing::error!(error = %error, "Appearance market retention cleanup failed"); + } + } + } + }); +} diff --git a/src/crates/services/skin-market-service/src/routes.rs b/src/crates/services/skin-market-service/src/routes.rs new file mode 100644 index 0000000000..e1979e37c7 --- /dev/null +++ b/src/crates/services/skin-market-service/src/routes.rs @@ -0,0 +1,1971 @@ +use crate::artifacts::ArtifactStore; +use crate::auth::{AuthenticatedIdentity, IdentityVerifier}; +use crate::config::SkinMarketConfig; +use crate::db::Database; +use crate::error::{SkinMarketError, SkinMarketResult}; +use crate::package::validate_appearance_package; +use axum::body::Body; +use axum::extract::{DefaultBodyLimit, Path, Query, Request, State}; +use axum::http::{header, HeaderMap, HeaderValue, Method, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{any, get, post, put}; +use axum::{Json, Router}; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use bitfun_product_domains::appearance_market::{ + compute_appearance_review_bundle_hash, validate_appearance_market_slug, + AppearanceAdminSubmissionDetail, AppearanceCursorPage, AppearanceMarketListingDetail, + AppearanceMarketListingSummary, AppearanceMarketPackageMeta, AppearanceMarketRelease, + AppearanceMarketSort, AppearanceMarketSubmission, AppearanceMarketSubmissionDraftRequest, + AppearanceMarketSubmissionStatus, AppearanceMarketUserSummary, AppearanceReviewDecision, + AppearanceReviewDecisionRequest, APPEARANCE_MARKET_API_VERSION, + APPEARANCE_MARKET_DEFAULT_PAGE_SIZE, APPEARANCE_MARKET_MAX_PACKAGE_BYTES, + APPEARANCE_MARKET_MAX_PAGE_SIZE, APPEARANCE_MARKET_PACKAGE_CONTENT_TYPE, +}; +use chrono::Utc; +use hmac::{Hmac, Mac}; +use semver::Version; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::Sha256; +use sqlx::{Executor, QueryBuilder, Row, Sqlite}; +use std::sync::Arc; +use tokio::sync::Semaphore; +use tokio_util::io::ReaderStream; +use uuid::Uuid; + +#[derive(Clone)] +pub(crate) struct SkinMarketState { + pub config: SkinMarketConfig, + pub database: Database, + pub artifacts: ArtifactStore, + pub identity: IdentityVerifier, + pub upload_permits: Arc, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ListingQuery { + q: Option, + mode: Option, + sort: Option, + cursor: Option, + limit: Option, +} + +#[derive(Debug, Deserialize)] +struct SubmissionQuery { + status: Option, + cursor: Option, + limit: Option, +} + +#[derive(Debug, Deserialize)] +struct ModerationReason { + reason: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ConfigResponse { + api_version: &'static str, + public_browse: bool, + web_submissions_enabled: bool, + package_content_type: &'static str, + max_package_bytes: u64, +} + +pub(crate) fn api_router(state: Arc) -> Router { + Router::new() + .route("/health", get(health)) + .route("/config", get(config)) + .route("/listings", get(list_listings)) + .route("/listings/{slug}", get(get_listing)) + .route( + "/listings/{slug}/releases/{release_number}/download", + get(download_release), + ) + .route("/artifacts/previews/{sha256}", get(get_preview)) + .route( + "/submissions", + post(create_submission).get(list_my_submissions), + ) + .route( + "/submissions/{submission_id}", + get(get_my_submission).delete(withdraw_submission), + ) + .route( + "/submissions/{submission_id}/package", + put(upload_submission_package), + ) + .route( + "/submissions/{submission_id}/submit", + post(submit_submission), + ) + .route("/admin/submissions", get(list_admin_submissions)) + .route( + "/admin/submissions/{submission_id}", + get(get_admin_submission), + ) + .route( + "/admin/submissions/{submission_id}/decision", + post(review_submission), + ) + .route("/admin/releases/{release_id}/yank", post(yank_release)) + .route( + "/admin/listings/{listing_id}/unpublish", + post(unpublish_listing), + ) + .route("/{*rest}", any(api_not_found)) + .layer(axum::middleware::from_fn(normalize_api_rejection)) + // Uploads use a raw Request and enforce the 96 MiB package limit in + // the authenticated handler. Keep every structured API extractor + // small so malformed or unauthenticated JSON cannot reserve 96 MiB. + .layer(DefaultBodyLimit::max(64 * 1024)) + .with_state(state) +} + +async fn normalize_api_rejection(request: Request, next: axum::middleware::Next) -> Response { + let response = next.run(request).await; + if !response.status().is_client_error() + || response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("application/json")) + { + return response; + } + match response.status() { + StatusCode::PAYLOAD_TOO_LARGE => SkinMarketError::new( + StatusCode::PAYLOAD_TOO_LARGE, + "request_too_large", + "The Skin market API request body exceeds 64 KiB.", + ) + .into_response(), + StatusCode::METHOD_NOT_ALLOWED => SkinMarketError::new( + StatusCode::METHOD_NOT_ALLOWED, + "method_not_allowed", + "The HTTP method is not allowed for this Skin market API route.", + ) + .into_response(), + status + if matches!( + status, + StatusCode::BAD_REQUEST + | StatusCode::UNPROCESSABLE_ENTITY + | StatusCode::UNSUPPORTED_MEDIA_TYPE + ) => + { + SkinMarketError::bad_request( + "invalid_request", + "The Skin market API request is malformed or has an unsupported content type.", + ) + .into_response() + } + _ => response, + } +} + +async fn api_not_found() -> SkinMarketError { + SkinMarketError::not_found("Unknown appearance marketplace API route.") +} + +async fn health(State(state): State>) -> impl IntoResponse { + let database_ready = sqlx::query_scalar::<_, i64>("SELECT 1") + .fetch_one(state.database.pool()) + .await + .is_ok(); + ( + if database_ready { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + }, + Json(serde_json::json!({ + "status": if database_ready { "ok" } else { "degraded" }, + "database": database_ready, + "identityVerifierConfigured": true + })), + ) +} + +async fn config(State(state): State>) -> Json { + Json(ConfigResponse { + api_version: APPEARANCE_MARKET_API_VERSION, + public_browse: state.config.public_browse, + web_submissions_enabled: false, + package_content_type: APPEARANCE_MARKET_PACKAGE_CONTENT_TYPE, + max_package_bytes: APPEARANCE_MARKET_MAX_PACKAGE_BYTES, + }) +} + +async fn list_listings( + State(state): State>, + Query(query): Query, +) -> SkinMarketResult>> { + ensure_public_browse(&state)?; + let limit = query + .limit + .unwrap_or(APPEARANCE_MARKET_DEFAULT_PAGE_SIZE) + .clamp(1, APPEARANCE_MARKET_MAX_PAGE_SIZE); + let offset = decode_cursor(query.cursor.as_deref())?; + let mode = match query.mode.as_deref().map(str::trim) { + None | Some("") | Some("all") => None, + Some("light") => Some("light"), + Some("dark") => Some("dark"), + Some(_) => { + return Err(SkinMarketError::bad_request( + "invalid_mode", + "Appearance mode must be light, dark, or all.", + )) + } + }; + let mut builder = QueryBuilder::::new( + "SELECT l.id AS listing_id, l.slug, r.release_number, r.draft_json, + r.package_meta_json, r.preview_sha256, r.published_at, + u.github_id, u.login, u.avatar_url, + l.download_count + + (SELECT COUNT(*) FROM download_days d WHERE d.listing_id = l.id) + AS download_count + FROM listings l + JOIN releases r ON r.id = l.latest_release_id + JOIN users u ON u.id = l.owner_user_id + WHERE l.is_published = 1 AND r.yanked_at IS NULL", + ); + if let Some(search) = query.q.filter(|value| !value.trim().is_empty()) { + let search = format!("%{}%", search.trim().to_ascii_lowercase()); + builder.push(" AND (lower(l.slug) LIKE "); + builder.push_bind(search.clone()); + builder.push(" OR lower(json_extract(r.package_meta_json, '$.name')) LIKE "); + builder.push_bind(search.clone()); + builder.push(" OR lower(json_extract(r.package_meta_json, '$.description')) LIKE "); + builder.push_bind(search); + builder.push(")"); + } + if let Some(mode) = mode { + builder.push(" AND json_extract(r.package_meta_json, '$.mode') = "); + builder.push_bind(mode); + } + match query.sort.unwrap_or_default() { + AppearanceMarketSort::Newest => { + builder.push(" ORDER BY r.published_at DESC, l.id DESC"); + } + AppearanceMarketSort::Downloads => { + builder.push(" ORDER BY download_count DESC, r.published_at DESC, l.id DESC"); + } + } + builder.push(" LIMIT "); + builder.push_bind(i64::from(limit + 1)); + builder.push(" OFFSET "); + builder.push_bind(i64::from(offset)); + let rows = builder + .build() + .fetch_all(state.database.pool()) + .await + .map_err(SkinMarketError::internal)?; + let has_more = rows.len() > limit as usize; + let mut items = Vec::with_capacity(rows.len().min(limit as usize)); + for row in rows.into_iter().take(limit as usize) { + items.push(summary_from_row(&state, &row)?); + } + Ok(Json(AppearanceCursorPage { + items, + next_cursor: has_more.then(|| encode_cursor(offset + limit)), + })) +} + +async fn get_listing( + State(state): State>, + Path(slug): Path, +) -> SkinMarketResult> { + ensure_public_browse(&state)?; + Ok(Json(listing_detail_by_slug(&state, &slug).await?)) +} + +async fn download_release( + State(state): State>, + method: Method, + headers: HeaderMap, + Path((slug, release_number)): Path<(String, u32)>, +) -> SkinMarketResult { + ensure_public_browse(&state)?; + let row = sqlx::query( + "SELECT l.id AS listing_id, r.package_sha256, r.package_size, r.package_meta_json + FROM listings l JOIN releases r ON r.listing_id = l.id + WHERE l.slug = ? AND l.is_published = 1 + AND r.release_number = ? AND r.yanked_at IS NULL", + ) + .bind(&slug) + .bind(i64::from(release_number)) + .fetch_optional(state.database.pool()) + .await + .map_err(SkinMarketError::internal)? + .ok_or_else(|| { + SkinMarketError::not_found("The requested appearance release is unavailable.") + })?; + let listing_id: String = row.get("listing_id"); + let package_sha256: String = row.get("package_sha256"); + let expected_size = row.get::("package_size") as u64; + let meta: AppearanceMarketPackageMeta = parse_json( + row.get::("package_meta_json"), + "package metadata", + )?; + let (file, content_length) = state.artifacts.open_package(&package_sha256).await?; + if content_length != expected_size { + return Err(SkinMarketError::internal( + "appearance package artifact size does not match its release record", + )); + } + if method != Method::HEAD { + record_download(&state, &headers, &listing_id).await?; + } + let mut response = Response::new(if method == Method::HEAD { + Body::empty() + } else { + Body::from_stream(ReaderStream::new(file)) + }); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static(APPEARANCE_MARKET_PACKAGE_CONTENT_TYPE), + ); + response.headers_mut().insert( + header::CONTENT_DISPOSITION, + HeaderValue::from_str(&format!( + "attachment; filename=\"{}-{}.bitfun-appearance\"", + slug, meta.package_version + )) + .map_err(SkinMarketError::internal)?, + ); + response.headers_mut().insert( + header::ETAG, + HeaderValue::from_str(&format!("\"{package_sha256}\"")) + .map_err(SkinMarketError::internal)?, + ); + response.headers_mut().insert( + header::CONTENT_LENGTH, + HeaderValue::from_str(&content_length.to_string()).map_err(SkinMarketError::internal)?, + ); + Ok(response) +} + +async fn get_preview( + State(state): State>, + headers: HeaderMap, + Path(sha256): Path, +) -> SkinMarketResult { + validate_sha256(&sha256)?; + let public_references: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM releases r + JOIN listings l ON l.id = r.listing_id + WHERE r.preview_sha256 = ? AND r.yanked_at IS NULL AND l.is_published = 1", + ) + .bind(&sha256) + .fetch_one(state.database.pool()) + .await + .map_err(SkinMarketError::internal)?; + let is_public = public_references > 0 && state.config.public_browse; + if !is_public { + let identity = state.identity.require(&headers, &state.database).await?; + let private_references: i64 = if identity.is_admin { + sqlx::query_scalar("SELECT COUNT(*) FROM submissions WHERE preview_sha256 = ?") + .bind(&sha256) + .fetch_one(state.database.pool()) + .await + .map_err(SkinMarketError::internal)? + } else { + sqlx::query_scalar( + "SELECT COUNT(*) FROM submissions WHERE preview_sha256 = ? AND owner_user_id = ?", + ) + .bind(&sha256) + .bind(identity.user.internal_id) + .fetch_one(state.database.pool()) + .await + .map_err(SkinMarketError::internal)? + }; + if private_references == 0 { + return Err(SkinMarketError::not_found( + "The requested appearance preview is unavailable.", + )); + } + } + let bytes = state.artifacts.read_preview(&sha256).await?; + let content_length = bytes.len(); + let mut response = Response::new(Body::from(bytes)); + response + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static("image/webp")); + response.headers_mut().insert( + header::CACHE_CONTROL, + if is_public { + HeaderValue::from_static("public, max-age=31536000, immutable") + } else { + HeaderValue::from_static("private, no-store") + }, + ); + response.headers_mut().insert( + header::ETAG, + HeaderValue::from_str(&format!("\"{sha256}\"")).map_err(SkinMarketError::internal)?, + ); + response.headers_mut().insert( + header::CONTENT_LENGTH, + HeaderValue::from_str(&content_length.to_string()).map_err(SkinMarketError::internal)?, + ); + Ok(response) +} + +async fn create_submission( + State(state): State>, + headers: HeaderMap, + Json(request): Json, +) -> SkinMarketResult<(StatusCode, Json)> { + let identity = state + .identity + .require_write(&headers, &state.database) + .await?; + validate_draft(&request)?; + validate_draft_target(&state, &identity, &request).await?; + let now = Utc::now().timestamp(); + let submission_id = Uuid::new_v4().to_string(); + let draft_json = serde_json::to_string(&request).map_err(SkinMarketError::internal)?; + let mut transaction = state + .database + .pool() + .begin() + .await + .map_err(SkinMarketError::internal)?; + sqlx::query( + "INSERT INTO submissions( + id, listing_id, owner_user_id, slug, release_number, draft_json, + status, created_at, updated_at + ) VALUES(?, ?, ?, ?, ?, ?, 'draft', ?, ?)", + ) + .bind(&submission_id) + .bind(&request.listing_id) + .bind(identity.user.internal_id) + .bind(&request.slug) + .bind(i64::from(request.release_number)) + .bind(draft_json) + .bind(now) + .bind(now) + .execute(&mut *transaction) + .await + .map_err(|error| { + if is_unique_violation(&error) { + SkinMarketError::conflict( + "submission_conflict", + "An active submission already reserves this Skin release target.", + ) + } else { + SkinMarketError::internal(error) + } + })?; + insert_audit( + &mut *transaction, + identity.user.internal_id, + "submission_created", + "submission", + &submission_id, + serde_json::json!({"slug": request.slug, "releaseNumber": request.release_number}), + ) + .await?; + transaction + .commit() + .await + .map_err(SkinMarketError::internal)?; + Ok(( + StatusCode::CREATED, + Json(submission_by_id(&state, &submission_id, Some(identity.user.internal_id)).await?), + )) +} + +async fn upload_submission_package( + State(state): State>, + Path(submission_id): Path, + request: Request, +) -> SkinMarketResult> { + let headers = request.headers().clone(); + ensure_package_content_type(&headers)?; + let identity = state + .identity + .require_write(&headers, &state.database) + .await?; + if headers + .get(header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .is_some_and(|length| length > APPEARANCE_MARKET_MAX_PACKAGE_BYTES) + { + return Err(SkinMarketError::payload_too_large()); + } + let row = sqlx::query("SELECT status FROM submissions WHERE id = ? AND owner_user_id = ?") + .bind(&submission_id) + .bind(identity.user.internal_id) + .fetch_optional(state.database.pool()) + .await + .map_err(SkinMarketError::internal)? + .ok_or_else(|| SkinMarketError::not_found("Appearance submission was not found."))?; + if row.get::("status") != "draft" { + return Err(SkinMarketError::conflict( + "submission_not_editable", + "Only draft appearance submissions can replace their package.", + )); + } + let _upload_permit = state + .upload_permits + .clone() + .acquire_owned() + .await + .map_err(SkinMarketError::internal)?; + let body = axum::body::to_bytes( + request.into_body(), + APPEARANCE_MARKET_MAX_PACKAGE_BYTES as usize, + ) + .await + .map_err(|_| SkinMarketError::payload_too_large())?; + let package_bytes = body; + let validated = tokio::task::spawn_blocking(move || { + validate_appearance_package(&package_bytes).map(|package| (package, package_bytes)) + }) + .await + .map_err(SkinMarketError::internal)??; + let (validated, package_bytes) = validated; + let artifact_guard = state.artifacts.lock_mutations().await; + state + .artifacts + .put_package(&artifact_guard, &validated.sha256, &package_bytes) + .await?; + state + .artifacts + .put_preview( + &artifact_guard, + &validated.preview.sha256, + &validated.preview.bytes, + ) + .await?; + let package_meta_json = + serde_json::to_string(&validated.meta).map_err(SkinMarketError::internal)?; + let mut transaction = state + .database + .pool() + .begin() + .await + .map_err(SkinMarketError::internal)?; + let updated = sqlx::query( + "UPDATE submissions SET package_meta_json = ?, manifest_json = ?, + package_sha256 = ?, package_size = ?, preview_sha256 = ?, updated_at = ? + WHERE id = ? AND owner_user_id = ? AND status = 'draft'", + ) + .bind(package_meta_json) + .bind(validated.canonical_manifest_json) + .bind(&validated.sha256) + .bind(validated.size as i64) + .bind(&validated.preview.sha256) + .bind(Utc::now().timestamp()) + .bind(&submission_id) + .bind(identity.user.internal_id) + .execute(&mut *transaction) + .await + .map_err(SkinMarketError::internal)?; + if updated.rows_affected() != 1 { + return Err(SkinMarketError::conflict( + "submission_changed", + "The appearance submission changed while its package was being validated.", + )); + } + insert_audit( + &mut *transaction, + identity.user.internal_id, + "submission_package_uploaded", + "submission", + &submission_id, + serde_json::json!({ + "packageSha256": validated.sha256, + "previewSha256": validated.preview.sha256, + "previewWidth": validated.preview.width, + "previewHeight": validated.preview.height + }), + ) + .await?; + transaction + .commit() + .await + .map_err(SkinMarketError::internal)?; + Ok(Json( + submission_by_id(&state, &submission_id, Some(identity.user.internal_id)).await?, + )) +} + +async fn submit_submission( + State(state): State>, + headers: HeaderMap, + Path(submission_id): Path, +) -> SkinMarketResult> { + let identity = state + .identity + .require_write(&headers, &state.database) + .await?; + let mut transaction = state + .database + .pool() + .begin() + .await + .map_err(SkinMarketError::internal)?; + let row = sqlx::query( + "SELECT listing_id, slug, release_number, draft_json, package_meta_json, + package_sha256, preview_sha256, status + FROM submissions WHERE id = ? AND owner_user_id = ?", + ) + .bind(&submission_id) + .bind(identity.user.internal_id) + .fetch_optional(&mut *transaction) + .await + .map_err(SkinMarketError::internal)? + .ok_or_else(|| SkinMarketError::not_found("Appearance submission was not found."))?; + if row.get::("status") != "draft" { + return Err(SkinMarketError::conflict( + "submission_not_submittable", + "Only draft appearance submissions can be submitted.", + )); + } + let draft: AppearanceMarketSubmissionDraftRequest = + parse_json(row.get("draft_json"), "submission draft")?; + let package_meta: AppearanceMarketPackageMeta = parse_optional_json( + row.try_get::, _>("package_meta_json") + .map_err(SkinMarketError::internal)?, + "package metadata", + )? + .ok_or_else(|| { + SkinMarketError::conflict( + "package_required", + "An appearance package must be uploaded before submission.", + ) + })?; + let package_present = row + .try_get::, _>("package_sha256") + .map_err(SkinMarketError::internal)? + .is_some(); + let preview_present = row + .try_get::, _>("preview_sha256") + .map_err(SkinMarketError::internal)? + .is_some(); + if !package_present || !preview_present { + return Err(SkinMarketError::conflict( + "package_required", + "The appearance package and normalized preview are required.", + )); + } + validate_release_target( + &mut transaction, + identity.user.internal_id, + &draft, + &package_meta, + ) + .await?; + let now = Utc::now().timestamp(); + let updated = sqlx::query( + "UPDATE submissions SET status = 'submitted', submitted_at = ?, updated_at = ? + WHERE id = ? AND owner_user_id = ? AND status = 'draft'", + ) + .bind(now) + .bind(now) + .bind(&submission_id) + .bind(identity.user.internal_id) + .execute(&mut *transaction) + .await + .map_err(SkinMarketError::internal)?; + if updated.rows_affected() != 1 { + return Err(SkinMarketError::conflict( + "submission_changed", + "The appearance submission changed while it was being submitted.", + )); + } + insert_audit( + &mut *transaction, + identity.user.internal_id, + "submission_submitted", + "submission", + &submission_id, + serde_json::json!({"slug": draft.slug, "packageId": package_meta.package_id}), + ) + .await?; + transaction + .commit() + .await + .map_err(SkinMarketError::internal)?; + Ok(Json( + submission_by_id(&state, &submission_id, Some(identity.user.internal_id)).await?, + )) +} + +async fn list_my_submissions( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> SkinMarketResult>> { + let identity = state.identity.require(&headers, &state.database).await?; + Ok(Json( + list_submissions( + &state, + Some(identity.user.internal_id), + query.status, + query.cursor.as_deref(), + query.limit, + ) + .await?, + )) +} + +async fn get_my_submission( + State(state): State>, + headers: HeaderMap, + Path(submission_id): Path, +) -> SkinMarketResult> { + let identity = state.identity.require(&headers, &state.database).await?; + Ok(Json( + submission_by_id(&state, &submission_id, Some(identity.user.internal_id)).await?, + )) +} + +async fn withdraw_submission( + State(state): State>, + headers: HeaderMap, + Path(submission_id): Path, +) -> SkinMarketResult> { + let identity = state + .identity + .require_write(&headers, &state.database) + .await?; + let now = Utc::now().timestamp(); + let mut transaction = state + .database + .pool() + .begin() + .await + .map_err(SkinMarketError::internal)?; + let updated = sqlx::query( + "UPDATE submissions SET status = 'withdrawn', updated_at = ? + WHERE id = ? AND owner_user_id = ? AND status IN ('draft', 'submitted')", + ) + .bind(now) + .bind(&submission_id) + .bind(identity.user.internal_id) + .execute(&mut *transaction) + .await + .map_err(SkinMarketError::internal)?; + if updated.rows_affected() != 1 { + let exists: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM submissions WHERE id = ? AND owner_user_id = ?", + ) + .bind(&submission_id) + .bind(identity.user.internal_id) + .fetch_one(&mut *transaction) + .await + .map_err(SkinMarketError::internal)?; + return Err(if exists == 0 { + SkinMarketError::not_found("Appearance submission was not found.") + } else { + SkinMarketError::conflict( + "submission_not_withdrawable", + "Only draft or submitted appearance entries may be withdrawn.", + ) + }); + } + insert_audit( + &mut *transaction, + identity.user.internal_id, + "submission_withdrawn", + "submission", + &submission_id, + serde_json::json!({}), + ) + .await?; + transaction + .commit() + .await + .map_err(SkinMarketError::internal)?; + Ok(Json( + submission_by_id(&state, &submission_id, Some(identity.user.internal_id)).await?, + )) +} + +async fn list_admin_submissions( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> SkinMarketResult>> { + state + .identity + .require_admin(&headers, &state.database) + .await?; + Ok(Json( + list_submissions( + &state, + None, + Some( + query + .status + .unwrap_or(AppearanceMarketSubmissionStatus::Submitted), + ), + query.cursor.as_deref(), + query.limit, + ) + .await?, + )) +} + +async fn get_admin_submission( + State(state): State>, + headers: HeaderMap, + Path(submission_id): Path, +) -> SkinMarketResult> { + state + .identity + .require_admin(&headers, &state.database) + .await?; + Ok(Json(admin_submission_detail(&state, &submission_id).await?)) +} + +async fn review_submission( + State(state): State>, + headers: HeaderMap, + Path(submission_id): Path, + Json(request): Json, +) -> SkinMarketResult> { + let admin = state + .identity + .require_admin_write(&headers, &state.database) + .await?; + match request.decision { + AppearanceReviewDecision::Approve => { + approve_submission(&state, &admin, &submission_id).await?; + } + AppearanceReviewDecision::Reject => { + let reason = validate_reason(&request.reason)?; + reject_submission(&state, &admin, &submission_id, reason).await?; + } + } + Ok(Json(admin_submission_detail(&state, &submission_id).await?)) +} + +async fn reject_submission( + state: &SkinMarketState, + admin: &AuthenticatedIdentity, + submission_id: &str, + reason: &str, +) -> SkinMarketResult<()> { + let now = Utc::now().timestamp(); + let mut transaction = state + .database + .pool() + .begin() + .await + .map_err(SkinMarketError::internal)?; + let updated = sqlx::query( + "UPDATE submissions SET status = 'rejected', rejection_reason = ?, + reviewed_at = ?, reviewer_user_id = ?, updated_at = ? + WHERE id = ? AND status = 'submitted'", + ) + .bind(reason) + .bind(now) + .bind(admin.user.internal_id) + .bind(now) + .bind(submission_id) + .execute(&mut *transaction) + .await + .map_err(SkinMarketError::internal)?; + if updated.rows_affected() != 1 { + return Err(SkinMarketError::conflict( + "submission_not_reviewable", + "Only submitted appearance entries may be rejected.", + )); + } + insert_audit( + &mut *transaction, + admin.user.internal_id, + "submission_rejected", + "submission", + submission_id, + serde_json::json!({"reason": reason}), + ) + .await?; + transaction + .commit() + .await + .map_err(SkinMarketError::internal) +} + +async fn approve_submission( + state: &SkinMarketState, + admin: &AuthenticatedIdentity, + submission_id: &str, +) -> SkinMarketResult<()> { + let mut transaction = state + .database + .pool() + .begin() + .await + .map_err(SkinMarketError::internal)?; + let row = sqlx::query( + "SELECT listing_id, owner_user_id, slug, release_number, draft_json, + package_meta_json, manifest_json, package_sha256, package_size, + preview_sha256, status + FROM submissions WHERE id = ?", + ) + .bind(submission_id) + .fetch_optional(&mut *transaction) + .await + .map_err(SkinMarketError::internal)? + .ok_or_else(|| SkinMarketError::not_found("Appearance submission was not found."))?; + if row.get::("status") != "submitted" { + return Err(SkinMarketError::conflict( + "submission_not_reviewable", + "Only submitted appearance entries may be approved.", + )); + } + let owner_user_id: i64 = row.get("owner_user_id"); + let slug: String = row.get("slug"); + let release_number = row.get::("release_number") as u32; + let draft_json: String = row.get("draft_json"); + let package_meta_json: String = row + .try_get::, _>("package_meta_json") + .map_err(SkinMarketError::internal)? + .ok_or_else(|| { + SkinMarketError::conflict("package_required", "Package metadata is missing.") + })?; + let manifest_json: String = row + .try_get::, _>("manifest_json") + .map_err(SkinMarketError::internal)? + .ok_or_else(|| { + SkinMarketError::conflict("package_required", "Package manifest is missing.") + })?; + let package_sha256: String = row + .try_get::, _>("package_sha256") + .map_err(SkinMarketError::internal)? + .ok_or_else(|| { + SkinMarketError::conflict("package_required", "Package artifact is missing.") + })?; + let package_size: i64 = row + .try_get::, _>("package_size") + .map_err(SkinMarketError::internal)? + .ok_or_else(|| SkinMarketError::conflict("package_required", "Package size is missing."))?; + let preview_sha256: String = row + .try_get::, _>("preview_sha256") + .map_err(SkinMarketError::internal)? + .ok_or_else(|| { + SkinMarketError::conflict("preview_required", "Preview artifact is missing.") + })?; + let draft: AppearanceMarketSubmissionDraftRequest = + parse_json(draft_json.clone(), "submission draft")?; + let package_meta: AppearanceMarketPackageMeta = + parse_json(package_meta_json.clone(), "package metadata")?; + validate_release_target(&mut transaction, owner_user_id, &draft, &package_meta).await?; + + let now = Utc::now().timestamp(); + let listing_id = if let Some(listing_id) = row + .try_get::, _>("listing_id") + .map_err(SkinMarketError::internal)? + { + listing_id + } else { + let listing_id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO listings( + id, slug, package_id, owner_user_id, is_published, created_at, updated_at + ) VALUES(?, ?, ?, ?, 0, ?, ?)", + ) + .bind(&listing_id) + .bind(&slug) + .bind(&package_meta.package_id) + .bind(owner_user_id) + .bind(now) + .bind(now) + .execute(&mut *transaction) + .await + .map_err(|error| { + if is_unique_violation(&error) { + listing_identity_conflict(&error) + } else { + SkinMarketError::internal(error) + } + })?; + listing_id + }; + let canonical_metadata = + canonical_review_metadata(&draft_json, &package_meta_json, &manifest_json)?; + let review_bundle_hash = compute_appearance_review_bundle_hash( + &package_sha256, + &canonical_metadata, + &preview_sha256, + ); + let release_id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO releases( + id, listing_id, submission_id, release_number, draft_json, + package_meta_json, manifest_json, package_sha256, package_size, + preview_sha256, review_bundle_hash, published_at + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&release_id) + .bind(&listing_id) + .bind(submission_id) + .bind(i64::from(release_number)) + .bind(&draft_json) + .bind(&package_meta_json) + .bind(&manifest_json) + .bind(&package_sha256) + .bind(package_size) + .bind(&preview_sha256) + .bind(&review_bundle_hash) + .bind(now) + .execute(&mut *transaction) + .await + .map_err(|error| { + if is_unique_violation(&error) { + SkinMarketError::conflict( + "release_exists", + "This appearance release number has already been published.", + ) + } else { + SkinMarketError::internal(error) + } + })?; + sqlx::query( + "UPDATE listings SET latest_release_id = ?, is_published = 1, updated_at = ? + WHERE id = ?", + ) + .bind(&release_id) + .bind(now) + .bind(&listing_id) + .execute(&mut *transaction) + .await + .map_err(SkinMarketError::internal)?; + sqlx::query( + "UPDATE submissions SET listing_id = ?, status = 'approved', reviewer_user_id = ?, + reviewed_at = ?, updated_at = ? WHERE id = ? AND status = 'submitted'", + ) + .bind(&listing_id) + .bind(admin.user.internal_id) + .bind(now) + .bind(now) + .bind(submission_id) + .execute(&mut *transaction) + .await + .map_err(SkinMarketError::internal)?; + insert_audit( + &mut *transaction, + admin.user.internal_id, + "submission_approved", + "submission", + submission_id, + serde_json::json!({ + "listingId": listing_id, + "releaseId": release_id, + "reviewBundleHash": review_bundle_hash + }), + ) + .await?; + transaction + .commit() + .await + .map_err(SkinMarketError::internal) +} + +async fn yank_release( + State(state): State>, + headers: HeaderMap, + Path(release_id): Path, + Json(request): Json, +) -> SkinMarketResult { + let admin = state + .identity + .require_admin_write(&headers, &state.database) + .await?; + let reason = validate_reason(&request.reason)?; + let mut transaction = state + .database + .pool() + .begin() + .await + .map_err(SkinMarketError::internal)?; + let row = sqlx::query("SELECT listing_id, yanked_at FROM releases WHERE id = ?") + .bind(&release_id) + .fetch_optional(&mut *transaction) + .await + .map_err(SkinMarketError::internal)? + .ok_or_else(|| SkinMarketError::not_found("Appearance release was not found."))?; + if row + .try_get::, _>("yanked_at") + .map_err(SkinMarketError::internal)? + .is_some() + { + return Err(SkinMarketError::conflict( + "release_already_yanked", + "The appearance release has already been yanked.", + )); + } + let listing_id: String = row.get("listing_id"); + sqlx::query("UPDATE releases SET yanked_at = ?, yank_reason = ? WHERE id = ?") + .bind(Utc::now().timestamp()) + .bind(reason) + .bind(&release_id) + .execute(&mut *transaction) + .await + .map_err(SkinMarketError::internal)?; + let replacement: Option = sqlx::query_scalar( + "SELECT id FROM releases + WHERE listing_id = ? AND yanked_at IS NULL + ORDER BY release_number DESC LIMIT 1", + ) + .bind(&listing_id) + .fetch_optional(&mut *transaction) + .await + .map_err(SkinMarketError::internal)?; + sqlx::query( + "UPDATE listings SET latest_release_id = ?, is_published = ?, updated_at = ? WHERE id = ?", + ) + .bind(&replacement) + .bind(i64::from(replacement.is_some())) + .bind(Utc::now().timestamp()) + .bind(&listing_id) + .execute(&mut *transaction) + .await + .map_err(SkinMarketError::internal)?; + insert_audit( + &mut *transaction, + admin.user.internal_id, + "release_yanked", + "release", + &release_id, + serde_json::json!({"reason": reason, "listingId": listing_id}), + ) + .await?; + transaction + .commit() + .await + .map_err(SkinMarketError::internal)?; + Ok(StatusCode::NO_CONTENT) +} + +async fn unpublish_listing( + State(state): State>, + headers: HeaderMap, + Path(listing_id): Path, + Json(request): Json, +) -> SkinMarketResult { + let admin = state + .identity + .require_admin_write(&headers, &state.database) + .await?; + let reason = validate_reason(&request.reason)?; + let mut transaction = state + .database + .pool() + .begin() + .await + .map_err(SkinMarketError::internal)?; + let updated = sqlx::query( + "UPDATE listings SET is_published = 0, updated_at = ? + WHERE id = ? AND is_published = 1", + ) + .bind(Utc::now().timestamp()) + .bind(&listing_id) + .execute(&mut *transaction) + .await + .map_err(SkinMarketError::internal)?; + if updated.rows_affected() != 1 { + return Err(SkinMarketError::not_found( + "Published appearance listing was not found.", + )); + } + insert_audit( + &mut *transaction, + admin.user.internal_id, + "listing_unpublished", + "listing", + &listing_id, + serde_json::json!({"reason": reason}), + ) + .await?; + transaction + .commit() + .await + .map_err(SkinMarketError::internal)?; + Ok(StatusCode::NO_CONTENT) +} + +async fn listing_detail_by_slug( + state: &SkinMarketState, + slug: &str, +) -> SkinMarketResult { + let row = sqlx::query( + "SELECT l.id AS listing_id, l.slug, r.release_number, r.draft_json, + r.package_meta_json, r.preview_sha256, r.published_at, + u.github_id, u.login, u.avatar_url, + l.download_count + + (SELECT COUNT(*) FROM download_days d WHERE d.listing_id = l.id) + AS download_count + FROM listings l + JOIN releases r ON r.id = l.latest_release_id + JOIN users u ON u.id = l.owner_user_id + WHERE l.slug = ? AND l.is_published = 1 AND r.yanked_at IS NULL", + ) + .bind(slug) + .fetch_optional(state.database.pool()) + .await + .map_err(SkinMarketError::internal)? + .ok_or_else(|| SkinMarketError::not_found("Appearance listing was not found."))?; + let summary = summary_from_row(state, &row)?; + let draft: AppearanceMarketSubmissionDraftRequest = + parse_json(row.get::("draft_json"), "release draft")?; + let rows = sqlx::query( + "SELECT id, listing_id, release_number, draft_json, package_meta_json, + package_sha256, package_size, review_bundle_hash, published_at, yanked_at + FROM releases WHERE listing_id = ? ORDER BY release_number DESC", + ) + .bind(&summary.listing_id) + .fetch_all(state.database.pool()) + .await + .map_err(SkinMarketError::internal)?; + let releases = rows + .iter() + .map(release_from_row) + .collect::>>()?; + Ok(AppearanceMarketListingDetail { + summary, + changelog: draft.changelog, + license: draft.license, + repository_url: draft.repository_url, + releases, + }) +} + +fn summary_from_row( + state: &SkinMarketState, + row: &sqlx::sqlite::SqliteRow, +) -> SkinMarketResult { + let draft: AppearanceMarketSubmissionDraftRequest = + parse_json(row.get::("draft_json"), "release draft")?; + let meta: AppearanceMarketPackageMeta = parse_json( + row.get::("package_meta_json"), + "package metadata", + )?; + let preview_sha256: String = row.get("preview_sha256"); + Ok(AppearanceMarketListingSummary { + listing_id: row.get("listing_id"), + slug: row.get("slug"), + package_id: meta.package_id, + name: meta.name, + description: meta.description, + author: meta.author, + mode: meta.mode, + package_version: meta.package_version, + latest_release: row.get::("release_number") as u32, + min_bitfun_version: draft.min_bitfun_version, + required_capabilities: meta.required_capabilities, + owner: AppearanceMarketUserSummary { + github_id: row.get("github_id"), + login: row.get("login"), + avatar_url: row.get("avatar_url"), + }, + preview_url: format!( + "{}/api/v1/artifacts/previews/{preview_sha256}", + state.config.public_base_url + ), + download_count: row.get::("download_count").max(0) as u64, + published_at: row.get("published_at"), + }) +} + +fn release_from_row(row: &sqlx::sqlite::SqliteRow) -> SkinMarketResult { + let draft: AppearanceMarketSubmissionDraftRequest = + parse_json(row.get::("draft_json"), "release draft")?; + let meta: AppearanceMarketPackageMeta = parse_json( + row.get::("package_meta_json"), + "package metadata", + )?; + Ok(AppearanceMarketRelease { + release_id: row.get("id"), + listing_id: row.get("listing_id"), + release_number: row.get::("release_number") as u32, + package_version: meta.package_version, + min_bitfun_version: draft.min_bitfun_version, + package_sha256: row.get("package_sha256"), + package_size: row.get::("package_size") as u64, + review_bundle_hash: row.get("review_bundle_hash"), + published_at: row.get("published_at"), + yanked: row + .try_get::, _>("yanked_at") + .map_err(SkinMarketError::internal)? + .is_some(), + }) +} + +async fn list_submissions( + state: &SkinMarketState, + owner_user_id: Option, + status: Option, + cursor: Option<&str>, + limit: Option, +) -> SkinMarketResult> { + let limit = limit + .unwrap_or(APPEARANCE_MARKET_DEFAULT_PAGE_SIZE) + .clamp(1, APPEARANCE_MARKET_MAX_PAGE_SIZE); + let cursor = decode_submission_cursor(cursor)?; + let mut builder = QueryBuilder::::new( + "SELECT id, listing_id, owner_user_id, slug, release_number, draft_json, + package_meta_json, status, package_sha256, package_size, preview_sha256, + rejection_reason, created_at, updated_at + FROM submissions WHERE 1 = 1", + ); + if let Some(owner_user_id) = owner_user_id { + builder.push(" AND owner_user_id = "); + builder.push_bind(owner_user_id); + } + if let Some(status) = status { + builder.push(" AND status = "); + builder.push_bind(status_string(status)); + } + if let Some((updated_at, submission_id)) = cursor { + builder.push(" AND (updated_at < "); + builder.push_bind(updated_at); + builder.push(" OR (updated_at = "); + builder.push_bind(updated_at); + builder.push(" AND id < "); + builder.push_bind(submission_id); + builder.push("))"); + } + builder.push(" ORDER BY updated_at DESC, id DESC LIMIT "); + builder.push_bind(i64::from(limit + 1)); + let rows = builder + .build() + .fetch_all(state.database.pool()) + .await + .map_err(SkinMarketError::internal)?; + let has_more = rows.len() > limit as usize; + let items = rows + .iter() + .take(limit as usize) + .map(|row| submission_from_row(state, row)) + .collect::>>()?; + let next_cursor = has_more.then(|| { + let last = items + .last() + .expect("a paginated submission page with more rows is non-empty"); + encode_submission_cursor(last.updated_at, &last.submission_id) + }); + Ok(AppearanceCursorPage { items, next_cursor }) +} + +async fn submission_by_id( + state: &SkinMarketState, + submission_id: &str, + owner_user_id: Option, +) -> SkinMarketResult { + let mut builder = QueryBuilder::::new( + "SELECT id, listing_id, owner_user_id, slug, release_number, draft_json, + package_meta_json, status, package_sha256, package_size, preview_sha256, + rejection_reason, created_at, updated_at + FROM submissions WHERE id = ", + ); + builder.push_bind(submission_id); + if let Some(owner_user_id) = owner_user_id { + builder.push(" AND owner_user_id = "); + builder.push_bind(owner_user_id); + } + let row = builder + .build() + .fetch_optional(state.database.pool()) + .await + .map_err(SkinMarketError::internal)? + .ok_or_else(|| SkinMarketError::not_found("Appearance submission was not found."))?; + submission_from_row(state, &row) +} + +fn submission_from_row( + state: &SkinMarketState, + row: &sqlx::sqlite::SqliteRow, +) -> SkinMarketResult { + let draft: AppearanceMarketSubmissionDraftRequest = + parse_json(row.get::("draft_json"), "submission draft")?; + let meta: Option = parse_optional_json( + row.try_get::, _>("package_meta_json") + .map_err(SkinMarketError::internal)?, + "package metadata", + )?; + let preview_sha256 = row + .try_get::, _>("preview_sha256") + .map_err(SkinMarketError::internal)?; + Ok(AppearanceMarketSubmission { + submission_id: row.get("id"), + listing_id: row + .try_get("listing_id") + .map_err(SkinMarketError::internal)?, + slug: row.get("slug"), + release_number: row.get::("release_number") as u32, + package_id: meta.as_ref().map(|meta| meta.package_id.clone()), + name: meta.as_ref().map(|meta| meta.name.clone()), + description: meta.as_ref().map(|meta| meta.description.clone()), + author: meta.as_ref().and_then(|meta| meta.author.clone()), + mode: meta.as_ref().map(|meta| meta.mode), + package_version: meta.as_ref().map(|meta| meta.package_version.clone()), + min_bitfun_version: draft.min_bitfun_version, + required_capabilities: meta + .map(|meta| meta.required_capabilities) + .unwrap_or_default(), + changelog: draft.changelog, + license: draft.license, + repository_url: draft.repository_url, + status: parse_submission_status(&row.get::("status"))?, + package_sha256: row + .try_get("package_sha256") + .map_err(SkinMarketError::internal)?, + package_size: row + .try_get::, _>("package_size") + .map_err(SkinMarketError::internal)? + .map(|value| value as u64), + preview_url: preview_sha256.map(|sha256| { + format!( + "{}/api/v1/artifacts/previews/{sha256}", + state.config.public_base_url + ) + }), + rejection_reason: row + .try_get("rejection_reason") + .map_err(SkinMarketError::internal)?, + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + }) +} + +async fn admin_submission_detail( + state: &SkinMarketState, + submission_id: &str, +) -> SkinMarketResult { + let row = sqlx::query( + "SELECT manifest_json, package_sha256, preview_sha256, draft_json, package_meta_json + FROM submissions WHERE id = ?", + ) + .bind(submission_id) + .fetch_optional(state.database.pool()) + .await + .map_err(SkinMarketError::internal)? + .ok_or_else(|| SkinMarketError::not_found("Appearance submission was not found."))?; + let manifest_json = row + .try_get::, _>("manifest_json") + .map_err(SkinMarketError::internal)?; + let package_sha256 = row + .try_get::, _>("package_sha256") + .map_err(SkinMarketError::internal)?; + let preview_sha256 = row + .try_get::, _>("preview_sha256") + .map_err(SkinMarketError::internal)?; + let review_bundle_hash = match ( + package_sha256.as_deref(), + preview_sha256.as_deref(), + manifest_json.as_deref(), + row.try_get::, _>("package_meta_json") + .map_err(SkinMarketError::internal)? + .as_deref(), + ) { + (Some(package), Some(preview), Some(manifest), Some(package_meta)) => { + Some(compute_appearance_review_bundle_hash( + package, + &canonical_review_metadata( + &row.get::("draft_json"), + package_meta, + manifest, + )?, + preview, + )) + } + _ => None, + }; + Ok(AppearanceAdminSubmissionDetail { + submission: submission_by_id(state, submission_id, None).await?, + manifest: manifest_json + .map(|value| parse_json(value, "appearance manifest")) + .transpose()?, + package_sha256, + preview_sha256, + review_bundle_hash, + }) +} + +fn validate_draft(request: &AppearanceMarketSubmissionDraftRequest) -> SkinMarketResult<()> { + if !validate_appearance_market_slug(&request.slug) { + return Err(SkinMarketError::bad_request( + "invalid_slug", + "Appearance slugs must be lowercase, dashed identifiers of 3 to 63 characters.", + )); + } + if request.release_number == 0 { + return Err(SkinMarketError::bad_request( + "invalid_release_number", + "Appearance release numbers start at 1.", + )); + } + Version::parse(&request.min_bitfun_version).map_err(|_| { + SkinMarketError::bad_request( + "invalid_min_bitfun_version", + "minBitfunVersion must use semantic version syntax.", + ) + })?; + if request.changelog.chars().count() > 2_000 { + return Err(SkinMarketError::bad_request( + "invalid_changelog", + "Appearance changelogs may contain at most 2000 characters.", + )); + } + validate_license(&request.license)?; + if let Some(repository_url) = &request.repository_url { + if !is_safe_external_url(repository_url) { + return Err(SkinMarketError::bad_request( + "invalid_repository_url", + "Repository URLs must be credential-free HTTPS URLs.", + )); + } + } + Ok(()) +} + +fn validate_license( + license: &bitfun_product_domains::appearance_market::AppearanceMarketLicense, +) -> SkinMarketResult<()> { + let spdx = license + .spdx_expression + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let custom_url = license + .custom_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + if spdx.is_none() == custom_url.is_none() { + return Err(SkinMarketError::bad_request( + "invalid_license", + "Declare exactly one SPDX expression or custom license URL.", + )); + } + if spdx.is_some_and(|value| { + value.len() > 120 + || !value.chars().all(|character| { + character.is_ascii_alphanumeric() + || matches!(character, '-' | '.' | '+' | '(' | ')' | ' ' | ':') + }) + }) { + return Err(SkinMarketError::bad_request( + "invalid_license", + "The SPDX license expression has an invalid shape.", + )); + } + if custom_url.is_some_and(|value| !is_safe_external_url(value)) { + return Err(SkinMarketError::bad_request( + "invalid_license", + "Custom license URLs must be credential-free HTTPS URLs.", + )); + } + Ok(()) +} + +fn is_safe_external_url(value: &str) -> bool { + value.len() <= 2_048 + && url::Url::parse(value).is_ok_and(|parsed| { + parsed.scheme() == "https" + && parsed.host_str().is_some() + && parsed.username().is_empty() + && parsed.password().is_none() + }) +} + +async fn validate_draft_target( + state: &SkinMarketState, + identity: &AuthenticatedIdentity, + draft: &AppearanceMarketSubmissionDraftRequest, +) -> SkinMarketResult<()> { + if let Some(listing_id) = &draft.listing_id { + let row = sqlx::query( + "SELECT slug, owner_user_id, + COALESCE((SELECT MAX(release_number) FROM releases WHERE listing_id = l.id), 0) AS latest + FROM listings l WHERE id = ?", + ) + .bind(listing_id) + .fetch_optional(state.database.pool()) + .await + .map_err(SkinMarketError::internal)? + .ok_or_else(|| SkinMarketError::not_found("Appearance listing was not found."))?; + if row.get::("owner_user_id") != identity.user.internal_id { + return Err(SkinMarketError::forbidden( + "Only the listing owner may submit a new appearance release.", + )); + } + if row.get::("slug") != draft.slug + || draft.release_number != row.get::("latest") as u32 + 1 + { + return Err(SkinMarketError::conflict( + "invalid_release_target", + "The listing slug or next release number does not match.", + )); + } + } else { + if draft.release_number != 1 { + return Err(SkinMarketError::bad_request( + "invalid_release_number", + "A new appearance listing must start at release 1.", + )); + } + let conflicts: i64 = sqlx::query_scalar( + "SELECT (SELECT COUNT(*) FROM listings WHERE slug = ?) + + (SELECT COUNT(*) FROM submissions + WHERE slug = ? AND status IN ('draft', 'submitted'))", + ) + .bind(&draft.slug) + .bind(&draft.slug) + .fetch_one(state.database.pool()) + .await + .map_err(SkinMarketError::internal)?; + if conflicts > 0 { + return Err(SkinMarketError::conflict( + "slug_unavailable", + "The appearance listing slug is already reserved.", + )); + } + } + Ok(()) +} + +async fn validate_release_target( + transaction: &mut sqlx::Transaction<'_, Sqlite>, + owner_user_id: i64, + draft: &AppearanceMarketSubmissionDraftRequest, + package_meta: &AppearanceMarketPackageMeta, +) -> SkinMarketResult<()> { + if let Some(listing_id) = &draft.listing_id { + let row = sqlx::query( + "SELECT l.slug, l.package_id, l.owner_user_id, + COALESCE((SELECT MAX(release_number) FROM releases WHERE listing_id = l.id), 0) AS latest, + r.package_meta_json AS latest_package_meta + FROM listings l LEFT JOIN releases r ON r.id = ( + SELECT newest.id FROM releases newest + WHERE newest.listing_id = l.id + ORDER BY newest.release_number DESC LIMIT 1 + ) + WHERE l.id = ?", + ) + .bind(listing_id) + .fetch_optional(&mut **transaction) + .await + .map_err(SkinMarketError::internal)? + .ok_or_else(|| SkinMarketError::not_found("Appearance listing was not found."))?; + if row.get::("owner_user_id") != owner_user_id { + return Err(SkinMarketError::forbidden( + "Only the listing owner may publish a new appearance release.", + )); + } + if row.get::("slug") != draft.slug + || draft.release_number != row.get::("latest") as u32 + 1 + { + return Err(SkinMarketError::conflict( + "invalid_release_target", + "The listing slug or next release number changed.", + )); + } + if row.get::("package_id") != package_meta.package_id { + return Err(SkinMarketError::conflict( + "package_id_changed", + "New releases must preserve the appearance package id.", + )); + } + if let Some(previous_json) = row + .try_get::, _>("latest_package_meta") + .map_err(SkinMarketError::internal)? + { + let previous: AppearanceMarketPackageMeta = + parse_json(previous_json, "latest package metadata")?; + if previous.package_id != package_meta.package_id { + return Err(SkinMarketError::conflict( + "package_id_changed", + "New releases must preserve the appearance package id.", + )); + } + let previous_version = + Version::parse(&previous.package_version).map_err(SkinMarketError::internal)?; + let next_version = + Version::parse(&package_meta.package_version).map_err(SkinMarketError::internal)?; + if next_version <= previous_version { + return Err(SkinMarketError::conflict( + "package_version_not_newer", + "New releases must use a higher semantic package version.", + )); + } + } + } else { + if draft.release_number != 1 { + return Err(SkinMarketError::conflict( + "invalid_release_number", + "A new appearance listing must start at release 1.", + )); + } + let existing: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM listings WHERE slug = ?") + .bind(&draft.slug) + .fetch_one(&mut **transaction) + .await + .map_err(SkinMarketError::internal)?; + if existing > 0 { + return Err(SkinMarketError::conflict( + "slug_unavailable", + "The appearance listing slug is no longer available.", + )); + } + let package_conflict: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM listings WHERE package_id = ?") + .bind(&package_meta.package_id) + .fetch_one(&mut **transaction) + .await + .map_err(SkinMarketError::internal)?; + if package_conflict > 0 { + return Err(SkinMarketError::conflict( + "package_id_unavailable", + "The appearance package id already belongs to another Skin listing.", + )); + } + } + Ok(()) +} + +async fn record_download( + state: &SkinMarketState, + headers: &HeaderMap, + listing_id: &str, +) -> SkinMarketResult<()> { + let day = Utc::now().format("%Y-%m-%d").to_string(); + let visitor = headers + .get("x-forwarded-for") + .or_else(|| headers.get("x-real-ip")) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(',').next()) + .unwrap_or("unknown"); + let visitor = visitor + .trim() + .parse::() + .map(|value| value.to_string()) + .unwrap_or_else(|_| "unknown".to_string()); + let mut digest = Hmac::::new_from_slice(state.config.download_hash_secret.as_bytes()) + .map_err(SkinMarketError::internal)?; + digest.update(day.as_bytes()); + digest.update(b"\0"); + digest.update(visitor.as_bytes()); + let visitor_hash = hex::encode(digest.finalize().into_bytes()); + sqlx::query( + "INSERT OR IGNORE INTO download_days(listing_id, day, visitor_hash, created_at) + VALUES(?, ?, ?, ?)", + ) + .bind(listing_id) + .bind(day) + .bind(visitor_hash) + .bind(Utc::now().timestamp()) + .execute(state.database.pool()) + .await + .map_err(SkinMarketError::internal)?; + Ok(()) +} + +async fn insert_audit<'executor, ExecutorType>( + executor: ExecutorType, + actor_user_id: i64, + action: &str, + target_kind: &str, + target_id: &str, + details: Value, +) -> SkinMarketResult<()> +where + ExecutorType: Executor<'executor, Database = Sqlite>, +{ + sqlx::query( + "INSERT INTO audit_log( + id, actor_user_id, action, target_kind, target_id, details_json, created_at + ) VALUES(?, ?, ?, ?, ?, ?, ?)", + ) + .bind(Uuid::new_v4().to_string()) + .bind(actor_user_id) + .bind(action) + .bind(target_kind) + .bind(target_id) + .bind(serde_json::to_string(&details).map_err(SkinMarketError::internal)?) + .bind(Utc::now().timestamp()) + .execute(executor) + .await + .map_err(SkinMarketError::internal)?; + Ok(()) +} + +fn canonical_review_metadata( + draft_json: &str, + package_meta_json: &str, + manifest_json: &str, +) -> SkinMarketResult { + let value = serde_json::json!({ + "draft": serde_json::from_str::(draft_json).map_err(SkinMarketError::internal)?, + "package": serde_json::from_str::(package_meta_json).map_err(SkinMarketError::internal)?, + "manifest": serde_json::from_str::(manifest_json).map_err(SkinMarketError::internal)? + }); + serde_json::to_string(&value).map_err(SkinMarketError::internal) +} + +fn ensure_public_browse(state: &SkinMarketState) -> SkinMarketResult<()> { + if state.config.public_browse { + Ok(()) + } else { + Err(SkinMarketError::forbidden( + "Public appearance marketplace browsing is disabled.", + )) + } +} + +fn ensure_package_content_type(headers: &HeaderMap) -> SkinMarketResult<()> { + let content_type = headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(';').next()) + .map(str::trim); + if matches!( + content_type, + Some(APPEARANCE_MARKET_PACKAGE_CONTENT_TYPE | "application/zip") + ) { + Ok(()) + } else { + Err(SkinMarketError::bad_request( + "unsupported_package_content_type", + format!("Appearance uploads must use {APPEARANCE_MARKET_PACKAGE_CONTENT_TYPE}."), + )) + } +} + +fn validate_reason(reason: &str) -> SkinMarketResult<&str> { + let reason = reason.trim(); + if reason.is_empty() || reason.chars().count() > 500 { + Err(SkinMarketError::bad_request( + "invalid_moderation_reason", + "Moderation reasons must contain between 1 and 500 characters.", + )) + } else { + Ok(reason) + } +} + +fn validate_sha256(value: &str) -> SkinMarketResult<()> { + if value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + Ok(()) + } else { + Err(SkinMarketError::not_found( + "The requested appearance artifact is unavailable.", + )) + } +} + +fn parse_submission_status(value: &str) -> SkinMarketResult { + match value { + "draft" => Ok(AppearanceMarketSubmissionStatus::Draft), + "submitted" => Ok(AppearanceMarketSubmissionStatus::Submitted), + "approved" => Ok(AppearanceMarketSubmissionStatus::Approved), + "rejected" => Ok(AppearanceMarketSubmissionStatus::Rejected), + "withdrawn" => Ok(AppearanceMarketSubmissionStatus::Withdrawn), + _ => Err(SkinMarketError::internal(format!( + "unknown appearance submission status: {value}" + ))), + } +} + +fn status_string(status: AppearanceMarketSubmissionStatus) -> &'static str { + match status { + AppearanceMarketSubmissionStatus::Draft => "draft", + AppearanceMarketSubmissionStatus::Submitted => "submitted", + AppearanceMarketSubmissionStatus::Approved => "approved", + AppearanceMarketSubmissionStatus::Rejected => "rejected", + AppearanceMarketSubmissionStatus::Withdrawn => "withdrawn", + } +} + +fn encode_cursor(offset: u32) -> String { + URL_SAFE_NO_PAD.encode(offset.to_string()) +} + +fn decode_cursor(cursor: Option<&str>) -> SkinMarketResult { + let Some(cursor) = cursor else { + return Ok(0); + }; + let bytes = URL_SAFE_NO_PAD.decode(cursor).map_err(|_| { + SkinMarketError::bad_request("invalid_cursor", "The appearance cursor is invalid.") + })?; + String::from_utf8(bytes) + .ok() + .and_then(|value| value.parse().ok()) + .filter(|offset| *offset <= 100_000) + .ok_or_else(|| { + SkinMarketError::bad_request("invalid_cursor", "The appearance cursor is invalid.") + }) +} + +fn encode_submission_cursor(updated_at: i64, submission_id: &str) -> String { + URL_SAFE_NO_PAD.encode(format!("{updated_at}:{submission_id}")) +} + +fn decode_submission_cursor(cursor: Option<&str>) -> SkinMarketResult> { + let Some(cursor) = cursor else { + return Ok(None); + }; + let decoded = URL_SAFE_NO_PAD + .decode(cursor) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .and_then(|value| { + let (updated_at, submission_id) = value.split_once(':')?; + let updated_at = updated_at.parse::().ok()?; + (updated_at >= 0 && Uuid::parse_str(submission_id).is_ok()) + .then(|| (updated_at, submission_id.to_string())) + }) + .ok_or_else(|| { + SkinMarketError::bad_request( + "invalid_cursor", + "The appearance submission cursor is invalid.", + ) + })?; + Ok(Some(decoded)) +} + +fn parse_json(value: String, label: &str) -> SkinMarketResult { + serde_json::from_str(&value) + .map_err(|error| SkinMarketError::internal(format!("invalid stored {label}: {error}"))) +} + +fn parse_optional_json( + value: Option, + label: &str, +) -> SkinMarketResult> { + value.map(|value| parse_json(value, label)).transpose() +} + +fn is_unique_violation(error: &sqlx::Error) -> bool { + error + .as_database_error() + .is_some_and(sqlx::error::DatabaseError::is_unique_violation) +} + +fn listing_identity_conflict(error: &sqlx::Error) -> SkinMarketError { + if error.to_string().contains("listings.package_id") { + SkinMarketError::conflict( + "package_id_unavailable", + "The appearance package id already belongs to another Skin listing.", + ) + } else { + SkinMarketError::conflict( + "slug_unavailable", + "The appearance listing slug is no longer available.", + ) + } +} diff --git a/src/shared/appearance-market-contract-fixtures/listing-detail.json b/src/shared/appearance-market-contract-fixtures/listing-detail.json new file mode 100644 index 0000000000..14d9eea5ce --- /dev/null +++ b/src/shared/appearance-market-contract-fixtures/listing-detail.json @@ -0,0 +1,40 @@ +{ + "listingId": "appearance-listing-fixture-1", + "slug": "ocean-night", + "packageId": "community.ocean-night", + "name": "Ocean Night", + "description": "A quiet blue workspace with a cinematic background.", + "author": "BitFun Community", + "mode": "dark", + "packageVersion": "2.1.0", + "latestRelease": 2, + "minBitfunVersion": "0.3.0", + "requiredCapabilities": ["assets.v1", "components.v1"], + "owner": { + "githubId": 24753352, + "login": "bobleer", + "avatarUrl": "https://avatars.githubusercontent.com/u/24753352" + }, + "previewUrl": "/skin/api/v1/artifacts/previews/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "downloadCount": 42, + "publishedAt": 1785196800, + "changelog": "Improves panel contrast and refines the background crop.", + "license": { + "spdxExpression": "MIT" + }, + "repositoryUrl": "https://github.com/openbitfun/ocean-night", + "releases": [ + { + "releaseId": "appearance-release-fixture-2", + "listingId": "appearance-listing-fixture-1", + "releaseNumber": 2, + "packageVersion": "2.1.0", + "minBitfunVersion": "0.3.0", + "packageSha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "packageSize": 4096, + "reviewBundleHash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "publishedAt": 1785196800, + "yanked": false + } + ] +} diff --git a/src/skin-market-web/README.md b/src/skin-market-web/README.md new file mode 100644 index 0000000000..4b6470dc9a --- /dev/null +++ b/src/skin-market-web/README.md @@ -0,0 +1,23 @@ +# BitFun Skin Market Web + +Public catalog and authenticated contribution workflow for reviewed BitFun +Appearance packages. + +- Web base: `/skin/` +- API base: `/skin/api/v1` +- Routes: `/skin/`, `/skin/appearances/:slug`, `/skin/submissions`, and `/skin/admin` +- Features: catalog search and filters, release details and downloads, personal submission status and withdrawal, plus administrator review and publishing +- Installation remains a BitFun Desktop action through Settings > Appearance. + +The site is self-contained and does not import the main Web UI locale or theme catalogs. +GitHub identity is shared with the MiniApp market through its same-origin auth +broker. Skin writes use the `/skin`-scoped CSRF alias issued by that broker. +Local development proxies `/miniapp/api` to `127.0.0.1:9710`; set +`MINIAPP_MARKET_DEV_API` when the broker runs elsewhere. + +```bash +pnpm --dir src/skin-market-web dev +pnpm --dir src/skin-market-web type-check +pnpm --dir src/skin-market-web test +pnpm --dir src/skin-market-web build +``` diff --git a/src/skin-market-web/index.html b/src/skin-market-web/index.html new file mode 100644 index 0000000000..1315272705 --- /dev/null +++ b/src/skin-market-web/index.html @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + BitFun Skin Market + + +
+ + + diff --git a/src/skin-market-web/package.json b/src/skin-market-web/package.json new file mode 100644 index 0000000000..4e84bbf105 --- /dev/null +++ b/src/skin-market-web/package.json @@ -0,0 +1,26 @@ +{ + "name": "bitfun-skin-market-web", + "version": "0.2.15", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "type-check": "tsc --noEmit", + "test": "vitest run", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@phosphor-icons/react": "^2.1.10", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.6.0", + "typescript": "~5.8.3", + "vite": "^7.0.4", + "vitest": "^4.1.0" + } +} diff --git a/src/skin-market-web/public/favicon.svg b/src/skin-market-web/public/favicon.svg new file mode 100644 index 0000000000..8635fbeaea --- /dev/null +++ b/src/skin-market-web/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/skin-market-web/public/site.webmanifest b/src/skin-market-web/public/site.webmanifest new file mode 100644 index 0000000000..4190bc7862 --- /dev/null +++ b/src/skin-market-web/public/site.webmanifest @@ -0,0 +1,12 @@ +{ + "name": "BitFun Skin Market", + "short_name": "Skin Market", + "start_url": "/skin/", + "scope": "/skin/", + "display": "standalone", + "background_color": "#101216", + "theme_color": "#101216", + "icons": [ + { "src": "/skin/favicon.svg", "sizes": "any", "type": "image/svg+xml" } + ] +} diff --git a/src/skin-market-web/public/theme-init.js b/src/skin-market-web/public/theme-init.js new file mode 100644 index 0000000000..954bbb352d --- /dev/null +++ b/src/skin-market-web/public/theme-init.js @@ -0,0 +1,23 @@ +(function () { + var storageKey = 'bitfun-skin-market-theme'; + var storedTheme = null; + + try { + storedTheme = window.localStorage.getItem(storageKey); + } catch { + storedTheme = null; + } + + var theme = + storedTheme === 'light' || storedTheme === 'dark' + ? storedTheme + : window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light'; + + document.documentElement.dataset.theme = theme; + document.documentElement.style.colorScheme = theme; + document + .querySelector('meta[name="theme-color"]') + ?.setAttribute('content', theme === 'dark' ? '#101216' : '#f4f5f7'); +})(); diff --git a/src/skin-market-web/src/AdminPage.tsx b/src/skin-market-web/src/AdminPage.tsx new file mode 100644 index 0000000000..1da1152979 --- /dev/null +++ b/src/skin-market-web/src/AdminPage.tsx @@ -0,0 +1,195 @@ +import { ArrowClockwise, GithubLogo, ShieldCheck, WarningCircle } from '@phosphor-icons/react'; +import { useCallback, useEffect, useState } from 'react'; +import { sharedMarketLoginUrl } from './account'; +import { skinMarketApi } from './api'; +import { formatMarketDate } from './format'; +import type { Locale, Translate } from './i18n'; +import type { + AppearanceAdminSubmissionDetail, + AppearanceSubmission, + SharedMarketAccount, +} from './types'; + +interface AdminPageProps { + account?: SharedMarketAccount; + accountResolved: boolean; + locale: Locale; + t: Translate; +} + +export function AdminPage({ account, accountResolved, locale, t }: AdminPageProps) { + const [queue, setQueue] = useState([]); + const [detail, setDetail] = useState(); + const [loading, setLoading] = useState(false); + const [detailLoading, setDetailLoading] = useState(false); + const [acting, setActing] = useState(false); + const [reason, setReason] = useState(''); + const [error, setError] = useState(); + + const openSubmission = useCallback(async (submissionId: string, signal?: AbortSignal) => { + setDetailLoading(true); + setReason(''); + setError(undefined); + try { + setDetail(await skinMarketApi.reviewSubmission(submissionId, signal)); + } catch (loadError) { + if (loadError instanceof DOMException && loadError.name === 'AbortError') return; + setError(loadError instanceof Error ? loadError : new Error(String(loadError))); + setDetail(undefined); + } finally { + if (!signal?.aborted) setDetailLoading(false); + } + }, []); + + const load = useCallback(async (signal?: AbortSignal) => { + if (!account?.isAdmin) return; + setLoading(true); + setError(undefined); + try { + const items = (await skinMarketApi.reviewSubmissions('submitted', signal)).items; + setQueue(items); + if (items.length === 0) { + setDetail(undefined); + } else if (!detail || !items.some(item => item.submissionId === detail.submission.submissionId)) { + await openSubmission(items[0].submissionId, signal); + } + } catch (loadError) { + if (loadError instanceof DOMException && loadError.name === 'AbortError') return; + setError(loadError instanceof Error ? loadError : new Error(String(loadError))); + setQueue([]); + setDetail(undefined); + } finally { + if (!signal?.aborted) setLoading(false); + } + }, [account?.isAdmin, detail, openSubmission]); + + useEffect(() => { + const controller = new AbortController(); + void load(controller.signal); + return () => controller.abort(); + // Refreshes after decisions are explicit; do not re-fetch when detail selection changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [account?.isAdmin]); + + const decide = async (decision: 'approve' | 'reject') => { + if (!detail || (decision === 'reject' && !reason.trim())) return; + setActing(true); + setError(undefined); + try { + await skinMarketApi.decideSubmission(detail.submission.submissionId, decision, reason.trim()); + setDetail(undefined); + setReason(''); + await load(); + } catch (decisionError) { + setError(decisionError instanceof Error ? decisionError : new Error(String(decisionError))); + } finally { + setActing(false); + } + }; + + if (!accountResolved) { + return
{t('reviewLoading')}
; + } + + if (!account) { + return ( +
+
+
+
+ ); + } + + if (!account.isAdmin) { + return ( +
+
+
+
+ ); + } + + return ( +
+
+
+

{t('market')}

+

{t('reviewTitle')}

+

{t('reviewIntro')}

+
+ +
+ {error &&
{t('reviewError')}{error.message}
} + {loading && queue.length === 0 ?
{t('reviewLoading')}
+ : queue.length === 0 ? ( +
+
+ ) : ( +
+ +
+ {detailLoading || !detail ?
{t('reviewDetailLoading')}
: ( + <> +
+

{detail.submission.name || detail.submission.slug}

{detail.submission.description}

+ {detail.submission.previewUrl && } +
+
+
{t('packageIdentity')}
{detail.submission.packageId || t('notDeclared')}
+
{t('version')}
{detail.submission.packageVersion || t('notDeclared')}
+
{t('compatibility')}
{detail.submission.minBitfunVersion}
+
{t('license')}
{detail.submission.license.spdxExpression || t('customLicense')}
+
+

{t('requiredCapabilities')}

{detail.submission.requiredCapabilities.length > 0 ? detail.submission.requiredCapabilities.map(value => {value}) : {t('noExtraCapabilities')}}
+

{t('whatsNew')}

{detail.submission.changelog || t('notDeclared')}

+
+
{t('reviewPackageHash')}
{detail.packageSha256 || t('notDeclared')}
+
{t('reviewPreviewHash')}
{detail.previewSha256 || t('notDeclared')}
+
{t('reviewBundleHash')}
{detail.reviewBundleHash || t('notDeclared')}
+
+ {detail.manifest !== undefined &&
{t('reviewManifest')}
{JSON.stringify(detail.manifest, null, 2)}
} +
+ +