Add secure Node image webhook service

This commit is contained in:
2026-08-06 11:21:54 +00:00
parent 24073326b3
commit f04b81c606
22 changed files with 1116 additions and 0 deletions
+15
View File
@@ -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
+5
View File
@@ -2,3 +2,8 @@
/hook/logs.txt
/hook/list.json
/hook/HashKey.php
/hook/inventory.v2.json
/.env
/secrets/*
!/secrets/.gitkeep
/runtime-data/
+101
View File
@@ -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 <branch> [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.
+90
View File
@@ -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}
+9
View File
@@ -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"]
+13
View File
@@ -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;'
@@ -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; }
}
}
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
set -eu
if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then
echo "Usage: $0 <branch> [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 "$@"
+53
View File
@@ -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
+21
View File
@@ -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)."
+16
View File
@@ -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"]
+13
View File
@@ -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"
}
}
+33
View File
@@ -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 <name> [--commit <sha>]');
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);
}
+39
View File
@@ -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);
}
+49
View File
@@ -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')),
};
}
+213
View File
@@ -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;
}
}
}
+44
View File
@@ -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;
}
+123
View File
@@ -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 }));
});
}
+23
View File
@@ -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);
});
+126
View File
@@ -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/,
);
});
+21
View File
@@ -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' },
});
});
+1
View File
@@ -0,0 +1 @@