Add secure Node image webhook service
This commit is contained in:
@@ -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
|
||||
@@ -2,3 +2,8 @@
|
||||
/hook/logs.txt
|
||||
/hook/list.json
|
||||
/hook/HashKey.php
|
||||
/hook/inventory.v2.json
|
||||
/.env
|
||||
/secrets/*
|
||||
!/secrets/.gitkeep
|
||||
/runtime-data/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
@@ -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"]
|
||||
Executable
+13
@@ -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; }
|
||||
}
|
||||
}
|
||||
Executable
+17
@@ -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 "$@"
|
||||
Executable
+53
@@ -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
|
||||
Executable
+21
@@ -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)."
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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')),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user