#!/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. # - A file that parses as JSON must hold mosaicstack.url and api_token. # A file that does not parse is a raw per-seat token file: exactly 40 # lowercase hex characters, optionally followed by one newline. Its base # URL is fixed at https://git.mosaicstack.dev, with no override. Any other # content refuses (exit 3) before a request is made. # - 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 the credential file. `base` prints only the non-secret base URL; # `cfg` prints the curl config stream. Both run every check, so the second # read refuses a file that changed after the first. CRED_JS=' 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 text; try { text = fs.readFileSync(p, "utf8"); } catch { process.exit(3); } let base, token; let e = null; try { e = JSON.parse(text).mosaicstack || {}; } catch (err) { if (!(err instanceof SyntaxError)) process.exit(3); } if (e !== null) { 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); token = e.api_token; } else { const n = Buffer.byteLength(text); if ((s.size !== 40 && s.size !== 41) || n !== s.size || !/^[0-9a-f]{40}\n?$/.test(text)) process.exit(3); base = "https://git.mosaicstack.dev"; token = text.slice(0, 40); } if (process.argv[1] === "cfg") { process.stdout.write("header = \"Authorization: token " + token + "\"\n"); process.stdout.write("header = \"Content-Type: application/json\"\n"); } else { process.stdout.write(base); } ' BASE="$(node -e "$CRED_JS" 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. The second read # finishes, and must succeed, before curl starts: a refusal inside <(...) # would lose its exit status and leave curl an empty config. A CFG # inherited from the environment keeps its export attribute on assignment, # and SHELLOPTS=allexport exports every assignment, so export -n clears the # attribute before any child starts. printf is a builtin, so # the token is in no argv. CFG="$(node -e "$CRED_JS" cfg)" || exit 3 export -n CFG [ -n "$CFG" ] || exit 3 BODY_FILE="" cleanup() { if [ -n "$BODY_FILE" ]; then rm -f "$BODY_FILE"; fi; } 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 <(printf '%s\n' "$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 <(printf '%s\n' "$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