# Shared image repository `icons/` is served as `/image/icons/`. General portraits follow these rules: - `icons/장수/<장수명>.<확장자>` is the canonical shared path. - Scenario-specific collections keep a separate directory such as `icons/걸그룹/` or `icons/롤시나리오/`. - Root-level numeric files such as `icons/1047.jpg` are compatibility aliases for deployed legacy versions. Do not delete or replace them while those versions are in service. - A general name must map to one canonical file. If two records intentionally need different portraits, disambiguate the scenario name or use an explicit scenario-specific path. 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 service is reverse-proxy agnostic. Its current reverse proxy happens to reach this server from `172.30.1.75`, but Caddy, Nginx, HAProxy, or another proxy can be used. Keep every observed proxy source CIDR in the untracked `TRUSTED_PROXY_CIDRS` value, separated by commas or spaces, and re-check the value whenever proxy networking changes. The published port binds all server interfaces, so production also needs a host `DOCKER-USER` (or equivalent) firewall rule allowing those source CIDRs to TCP 8191 and rejecting other sources. The static Nginx edge 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 TRUSTED_PROXY_CIDRS=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. ### Fallback sync callers If Gitea webhook delivery is missed, Core and Core2026 can request a restricted reconciliation through `POST /v1/sync`. This endpoint cannot select or change a branch: it only fetches the current active branch and applies the same clean worktree and fast-forward checks as a webhook deployment. Each caller has a separate secret: - `secrets/image_sync_core_secret` for legacy Core - `secrets/image_sync_core2026_secret` for Core2026 The caller sends its name, a timestamp, a unique request ID, and an HMAC-SHA256 signature over `timestamp.request-id.`. Requests expire after five minutes and successful request IDs are persisted for replay protection. The body is either `{}` or `{ "commit": "" }`; all other fields are rejected. The optional commit asserts the expected remote tip and does not grant checkout selection. Distribute only the matching caller secret through an ignored secret file. Never give either caller `image_admin_secret`, which also authorizes explicit branch changes. This fallback handles webhook delivery outages; if the image service itself is stopped, restore it and run the caller command again. Legacy HTTP mutation is disabled by default. An emergency PHP rollback must first stop `image-hook`, then create the ignored `hook/legacy-enabled` sentinel in the legacy checkout before restoring its Caddy/Gitea route. Remove the sentinel before Node is started again. CLI execution of `hook/git_pull.php` from the `hook/` directory remains available for local recovery without exposing the HTTP endpoint. ### 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 exposed through the public reverse proxy. ### 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.