diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..426ace6 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +IMAGE_BIND_ADDRESS=0.0.0.0 +IMAGE_PORT=8191 +IMAGE_REPOSITORY_PATH=/home/letrhee/sam_rebuild/image +IMAGE_UID=1000 +IMAGE_GID=1000 +CADDY_SOURCE_CIDR=172.30.1.75/32 + +IMAGE_REMOTE_URL=https://gitea.hided.net/devsam/image.git +IMAGE_REPOSITORY_FULL_NAME=devsam/image +IMAGE_DEFAULT_BRANCH=master +IMAGE_ALLOWED_BRANCHES=master +IMAGE_PUBLIC_BASES=https://sam.hided.net/image,https://sam-image.hided.net + +GITEA_WEBHOOK_SECRET_FILE=./secrets/gitea_webhook_secret +IMAGE_ADMIN_SECRET_FILE=./secrets/image_admin_secret diff --git a/.gitignore b/.gitignore index 3081f8d..8ef8722 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ /hook/logs.txt /hook/list.json /hook/HashKey.php +/hook/inventory.v2.json +/.env +/secrets/* +!/secrets/.gitkeep +/runtime-data/ diff --git a/README.md b/README.md index f61f964..c55defd 100644 --- a/README.md +++ b/README.md @@ -16,3 +16,104 @@ The core2026 repository owns `resources/general-icons.json` and `tools/manage-general-icons.mjs`. Use that tool with this repository as `--image-root` to synchronize aliases, scenario paths, and verify that every catalog source exists and has identical bytes. + +## Public URLs + +The same tracked files are exposed through both URL contracts: + +- `https://sam.hided.net/image/game/...` and `/image/icons/...` +- `https://sam-image.hided.net/game/...` and `/icons/...` +- `https://sam-image.hided.net/image/...` is a compatibility alias. + +Do not redirect the old `/image/*` URLs to the dedicated domain. Existing PHP +and core2026 clients use same-origin relative paths and can move independently. +The image server never exposes the repository root, `.git`, PHP sources, or +directory listings. + +## Node webhook deployment service + +`compose.yaml` runs a read-only Nginx static edge and an internal Node service. +Only the edge publishes port 8191. The Node service verifies the raw Gitea +`X-Gitea-Signature`, accepts push events for `devsam/image`, fetches the exact +remote branch tip, rejects dirty or non-fast-forward updates, and serializes all +Git changes. The initial and only allowed branch is `master` unless +`IMAGE_ALLOWED_BRANCHES` is explicitly expanded. + +The current Caddy host reaches this server from `172.30.1.75`. Keep the observed +source in the untracked `.env`; re-check it whenever Caddy networking changes. +The published port binds all server interfaces, so production also needs a +host `DOCKER-USER` (or equivalent) firewall rule allowing that source CIDR to +TCP 8191 and rejecting other sources. Nginx applies the same source allowlist. + +### Prepare + +```sh +cp .env.example .env +./deploy/scripts/init-secrets.sh +docker compose config --quiet +docker compose build +``` + +Apply and inspect the dedicated Docker ingress chain with root privileges: + +```sh +sudo env CADDY_SOURCE_CIDR=172.30.1.75/32 IMAGE_PORT=8191 \ + ./deploy/scripts/firewall-8191.sh apply +sudo ./deploy/scripts/firewall-8191.sh check +``` + +The script only owns the `SAM_IMAGE_INGRESS` chain and its port-8191 jump from +`DOCKER-USER`; it does not flush shared firewall chains. + +Secret values are generated under ignored `secrets/` files with mode 0600 and +are never printed. Put the contents of `secrets/gitea_webhook_secret` into the +Gitea webhook configuration. Configure a JSON push webhook targeting: + +```text +https://sam-image.hided.net/v1/hooks/gitea +``` + +Use branch filter `master`. Disable the old PHP webhook before enabling the new +writer. The legacy PHP files remain in `hook/` for an explicit rollback, but +PHP and Node must never mutate the checkout concurrently. + +### Start and verify + +```sh +docker compose up -d --build +docker compose ps +curl -fsS https://sam-image.hided.net/healthz +curl -fsS https://sam-image.hided.net/v1/status +curl -fsS https://sam-image.hided.net/game/back.jpg -o /dev/null +curl -fsS https://sam-image.hided.net/image/icons/default.jpg -o /dev/null +``` + +Nginx serves only `game/`, `icons/`, `hook/list.json`, and +`hook/inventory.v2.json`. The API inventory additionally reports the deployed +branch, full commit, generation time, asset list, and both public base URLs. + +### Explicit branch deployment + +Automatic pushes only update the active branch. Add a branch to +`IMAGE_ALLOWED_BRANCHES`, recreate `image-hook`, and switch it with the internal +signed administration command: + +```sh +./deploy/scripts/admin-deploy.sh [expected-commit] +``` + +The administration route is not proxied through Nginx or Caddy. + +### Tests and rollback + +```sh +docker build -t sam-image-hook:test node-hook +docker run --rm sam-image-hook:test npm test +docker compose exec -T image-web nginx -t -c /tmp/nginx.conf +``` + +Stopping `image-hook` does not remove the last checked-out files from the +static service. For a full rollback, stop Node mutation first, restore the old +Caddy/PHP webhook route, and only then enable the legacy writer. Preserve both +checkouts until public file hashes and representative browser requests have +been verified. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..b701f43 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,90 @@ +name: sam-image + +services: + image-hook: + build: + context: ./node-hook + image: sam-image-hook:1.0.0 + restart: unless-stopped + user: "${IMAGE_UID:-1000}:${IMAGE_GID:-1000}" + read_only: true + init: true + environment: + PORT: "8081" + IMAGE_REPOSITORY_PATH: /data/image + IMAGE_REMOTE_URL: ${IMAGE_REMOTE_URL:-https://gitea.hided.net/devsam/image.git} + IMAGE_REPOSITORY_FULL_NAME: ${IMAGE_REPOSITORY_FULL_NAME:-devsam/image} + IMAGE_DEFAULT_BRANCH: ${IMAGE_DEFAULT_BRANCH:-master} + IMAGE_ALLOWED_BRANCHES: ${IMAGE_ALLOWED_BRANCHES:-master} + IMAGE_PUBLIC_BASES: ${IMAGE_PUBLIC_BASES:-https://sam.hided.net/image,https://sam-image.hided.net} + IMAGE_STATE_PATH: /var/lib/image-hook/state.json + GITEA_WEBHOOK_SECRET_FILE: /run/secrets/gitea_webhook_secret + IMAGE_ADMIN_SECRET_FILE: /run/secrets/image_admin_secret + volumes: + - type: bind + source: ${IMAGE_REPOSITORY_PATH:-.} + target: /data/image + - ./runtime-data:/var/lib/image-hook + secrets: + - gitea_webhook_secret + - image_admin_secret + tmpfs: + - /tmp:size=16m,mode=1777 + cap_drop: [ALL] + security_opt: + - no-new-privileges:true + pids_limit: 64 + mem_limit: 256m + cpus: 1.0 + healthcheck: + test: [CMD, node, -e, "fetch('http://127.0.0.1:8081/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] + interval: 10s + timeout: 3s + retries: 6 + start_period: 10s + networks: [image-internal, image-egress] + + image-web: + build: + context: ./deploy/nginx + image: sam-image-web:1.0.0 + restart: unless-stopped + depends_on: + image-hook: + condition: service_healthy + read_only: true + environment: + CADDY_SOURCE_CIDR: ${CADDY_SOURCE_CIDR:?Set CADDY_SOURCE_CIDR to the direct Caddy source CIDR} + ports: + - "${IMAGE_BIND_ADDRESS:-0.0.0.0}:${IMAGE_PORT:-8191}:8080" + volumes: + - type: bind + source: ${IMAGE_REPOSITORY_PATH:-.} + target: /srv/image + read_only: true + tmpfs: + - /tmp:size=8m,mode=1777 + cap_drop: [ALL] + security_opt: + - no-new-privileges:true + pids_limit: 32 + mem_limit: 64m + cpus: 0.5 + healthcheck: + test: [CMD, wget, -q, -O, /dev/null, http://127.0.0.1:8080/healthz] + interval: 10s + timeout: 3s + retries: 6 + networks: [image-internal, image-edge] + +networks: + image-internal: + internal: true + image-egress: + image-edge: + +secrets: + gitea_webhook_secret: + file: ${GITEA_WEBHOOK_SECRET_FILE:-./secrets/gitea_webhook_secret} + image_admin_secret: + file: ${IMAGE_ADMIN_SECRET_FILE:-./secrets/image_admin_secret} diff --git a/deploy/nginx/Dockerfile b/deploy/nginx/Dockerfile new file mode 100644 index 0000000..5ab4356 --- /dev/null +++ b/deploy/nginx/Dockerfile @@ -0,0 +1,9 @@ +FROM nginx:1.29.5-alpine@sha256:1eff5a5f3fcf8431a0abb7eddf5471fec24e5e1905a2581aeacdb07a4479b92b + +COPY templates/default.conf.template /etc/image/default.conf.template +COPY entrypoint.sh /usr/local/bin/image-web-entrypoint +RUN chmod 0555 /usr/local/bin/image-web-entrypoint + +USER nginx +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/image-web-entrypoint"] diff --git a/deploy/nginx/entrypoint.sh b/deploy/nginx/entrypoint.sh new file mode 100755 index 0000000..ed53798 --- /dev/null +++ b/deploy/nginx/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu + +if [ -z "${CADDY_SOURCE_CIDR:-}" ]; then + echo "CADDY_SOURCE_CIDR is required" >&2 + exit 1 +fi + +envsubst '${CADDY_SOURCE_CIDR}' \ + < /etc/image/default.conf.template \ + > /tmp/nginx.conf + +exec nginx -c /tmp/nginx.conf -g 'daemon off;' diff --git a/deploy/nginx/templates/default.conf.template b/deploy/nginx/templates/default.conf.template new file mode 100644 index 0000000..babbedd --- /dev/null +++ b/deploy/nginx/templates/default.conf.template @@ -0,0 +1,91 @@ +worker_processes auto; +pid /tmp/nginx.pid; +error_log /dev/stderr notice; + +events { + worker_connections 256; +} + +http { + include /etc/nginx/mime.types; + access_log /dev/stdout combined; + client_body_temp_path /tmp/client_body; + proxy_temp_path /tmp/proxy; + fastcgi_temp_path /tmp/fastcgi; + uwsgi_temp_path /tmp/uwsgi; + scgi_temp_path /tmp/scgi; + + server { + listen 8080; + server_name _; + server_tokens off; + root /srv/image; + disable_symlinks on; + + allow 127.0.0.1; + allow ::1; + allow ${CADDY_SOURCE_CIDR}; + deny all; + + location = /healthz { + proxy_pass http://image-hook:8081/healthz; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location = /v1/status { + proxy_pass http://image-hook:8081/v1/status; + proxy_set_header Host $host; + add_header Access-Control-Allow-Origin "*" always; + } + + location = /v1/inventory { + proxy_pass http://image-hook:8081/v1/inventory; + proxy_set_header Host $host; + proxy_buffering off; + add_header Access-Control-Allow-Origin "*" always; + } + + location = /v1/hooks/gitea { + limit_except POST { deny all; } + client_max_body_size 1m; + proxy_pass http://image-hook:8081/v1/hooks/gitea; + proxy_set_header Host $host; + proxy_set_header X-Gitea-Signature $http_x_gitea_signature; + proxy_set_header X-Gitea-Event $http_x_gitea_event; + proxy_set_header X-Gitea-Delivery $http_x_gitea_delivery; + proxy_request_buffering on; + } + + location ^~ /v1/admin/ { return 404; } + location = /image { return 404; } + location = /image/ { return 404; } + location ^~ /image/ { rewrite ^/image/(.*)$ /$1 last; } + + location ^~ /game/ { + try_files $uri =404; + add_header Access-Control-Allow-Origin "*" always; + add_header X-Content-Type-Options nosniff always; + } + + location ^~ /icons/ { + try_files $uri =404; + add_header Access-Control-Allow-Origin "*" always; + add_header X-Content-Type-Options nosniff always; + } + + location = /hook/list.json { + try_files $uri =404; + add_header Access-Control-Allow-Origin "*" always; + } + + location = /hook/inventory.v2.json { + try_files $uri =404; + add_header Access-Control-Allow-Origin "*" always; + } + + location ~ (^|/)\. { return 404; } + location ~ \.php$ { return 404; } + location / { return 404; } + } +} diff --git a/deploy/scripts/admin-deploy.sh b/deploy/scripts/admin-deploy.sh new file mode 100755 index 0000000..bc29a3e --- /dev/null +++ b/deploy/scripts/admin-deploy.sh @@ -0,0 +1,17 @@ +#!/bin/sh +set -eu + +if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then + echo "Usage: $0 [commit]" >&2 + exit 2 +fi + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repository_dir=$(CDPATH= cd -- "$script_dir/../.." && pwd) +branch=$1 +commit=${2:-} +set -- deploy --branch "$branch" +if [ -n "$commit" ]; then + set -- "$@" --commit "$commit" +fi +exec docker compose --project-directory "$repository_dir" exec -T image-hook node src/admin-cli.mjs "$@" diff --git a/deploy/scripts/firewall-8191.sh b/deploy/scripts/firewall-8191.sh new file mode 100755 index 0000000..cbec51c --- /dev/null +++ b/deploy/scripts/firewall-8191.sh @@ -0,0 +1,53 @@ +#!/bin/sh +set -eu + +action=${1:-check} +source_cidr=${CADDY_SOURCE_CIDR:-} +image_port=${IMAGE_PORT:-8191} +chain=SAM_IMAGE_INGRESS + +case "$image_port" in + ''|*[!0-9]*) echo "IMAGE_PORT must be numeric" >&2; exit 2 ;; +esac + +require_root() { + if [ "$(id -u)" -ne 0 ]; then + echo "Run this action as root." >&2 + exit 1 + fi +} + +case "$action" in + check) + iptables -S DOCKER-USER 2>/dev/null | grep -F "$chain" || true + iptables -S "$chain" 2>/dev/null || true + ;; + apply) + require_root + if [ -z "$source_cidr" ]; then + echo "CADDY_SOURCE_CIDR is required" >&2 + exit 2 + fi + iptables -n -L DOCKER-USER >/dev/null + iptables -n -L "$chain" >/dev/null 2>&1 || iptables -N "$chain" + iptables -F "$chain" + iptables -A "$chain" -s "$source_cidr" -j ACCEPT + iptables -A "$chain" -j DROP + iptables -C DOCKER-USER -p tcp -m conntrack --ctorigdstport "$image_port" -j "$chain" 2>/dev/null \ + || iptables -I DOCKER-USER 1 -p tcp -m conntrack --ctorigdstport "$image_port" -j "$chain" + ;; + remove) + require_root + while iptables -C DOCKER-USER -p tcp -m conntrack --ctorigdstport "$image_port" -j "$chain" 2>/dev/null; do + iptables -D DOCKER-USER -p tcp -m conntrack --ctorigdstport "$image_port" -j "$chain" + done + if iptables -n -L "$chain" >/dev/null 2>&1; then + iptables -F "$chain" + iptables -X "$chain" + fi + ;; + *) + echo "Usage: $0 [check|apply|remove]" >&2 + exit 2 + ;; +esac diff --git a/deploy/scripts/init-secrets.sh b/deploy/scripts/init-secrets.sh new file mode 100755 index 0000000..5c6ed33 --- /dev/null +++ b/deploy/scripts/init-secrets.sh @@ -0,0 +1,21 @@ +#!/bin/sh +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repository_dir=$(CDPATH= cd -- "$script_dir/../.." && pwd) +secret_dir="$repository_dir/secrets" +state_dir="$repository_dir/runtime-data" + +umask 077 +mkdir -p "$secret_dir" +mkdir -p "$state_dir" +for name in gitea_webhook_secret image_admin_secret; do + path="$secret_dir/$name" + if [ ! -e "$path" ]; then + openssl rand -hex 32 > "$path" + fi + chmod 600 "$path" +done +chmod 700 "$state_dir" + +echo "Secret files are ready in $secret_dir (values not printed)." diff --git a/node-hook/Dockerfile b/node-hook/Dockerfile new file mode 100644 index 0000000..d07eea5 --- /dev/null +++ b/node-hook/Dockerfile @@ -0,0 +1,16 @@ +FROM node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates=20230311+deb12u1 \ + git=1:2.39.5-0+deb12u3 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY package.json ./ +COPY src ./src +COPY test ./test + +USER node +EXPOSE 8081 +CMD ["node", "src/server.mjs"] diff --git a/node-hook/package.json b/node-hook/package.json new file mode 100644 index 0000000..d8f186e --- /dev/null +++ b/node-hook/package.json @@ -0,0 +1,13 @@ +{ + "name": "sam-image-hook", + "version": "1.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=24.0.0" + }, + "scripts": { + "start": "node src/server.mjs", + "test": "node --test" + } +} diff --git a/node-hook/src/admin-cli.mjs b/node-hook/src/admin-cli.mjs new file mode 100644 index 0000000..e703b06 --- /dev/null +++ b/node-hook/src/admin-cli.mjs @@ -0,0 +1,33 @@ +import { randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { adminSignature } from './auth.mjs'; + +const branchIndex = process.argv.indexOf('--branch'); +const commitIndex = process.argv.indexOf('--commit'); +if (process.argv[2] !== 'deploy' || branchIndex === -1 || !process.argv[branchIndex + 1]) { + console.error('Usage: node src/admin-cli.mjs deploy --branch [--commit ]'); + process.exit(2); +} + +const secretPath = process.env.IMAGE_ADMIN_SECRET_FILE ?? '/run/secrets/image_admin_secret'; +const secret = (await readFile(secretPath, 'utf8')).trim(); +const payload = Buffer.from(JSON.stringify({ + branch: process.argv[branchIndex + 1], + commit: commitIndex === -1 ? undefined : process.argv[commitIndex + 1], +})); +const timestamp = String(Date.now()); +const requestId = randomUUID(); +const response = await fetch('http://127.0.0.1:8081/v1/admin/deploy', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-image-timestamp': timestamp, + 'x-image-request-id': requestId, + 'x-image-signature': adminSignature(secret, timestamp, requestId, payload), + }, + body: payload, +}); +console.log(await response.text()); +if (!response.ok) { + process.exit(1); +} diff --git a/node-hook/src/auth.mjs b/node-hook/src/auth.mjs new file mode 100644 index 0000000..b20be01 --- /dev/null +++ b/node-hook/src/auth.mjs @@ -0,0 +1,39 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +export function hmacHex(secret, body) { + return createHmac('sha256', secret).update(body).digest('hex'); +} + +export function verifyHexHmac(secret, body, supplied) { + if (typeof supplied !== 'string' || !/^[0-9a-f]{64}$/i.test(supplied)) { + return false; + } + + const expected = Buffer.from(hmacHex(secret, body), 'hex'); + const actual = Buffer.from(supplied, 'hex'); + return expected.length === actual.length && timingSafeEqual(expected, actual); +} + +export function adminSignature(secret, timestamp, requestId, body) { + return hmacHex(secret, Buffer.concat([ + Buffer.from(`${timestamp}.${requestId}.`, 'utf8'), + body, + ])); +} + +export function verifyAdminSignature({ secret, timestamp, requestId, body, supplied, now = Date.now() }) { + if (!/^\d{10,13}$/.test(String(timestamp ?? '')) || !/^[A-Za-z0-9._:-]{8,128}$/.test(requestId ?? '')) { + return false; + } + + const numericTimestamp = Number(timestamp); + const milliseconds = numericTimestamp < 10_000_000_000 ? numericTimestamp * 1000 : numericTimestamp; + if (!Number.isFinite(milliseconds) || Math.abs(now - milliseconds) > 300_000) { + return false; + } + + return verifyHexHmac(secret, Buffer.concat([ + Buffer.from(`${timestamp}.${requestId}.`, 'utf8'), + body, + ]), supplied); +} diff --git a/node-hook/src/config.mjs b/node-hook/src/config.mjs new file mode 100644 index 0000000..96afd76 --- /dev/null +++ b/node-hook/src/config.mjs @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs'; + +function text(name, fallback) { + const value = process.env[name]; + return value === undefined || value === '' ? fallback : value; +} + +function secret(name, fileName) { + const direct = process.env[name]; + if (direct) { + return direct; + } + + const path = process.env[fileName]; + if (!path) { + throw new Error(`${name} or ${fileName} is required`); + } + return readFileSync(path, 'utf8').trim(); +} + +export function loadConfig() { + const webhookSecret = secret('GITEA_WEBHOOK_SECRET', 'GITEA_WEBHOOK_SECRET_FILE'); + const adminSecret = secret('IMAGE_ADMIN_SECRET', 'IMAGE_ADMIN_SECRET_FILE'); + if (webhookSecret.length < 32 || adminSecret.length < 32) { + throw new Error('Webhook and admin secrets must be at least 32 characters'); + } + + const allowedBranches = text('IMAGE_ALLOWED_BRANCHES', 'master') + .split(',') + .map((branch) => branch.trim()) + .filter(Boolean); + + return { + port: Number(text('PORT', '8081')), + repositoryPath: text('IMAGE_REPOSITORY_PATH', '/data/image'), + remoteUrl: text('IMAGE_REMOTE_URL', 'https://gitea.hided.net/devsam/image.git'), + repositoryFullName: text('IMAGE_REPOSITORY_FULL_NAME', 'devsam/image'), + defaultBranch: text('IMAGE_DEFAULT_BRANCH', 'master'), + allowedBranches, + statePath: text('IMAGE_STATE_PATH', '/var/lib/image-hook/state.json'), + publicBases: text('IMAGE_PUBLIC_BASES', 'https://sam.hided.net/image,https://sam-image.hided.net') + .split(',') + .map((base) => base.trim().replace(/\/$/, '')) + .filter(Boolean), + webhookSecret, + adminSecret, + maxBodyBytes: Number(text('MAX_BODY_BYTES', '1048576')), + }; +} diff --git a/node-hook/src/git-service.mjs b/node-hook/src/git-service.mjs new file mode 100644 index 0000000..dc85b8c --- /dev/null +++ b/node-hook/src/git-service.mjs @@ -0,0 +1,213 @@ +import { execFile } from 'node:child_process'; +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { promisify } from 'node:util'; +import { writeInventories } from './inventory.mjs'; + +const execFileAsync = promisify(execFile); +const BRANCH_PATTERN = /^(?!\/)(?!.*(?:\.\.|\/\/|@\{|\\))[A-Za-z0-9._/-]{1,200}$/; +const ZERO_SHA = /^0{40,64}$/; + +export class DeploymentError extends Error { + constructor(message, status = 409) { + super(message); + this.name = 'DeploymentError'; + this.status = status; + } +} + +export class GitService { + constructor(config, { run = execFileAsync, now = () => new Date() } = {}) { + this.config = config; + this.run = run; + this.now = now; + this.queue = Promise.resolve(); + this.state = null; + } + + async initialize() { + await mkdir(dirname(this.config.statePath), { recursive: true }); + this.state = await this.#loadState(); + await this.#assertRepository(); + await this.#assertClean(); + if (!this.config.allowedBranches.includes(this.state.activeBranch)) { + throw new Error(`Active branch ${this.state.activeBranch} is not allowed`); + } + await this.refreshInventory(); + } + + enqueue(operation) { + const result = this.queue.then(operation, operation); + this.queue = result.catch(() => undefined); + return result; + } + + async deployWebhook({ deliveryId, branch, after }) { + return this.enqueue(async () => { + if (!deliveryId || deliveryId.length > 128) { + throw new DeploymentError('Invalid delivery ID', 400); + } + if (this.state.deliveries.includes(deliveryId)) { + return { duplicate: true, ...this.publicStatus() }; + } + if (branch !== this.state.activeBranch) { + return { ignored: true, reason: 'inactive branch', ...this.publicStatus() }; + } + if (ZERO_SHA.test(after)) { + return { ignored: true, reason: 'deleted ref', ...this.publicStatus() }; + } + + const result = await this.#deploy({ branch, expectedCommit: after, allowUnrelated: false }); + this.state.deliveries = [...this.state.deliveries.slice(-199), deliveryId]; + await this.#saveState(); + return result; + }); + } + + async deployAdmin({ requestId, branch, expectedCommit }) { + return this.enqueue(async () => { + if (this.state.adminRequests.includes(requestId)) { + return { duplicate: true, ...this.publicStatus() }; + } + const result = await this.#deploy({ branch, expectedCommit, allowUnrelated: branch !== this.state.activeBranch }); + this.state.activeBranch = branch; + this.state.adminRequests = [...this.state.adminRequests.slice(-199), requestId]; + await this.#saveState(); + return result; + }); + } + + async #deploy({ branch, expectedCommit, allowUnrelated }) { + this.#validateBranch(branch); + await this.#assertClean(); + const remoteRef = `refs/remotes/image-hook/${branch}`; + await this.#git('fetch', '--no-tags', '--prune', this.config.remoteUrl, `+refs/heads/${branch}:${remoteRef}`); + const target = await this.#gitText('rev-parse', '--verify', `${remoteRef}^{commit}`); + if (expectedCommit && target !== expectedCommit) { + throw new DeploymentError('Payload commit does not match remote branch tip'); + } + + const current = await this.#gitText('rev-parse', 'HEAD'); + if (!allowUnrelated && current !== target) { + const ancestor = await this.#gitExit('merge-base', '--is-ancestor', current, target); + if (ancestor !== 0) { + throw new DeploymentError('Non-fast-forward deployment rejected'); + } + } + + if (current !== target) { + await this.#git('checkout', '--detach', target); + } + this.state.lastSuccess = { + branch, + commit: target, + at: this.now().toISOString(), + }; + this.state.lastError = null; + const inventory = await this.refreshInventory(branch, target); + await this.#saveState(); + return { changed: current !== target, inventoryAssets: inventory.assets.length, ...this.publicStatus() }; + } + + async refreshInventory(branch = this.state.activeBranch, commit) { + const resolvedCommit = commit ?? await this.#gitText('rev-parse', 'HEAD'); + const output = await this.#gitRaw('ls-files', '-z', '--', 'game', 'icons'); + const paths = output.split('\0').filter(Boolean); + return writeInventories({ + repositoryPath: this.config.repositoryPath, + paths, + branch, + commit: resolvedCommit, + publicBases: this.config.publicBases, + generatedAt: this.now(), + }); + } + + publicStatus() { + return { + activeBranch: this.state.activeBranch, + lastSuccess: this.state.lastSuccess, + lastError: this.state.lastError, + }; + } + + async recordError(error) { + this.state.lastError = { message: error.message, at: this.now().toISOString() }; + await this.#saveState(); + } + + #validateBranch(branch) { + if (!BRANCH_PATTERN.test(branch) || !this.config.allowedBranches.includes(branch)) { + throw new DeploymentError('Branch is not allowed', 400); + } + } + + async #assertRepository() { + const inside = await this.#gitText('rev-parse', '--is-inside-work-tree'); + if (inside !== 'true') { + throw new Error('IMAGE_REPOSITORY_PATH is not a Git worktree'); + } + } + + async #assertClean() { + const status = await this.#gitRaw('status', '--porcelain=v1', '--untracked-files=no'); + if (status.trim()) { + throw new DeploymentError('Tracked worktree changes prevent deployment'); + } + } + + async #loadState() { + try { + const saved = JSON.parse(await readFile(this.config.statePath, 'utf8')); + return { + activeBranch: saved.activeBranch ?? this.config.defaultBranch, + deliveries: Array.isArray(saved.deliveries) ? saved.deliveries : [], + adminRequests: Array.isArray(saved.adminRequests) ? saved.adminRequests : [], + lastSuccess: saved.lastSuccess ?? null, + lastError: saved.lastError ?? null, + }; + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + return { + activeBranch: this.config.defaultBranch, + deliveries: [], + adminRequests: [], + lastSuccess: null, + lastError: null, + }; + } + } + + async #saveState() { + const temporary = `${this.config.statePath}.tmp-${process.pid}`; + await writeFile(temporary, JSON.stringify(this.state, null, 2), { encoding: 'utf8', mode: 0o600 }); + await rename(temporary, this.config.statePath); + } + + async #git(...args) { + await this.run('git', ['-C', this.config.repositoryPath, ...args], { maxBuffer: 16 * 1024 * 1024 }); + } + + async #gitRaw(...args) { + const { stdout } = await this.run('git', ['-C', this.config.repositoryPath, ...args], { + encoding: 'buffer', + maxBuffer: 64 * 1024 * 1024, + }); + return Buffer.isBuffer(stdout) ? stdout.toString('utf8') : stdout; + } + + async #gitText(...args) { + return (await this.#gitRaw(...args)).trim(); + } + + async #gitExit(...args) { + try { + await this.#git(...args); + return 0; + } catch (error) { + return error.code ?? 1; + } + } +} diff --git a/node-hook/src/inventory.mjs b/node-hook/src/inventory.mjs new file mode 100644 index 0000000..2353887 --- /dev/null +++ b/node-hook/src/inventory.mjs @@ -0,0 +1,44 @@ +import { basename, dirname, extname } from 'node:path/posix'; +import { rename, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +export function buildLegacyInventory(paths) { + const inventory = {}; + for (const fullPath of paths) { + if (!fullPath.startsWith('icons/')) { + continue; + } + + const relative = fullPath.slice('icons/'.length); + const directory = dirname(relative); + const file = basename(relative); + const extension = extname(file); + const name = extension ? file.slice(0, -extension.length) : file; + inventory[directory] ??= {}; + inventory[directory][name] = file; + } + return inventory; +} + +async function atomicJson(path, value) { + const temporary = `${path}.tmp-${process.pid}`; + await writeFile(temporary, JSON.stringify(value), { encoding: 'utf8', mode: 0o644 }); + await rename(temporary, path); +} + +export async function writeInventories({ repositoryPath, paths, branch, commit, publicBases, generatedAt = new Date() }) { + const legacy = buildLegacyInventory(paths); + const versioned = { + version: 2, + branch, + commit, + generatedAt: generatedAt.toISOString(), + publicBases, + assets: paths.filter((path) => path.startsWith('game/') || path.startsWith('icons/')), + directories: legacy, + }; + + await atomicJson(join(repositoryPath, 'hook', 'list.json'), legacy); + await atomicJson(join(repositoryPath, 'hook', 'inventory.v2.json'), versioned); + return versioned; +} diff --git a/node-hook/src/server.mjs b/node-hook/src/server.mjs new file mode 100644 index 0000000..a65c4ad --- /dev/null +++ b/node-hook/src/server.mjs @@ -0,0 +1,123 @@ +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { loadConfig } from './config.mjs'; +import { verifyAdminSignature, verifyHexHmac } from './auth.mjs'; +import { DeploymentError, GitService } from './git-service.mjs'; + +function json(response, status, value) { + const body = JSON.stringify(value); + response.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'content-length': Buffer.byteLength(body), + 'cache-control': 'no-store', + }); + response.end(body); +} + +async function readBody(request, limit) { + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > limit) { + throw new DeploymentError('Request body too large', 413); + } + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + +function parseJson(body) { + try { + return JSON.parse(body.toString('utf8')); + } catch { + throw new DeploymentError('Invalid JSON body', 400); + } +} + +export async function createApp(config = loadConfig()) { + const service = new GitService(config); + await service.initialize(); + + const server = createServer(async (request, response) => { + const url = new URL(request.url, 'http://image-hook'); + try { + if (request.method === 'GET' && url.pathname === '/healthz') { + return json(response, 200, { ok: true }); + } + if (request.method === 'GET' && url.pathname === '/v1/status') { + return json(response, 200, service.publicStatus()); + } + if (request.method === 'GET' && url.pathname === '/v1/inventory') { + const inventory = JSON.parse(await readFile(`${config.repositoryPath}/hook/inventory.v2.json`, 'utf8')); + return json(response, 200, inventory); + } + if (request.method === 'POST' && url.pathname === '/v1/hooks/gitea') { + if (!request.headers['content-type']?.toLowerCase().startsWith('application/json')) { + throw new DeploymentError('Content-Type must be application/json', 415); + } + const body = await readBody(request, config.maxBodyBytes); + if (!verifyHexHmac(config.webhookSecret, body, request.headers['x-gitea-signature'])) { + return json(response, 401, { ok: false, reason: 'invalid signature' }); + } + if (request.headers['x-gitea-event'] !== 'push') { + return json(response, 202, { ok: true, ignored: true, reason: 'unsupported event' }); + } + const payload = parseJson(body); + if (payload.repository?.full_name !== config.repositoryFullName) { + return json(response, 403, { ok: false, reason: 'unexpected repository' }); + } + if (typeof payload.ref !== 'string' || !payload.ref.startsWith('refs/heads/')) { + return json(response, 202, { ok: true, ignored: true, reason: 'non-branch ref' }); + } + if (typeof payload.after !== 'string' || !/^[0-9a-f]{40,64}$/i.test(payload.after)) { + throw new DeploymentError('Invalid target commit', 400); + } + const result = await service.deployWebhook({ + deliveryId: request.headers['x-gitea-delivery'], + branch: payload.ref.slice('refs/heads/'.length), + after: payload.after, + }); + return json(response, 200, { ok: true, ...result }); + } + if (request.method === 'POST' && url.pathname === '/v1/admin/deploy') { + if (!request.headers['content-type']?.toLowerCase().startsWith('application/json')) { + throw new DeploymentError('Content-Type must be application/json', 415); + } + const body = await readBody(request, config.maxBodyBytes); + const timestamp = request.headers['x-image-timestamp']; + const requestId = request.headers['x-image-request-id']; + if (!verifyAdminSignature({ + secret: config.adminSecret, + timestamp, + requestId, + body, + supplied: request.headers['x-image-signature'], + })) { + return json(response, 401, { ok: false, reason: 'invalid admin signature' }); + } + const payload = parseJson(body); + const result = await service.deployAdmin({ requestId, branch: payload.branch, expectedCommit: payload.commit }); + return json(response, 200, { ok: true, ...result }); + } + return json(response, 404, { ok: false, reason: 'not found' }); + } catch (error) { + await service.recordError(error).catch(() => undefined); + console.error(JSON.stringify({ level: 'error', message: error.message, at: new Date().toISOString() })); + return json(response, error instanceof DeploymentError ? error.status : 500, { + ok: false, + reason: error instanceof DeploymentError ? error.message : 'internal error', + }); + } + }); + + return { server, service }; +} + +if (process.argv[1] === new URL(import.meta.url).pathname) { + const config = loadConfig(); + const { server } = await createApp(config); + server.listen(config.port, '0.0.0.0', () => { + console.log(JSON.stringify({ level: 'info', message: 'image hook listening', port: config.port })); + }); +} diff --git a/node-hook/test/auth.test.mjs b/node-hook/test/auth.test.mjs new file mode 100644 index 0000000..6b391dc --- /dev/null +++ b/node-hook/test/auth.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { adminSignature, hmacHex, verifyAdminSignature, verifyHexHmac } from '../src/auth.mjs'; + +test('Gitea HMAC verifies the exact raw body', () => { + const body = Buffer.from('{"ref":"refs/heads/master"}'); + const signature = hmacHex('a'.repeat(32), body); + assert.equal(verifyHexHmac('a'.repeat(32), body, signature), true); + assert.equal(verifyHexHmac('a'.repeat(32), Buffer.from(`${body} `), signature), false); + assert.equal(verifyHexHmac('a'.repeat(32), body, 'not-a-signature'), false); +}); + +test('admin signatures bind timestamp, request ID, and body with a five-minute window', () => { + const secret = 'b'.repeat(32); + const now = 1_786_013_000_000; + const timestamp = String(now); + const requestId = 'request-1234'; + const body = Buffer.from('{"branch":"master"}'); + const supplied = adminSignature(secret, timestamp, requestId, body); + assert.equal(verifyAdminSignature({ secret, timestamp, requestId, body, supplied, now }), true); + assert.equal(verifyAdminSignature({ secret, timestamp: String(now - 300_001), requestId, body, supplied, now }), false); + assert.equal(verifyAdminSignature({ secret, timestamp, requestId: 'changed-id', body, supplied, now }), false); +}); diff --git a/node-hook/test/git-service.test.mjs b/node-hook/test/git-service.test.mjs new file mode 100644 index 0000000..593b59e --- /dev/null +++ b/node-hook/test/git-service.test.mjs @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, writeFile, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import test from 'node:test'; +import { DeploymentError, GitService } from '../src/git-service.mjs'; + +const exec = promisify(execFile); + +async function git(cwd, ...args) { + const { stdout } = await exec('git', ['-C', cwd, ...args]); + return stdout.trim(); +} + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'image-hook-test-')); + const remote = join(root, 'remote.git'); + const seed = join(root, 'seed'); + const deployed = join(root, 'deployed'); + await exec('git', ['init', '--bare', remote]); + await exec('git', ['init', '-b', 'master', seed]); + await git(seed, 'config', 'user.email', 'test@example.invalid'); + await git(seed, 'config', 'user.name', 'Image Test'); + await mkdir(join(seed, 'icons'), { recursive: true }); + await mkdir(join(seed, 'hook'), { recursive: true }); + await writeFile(join(seed, 'icons', 'first.jpg'), 'first'); + await writeFile(join(seed, 'hook', '.keep'), ''); + await git(seed, 'add', '.'); + await git(seed, 'commit', '-m', 'initial'); + await git(seed, 'remote', 'add', 'origin', remote); + await git(seed, 'push', '-u', 'origin', 'master'); + await exec('git', ['clone', remote, deployed]); + + const config = { + repositoryPath: deployed, + remoteUrl: remote, + repositoryFullName: 'devsam/image', + defaultBranch: 'master', + allowedBranches: ['master', 'preview'], + statePath: join(root, 'state', 'state.json'), + publicBases: ['https://sam.hided.net/image', 'https://sam-image.hided.net'], + }; + const service = new GitService(config, { now: () => new Date('2026-08-06T00:00:00Z') }); + await service.initialize(); + return { root, remote, seed, deployed, service }; +} + +test('push deployment fast-forwards, writes inventory, and deduplicates delivery IDs', async (t) => { + const f = await fixture(); + t.after(() => rm(f.root, { recursive: true, force: true })); + await writeFile(join(f.seed, 'icons', '둘째.png'), 'second'); + await git(f.seed, 'add', '.'); + await git(f.seed, 'commit', '-m', 'second'); + await git(f.seed, 'push', 'origin', 'master'); + const target = await git(f.seed, 'rev-parse', 'HEAD'); + + const result = await f.service.deployWebhook({ deliveryId: 'delivery-0001', branch: 'master', after: target }); + assert.equal(result.changed, true); + assert.equal(await git(f.deployed, 'rev-parse', 'HEAD'), target); + const inventory = JSON.parse(await readFile(join(f.deployed, 'hook', 'inventory.v2.json'), 'utf8')); + assert.equal(inventory.directories['.']['둘째'], '둘째.png'); + assert.equal((await f.service.deployWebhook({ deliveryId: 'delivery-0001', branch: 'master', after: target })).duplicate, true); +}); + +test('inactive branches are ignored and tracked changes block deployment', async (t) => { + const f = await fixture(); + t.after(() => rm(f.root, { recursive: true, force: true })); + assert.equal((await f.service.deployWebhook({ deliveryId: 'delivery-0002', branch: 'preview', after: 'a'.repeat(40) })).ignored, true); + await writeFile(join(f.deployed, 'icons', 'first.jpg'), 'dirty'); + await assert.rejects( + f.service.deployWebhook({ deliveryId: 'delivery-0003', branch: 'master', after: await git(f.seed, 'rev-parse', 'HEAD') }), + (error) => error instanceof DeploymentError && /Tracked worktree/.test(error.message), + ); +}); + +test('admin deploy explicitly switches to an allowlisted unrelated branch', async (t) => { + const f = await fixture(); + t.after(() => rm(f.root, { recursive: true, force: true })); + await git(f.seed, 'checkout', '--orphan', 'preview'); + await git(f.seed, 'rm', '-rf', '.'); + await mkdir(join(f.seed, 'icons'), { recursive: true }); + await mkdir(join(f.seed, 'hook'), { recursive: true }); + await writeFile(join(f.seed, 'icons', 'preview.jpg'), 'preview'); + await writeFile(join(f.seed, 'hook', '.keep'), ''); + await git(f.seed, 'add', '.'); + await git(f.seed, 'commit', '-m', 'preview'); + await git(f.seed, 'push', 'origin', 'preview'); + const target = await git(f.seed, 'rev-parse', 'HEAD'); + + const result = await f.service.deployAdmin({ requestId: 'admin-request-1', branch: 'preview', expectedCommit: target }); + assert.equal(result.changed, true); + assert.equal(f.service.publicStatus().activeBranch, 'preview'); + await assert.rejects( + f.service.deployAdmin({ requestId: 'admin-request-2', branch: 'forbidden' }), + /Branch is not allowed/, + ); +}); + +test('same-branch force pushes and payload SHA mismatches are rejected', async (t) => { + const f = await fixture(); + t.after(() => rm(f.root, { recursive: true, force: true })); + const initial = await git(f.seed, 'rev-parse', 'HEAD'); + await writeFile(join(f.seed, 'icons', 'second.jpg'), 'second'); + await git(f.seed, 'add', '.'); + await git(f.seed, 'commit', '-m', 'second'); + await git(f.seed, 'push', 'origin', 'master'); + const second = await git(f.seed, 'rev-parse', 'HEAD'); + await f.service.deployWebhook({ deliveryId: 'delivery-0004', branch: 'master', after: second }); + + await git(f.seed, 'reset', '--hard', initial); + await writeFile(join(f.seed, 'icons', 'forced.jpg'), 'forced'); + await git(f.seed, 'add', '.'); + await git(f.seed, 'commit', '-m', 'forced replacement'); + await git(f.seed, 'push', '--force', 'origin', 'master'); + const forced = await git(f.seed, 'rev-parse', 'HEAD'); + await assert.rejects( + f.service.deployWebhook({ deliveryId: 'delivery-0005', branch: 'master', after: forced }), + /Non-fast-forward deployment rejected/, + ); + await assert.rejects( + f.service.deployWebhook({ deliveryId: 'delivery-0006', branch: 'master', after: 'f'.repeat(40) }), + /Payload commit does not match remote branch tip/, + ); +}); diff --git a/node-hook/test/inventory.test.mjs b/node-hook/test/inventory.test.mjs new file mode 100644 index 0000000..803785f --- /dev/null +++ b/node-hook/test/inventory.test.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildLegacyInventory } from '../src/inventory.mjs'; + +test('legacy inventory preserves dirname to stem to basename shape', () => { + assert.deepEqual(buildLegacyInventory([ + 'game/back.jpg', + 'icons/1047.jpg', + 'icons/장수/유표.jpg', + 'icons/장수/유표1.png', + ]), { + '.': { '1047': '1047.jpg' }, + '장수': { '유표': '유표.jpg', '유표1': '유표1.png' }, + }); +}); + +test('legacy duplicate stems retain the last Git path for PHP compatibility', () => { + assert.deepEqual(buildLegacyInventory(['icons/a/same.jpg', 'icons/a/same.png']), { + a: { same: 'same.png' }, + }); +}); diff --git a/secrets/.gitkeep b/secrets/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/secrets/.gitkeep @@ -0,0 +1 @@ +