#!/usr/bin/env bash # Minimal Gitea API client for this repository. # # Usage: scripts/gitea-api.sh METHOD api/path [json-body] # e.g. scripts/gitea-api.sh GET repos/mosaicstack/stack-v2/issues # # Security: # - Reads credentials from ~/secrets/mosaic.gitea.json (or # MOSAIC_GITEA_CREDENTIAL_FILE); file must be 0600, non-symlink. # - Token is passed to curl via a config stream (never argv, never disk, # never stdout/stderr). # - Prints the response body on stdout and "HTTP " on stderr. # Exits nonzero when the API reports an error. set -euo pipefail METHOD="${1:?usage: gitea-api.sh METHOD api/path [json-body]}" API_PATH="${2:?missing api/path}" API_PATH="${API_PATH#/}" BODY="${3:-}" command -v curl >/dev/null || { echo "gitea-api: curl not found" >&2; exit 1; } command -v node >/dev/null || { echo "gitea-api: node not found" >&2; exit 1; } export MOSAIC_GITEA_CREDENTIAL_FILE="${MOSAIC_GITEA_CREDENTIAL_FILE:-$HOME/secrets/mosaic.gitea.json}" # Validate credential file; emit only the non-secret base URL on stdout. BASE="$(node -e ' const fs = require("fs"); const p = process.env.MOSAIC_GITEA_CREDENTIAL_FILE; let s; try { s = fs.lstatSync(p); } catch { process.exit(3); } if (!s.isFile() || s.isSymbolicLink() || (s.mode & 0o077) !== 0) process.exit(3); let e; try { e = JSON.parse(fs.readFileSync(p, "utf8")).mosaicstack || {}; } catch { process.exit(3); } const base = String(e.url || "").replace(/\/+$/, ""); if (!/^https:\/\/git\.mosaicstack\.dev$/.test(base)) process.exit(3); if (typeof e.api_token !== "string" || e.api_token.length === 0) process.exit(3); process.stdout.write(base); ')" # Repo path from the configured origin remote (never from credentials). REMOTE_URL="$(git remote get-url origin)" REPO_PATH="${REMOTE_URL#https://git.mosaicstack.dev/}" REPO_PATH="${REPO_PATH%.git}" # curl config stream: auth header via fd, never argv. gen_curl_cfg() { node -e ' const fs = require("fs"); const e = JSON.parse(fs.readFileSync(process.env.MOSAIC_GITEA_CREDENTIAL_FILE, "utf8")).mosaicstack || {}; process.stdout.write("header = \"Authorization: token " + e.api_token + "\"\n"); process.stdout.write("header = \"Content-Type: application/json\"\n"); ' } BODY_FILE="" cleanup() { [ -n "$BODY_FILE" ] && rm -f "$BODY_FILE"; } trap cleanup EXIT if [ -n "$BODY" ]; then BODY_FILE="$(mktemp)" chmod 600 "$BODY_FILE" printf '%s' "$BODY" > "$BODY_FILE" fi URL="$BASE/api/v1/$API_PATH" if [ -n "$BODY_FILE" ]; then HTTP_CODE="$(curl -sS -K <(gen_curl_cfg) -o /tmp/gitea-api-response.$$ \ -w '%{http_code}' -X "$METHOD" "$URL" --data-binary @"$BODY_FILE")" || { echo "gitea-api: request failed" >&2; exit 1; } else HTTP_CODE="$(curl -sS -K <(gen_curl_cfg) -o /tmp/gitea-api-response.$$ \ -w '%{http_code}' -X "$METHOD" "$URL")" || { echo "gitea-api: request failed" >&2; exit 1; } fi cat /tmp/gitea-api-response.$$ 2>/dev/null || true rm -f /tmp/gitea-api-response.$$ echo "HTTP $HTTP_CODE" >&2 case "$HTTP_CODE" in 2*) exit 0 ;; *) echo "gitea-api: $METHOD $API_PATH failed (HTTP $HTTP_CODE)" >&2; exit 1 ;; esac