Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f06e9cc7c8 |
@@ -1,33 +0,0 @@
|
|||||||
name: Build core.wasm
|
|
||||||
description: >-
|
|
||||||
Install TinyGo (cached) and build ui/core to frontend/static/core.wasm
|
|
||||||
and wasm_exec.js via `make -C ui wasm`. The binaries are no longer
|
|
||||||
committed, so every workflow that builds or serves the frontend bundle
|
|
||||||
(ui-test, dev-deploy, prod-build) runs this first. Requires Go to be
|
|
||||||
set up by the caller — TinyGo shells out to the Go toolchain.
|
|
||||||
|
|
||||||
runs:
|
|
||||||
using: composite
|
|
||||||
steps:
|
|
||||||
- name: Restore TinyGo cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.cache/galaxy-tinygo
|
|
||||||
key: tinygo-0.41.1-linux-amd64
|
|
||||||
|
|
||||||
- name: Install TinyGo
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
version="0.41.1"
|
|
||||||
root="$HOME/.cache/galaxy-tinygo/tinygo"
|
|
||||||
if [ ! -x "$root/bin/tinygo" ]; then
|
|
||||||
mkdir -p "$HOME/.cache/galaxy-tinygo"
|
|
||||||
curl -fsSL "https://github.com/tinygo-org/tinygo/releases/download/v${version}/tinygo${version}.linux-amd64.tar.gz" \
|
|
||||||
| tar -xz -C "$HOME/.cache/galaxy-tinygo"
|
|
||||||
fi
|
|
||||||
echo "$root/bin" >> "$GITHUB_PATH"
|
|
||||||
|
|
||||||
- name: Build core.wasm
|
|
||||||
shell: bash
|
|
||||||
run: make -C ui wasm
|
|
||||||
@@ -28,4 +28,4 @@ jobs:
|
|||||||
echo " 2. scp the .tar.gz bundles to the production host."
|
echo " 2. scp the .tar.gz bundles to the production host."
|
||||||
echo " 3. ssh prod 'docker load -i ...' for backend / gateway / engine."
|
echo " 3. ssh prod 'docker load -i ...' for backend / gateway / engine."
|
||||||
echo " 4. ssh prod 'docker compose -f /opt/galaxy/docker-compose.yml up -d'."
|
echo " 4. ssh prod 'docker compose -f /opt/galaxy/docker-compose.yml up -d'."
|
||||||
echo " 5. Probe https://<public host>/healthz and roll back on failure."
|
echo " 5. Probe https://api.galaxy.com/healthz and roll back on failure."
|
||||||
|
|||||||
@@ -7,12 +7,6 @@ name: Deploy · Dev
|
|||||||
# `integration` as part of the PR that produced this push, so this
|
# `integration` as part of the PR that produced this push, so this
|
||||||
# workflow does not re-run those tests — it focuses on packaging and
|
# workflow does not re-run those tests — it focuses on packaging and
|
||||||
# rollout.
|
# rollout.
|
||||||
#
|
|
||||||
# `workflow_dispatch` is also accepted so a developer can deploy any
|
|
||||||
# branch (typically a feature branch under active review) into the
|
|
||||||
# shared dev environment from the Gitea Actions UI without waiting for
|
|
||||||
# the PR to merge first. The deploy job picks up whatever the chosen
|
|
||||||
# ref is — same packaging + healthcheck steps as the merge path.
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -24,13 +18,11 @@ on:
|
|||||||
- 'game/**'
|
- 'game/**'
|
||||||
- 'pkg/**'
|
- 'pkg/**'
|
||||||
- 'ui/**'
|
- 'ui/**'
|
||||||
- 'site/**'
|
|
||||||
- 'go.work'
|
- 'go.work'
|
||||||
- 'go.work.sum'
|
- 'go.work.sum'
|
||||||
- 'tools/dev-deploy/**'
|
- 'tools/dev-deploy/**'
|
||||||
- '.gitea/workflows/dev-deploy.yaml'
|
- '.gitea/workflows/dev-deploy.yaml'
|
||||||
- '!**/*.md'
|
- '!**/*.md'
|
||||||
workflow_dispatch: {}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
@@ -54,11 +46,6 @@ jobs:
|
|||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 11.0.7
|
version: 11.0.7
|
||||||
# Install pnpm into a per-job directory so concurrent jobs on
|
|
||||||
# the shared host runner do not race on the default
|
|
||||||
# `~/setup-pnpm` (the self-installer otherwise fails with
|
|
||||||
# `ENOTEMPTY` while cleaning a sibling job's install).
|
|
||||||
dest: ${{ runner.temp }}/setup-pnpm
|
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -71,22 +58,10 @@ jobs:
|
|||||||
working-directory: ui
|
working-directory: ui
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Build core.wasm
|
|
||||||
uses: ./.gitea/actions/build-wasm
|
|
||||||
|
|
||||||
- name: Build UI frontend
|
- name: Build UI frontend
|
||||||
working-directory: ui/frontend
|
working-directory: ui/frontend
|
||||||
env:
|
env:
|
||||||
# Single-origin deployment: an empty base URL means the
|
VITE_GATEWAY_BASE_URL: https://api.galaxy.lan
|
||||||
# gateway shares the document origin (REST at /api, Connect at
|
|
||||||
# /rpc). The game UI is served under the /game/ base path.
|
|
||||||
VITE_GATEWAY_BASE_URL: ""
|
|
||||||
BASE_PATH: /game
|
|
||||||
# Surface the synthetic-report loader and similar dev-only
|
|
||||||
# affordances in the long-lived dev bundle. The prod build
|
|
||||||
# path (`prod-build.yaml`) leaves this flag unset so the
|
|
||||||
# production bundle keeps the same affordances stripped.
|
|
||||||
VITE_GALAXY_DEV_AFFORDANCES: "true"
|
|
||||||
run: |
|
run: |
|
||||||
# The response-signing public key is committed in
|
# The response-signing public key is committed in
|
||||||
# `.env.development` alongside its private counterpart in
|
# `.env.development` alongside its private counterpart in
|
||||||
@@ -96,14 +71,6 @@ jobs:
|
|||||||
export VITE_GATEWAY_RESPONSE_PUBLIC_KEY="$(grep -E '^VITE_GATEWAY_RESPONSE_PUBLIC_KEY=' .env.development | cut -d= -f2)"
|
export VITE_GATEWAY_RESPONSE_PUBLIC_KEY="$(grep -E '^VITE_GATEWAY_RESPONSE_PUBLIC_KEY=' .env.development | cut -d= -f2)"
|
||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
- name: Install site dependencies
|
|
||||||
working-directory: site
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build project site
|
|
||||||
working-directory: site
|
|
||||||
run: pnpm build
|
|
||||||
|
|
||||||
- name: Build galaxy-engine image
|
- name: Build galaxy-engine image
|
||||||
working-directory: ${{ gitea.workspace }}
|
working-directory: ${{ gitea.workspace }}
|
||||||
run: |
|
run: |
|
||||||
@@ -125,155 +92,13 @@ jobs:
|
|||||||
-v "${{ gitea.workspace }}/ui/frontend/build:/src:ro" \
|
-v "${{ gitea.workspace }}/ui/frontend/build:/src:ro" \
|
||||||
alpine sh -c 'rm -rf /dst/* /dst/.??* 2>/dev/null; cp -a /src/. /dst/'
|
alpine sh -c 'rm -rf /dst/* /dst/.??* 2>/dev/null; cp -a /src/. /dst/'
|
||||||
|
|
||||||
- name: Seed site volume
|
|
||||||
run: |
|
|
||||||
docker volume create galaxy-dev-site-dist >/dev/null
|
|
||||||
docker run --rm \
|
|
||||||
-v galaxy-dev-site-dist:/dst \
|
|
||||||
-v "${{ gitea.workspace }}/site/.vitepress/dist:/src:ro" \
|
|
||||||
alpine sh -c 'rm -rf /dst/* /dst/.??* 2>/dev/null; cp -a /src/. /dst/'
|
|
||||||
|
|
||||||
- name: Seed geoip volume
|
|
||||||
run: |
|
|
||||||
# Copy the GeoIP test fixture into a named volume so the
|
|
||||||
# backend can mount it as /var/lib/galaxy. A bind-mount with
|
|
||||||
# a relative path would resolve against this runner's
|
|
||||||
# ephemeral workspace under /home/runner/.cache/act/<hash>/,
|
|
||||||
# which the runner deletes once the workflow ends — the next
|
|
||||||
# `docker restart galaxy-dev-backend` would then fail with
|
|
||||||
# "not a directory" because the mount source vanished.
|
|
||||||
docker volume create galaxy-dev-geoip-data >/dev/null
|
|
||||||
docker run --rm \
|
|
||||||
-v galaxy-dev-geoip-data:/dst \
|
|
||||||
-v "${{ gitea.workspace }}/pkg/geoip/test-data/test-data:/src:ro" \
|
|
||||||
alpine sh -c 'cp /src/GeoIP2-Country-Test.mmdb /dst/geoip.mmdb'
|
|
||||||
|
|
||||||
- name: Seed mailpit relay config
|
|
||||||
env:
|
|
||||||
GALAXY_DEV_MAIL_RELAY_USERNAME: ${{ secrets.GALAXY_DEV_MAIL_RELAY_USERNAME }}
|
|
||||||
GALAXY_DEV_MAIL_RELAY_PASSWORD: ${{ secrets.GALAXY_DEV_MAIL_RELAY_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
# Render the Mailpit relay upstream config from the template,
|
|
||||||
# substituting the Gmail App Password from a Gitea secret, then
|
|
||||||
# seed it into a named volume (same rationale as the geoip seed:
|
|
||||||
# a workspace bind-mount would vanish with the runner workspace).
|
|
||||||
# The secret never lands in git or a committed file; it is
|
|
||||||
# rendered to a tmpfile outside the repo and removed after. Gmail
|
|
||||||
# App Passwords are [a-z]{16}, so the `|` sed delimiter is safe.
|
|
||||||
# When the secret is unset the creds render empty and the compose
|
|
||||||
# default relay-match is non-routable, so the stack only captures.
|
|
||||||
rendered="$(mktemp)"
|
|
||||||
sed -e "s|\${GALAXY_DEV_MAIL_RELAY_USERNAME}|${GALAXY_DEV_MAIL_RELAY_USERNAME}|g" \
|
|
||||||
-e "s|\${GALAXY_DEV_MAIL_RELAY_PASSWORD}|${GALAXY_DEV_MAIL_RELAY_PASSWORD}|g" \
|
|
||||||
"${{ gitea.workspace }}/tools/dev-deploy/mailpit/relay.conf.tmpl" > "$rendered"
|
|
||||||
docker volume create galaxy-dev-mailpit-config >/dev/null
|
|
||||||
docker run --rm \
|
|
||||||
-v galaxy-dev-mailpit-config:/dst \
|
|
||||||
-v "$rendered:/src/relay.conf:ro" \
|
|
||||||
alpine sh -c 'cp /src/relay.conf /dst/relay.conf && chmod 600 /dst/relay.conf'
|
|
||||||
rm -f "$rendered"
|
|
||||||
|
|
||||||
- name: Recycle engine containers on image drift
|
|
||||||
run: |
|
|
||||||
# Compare the freshly-built `galaxy-engine:dev` SHA against
|
|
||||||
# every running `galaxy-game-*` container. The backend
|
|
||||||
# reconciler adopts pre-existing labelled engine containers
|
|
||||||
# without checking image drift, so a running game would
|
|
||||||
# otherwise keep serving the previous engine code until the
|
|
||||||
# container is recycled by hand. This step makes the recycle
|
|
||||||
# automatic but only when it is actually needed:
|
|
||||||
#
|
|
||||||
# * BuildKit cache hit on the `Build galaxy-engine image`
|
|
||||||
# step → `galaxy-engine:dev` keeps its previous SHA →
|
|
||||||
# no drift → no-op (no engine source change to deploy).
|
|
||||||
# * engine source change → fresh SHA → for each drifted
|
|
||||||
# container we stop the backend, remove the container,
|
|
||||||
# wipe its bind-mounted state directory (Engine.Init()
|
|
||||||
# writes turn-0 over any pre-existing `turn-N` files —
|
|
||||||
# silent state corruption otherwise), and cascade-delete
|
|
||||||
# the lobby `games` row (the FKs in `00001_init.sql`
|
|
||||||
# drop the matching `runtime_records`, `memberships`,
|
|
||||||
# `player_mappings`, etc. in the same write).
|
|
||||||
#
|
|
||||||
# Backend is stopped first to keep the reconciler from
|
|
||||||
# racing the recycle (mid-stream adoption / restart). The
|
|
||||||
# subsequent `Bring up the stack` step restarts it.
|
|
||||||
set -u
|
|
||||||
new_sha=$(docker image inspect galaxy-engine:dev --format '{{.Id}}')
|
|
||||||
echo "fresh galaxy-engine:dev = $new_sha"
|
|
||||||
|
|
||||||
drift=()
|
|
||||||
for c in $(docker ps --filter "name=galaxy-game-" --format '{{.Names}}'); do
|
|
||||||
cur=$(docker inspect "$c" --format '{{.Image}}')
|
|
||||||
if [ "$cur" != "$new_sha" ]; then
|
|
||||||
drift+=("${c#galaxy-game-}")
|
|
||||||
echo " drift: $c was on $cur"
|
|
||||||
else
|
|
||||||
echo " match: $c"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [ ${#drift[@]} -eq 0 ]; then
|
|
||||||
echo "no drift detected — recycle skipped"
|
|
||||||
else
|
|
||||||
docker stop -t 30 galaxy-dev-backend >/dev/null 2>&1 || true
|
|
||||||
state_root="$HOME/.galaxy-dev/game-state"
|
|
||||||
for gid in "${drift[@]}"; do
|
|
||||||
echo "recycling $gid"
|
|
||||||
docker rm -f "galaxy-game-$gid" >/dev/null 2>&1 || true
|
|
||||||
# Wipe the per-game state dir as root inside a throwaway
|
|
||||||
# container so we can remove files left behind by the
|
|
||||||
# engine container even when its uid differs from the
|
|
||||||
# runner's.
|
|
||||||
docker run --rm -v "$state_root:/state" alpine \
|
|
||||||
sh -c "rm -rf -- /state/$gid"
|
|
||||||
done
|
|
||||||
ids_csv=$(printf "'%s'," "${drift[@]}")
|
|
||||||
ids_csv=${ids_csv%,}
|
|
||||||
docker exec galaxy-dev-postgres psql -v ON_ERROR_STOP=1 \
|
|
||||||
-U galaxy -d galaxy_backend \
|
|
||||||
-c "DELETE FROM backend.games WHERE game_id IN (${ids_csv});"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Reap stray dev-deploy containers
|
|
||||||
run: |
|
|
||||||
# Remove any non-running compose-managed containers from
|
|
||||||
# earlier deploys before `compose up`. Filter by the stack
|
|
||||||
# label so we never touch unrelated workloads on the same
|
|
||||||
# daemon. Running engine containers spawned by backend with
|
|
||||||
# the same label are left intact when their image SHA still
|
|
||||||
# matches the freshly-built `galaxy-engine:dev` (handled by
|
|
||||||
# the preceding `Recycle engine containers on image drift`
|
|
||||||
# step); the reconciler reattaches them on backend boot.
|
|
||||||
ids=$(docker ps -aq \
|
|
||||||
--filter "label=galaxy.stack=dev-deploy" \
|
|
||||||
--filter "status=exited" \
|
|
||||||
--filter "status=created" \
|
|
||||||
--filter "status=dead")
|
|
||||||
if [ -n "$ids" ]; then
|
|
||||||
echo "reaping: $ids"
|
|
||||||
docker rm -f $ids
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Bring up the stack
|
- name: Bring up the stack
|
||||||
working-directory: tools/dev-deploy
|
working-directory: tools/dev-deploy
|
||||||
env:
|
|
||||||
# Recipient regex Mailpit auto-relays to the owner's Gmail.
|
|
||||||
# Unset/empty → the compose default (non-routable) keeps the
|
|
||||||
# stack capture-only.
|
|
||||||
GALAXY_DEV_MAIL_RELAY_MATCH: ${{ vars.GALAXY_DEV_MAIL_RELAY_MATCH }}
|
|
||||||
# Grafana admin password; unset/empty -> compose default 'admin'.
|
|
||||||
GALAXY_DEV_GRAFANA_ADMIN_PASSWORD: ${{ secrets.GALAXY_DEV_GRAFANA_ADMIN_PASSWORD }}
|
|
||||||
run: |
|
run: |
|
||||||
# Resolve in the shell, not in YAML expressions — `env.HOME`
|
# Resolve in the shell, not in YAML expressions — `env.HOME`
|
||||||
# is empty at the workflow-evaluation stage.
|
# is empty at the workflow-evaluation stage.
|
||||||
export GALAXY_DEV_GAME_STATE_DIR="$HOME/.galaxy-dev/game-state"
|
export GALAXY_DEV_GAME_STATE_DIR="$HOME/.galaxy-dev/game-state"
|
||||||
mkdir -p "$GALAXY_DEV_GAME_STATE_DIR"
|
mkdir -p "$GALAXY_DEV_GAME_STATE_DIR"
|
||||||
# Seed the monitoring config to a stable, reboot-surviving host
|
|
||||||
# path (compose binds \${GALAXY_DEV_MONITORING_DIR} read-only).
|
|
||||||
export GALAXY_DEV_MONITORING_DIR="$HOME/.galaxy-dev/monitoring"
|
|
||||||
rm -rf "$GALAXY_DEV_MONITORING_DIR"
|
|
||||||
mkdir -p "$GALAXY_DEV_MONITORING_DIR"
|
|
||||||
cp -r monitoring/. "$GALAXY_DEV_MONITORING_DIR/"
|
|
||||||
docker compose up -d --wait --remove-orphans
|
docker compose up -d --wait --remove-orphans
|
||||||
|
|
||||||
- name: Probe the stack
|
- name: Probe the stack
|
||||||
@@ -284,12 +109,9 @@ jobs:
|
|||||||
# `tls internal`) terminates and forwards into the edge
|
# `tls internal`) terminates and forwards into the edge
|
||||||
# network. We accept the host's internal CA via -k because
|
# network. We accept the host's internal CA via -k because
|
||||||
# the runner image has no reason to trust it.
|
# the runner image has no reason to trust it.
|
||||||
curl -sk --max-time 10 https://galaxy.lan/healthz \
|
curl -sk --max-time 10 https://api.galaxy.lan/healthz \
|
||||||
| tee /tmp/healthz
|
| tee /tmp/healthz
|
||||||
test -s /tmp/healthz
|
test -s /tmp/healthz
|
||||||
curl -sk --max-time 10 -o /dev/null -w '%{http_code}\n' \
|
curl -sk --max-time 10 -o /dev/null -w '%{http_code}\n' \
|
||||||
https://galaxy.lan/ | tee /tmp/site_status
|
https://www.galaxy.lan/ | tee /tmp/www_status
|
||||||
grep -qE '^(200|304)$' /tmp/site_status
|
grep -qE '^(200|304)$' /tmp/www_status
|
||||||
curl -sk --max-time 10 -o /dev/null -w '%{http_code}\n' \
|
|
||||||
https://galaxy.lan/game/ | tee /tmp/game_status
|
|
||||||
grep -qE '^(200|304)$' /tmp/game_status
|
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
name: Tests · FBS codegen
|
|
||||||
|
|
||||||
# Guards that the committed FlatBuffers bindings (Go under
|
|
||||||
# pkg/schema/fbs/<schema>/ and TS under ui/frontend/src/proto/galaxy/fbs/)
|
|
||||||
# are exactly what the pinned flatc produces from the .fbs schemas.
|
|
||||||
# Catches both "changed a schema but forgot to regenerate" and
|
|
||||||
# "regenerated with the wrong flatc version" (e.g. a distro's older
|
|
||||||
# flatbuffers-compiler), which silently churns output and can flip
|
|
||||||
# nullable-scalar wire defaults. Path-filtered so it only runs when the
|
|
||||||
# schemas, the generated trees, the fbs Makefiles, or this workflow change.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- 'pkg/schema/fbs/**'
|
|
||||||
- 'ui/frontend/src/proto/galaxy/fbs/**'
|
|
||||||
- 'ui/Makefile'
|
|
||||||
- '.gitea/workflows/fbs-codegen.yaml'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'pkg/schema/fbs/**'
|
|
||||||
- 'ui/frontend/src/proto/galaxy/fbs/**'
|
|
||||||
- 'ui/Makefile'
|
|
||||||
- '.gitea/workflows/fbs-codegen.yaml'
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: fbs-codegen-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
env:
|
|
||||||
FLATC_VERSION: 25.9.23
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
codegen:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Cache flatc
|
|
||||||
id: cache-flatc
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ${{ runner.temp }}/flatc-bin
|
|
||||||
key: flatc-${{ env.FLATC_VERSION }}-linux-g++13
|
|
||||||
|
|
||||||
- name: Install pinned flatc
|
|
||||||
if: steps.cache-flatc.outputs.cache-hit != 'true'
|
|
||||||
run: |
|
|
||||||
mkdir -p "${{ runner.temp }}/flatc-bin"
|
|
||||||
curl -sSL -o /tmp/flatc.zip \
|
|
||||||
"https://github.com/google/flatbuffers/releases/download/v${FLATC_VERSION}/Linux.flatc.binary.g++-13.zip"
|
|
||||||
python3 -m zipfile -e /tmp/flatc.zip "${{ runner.temp }}/flatc-bin"
|
|
||||||
chmod +x "${{ runner.temp }}/flatc-bin/flatc"
|
|
||||||
|
|
||||||
- name: Add flatc to PATH
|
|
||||||
run: echo "${{ runner.temp }}/flatc-bin" >> "$GITHUB_PATH"
|
|
||||||
|
|
||||||
- name: Verify flatc version
|
|
||||||
run: flatc --version
|
|
||||||
|
|
||||||
- name: Regenerate Go + TS bindings
|
|
||||||
run: |
|
|
||||||
make -C pkg/schema/fbs fbs-go
|
|
||||||
make -C ui fbs-ts
|
|
||||||
|
|
||||||
- name: Assert no drift
|
|
||||||
run: |
|
|
||||||
if ! git diff --exit-code || [ -n "$(git status --porcelain)" ]; then
|
|
||||||
echo "::error::Committed FlatBuffers bindings differ from a flatc ${FLATC_VERSION} regeneration."
|
|
||||||
echo "Run 'make -C pkg/schema/fbs fbs-go' and 'make -C ui fbs-ts' with flatc ${FLATC_VERSION} and commit the result."
|
|
||||||
git status --porcelain
|
|
||||||
git --no-pager diff
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -16,7 +16,6 @@ on:
|
|||||||
- 'game/**'
|
- 'game/**'
|
||||||
- 'pkg/**'
|
- 'pkg/**'
|
||||||
- 'ui/**'
|
- 'ui/**'
|
||||||
- 'site/**'
|
|
||||||
- 'go.work'
|
- 'go.work'
|
||||||
- 'go.work.sum'
|
- 'go.work.sum'
|
||||||
- '.gitea/workflows/prod-build.yaml'
|
- '.gitea/workflows/prod-build.yaml'
|
||||||
@@ -44,11 +43,6 @@ jobs:
|
|||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 11.0.7
|
version: 11.0.7
|
||||||
# Install pnpm into a per-job directory so concurrent jobs on
|
|
||||||
# the shared host runner do not race on the default
|
|
||||||
# `~/setup-pnpm` (the self-installer otherwise fails with
|
|
||||||
# `ENOTEMPTY` while cleaning a sibling job's install).
|
|
||||||
dest: ${{ runner.temp }}/setup-pnpm
|
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -88,17 +82,10 @@ jobs:
|
|||||||
working-directory: ui
|
working-directory: ui
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Build core.wasm
|
|
||||||
uses: ./.gitea/actions/build-wasm
|
|
||||||
|
|
||||||
- name: Build UI bundle
|
- name: Build UI bundle
|
||||||
working-directory: ui/frontend
|
working-directory: ui/frontend
|
||||||
env:
|
env:
|
||||||
# Single-origin deployment: an empty base URL means the
|
VITE_GATEWAY_BASE_URL: https://api.galaxy.com
|
||||||
# gateway shares the document origin (REST at /api, Connect at
|
|
||||||
# /rpc). The game UI is served under the /game/ base path.
|
|
||||||
VITE_GATEWAY_BASE_URL: ""
|
|
||||||
BASE_PATH: /game
|
|
||||||
run: |
|
run: |
|
||||||
# Production response-signing public key is not in the repo
|
# Production response-signing public key is not in the repo
|
||||||
# yet (the dev key in `tools/local-dev/keys/` is for dev
|
# yet (the dev key in `tools/local-dev/keys/` is for dev
|
||||||
@@ -109,14 +96,6 @@ jobs:
|
|||||||
export VITE_GATEWAY_RESPONSE_PUBLIC_KEY="$(grep -E '^VITE_GATEWAY_RESPONSE_PUBLIC_KEY=' .env.development | cut -d= -f2)"
|
export VITE_GATEWAY_RESPONSE_PUBLIC_KEY="$(grep -E '^VITE_GATEWAY_RESPONSE_PUBLIC_KEY=' .env.development | cut -d= -f2)"
|
||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
- name: Install site dependencies
|
|
||||||
working-directory: site
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build project site
|
|
||||||
working-directory: site
|
|
||||||
run: pnpm build
|
|
||||||
|
|
||||||
- name: Save images as artifact bundles
|
- name: Save images as artifact bundles
|
||||||
run: |
|
run: |
|
||||||
mkdir -p artifacts
|
mkdir -p artifacts
|
||||||
@@ -128,8 +107,6 @@ jobs:
|
|||||||
| gzip >"artifacts/game-engine-${{ steps.tag.outputs.tag }}.tar.gz"
|
| gzip >"artifacts/game-engine-${{ steps.tag.outputs.tag }}.tar.gz"
|
||||||
tar -C ui/frontend -czf \
|
tar -C ui/frontend -czf \
|
||||||
"artifacts/ui-dist-${{ steps.tag.outputs.tag }}.tar.gz" build
|
"artifacts/ui-dist-${{ steps.tag.outputs.tag }}.tar.gz" build
|
||||||
tar -C site/.vitepress -czf \
|
|
||||||
"artifacts/site-dist-${{ steps.tag.outputs.tag }}.tar.gz" dist
|
|
||||||
|
|
||||||
- name: Upload images
|
- name: Upload images
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
name: Build · Site
|
|
||||||
|
|
||||||
# Builds the VitePress project site so a broken site change fails its PR.
|
|
||||||
# The dev-deploy / prod-build workflows build and ship the site
|
|
||||||
# separately; this is the fast PR gate. No `!**/*.md` exclusion — the
|
|
||||||
# site is Markdown, so content changes must be exercised too.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- 'site/**'
|
|
||||||
- '.gitea/workflows/site-build.yaml'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'site/**'
|
|
||||||
- '.gitea/workflows/site-build.yaml'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 11.0.7
|
|
||||||
dest: ${{ runner.temp }}/setup-pnpm
|
|
||||||
|
|
||||||
- name: Set up Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: site/pnpm-lock.yaml
|
|
||||||
|
|
||||||
- name: Install site dependencies
|
|
||||||
working-directory: site
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build project site
|
|
||||||
working-directory: site
|
|
||||||
run: pnpm build
|
|
||||||
@@ -44,11 +44,6 @@ jobs:
|
|||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 11.0.7
|
version: 11.0.7
|
||||||
# Install pnpm into a per-job directory so concurrent jobs on
|
|
||||||
# the shared host runner do not race on the default
|
|
||||||
# `~/setup-pnpm` (the self-installer otherwise fails with
|
|
||||||
# `ENOTEMPTY` while cleaning a sibling job's install).
|
|
||||||
dest: ${{ runner.temp }}/setup-pnpm
|
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -61,15 +56,6 @@ jobs:
|
|||||||
working-directory: ui
|
working-directory: ui
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version-file: go.work
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Build core.wasm
|
|
||||||
uses: ./.gitea/actions/build-wasm
|
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
# `--with-deps` would shell out to `sudo apt-get install` for
|
# `--with-deps` would shell out to `sudo apt-get install` for
|
||||||
# the system .so libraries, which the host-mode runner cannot
|
# the system .so libraries, which the host-mode runner cannot
|
||||||
@@ -98,12 +84,6 @@ jobs:
|
|||||||
working-directory: ui/frontend
|
working-directory: ui/frontend
|
||||||
run: pnpm exec playwright test
|
run: pnpm exec playwright test
|
||||||
|
|
||||||
- name: Run PWA tests
|
|
||||||
# Builds + previews the production bundle (the service worker only
|
|
||||||
# precaches a real build) and checks manifest / SW / offline.
|
|
||||||
working-directory: ui/frontend
|
|
||||||
run: pnpm test:pwa
|
|
||||||
|
|
||||||
- name: Upload Playwright report on failure
|
- name: Upload Playwright report on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -119,16 +99,3 @@ jobs:
|
|||||||
name: playwright-traces
|
name: playwright-traces
|
||||||
path: ui/frontend/test-results/
|
path: ui/frontend/test-results/
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
- name: Remove root-owned build artifacts
|
|
||||||
if: always()
|
|
||||||
# In host-mode the job runs as root, so vite (test:pwa),
|
|
||||||
# svelte-kit and Playwright write these outputs root-owned into
|
|
||||||
# the shared host workspace. The act_runner (non-root) then
|
|
||||||
# cannot remove them at teardown ("unlinkat ... permission
|
|
||||||
# denied"), which spuriously fails this or a sibling job that
|
|
||||||
# inherits the dirty workspace (observed on go-unit). Clean them
|
|
||||||
# here while the step still has root, after the uploads above.
|
|
||||||
run: |
|
|
||||||
rm -rf ui/frontend/build ui/frontend/.svelte-kit \
|
|
||||||
ui/frontend/test-results ui/frontend/playwright-report
|
|
||||||
|
|||||||
@@ -16,12 +16,6 @@ This repository hosts the Galaxy Game project.
|
|||||||
mirrored into `docs/FUNCTIONAL_ru.md` in the same patch (translate
|
mirrored into `docs/FUNCTIONAL_ru.md` in the same patch (translate
|
||||||
the changed paragraphs only, do not re-translate the whole file).
|
the changed paragraphs only, do not re-translate the whole file).
|
||||||
A full re-translation only happens on explicit owner request.
|
A full re-translation only happens on explicit owner request.
|
||||||
- `site/ru/rules.md` — the player-facing game rules (ported from the
|
|
||||||
former `game/rules.txt`). **Russian is authoritative here**, inverting
|
|
||||||
the usual English-first rule: the game's rules and lore are
|
|
||||||
Russian-native, so `site/ru/rules.md` leads and the English
|
|
||||||
`site/rules.md` is its mirror. Mirror point edits the same way as
|
|
||||||
`docs/FUNCTIONAL.md`, but RU → EN.
|
|
||||||
- `docs/TESTING.md` — testing layers (unit / integration), the
|
- `docs/TESTING.md` — testing layers (unit / integration), the
|
||||||
integration runbook, and the principles every test must follow
|
integration runbook, and the principles every test must follow
|
||||||
(no-op observability for testcontainers, `t.Fatal` on
|
(no-op observability for testcontainers, `t.Fatal` on
|
||||||
@@ -50,10 +44,9 @@ Branches:
|
|||||||
is manual through `deploy-prod.yaml`.
|
is manual through `deploy-prod.yaml`.
|
||||||
- `development` — long-lived dev integration branch. Every merge into
|
- `development` — long-lived dev integration branch. Every merge into
|
||||||
it auto-deploys to the dev environment via `dev-deploy.yaml`
|
it auto-deploys to the dev environment via `dev-deploy.yaml`
|
||||||
(single origin `https://galaxy.lan`: site at `/`, game at `/game/`,
|
(reachable at `https://www.galaxy.lan` / `https://api.galaxy.lan`).
|
||||||
gateway REST at `/api`).
|
|
||||||
- `feature/*` — short-lived branches off `development`. Merged back
|
- `feature/*` — short-lived branches off `development`. Merged back
|
||||||
via PR; only then do they reach the dev environment automatically.
|
via PR; only then do they reach the dev environment.
|
||||||
|
|
||||||
Workflows in `.gitea/workflows/`:
|
Workflows in `.gitea/workflows/`:
|
||||||
|
|
||||||
@@ -62,24 +55,10 @@ Workflows in `.gitea/workflows/`:
|
|||||||
| `go-unit.yaml` | push + PR matching Go paths | Fast Go unit tests. |
|
| `go-unit.yaml` | push + PR matching Go paths | Fast Go unit tests. |
|
||||||
| `ui-test.yaml` | push + PR matching `ui/**` | Vitest + Playwright. |
|
| `ui-test.yaml` | push + PR matching `ui/**` | Vitest + Playwright. |
|
||||||
| `integration.yaml` | PR to `development`/`main`; push to `development` | testcontainers integration suite. |
|
| `integration.yaml` | PR to `development`/`main`; push to `development` | testcontainers integration suite. |
|
||||||
| `dev-deploy.yaml` | push to `development`; `workflow_dispatch` on any ref | Build images + (re)deploy to `tools/dev-deploy/`. |
|
| `dev-deploy.yaml` | push to `development` | Build images + (re)deploy to `tools/dev-deploy/`. |
|
||||||
| `prod-build.yaml` | push to `main` | Build prod images and `docker save` into artifacts. |
|
| `prod-build.yaml` | push to `main` | Build prod images and `docker save` into artifacts. |
|
||||||
| `deploy-prod.yaml` | `workflow_dispatch` | Manual rollout (placeholder until prod host exists). |
|
| `deploy-prod.yaml` | `workflow_dispatch` | Manual rollout (placeholder until prod host exists). |
|
||||||
|
|
||||||
### Deployment cadence
|
|
||||||
|
|
||||||
The long-lived dev environment (`tools/dev-deploy/`) is single-tenant:
|
|
||||||
one live deployment, redeployed on every merge into `development`.
|
|
||||||
While a PR is open the dev environment stays on whatever was last
|
|
||||||
merged — pushes to `feature/*` only fire the test workflows
|
|
||||||
(`go-unit`, `ui-test`, `integration`), not `dev-deploy.yaml`.
|
|
||||||
|
|
||||||
To preview an unmerged feature branch on the shared dev environment,
|
|
||||||
trigger `dev-deploy.yaml` manually from the Gitea UI
|
|
||||||
(**Actions → Deploy · Dev → Run workflow**) and pick the feature ref.
|
|
||||||
The deploy is idempotent: the next merge into `development` simply
|
|
||||||
overwrites whatever the manual dispatch left behind.
|
|
||||||
|
|
||||||
## Per-stage CI gate
|
## Per-stage CI gate
|
||||||
|
|
||||||
Every completed stage from any `PLAN.md` (per-service or `ui/PLAN.md`)
|
Every completed stage from any `PLAN.md` (per-service or `ui/PLAN.md`)
|
||||||
@@ -93,6 +72,10 @@ short version:
|
|||||||
4. Only after every workflow that fired is `success` may the stage be
|
4. Only after every workflow that fired is `success` may the stage be
|
||||||
marked done in the corresponding `PLAN.md`.
|
marked done in the corresponding `PLAN.md`.
|
||||||
|
|
||||||
|
`tools/local-ci/` is now an opt-in fallback for testing workflow
|
||||||
|
changes without `gitea.lan` (offline iterations, runner-isolation
|
||||||
|
debugging). It is no longer required for the per-stage gate.
|
||||||
|
|
||||||
## Decisions during stage implementation
|
## Decisions during stage implementation
|
||||||
|
|
||||||
Stages from `PLAN.md` produce decisions. Those decisions never live in a
|
Stages from `PLAN.md` produce decisions. Those decisions never live in a
|
||||||
@@ -119,22 +102,18 @@ The existing codebase of `galaxy/<service>` may be modified or extended when a
|
|||||||
plan stage requires it. All such changes must be covered by new or updated tests
|
plan stage requires it. All such changes must be covered by new or updated tests
|
||||||
and reflected in documentation when they affect documented behavior.
|
and reflected in documentation when they affect documented behavior.
|
||||||
|
|
||||||
## Migrations
|
## Pre-production migration rule
|
||||||
|
|
||||||
Schema changes for `backend` go into a new `0000N_*.sql` file under
|
The platform is not yet in production. Schema changes for `backend` go
|
||||||
`backend/internal/postgres/migrations/` with a monotonically increasing
|
into the existing `backend/internal/postgres/migrations/00001_init.sql`
|
||||||
prefix. `00001_init.sql` is the historical baseline and stays
|
file rather than into new `00002_*`-prefixed files. Local databases and
|
||||||
immutable; every subsequent change is its own additive migration with
|
integration test harnesses are recreated from scratch on every pull.
|
||||||
matching Up/Down sides. `pressly/goose/v3` (embedded into the backend
|
|
||||||
binary) applies pending migrations on startup, so the long-lived dev
|
|
||||||
environment picks up schema deltas without a manual reset.
|
|
||||||
|
|
||||||
Before the first production deployment the migration chain may be
|
**This rule is removed before the first production deployment.** From
|
||||||
squashed back into a single fresh `00001_init.sql` for a clean slate;
|
that point on every schema change becomes a new migration file with a
|
||||||
plan that work as an explicit task when it lands. See
|
monotonically increasing prefix, and `00001_init.sql` becomes immutable
|
||||||
`backend/internal/postgres/migrations/README.md` for the local
|
history. See `backend/internal/postgres/migrations/README.md` for
|
||||||
authoring conventions (file naming, transactional vs. non-transactional
|
details.
|
||||||
sections, backward-compatible deletes, rollback expectations).
|
|
||||||
|
|
||||||
## Documentation discipline
|
## Documentation discipline
|
||||||
|
|
||||||
|
|||||||
+7
-17
@@ -27,16 +27,10 @@ The implementation specification lives in `PLAN.md`.
|
|||||||
| ------------------ | ----------------------------------------------- | ------------------------------------- |
|
| ------------------ | ----------------------------------------------- | ------------------------------------- |
|
||||||
| `/api/v1/public/*` | none | Registration, code confirmation |
|
| `/api/v1/public/*` | none | Registration, code confirmation |
|
||||||
| `/api/v1/user/*` | `X-User-ID` injected by gateway | Authenticated end users |
|
| `/api/v1/user/*` | `X-User-ID` injected by gateway | Authenticated end users |
|
||||||
| `/api/v1/admin/*` | HTTP Basic Auth against `admin_accounts` | Platform administrators (JSON) |
|
| `/api/v1/admin/*` | HTTP Basic Auth against `admin_accounts` | Platform administrators |
|
||||||
| `/_gm`, `/_gm/*` | HTTP Basic Auth against `admin_accounts` | Operator console (server-rendered HTML)|
|
|
||||||
| `/healthz` | none | Liveness probe |
|
| `/healthz` | none | Liveness probe |
|
||||||
| `/readyz` | none | Readiness probe |
|
| `/readyz` | none | Readiness probe |
|
||||||
|
|
||||||
The `/_gm` operator console is the human-facing surface for the admin
|
|
||||||
operations; it reuses the admin Basic Auth verifier, renders with
|
|
||||||
`html/template`, and is the only admin surface exposed publicly (through
|
|
||||||
the gateway). See `docs/admin-console.md`.
|
|
||||||
|
|
||||||
The full contract is documented in `openapi.yaml` and validated at
|
The full contract is documented in `openapi.yaml` and validated at
|
||||||
runtime by the contract tests under `internal/server/`.
|
runtime by the contract tests under `internal/server/`.
|
||||||
|
|
||||||
@@ -106,7 +100,6 @@ fast.
|
|||||||
| `BACKEND_GAME_STATE_ROOT` | yes | — | Host directory bind-mounted into engine containers. |
|
| `BACKEND_GAME_STATE_ROOT` | yes | — | Host directory bind-mounted into engine containers. |
|
||||||
| `BACKEND_ADMIN_BOOTSTRAP_USER` | no | — | Initial admin username; idempotent insert. |
|
| `BACKEND_ADMIN_BOOTSTRAP_USER` | no | — | Initial admin username; idempotent insert. |
|
||||||
| `BACKEND_ADMIN_BOOTSTRAP_PASSWORD` | no | — | Initial admin password; required if user is set. |
|
| `BACKEND_ADMIN_BOOTSTRAP_PASSWORD` | no | — | Initial admin password; required if user is set. |
|
||||||
| `BACKEND_ADMIN_CONSOLE_CSRF_KEY` | no | random per-process | Secret keying the `/_gm` console CSRF token. Set a shared value across replicas; unset uses a per-process random key (forms reset on restart). |
|
|
||||||
| `BACKEND_GEOIP_DB_PATH` | yes | — | Filesystem path to GeoLite2 Country `.mmdb`. |
|
| `BACKEND_GEOIP_DB_PATH` | yes | — | Filesystem path to GeoLite2 Country `.mmdb`. |
|
||||||
| `BACKEND_OTEL_TRACES_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`. |
|
| `BACKEND_OTEL_TRACES_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`. |
|
||||||
| `BACKEND_OTEL_METRICS_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`, `prometheus`. |
|
| `BACKEND_OTEL_METRICS_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`, `prometheus`. |
|
||||||
@@ -136,7 +129,6 @@ fast.
|
|||||||
| `BACKEND_RUNTIME_CONTAINER_PIDS_LIMIT` | no | `256` | Engine container `--pids-limit`. |
|
| `BACKEND_RUNTIME_CONTAINER_PIDS_LIMIT` | no | `256` | Engine container `--pids-limit`. |
|
||||||
| `BACKEND_RUNTIME_CONTAINER_STATE_MOUNT` | no | `/var/lib/galaxy-game` | Absolute in-container path for the per-game state bind mount. |
|
| `BACKEND_RUNTIME_CONTAINER_STATE_MOUNT` | no | `/var/lib/galaxy-game` | Absolute in-container path for the per-game state bind mount. |
|
||||||
| `BACKEND_RUNTIME_STOP_GRACE_PERIOD` | no | `10s` | SIGTERM-to-SIGKILL grace period for engine container stop. |
|
| `BACKEND_RUNTIME_STOP_GRACE_PERIOD` | no | `10s` | SIGTERM-to-SIGKILL grace period for engine container stop. |
|
||||||
| `BACKEND_STACK_LABEL` | no | — | Optional value stamped as `galaxy.stack=<value>` on every engine container backend spawns. Lets host-side tooling (Makefile / CI) scope cleanup to one dev stack. Empty → label is not applied. |
|
|
||||||
| `BACKEND_NOTIFICATION_ADMIN_EMAIL` | no | — | Recipient address for admin-channel notifications (`runtime.*` kinds). When empty, admin-channel routes are recorded as `skipped` and the catalog is partially silenced. |
|
| `BACKEND_NOTIFICATION_ADMIN_EMAIL` | no | — | Recipient address for admin-channel notifications (`runtime.*` kinds). When empty, admin-channel routes are recorded as `skipped` and the catalog is partially silenced. |
|
||||||
| `BACKEND_NOTIFICATION_WORKER_INTERVAL` | no | `5s` | Notification route worker scan interval. |
|
| `BACKEND_NOTIFICATION_WORKER_INTERVAL` | no | `5s` | Notification route worker scan interval. |
|
||||||
| `BACKEND_NOTIFICATION_MAX_ATTEMPTS` | no | `8` | Notification route delivery attempts before dead-lettering. |
|
| `BACKEND_NOTIFICATION_MAX_ATTEMPTS` | no | `8` | Notification route delivery attempts before dead-lettering. |
|
||||||
@@ -161,10 +153,10 @@ seeded `admin_accounts` ahead of time.
|
|||||||
before the HTTP listener opens. The startup path also issues a
|
before the HTTP listener opens. The startup path also issues a
|
||||||
`CREATE SCHEMA IF NOT EXISTS backend` so a fresh database does not
|
`CREATE SCHEMA IF NOT EXISTS backend` so a fresh database does not
|
||||||
trip goose's bookkeeping table on the first migration.
|
trip goose's bookkeeping table on the first migration.
|
||||||
- Migrations are sequence-numbered (`0000N_*.sql`) and applied
|
- Pre-production uses one migration file (`00001_init.sql`) covering
|
||||||
additively. `00001_init.sql` is the historical baseline; every
|
every backend domain (auth, user, admin, lobby, runtime, mail,
|
||||||
schema change after it is a new file with a higher prefix. See
|
notification, geo). Future migrations are sequence-numbered and
|
||||||
`internal/postgres/migrations/README.md` for the authoring rules.
|
additive.
|
||||||
- Queries are written through `go-jet/jet/v2`. The generated code is in
|
- Queries are written through `go-jet/jet/v2`. The generated code is in
|
||||||
`internal/postgres/jet/backend/` and is committed; `internal/postgres/jet/jet.go`
|
`internal/postgres/jet/backend/` and is committed; `internal/postgres/jet/jet.go`
|
||||||
carries package metadata that survives regeneration.
|
carries package metadata that survives regeneration.
|
||||||
@@ -264,13 +256,11 @@ introduce its own request/response types.
|
|||||||
|
|
||||||
Endpoints used:
|
Endpoints used:
|
||||||
|
|
||||||
- `POST /api/v1/admin/init` — the runtime worker passes the canonical
|
- `POST /api/v1/admin/init`
|
||||||
`game_id` (the same UUID that names the engine container and the
|
|
||||||
host bind-mount directory) in the request body so the engine's
|
|
||||||
`state.json` shares identity with the backend's `games.game_id`.
|
|
||||||
- `GET /api/v1/admin/status`
|
- `GET /api/v1/admin/status`
|
||||||
- `PUT /api/v1/admin/turn`
|
- `PUT /api/v1/admin/turn`
|
||||||
- `POST /api/v1/admin/race/banish`
|
- `POST /api/v1/admin/race/banish`
|
||||||
|
- `PUT /api/v1/command`
|
||||||
- `PUT /api/v1/order`
|
- `PUT /api/v1/order`
|
||||||
- `GET /api/v1/report`
|
- `GET /api/v1/report`
|
||||||
- `GET /healthz`
|
- `GET /healthz`
|
||||||
|
|||||||
+18
-46
@@ -22,10 +22,10 @@ import (
|
|||||||
_ "time/tzdata"
|
_ "time/tzdata"
|
||||||
|
|
||||||
"galaxy/backend/internal/admin"
|
"galaxy/backend/internal/admin"
|
||||||
"galaxy/backend/internal/adminconsole"
|
|
||||||
"galaxy/backend/internal/app"
|
"galaxy/backend/internal/app"
|
||||||
"galaxy/backend/internal/auth"
|
"galaxy/backend/internal/auth"
|
||||||
"galaxy/backend/internal/config"
|
"galaxy/backend/internal/config"
|
||||||
|
"galaxy/backend/internal/devsandbox"
|
||||||
"galaxy/backend/internal/diplomail"
|
"galaxy/backend/internal/diplomail"
|
||||||
"galaxy/backend/internal/diplomail/detector"
|
"galaxy/backend/internal/diplomail/detector"
|
||||||
"galaxy/backend/internal/diplomail/translator"
|
"galaxy/backend/internal/diplomail/translator"
|
||||||
@@ -37,7 +37,6 @@ import (
|
|||||||
"galaxy/backend/internal/mail"
|
"galaxy/backend/internal/mail"
|
||||||
"galaxy/backend/internal/metricsapi"
|
"galaxy/backend/internal/metricsapi"
|
||||||
"galaxy/backend/internal/notification"
|
"galaxy/backend/internal/notification"
|
||||||
"galaxy/backend/internal/opsstatus"
|
|
||||||
backendpostgres "galaxy/backend/internal/postgres"
|
backendpostgres "galaxy/backend/internal/postgres"
|
||||||
"galaxy/backend/push"
|
"galaxy/backend/push"
|
||||||
"galaxy/backend/internal/runtime"
|
"galaxy/backend/internal/runtime"
|
||||||
@@ -273,18 +272,29 @@ func run(ctx context.Context) (err error) {
|
|||||||
)
|
)
|
||||||
runtimeGateway.svc = runtimeSvc
|
runtimeGateway.svc = runtimeSvc
|
||||||
|
|
||||||
// Run a single reconciliation pass at startup so any runtime row
|
// Run a single reconciliation pass before the dev-sandbox
|
||||||
// pointing at a vanished engine container (a host reboot wiped
|
// bootstrap so any runtime row pointing at a vanished engine
|
||||||
// /tmp/galaxy-game-state/<uuid>; `tools/local-dev`'s
|
// container (host reboot wiped /tmp/galaxy-game-state/<uuid>;
|
||||||
// `prune-broken-engines` target reaped the husk) is cascaded
|
// `tools/local-dev`'s `prune-broken-engines` target reaped the
|
||||||
// through `markRemoved` → lobby `cancelled` before the server
|
// husk) is already cascaded through `markRemoved` → lobby
|
||||||
// starts serving requests. Failures are
|
// `cancelled` by the time the bootstrap walks the sandbox list.
|
||||||
|
// Without this pre-tick the bootstrap would reuse the
|
||||||
|
// soon-to-be-cancelled game and force the developer into a
|
||||||
|
// second `make up` cycle to land a healthy sandbox. Failures are
|
||||||
// non-fatal: the periodic ticker started later catches up, and
|
// non-fatal: the periodic ticker started later catches up, and
|
||||||
// the worst case degrades to the legacy two-cycle recovery.
|
// the worst case degrades to the legacy two-cycle recovery.
|
||||||
if err := runtimeSvc.Reconciler().Tick(ctx); err != nil {
|
if err := runtimeSvc.Reconciler().Tick(ctx); err != nil {
|
||||||
logger.Warn("pre-bootstrap reconciler tick failed", zap.Error(err))
|
logger.Warn("pre-bootstrap reconciler tick failed", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := devsandbox.Bootstrap(ctx, devsandbox.Deps{
|
||||||
|
Users: userSvc,
|
||||||
|
Lobby: lobbySvc,
|
||||||
|
EngineVersions: engineVersionSvc,
|
||||||
|
}, cfg.DevSandbox, logger); err != nil {
|
||||||
|
return fmt.Errorf("dev sandbox bootstrap: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
notifStore := notification.NewStore(db)
|
notifStore := notification.NewStore(db)
|
||||||
notifSvc := notification.NewService(notification.Deps{
|
notifSvc := notification.NewService(notification.Deps{
|
||||||
Store: notifStore,
|
Store: notifStore,
|
||||||
@@ -350,32 +360,6 @@ func run(ctx context.Context) (err error) {
|
|||||||
return authCache.Ready() && userCache.Ready() && adminCache.Ready() && lobbyCache.Ready() && runtimeCache.Ready()
|
return authCache.Ready() && userCache.Ready() && adminCache.Ready() && lobbyCache.Ready() && runtimeCache.Ready()
|
||||||
}
|
}
|
||||||
|
|
||||||
var consoleCSRF *adminconsole.CSRF
|
|
||||||
if cfg.AdminConsole.CSRFKey != "" {
|
|
||||||
consoleCSRF = adminconsole.NewCSRF([]byte(cfg.AdminConsole.CSRFKey))
|
|
||||||
} else {
|
|
||||||
consoleCSRF, err = adminconsole.NewRandomCSRF()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("init admin console CSRF: %w", err)
|
|
||||||
}
|
|
||||||
logger.Warn("admin console CSRF key not set; using a per-process random key (forms reset on restart, not valid across replicas)",
|
|
||||||
zap.String("env", "BACKEND_ADMIN_CONSOLE_CSRF_KEY"))
|
|
||||||
}
|
|
||||||
adminConsoleHandlers := backendserver.NewAdminConsoleHandlers(backendserver.AdminConsoleDeps{
|
|
||||||
CSRF: consoleCSRF,
|
|
||||||
Monitor: opsstatus.NewStore(db),
|
|
||||||
Ready: ready,
|
|
||||||
Users: userSvc,
|
|
||||||
Games: lobbySvc,
|
|
||||||
Runtime: runtimeSvc,
|
|
||||||
EngineVersions: engineVersionSvc,
|
|
||||||
Operators: adminSvc,
|
|
||||||
Mail: mailSvc,
|
|
||||||
Notifications: notifSvc,
|
|
||||||
Diplomail: diplomailSvc,
|
|
||||||
Logger: logger,
|
|
||||||
})
|
|
||||||
|
|
||||||
handler, err := backendserver.NewRouter(backendserver.RouterDependencies{
|
handler, err := backendserver.NewRouter(backendserver.RouterDependencies{
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
Telemetry: telemetryRT,
|
Telemetry: telemetryRT,
|
||||||
@@ -404,7 +388,6 @@ func run(ctx context.Context) (err error) {
|
|||||||
AdminGeo: adminGeoHandlers,
|
AdminGeo: adminGeoHandlers,
|
||||||
UserGames: userGamesHandlers,
|
UserGames: userGamesHandlers,
|
||||||
UserMail: userMailHandlers,
|
UserMail: userMailHandlers,
|
||||||
AdminConsole: adminConsoleHandlers,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("build backend router: %w", err)
|
return fmt.Errorf("build backend router: %w", err)
|
||||||
@@ -502,17 +485,6 @@ func (a *userEntitlementAdapter) GetMaxRegisteredRaceNames(ctx context.Context,
|
|||||||
return snap.MaxRegisteredRaceNames, nil
|
return snap.MaxRegisteredRaceNames, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *userEntitlementAdapter) IsPaid(ctx context.Context, userID uuid.UUID) (bool, error) {
|
|
||||||
if a == nil || a.svc == nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
snap, err := a.svc.GetEntitlementSnapshot(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return snap.IsPaid, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// runtimeGatewayAdapter implements `lobby.RuntimeGateway` by
|
// runtimeGatewayAdapter implements `lobby.RuntimeGateway` by
|
||||||
// delegating to `*runtime.Service`. The svc pointer is patched after
|
// delegating to `*runtime.Service`. The svc pointer is patched after
|
||||||
// the services are constructed — runtime depends on lobby
|
// the services are constructed — runtime depends on lobby
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
# Operator console (`/_gm`)
|
|
||||||
|
|
||||||
The operator console is a server-rendered web UI for the platform's admin
|
|
||||||
operations. It is the human-facing counterpart to the JSON admin API under
|
|
||||||
`/api/v1/admin/*`: both call the same service layer, but the console renders
|
|
||||||
HTML pages an operator drives in a browser, while the JSON API stays internal
|
|
||||||
to the deployment for programmatic and test use.
|
|
||||||
|
|
||||||
## Design choices
|
|
||||||
|
|
||||||
- **Server-rendered, no client framework.** Pages are rendered with the
|
|
||||||
standard library's `html/template`. Navigation is by ordinary links and
|
|
||||||
query parameters; every state change is an HTML form `POST` answered with a
|
|
||||||
Post/Redirect/Get redirect. There is no build step, no JavaScript framework,
|
|
||||||
and no separate asset pipeline — a single embedded stylesheet under
|
|
||||||
`/_gm/assets/`.
|
|
||||||
- **Reuses the existing admin auth.** The console mounts behind the same
|
|
||||||
`basicauth.Middleware(admin.Service)` verifier that gates `/api/v1/admin/*`,
|
|
||||||
so there is one credential store (`admin_accounts`, bcrypt-12) and no second
|
|
||||||
secret to manage.
|
|
||||||
- **Lives in the backend.** The backend owns the admin domain and the data, so
|
|
||||||
rendering there lets the console call the service layer directly. The gateway
|
|
||||||
stays a thin proxy.
|
|
||||||
|
|
||||||
## Request path
|
|
||||||
|
|
||||||
```
|
|
||||||
Browser ── /_gm/* ──► edge Caddy ──► gateway (public listener)
|
|
||||||
gateway: anti-abuse `admin` class (per-IP rate limit, body + method limits)
|
|
||||||
└─► reverse proxy ──► backend /_gm/*
|
|
||||||
backend: basicauth.Middleware(admin.Service)
|
|
||||||
└─► CSRF guard (state-changing methods)
|
|
||||||
└─► console handler ──► admin service layer ──► html/template
|
|
||||||
```
|
|
||||||
|
|
||||||
The gateway preserves the inbound `Host` and relays the backend's `401` Basic
|
|
||||||
Auth challenge unchanged, so the browser shows its native credential dialog.
|
|
||||||
The gateway adds only the edge anti-abuse layer; authentication and every state
|
|
||||||
change are enforced by the backend. The gateway answers `502` when the backend
|
|
||||||
is unreachable. See the gateway README "Operator Console Proxy" section for the
|
|
||||||
`admin` route-class env vars.
|
|
||||||
|
|
||||||
## Components (package `internal/adminconsole`)
|
|
||||||
|
|
||||||
The package is framework-agnostic (no gin) so it unit-tests in isolation:
|
|
||||||
|
|
||||||
- `Renderer` — parses the embedded layout plus one content page per route and
|
|
||||||
renders a named page wrapped in the shared layout. Rendering goes through an
|
|
||||||
intermediate buffer, so a template failure never emits a partial document.
|
|
||||||
- `CSRF` — issues and verifies the stateless anti-CSRF token: HMAC-SHA256 over
|
|
||||||
the authenticated username, keyed by `BACKEND_ADMIN_CONSOLE_CSRF_KEY`. When
|
|
||||||
the key is unset a per-process random key is used (secure, but forms reset on
|
|
||||||
restart and do not validate across replicas — set a shared key for
|
|
||||||
multi-replica deployments).
|
|
||||||
- `Assets` — the embedded stylesheet filesystem served under `/_gm/assets/`.
|
|
||||||
|
|
||||||
The gin glue (route group, Basic Auth, the CSRF guard middleware, the per-page
|
|
||||||
handlers) lives in `internal/server/handlers_admin_console.go` and
|
|
||||||
`internal/server/router.go` (`registerAdminConsoleRoutes`).
|
|
||||||
|
|
||||||
## CSRF protection
|
|
||||||
|
|
||||||
Because the console is sessionless (HTTP Basic Auth, whose credentials the
|
|
||||||
browser replays automatically), state-changing requests are double-guarded:
|
|
||||||
|
|
||||||
1. A stateless per-operator token (`_csrf` form field) that a cross-site page
|
|
||||||
cannot read or forge.
|
|
||||||
2. A same-origin `Origin`/`Referer` check (when the browser sends one), which
|
|
||||||
relies on the gateway preserving the inbound `Host`.
|
|
||||||
|
|
||||||
Safe methods (`GET`/`HEAD`/`OPTIONS`) pass without a token.
|
|
||||||
|
|
||||||
## Monitoring
|
|
||||||
|
|
||||||
The dashboard is the console landing page. It surfaces backend-visible
|
|
||||||
operational state — service health, game-runtime status, and queue depths —
|
|
||||||
read through the existing service and persistence layers. Richer cross-service
|
|
||||||
metrics are out of scope for the console itself: the `/metrics` Prometheus
|
|
||||||
exporters on `backend` and `gateway` are wired and enabled in the dev
|
|
||||||
deployment so a future Prometheus + Grafana stack can scrape them without code
|
|
||||||
changes.
|
|
||||||
|
|
||||||
## Pages
|
|
||||||
|
|
||||||
| Path | Method | Purpose |
|
|
||||||
| --------------------------------- | -------- | -------------------------------------------------------------- |
|
|
||||||
| `/_gm`, `/_gm/` | GET | Dashboard: health, runtime/mail/notification status, queues. |
|
|
||||||
| `/_gm/assets/*` | GET | Embedded stylesheet. |
|
|
||||||
| `/_gm/users` | GET | Paginated account list. |
|
|
||||||
| `/_gm/users/{id}` | GET | Account detail: profile, entitlement, active sanctions. |
|
|
||||||
| `/_gm/users/{id}/block` | POST | Apply a permanent block (reason required). |
|
|
||||||
| `/_gm/users/{id}/entitlement` | POST | Set the entitlement tier. |
|
|
||||||
| `/_gm/users/{id}/soft-delete` | POST | Soft-delete the account (cascades). |
|
|
||||||
| `/_gm/games` | GET/POST | Paginated game list; POST creates a public game. |
|
|
||||||
| `/_gm/games/{id}` | GET | Game detail with the runtime snapshot. |
|
|
||||||
| `/_gm/games/{id}/force-start` | POST | Force-start the game. |
|
|
||||||
| `/_gm/games/{id}/force-stop` | POST | Force-stop the game. |
|
|
||||||
| `/_gm/games/{id}/ban-member` | POST | Ban a member (user id + reason). |
|
|
||||||
| `/_gm/games/{id}/runtime/restart` | POST | Restart the engine container. |
|
|
||||||
| `/_gm/games/{id}/runtime/patch` | POST | Patch the runtime to a target version. |
|
|
||||||
| `/_gm/games/{id}/runtime/force-next-turn` | POST | Force the next turn now. |
|
|
||||||
| `/_gm/engine-versions` | GET/POST | Version registry; POST registers a version. |
|
|
||||||
| `/_gm/engine-versions/{ver}/disable` | POST | Disable a registered version. |
|
|
||||||
| `/_gm/operators` | GET/POST | Admin-account list; POST creates an operator. |
|
|
||||||
| `/_gm/operators/{user}/disable` | POST | Disable an operator. |
|
|
||||||
| `/_gm/operators/{user}/enable` | POST | Re-enable an operator. |
|
|
||||||
| `/_gm/operators/{user}/reset-password` | POST | Reset an operator's password. |
|
|
||||||
| `/_gm/mail` | GET | Mail deliveries (paginated) + a dead-letter snapshot. |
|
|
||||||
| `/_gm/mail/deliveries/{id}` | GET | Delivery detail with its attempts. |
|
|
||||||
| `/_gm/mail/deliveries/{id}/resend`| POST | Re-enqueue a non-sent delivery. |
|
|
||||||
| `/_gm/notifications` | GET | Notifications, dead-letters, and malformed intents overview. |
|
|
||||||
| `/_gm/broadcast` | GET/POST | Admin multi-game diplomatic broadcast. |
|
|
||||||
|
|
||||||
Each page reuses the same service layer as the corresponding `/api/v1/admin/*`
|
|
||||||
JSON endpoint; the console adds no business logic. Collection-mutating POSTs are
|
|
||||||
mounted on the collection path (`POST /_gm/games`, `POST /_gm/engine-versions`)
|
|
||||||
so a static action segment never collides with a path parameter in the gin
|
|
||||||
router. Unblocking a user is not yet available because the JSON admin API
|
|
||||||
exposes no remove-sanction endpoint.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
| Variable | Where | Notes |
|
|
||||||
| --------------------------------- | ------- | ------------------------------------------------------------ |
|
|
||||||
| `BACKEND_ADMIN_CONSOLE_CSRF_KEY` | backend | CSRF token key; unset → per-process random key. |
|
|
||||||
| `BACKEND_ADMIN_BOOTSTRAP_USER` | backend | Bootstrap operator account (shared with the JSON admin API). |
|
|
||||||
| `BACKEND_ADMIN_BOOTSTRAP_PASSWORD`| backend | Bootstrap operator password. |
|
|
||||||
| `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_*` | gateway | `admin` route-class rate-limit and body budgets. |
|
|
||||||
@@ -234,8 +234,8 @@ sequenceDiagram
|
|||||||
|
|
||||||
Workers->>Docker: pull / create / start engine container
|
Workers->>Docker: pull / create / start engine container
|
||||||
Docker-->>Workers: container id
|
Docker-->>Workers: container id
|
||||||
Workers->>Engine: POST /api/v1/admin/init {gameId, races}
|
Workers->>Engine: POST /api/v1/admin/init
|
||||||
Engine-->>Workers: StateResponse{id == gameId} / error
|
Engine-->>Workers: ok / error
|
||||||
Workers->>Runtime: write runtime_records (running or start_failed)
|
Workers->>Runtime: write runtime_records (running or start_failed)
|
||||||
Workers->>Lobby: OnRuntimeJobResult
|
Workers->>Lobby: OnRuntimeJobResult
|
||||||
|
|
||||||
|
|||||||
@@ -28,11 +28,10 @@ test stack. The list mirrors the steady-state behaviour documented in
|
|||||||
## Migrations
|
## Migrations
|
||||||
|
|
||||||
`pressly/goose/v3` applies embedded migrations from
|
`pressly/goose/v3` applies embedded migrations from
|
||||||
`internal/postgres/migrations/`. Migrations are additive,
|
`internal/postgres/migrations/`. The pre-production set ships as
|
||||||
sequence-numbered files (`00001_init.sql` is the baseline). Backend
|
`00001_init.sql` plus additive numbered files. Backend always runs
|
||||||
always runs `CREATE SCHEMA IF NOT EXISTS backend` before goose so a
|
`CREATE SCHEMA IF NOT EXISTS backend` before goose so a fresh database
|
||||||
fresh database does not trip the bookkeeping table on the first
|
does not trip the bookkeeping table on the first migration.
|
||||||
migration.
|
|
||||||
|
|
||||||
`internal/postgres/migrations_test.go` asserts that the migration
|
`internal/postgres/migrations_test.go` asserts that the migration
|
||||||
produces the expected table set; adding a table without updating the
|
produces the expected table set; adding a table without updating the
|
||||||
|
|||||||
@@ -141,10 +141,7 @@ boot).
|
|||||||
polls the engine `/healthz` until the listener is bound (Docker
|
polls the engine `/healthz` until the listener is bound (Docker
|
||||||
marks a container running as soon as the entrypoint starts; the
|
marks a container running as soon as the entrypoint starts; the
|
||||||
Go binary inside takes a moment to bind its TCP port). Only after
|
Go binary inside takes a moment to bind its TCP port). Only after
|
||||||
`/healthz` succeeds does the worker call `/admin/init`, passing the
|
`/healthz` succeeds does the worker call `/admin/init`.
|
||||||
same `game_id` the backend uses to mount the engine's storage
|
|
||||||
directory; the engine echoes it back in `StateResponse.id`. The
|
|
||||||
engine rejects a mismatched gameId with `409 Conflict`.
|
|
||||||
- **Runtime scheduler** (`internal/runtime.SchedulerComponent`) —
|
- **Runtime scheduler** (`internal/runtime.SchedulerComponent`) —
|
||||||
`pkg/cronutil` schedule per running game; each tick invokes the
|
`pkg/cronutil` schedule per running game; each tick invokes the
|
||||||
engine `admin/turn`. Force-next-turn flips a one-shot skip flag in
|
engine `admin/turn`. Force-next-turn flips a one-shot skip flag in
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,6 @@ require (
|
|||||||
galaxy/model v0.0.0
|
galaxy/model v0.0.0
|
||||||
galaxy/postgres v0.0.0
|
galaxy/postgres v0.0.0
|
||||||
galaxy/util v0.0.0-00010101000000-000000000000
|
galaxy/util v0.0.0-00010101000000-000000000000
|
||||||
github.com/abadojack/whatlanggo v1.0.1
|
|
||||||
github.com/disciplinedware/go-confusables v0.1.1
|
github.com/disciplinedware/go-confusables v0.1.1
|
||||||
github.com/getkin/kin-openapi v0.135.0
|
github.com/getkin/kin-openapi v0.135.0
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
@@ -37,6 +36,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/abadojack/whatlanggo v1.0.1 // indirect
|
||||||
github.com/oschwald/geoip2-golang/v2 v2.1.0 // indirect
|
github.com/oschwald/geoip2-golang/v2 v2.1.0 // indirect
|
||||||
github.com/oschwald/maxminddb-golang/v2 v2.1.1 // indirect
|
github.com/oschwald/maxminddb-golang/v2 v2.1.1 // indirect
|
||||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
/* Admin console stylesheet. Deliberately small and dependency-free: the
|
|
||||||
console is an internal operator tool, not a public surface. */
|
|
||||||
:root {
|
|
||||||
--bg: #11151c;
|
|
||||||
--panel: #1b2230;
|
|
||||||
--panel-hi: #232c3d;
|
|
||||||
--ink: #e6ebf2;
|
|
||||||
--ink-dim: #9aa7ba;
|
|
||||||
--line: #2c3850;
|
|
||||||
--accent: #5aa9ff;
|
|
||||||
--danger: #ff6b6b;
|
|
||||||
--ok: #4ecb8d;
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--ink);
|
|
||||||
font: 15px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
}
|
|
||||||
a { color: var(--accent); text-decoration: none; }
|
|
||||||
a:hover { text-decoration: underline; }
|
|
||||||
.topbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1.5rem;
|
|
||||||
padding: 0.6rem 1.2rem;
|
|
||||||
background: var(--panel);
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
.topbar .brand { font-weight: 700; letter-spacing: 0.04em; }
|
|
||||||
.topbar .mainnav { display: flex; gap: 1rem; flex: 1; }
|
|
||||||
.topbar .mainnav a.active { color: var(--ink); border-bottom: 2px solid var(--accent); }
|
|
||||||
.topbar .who { color: var(--ink-dim); }
|
|
||||||
.content { padding: 1.5rem; max-width: 1100px; margin: 0 auto; }
|
|
||||||
h1 { font-size: 1.4rem; margin: 0 0 0.4rem; }
|
|
||||||
.lede { color: var(--ink-dim); margin-top: 0; }
|
|
||||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-top: 1.5rem; }
|
|
||||||
.card {
|
|
||||||
display: block;
|
|
||||||
padding: 1rem 1.2rem;
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
.card:hover { background: var(--panel-hi); text-decoration: none; }
|
|
||||||
.card h2 { font-size: 1.05rem; margin: 0 0 0.3rem; color: var(--accent); }
|
|
||||||
.card p { margin: 0; color: var(--ink-dim); font-size: 0.9rem; }
|
|
||||||
|
|
||||||
.panel {
|
|
||||||
padding: 0.9rem 1.1rem;
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.panel h2 { font-size: 1rem; margin: 0 0 0.6rem; color: var(--ink); }
|
|
||||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 1rem; }
|
|
||||||
.grid .panel { margin-bottom: 0; }
|
|
||||||
.kv { list-style: none; margin: 0; padding: 0; }
|
|
||||||
.kv li { padding: 0.15rem 0; color: var(--ink-dim); }
|
|
||||||
.counts { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
|
||||||
.counts td { padding: 0.2rem 0; border-bottom: 1px solid var(--line); color: var(--ink-dim); }
|
|
||||||
.counts td.num { text-align: right; color: var(--ink); font-variant-numeric: tabular-nums; }
|
|
||||||
.bignum { font-size: 1.6rem; margin: 0; color: var(--ink); }
|
|
||||||
.note { color: var(--ink-dim); font-style: italic; margin: 0.2rem 0; }
|
|
||||||
.errors { border-color: var(--danger); }
|
|
||||||
.errors ul { margin: 0; padding-left: 1.1rem; color: var(--danger); }
|
|
||||||
.ok { color: var(--ok); }
|
|
||||||
.bad { color: var(--danger); }
|
|
||||||
|
|
||||||
.list { width: 100%; border-collapse: collapse; font-size: 0.9rem; margin-bottom: 1rem; }
|
|
||||||
.list th, .list td { text-align: left; padding: 0.35rem 0.6rem; border-bottom: 1px solid var(--line); }
|
|
||||||
.list th { color: var(--ink-dim); font-weight: 600; }
|
|
||||||
.list tr:hover td { background: var(--panel-hi); }
|
|
||||||
.pager { display: flex; gap: 1rem; align-items: center; color: var(--ink-dim); }
|
|
||||||
.form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.8rem; }
|
|
||||||
.form label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); }
|
|
||||||
.form input, .form select {
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--ink);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.35rem 0.5rem;
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
background: var(--accent);
|
|
||||||
color: #06121f;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.4rem 0.9rem;
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
button:hover { filter: brightness(1.1); }
|
|
||||||
button.danger { background: var(--danger); color: #1a0606; }
|
|
||||||
code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
|
|
||||||
.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; }
|
|
||||||
.actions form { margin: 0; }
|
|
||||||
.subnav { color: var(--ink-dim); margin: -0.3rem 0 1rem; font-size: 0.9rem; }
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
package adminconsole
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CSRF issues and verifies the stateless anti-CSRF token used by the admin
|
|
||||||
// console. The token is an HMAC-SHA256 over the authenticated operator's
|
|
||||||
// username keyed by a process secret, so a cross-site request cannot forge it
|
|
||||||
// without already being able to read an authenticated page. The console is
|
|
||||||
// sessionless (HTTP Basic Auth), which makes a stateless, per-operator token
|
|
||||||
// the natural fit.
|
|
||||||
type CSRF struct {
|
|
||||||
key []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCSRF returns a CSRF signer keyed by key. A shared key across backend
|
|
||||||
// replicas lets a form rendered by one replica validate on another; callers
|
|
||||||
// that pass a per-process random key (see NewRandomCSRF) accept that forms do
|
|
||||||
// not survive a restart or span replicas.
|
|
||||||
func NewCSRF(key []byte) *CSRF {
|
|
||||||
return &CSRF{key: key}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRandomCSRF returns a CSRF signer keyed by a fresh 32-byte random secret.
|
|
||||||
// It is the secure default when no shared key is configured.
|
|
||||||
func NewRandomCSRF() (*CSRF, error) {
|
|
||||||
key := make([]byte, 32)
|
|
||||||
if _, err := rand.Read(key); err != nil {
|
|
||||||
return nil, fmt.Errorf("generate admin console CSRF key: %w", err)
|
|
||||||
}
|
|
||||||
return &CSRF{key: key}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Token returns the anti-CSRF token bound to username.
|
|
||||||
func (c *CSRF) Token(username string) string {
|
|
||||||
mac := hmac.New(sha256.New, c.key)
|
|
||||||
mac.Write([]byte(username))
|
|
||||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify reports whether token is the valid anti-CSRF token for username. The
|
|
||||||
// comparison runs in constant time relative to the token bytes.
|
|
||||||
func (c *CSRF) Verify(username, token string) bool {
|
|
||||||
if token == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
expected := c.Token(username)
|
|
||||||
return hmac.Equal([]byte(token), []byte(expected))
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package adminconsole
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestCSRFTokenRoundTrip(t *testing.T) {
|
|
||||||
signer := NewCSRF([]byte("shared-secret"))
|
|
||||||
token := signer.Token("alice")
|
|
||||||
|
|
||||||
if !signer.Verify("alice", token) {
|
|
||||||
t.Fatal("valid token rejected")
|
|
||||||
}
|
|
||||||
if signer.Verify("bob", token) {
|
|
||||||
t.Fatal("token accepted for a different operator")
|
|
||||||
}
|
|
||||||
if signer.Verify("alice", "") {
|
|
||||||
t.Fatal("empty token accepted")
|
|
||||||
}
|
|
||||||
if signer.Verify("alice", token+"x") {
|
|
||||||
t.Fatal("tampered token accepted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCSRFKeySeparation(t *testing.T) {
|
|
||||||
a := NewCSRF([]byte("key-a"))
|
|
||||||
b := NewCSRF([]byte("key-b"))
|
|
||||||
if a.Token("operator") == b.Token("operator") {
|
|
||||||
t.Fatal("tokens collide across distinct keys")
|
|
||||||
}
|
|
||||||
if b.Verify("operator", a.Token("operator")) {
|
|
||||||
t.Fatal("token minted under one key verified under another")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRandomCSRFRoundTrip(t *testing.T) {
|
|
||||||
signer, err := NewRandomCSRF()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewRandomCSRF: %v", err)
|
|
||||||
}
|
|
||||||
if !signer.Verify("operator", signer.Token("operator")) {
|
|
||||||
t.Fatal("random-key token failed to round-trip")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package adminconsole
|
|
||||||
|
|
||||||
// StatusCount pairs a status label with its current row count for the
|
|
||||||
// dashboard's per-status tables. It is the view-layer counterpart of the
|
|
||||||
// data gathered by the ops-status reader; the server handler maps between
|
|
||||||
// them so this package stays free of database concerns.
|
|
||||||
type StatusCount struct {
|
|
||||||
Status string
|
|
||||||
Count int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// DashboardData is the view model for the console landing page. MonitorAvailable
|
|
||||||
// is false when no ops-status reader is wired, in which case the monitoring
|
|
||||||
// panels are omitted. Errors carries non-fatal probe failures for display.
|
|
||||||
type DashboardData struct {
|
|
||||||
MonitorAvailable bool
|
|
||||||
BackendReady bool
|
|
||||||
PostgresHealthy bool
|
|
||||||
Runtimes []StatusCount
|
|
||||||
MailDeliveries []StatusCount
|
|
||||||
NotificationRoutes []StatusCount
|
|
||||||
NotificationMalformed int64
|
|
||||||
Errors []string
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
// Package adminconsole renders the server-side operator console mounted by the
|
|
||||||
// backend under the `/_gm` route group.
|
|
||||||
//
|
|
||||||
// The console is a multi-page, server-rendered surface built on the standard
|
|
||||||
// library's html/template package: navigation is driven by request path and
|
|
||||||
// query, state changes are submitted with HTML forms and answered with a
|
|
||||||
// Post/Redirect/Get redirect. The package owns three concerns and nothing
|
|
||||||
// transport-specific:
|
|
||||||
//
|
|
||||||
// - Renderer composes the shared layout with one content page per route.
|
|
||||||
// - CSRF issues and verifies the stateless anti-CSRF token embedded in every
|
|
||||||
// state-changing form.
|
|
||||||
// - Assets exposes the embedded stylesheet served under `/_gm/assets/`.
|
|
||||||
//
|
|
||||||
// The gin glue (route registration, Basic Auth, the CSRF guard middleware, and
|
|
||||||
// the per-page handlers) lives in package server; this package stays free of
|
|
||||||
// the web framework so it can be unit-tested in isolation.
|
|
||||||
package adminconsole
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package adminconsole
|
|
||||||
|
|
||||||
// GameRow is one line in the games list table.
|
|
||||||
type GameRow struct {
|
|
||||||
GameID string
|
|
||||||
GameName string
|
|
||||||
Visibility string
|
|
||||||
Status string
|
|
||||||
Owner string
|
|
||||||
Players string
|
|
||||||
TurnSchedule string
|
|
||||||
CreatedAt string
|
|
||||||
}
|
|
||||||
|
|
||||||
// GamesListData is the view model for the paginated games list.
|
|
||||||
type GamesListData struct {
|
|
||||||
Items []GameRow
|
|
||||||
Page int
|
|
||||||
PageSize int
|
|
||||||
Total int
|
|
||||||
HasPrev bool
|
|
||||||
HasNext bool
|
|
||||||
PrevPage int
|
|
||||||
NextPage int
|
|
||||||
}
|
|
||||||
|
|
||||||
// GameDetailData is the view model for a single game, combining the lobby
|
|
||||||
// record with the runtime snapshot and the available actions.
|
|
||||||
type GameDetailData struct {
|
|
||||||
GameID string
|
|
||||||
GameName string
|
|
||||||
Description string
|
|
||||||
Visibility string
|
|
||||||
Status string
|
|
||||||
Owner string
|
|
||||||
MinPlayers int32
|
|
||||||
MaxPlayers int32
|
|
||||||
StartGapHours int32
|
|
||||||
StartGapPlayers int32
|
|
||||||
TurnSchedule string
|
|
||||||
TargetEngineVersion string
|
|
||||||
EnrollmentEndsAt string
|
|
||||||
CreatedAt string
|
|
||||||
StartedAt string
|
|
||||||
FinishedAt string
|
|
||||||
|
|
||||||
HasRuntime bool
|
|
||||||
RuntimeStatus string
|
|
||||||
CurrentEngineVersion string
|
|
||||||
EngineHealth string
|
|
||||||
CurrentTurn int32
|
|
||||||
NextGenerationAt string
|
|
||||||
Paused bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// EngineVersionRow is one line in the engine-version registry table.
|
|
||||||
type EngineVersionRow struct {
|
|
||||||
Version string
|
|
||||||
ImageRef string
|
|
||||||
Enabled bool
|
|
||||||
CreatedAt string
|
|
||||||
}
|
|
||||||
|
|
||||||
// EngineVersionsData is the view model for the engine-version registry page.
|
|
||||||
type EngineVersionsData struct {
|
|
||||||
Items []EngineVersionRow
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
package adminconsole
|
|
||||||
|
|
||||||
// MailDeliveryRow is one line in the mail deliveries table.
|
|
||||||
type MailDeliveryRow struct {
|
|
||||||
DeliveryID string
|
|
||||||
Template string
|
|
||||||
Status string
|
|
||||||
Attempts int32
|
|
||||||
NextAttempt string
|
|
||||||
Created string
|
|
||||||
}
|
|
||||||
|
|
||||||
// MailDeadLetterRow is one line in the mail dead-letters table.
|
|
||||||
type MailDeadLetterRow struct {
|
|
||||||
DeliveryID string
|
|
||||||
Reason string
|
|
||||||
Archived string
|
|
||||||
}
|
|
||||||
|
|
||||||
// MailData is the view model for the mail page: a paginated deliveries list
|
|
||||||
// plus a snapshot of dead-letters.
|
|
||||||
type MailData struct {
|
|
||||||
Deliveries []MailDeliveryRow
|
|
||||||
DeadLetters []MailDeadLetterRow
|
|
||||||
Page int
|
|
||||||
PageSize int
|
|
||||||
Total int64
|
|
||||||
HasPrev bool
|
|
||||||
HasNext bool
|
|
||||||
PrevPage int
|
|
||||||
NextPage int
|
|
||||||
}
|
|
||||||
|
|
||||||
// MailAttemptRow is one delivery attempt on the mail detail page.
|
|
||||||
type MailAttemptRow struct {
|
|
||||||
AttemptNo int32
|
|
||||||
Outcome string
|
|
||||||
Started string
|
|
||||||
Finished string
|
|
||||||
Error string
|
|
||||||
}
|
|
||||||
|
|
||||||
// MailDeliveryDetail is the view model for a single delivery.
|
|
||||||
type MailDeliveryDetail struct {
|
|
||||||
DeliveryID string
|
|
||||||
Template string
|
|
||||||
Status string
|
|
||||||
Attempts int32
|
|
||||||
NextAttempt string
|
|
||||||
LastError string
|
|
||||||
Created string
|
|
||||||
Sent string
|
|
||||||
DeadLettered string
|
|
||||||
CanResend bool
|
|
||||||
AttemptRows []MailAttemptRow
|
|
||||||
}
|
|
||||||
|
|
||||||
// NotificationRow is one line in the notifications table.
|
|
||||||
type NotificationRow struct {
|
|
||||||
NotificationID string
|
|
||||||
Kind string
|
|
||||||
UserID string
|
|
||||||
Created string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NotificationDeadLetterRow is one line in the notification dead-letters table.
|
|
||||||
type NotificationDeadLetterRow struct {
|
|
||||||
NotificationID string
|
|
||||||
RouteID string
|
|
||||||
Reason string
|
|
||||||
Archived string
|
|
||||||
}
|
|
||||||
|
|
||||||
// MalformedRow is one line in the malformed-intents table.
|
|
||||||
type MalformedRow struct {
|
|
||||||
ID string
|
|
||||||
Reason string
|
|
||||||
Received string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NotificationsData is the view model for the notifications overview page.
|
|
||||||
type NotificationsData struct {
|
|
||||||
Notifications []NotificationRow
|
|
||||||
DeadLetters []NotificationDeadLetterRow
|
|
||||||
Malformed []MalformedRow
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package adminconsole
|
|
||||||
|
|
||||||
// MessageData is the view model for the generic message page used to render
|
|
||||||
// not-found, validation, and operation-failure notices. Class selects the CSS
|
|
||||||
// styling (for example "bad" for errors); BackHref, when set, renders a link
|
|
||||||
// back to a relevant page.
|
|
||||||
type MessageData struct {
|
|
||||||
Message string
|
|
||||||
Class string
|
|
||||||
BackHref string
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package adminconsole
|
|
||||||
|
|
||||||
// OperatorRow is one line in the operators (admin accounts) table.
|
|
||||||
type OperatorRow struct {
|
|
||||||
Username string
|
|
||||||
CreatedAt string
|
|
||||||
LastUsedAt string
|
|
||||||
Disabled bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// OperatorsData is the view model for the operators page.
|
|
||||||
type OperatorsData struct {
|
|
||||||
Items []OperatorRow
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
package adminconsole
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"embed"
|
|
||||||
"fmt"
|
|
||||||
"html/template"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed templates
|
|
||||||
var templatesFS embed.FS
|
|
||||||
|
|
||||||
//go:embed assets
|
|
||||||
var assetsFS embed.FS
|
|
||||||
|
|
||||||
// Renderer holds the parsed admin console templates. It composes one template
|
|
||||||
// set per content page, each combining the shared layout (defining the page
|
|
||||||
// chrome and the "layout" entry template) with that page's "content" block, so
|
|
||||||
// rendering a page is a single ExecuteTemplate call against the "layout" name.
|
|
||||||
type Renderer struct {
|
|
||||||
pages map[string]*template.Template
|
|
||||||
}
|
|
||||||
|
|
||||||
// PageData is the view model passed to every admin console page. Title is the
|
|
||||||
// document title; Username is the authenticated operator; CSRFToken is the
|
|
||||||
// per-operator token embedded into state-changing forms; ActiveNav marks the
|
|
||||||
// highlighted navigation entry; Data carries the page-specific payload.
|
|
||||||
type PageData struct {
|
|
||||||
Title string
|
|
||||||
Username string
|
|
||||||
CSRFToken string
|
|
||||||
ActiveNav string
|
|
||||||
Data any
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRenderer parses the embedded layout and every content page under
|
|
||||||
// templates/pages, returning a Renderer ready to serve them. It fails when a
|
|
||||||
// template cannot be parsed.
|
|
||||||
func NewRenderer() (*Renderer, error) {
|
|
||||||
base, err := template.New("layout").ParseFS(templatesFS, "templates/layout.gohtml")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parse admin console layout: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pageFiles, err := fs.Glob(templatesFS, "templates/pages/*.gohtml")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("enumerate admin console pages: %w", err)
|
|
||||||
}
|
|
||||||
if len(pageFiles) == 0 {
|
|
||||||
return nil, fmt.Errorf("admin console: no page templates found under templates/pages")
|
|
||||||
}
|
|
||||||
|
|
||||||
pages := make(map[string]*template.Template, len(pageFiles))
|
|
||||||
for _, file := range pageFiles {
|
|
||||||
name := strings.TrimSuffix(path.Base(file), ".gohtml")
|
|
||||||
clone, err := base.Clone()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("clone admin console layout for %q: %w", name, err)
|
|
||||||
}
|
|
||||||
if _, err := clone.ParseFS(templatesFS, file); err != nil {
|
|
||||||
return nil, fmt.Errorf("parse admin console page %q: %w", name, err)
|
|
||||||
}
|
|
||||||
pages[name] = clone
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Renderer{pages: pages}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MustNewRenderer is like NewRenderer but panics on error. The templates are
|
|
||||||
// embedded at build time, so a parse failure is a programmer error rather than
|
|
||||||
// a runtime condition.
|
|
||||||
func MustNewRenderer() *Renderer {
|
|
||||||
renderer, err := NewRenderer()
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return renderer
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render writes the named page, wrapped in the shared layout, to w using data.
|
|
||||||
// It returns an error when page is unknown or template execution fails; the
|
|
||||||
// page is rendered into an intermediate buffer first so a mid-render failure
|
|
||||||
// never emits a partial document to w.
|
|
||||||
func (r *Renderer) Render(w io.Writer, page string, data PageData) error {
|
|
||||||
tmpl, ok := r.pages[page]
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("admin console: unknown page %q", page)
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil {
|
|
||||||
return fmt.Errorf("render admin console page %q: %w", page, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := buf.WriteTo(w)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assets returns the embedded static asset tree rooted at the assets directory,
|
|
||||||
// suitable for serving under `/_gm/assets/`.
|
|
||||||
func Assets() (fs.FS, error) {
|
|
||||||
return fs.Sub(assetsFS, "assets")
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package adminconsole
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"io/fs"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestRendererRendersDashboard(t *testing.T) {
|
|
||||||
renderer, err := NewRenderer()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewRenderer: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
err = renderer.Render(&buf, "dashboard", PageData{
|
|
||||||
Title: "Dashboard",
|
|
||||||
Username: "ops-bob",
|
|
||||||
ActiveNav: "dashboard",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Render: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := buf.String()
|
|
||||||
for _, want := range []string{
|
|
||||||
"<!DOCTYPE html>",
|
|
||||||
"Dashboard",
|
|
||||||
"ops-bob",
|
|
||||||
`href="/_gm/users"`,
|
|
||||||
"/_gm/assets/console.css",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(out, want) {
|
|
||||||
t.Errorf("rendered page missing %q\n--- page ---\n%s", want, out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRendererUnknownPage(t *testing.T) {
|
|
||||||
renderer := MustNewRenderer()
|
|
||||||
if err := renderer.Render(&bytes.Buffer{}, "does-not-exist", PageData{}); err == nil {
|
|
||||||
t.Fatal("expected an error rendering an unknown page")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRendererEscapesUsername(t *testing.T) {
|
|
||||||
renderer := MustNewRenderer()
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := renderer.Render(&buf, "dashboard", PageData{Username: "<script>evil</script>"}); err != nil {
|
|
||||||
t.Fatalf("Render: %v", err)
|
|
||||||
}
|
|
||||||
if strings.Contains(buf.String(), "<script>evil</script>") {
|
|
||||||
t.Error("username was not HTML-escaped in the rendered page")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAssetsContainsStylesheet(t *testing.T) {
|
|
||||||
fsys, err := Assets()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Assets: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := fs.Stat(fsys, "console.css"); err != nil {
|
|
||||||
t.Fatalf("console.css missing from embedded assets: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{{define "layout" -}}
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<meta name="robots" content="noindex, nofollow">
|
|
||||||
<title>{{.Title}} · Galaxy GM</title>
|
|
||||||
<link rel="stylesheet" href="/_gm/assets/console.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header class="topbar">
|
|
||||||
<span class="brand">Galaxy · GM</span>
|
|
||||||
<nav class="mainnav">
|
|
||||||
<a href="/_gm/"{{if eq .ActiveNav "dashboard"}} class="active"{{end}}>Dashboard</a>
|
|
||||||
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
|
|
||||||
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
|
|
||||||
<a href="/_gm/operators"{{if eq .ActiveNav "operators"}} class="active"{{end}}>Operators</a>
|
|
||||||
<a href="/_gm/mail"{{if eq .ActiveNav "mail"}} class="active"{{end}}>Mail</a>
|
|
||||||
<a href="/_gm/grafana/" target="_blank" rel="noopener">Grafana</a>
|
|
||||||
<a href="/_gm/mailpit/" target="_blank" rel="noopener">Mailpit</a>
|
|
||||||
</nav>
|
|
||||||
<span class="who">{{.Username}}</span>
|
|
||||||
</header>
|
|
||||||
<main class="content">
|
|
||||||
{{template "content" .}}
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
{{- end}}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{{define "content" -}}
|
|
||||||
{{$csrf := .CSRFToken}}
|
|
||||||
<h1>Broadcast</h1>
|
|
||||||
<nav class="subnav"><a href="/_gm/mail">Deliveries</a> · <a href="/_gm/notifications">Notifications</a> · <a href="/_gm/broadcast">Broadcast</a></nav>
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Admin broadcast</h2>
|
|
||||||
<form method="post" action="/_gm/broadcast" class="form">
|
|
||||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
|
||||||
<label>Scope
|
|
||||||
<select name="scope"><option value="all_running">all running games</option><option value="selected">selected games</option></select>
|
|
||||||
</label>
|
|
||||||
<label>Game IDs (comma-separated, for "selected") <input type="text" name="game_ids" placeholder="uuid,uuid"></label>
|
|
||||||
<label>Recipients
|
|
||||||
<select name="recipients"><option value="active">active members</option><option value="active_and_removed">active and removed</option><option value="all_members">all members</option></select>
|
|
||||||
</label>
|
|
||||||
<label>Subject <input type="text" name="subject"></label>
|
|
||||||
<label>Body <input type="text" name="body" required></label>
|
|
||||||
<button type="submit">Send broadcast</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
{{- end}}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
{{define "content" -}}
|
|
||||||
<h1>Dashboard</h1>
|
|
||||||
<p class="lede">Signed in as <strong>{{.Username}}</strong>.</p>
|
|
||||||
{{with .Data}}
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Health</h2>
|
|
||||||
<ul class="kv">
|
|
||||||
<li>Backend ready: {{if .BackendReady}}<span class="ok">yes</span>{{else}}<span class="bad">no</span>{{end}}</li>
|
|
||||||
<li>Postgres: {{if .PostgresHealthy}}<span class="ok">healthy</span>{{else}}<span class="bad">unreachable</span>{{end}}</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
{{if .MonitorAvailable}}
|
|
||||||
<div class="grid">
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Game runtimes</h2>
|
|
||||||
{{template "statuscounts" .Runtimes}}
|
|
||||||
</section>
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Mail deliveries</h2>
|
|
||||||
{{template "statuscounts" .MailDeliveries}}
|
|
||||||
</section>
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Notification routes</h2>
|
|
||||||
{{template "statuscounts" .NotificationRoutes}}
|
|
||||||
</section>
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Malformed notifications</h2>
|
|
||||||
<p class="bignum {{if gt .NotificationMalformed 0}}bad{{end}}">{{.NotificationMalformed}}</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
{{if .Errors}}
|
|
||||||
<section class="panel errors">
|
|
||||||
<h2>Collection errors</h2>
|
|
||||||
<ul>{{range .Errors}}<li>{{.}}</li>{{end}}</ul>
|
|
||||||
</section>
|
|
||||||
{{end}}
|
|
||||||
{{else}}
|
|
||||||
<p class="note">Monitoring is not wired in this deployment.</p>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
<section class="cards">
|
|
||||||
<a class="card" href="/_gm/users">
|
|
||||||
<h2>Users</h2>
|
|
||||||
<p>Accounts, sanctions, entitlements, soft-delete.</p>
|
|
||||||
</a>
|
|
||||||
<a class="card" href="/_gm/games">
|
|
||||||
<h2>Games & runtimes</h2>
|
|
||||||
<p>Lobby state, engine versions, turn control.</p>
|
|
||||||
</a>
|
|
||||||
<a class="card" href="/_gm/operators">
|
|
||||||
<h2>Operators</h2>
|
|
||||||
<p>Admin accounts: create, disable, reset password.</p>
|
|
||||||
</a>
|
|
||||||
<a class="card" href="/_gm/mail">
|
|
||||||
<h2>Mail & notifications</h2>
|
|
||||||
<p>Deliveries, dead-letters, broadcasts.</p>
|
|
||||||
</a>
|
|
||||||
</section>
|
|
||||||
{{- end}}
|
|
||||||
|
|
||||||
{{define "statuscounts" -}}
|
|
||||||
{{if .}}
|
|
||||||
<table class="counts"><tbody>
|
|
||||||
{{range .}}<tr><td>{{.Status}}</td><td class="num">{{.Count}}</td></tr>{{end}}
|
|
||||||
</tbody></table>
|
|
||||||
{{else}}
|
|
||||||
<p class="note">none</p>
|
|
||||||
{{end}}
|
|
||||||
{{- end}}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{{define "content" -}}
|
|
||||||
{{$csrf := .CSRFToken}}
|
|
||||||
<h1>Engine versions</h1>
|
|
||||||
{{with .Data}}
|
|
||||||
<table class="list">
|
|
||||||
<thead><tr><th>Version</th><th>Image</th><th>Enabled</th><th>Created</th><th></th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .Items}}
|
|
||||||
<tr>
|
|
||||||
<td>{{.Version}}</td>
|
|
||||||
<td><code>{{.ImageRef}}</code></td>
|
|
||||||
<td>{{if .Enabled}}<span class="ok">yes</span>{{else}}<span class="bad">no</span>{{end}}</td>
|
|
||||||
<td>{{.CreatedAt}}</td>
|
|
||||||
<td>{{if .Enabled}}<form method="post" action="/_gm/engine-versions/{{.Version}}/disable" onsubmit="return confirm('Disable {{.Version}}?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit" class="danger">Disable</button></form>{{end}}</td>
|
|
||||||
</tr>
|
|
||||||
{{else}}<tr><td colspan="5"><span class="note">no engine versions</span></td></tr>{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{{end}}
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Register version</h2>
|
|
||||||
<form method="post" action="/_gm/engine-versions" class="form">
|
|
||||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
|
||||||
<label>Version <input type="text" name="version" placeholder="semver e.g. 0.1.0" required></label>
|
|
||||||
<label>Image ref <input type="text" name="image_ref" required></label>
|
|
||||||
<label>Enabled <input type="checkbox" name="enabled" value="true" checked></label>
|
|
||||||
<button type="submit">Register</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
{{- end}}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
{{define "content" -}}
|
|
||||||
{{$csrf := .CSRFToken}}
|
|
||||||
{{with .Data}}
|
|
||||||
<p><a href="/_gm/games">« all games</a></p>
|
|
||||||
<h1>{{if .GameName}}{{.GameName}}{{else}}(unnamed){{end}}</h1>
|
|
||||||
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Game</h2>
|
|
||||||
<ul class="kv">
|
|
||||||
<li>Game ID: <code>{{.GameID}}</code></li>
|
|
||||||
<li>Visibility: {{.Visibility}}</li>
|
|
||||||
<li>Status: {{.Status}}</li>
|
|
||||||
<li>Owner: {{.Owner}}</li>
|
|
||||||
<li>Players: {{.MinPlayers}}–{{.MaxPlayers}}</li>
|
|
||||||
<li>Start gap: {{.StartGapHours}}h / {{.StartGapPlayers}} players</li>
|
|
||||||
<li>Turn schedule: {{.TurnSchedule}}</li>
|
|
||||||
<li>Target engine: {{.TargetEngineVersion}}</li>
|
|
||||||
<li>Enrollment ends: {{.EnrollmentEndsAt}}</li>
|
|
||||||
<li>Created: {{.CreatedAt}}</li>
|
|
||||||
<li>Started: {{if .StartedAt}}{{.StartedAt}}{{else}}—{{end}}</li>
|
|
||||||
<li>Finished: {{if .FinishedAt}}{{.FinishedAt}}{{else}}—{{end}}</li>
|
|
||||||
</ul>
|
|
||||||
{{if .Description}}<p>{{.Description}}</p>{{end}}
|
|
||||||
<div class="actions">
|
|
||||||
<form method="post" action="/_gm/games/{{.GameID}}/force-start" onsubmit="return confirm('Force-start this game?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Force start</button></form>
|
|
||||||
<form method="post" action="/_gm/games/{{.GameID}}/force-stop" onsubmit="return confirm('Force-stop this game?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit" class="danger">Force stop</button></form>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Runtime</h2>
|
|
||||||
{{if .HasRuntime}}
|
|
||||||
<ul class="kv">
|
|
||||||
<li>Status: {{.RuntimeStatus}}</li>
|
|
||||||
<li>Engine version: {{.CurrentEngineVersion}}</li>
|
|
||||||
<li>Engine health: {{.EngineHealth}}</li>
|
|
||||||
<li>Current turn: {{.CurrentTurn}}</li>
|
|
||||||
<li>Next generation: {{if .NextGenerationAt}}{{.NextGenerationAt}}{{else}}—{{end}}</li>
|
|
||||||
<li>Paused: {{if .Paused}}yes{{else}}no{{end}}</li>
|
|
||||||
</ul>
|
|
||||||
<div class="actions">
|
|
||||||
<form method="post" action="/_gm/games/{{.GameID}}/runtime/restart" onsubmit="return confirm('Restart the engine container?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Restart</button></form>
|
|
||||||
<form method="post" action="/_gm/games/{{.GameID}}/runtime/force-next-turn" onsubmit="return confirm('Force the next turn now?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Force next turn</button></form>
|
|
||||||
</div>
|
|
||||||
<form method="post" action="/_gm/games/{{.GameID}}/runtime/patch" class="form">
|
|
||||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
|
||||||
<label>Patch to version <input type="text" name="target_version" placeholder="e.g. 0.1.1" required></label>
|
|
||||||
<button type="submit">Patch</button>
|
|
||||||
</form>
|
|
||||||
{{else}}
|
|
||||||
<p class="note">No runtime record for this game yet.</p>
|
|
||||||
{{end}}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Ban member</h2>
|
|
||||||
<form method="post" action="/_gm/games/{{.GameID}}/ban-member" class="form" onsubmit="return confirm('Ban this member from the game?');">
|
|
||||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
|
||||||
<label>User ID <input type="text" name="user_id" required></label>
|
|
||||||
<label>Reason <input type="text" name="reason"></label>
|
|
||||||
<button type="submit" class="danger">Ban member</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
{{end}}
|
|
||||||
{{- end}}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
{{define "content" -}}
|
|
||||||
{{$csrf := .CSRFToken}}
|
|
||||||
<h1>Games</h1>
|
|
||||||
{{with .Data}}
|
|
||||||
<table class="list">
|
|
||||||
<thead><tr><th>Name</th><th>Visibility</th><th>Status</th><th>Owner</th><th>Players</th><th>Schedule</th><th>Created</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .Items}}
|
|
||||||
<tr>
|
|
||||||
<td><a href="/_gm/games/{{.GameID}}">{{if .GameName}}{{.GameName}}{{else}}(unnamed){{end}}</a></td>
|
|
||||||
<td>{{.Visibility}}</td>
|
|
||||||
<td>{{.Status}}</td>
|
|
||||||
<td>{{.Owner}}</td>
|
|
||||||
<td>{{.Players}}</td>
|
|
||||||
<td>{{.TurnSchedule}}</td>
|
|
||||||
<td>{{.CreatedAt}}</td>
|
|
||||||
</tr>
|
|
||||||
{{else}}<tr><td colspan="7"><span class="note">no games</span></td></tr>{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<nav class="pager">
|
|
||||||
{{if .HasPrev}}<a href="/_gm/games?page={{.PrevPage}}&page_size={{.PageSize}}">« prev</a>{{end}}
|
|
||||||
<span>page {{.Page}} · {{.Total}} total</span>
|
|
||||||
{{if .HasNext}}<a href="/_gm/games?page={{.NextPage}}&page_size={{.PageSize}}">next »</a>{{end}}
|
|
||||||
</nav>
|
|
||||||
{{end}}
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Create public game</h2>
|
|
||||||
<form method="post" action="/_gm/games" class="form">
|
|
||||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
|
||||||
<label>Name <input type="text" name="game_name" required></label>
|
|
||||||
<label>Description <input type="text" name="description"></label>
|
|
||||||
<label>Min players <input type="number" name="min_players" value="2" min="1"></label>
|
|
||||||
<label>Max players <input type="number" name="max_players" value="8" min="1"></label>
|
|
||||||
<label>Start gap hours <input type="number" name="start_gap_hours" value="0" min="0"></label>
|
|
||||||
<label>Start gap players <input type="number" name="start_gap_players" value="0" min="0"></label>
|
|
||||||
<label>Enrollment ends <input type="datetime-local" name="enrollment_ends_at" required></label>
|
|
||||||
<label>Turn schedule <input type="text" name="turn_schedule" placeholder="e.g. @every 24h" required></label>
|
|
||||||
<label>Engine version <input type="text" name="target_engine_version" placeholder="e.g. 0.1.0" required></label>
|
|
||||||
<button type="submit">Create</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
{{- end}}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{{define "content" -}}
|
|
||||||
<h1>Mail</h1>
|
|
||||||
<nav class="subnav"><a href="/_gm/mail">Deliveries</a> · <a href="/_gm/notifications">Notifications</a> · <a href="/_gm/broadcast">Broadcast</a></nav>
|
|
||||||
{{with .Data}}
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Deliveries</h2>
|
|
||||||
<table class="list">
|
|
||||||
<thead><tr><th>Delivery</th><th>Template</th><th>Status</th><th>Attempts</th><th>Next attempt</th><th>Created</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .Deliveries}}
|
|
||||||
<tr><td><a href="/_gm/mail/deliveries/{{.DeliveryID}}"><code>{{.DeliveryID}}</code></a></td><td>{{.Template}}</td><td>{{.Status}}</td><td>{{.Attempts}}</td><td>{{if .NextAttempt}}{{.NextAttempt}}{{else}}—{{end}}</td><td>{{.Created}}</td></tr>
|
|
||||||
{{else}}<tr><td colspan="6"><span class="note">no deliveries</span></td></tr>{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<nav class="pager">
|
|
||||||
{{if .HasPrev}}<a href="/_gm/mail?page={{.PrevPage}}&page_size={{.PageSize}}">« prev</a>{{end}}
|
|
||||||
<span>page {{.Page}} · {{.Total}} total</span>
|
|
||||||
{{if .HasNext}}<a href="/_gm/mail?page={{.NextPage}}&page_size={{.PageSize}}">next »</a>{{end}}
|
|
||||||
</nav>
|
|
||||||
</section>
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Dead-letters</h2>
|
|
||||||
<table class="list">
|
|
||||||
<thead><tr><th>Delivery</th><th>Reason</th><th>Archived</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .DeadLetters}}<tr><td><a href="/_gm/mail/deliveries/{{.DeliveryID}}"><code>{{.DeliveryID}}</code></a></td><td>{{.Reason}}</td><td>{{.Archived}}</td></tr>
|
|
||||||
{{else}}<tr><td colspan="3"><span class="note">no dead-letters</span></td></tr>{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
{{end}}
|
|
||||||
{{- end}}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{{define "content" -}}
|
|
||||||
{{$csrf := .CSRFToken}}
|
|
||||||
{{with .Data}}
|
|
||||||
<p><a href="/_gm/mail">« mail</a></p>
|
|
||||||
<h1>Delivery</h1>
|
|
||||||
<section class="panel">
|
|
||||||
<ul class="kv">
|
|
||||||
<li>Delivery ID: <code>{{.DeliveryID}}</code></li>
|
|
||||||
<li>Template: {{.Template}}</li>
|
|
||||||
<li>Status: {{.Status}}</li>
|
|
||||||
<li>Attempts: {{.Attempts}}</li>
|
|
||||||
<li>Next attempt: {{if .NextAttempt}}{{.NextAttempt}}{{else}}—{{end}}</li>
|
|
||||||
<li>Created: {{.Created}}</li>
|
|
||||||
<li>Sent: {{if .Sent}}{{.Sent}}{{else}}—{{end}}</li>
|
|
||||||
<li>Dead-lettered: {{if .DeadLettered}}{{.DeadLettered}}{{else}}—{{end}}</li>
|
|
||||||
<li>Last error: {{if .LastError}}{{.LastError}}{{else}}—{{end}}</li>
|
|
||||||
</ul>
|
|
||||||
{{if .CanResend}}
|
|
||||||
<form method="post" action="/_gm/mail/deliveries/{{.DeliveryID}}/resend" class="form" onsubmit="return confirm('Resend this delivery?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Resend</button></form>
|
|
||||||
{{else}}<p class="note">Already sent — resend is not available.</p>{{end}}
|
|
||||||
</section>
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Attempts</h2>
|
|
||||||
<table class="list">
|
|
||||||
<thead><tr><th>#</th><th>Outcome</th><th>Started</th><th>Finished</th><th>Error</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .AttemptRows}}<tr><td>{{.AttemptNo}}</td><td>{{.Outcome}}</td><td>{{.Started}}</td><td>{{if .Finished}}{{.Finished}}{{else}}—{{end}}</td><td>{{.Error}}</td></tr>
|
|
||||||
{{else}}<tr><td colspan="5"><span class="note">no attempts</span></td></tr>{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
{{end}}
|
|
||||||
{{- end}}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{{define "content" -}}
|
|
||||||
<h1>{{.Title}}</h1>
|
|
||||||
{{with .Data}}
|
|
||||||
<p class="{{.Class}}">{{.Message}}</p>
|
|
||||||
{{if .BackHref}}<p><a href="{{.BackHref}}">« back</a></p>{{end}}
|
|
||||||
{{end}}
|
|
||||||
{{- end}}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{{define "content" -}}
|
|
||||||
<h1>Notifications</h1>
|
|
||||||
<nav class="subnav"><a href="/_gm/mail">Deliveries</a> · <a href="/_gm/notifications">Notifications</a> · <a href="/_gm/broadcast">Broadcast</a></nav>
|
|
||||||
{{with .Data}}
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Recent notifications</h2>
|
|
||||||
<table class="list"><thead><tr><th>ID</th><th>Kind</th><th>User</th><th>Created</th></tr></thead><tbody>
|
|
||||||
{{range .Notifications}}<tr><td><code>{{.NotificationID}}</code></td><td>{{.Kind}}</td><td>{{.UserID}}</td><td>{{.Created}}</td></tr>
|
|
||||||
{{else}}<tr><td colspan="4"><span class="note">none</span></td></tr>{{end}}
|
|
||||||
</tbody></table>
|
|
||||||
</section>
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Dead-letters</h2>
|
|
||||||
<table class="list"><thead><tr><th>Notification</th><th>Route</th><th>Reason</th><th>Archived</th></tr></thead><tbody>
|
|
||||||
{{range .DeadLetters}}<tr><td><code>{{.NotificationID}}</code></td><td><code>{{.RouteID}}</code></td><td>{{.Reason}}</td><td>{{.Archived}}</td></tr>
|
|
||||||
{{else}}<tr><td colspan="4"><span class="note">none</span></td></tr>{{end}}
|
|
||||||
</tbody></table>
|
|
||||||
</section>
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Malformed intents</h2>
|
|
||||||
<table class="list"><thead><tr><th>ID</th><th>Reason</th><th>Received</th></tr></thead><tbody>
|
|
||||||
{{range .Malformed}}<tr><td><code>{{.ID}}</code></td><td>{{.Reason}}</td><td>{{.Received}}</td></tr>
|
|
||||||
{{else}}<tr><td colspan="3"><span class="note">none</span></td></tr>{{end}}
|
|
||||||
</tbody></table>
|
|
||||||
</section>
|
|
||||||
{{end}}
|
|
||||||
{{- end}}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
{{define "content" -}}
|
|
||||||
{{$csrf := .CSRFToken}}
|
|
||||||
<h1>Operators</h1>
|
|
||||||
{{with .Data}}
|
|
||||||
<table class="list">
|
|
||||||
<thead><tr><th>Username</th><th>Status</th><th>Created</th><th>Last used</th><th>Actions</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .Items}}
|
|
||||||
<tr>
|
|
||||||
<td>{{.Username}}</td>
|
|
||||||
<td>{{if .Disabled}}<span class="bad">disabled</span>{{else}}<span class="ok">active</span>{{end}}</td>
|
|
||||||
<td>{{.CreatedAt}}</td>
|
|
||||||
<td>{{if .LastUsedAt}}{{.LastUsedAt}}{{else}}—{{end}}</td>
|
|
||||||
<td>
|
|
||||||
<div class="actions">
|
|
||||||
{{if .Disabled}}
|
|
||||||
<form method="post" action="/_gm/operators/{{.Username}}/enable"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Enable</button></form>
|
|
||||||
{{else}}
|
|
||||||
<form method="post" action="/_gm/operators/{{.Username}}/disable" onsubmit="return confirm('Disable {{.Username}}?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit" class="danger">Disable</button></form>
|
|
||||||
{{end}}
|
|
||||||
<form method="post" action="/_gm/operators/{{.Username}}/reset-password" class="form"><input type="hidden" name="_csrf" value="{{$csrf}}"><input type="password" name="password" placeholder="new password" required><button type="submit">Reset</button></form>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{else}}<tr><td colspan="5"><span class="note">no operators</span></td></tr>{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{{end}}
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Create operator</h2>
|
|
||||||
<form method="post" action="/_gm/operators" class="form">
|
|
||||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
|
||||||
<label>Username <input type="text" name="username" required></label>
|
|
||||||
<label>Password <input type="password" name="password" required></label>
|
|
||||||
<button type="submit">Create</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
{{- end}}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
{{define "content" -}}
|
|
||||||
{{$csrf := .CSRFToken}}
|
|
||||||
{{with .Data}}
|
|
||||||
<p><a href="/_gm/users">« all users</a></p>
|
|
||||||
<h1>{{.Email}}</h1>
|
|
||||||
{{if .Deleted}}<p class="bad">This account is soft-deleted.</p>{{end}}
|
|
||||||
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Account</h2>
|
|
||||||
<ul class="kv">
|
|
||||||
<li>User ID: <code>{{.UserID}}</code></li>
|
|
||||||
<li>User name: {{.UserName}}</li>
|
|
||||||
<li>Display name: {{.DisplayName}}</li>
|
|
||||||
<li>Preferred language: {{.PreferredLanguage}}</li>
|
|
||||||
<li>Time zone: {{.TimeZone}}</li>
|
|
||||||
<li>Declared country: {{.DeclaredCountry}}</li>
|
|
||||||
<li>Status: {{if .Blocked}}<span class="bad">blocked</span>{{else}}<span class="ok">active</span>{{end}}</li>
|
|
||||||
<li>Created: {{.CreatedAt}}</li>
|
|
||||||
<li>Updated: {{.UpdatedAt}}</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Entitlement</h2>
|
|
||||||
<ul class="kv">
|
|
||||||
<li>Tier: <strong>{{.Tier}}</strong> ({{if .IsPaid}}paid{{else}}free{{end}})</li>
|
|
||||||
<li>Source: {{.EntitlementSource}}</li>
|
|
||||||
<li>Reason: {{.EntitlementReason}}</li>
|
|
||||||
<li>Ends: {{if .EntitlementEnds}}{{.EntitlementEnds}}{{else}}—{{end}}</li>
|
|
||||||
</ul>
|
|
||||||
<form method="post" action="/_gm/users/{{.UserID}}/entitlement" class="form">
|
|
||||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
|
||||||
<label>Tier
|
|
||||||
<select name="tier">{{range .Tiers}}<option value="{{.}}">{{.}}</option>{{end}}</select>
|
|
||||||
</label>
|
|
||||||
<label>Source <input type="text" name="source" value="admin"></label>
|
|
||||||
<label>Reason <input type="text" name="reason_code" placeholder="optional"></label>
|
|
||||||
<button type="submit">Update entitlement</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Active sanctions</h2>
|
|
||||||
{{if .Sanctions}}
|
|
||||||
<table class="counts"><tbody>
|
|
||||||
{{range .Sanctions}}<tr><td>{{.SanctionCode}}</td><td>{{.Scope}}</td><td>{{.ReasonCode}}</td><td>{{.AppliedAt}}</td></tr>{{end}}
|
|
||||||
</tbody></table>
|
|
||||||
{{else}}<p class="note">none</p>{{end}}
|
|
||||||
{{if .Blocked}}
|
|
||||||
<p class="note">User is permanently blocked. Unblock is not available in the current admin API.</p>
|
|
||||||
{{else}}
|
|
||||||
<form method="post" action="/_gm/users/{{.UserID}}/block" class="form" onsubmit="return confirm('Permanently block this user?');">
|
|
||||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
|
||||||
<label>Reason <input type="text" name="reason_code" required></label>
|
|
||||||
<button type="submit" class="danger">Permanently block</button>
|
|
||||||
</form>
|
|
||||||
{{end}}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Danger zone</h2>
|
|
||||||
<form method="post" action="/_gm/users/{{.UserID}}/soft-delete" class="form" onsubmit="return confirm('Soft-delete this account? This cascades to sessions, memberships, and owned games.');">
|
|
||||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
|
||||||
<button type="submit" class="danger">Soft-delete account</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
{{end}}
|
|
||||||
{{- end}}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{{define "content" -}}
|
|
||||||
<h1>Users</h1>
|
|
||||||
{{with .Data}}
|
|
||||||
<table class="list">
|
|
||||||
<thead><tr><th>Email</th><th>User name</th><th>Display</th><th>Tier</th><th>Status</th><th>Created</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .Items}}
|
|
||||||
<tr>
|
|
||||||
<td><a href="/_gm/users/{{.UserID}}">{{.Email}}</a></td>
|
|
||||||
<td>{{.UserName}}</td>
|
|
||||||
<td>{{.DisplayName}}</td>
|
|
||||||
<td>{{.Tier}}</td>
|
|
||||||
<td>{{if .Deleted}}<span class="bad">deleted</span>{{else if .Blocked}}<span class="bad">blocked</span>{{else}}<span class="ok">active</span>{{end}}</td>
|
|
||||||
<td>{{.CreatedAt}}</td>
|
|
||||||
</tr>
|
|
||||||
{{else}}
|
|
||||||
<tr><td colspan="6"><span class="note">no users</span></td></tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<nav class="pager">
|
|
||||||
{{if .HasPrev}}<a href="/_gm/users?page={{.PrevPage}}&page_size={{.PageSize}}">« prev</a>{{end}}
|
|
||||||
<span>page {{.Page}} · {{.Total}} total</span>
|
|
||||||
{{if .HasNext}}<a href="/_gm/users?page={{.NextPage}}&page_size={{.PageSize}}">next »</a>{{end}}
|
|
||||||
</nav>
|
|
||||||
{{end}}
|
|
||||||
{{- end}}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
package adminconsole
|
|
||||||
|
|
||||||
// UserRow is one line in the users list table.
|
|
||||||
type UserRow struct {
|
|
||||||
UserID string
|
|
||||||
Email string
|
|
||||||
UserName string
|
|
||||||
DisplayName string
|
|
||||||
Tier string
|
|
||||||
Blocked bool
|
|
||||||
Deleted bool
|
|
||||||
CreatedAt string
|
|
||||||
}
|
|
||||||
|
|
||||||
// UsersListData is the view model for the paginated users list.
|
|
||||||
type UsersListData struct {
|
|
||||||
Items []UserRow
|
|
||||||
Page int
|
|
||||||
PageSize int
|
|
||||||
Total int
|
|
||||||
HasPrev bool
|
|
||||||
HasNext bool
|
|
||||||
PrevPage int
|
|
||||||
NextPage int
|
|
||||||
}
|
|
||||||
|
|
||||||
// SanctionView is one active sanction shown on the user detail page.
|
|
||||||
type SanctionView struct {
|
|
||||||
SanctionCode string
|
|
||||||
Scope string
|
|
||||||
ReasonCode string
|
|
||||||
AppliedAt string
|
|
||||||
ExpiresAt string
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserDetailData is the view model for a single user's detail page,
|
|
||||||
// combining the account aggregate with the form option lists.
|
|
||||||
type UserDetailData struct {
|
|
||||||
UserID string
|
|
||||||
Email string
|
|
||||||
UserName string
|
|
||||||
DisplayName string
|
|
||||||
PreferredLanguage string
|
|
||||||
TimeZone string
|
|
||||||
DeclaredCountry string
|
|
||||||
Blocked bool
|
|
||||||
Deleted bool
|
|
||||||
CreatedAt string
|
|
||||||
UpdatedAt string
|
|
||||||
|
|
||||||
Tier string
|
|
||||||
IsPaid bool
|
|
||||||
EntitlementSource string
|
|
||||||
EntitlementReason string
|
|
||||||
EntitlementEnds string
|
|
||||||
|
|
||||||
Sanctions []SanctionView
|
|
||||||
|
|
||||||
// Tiers lists the selectable entitlement tiers for the form.
|
|
||||||
Tiers []string
|
|
||||||
}
|
|
||||||
@@ -513,52 +513,6 @@ func TestConfirmEmailCodeWrongCode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConfirmEmailCodeDevFixedCodeBypassesAttemptsCeiling proves the
|
|
||||||
// dev-mode override is a true escape hatch: a developer who already
|
|
||||||
// burned past ChallengeMaxAttempts on a long-lived dev challenge
|
|
||||||
// (typically because the throttle merged repeated send-email-code
|
|
||||||
// calls onto one challenge_id) can still recover by submitting the
|
|
||||||
// fixed code without first waiting out the challenge TTL.
|
|
||||||
func TestConfirmEmailCodeDevFixedCodeBypassesAttemptsCeiling(t *testing.T) {
|
|
||||||
db := startPostgres(t)
|
|
||||||
cfg := authConfig()
|
|
||||||
cfg.DevFixedCode = "999999"
|
|
||||||
svc := buildServiceWithConfig(t, db, cfg)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
id, err := svc.SendEmailCode(ctx, "dev-bypass-ceiling@example.test", "en", "", "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("send: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Burn through the attempts ceiling with deliberately wrong codes.
|
|
||||||
for i := range cfg.ChallengeMaxAttempts + 1 {
|
|
||||||
_, err := svc.ConfirmEmailCode(ctx, auth.ConfirmInputs{
|
|
||||||
ChallengeID: id,
|
|
||||||
Code: "111111",
|
|
||||||
ClientPublicKey: randomKey(t),
|
|
||||||
TimeZone: "UTC",
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("attempt %d unexpectedly succeeded", i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The dev-fixed code still goes through.
|
|
||||||
session, err := svc.ConfirmEmailCode(ctx, auth.ConfirmInputs{
|
|
||||||
ChallengeID: id,
|
|
||||||
Code: "999999",
|
|
||||||
ClientPublicKey: randomKey(t),
|
|
||||||
TimeZone: "UTC",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("dev-fixed-code after attempts exhausted: %v", err)
|
|
||||||
}
|
|
||||||
if session.DeviceSessionID == uuid.Nil {
|
|
||||||
t.Fatalf("dev-fixed-code did not produce a session")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfirmEmailCodeAttemptsCeiling(t *testing.T) {
|
func TestConfirmEmailCodeAttemptsCeiling(t *testing.T) {
|
||||||
db := startPostgres(t)
|
db := startPostgres(t)
|
||||||
svc, mailer, _, _ := buildService(t, db)
|
svc, mailer, _, _ := buildService(t, db)
|
||||||
|
|||||||
@@ -163,21 +163,6 @@ func (s *Service) ConfirmEmailCode(ctx context.Context, in ConfirmInputs) (Sessi
|
|||||||
return Session{}, err
|
return Session{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// The dev-mode fixed-code override is checked first so it bypasses
|
|
||||||
// both the bcrypt verify and the per-challenge attempts ceiling.
|
|
||||||
// Without this, a developer who already burned through
|
|
||||||
// `ChallengeMaxAttempts` on an existing un-consumed challenge —
|
|
||||||
// for example after the throttle merged repeated send-email-code
|
|
||||||
// calls onto one challenge_id — could not recover with the fixed
|
|
||||||
// code either, defeating the purpose of the override. Production
|
|
||||||
// deployments leave `DevFixedCode` empty, so this branch is
|
|
||||||
// inert and the regular attempts gate still applies.
|
|
||||||
if s.devFixedCodeMatches(in.Code) {
|
|
||||||
s.deps.Logger.Warn("auth challenge accepted via dev-mode fixed code override",
|
|
||||||
zap.String("challenge_id", in.ChallengeID.String()),
|
|
||||||
zap.Int32("attempts", loaded.Attempts),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
if int(loaded.Attempts) > s.deps.Config.ChallengeMaxAttempts {
|
if int(loaded.Attempts) > s.deps.Config.ChallengeMaxAttempts {
|
||||||
s.deps.Logger.Info("auth challenge attempts exhausted",
|
s.deps.Logger.Info("auth challenge attempts exhausted",
|
||||||
zap.String("challenge_id", in.ChallengeID.String()),
|
zap.String("challenge_id", in.ChallengeID.String()),
|
||||||
@@ -185,6 +170,8 @@ func (s *Service) ConfirmEmailCode(ctx context.Context, in ConfirmInputs) (Sessi
|
|||||||
)
|
)
|
||||||
return Session{}, ErrTooManyAttempts
|
return Session{}, ErrTooManyAttempts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !s.devFixedCodeMatches(in.Code) {
|
||||||
if err := verifyCode(loaded.CodeHash, in.Code); err != nil {
|
if err := verifyCode(loaded.CodeHash, in.Code); err != nil {
|
||||||
if errors.Is(err, ErrCodeMismatch) {
|
if errors.Is(err, ErrCodeMismatch) {
|
||||||
s.deps.Logger.Info("auth challenge code mismatch",
|
s.deps.Logger.Info("auth challenge code mismatch",
|
||||||
@@ -195,6 +182,10 @@ func (s *Service) ConfirmEmailCode(ctx context.Context, in ConfirmInputs) (Sessi
|
|||||||
}
|
}
|
||||||
return Session{}, err
|
return Session{}, err
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
s.deps.Logger.Warn("auth challenge accepted via dev-mode fixed code override",
|
||||||
|
zap.String("challenge_id", in.ChallengeID.String()),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-check permanent_block after verifying the code. SendEmailCode
|
// Re-check permanent_block after verifying the code. SendEmailCode
|
||||||
|
|||||||
@@ -55,8 +55,6 @@ const (
|
|||||||
envAdminBootstrapUser = "BACKEND_ADMIN_BOOTSTRAP_USER"
|
envAdminBootstrapUser = "BACKEND_ADMIN_BOOTSTRAP_USER"
|
||||||
envAdminBootstrapPassword = "BACKEND_ADMIN_BOOTSTRAP_PASSWORD"
|
envAdminBootstrapPassword = "BACKEND_ADMIN_BOOTSTRAP_PASSWORD"
|
||||||
|
|
||||||
envAdminConsoleCSRFKey = "BACKEND_ADMIN_CONSOLE_CSRF_KEY"
|
|
||||||
|
|
||||||
envGeoIPDBPath = "BACKEND_GEOIP_DB_PATH"
|
envGeoIPDBPath = "BACKEND_GEOIP_DB_PATH"
|
||||||
|
|
||||||
envOTelTracesExporter = "BACKEND_OTEL_TRACES_EXPORTER"
|
envOTelTracesExporter = "BACKEND_OTEL_TRACES_EXPORTER"
|
||||||
@@ -93,7 +91,6 @@ const (
|
|||||||
envRuntimeContainerPIDsLimit = "BACKEND_RUNTIME_CONTAINER_PIDS_LIMIT"
|
envRuntimeContainerPIDsLimit = "BACKEND_RUNTIME_CONTAINER_PIDS_LIMIT"
|
||||||
envRuntimeContainerStateMount = "BACKEND_RUNTIME_CONTAINER_STATE_MOUNT"
|
envRuntimeContainerStateMount = "BACKEND_RUNTIME_CONTAINER_STATE_MOUNT"
|
||||||
envRuntimeStopGracePeriod = "BACKEND_RUNTIME_STOP_GRACE_PERIOD"
|
envRuntimeStopGracePeriod = "BACKEND_RUNTIME_STOP_GRACE_PERIOD"
|
||||||
envRuntimeStackLabel = "BACKEND_STACK_LABEL"
|
|
||||||
|
|
||||||
envNotificationAdminEmail = "BACKEND_NOTIFICATION_ADMIN_EMAIL"
|
envNotificationAdminEmail = "BACKEND_NOTIFICATION_ADMIN_EMAIL"
|
||||||
envNotificationWorkerInterval = "BACKEND_NOTIFICATION_WORKER_INTERVAL"
|
envNotificationWorkerInterval = "BACKEND_NOTIFICATION_WORKER_INTERVAL"
|
||||||
@@ -105,6 +102,11 @@ const (
|
|||||||
envDiplomailTranslatorTimeout = "BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT"
|
envDiplomailTranslatorTimeout = "BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT"
|
||||||
envDiplomailTranslatorMaxAttempts = "BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS"
|
envDiplomailTranslatorMaxAttempts = "BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS"
|
||||||
envDiplomailWorkerInterval = "BACKEND_DIPLOMAIL_WORKER_INTERVAL"
|
envDiplomailWorkerInterval = "BACKEND_DIPLOMAIL_WORKER_INTERVAL"
|
||||||
|
|
||||||
|
envDevSandboxEmail = "BACKEND_DEV_SANDBOX_EMAIL"
|
||||||
|
envDevSandboxEngineImage = "BACKEND_DEV_SANDBOX_ENGINE_IMAGE"
|
||||||
|
envDevSandboxEngineVersion = "BACKEND_DEV_SANDBOX_ENGINE_VERSION"
|
||||||
|
envDevSandboxPlayerCount = "BACKEND_DEV_SANDBOX_PLAYER_COUNT"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Default values applied when an environment variable is absent.
|
// Default values applied when an environment variable is absent.
|
||||||
@@ -173,6 +175,9 @@ const (
|
|||||||
defaultDiplomailTranslatorTimeout = 10 * time.Second
|
defaultDiplomailTranslatorTimeout = 10 * time.Second
|
||||||
defaultDiplomailTranslatorMaxAttempts = 5
|
defaultDiplomailTranslatorMaxAttempts = 5
|
||||||
defaultDiplomailWorkerInterval = 2 * time.Second
|
defaultDiplomailWorkerInterval = 2 * time.Second
|
||||||
|
|
||||||
|
defaultDevSandboxEngineVersion = "0.1.0"
|
||||||
|
defaultDevSandboxPlayerCount = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
// Allowed values for the closed-set string options.
|
// Allowed values for the closed-set string options.
|
||||||
@@ -202,7 +207,6 @@ type Config struct {
|
|||||||
Docker DockerConfig
|
Docker DockerConfig
|
||||||
Game GameConfig
|
Game GameConfig
|
||||||
Admin AdminBootstrapConfig
|
Admin AdminBootstrapConfig
|
||||||
AdminConsole AdminConsoleConfig
|
|
||||||
GeoIP GeoIPConfig
|
GeoIP GeoIPConfig
|
||||||
Telemetry TelemetryConfig
|
Telemetry TelemetryConfig
|
||||||
Auth AuthConfig
|
Auth AuthConfig
|
||||||
@@ -211,12 +215,29 @@ type Config struct {
|
|||||||
Runtime RuntimeConfig
|
Runtime RuntimeConfig
|
||||||
Notification NotificationConfig
|
Notification NotificationConfig
|
||||||
Diplomail DiplomailConfig
|
Diplomail DiplomailConfig
|
||||||
|
DevSandbox DevSandboxConfig
|
||||||
|
|
||||||
// FreshnessWindow mirrors the gateway freshness window and is used by the
|
// FreshnessWindow mirrors the gateway freshness window and is used by the
|
||||||
// push server to bound the cursor TTL.
|
// push server to bound the cursor TTL.
|
||||||
FreshnessWindow time.Duration
|
FreshnessWindow time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DevSandboxConfig configures the boot-time bootstrap implemented in
|
||||||
|
// `backend/internal/devsandbox`. When Email is empty the bootstrap
|
||||||
|
// is a no-op, which is the production posture. When Email is set —
|
||||||
|
// from `BACKEND_DEV_SANDBOX_EMAIL` in the `tools/local-dev` stack —
|
||||||
|
// the bootstrap idempotently provisions a real user, the configured
|
||||||
|
// number of dummy participants, a private "Dev Sandbox" game, the
|
||||||
|
// matching memberships, and drives the lifecycle to `running`. The
|
||||||
|
// engine image and engine version refer to a row that the bootstrap
|
||||||
|
// also seeds in `engine_versions`.
|
||||||
|
type DevSandboxConfig struct {
|
||||||
|
Email string
|
||||||
|
EngineImage string
|
||||||
|
EngineVersion string
|
||||||
|
PlayerCount int
|
||||||
|
}
|
||||||
|
|
||||||
// LoggingConfig stores the parameters used by the structured logger.
|
// LoggingConfig stores the parameters used by the structured logger.
|
||||||
type LoggingConfig struct {
|
type LoggingConfig struct {
|
||||||
// Level is the zap level name (e.g. "debug", "info", "warn", "error").
|
// Level is the zap level name (e.g. "debug", "info", "warn", "error").
|
||||||
@@ -286,15 +307,6 @@ type AdminBootstrapConfig struct {
|
|||||||
Password string
|
Password string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminConsoleConfig configures the server-rendered operator console.
|
|
||||||
// CSRFKey is the secret keying the console's stateless anti-CSRF token.
|
|
||||||
// When empty the console falls back to a per-process random key, which is
|
|
||||||
// secure but means forms do not survive a restart and do not validate across
|
|
||||||
// replicas; set a shared key when running more than one backend instance.
|
|
||||||
type AdminConsoleConfig struct {
|
|
||||||
CSRFKey string
|
|
||||||
}
|
|
||||||
|
|
||||||
// GeoIPConfig configures the GeoLite2 country database used by geo lookups.
|
// GeoIPConfig configures the GeoLite2 country database used by geo lookups.
|
||||||
type GeoIPConfig struct {
|
type GeoIPConfig struct {
|
||||||
DBPath string
|
DBPath string
|
||||||
@@ -397,14 +409,6 @@ type RuntimeConfig struct {
|
|||||||
// StopGracePeriod is the docker stop SIGTERM-to-SIGKILL grace period
|
// StopGracePeriod is the docker stop SIGTERM-to-SIGKILL grace period
|
||||||
// applied during stop / cancel / restart / patch.
|
// applied during stop / cancel / restart / patch.
|
||||||
StopGracePeriod time.Duration
|
StopGracePeriod time.Duration
|
||||||
|
|
||||||
// StackLabel is the optional value backend stamps as
|
|
||||||
// `galaxy.stack=<value>` on every engine container it spawns. It
|
|
||||||
// lets host-side tooling (Makefile, CI workflows) scope cleanup
|
|
||||||
// operations to a single dev stack without touching unrelated
|
|
||||||
// workloads on the same Docker daemon. When empty, the label is
|
|
||||||
// not applied.
|
|
||||||
StackLabel string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DiplomailConfig bounds the diplomatic-mail subsystem. Both limits
|
// DiplomailConfig bounds the diplomatic-mail subsystem. Both limits
|
||||||
@@ -547,6 +551,10 @@ func DefaultConfig() Config {
|
|||||||
TranslatorMaxAttempts: defaultDiplomailTranslatorMaxAttempts,
|
TranslatorMaxAttempts: defaultDiplomailTranslatorMaxAttempts,
|
||||||
WorkerInterval: defaultDiplomailWorkerInterval,
|
WorkerInterval: defaultDiplomailWorkerInterval,
|
||||||
},
|
},
|
||||||
|
DevSandbox: DevSandboxConfig{
|
||||||
|
EngineVersion: defaultDevSandboxEngineVersion,
|
||||||
|
PlayerCount: defaultDevSandboxPlayerCount,
|
||||||
|
},
|
||||||
Runtime: RuntimeConfig{
|
Runtime: RuntimeConfig{
|
||||||
WorkerPoolSize: defaultRuntimeWorkerPoolSize,
|
WorkerPoolSize: defaultRuntimeWorkerPoolSize,
|
||||||
JobQueueSize: defaultRuntimeJobQueueSize,
|
JobQueueSize: defaultRuntimeJobQueueSize,
|
||||||
@@ -627,8 +635,6 @@ func LoadFromEnv() (Config, error) {
|
|||||||
cfg.Admin.User = loadString(envAdminBootstrapUser, cfg.Admin.User)
|
cfg.Admin.User = loadString(envAdminBootstrapUser, cfg.Admin.User)
|
||||||
cfg.Admin.Password = loadString(envAdminBootstrapPassword, cfg.Admin.Password)
|
cfg.Admin.Password = loadString(envAdminBootstrapPassword, cfg.Admin.Password)
|
||||||
|
|
||||||
cfg.AdminConsole.CSRFKey = loadString(envAdminConsoleCSRFKey, cfg.AdminConsole.CSRFKey)
|
|
||||||
|
|
||||||
cfg.GeoIP.DBPath = loadString(envGeoIPDBPath, cfg.GeoIP.DBPath)
|
cfg.GeoIP.DBPath = loadString(envGeoIPDBPath, cfg.GeoIP.DBPath)
|
||||||
|
|
||||||
cfg.Telemetry.TracesExporter = strings.ToLower(loadString(envOTelTracesExporter, cfg.Telemetry.TracesExporter))
|
cfg.Telemetry.TracesExporter = strings.ToLower(loadString(envOTelTracesExporter, cfg.Telemetry.TracesExporter))
|
||||||
@@ -699,7 +705,6 @@ func LoadFromEnv() (Config, error) {
|
|||||||
if cfg.Runtime.StopGracePeriod, err = loadDuration(envRuntimeStopGracePeriod, cfg.Runtime.StopGracePeriod); err != nil {
|
if cfg.Runtime.StopGracePeriod, err = loadDuration(envRuntimeStopGracePeriod, cfg.Runtime.StopGracePeriod); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
cfg.Runtime.StackLabel = strings.TrimSpace(loadString(envRuntimeStackLabel, cfg.Runtime.StackLabel))
|
|
||||||
|
|
||||||
cfg.Notification.AdminEmail = loadString(envNotificationAdminEmail, cfg.Notification.AdminEmail)
|
cfg.Notification.AdminEmail = loadString(envNotificationAdminEmail, cfg.Notification.AdminEmail)
|
||||||
if cfg.Notification.WorkerInterval, err = loadDuration(envNotificationWorkerInterval, cfg.Notification.WorkerInterval); err != nil {
|
if cfg.Notification.WorkerInterval, err = loadDuration(envNotificationWorkerInterval, cfg.Notification.WorkerInterval); err != nil {
|
||||||
@@ -726,6 +731,13 @@ func LoadFromEnv() (Config, error) {
|
|||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg.DevSandbox.Email = strings.TrimSpace(loadString(envDevSandboxEmail, cfg.DevSandbox.Email))
|
||||||
|
cfg.DevSandbox.EngineImage = strings.TrimSpace(loadString(envDevSandboxEngineImage, cfg.DevSandbox.EngineImage))
|
||||||
|
cfg.DevSandbox.EngineVersion = strings.TrimSpace(loadString(envDevSandboxEngineVersion, cfg.DevSandbox.EngineVersion))
|
||||||
|
if cfg.DevSandbox.PlayerCount, err = loadInt(envDevSandboxPlayerCount, cfg.DevSandbox.PlayerCount); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
@@ -937,6 +949,21 @@ func (c Config) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if email := strings.TrimSpace(c.DevSandbox.Email); email != "" {
|
||||||
|
if _, err := netmail.ParseAddress(email); err != nil {
|
||||||
|
return fmt.Errorf("%s must be a valid RFC 5322 address: %w", envDevSandboxEmail, err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.DevSandbox.EngineImage) == "" {
|
||||||
|
return fmt.Errorf("%s must not be empty when %s is set", envDevSandboxEngineImage, envDevSandboxEmail)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.DevSandbox.EngineVersion) == "" {
|
||||||
|
return fmt.Errorf("%s must not be empty when %s is set", envDevSandboxEngineVersion, envDevSandboxEmail)
|
||||||
|
}
|
||||||
|
if c.DevSandbox.PlayerCount <= 0 {
|
||||||
|
return fmt.Errorf("%s must be positive when %s is set", envDevSandboxPlayerCount, envDevSandboxEmail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
// Package devsandbox provisions a ready-to-play game on backend boot
|
||||||
|
// for the `tools/local-dev` stack.
|
||||||
|
//
|
||||||
|
// Bootstrap is invoked from `backend/cmd/backend/main.go` after the
|
||||||
|
// admin bootstrap and before the HTTP listener starts. It reads
|
||||||
|
// `cfg.DevSandbox`; when `Email` is empty (the production posture)
|
||||||
|
// the function logs "skipped" and returns nil. When set, it
|
||||||
|
// idempotently:
|
||||||
|
//
|
||||||
|
// 1. registers the configured engine version and image;
|
||||||
|
// 2. find-or-creates the real dev user with the configured email;
|
||||||
|
// 3. find-or-creates `cfg.PlayerCount - 1` deterministic dummy
|
||||||
|
// users so the engine's minimum-players constraint is met;
|
||||||
|
// 4. find-or-creates a private "Dev Sandbox" game owned by the
|
||||||
|
// real user with min/max_players = cfg.PlayerCount and a
|
||||||
|
// year-out turn schedule (effectively frozen at turn 1);
|
||||||
|
// 5. inserts memberships for all participants bypassing the
|
||||||
|
// application/approval flow;
|
||||||
|
// 6. drives the lifecycle to `running` (or as far as possible if
|
||||||
|
// the runtime is busy).
|
||||||
|
//
|
||||||
|
// The function is a no-op on subsequent boots once the game is
|
||||||
|
// running; partial states from earlier crashes are recovered.
|
||||||
|
package devsandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"galaxy/backend/internal/config"
|
||||||
|
"galaxy/backend/internal/lobby"
|
||||||
|
"galaxy/backend/internal/runtime"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SandboxGameName is the display name used to identify the
|
||||||
|
// auto-provisioned game on subsequent reboots. The combination of
|
||||||
|
// game_name and owner_user_id is unique enough in practice — only
|
||||||
|
// the dev sandbox bootstrap creates a game owned by the configured
|
||||||
|
// real user with this exact name.
|
||||||
|
const SandboxGameName = "Dev Sandbox"
|
||||||
|
|
||||||
|
// SandboxTurnSchedule keeps the game on turn 1 by scheduling the
|
||||||
|
// next turn a year out. The runtime scheduler still parses this and
|
||||||
|
// will tick once a year — long enough to never interfere with
|
||||||
|
// solo UI development.
|
||||||
|
const SandboxTurnSchedule = "0 0 1 1 *"
|
||||||
|
|
||||||
|
// UserEnsurer matches `auth.UserEnsurer`. We define a local
|
||||||
|
// interface to avoid importing the auth package and circular
|
||||||
|
// dependencies — the production wiring passes the same `*user.Service`
|
||||||
|
// instance used by auth.
|
||||||
|
type UserEnsurer interface {
|
||||||
|
EnsureByEmail(ctx context.Context, email, preferredLanguage, timeZone, declaredCountry string) (uuid.UUID, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deps aggregates the collaborators Bootstrap needs.
|
||||||
|
type Deps struct {
|
||||||
|
Users UserEnsurer
|
||||||
|
Lobby *lobby.Service
|
||||||
|
EngineVersions *runtime.EngineVersionService
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap runs the seven-step provisioning flow described on the
|
||||||
|
// package doc comment. Errors are returned to the caller; the boot
|
||||||
|
// path in `cmd/backend/main.go` aborts startup if Bootstrap fails so
|
||||||
|
// a misconfigured dev environment surfaces immediately rather than
|
||||||
|
// silently leaving the lobby empty.
|
||||||
|
func Bootstrap(ctx context.Context, deps Deps, cfg config.DevSandboxConfig, logger *zap.Logger) error {
|
||||||
|
if logger == nil {
|
||||||
|
logger = zap.NewNop()
|
||||||
|
}
|
||||||
|
logger = logger.Named("dev_sandbox")
|
||||||
|
|
||||||
|
if cfg.Email == "" {
|
||||||
|
logger.Info("skipped (no email)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if deps.Users == nil || deps.Lobby == nil || deps.EngineVersions == nil {
|
||||||
|
return errors.New("dev_sandbox: deps.Users, deps.Lobby and deps.EngineVersions are required")
|
||||||
|
}
|
||||||
|
if cfg.PlayerCount <= 0 {
|
||||||
|
return fmt.Errorf("dev_sandbox: PlayerCount must be positive, got %d", cfg.PlayerCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ensureEngineVersion(ctx, deps.EngineVersions, cfg, logger); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
realID, err := deps.Users.EnsureByEmail(ctx, cfg.Email, "en", "UTC", "")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dev_sandbox: ensure real user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dummyIDs := make([]uuid.UUID, 0, cfg.PlayerCount-1)
|
||||||
|
for i := 1; i < cfg.PlayerCount; i++ {
|
||||||
|
email := fmt.Sprintf("dev-dummy-%02d@local.test", i)
|
||||||
|
id, err := deps.Users.EnsureByEmail(ctx, email, "en", "UTC", "")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dev_sandbox: ensure dummy %d: %w", i, err)
|
||||||
|
}
|
||||||
|
dummyIDs = append(dummyIDs, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := purgeTerminalSandboxGames(ctx, deps.Lobby, realID, logger); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
game, err := findOrCreateSandboxGame(ctx, deps.Lobby, realID, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
game, err = ensureMembershipsAndDrive(ctx, deps.Lobby, game, realID, dummyIDs, logger)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("bootstrap complete",
|
||||||
|
zap.String("user_id", realID.String()),
|
||||||
|
zap.String("game_id", game.GameID.String()),
|
||||||
|
zap.String("status", game.Status),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureEngineVersion(ctx context.Context, svc *runtime.EngineVersionService, cfg config.DevSandboxConfig, logger *zap.Logger) error {
|
||||||
|
_, err := svc.Register(ctx, runtime.RegisterInput{
|
||||||
|
Version: cfg.EngineVersion,
|
||||||
|
ImageRef: cfg.EngineImage,
|
||||||
|
})
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
logger.Info("engine version registered",
|
||||||
|
zap.String("version", cfg.EngineVersion),
|
||||||
|
zap.String("image", cfg.EngineImage),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
case errors.Is(err, runtime.ErrEngineVersionTaken):
|
||||||
|
logger.Debug("engine version already registered",
|
||||||
|
zap.String("version", cfg.EngineVersion),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("dev_sandbox: register engine version: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// terminalSandboxStatus reports whether a sandbox game has reached a
|
||||||
|
// state from which it can no longer be driven back to running. We
|
||||||
|
// treat such games as "absent" so the next bootstrap creates a fresh
|
||||||
|
// one rather than handing the developer a dead lobby tile.
|
||||||
|
func terminalSandboxStatus(status string) bool {
|
||||||
|
switch status {
|
||||||
|
case lobby.GameStatusCancelled, lobby.GameStatusFinished, lobby.GameStatusStartFailed:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// purgeTerminalSandboxGames deletes every previous "Dev Sandbox" game
|
||||||
|
// the dev user owns that has reached a terminal state
|
||||||
|
// (cancelled / finished / start_failed). The cascade declared in
|
||||||
|
// `00001_init.sql` removes the matching memberships, applications,
|
||||||
|
// invites, runtime records, and player mappings in the same write,
|
||||||
|
// so the developer's lobby never piles up dead tiles between
|
||||||
|
// `make rebuild` cycles. Non-terminal games are left untouched —
|
||||||
|
// a `running` sandbox from a previous boot is the happy path.
|
||||||
|
func purgeTerminalSandboxGames(ctx context.Context, svc *lobby.Service, ownerID uuid.UUID, logger *zap.Logger) error {
|
||||||
|
games, err := svc.ListMyGames(ctx, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dev_sandbox: list my games: %w", err)
|
||||||
|
}
|
||||||
|
for _, g := range games {
|
||||||
|
if g.GameName != SandboxGameName || g.OwnerUserID == nil || *g.OwnerUserID != ownerID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !terminalSandboxStatus(g.Status) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := svc.DeleteGame(ctx, g.GameID); err != nil {
|
||||||
|
return fmt.Errorf("dev_sandbox: delete terminal sandbox %s: %w", g.GameID, err)
|
||||||
|
}
|
||||||
|
logger.Info("purged terminal sandbox game",
|
||||||
|
zap.String("game_id", g.GameID.String()),
|
||||||
|
zap.String("status", g.Status),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findOrCreateSandboxGame(ctx context.Context, svc *lobby.Service, ownerID uuid.UUID, cfg config.DevSandboxConfig) (lobby.GameRecord, error) {
|
||||||
|
games, err := svc.ListMyGames(ctx, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return lobby.GameRecord{}, fmt.Errorf("dev_sandbox: list my games: %w", err)
|
||||||
|
}
|
||||||
|
for _, g := range games {
|
||||||
|
if g.GameName != SandboxGameName || g.OwnerUserID == nil || *g.OwnerUserID != ownerID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// `purgeTerminalSandboxGames` ran before us, so any sandbox
|
||||||
|
// game still in the list is either a live one we should
|
||||||
|
// reuse or a transient state we can drive forward.
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
rec, err := svc.CreateGame(ctx, lobby.CreateGameInput{
|
||||||
|
OwnerUserID: &ownerID,
|
||||||
|
Visibility: lobby.VisibilityPrivate,
|
||||||
|
GameName: SandboxGameName,
|
||||||
|
Description: "Auto-provisioned by backend/internal/devsandbox for solo UI development.",
|
||||||
|
MinPlayers: int32(cfg.PlayerCount),
|
||||||
|
MaxPlayers: int32(cfg.PlayerCount),
|
||||||
|
StartGapHours: 0,
|
||||||
|
StartGapPlayers: 0,
|
||||||
|
EnrollmentEndsAt: time.Now().Add(365 * 24 * time.Hour),
|
||||||
|
TurnSchedule: SandboxTurnSchedule,
|
||||||
|
TargetEngineVersion: cfg.EngineVersion,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return lobby.GameRecord{}, fmt.Errorf("dev_sandbox: create game: %w", err)
|
||||||
|
}
|
||||||
|
return rec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureMembershipsAndDrive(ctx context.Context, svc *lobby.Service, game lobby.GameRecord, realID uuid.UUID, dummyIDs []uuid.UUID, logger *zap.Logger) (lobby.GameRecord, error) {
|
||||||
|
caller := realID
|
||||||
|
if game.Status == lobby.GameStatusDraft {
|
||||||
|
next, err := svc.OpenEnrollment(ctx, &caller, false, game.GameID)
|
||||||
|
if err != nil {
|
||||||
|
return game, fmt.Errorf("dev_sandbox: open enrollment: %w", err)
|
||||||
|
}
|
||||||
|
game = next
|
||||||
|
}
|
||||||
|
|
||||||
|
if game.Status == lobby.GameStatusEnrollmentOpen {
|
||||||
|
users := append([]uuid.UUID{realID}, dummyIDs...)
|
||||||
|
for i, uid := range users {
|
||||||
|
raceName := fmt.Sprintf("Sandbox-%02d", i+1)
|
||||||
|
if _, err := svc.InsertMembershipDirect(ctx, lobby.InsertMembershipDirectInput{
|
||||||
|
GameID: game.GameID,
|
||||||
|
UserID: uid,
|
||||||
|
RaceName: raceName,
|
||||||
|
}); err != nil {
|
||||||
|
return game, fmt.Errorf("dev_sandbox: insert membership %d: %w", i+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.Info("memberships ensured",
|
||||||
|
zap.Int("count", len(users)),
|
||||||
|
zap.String("game_id", game.GameID.String()),
|
||||||
|
)
|
||||||
|
next, err := svc.ReadyToStart(ctx, &caller, false, game.GameID)
|
||||||
|
if err != nil {
|
||||||
|
return game, fmt.Errorf("dev_sandbox: ready to start: %w", err)
|
||||||
|
}
|
||||||
|
game = next
|
||||||
|
}
|
||||||
|
|
||||||
|
if game.Status == lobby.GameStatusReadyToStart {
|
||||||
|
next, err := svc.Start(ctx, &caller, false, game.GameID)
|
||||||
|
if err != nil {
|
||||||
|
return game, fmt.Errorf("dev_sandbox: start: %w", err)
|
||||||
|
}
|
||||||
|
game = next
|
||||||
|
}
|
||||||
|
|
||||||
|
if game.Status == lobby.GameStatusStartFailed {
|
||||||
|
next, err := svc.RetryStart(ctx, &caller, false, game.GameID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("retry start failed", zap.Error(err))
|
||||||
|
return game, nil
|
||||||
|
}
|
||||||
|
game = next
|
||||||
|
if game.Status == lobby.GameStatusReadyToStart {
|
||||||
|
next, err := svc.Start(ctx, &caller, false, game.GameID)
|
||||||
|
if err != nil {
|
||||||
|
return game, fmt.Errorf("dev_sandbox: start after retry: %w", err)
|
||||||
|
}
|
||||||
|
game = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return game, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package devsandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"galaxy/backend/internal/config"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBootstrapSkippedWhenEmailEmpty exercises the no-op branch: with
|
||||||
|
// the production posture (Email == "") Bootstrap must return without
|
||||||
|
// touching any dependency. The fact that Users/Lobby/EngineVersions
|
||||||
|
// are nil here doubles as a check that the early-return runs first.
|
||||||
|
func TestBootstrapSkippedWhenEmailEmpty(t *testing.T) {
|
||||||
|
err := Bootstrap(
|
||||||
|
context.Background(),
|
||||||
|
Deps{},
|
||||||
|
config.DevSandboxConfig{},
|
||||||
|
zap.NewNop(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected nil error on empty email, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBootstrapRejectsZeroPlayerCount confirms the validation
|
||||||
|
// short-circuits the flow before any DB call when PlayerCount is
|
||||||
|
// non-positive but Email is set. The error path is fast and never
|
||||||
|
// dereferences the (still-nil) Users/Lobby deps.
|
||||||
|
func TestBootstrapRejectsZeroPlayerCount(t *testing.T) {
|
||||||
|
err := Bootstrap(
|
||||||
|
context.Background(),
|
||||||
|
Deps{Users: stubEnsurer{}, Lobby: nil, EngineVersions: nil},
|
||||||
|
config.DevSandboxConfig{
|
||||||
|
Email: "dev@local.test",
|
||||||
|
EngineImage: "galaxy-engine:local-dev",
|
||||||
|
EngineVersion: "0.0.0-local-dev",
|
||||||
|
PlayerCount: 0,
|
||||||
|
},
|
||||||
|
zap.NewNop(),
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error on zero PlayerCount, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBootstrapRejectsMissingDeps checks that a misconfigured wiring
|
||||||
|
// (Email set but one of the required services nil) fails fast rather
|
||||||
|
// than panicking when the bootstrap reaches its first service call.
|
||||||
|
func TestBootstrapRejectsMissingDeps(t *testing.T) {
|
||||||
|
err := Bootstrap(
|
||||||
|
context.Background(),
|
||||||
|
Deps{Users: stubEnsurer{}, Lobby: nil, EngineVersions: nil},
|
||||||
|
config.DevSandboxConfig{
|
||||||
|
Email: "dev@local.test",
|
||||||
|
EngineImage: "galaxy-engine:local-dev",
|
||||||
|
EngineVersion: "0.0.0-local-dev",
|
||||||
|
PlayerCount: 20,
|
||||||
|
},
|
||||||
|
zap.NewNop(),
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error on missing deps, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, errMissingDepsSentinel) && err.Error() == "" {
|
||||||
|
// The exact wording is not part of the contract; this branch
|
||||||
|
// only asserts the error is non-nil and human-readable.
|
||||||
|
t.Fatalf("error has empty message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// errMissingDepsSentinel exists so the assertion above can compile;
|
||||||
|
// the real error is constructed via errors.New inside Bootstrap and
|
||||||
|
// is intentionally not exported. The test only needs to confirm the
|
||||||
|
// returned error has a message.
|
||||||
|
var errMissingDepsSentinel = errors.New("sentinel")
|
||||||
|
|
||||||
|
// TestTerminalSandboxStatus pins the contract that decides whether a
|
||||||
|
// previously created sandbox game gets purged on the next boot.
|
||||||
|
// Terminal states are deleted (cascade-style) so the developer's
|
||||||
|
// lobby never piles up dead tiles between `make rebuild` cycles.
|
||||||
|
func TestTerminalSandboxStatus(t *testing.T) {
|
||||||
|
terminal := []string{"cancelled", "finished", "start_failed"}
|
||||||
|
live := []string{"draft", "enrollment_open", "ready_to_start", "starting", "running", "paused"}
|
||||||
|
|
||||||
|
for _, status := range terminal {
|
||||||
|
if !terminalSandboxStatus(status) {
|
||||||
|
t.Errorf("expected %q to be terminal", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, status := range live {
|
||||||
|
if terminalSandboxStatus(status) {
|
||||||
|
t.Errorf("expected %q to be non-terminal", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubEnsurer struct{}
|
||||||
|
|
||||||
|
func (stubEnsurer) EnsureByEmail(_ context.Context, _, _, _, _ string) (uuid.UUID, error) {
|
||||||
|
return uuid.UUID{}, nil
|
||||||
|
}
|
||||||
@@ -26,10 +26,7 @@ Three Postgres tables in the `backend` schema:
|
|||||||
|
|
||||||
- `diplomail_messages` — one row per send (personal, admin, or
|
- `diplomail_messages` — one row per send (personal, admin, or
|
||||||
system). Captures `game_name` and IP at insert time so audit
|
system). Captures `game_name` and IP at insert time so audit
|
||||||
rendering survives renames and purges. The `sender_race_name`
|
rendering survives renames and purges.
|
||||||
column snapshots the sender's race in the game at send time when
|
|
||||||
the sender is a player with an active membership; the in-game UI
|
|
||||||
keys per-race thread grouping on this column.
|
|
||||||
- `diplomail_recipients` — one row per (message, recipient). Holds
|
- `diplomail_recipients` — one row per (message, recipient). Holds
|
||||||
per-user `read_at`, `deleted_at`, `delivered_at`, `notified_at`
|
per-user `read_at`, `deleted_at`, `delivered_at`, `notified_at`
|
||||||
state. Snapshot fields (`recipient_user_name`,
|
state. Snapshot fields (`recipient_user_name`,
|
||||||
@@ -75,24 +72,6 @@ mail to every active member; `Service.changeMembershipStatus` /
|
|||||||
`detector.LanguageDetector` (default: `whatlanggo`, body-only,
|
`detector.LanguageDetector` (default: `whatlanggo`, body-only,
|
||||||
≥ 25 runes; shorter bodies stay `und`).
|
≥ 25 runes; shorter bodies stay `und`).
|
||||||
|
|
||||||
## Recipient selection
|
|
||||||
|
|
||||||
`POST /messages` and `POST /admin` (when `target="user"`) accept the
|
|
||||||
recipient identifier in one of two shapes:
|
|
||||||
|
|
||||||
- `recipient_user_id` (uuid) — explicit user lookup; the recipient
|
|
||||||
may be any active member of the game.
|
|
||||||
- `recipient_race_name` (string) — resolves to the active member
|
|
||||||
with this race name in the game. Race names are unique by lobby
|
|
||||||
invariant; lobby-removed and blocked members cannot be reached
|
|
||||||
through the race-name shortcut (they no longer appear in the
|
|
||||||
active scope). Exactly one of the two fields must be supplied;
|
|
||||||
supplying both, or neither, returns `invalid_request`.
|
|
||||||
|
|
||||||
The race-name path lets the in-game UI compose mail directly off
|
|
||||||
the engine's `report.races[]` view without an extra membership
|
|
||||||
round-trip.
|
|
||||||
|
|
||||||
## Translation
|
## Translation
|
||||||
|
|
||||||
Stage D adds a lazy translation cache. When a recipient reads a
|
Stage D adds a lazy translation cache. When a recipient reads a
|
||||||
|
|||||||
@@ -29,11 +29,7 @@ func (s *Service) SendAdminPersonal(ctx context.Context, in SendAdminPersonalInp
|
|||||||
return Message{}, Recipient{}, err
|
return Message{}, Recipient{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
recipientID, err := s.resolveActiveRecipient(ctx, in.GameID, in.RecipientUserID, in.RecipientRaceName)
|
recipient, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, in.GameID, in.RecipientUserID)
|
||||||
if err != nil {
|
|
||||||
return Message{}, Recipient{}, err
|
|
||||||
}
|
|
||||||
recipient, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, in.GameID, recipientID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not a member of the game", ErrForbidden)
|
return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not a member of the game", ErrForbidden)
|
||||||
@@ -41,7 +37,7 @@ func (s *Service) SendAdminPersonal(ctx context.Context, in SendAdminPersonalInp
|
|||||||
return Message{}, Recipient{}, fmt.Errorf("diplomail: load admin recipient: %w", err)
|
return Message{}, Recipient{}, fmt.Errorf("diplomail: load admin recipient: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgInsert, err := s.buildAdminMessageInsert(ctx, in.CallerKind, in.CallerUserID, in.CallerUsername,
|
msgInsert, err := s.buildAdminMessageInsert(in.CallerKind, in.CallerUserID, in.CallerUsername,
|
||||||
recipient.GameID, recipient.GameName, subject, body, in.SenderIP, BroadcastScopeSingle)
|
recipient.GameID, recipient.GameName, subject, body, in.SenderIP, BroadcastScopeSingle)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Message{}, Recipient{}, err
|
return Message{}, Recipient{}, err
|
||||||
@@ -88,7 +84,7 @@ func (s *Service) SendAdminBroadcast(ctx context.Context, in SendAdminBroadcastI
|
|||||||
}
|
}
|
||||||
|
|
||||||
gameName := members[0].GameName
|
gameName := members[0].GameName
|
||||||
msgInsert, err := s.buildAdminMessageInsert(ctx, in.CallerKind, in.CallerUserID, in.CallerUsername,
|
msgInsert, err := s.buildAdminMessageInsert(in.CallerKind, in.CallerUserID, in.CallerUsername,
|
||||||
in.GameID, gameName, subject, body, in.SenderIP, BroadcastScopeGameBroadcast)
|
in.GameID, gameName, subject, body, in.SenderIP, BroadcastScopeGameBroadcast)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Message{}, nil, err
|
return Message{}, nil, err
|
||||||
@@ -151,7 +147,6 @@ func (s *Service) SendPlayerBroadcast(ctx context.Context, in SendPlayerBroadcas
|
|||||||
}
|
}
|
||||||
|
|
||||||
username := sender.UserName
|
username := sender.UserName
|
||||||
senderRace := sender.RaceName
|
|
||||||
msgInsert := MessageInsert{
|
msgInsert := MessageInsert{
|
||||||
MessageID: uuid.New(),
|
MessageID: uuid.New(),
|
||||||
GameID: in.GameID,
|
GameID: in.GameID,
|
||||||
@@ -160,7 +155,6 @@ func (s *Service) SendPlayerBroadcast(ctx context.Context, in SendPlayerBroadcas
|
|||||||
SenderKind: SenderKindPlayer,
|
SenderKind: SenderKindPlayer,
|
||||||
SenderUserID: &callerID,
|
SenderUserID: &callerID,
|
||||||
SenderUsername: &username,
|
SenderUsername: &username,
|
||||||
SenderRaceName: &senderRace,
|
|
||||||
SenderIP: in.SenderIP,
|
SenderIP: in.SenderIP,
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
Body: body,
|
Body: body,
|
||||||
@@ -223,7 +217,7 @@ func (s *Service) SendAdminMultiGameBroadcast(ctx context.Context, in SendMultiG
|
|||||||
zap.String("scope", scope))
|
zap.String("scope", scope))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
msgInsert, err := s.buildAdminMessageInsert(ctx, CallerKindAdmin, nil, in.CallerUsername,
|
msgInsert, err := s.buildAdminMessageInsert(CallerKindAdmin, nil, in.CallerUsername,
|
||||||
game.GameID, game.GameName, subject, body, in.SenderIP, BroadcastScopeMultiGameBroadcast)
|
game.GameID, game.GameName, subject, body, in.SenderIP, BroadcastScopeMultiGameBroadcast)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
@@ -362,7 +356,7 @@ func (s *Service) publishGameLifecycle(ctx context.Context, ev LifecycleEvent) e
|
|||||||
gameName := members[0].GameName
|
gameName := members[0].GameName
|
||||||
subject, body := renderGameLifecycle(ev.Kind, gameName, ev.Actor, ev.Reason)
|
subject, body := renderGameLifecycle(ev.Kind, gameName, ev.Actor, ev.Reason)
|
||||||
|
|
||||||
msgInsert, err := s.buildAdminMessageInsert(ctx, CallerKindSystem, nil, "",
|
msgInsert, err := s.buildAdminMessageInsert(CallerKindSystem, nil, "",
|
||||||
ev.GameID, gameName, subject, body, "", BroadcastScopeGameBroadcast)
|
ev.GameID, gameName, subject, body, "", BroadcastScopeGameBroadcast)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -391,7 +385,7 @@ func (s *Service) publishMembershipLifecycle(ctx context.Context, ev LifecycleEv
|
|||||||
}
|
}
|
||||||
subject, body := renderMembershipLifecycle(ev.Kind, target.GameName, ev.Actor, ev.Reason)
|
subject, body := renderMembershipLifecycle(ev.Kind, target.GameName, ev.Actor, ev.Reason)
|
||||||
|
|
||||||
msgInsert, err := s.buildAdminMessageInsert(ctx, CallerKindSystem, nil, "",
|
msgInsert, err := s.buildAdminMessageInsert(CallerKindSystem, nil, "",
|
||||||
ev.GameID, target.GameName, subject, body, "", BroadcastScopeSingle)
|
ev.GameID, target.GameName, subject, body, "", BroadcastScopeSingle)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -423,12 +417,10 @@ func (s *Service) prepareContent(subject, body string) (string, string, error) {
|
|||||||
// for every admin-kind send. The CHECK constraint maps sender
|
// for every admin-kind send. The CHECK constraint maps sender
|
||||||
// shapes:
|
// shapes:
|
||||||
//
|
//
|
||||||
// sender_kind='player' → CallerKind owner; sender_user_id set,
|
// sender_kind='player' → CallerKind owner; sender_user_id set
|
||||||
// sender_race_name resolved from
|
|
||||||
// Memberships.GetActiveMembership
|
|
||||||
// sender_kind='admin' → CallerKind admin; sender_user_id nil
|
// sender_kind='admin' → CallerKind admin; sender_user_id nil
|
||||||
// sender_kind='system' → CallerKind system; sender_username nil
|
// sender_kind='system' → CallerKind system; sender_username nil
|
||||||
func (s *Service) buildAdminMessageInsert(ctx context.Context, callerKind string, callerUserID *uuid.UUID, callerUsername string,
|
func (s *Service) buildAdminMessageInsert(callerKind string, callerUserID *uuid.UUID, callerUsername string,
|
||||||
gameID uuid.UUID, gameName, subject, body, senderIP, scope string) (MessageInsert, error) {
|
gameID uuid.UUID, gameName, subject, body, senderIP, scope string) (MessageInsert, error) {
|
||||||
out := MessageInsert{
|
out := MessageInsert{
|
||||||
MessageID: uuid.New(),
|
MessageID: uuid.New(),
|
||||||
@@ -451,17 +443,6 @@ func (s *Service) buildAdminMessageInsert(ctx context.Context, callerKind string
|
|||||||
out.SenderKind = SenderKindPlayer
|
out.SenderKind = SenderKindPlayer
|
||||||
out.SenderUserID = &uid
|
out.SenderUserID = &uid
|
||||||
out.SenderUsername = &uname
|
out.SenderUsername = &uname
|
||||||
// Owner race snapshot is best-effort: a private-game owner who
|
|
||||||
// has an active membership in their own game contributes a
|
|
||||||
// race name; an owner who is not a current member (or whose
|
|
||||||
// membership is removed/blocked) leaves the field nil. The
|
|
||||||
// CHECK constraint accepts both shapes for sender_kind='player'.
|
|
||||||
if ownerMember, err := s.deps.Memberships.GetActiveMembership(ctx, gameID, uid); err == nil {
|
|
||||||
race := ownerMember.RaceName
|
|
||||||
out.SenderRaceName = &race
|
|
||||||
} else if !errors.Is(err, ErrNotFound) {
|
|
||||||
return MessageInsert{}, fmt.Errorf("diplomail: load owner membership: %w", err)
|
|
||||||
}
|
|
||||||
case CallerKindAdmin:
|
case CallerKindAdmin:
|
||||||
uname := callerUsername
|
uname := callerUsername
|
||||||
out.SenderKind = SenderKindAdmin
|
out.SenderKind = SenderKindAdmin
|
||||||
|
|||||||
@@ -369,114 +369,6 @@ func TestDiplomailPersonalFlow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDiplomailPersonalByRaceName exercises the Phase 28 contract: the
|
|
||||||
// UI passes a recipient race name (read out of the game report); the
|
|
||||||
// service resolves it to the active member with that race name and
|
|
||||||
// snapshots the sender's race onto the message row. Error cases cover
|
|
||||||
// the validation rules baked into the wire schema.
|
|
||||||
func TestDiplomailPersonalByRaceName(t *testing.T) {
|
|
||||||
db := startPostgres(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
gameID := uuid.New()
|
|
||||||
sender := uuid.New()
|
|
||||||
recipient := uuid.New()
|
|
||||||
kicked := uuid.New()
|
|
||||||
seedAccount(t, db, sender)
|
|
||||||
seedAccount(t, db, recipient)
|
|
||||||
seedAccount(t, db, kicked)
|
|
||||||
seedGame(t, db, gameID, "Race-Name Resolution Game")
|
|
||||||
|
|
||||||
lookup := &staticMembershipLookup{
|
|
||||||
rows: map[[2]uuid.UUID]diplomail.ActiveMembership{
|
|
||||||
{gameID, sender}: {
|
|
||||||
UserID: sender, GameID: gameID, GameName: "Race-Name Resolution Game",
|
|
||||||
UserName: "sender", RaceName: "Senders",
|
|
||||||
},
|
|
||||||
{gameID, recipient}: {
|
|
||||||
UserID: recipient, GameID: gameID, GameName: "Race-Name Resolution Game",
|
|
||||||
UserName: "recipient", RaceName: "Receivers",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
inactive: map[[2]uuid.UUID]diplomail.MemberSnapshot{
|
|
||||||
{gameID, kicked}: {
|
|
||||||
UserID: kicked, GameID: gameID, GameName: "Race-Name Resolution Game",
|
|
||||||
UserName: "kicked", RaceName: "Departed", Status: "removed",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
svc := diplomail.NewService(diplomail.Deps{
|
|
||||||
Store: diplomail.NewStore(db),
|
|
||||||
Memberships: lookup,
|
|
||||||
Notification: &recordingPublisher{},
|
|
||||||
Config: config.DiplomailConfig{
|
|
||||||
MaxBodyBytes: 4096,
|
|
||||||
MaxSubjectBytes: 256,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Happy path: race name resolves and sender_race_name is snapshotted.
|
|
||||||
msg, rcpt, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
|
||||||
GameID: gameID,
|
|
||||||
SenderUserID: sender,
|
|
||||||
RecipientRaceName: "Receivers",
|
|
||||||
Subject: "Trade",
|
|
||||||
Body: "Care to talk?",
|
|
||||||
SenderIP: "203.0.113.4",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("send by race name: %v", err)
|
|
||||||
}
|
|
||||||
if rcpt.UserID != recipient {
|
|
||||||
t.Fatalf("recipient = %s, want %s", rcpt.UserID, recipient)
|
|
||||||
}
|
|
||||||
if msg.SenderRaceName == nil || *msg.SenderRaceName != "Senders" {
|
|
||||||
t.Fatalf("sender_race_name = %v, want \"Senders\"", msg.SenderRaceName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both identifiers supplied → invalid_request.
|
|
||||||
if _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
|
||||||
GameID: gameID,
|
|
||||||
SenderUserID: sender,
|
|
||||||
RecipientUserID: recipient,
|
|
||||||
RecipientRaceName: "Receivers",
|
|
||||||
Body: "x",
|
|
||||||
}); !errors.Is(err, diplomail.ErrInvalidInput) {
|
|
||||||
t.Fatalf("dual identifier: %v, want ErrInvalidInput", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Neither identifier supplied → invalid_request.
|
|
||||||
if _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
|
||||||
GameID: gameID,
|
|
||||||
SenderUserID: sender,
|
|
||||||
Body: "x",
|
|
||||||
}); !errors.Is(err, diplomail.ErrInvalidInput) {
|
|
||||||
t.Fatalf("no identifier: %v, want ErrInvalidInput", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Race name with no matching active member → invalid_request.
|
|
||||||
if _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
|
||||||
GameID: gameID,
|
|
||||||
SenderUserID: sender,
|
|
||||||
RecipientRaceName: "Strangers",
|
|
||||||
Body: "x",
|
|
||||||
}); !errors.Is(err, diplomail.ErrInvalidInput) {
|
|
||||||
t.Fatalf("unknown race: %v, want ErrInvalidInput", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Race name of a lobby-removed member → invalid_request (the
|
|
||||||
// active-only scope filters them out; the lookup never returns
|
|
||||||
// them).
|
|
||||||
if _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
|
||||||
GameID: gameID,
|
|
||||||
SenderUserID: sender,
|
|
||||||
RecipientRaceName: "Departed",
|
|
||||||
Body: "x",
|
|
||||||
}); !errors.Is(err, diplomail.ErrInvalidInput) {
|
|
||||||
t.Fatalf("kicked race: %v, want ErrInvalidInput", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDiplomailRejectsNonActiveSender(t *testing.T) {
|
func TestDiplomailRejectsNonActiveSender(t *testing.T) {
|
||||||
db := startPostgres(t)
|
db := startPostgres(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -32,20 +32,15 @@ const previewMaxRunes = 120
|
|||||||
// ErrForbidden; the inserted Message is never persisted in those
|
// ErrForbidden; the inserted Message is never persisted in those
|
||||||
// cases.
|
// cases.
|
||||||
func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Message, Recipient, error) {
|
func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Message, Recipient, error) {
|
||||||
|
if in.SenderUserID == in.RecipientUserID {
|
||||||
|
return Message{}, Recipient{}, fmt.Errorf("%w: cannot send mail to yourself", ErrInvalidInput)
|
||||||
|
}
|
||||||
subject := strings.TrimRight(in.Subject, " \t")
|
subject := strings.TrimRight(in.Subject, " \t")
|
||||||
body := strings.TrimRight(in.Body, " \t\n")
|
body := strings.TrimRight(in.Body, " \t\n")
|
||||||
if err := s.validateContent(subject, body); err != nil {
|
if err := s.validateContent(subject, body); err != nil {
|
||||||
return Message{}, Recipient{}, err
|
return Message{}, Recipient{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
recipientID, err := s.resolveActiveRecipient(ctx, in.GameID, in.RecipientUserID, in.RecipientRaceName)
|
|
||||||
if err != nil {
|
|
||||||
return Message{}, Recipient{}, err
|
|
||||||
}
|
|
||||||
if in.SenderUserID == recipientID {
|
|
||||||
return Message{}, Recipient{}, fmt.Errorf("%w: cannot send mail to yourself", ErrInvalidInput)
|
|
||||||
}
|
|
||||||
|
|
||||||
sender, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.SenderUserID)
|
sender, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.SenderUserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
@@ -53,7 +48,7 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa
|
|||||||
}
|
}
|
||||||
return Message{}, Recipient{}, fmt.Errorf("diplomail: load sender membership: %w", err)
|
return Message{}, Recipient{}, fmt.Errorf("diplomail: load sender membership: %w", err)
|
||||||
}
|
}
|
||||||
recipient, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, recipientID)
|
recipient, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.RecipientUserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not an active member of the game", ErrForbidden)
|
return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not an active member of the game", ErrForbidden)
|
||||||
@@ -62,17 +57,14 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa
|
|||||||
}
|
}
|
||||||
|
|
||||||
username := sender.UserName
|
username := sender.UserName
|
||||||
senderRace := sender.RaceName
|
|
||||||
senderUserID := in.SenderUserID
|
|
||||||
msgInsert := MessageInsert{
|
msgInsert := MessageInsert{
|
||||||
MessageID: uuid.New(),
|
MessageID: uuid.New(),
|
||||||
GameID: in.GameID,
|
GameID: in.GameID,
|
||||||
GameName: sender.GameName,
|
GameName: sender.GameName,
|
||||||
Kind: KindPersonal,
|
Kind: KindPersonal,
|
||||||
SenderKind: SenderKindPlayer,
|
SenderKind: SenderKindPlayer,
|
||||||
SenderUserID: &senderUserID,
|
SenderUserID: &in.SenderUserID,
|
||||||
SenderUsername: &username,
|
SenderUsername: &username,
|
||||||
SenderRaceName: &senderRace,
|
|
||||||
SenderIP: in.SenderIP,
|
SenderIP: in.SenderIP,
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
Body: body,
|
Body: body,
|
||||||
@@ -83,7 +75,7 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa
|
|||||||
rcptInsert := buildRecipientInsert(
|
rcptInsert := buildRecipientInsert(
|
||||||
msgInsert.MessageID,
|
msgInsert.MessageID,
|
||||||
MemberSnapshot{
|
MemberSnapshot{
|
||||||
UserID: recipientID,
|
UserID: in.RecipientUserID,
|
||||||
GameID: in.GameID,
|
GameID: in.GameID,
|
||||||
GameName: recipient.GameName,
|
GameName: recipient.GameName,
|
||||||
UserName: recipient.UserName,
|
UserName: recipient.UserName,
|
||||||
@@ -109,47 +101,6 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa
|
|||||||
return msg, recipients[0], nil
|
return msg, recipients[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveActiveRecipient turns a (user_id, race_name) pair into the
|
|
||||||
// canonical user id of an active member of gameID. Exactly one of the
|
|
||||||
// two inputs must be set; both-set or both-empty returns
|
|
||||||
// ErrInvalidInput. Race-name resolution is restricted to the active
|
|
||||||
// scope so lobby-removed and blocked members cannot be reached
|
|
||||||
// through the race-name shortcut. ErrInvalidInput is also returned
|
|
||||||
// when the race name matches zero members; ErrForbidden when the
|
|
||||||
// race name matches more than one active row (defence in depth — race
|
|
||||||
// names are unique within a game by lobby invariant).
|
|
||||||
func (s *Service) resolveActiveRecipient(ctx context.Context, gameID uuid.UUID, byUserID uuid.UUID, byRaceName string) (uuid.UUID, error) {
|
|
||||||
byRaceName = strings.TrimSpace(byRaceName)
|
|
||||||
hasUser := byUserID != uuid.Nil
|
|
||||||
hasRace := byRaceName != ""
|
|
||||||
switch {
|
|
||||||
case hasUser && hasRace:
|
|
||||||
return uuid.Nil, fmt.Errorf("%w: only one of recipient_user_id, recipient_race_name may be supplied", ErrInvalidInput)
|
|
||||||
case !hasUser && !hasRace:
|
|
||||||
return uuid.Nil, fmt.Errorf("%w: recipient_user_id or recipient_race_name must be supplied", ErrInvalidInput)
|
|
||||||
case hasUser:
|
|
||||||
return byUserID, nil
|
|
||||||
}
|
|
||||||
members, err := s.deps.Memberships.ListMembers(ctx, gameID, RecipientScopeActive)
|
|
||||||
if err != nil {
|
|
||||||
return uuid.Nil, fmt.Errorf("diplomail: list active members for race lookup: %w", err)
|
|
||||||
}
|
|
||||||
var found []MemberSnapshot
|
|
||||||
for _, m := range members {
|
|
||||||
if m.RaceName == byRaceName {
|
|
||||||
found = append(found, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch len(found) {
|
|
||||||
case 0:
|
|
||||||
return uuid.Nil, fmt.Errorf("%w: no active member with race %q in this game", ErrInvalidInput, byRaceName)
|
|
||||||
case 1:
|
|
||||||
return found[0].UserID, nil
|
|
||||||
default:
|
|
||||||
return uuid.Nil, fmt.Errorf("%w: race %q matches multiple active members", ErrForbidden, byRaceName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMessage returns the InboxEntry for messageID addressed to
|
// GetMessage returns the InboxEntry for messageID addressed to
|
||||||
// userID. ErrNotFound is returned when the caller is not a recipient
|
// userID. ErrNotFound is returned when the caller is not a recipient
|
||||||
// of the message — handlers translate that to 404 so the existence
|
// of the message — handlers translate that to 404 so the existence
|
||||||
@@ -316,13 +267,10 @@ func (s *Service) allowedKinds(ctx context.Context, gameID, userID uuid.UUID) (m
|
|||||||
return map[string]bool{KindAdmin: true}, nil
|
return map[string]bool{KindAdmin: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListSent returns the sender-side view of personal messages
|
// ListSent returns personal messages authored by senderUserID in
|
||||||
// authored by senderUserID in gameID, newest first. Each entry pairs
|
// gameID, newest first. Admin/system rows have no `sender_user_id`
|
||||||
// the message with one of its recipient rows; single sends contribute
|
// and are therefore excluded; the user surface does not need them.
|
||||||
// one entry per message, broadcasts contribute one entry per
|
func (s *Service) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]Message, error) {
|
||||||
// addressee. Admin and system rows have no `sender_user_id` and are
|
|
||||||
// therefore excluded; the user surface does not need them.
|
|
||||||
func (s *Service) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]InboxEntry, error) {
|
|
||||||
return s.deps.Store.ListSent(ctx, gameID, senderUserID)
|
return s.deps.Store.ListSent(ctx, gameID, senderUserID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func messageColumns() postgres.ColumnList {
|
|||||||
m := table.DiplomailMessages
|
m := table.DiplomailMessages
|
||||||
return postgres.ColumnList{
|
return postgres.ColumnList{
|
||||||
m.MessageID, m.GameID, m.GameName, m.Kind, m.SenderKind,
|
m.MessageID, m.GameID, m.GameName, m.Kind, m.SenderKind,
|
||||||
m.SenderUserID, m.SenderUsername, m.SenderRaceName, m.SenderIP,
|
m.SenderUserID, m.SenderUsername, m.SenderIP,
|
||||||
m.Subject, m.Body, m.BodyLang, m.BroadcastScope, m.CreatedAt,
|
m.Subject, m.Body, m.BodyLang, m.BroadcastScope, m.CreatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,6 @@ type MessageInsert struct {
|
|||||||
SenderKind string
|
SenderKind string
|
||||||
SenderUserID *uuid.UUID
|
SenderUserID *uuid.UUID
|
||||||
SenderUsername *string
|
SenderUsername *string
|
||||||
SenderRaceName *string
|
|
||||||
SenderIP string
|
SenderIP string
|
||||||
Subject string
|
Subject string
|
||||||
Body string
|
Body string
|
||||||
@@ -102,7 +101,7 @@ func (s *Store) InsertMessageWithRecipients(ctx context.Context, msg MessageInse
|
|||||||
m := table.DiplomailMessages
|
m := table.DiplomailMessages
|
||||||
msgStmt := m.INSERT(
|
msgStmt := m.INSERT(
|
||||||
m.MessageID, m.GameID, m.GameName, m.Kind, m.SenderKind,
|
m.MessageID, m.GameID, m.GameName, m.Kind, m.SenderKind,
|
||||||
m.SenderUserID, m.SenderUsername, m.SenderRaceName, m.SenderIP,
|
m.SenderUserID, m.SenderUsername, m.SenderIP,
|
||||||
m.Subject, m.Body, m.BodyLang, m.BroadcastScope,
|
m.Subject, m.Body, m.BodyLang, m.BroadcastScope,
|
||||||
).VALUES(
|
).VALUES(
|
||||||
msg.MessageID,
|
msg.MessageID,
|
||||||
@@ -112,7 +111,6 @@ func (s *Store) InsertMessageWithRecipients(ctx context.Context, msg MessageInse
|
|||||||
msg.SenderKind,
|
msg.SenderKind,
|
||||||
uuidPtrArg(msg.SenderUserID),
|
uuidPtrArg(msg.SenderUserID),
|
||||||
stringPtrArg(msg.SenderUsername),
|
stringPtrArg(msg.SenderUsername),
|
||||||
stringPtrArg(msg.SenderRaceName),
|
|
||||||
msg.SenderIP,
|
msg.SenderIP,
|
||||||
msg.Subject,
|
msg.Subject,
|
||||||
msg.Body,
|
msg.Body,
|
||||||
@@ -243,38 +241,25 @@ func (s *Store) ListInbox(ctx context.Context, gameID, userID uuid.UUID) ([]Inbo
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListSent returns the sender-side view of personal messages
|
// ListSent returns messages authored by senderUserID in gameID,
|
||||||
// authored by senderUserID in gameID, newest first. Each
|
// newest first. Personal messages only — admin/system rows have
|
||||||
// `InboxEntry` carries the message together with one of its
|
// `sender_user_id IS NULL` and are filtered out by the WHERE clause.
|
||||||
// recipient rows — single sends produce one entry per message;
|
func (s *Store) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]Message, error) {
|
||||||
// game broadcasts produce one entry per addressee (the in-game
|
|
||||||
// mail UI collapses broadcast entries into a single stand-alone
|
|
||||||
// item by `message_id`). Admin / system rows have
|
|
||||||
// `sender_user_id IS NULL` and are excluded by the WHERE clause.
|
|
||||||
func (s *Store) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]InboxEntry, error) {
|
|
||||||
m := table.DiplomailMessages
|
m := table.DiplomailMessages
|
||||||
r := table.DiplomailRecipients
|
stmt := postgres.SELECT(messageColumns()).
|
||||||
cols := append(messageColumns(), recipientColumns()...)
|
FROM(m).
|
||||||
stmt := postgres.SELECT(cols).
|
|
||||||
FROM(m.INNER_JOIN(r, r.MessageID.EQ(m.MessageID))).
|
|
||||||
WHERE(
|
WHERE(
|
||||||
m.GameID.EQ(postgres.UUID(gameID)).
|
m.GameID.EQ(postgres.UUID(gameID)).
|
||||||
AND(m.SenderUserID.EQ(postgres.UUID(senderUserID))),
|
AND(m.SenderUserID.EQ(postgres.UUID(senderUserID))),
|
||||||
).
|
).
|
||||||
ORDER_BY(m.CreatedAt.DESC(), m.MessageID.DESC(), r.RecipientID.ASC())
|
ORDER_BY(m.CreatedAt.DESC(), m.MessageID.DESC())
|
||||||
var dest []struct {
|
var rows []model.DiplomailMessages
|
||||||
model.DiplomailMessages
|
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||||
Recipient model.DiplomailRecipients `alias:"diplomail_recipients"`
|
|
||||||
}
|
|
||||||
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
|
|
||||||
return nil, fmt.Errorf("diplomail store: list sent %s/%s: %w", gameID, senderUserID, err)
|
return nil, fmt.Errorf("diplomail store: list sent %s/%s: %w", gameID, senderUserID, err)
|
||||||
}
|
}
|
||||||
out := make([]InboxEntry, 0, len(dest))
|
out := make([]Message, 0, len(rows))
|
||||||
for _, row := range dest {
|
for _, row := range rows {
|
||||||
out = append(out, InboxEntry{
|
out = append(out, messageFromModel(row))
|
||||||
Message: messageFromModel(row.DiplomailMessages),
|
|
||||||
Recipient: recipientFromModel(row.Recipient),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -752,10 +737,6 @@ func messageFromModel(row model.DiplomailMessages) Message {
|
|||||||
name := *row.SenderUsername
|
name := *row.SenderUsername
|
||||||
out.SenderUsername = &name
|
out.SenderUsername = &name
|
||||||
}
|
}
|
||||||
if row.SenderRaceName != nil {
|
|
||||||
name := *row.SenderRaceName
|
|
||||||
out.SenderRaceName = &name
|
|
||||||
}
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,11 +23,6 @@ type Message struct {
|
|||||||
SenderKind string
|
SenderKind string
|
||||||
SenderUserID *uuid.UUID
|
SenderUserID *uuid.UUID
|
||||||
SenderUsername *string
|
SenderUsername *string
|
||||||
// SenderRaceName carries the snapshot of the sender's race in the
|
|
||||||
// game at send time. Non-nil for sender_kind='player' rows, nil
|
|
||||||
// for admin and system. The in-game mail UI groups personal
|
|
||||||
// threads by this name (Phase 28).
|
|
||||||
SenderRaceName *string
|
|
||||||
SenderIP string
|
SenderIP string
|
||||||
Subject string
|
Subject string
|
||||||
Body string
|
Body string
|
||||||
@@ -97,16 +92,13 @@ type Translation struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SendPersonalInput is the request payload for SendPersonal: the
|
// SendPersonalInput is the request payload for SendPersonal: the
|
||||||
// caller sending a single-recipient personal message. Exactly one of
|
// caller sending a single-recipient personal message. Validation
|
||||||
// RecipientUserID and RecipientRaceName must be non-zero; the
|
// (active membership, body length, etc.) is performed inside the
|
||||||
// service resolves a non-empty RecipientRaceName to the active
|
// service.
|
||||||
// member with that race in the game. Other validation (active
|
|
||||||
// membership, body length, etc.) is performed inside the service.
|
|
||||||
type SendPersonalInput struct {
|
type SendPersonalInput struct {
|
||||||
GameID uuid.UUID
|
GameID uuid.UUID
|
||||||
SenderUserID uuid.UUID
|
SenderUserID uuid.UUID
|
||||||
RecipientUserID uuid.UUID
|
RecipientUserID uuid.UUID
|
||||||
RecipientRaceName string
|
|
||||||
Subject string
|
Subject string
|
||||||
Body string
|
Body string
|
||||||
SenderIP string
|
SenderIP string
|
||||||
@@ -124,18 +116,14 @@ const (
|
|||||||
|
|
||||||
// SendAdminPersonalInput is the request payload for an owner /
|
// SendAdminPersonalInput is the request payload for an owner /
|
||||||
// admin / system sending an admin-kind message to a single
|
// admin / system sending an admin-kind message to a single
|
||||||
// recipient. Exactly one of RecipientUserID and RecipientRaceName
|
// recipient. Authorization (owner-vs-admin distinction) is enforced
|
||||||
// must be non-zero; the service resolves a non-empty
|
// by the HTTP layer; the service trusts the caller designation.
|
||||||
// RecipientRaceName to the active member with that race in the
|
|
||||||
// game. Authorization (owner-vs-admin distinction) is enforced by
|
|
||||||
// the HTTP layer; the service trusts the caller designation.
|
|
||||||
type SendAdminPersonalInput struct {
|
type SendAdminPersonalInput struct {
|
||||||
GameID uuid.UUID
|
GameID uuid.UUID
|
||||||
CallerKind string
|
CallerKind string
|
||||||
CallerUserID *uuid.UUID
|
CallerUserID *uuid.UUID
|
||||||
CallerUsername string
|
CallerUsername string
|
||||||
RecipientUserID uuid.UUID
|
RecipientUserID uuid.UUID
|
||||||
RecipientRaceName string
|
|
||||||
Subject string
|
Subject string
|
||||||
Body string
|
Body string
|
||||||
SenderIP string
|
SenderIP string
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const (
|
|||||||
pathAdminStatus = "/api/v1/admin/status"
|
pathAdminStatus = "/api/v1/admin/status"
|
||||||
pathAdminTurn = "/api/v1/admin/turn"
|
pathAdminTurn = "/api/v1/admin/turn"
|
||||||
pathAdminRaceBanish = "/api/v1/admin/race/banish"
|
pathAdminRaceBanish = "/api/v1/admin/race/banish"
|
||||||
|
pathPlayerCommand = "/api/v1/command"
|
||||||
pathPlayerOrder = "/api/v1/order"
|
pathPlayerOrder = "/api/v1/order"
|
||||||
pathPlayerReport = "/api/v1/report"
|
pathPlayerReport = "/api/v1/report"
|
||||||
pathPlayerBattle = "/api/v1/battle"
|
pathPlayerBattle = "/api/v1/battle"
|
||||||
@@ -182,10 +183,16 @@ func (c *Client) BanishRace(ctx context.Context, baseURL, raceName string) error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PutOrders calls `PUT /api/v1/order` with the payload forwarded
|
// ExecuteCommands calls `PUT /api/v1/command` with payload forwarded
|
||||||
// verbatim. The engine response body is returned verbatim; on 4xx the
|
// verbatim. The engine response body is returned verbatim; on 4xx the
|
||||||
// body is returned alongside ErrEngineValidation so callers can forward
|
// body is returned alongside ErrEngineValidation so callers can
|
||||||
// the per-command error.
|
// forward the per-command error.
|
||||||
|
func (c *Client) ExecuteCommands(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) {
|
||||||
|
return c.forwardPlayerWrite(ctx, baseURL, pathPlayerCommand, payload, "engine command")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutOrders calls `PUT /api/v1/order` with the same forwarding
|
||||||
|
// semantics as ExecuteCommands.
|
||||||
func (c *Client) PutOrders(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) {
|
func (c *Client) PutOrders(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) {
|
||||||
return c.forwardPlayerWrite(ctx, baseURL, pathPlayerOrder, payload, "engine order")
|
return c.forwardPlayerWrite(ctx, baseURL, pathPlayerOrder, payload, "engine order")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ func newTestClient(t *testing.T, srv *httptest.Server) *Client {
|
|||||||
|
|
||||||
func TestClientInitSuccess(t *testing.T) {
|
func TestClientInitSuccess(t *testing.T) {
|
||||||
wantID := uuid.New()
|
wantID := uuid.New()
|
||||||
var gotReq rest.InitRequest
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != pathAdminInit {
|
if r.URL.Path != pathAdminInit {
|
||||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
@@ -34,16 +33,13 @@ func TestClientInitSuccess(t *testing.T) {
|
|||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
t.Fatalf("unexpected method: %s", r.Method)
|
t.Fatalf("unexpected method: %s", r.Method)
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
|
|
||||||
t.Fatalf("decode request: %v", err)
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: wantID, Turn: 1, Players: []rest.PlayerState{{ID: uuid.New(), RaceName: "alpha"}}})
|
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: wantID, Turn: 1, Players: []rest.PlayerState{{ID: uuid.New(), RaceName: "alpha"}}})
|
||||||
}))
|
}))
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
cli := newTestClient(t, srv)
|
cli := newTestClient(t, srv)
|
||||||
got, err := cli.Init(context.Background(), srv.URL, rest.InitRequest{GameID: wantID, Races: []rest.InitRace{{RaceName: "alpha"}}})
|
got, err := cli.Init(context.Background(), srv.URL, rest.InitRequest{Races: []rest.InitRace{{RaceName: "alpha"}}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Init returned error: %v", err)
|
t.Fatalf("Init returned error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -53,9 +49,6 @@ func TestClientInitSuccess(t *testing.T) {
|
|||||||
if got.Turn != 1 {
|
if got.Turn != 1 {
|
||||||
t.Fatalf("Turn = %d, want 1", got.Turn)
|
t.Fatalf("Turn = %d, want 1", got.Turn)
|
||||||
}
|
}
|
||||||
if gotReq.GameID != wantID {
|
|
||||||
t.Fatalf("request gameId = %s, want %s", gotReq.GameID, wantID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClientInitValidationError(t *testing.T) {
|
func TestClientInitValidationError(t *testing.T) {
|
||||||
@@ -156,6 +149,27 @@ func TestClientBanishRace(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClientCommandsForwardsBody(t *testing.T) {
|
||||||
|
want := json.RawMessage(`{"actor":"alpha","cmd":[{"@type":"raceQuit"}]}`)
|
||||||
|
gotResp := json.RawMessage(`{"applied":true}`)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != pathPlayerCommand || r.Method != http.MethodPut {
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
_, _ = w.Write(gotResp)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
cli := newTestClient(t, srv)
|
||||||
|
resp, err := cli.ExecuteCommands(context.Background(), srv.URL, want)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExecuteCommands: %v", err)
|
||||||
|
}
|
||||||
|
if string(resp) != string(gotResp) {
|
||||||
|
t.Fatalf("response = %s, want %s", string(resp), string(gotResp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestClientReportsForwardsQuery(t *testing.T) {
|
func TestClientReportsForwardsQuery(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != pathPlayerReport {
|
if r.URL.Path != pathPlayerReport {
|
||||||
|
|||||||
@@ -9,21 +9,14 @@ import (
|
|||||||
|
|
||||||
// EntitlementProvider is the read-only view the lobby needs over the
|
// EntitlementProvider is the read-only view the lobby needs over the
|
||||||
// user-domain entitlement snapshot. The canonical implementation is
|
// user-domain entitlement snapshot. The canonical implementation is
|
||||||
// `*user.Service` exposing `GetEntitlementSnapshot(ctx, userID)`; tests
|
// `*user.Service` exposing `GetEntitlement(ctx, userID)`; tests substitute
|
||||||
// substitute a fake.
|
// a fake.
|
||||||
//
|
//
|
||||||
// `GetMaxRegisteredRaceNames` is consumed at race-name registration time
|
// `MaxRegisteredRaceNames` is the only field consumed by when
|
||||||
// — when the caller attempts to register a `pending_registration` row the
|
// the caller attempts to register a `pending_registration` row the lobby
|
||||||
// lobby counts already-`registered` rows for that user against this limit.
|
// counts already-`registered` rows for that user against this limit.
|
||||||
//
|
|
||||||
// `IsPaid` is consumed by the user-facing private-game creation gate at
|
|
||||||
// the HTTP handler level (`POST /api/v1/user/lobby/games`): free-tier
|
|
||||||
// callers are rejected with `403 forbidden` before the lobby Service is
|
|
||||||
// invoked. Admin-driven public-game creation
|
|
||||||
// (`POST /api/v1/admin/games`) bypasses the gate.
|
|
||||||
type EntitlementProvider interface {
|
type EntitlementProvider interface {
|
||||||
GetMaxRegisteredRaceNames(ctx context.Context, userID uuid.UUID) (int32, error)
|
GetMaxRegisteredRaceNames(ctx context.Context, userID uuid.UUID) (int32, error)
|
||||||
IsPaid(ctx context.Context, userID uuid.UUID) (bool, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RuntimeGateway is the outbound surface the lobby uses to ask the runtime
|
// RuntimeGateway is the outbound surface the lobby uses to ask the runtime
|
||||||
|
|||||||
@@ -274,10 +274,11 @@ func (s *Service) ListFinishedGamesBefore(ctx context.Context, cutoff time.Time)
|
|||||||
// `ON DELETE CASCADE` constraints declared in `00001_init.sql`.
|
// `ON DELETE CASCADE` constraints declared in `00001_init.sql`.
|
||||||
// Idempotent: returns nil when no game matches.
|
// Idempotent: returns nil when no game matches.
|
||||||
//
|
//
|
||||||
// `DeleteGame` is destructive — a hard delete that bypasses the
|
// Phase 14 introduces this method for the dev-sandbox bootstrap so a
|
||||||
// cascade-notification machinery — so production callers stay on the
|
// terminal "Dev Sandbox" tile from a previous local-dev session can
|
||||||
// regular cancel / finish lifecycle. It is exercised by the lobby
|
// be scrubbed before a fresh game spawns. Production callers must
|
||||||
// integration tests.
|
// stay on the regular cancel / finish lifecycle — `DeleteGame` is
|
||||||
|
// destructive and bypasses the cascade-notification machinery.
|
||||||
func (s *Service) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
|
func (s *Service) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
|
||||||
if err := s.deps.Store.DeleteGame(ctx, gameID); err != nil {
|
if err := s.deps.Store.DeleteGame(ctx, gameID); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
package lobby
|
package lobby
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -29,7 +28,6 @@ import (
|
|||||||
|
|
||||||
"galaxy/backend/internal/config"
|
"galaxy/backend/internal/config"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -209,17 +207,6 @@ func (s *Service) Config() config.LobbyConfig {
|
|||||||
return s.deps.Config
|
return s.deps.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsPaid reports whether userID currently sits on a paid tier. Thin
|
|
||||||
// pass-through over EntitlementProvider used by the HTTP handler that
|
|
||||||
// fronts user-driven private-game creation; admin-driven public-game
|
|
||||||
// creation does not consult this gate.
|
|
||||||
func (s *Service) IsPaid(ctx context.Context, userID uuid.UUID) (bool, error) {
|
|
||||||
if s == nil || s.deps.Entitlement == nil {
|
|
||||||
return false, fmt.Errorf("lobby: entitlement provider not configured")
|
|
||||||
}
|
|
||||||
return s.deps.Entitlement.IsPaid(ctx, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateInviteCode produces an `inviteCodeBytes`-byte hex code used
|
// generateInviteCode produces an `inviteCodeBytes`-byte hex code used
|
||||||
// for code-based invites. The function uses `crypto/rand`; a failure to
|
// for code-based invites. The function uses `crypto/rand`; a failure to
|
||||||
// read entropy is propagated to the caller.
|
// read entropy is propagated to the caller.
|
||||||
|
|||||||
@@ -103,10 +103,6 @@ func (s stubEntitlement) GetMaxRegisteredRaceNames(_ context.Context, _ uuid.UUI
|
|||||||
return s.max, nil
|
return s.max, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s stubEntitlement) IsPaid(_ context.Context, _ uuid.UUID) (bool, error) {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newServiceForTest(t *testing.T, db *sql.DB, now func() time.Time, max int32) *lobby.Service {
|
func newServiceForTest(t *testing.T, db *sql.DB, now func() time.Time, max int32) *lobby.Service {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
store := lobby.NewStore(db)
|
store := lobby.NewStore(db)
|
||||||
@@ -248,8 +244,8 @@ func TestEndToEndPrivateGameFlow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDeleteGameCascadesEverything pins the DeleteGame contract:
|
// TestDeleteGameCascadesEverything pins the contract the dev-sandbox
|
||||||
// removing a game wipes every referencing row
|
// bootstrap relies on: removing a game wipes every referencing row
|
||||||
// (memberships, applications, invites, runtime_records,
|
// (memberships, applications, invites, runtime_records,
|
||||||
// player_mappings) in a single SQL statement. Before this is wired
|
// player_mappings) in a single SQL statement. Before this is wired
|
||||||
// the developer's lobby pile up cancelled tiles between
|
// the developer's lobby pile up cancelled tiles between
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ type InsertMembershipDirectInput struct {
|
|||||||
// writes as ApproveApplication: the per-game race-name reservation
|
// writes as ApproveApplication: the per-game race-name reservation
|
||||||
// row plus the membership row, and refreshes the in-memory caches.
|
// row plus the membership row, and refreshes the in-memory caches.
|
||||||
//
|
//
|
||||||
// The method is intended for trusted boot-time provisioning and
|
// The method is intended for boot-time provisioning by
|
||||||
// integration tests; it is not exposed through any HTTP handler. The
|
// `backend/internal/devsandbox` and similar trusted callers. It is
|
||||||
// caller must guarantee
|
// not exposed through any HTTP handler. The caller must guarantee
|
||||||
// game.Status == GameStatusEnrollmentOpen — the function returns
|
// game.Status == GameStatusEnrollmentOpen — the function returns
|
||||||
// ErrConflict otherwise — and that the race-name policy and
|
// ErrConflict otherwise — and that the race-name policy and
|
||||||
// canonical-key invariants are honoured (the implementation reuses
|
// canonical-key invariants are honoured (the implementation reuses
|
||||||
@@ -30,8 +30,9 @@ type InsertMembershipDirectInput struct {
|
|||||||
// or unsuitable name still fails).
|
// or unsuitable name still fails).
|
||||||
//
|
//
|
||||||
// Idempotency: if a membership for (GameID, UserID) already exists
|
// Idempotency: if a membership for (GameID, UserID) already exists
|
||||||
// the function returns the existing row without modifying state, so
|
// the function returns the existing row without modifying state.
|
||||||
// the helper is safe to call repeatedly.
|
// This makes the helper safe to call on every backend boot from
|
||||||
|
// devsandbox.Bootstrap.
|
||||||
func (s *Service) InsertMembershipDirect(ctx context.Context, in InsertMembershipDirectInput) (Membership, error) {
|
func (s *Service) InsertMembershipDirect(ctx context.Context, in InsertMembershipDirectInput) (Membership, error) {
|
||||||
displayName, err := ValidateDisplayName(in.RaceName)
|
displayName, err := ValidateDisplayName(in.RaceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -236,8 +236,9 @@ func (s *Store) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameRecord
|
|||||||
// referencing table (memberships / applications / invites /
|
// referencing table (memberships / applications / invites /
|
||||||
// runtime_records / player_mappings — all declared with ON DELETE
|
// runtime_records / player_mappings — all declared with ON DELETE
|
||||||
// CASCADE in `00001_init.sql`). Idempotent: returns nil when no row
|
// CASCADE in `00001_init.sql`). Idempotent: returns nil when no row
|
||||||
// matches. A hard delete for trusted callers and integration tests;
|
// matches. Used by the dev-sandbox bootstrap to scrub terminal
|
||||||
// production lifecycle uses cancel / finish.
|
// games on every backend boot so the developer's lobby never piles
|
||||||
|
// up cancelled tiles.
|
||||||
func (s *Store) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
|
func (s *Store) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
|
||||||
g := table.Games
|
g := table.Games
|
||||||
stmt := g.DELETE().WHERE(g.GameID.EQ(postgres.UUID(gameID)))
|
stmt := g.DELETE().WHERE(g.GameID.EQ(postgres.UUID(gameID)))
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
// Package opsstatus reads point-in-time operational signals from Postgres for
|
|
||||||
// the admin console dashboard: database reachability, per-status counts of game
|
|
||||||
// runtimes, mail deliveries, and notification routes, plus the malformed
|
|
||||||
// notification-intent count.
|
|
||||||
//
|
|
||||||
// It is a read-only projection built entirely through the go-jet query builder
|
|
||||||
// against the generated table bindings; it owns no business logic and mutates
|
|
||||||
// nothing. Richer, historical metrics are out of scope — those belong to the
|
|
||||||
// Prometheus exporters wired on `backend` and `gateway`.
|
|
||||||
package opsstatus
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"galaxy/backend/internal/postgres/jet/backend/table"
|
|
||||||
|
|
||||||
"github.com/go-jet/jet/v2/postgres"
|
|
||||||
)
|
|
||||||
|
|
||||||
// defaultCollectTimeout bounds a single Collect call so a slow or wedged
|
|
||||||
// database cannot hang the dashboard request.
|
|
||||||
const defaultCollectTimeout = 3 * time.Second
|
|
||||||
|
|
||||||
// StatusCount pairs a status value with the number of rows currently in it.
|
|
||||||
type StatusCount struct {
|
|
||||||
Status string
|
|
||||||
Count int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// Snapshot is a point-in-time view of the operational signals rendered on the
|
|
||||||
// dashboard. Errors collects per-query failures so a single failing probe
|
|
||||||
// degrades to a visible note rather than failing the whole page.
|
|
||||||
type Snapshot struct {
|
|
||||||
PostgresHealthy bool
|
|
||||||
Runtimes []StatusCount
|
|
||||||
MailDeliveries []StatusCount
|
|
||||||
NotificationRoutes []StatusCount
|
|
||||||
NotificationMalformed int64
|
|
||||||
Errors []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reader collects an operational Snapshot. The admin console depends on this
|
|
||||||
// interface so the dashboard can be tested without a database.
|
|
||||||
type Reader interface {
|
|
||||||
Collect(ctx context.Context) Snapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store is the Postgres-backed Reader.
|
|
||||||
type Store struct {
|
|
||||||
db *sql.DB
|
|
||||||
timeout time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewStore constructs a Store reading from db.
|
|
||||||
func NewStore(db *sql.DB) *Store {
|
|
||||||
return &Store{db: db, timeout: defaultCollectTimeout}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect gathers the dashboard signals within a bounded timeout. It never
|
|
||||||
// returns an error: a failed probe is recorded in Snapshot.Errors and the
|
|
||||||
// remaining probes still run, except that a failed Postgres ping short-circuits
|
|
||||||
// the rest (the dependent queries would only fail the same way).
|
|
||||||
func (s *Store) Collect(ctx context.Context) Snapshot {
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, s.timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
var snap Snapshot
|
|
||||||
|
|
||||||
if err := s.db.PingContext(ctx); err != nil {
|
|
||||||
snap.Errors = append(snap.Errors, fmt.Sprintf("postgres ping: %v", err))
|
|
||||||
return snap
|
|
||||||
}
|
|
||||||
snap.PostgresHealthy = true
|
|
||||||
|
|
||||||
if counts, err := s.statusCounts(ctx, table.RuntimeRecords.Status, table.RuntimeRecords); err != nil {
|
|
||||||
snap.Errors = append(snap.Errors, fmt.Sprintf("runtime status counts: %v", err))
|
|
||||||
} else {
|
|
||||||
snap.Runtimes = counts
|
|
||||||
}
|
|
||||||
|
|
||||||
if counts, err := s.statusCounts(ctx, table.MailDeliveries.Status, table.MailDeliveries); err != nil {
|
|
||||||
snap.Errors = append(snap.Errors, fmt.Sprintf("mail delivery counts: %v", err))
|
|
||||||
} else {
|
|
||||||
snap.MailDeliveries = counts
|
|
||||||
}
|
|
||||||
|
|
||||||
if counts, err := s.statusCounts(ctx, table.NotificationRoutes.Status, table.NotificationRoutes); err != nil {
|
|
||||||
snap.Errors = append(snap.Errors, fmt.Sprintf("notification route counts: %v", err))
|
|
||||||
} else {
|
|
||||||
snap.NotificationRoutes = counts
|
|
||||||
}
|
|
||||||
|
|
||||||
if n, err := s.countAll(ctx, table.NotificationMalformedIntents); err != nil {
|
|
||||||
snap.Errors = append(snap.Errors, fmt.Sprintf("malformed notification count: %v", err))
|
|
||||||
} else {
|
|
||||||
snap.NotificationMalformed = n
|
|
||||||
}
|
|
||||||
|
|
||||||
return snap
|
|
||||||
}
|
|
||||||
|
|
||||||
// statusCounts runs `SELECT status, COUNT(*) FROM <from> GROUP BY status`
|
|
||||||
// through jet and returns the rows ordered by status.
|
|
||||||
func (s *Store) statusCounts(ctx context.Context, status postgres.ColumnString, from postgres.ReadableTable) ([]StatusCount, error) {
|
|
||||||
stmt := postgres.SELECT(
|
|
||||||
status.AS("status_count.status"),
|
|
||||||
postgres.COUNT(postgres.STAR).AS("status_count.count"),
|
|
||||||
).FROM(from).GROUP_BY(status).ORDER_BY(status.ASC())
|
|
||||||
|
|
||||||
var rows []struct {
|
|
||||||
Status string `alias:"status_count.status"`
|
|
||||||
Count int64 `alias:"status_count.count"`
|
|
||||||
}
|
|
||||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]StatusCount, len(rows))
|
|
||||||
for i, row := range rows {
|
|
||||||
out[i] = StatusCount{Status: row.Status, Count: row.Count}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// countAll runs `SELECT COUNT(*) FROM <from>` through jet.
|
|
||||||
func (s *Store) countAll(ctx context.Context, from postgres.ReadableTable) (int64, error) {
|
|
||||||
stmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).FROM(from)
|
|
||||||
|
|
||||||
var row struct {
|
|
||||||
Count int64 `alias:"count"`
|
|
||||||
}
|
|
||||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return row.Count, nil
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
package opsstatus_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"net/url"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"galaxy/backend/internal/mail"
|
|
||||||
"galaxy/backend/internal/opsstatus"
|
|
||||||
backendpg "galaxy/backend/internal/postgres"
|
|
||||||
pgshared "galaxy/postgres"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
testcontainers "github.com/testcontainers/testcontainers-go"
|
|
||||||
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
|
||||||
"github.com/testcontainers/testcontainers-go/wait"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
pgImage = "postgres:16-alpine"
|
|
||||||
pgUser = "galaxy"
|
|
||||||
pgPassword = "galaxy"
|
|
||||||
pgDatabase = "galaxy_backend"
|
|
||||||
pgSchema = "backend"
|
|
||||||
pgStartup = 90 * time.Second
|
|
||||||
pgOpTO = 10 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
// startPostgres mirrors the per-package scaffolding used by the other store
|
|
||||||
// tests: spin up Postgres, apply migrations, return *sql.DB.
|
|
||||||
func startPostgres(t *testing.T) *sql.DB {
|
|
||||||
t.Helper()
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
|
||||||
t.Cleanup(cancel)
|
|
||||||
|
|
||||||
pgContainer, err := tcpostgres.Run(ctx, pgImage,
|
|
||||||
tcpostgres.WithDatabase(pgDatabase),
|
|
||||||
tcpostgres.WithUsername(pgUser),
|
|
||||||
tcpostgres.WithPassword(pgPassword),
|
|
||||||
testcontainers.WithWaitStrategy(
|
|
||||||
wait.ForLog("database system is ready to accept connections").
|
|
||||||
WithOccurrence(2).
|
|
||||||
WithStartupTimeout(pgStartup),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Skipf("postgres testcontainer unavailable, skipping: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
if termErr := testcontainers.TerminateContainer(pgContainer); termErr != nil {
|
|
||||||
t.Errorf("terminate postgres container: %v", termErr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
baseDSN, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("connection string: %v", err)
|
|
||||||
}
|
|
||||||
scopedDSN, err := dsnWithSearchPath(baseDSN, pgSchema)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("scope dsn: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := pgshared.DefaultConfig()
|
|
||||||
cfg.PrimaryDSN = scopedDSN
|
|
||||||
cfg.OperationTimeout = pgOpTO
|
|
||||||
|
|
||||||
db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open primary: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = db.Close() })
|
|
||||||
|
|
||||||
if err := pgshared.Ping(ctx, db, cfg.OperationTimeout); err != nil {
|
|
||||||
t.Fatalf("ping: %v", err)
|
|
||||||
}
|
|
||||||
if err := backendpg.ApplyMigrations(ctx, db); err != nil {
|
|
||||||
t.Fatalf("apply migrations: %v", err)
|
|
||||||
}
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
|
|
||||||
func dsnWithSearchPath(baseDSN, schema string) (string, error) {
|
|
||||||
parsed, err := url.Parse(baseDSN)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
values := parsed.Query()
|
|
||||||
values.Set("search_path", schema)
|
|
||||||
if values.Get("sslmode") == "" {
|
|
||||||
values.Set("sslmode", "disable")
|
|
||||||
}
|
|
||||||
parsed.RawQuery = values.Encode()
|
|
||||||
return parsed.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStoreCollect(t *testing.T) {
|
|
||||||
db := startPostgres(t)
|
|
||||||
store := opsstatus.NewStore(db)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Empty schema: queries must execute cleanly with zero counts.
|
|
||||||
empty := store.Collect(ctx)
|
|
||||||
if !empty.PostgresHealthy {
|
|
||||||
t.Fatal("PostgresHealthy must be true against a reachable database")
|
|
||||||
}
|
|
||||||
if len(empty.Errors) != 0 {
|
|
||||||
t.Fatalf("unexpected collection errors: %v", empty.Errors)
|
|
||||||
}
|
|
||||||
if got := totalCount(empty.MailDeliveries); got != 0 {
|
|
||||||
t.Fatalf("mail deliveries total = %d, want 0", got)
|
|
||||||
}
|
|
||||||
if len(empty.Runtimes) != 0 || len(empty.NotificationRoutes) != 0 {
|
|
||||||
t.Fatalf("expected empty status slices, got runtimes=%v routes=%v", empty.Runtimes, empty.NotificationRoutes)
|
|
||||||
}
|
|
||||||
if empty.NotificationMalformed != 0 {
|
|
||||||
t.Fatalf("malformed notifications = %d, want 0", empty.NotificationMalformed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enqueue one mail delivery and confirm the GROUP BY count reflects it.
|
|
||||||
mailStore := mail.NewStore(db)
|
|
||||||
inserted, err := mailStore.InsertEnqueue(ctx, mail.EnqueueArgs{
|
|
||||||
DeliveryID: uuid.New(),
|
|
||||||
TemplateID: mail.TemplateLoginCode,
|
|
||||||
IdempotencyKey: uuid.NewString(),
|
|
||||||
Recipients: []string{"ops@example.test"},
|
|
||||||
ContentType: "text/plain",
|
|
||||||
Subject: "hello",
|
|
||||||
Body: []byte("hi"),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert mail delivery: %v", err)
|
|
||||||
}
|
|
||||||
if !inserted {
|
|
||||||
t.Fatal("expected the delivery to be inserted")
|
|
||||||
}
|
|
||||||
|
|
||||||
after := store.Collect(ctx)
|
|
||||||
if len(after.Errors) != 0 {
|
|
||||||
t.Fatalf("unexpected collection errors after insert: %v", after.Errors)
|
|
||||||
}
|
|
||||||
if got := totalCount(after.MailDeliveries); got != 1 {
|
|
||||||
t.Fatalf("mail deliveries total after insert = %d, want 1 (statuses: %v)", got, after.MailDeliveries)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func totalCount(counts []opsstatus.StatusCount) int64 {
|
|
||||||
var total int64
|
|
||||||
for _, c := range counts {
|
|
||||||
total += c.Count
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,6 @@ type DiplomailMessages struct {
|
|||||||
SenderKind string
|
SenderKind string
|
||||||
SenderUserID *uuid.UUID
|
SenderUserID *uuid.UUID
|
||||||
SenderUsername *string
|
SenderUsername *string
|
||||||
SenderRaceName *string
|
|
||||||
SenderIP string
|
SenderIP string
|
||||||
Subject string
|
Subject string
|
||||||
Body string
|
Body string
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ type diplomailMessagesTable struct {
|
|||||||
SenderKind postgres.ColumnString
|
SenderKind postgres.ColumnString
|
||||||
SenderUserID postgres.ColumnString
|
SenderUserID postgres.ColumnString
|
||||||
SenderUsername postgres.ColumnString
|
SenderUsername postgres.ColumnString
|
||||||
SenderRaceName postgres.ColumnString
|
|
||||||
SenderIP postgres.ColumnString
|
SenderIP postgres.ColumnString
|
||||||
Subject postgres.ColumnString
|
Subject postgres.ColumnString
|
||||||
Body postgres.ColumnString
|
Body postgres.ColumnString
|
||||||
@@ -79,15 +78,14 @@ func newDiplomailMessagesTableImpl(schemaName, tableName, alias string) diplomai
|
|||||||
SenderKindColumn = postgres.StringColumn("sender_kind")
|
SenderKindColumn = postgres.StringColumn("sender_kind")
|
||||||
SenderUserIDColumn = postgres.StringColumn("sender_user_id")
|
SenderUserIDColumn = postgres.StringColumn("sender_user_id")
|
||||||
SenderUsernameColumn = postgres.StringColumn("sender_username")
|
SenderUsernameColumn = postgres.StringColumn("sender_username")
|
||||||
SenderRaceNameColumn = postgres.StringColumn("sender_race_name")
|
|
||||||
SenderIPColumn = postgres.StringColumn("sender_ip")
|
SenderIPColumn = postgres.StringColumn("sender_ip")
|
||||||
SubjectColumn = postgres.StringColumn("subject")
|
SubjectColumn = postgres.StringColumn("subject")
|
||||||
BodyColumn = postgres.StringColumn("body")
|
BodyColumn = postgres.StringColumn("body")
|
||||||
BodyLangColumn = postgres.StringColumn("body_lang")
|
BodyLangColumn = postgres.StringColumn("body_lang")
|
||||||
BroadcastScopeColumn = postgres.StringColumn("broadcast_scope")
|
BroadcastScopeColumn = postgres.StringColumn("broadcast_scope")
|
||||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderRaceNameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
|
allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
|
||||||
mutableColumns = postgres.ColumnList{GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderRaceNameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
|
mutableColumns = postgres.ColumnList{GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
|
||||||
defaultColumns = postgres.ColumnList{SenderIPColumn, SubjectColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
|
defaultColumns = postgres.ColumnList{SenderIPColumn, SubjectColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -102,7 +100,6 @@ func newDiplomailMessagesTableImpl(schemaName, tableName, alias string) diplomai
|
|||||||
SenderKind: SenderKindColumn,
|
SenderKind: SenderKindColumn,
|
||||||
SenderUserID: SenderUserIDColumn,
|
SenderUserID: SenderUserIDColumn,
|
||||||
SenderUsername: SenderUsernameColumn,
|
SenderUsername: SenderUsernameColumn,
|
||||||
SenderRaceName: SenderRaceNameColumn,
|
|
||||||
SenderIP: SenderIPColumn,
|
SenderIP: SenderIPColumn,
|
||||||
Subject: SubjectColumn,
|
Subject: SubjectColumn,
|
||||||
Body: BodyColumn,
|
Body: BodyColumn,
|
||||||
|
|||||||
@@ -683,11 +683,6 @@ CREATE TABLE diplomail_messages (
|
|||||||
sender_kind text NOT NULL,
|
sender_kind text NOT NULL,
|
||||||
sender_user_id uuid,
|
sender_user_id uuid,
|
||||||
sender_username text,
|
sender_username text,
|
||||||
-- sender_race_name is the immutable snapshot of the sender's race
|
|
||||||
-- in this game, captured at insert time when sender_kind='player'.
|
|
||||||
-- Admin and system messages carry NULL. The Phase 28 mail UI keys
|
|
||||||
-- per-race threading on this column.
|
|
||||||
sender_race_name text,
|
|
||||||
sender_ip text NOT NULL DEFAULT '',
|
sender_ip text NOT NULL DEFAULT '',
|
||||||
subject text NOT NULL DEFAULT '',
|
subject text NOT NULL DEFAULT '',
|
||||||
body text NOT NULL,
|
body text NOT NULL,
|
||||||
@@ -703,13 +698,6 @@ CREATE TABLE diplomail_messages (
|
|||||||
(sender_kind = 'admin' AND sender_user_id IS NULL AND sender_username IS NOT NULL) OR
|
(sender_kind = 'admin' AND sender_user_id IS NULL AND sender_username IS NOT NULL) OR
|
||||||
(sender_kind = 'system' AND sender_user_id IS NULL AND sender_username IS NULL)
|
(sender_kind = 'system' AND sender_user_id IS NULL AND sender_username IS NULL)
|
||||||
),
|
),
|
||||||
-- sender_race_name is only meaningful for player senders. Admin
|
|
||||||
-- and system rows never carry a race; player rows carry one when
|
|
||||||
-- the sender has an active membership at send time (a non-playing
|
|
||||||
-- private-game owner may legitimately have none).
|
|
||||||
CONSTRAINT diplomail_messages_sender_race_chk CHECK (
|
|
||||||
sender_kind = 'player' OR sender_race_name IS NULL
|
|
||||||
),
|
|
||||||
CONSTRAINT diplomail_messages_kind_sender_chk CHECK (
|
CONSTRAINT diplomail_messages_kind_sender_chk CHECK (
|
||||||
(kind = 'personal' AND sender_kind = 'player') OR
|
(kind = 'personal' AND sender_kind = 'player') OR
|
||||||
(kind = 'admin' AND sender_kind IN ('player', 'admin', 'system'))
|
(kind = 'admin' AND sender_kind IN ('player', 'admin', 'system'))
|
||||||
|
|||||||
@@ -1,46 +1,26 @@
|
|||||||
# Backend migrations
|
# Backend migrations
|
||||||
|
|
||||||
Goose (`pressly/goose/v3`) migrations embedded into the backend binary
|
Goose migrations embedded into the backend binary by `embed.go`. Applied
|
||||||
by `embed.go`. Applied at startup before any listener opens — see
|
at startup before any listener opens (see `internal/postgres`).
|
||||||
`internal/postgres`.
|
|
||||||
|
|
||||||
## Authoring conventions
|
## Pre-production single-file rule
|
||||||
|
|
||||||
- Each schema change is a new file with a monotonically increasing
|
**While the platform is not yet in production, every schema change goes
|
||||||
numeric prefix and a snake-case slug:
|
into the existing `00001_init.sql` file** rather than a new
|
||||||
`0000N_short_description.sql`. Reuse of a prefix is forbidden once
|
`00002_*`-prefixed file. The intent is to keep the schema in one
|
||||||
the file is merged.
|
canonical place so reviewers and developers do not have to reconstruct
|
||||||
- `00001_init.sql` is the historical baseline. Treat it as immutable
|
the latest shape from a chain of incremental migrations.
|
||||||
history; do not edit it to land new schema. Squashing the chain back
|
|
||||||
into a fresh `00001` is reserved for the explicit pre-production
|
|
||||||
cut-over.
|
|
||||||
- Every file MUST contain both an `-- +goose Up` and `-- +goose Down`
|
|
||||||
section, even if Down is a single `DROP …` for the same artefacts.
|
|
||||||
Down migrations are exercised by the schema test and serve as the
|
|
||||||
documented rollback path.
|
|
||||||
- Destructive changes (dropping columns/tables, renaming with data
|
|
||||||
loss) MUST be split into at least two migrations so the chain stays
|
|
||||||
rollable forward and backward without coordinated code+schema
|
|
||||||
windows:
|
|
||||||
1. add the new shape, dual-write the data, leave the old shape in
|
|
||||||
place;
|
|
||||||
2. once all readers have switched, drop the old shape in a follow-up
|
|
||||||
migration.
|
|
||||||
- Migrations are applied automatically on backend startup, so a fresh
|
|
||||||
push to `development` plus the `dev-deploy.yaml` workflow brings the
|
|
||||||
long-lived dev database up to head without manual intervention.
|
|
||||||
`make -C tools/dev-deploy clean-data` is only needed when a developer
|
|
||||||
deliberately wants a fresh database.
|
|
||||||
- The integration harness (`backend/internal/postgres/migrations_test.go`)
|
|
||||||
spins up a disposable Postgres per run and asserts the final table
|
|
||||||
set. When a migration adds or removes tables, update the expected
|
|
||||||
list in the same patch.
|
|
||||||
|
|
||||||
## Pre-production squash
|
Operationally this means that pulling a branch with schema changes
|
||||||
|
requires a fresh database — the only consumer today is local development
|
||||||
|
and integration tests, both of which spin up disposable Postgres
|
||||||
|
instances.
|
||||||
|
|
||||||
The chain may be squashed back into one clean `00001_init.sql` before
|
> **Remove this rule before the first production deployment.** From
|
||||||
the first production deployment. That is a deliberate, one-time
|
> that point on every schema change must be a new migration file with a
|
||||||
operation; until then, additive numbered files are the rule. After the
|
> monotonically increasing prefix, and `00001_init.sql` becomes
|
||||||
squash this file gets a short note that `00001_init.sql` represents
|
> immutable history.
|
||||||
the production baseline and the policy above continues to apply for
|
|
||||||
every later migration.
|
If you need to make a change, edit `00001_init.sql` directly. Down
|
||||||
|
migrations should still be kept in sync (they live at the bottom of the
|
||||||
|
file — currently a single `DROP SCHEMA backend CASCADE`).
|
||||||
|
|||||||
@@ -10,10 +10,7 @@ package postgres
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"database/sql/driver"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"galaxy/backend/internal/config"
|
"galaxy/backend/internal/config"
|
||||||
@@ -70,29 +67,13 @@ func Open(ctx context.Context, cfg config.PostgresConfig, runtime *telemetry.Run
|
|||||||
// backend table lives here.
|
// backend table lives here.
|
||||||
const schemaName = "backend"
|
const schemaName = "backend"
|
||||||
|
|
||||||
// migrationRetryAttempts and migrationRetryBackoff bound the transient-error
|
|
||||||
// retry around ApplyMigrations. A freshly started Postgres — notably a test
|
|
||||||
// container — can reset a pooled connection moments after it reports ready,
|
|
||||||
// which surfaces as `driver: bad connection` mid-migration; a handful of quick
|
|
||||||
// retries rides over that without masking real failures.
|
|
||||||
const (
|
|
||||||
migrationRetryAttempts = 5
|
|
||||||
migrationRetryBackoff = 250 * time.Millisecond
|
|
||||||
)
|
|
||||||
|
|
||||||
// ApplyMigrations runs every pending Up migration embedded in the backend
|
// ApplyMigrations runs every pending Up migration embedded in the backend
|
||||||
// binary against db. The schema is created upfront so goose's bookkeeping
|
// binary against db. The schema is created upfront so goose's bookkeeping
|
||||||
// table (`goose_db_version`, scoped to the DSN `search_path = backend`)
|
// table (`goose_db_version`, scoped to the DSN `search_path = backend`)
|
||||||
// has somewhere to land before the first migration runs; migration
|
// has somewhere to land before the first migration runs; migration
|
||||||
// `00001_init.sql` re-asserts the schema with `IF NOT EXISTS`, so the
|
// `00001_init.sql` re-asserts the schema with `IF NOT EXISTS`, so the
|
||||||
// double-create is idempotent.
|
// double-create is idempotent.
|
||||||
//
|
|
||||||
// The apply is retried on transient connection errors (see retryOnTransient).
|
|
||||||
// Both steps are idempotent — `CREATE SCHEMA IF NOT EXISTS` and goose's
|
|
||||||
// version tracking — so a retry after a dropped connection re-runs cleanly and
|
|
||||||
// resumes from the last committed migration.
|
|
||||||
func ApplyMigrations(ctx context.Context, db *sql.DB) error {
|
func ApplyMigrations(ctx context.Context, db *sql.DB) error {
|
||||||
return retryOnTransient(ctx, migrationRetryAttempts, migrationRetryBackoff, func() error {
|
|
||||||
if _, err := db.ExecContext(ctx, "CREATE SCHEMA IF NOT EXISTS "+schemaName); err != nil {
|
if _, err := db.ExecContext(ctx, "CREATE SCHEMA IF NOT EXISTS "+schemaName); err != nil {
|
||||||
return fmt.Errorf("ensure backend schema: %w", err)
|
return fmt.Errorf("ensure backend schema: %w", err)
|
||||||
}
|
}
|
||||||
@@ -100,54 +81,4 @@ func ApplyMigrations(ctx context.Context, db *sql.DB) error {
|
|||||||
return fmt.Errorf("apply backend migrations: %w", err)
|
return fmt.Errorf("apply backend migrations: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// retryOnTransient runs op up to attempts times, retrying only when op fails
|
|
||||||
// with a transient connection error (see isTransientConnError) — a dropped,
|
|
||||||
// reset, or refused connection, as opposed to a deterministic SQL error. It
|
|
||||||
// waits backoff between attempts and stops early if ctx is cancelled. A
|
|
||||||
// non-transient error, or the error from the final attempt, is returned as-is.
|
|
||||||
func retryOnTransient(ctx context.Context, attempts int, backoff time.Duration, op func() error) error {
|
|
||||||
var err error
|
|
||||||
for attempt := 1; attempt <= attempts; attempt++ {
|
|
||||||
if err = op(); err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if attempt == attempts || !isTransientConnError(err) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return errors.Join(err, ctx.Err())
|
|
||||||
case <-time.After(backoff):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// isTransientConnError reports whether err is a transient connection-level
|
|
||||||
// failure worth retrying. It matches database/sql's driver.ErrBadConn and the
|
|
||||||
// connection-failure messages Postgres drivers surface, while leaving
|
|
||||||
// deterministic SQL errors (syntax, constraint violations) to fail fast.
|
|
||||||
func isTransientConnError(err error) bool {
|
|
||||||
if err == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if errors.Is(err, driver.ErrBadConn) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
msg := strings.ToLower(err.Error())
|
|
||||||
for _, s := range []string{
|
|
||||||
"bad connection",
|
|
||||||
"connection refused",
|
|
||||||
"connection reset",
|
|
||||||
"broken pipe",
|
|
||||||
"server closed the connection",
|
|
||||||
} {
|
|
||||||
if strings.Contains(msg, s) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql/driver"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestIsTransientConnError(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
err error
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"nil", nil, false},
|
|
||||||
{"driver.ErrBadConn", driver.ErrBadConn, true},
|
|
||||||
{"wrapped ErrBadConn", fmt.Errorf("run migrations: %w", driver.ErrBadConn), true},
|
|
||||||
// The exact shape observed flaking CI: goose surfaces the driver
|
|
||||||
// error as a plain string, so errors.Is can't see ErrBadConn.
|
|
||||||
{"bad connection string", errors.New(`apply backend migrations: run migrations: ERROR 00001_init.sql: CREATE TABLE race_names: driver: bad connection`), true},
|
|
||||||
{"connection refused", errors.New("dial tcp 127.0.0.1:5432: connect: connection refused"), true},
|
|
||||||
{"connection reset", errors.New("read tcp: connection reset by peer"), true},
|
|
||||||
{"broken pipe", errors.New("write tcp: broken pipe"), true},
|
|
||||||
{"server closed", errors.New("pq: server closed the connection unexpectedly"), true},
|
|
||||||
{"syntax error is not transient", errors.New(`pq: syntax error at or near "TABL"`), false},
|
|
||||||
{"constraint violation is not transient", errors.New("pq: duplicate key value violates unique constraint"), false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
assert.Equal(t, tt.want, isTransientConnError(tt.err))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRetryOnTransientSucceedsAfterTransientFailures(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
calls := 0
|
|
||||||
err := retryOnTransient(context.Background(), 5, time.Millisecond, func() error {
|
|
||||||
calls++
|
|
||||||
if calls < 3 {
|
|
||||||
return fmt.Errorf("attempt %d: %w", calls, driver.ErrBadConn)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, 3, calls, "should retry until the transient error clears")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRetryOnTransientStopsOnNonTransient(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
sentinel := errors.New(`pq: syntax error at or near "TABL"`)
|
|
||||||
calls := 0
|
|
||||||
err := retryOnTransient(context.Background(), 5, time.Millisecond, func() error {
|
|
||||||
calls++
|
|
||||||
return sentinel
|
|
||||||
})
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, sentinel)
|
|
||||||
assert.Equal(t, 1, calls, "a deterministic SQL error must not be retried")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRetryOnTransientExhaustsAttempts(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
calls := 0
|
|
||||||
err := retryOnTransient(context.Background(), 3, time.Millisecond, func() error {
|
|
||||||
calls++
|
|
||||||
return driver.ErrBadConn
|
|
||||||
})
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, driver.ErrBadConn)
|
|
||||||
assert.Equal(t, 3, calls, "must stop after the attempt budget is spent")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRetryOnTransientRespectsContextCancellation(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
calls := 0
|
|
||||||
err := retryOnTransient(ctx, 5, time.Hour, func() error {
|
|
||||||
calls++
|
|
||||||
return driver.ErrBadConn
|
|
||||||
})
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, context.Canceled)
|
|
||||||
require.ErrorIs(t, err, driver.ErrBadConn, "the underlying transient error is preserved")
|
|
||||||
assert.Equal(t, 1, calls, "cancellation during backoff stops further attempts")
|
|
||||||
}
|
|
||||||
@@ -52,7 +52,7 @@ var (
|
|||||||
ErrTurnAlreadyClosed = errors.New("runtime: turn already closed")
|
ErrTurnAlreadyClosed = errors.New("runtime: turn already closed")
|
||||||
|
|
||||||
// ErrGamePaused reports that the game is not in a state that
|
// ErrGamePaused reports that the game is not in a state that
|
||||||
// accepts user-games orders: the runtime row
|
// accepts user-games commands or orders: the runtime row
|
||||||
// carries `paused = true`, or the runtime status lands on any
|
// carries `paused = true`, or the runtime status lands on any
|
||||||
// terminal value (`engine_unreachable`, `generation_failed`,
|
// terminal value (`engine_unreachable`, `generation_failed`,
|
||||||
// `stopped`, `finished`, `removed`), or the game has not yet
|
// `stopped`, `finished`, `removed`), or the game has not yet
|
||||||
|
|||||||
@@ -258,10 +258,10 @@ func (s *Service) ResolvePlayerMapping(ctx context.Context, gameID, userID uuid.
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CheckOrdersAccept verifies that the runtime is in a state that
|
// CheckOrdersAccept verifies that the runtime is in a state that
|
||||||
// accepts user-games orders. It is called by the user game-proxy
|
// accepts user-games commands and orders. It is called by the user
|
||||||
// handler (`Orders`) before forwarding to engine, so the backend's
|
// game-proxy handlers (`Commands`, `Orders`) before forwarding to
|
||||||
// turn-cutoff and pause guards run before network traffic leaves the
|
// engine, so the backend's turn-cutoff and pause guards run before
|
||||||
// host. The decision itself lives in the
|
// network traffic leaves the host. The decision itself lives in the
|
||||||
// pure helper `OrdersAcceptStatus` so it can be unit-tested without
|
// pure helper `OrdersAcceptStatus` so it can be unit-tested without
|
||||||
// constructing a full Service.
|
// constructing a full Service.
|
||||||
//
|
//
|
||||||
@@ -276,7 +276,7 @@ func (s *Service) CheckOrdersAccept(ctx context.Context, gameID uuid.UUID) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OrdersAcceptStatus inspects a runtime record and returns the
|
// OrdersAcceptStatus inspects a runtime record and returns the
|
||||||
// matching sentinel for the user-games order pre-check:
|
// matching sentinel for the user-games order/command pre-check:
|
||||||
//
|
//
|
||||||
// - `runtime_status = generation_in_progress` → `ErrTurnAlreadyClosed`.
|
// - `runtime_status = generation_in_progress` → `ErrTurnAlreadyClosed`.
|
||||||
// The cron-driven `Scheduler.tick` has flipped the row before
|
// The cron-driven `Scheduler.tick` has flipped the row before
|
||||||
@@ -537,7 +537,10 @@ func (s *Service) runStart(ctx context.Context, op OperationLog) error {
|
|||||||
Env: map[string]string{
|
Env: map[string]string{
|
||||||
"GAME_STATE_PATH": statePath,
|
"GAME_STATE_PATH": statePath,
|
||||||
},
|
},
|
||||||
Labels: s.engineLabels(gameID.String(), version.Version),
|
Labels: map[string]string{
|
||||||
|
"galaxy.game_id": gameID.String(),
|
||||||
|
"galaxy.engine_version": version.Version,
|
||||||
|
},
|
||||||
BindMounts: []dockerclient.BindMount{
|
BindMounts: []dockerclient.BindMount{
|
||||||
{
|
{
|
||||||
HostPath: hostStatePath,
|
HostPath: hostStatePath,
|
||||||
@@ -607,7 +610,7 @@ func (s *Service) runStart(ctx context.Context, op OperationLog) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
initResp, err := s.deps.Engine.Init(ctx, runResult.EngineEndpoint, rest.InitRequest{GameID: gameID, Races: races})
|
initResp, err := s.deps.Engine.Init(ctx, runResult.EngineEndpoint, rest.InitRequest{Races: races})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.deps.Logger.Warn("engine init failed",
|
s.deps.Logger.Warn("engine init failed",
|
||||||
zap.String("game_id", gameID.String()),
|
zap.String("game_id", gameID.String()),
|
||||||
@@ -732,7 +735,10 @@ func (s *Service) runPatch(ctx context.Context, op OperationLog, target EngineVe
|
|||||||
Env: map[string]string{
|
Env: map[string]string{
|
||||||
"GAME_STATE_PATH": statePath,
|
"GAME_STATE_PATH": statePath,
|
||||||
},
|
},
|
||||||
Labels: s.engineLabels(op.GameID.String(), target.Version),
|
Labels: map[string]string{
|
||||||
|
"galaxy.game_id": op.GameID.String(),
|
||||||
|
"galaxy.engine_version": target.Version,
|
||||||
|
},
|
||||||
BindMounts: []dockerclient.BindMount{
|
BindMounts: []dockerclient.BindMount{
|
||||||
{HostPath: hostStatePath, MountPath: s.deps.Config.ContainerStateMount},
|
{HostPath: hostStatePath, MountPath: s.deps.Config.ContainerStateMount},
|
||||||
},
|
},
|
||||||
@@ -932,30 +938,6 @@ func (s *Service) upsertRuntimeRecord(ctx context.Context, in runtimeRecordInser
|
|||||||
// containers attach to. Wired from cfg.Docker.Network through Deps.
|
// containers attach to. Wired from cfg.Docker.Network through Deps.
|
||||||
func (s *Service) dockerNetwork() string { return s.deps.DockerNetwork }
|
func (s *Service) dockerNetwork() string { return s.deps.DockerNetwork }
|
||||||
|
|
||||||
// engineLabels returns the label set stamped on every engine container
|
|
||||||
// spawned for gameID running engineVersion. The runtime adapter merges
|
|
||||||
// `dockerclient.ManagedLabel` separately; this helper covers the
|
|
||||||
// game-scoped labels plus an optional `galaxy.stack=<value>` from the
|
|
||||||
// runtime config so host-side tooling can scope cleanup by dev stack
|
|
||||||
// without touching unrelated workloads.
|
|
||||||
func (s *Service) engineLabels(gameID, engineVersion string) map[string]string {
|
|
||||||
return engineLabels(gameID, engineVersion, s.deps.Config.StackLabel)
|
|
||||||
}
|
|
||||||
|
|
||||||
// engineLabels is the side-effect-free part of `(*Service).engineLabels`,
|
|
||||||
// exposed at package scope so unit tests can exercise the labelling
|
|
||||||
// rules without building a full Service.
|
|
||||||
func engineLabels(gameID, engineVersion, stackLabel string) map[string]string {
|
|
||||||
labels := map[string]string{
|
|
||||||
"galaxy.game_id": gameID,
|
|
||||||
"galaxy.engine_version": engineVersion,
|
|
||||||
}
|
|
||||||
if stackLabel != "" {
|
|
||||||
labels["galaxy.stack"] = stackLabel
|
|
||||||
}
|
|
||||||
return labels
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitForEngineHealthz polls the engine `/healthz` endpoint until it
|
// waitForEngineHealthz polls the engine `/healthz` endpoint until it
|
||||||
// responds 2xx or until the timeout elapses. The Docker daemon
|
// responds 2xx or until the timeout elapses. The Docker daemon
|
||||||
// reports a container as `running` as soon as the entrypoint starts,
|
// reports a container as `running` as soon as the entrypoint starts,
|
||||||
|
|||||||
@@ -203,13 +203,6 @@ func TestServiceStartGameEndToEnd(t *testing.T) {
|
|||||||
case "/healthz":
|
case "/healthz":
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
case "/api/v1/admin/init":
|
case "/api/v1/admin/init":
|
||||||
var got rest.InitRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
|
||||||
t.Errorf("decode init request: %v", err)
|
|
||||||
}
|
|
||||||
if got.GameID != gameID {
|
|
||||||
t.Errorf("init request gameId = %s, want %s", got.GameID, gameID)
|
|
||||||
}
|
|
||||||
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: gameID, Turn: 0, Players: []rest.PlayerState{{RaceName: "Alpha", Planets: 3, Population: 10}}})
|
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: gameID, Turn: 0, Players: []rest.PlayerState{{RaceName: "Alpha", Planets: 3, Population: 10}}})
|
||||||
case "/api/v1/admin/status":
|
case "/api/v1/admin/status":
|
||||||
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: gameID, Turn: 1, Players: []rest.PlayerState{{RaceName: "Alpha", Planets: 5, Population: 12}}})
|
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: gameID, Turn: 1, Players: []rest.PlayerState{{RaceName: "Alpha", Planets: 5, Population: 12}}})
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
package runtime
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestEngineLabels(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
gameID string
|
|
||||||
version string
|
|
||||||
stackLabel string
|
|
||||||
want map[string]string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "stack label omitted when empty",
|
|
||||||
gameID: "11111111-1111-1111-1111-111111111111",
|
|
||||||
version: "0.1.0",
|
|
||||||
stackLabel: "",
|
|
||||||
want: map[string]string{
|
|
||||||
"galaxy.game_id": "11111111-1111-1111-1111-111111111111",
|
|
||||||
"galaxy.engine_version": "0.1.0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "stack label included when set",
|
|
||||||
gameID: "22222222-2222-2222-2222-222222222222",
|
|
||||||
version: "0.2.3",
|
|
||||||
stackLabel: "dev-deploy",
|
|
||||||
want: map[string]string{
|
|
||||||
"galaxy.game_id": "22222222-2222-2222-2222-222222222222",
|
|
||||||
"galaxy.engine_version": "0.2.3",
|
|
||||||
"galaxy.stack": "dev-deploy",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
got := engineLabels(tc.gameID, tc.version, tc.stackLabel)
|
|
||||||
if len(got) != len(tc.want) {
|
|
||||||
t.Fatalf("len(labels) = %d, want %d (got %v)", len(got), len(tc.want), got)
|
|
||||||
}
|
|
||||||
for k, v := range tc.want {
|
|
||||||
if got[k] != v {
|
|
||||||
t.Errorf("labels[%q] = %q, want %q", k, got[k], v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"galaxy/backend/internal/adminconsole"
|
|
||||||
"galaxy/backend/internal/opsstatus"
|
|
||||||
"galaxy/backend/internal/server/httperr"
|
|
||||||
"galaxy/backend/internal/server/middleware/basicauth"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AdminConsoleHandlers renders the server-side operator console mounted under
|
|
||||||
// the `/_gm` route group. It wraps the framework-agnostic
|
|
||||||
// adminconsole.Renderer and CSRF signer with the gin glue: the per-page
|
|
||||||
// handlers, the embedded static-asset handler, and the CSRF guard middleware
|
|
||||||
// applied to state-changing requests. Authentication is provided by the shared
|
|
||||||
// admin Basic Auth middleware mounted on the group, so this type assumes the
|
|
||||||
// caller has already been verified.
|
|
||||||
type AdminConsoleHandlers struct {
|
|
||||||
renderer *adminconsole.Renderer
|
|
||||||
csrf *adminconsole.CSRF
|
|
||||||
assets http.Handler
|
|
||||||
monitor opsstatus.Reader
|
|
||||||
ready func() bool
|
|
||||||
users UserAdmin
|
|
||||||
games GameAdmin
|
|
||||||
runtime RuntimeAdmin
|
|
||||||
engineVersions EngineVersionAdmin
|
|
||||||
operators OperatorAdmin
|
|
||||||
mail MailAdmin
|
|
||||||
notifications NotificationAdmin
|
|
||||||
diplomail DiplomailAdmin
|
|
||||||
logger *zap.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminConsoleDeps bundles the collaborators for the operator console. Every
|
|
||||||
// field is optional: a nil Renderer or CSRF falls back to the embedded default
|
|
||||||
// templates and a per-process random key; a nil Monitor renders the dashboard
|
|
||||||
// without the monitoring panels; a nil Ready reports backend readiness as not
|
|
||||||
// ready; a nil Logger falls back to zap.NewNop.
|
|
||||||
type AdminConsoleDeps struct {
|
|
||||||
Renderer *adminconsole.Renderer
|
|
||||||
CSRF *adminconsole.CSRF
|
|
||||||
Monitor opsstatus.Reader
|
|
||||||
Ready func() bool
|
|
||||||
Users UserAdmin
|
|
||||||
Games GameAdmin
|
|
||||||
Runtime RuntimeAdmin
|
|
||||||
EngineVersions EngineVersionAdmin
|
|
||||||
Operators OperatorAdmin
|
|
||||||
Mail MailAdmin
|
|
||||||
Notifications NotificationAdmin
|
|
||||||
Diplomail DiplomailAdmin
|
|
||||||
Logger *zap.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAdminConsoleHandlers constructs the console handler set from deps. It
|
|
||||||
// panics only on conditions that are unrecoverable at startup (template parse
|
|
||||||
// failure or crypto/rand failure), both of which indicate a broken build or
|
|
||||||
// host rather than a runtime input.
|
|
||||||
func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers {
|
|
||||||
logger := deps.Logger
|
|
||||||
if logger == nil {
|
|
||||||
logger = zap.NewNop()
|
|
||||||
}
|
|
||||||
renderer := deps.Renderer
|
|
||||||
if renderer == nil {
|
|
||||||
renderer = adminconsole.MustNewRenderer()
|
|
||||||
}
|
|
||||||
csrf := deps.CSRF
|
|
||||||
if csrf == nil {
|
|
||||||
generated, err := adminconsole.NewRandomCSRF()
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
csrf = generated
|
|
||||||
}
|
|
||||||
|
|
||||||
assetsFS, err := adminconsole.Assets()
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &AdminConsoleHandlers{
|
|
||||||
renderer: renderer,
|
|
||||||
csrf: csrf,
|
|
||||||
assets: http.StripPrefix("/_gm/assets/", http.FileServer(http.FS(assetsFS))),
|
|
||||||
monitor: deps.Monitor,
|
|
||||||
ready: deps.Ready,
|
|
||||||
users: deps.Users,
|
|
||||||
games: deps.Games,
|
|
||||||
runtime: deps.Runtime,
|
|
||||||
engineVersions: deps.EngineVersions,
|
|
||||||
operators: deps.Operators,
|
|
||||||
mail: deps.Mail,
|
|
||||||
notifications: deps.Notifications,
|
|
||||||
diplomail: deps.Diplomail,
|
|
||||||
logger: logger.Named("http.admin.console"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dashboard renders the console landing page (GET /_gm and GET /_gm/),
|
|
||||||
// including the monitoring panels when an ops-status reader is wired.
|
|
||||||
func (h *AdminConsoleHandlers) Dashboard() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
data := adminconsole.DashboardData{}
|
|
||||||
if h.ready != nil {
|
|
||||||
data.BackendReady = h.ready()
|
|
||||||
}
|
|
||||||
if h.monitor != nil {
|
|
||||||
data.MonitorAvailable = true
|
|
||||||
snapshot := h.monitor.Collect(c.Request.Context())
|
|
||||||
data.PostgresHealthy = snapshot.PostgresHealthy
|
|
||||||
data.Runtimes = toViewCounts(snapshot.Runtimes)
|
|
||||||
data.MailDeliveries = toViewCounts(snapshot.MailDeliveries)
|
|
||||||
data.NotificationRoutes = toViewCounts(snapshot.NotificationRoutes)
|
|
||||||
data.NotificationMalformed = snapshot.NotificationMalformed
|
|
||||||
data.Errors = snapshot.Errors
|
|
||||||
}
|
|
||||||
h.render(c, http.StatusOK, "dashboard", "dashboard", "Dashboard", data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// toViewCounts maps ops-status counts to the console's view-layer counts.
|
|
||||||
func toViewCounts(in []opsstatus.StatusCount) []adminconsole.StatusCount {
|
|
||||||
if len(in) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]adminconsole.StatusCount, len(in))
|
|
||||||
for i, sc := range in {
|
|
||||||
out[i] = adminconsole.StatusCount{Status: sc.Status, Count: sc.Count}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// Asset serves the embedded console static assets under `/_gm/assets/`.
|
|
||||||
func (h *AdminConsoleHandlers) Asset() gin.HandlerFunc {
|
|
||||||
return gin.WrapH(h.assets)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequireCSRF returns middleware guarding state-changing requests against
|
|
||||||
// cross-site request forgery. Safe methods pass through untouched. For unsafe
|
|
||||||
// methods it requires both a same-origin Origin/Referer header (when the
|
|
||||||
// browser sends one) and a valid per-operator token in the `_csrf` form field;
|
|
||||||
// either check failing yields 403.
|
|
||||||
func (h *AdminConsoleHandlers) RequireCSRF() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if isSafeHTTPMethod(c.Request.Method) {
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !sameOriginRequest(c.Request) {
|
|
||||||
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "cross-origin request rejected")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
username, _ := basicauth.UsernameFromContext(c.Request.Context())
|
|
||||||
if !h.csrf.Verify(username, c.PostForm("_csrf")) {
|
|
||||||
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "invalid or missing CSRF token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// render composes the data common to every console page (operator name, CSRF
|
|
||||||
// token, active navigation entry) and writes the named page. It renders into an
|
|
||||||
// intermediate buffer so a template failure surfaces as a clean 500 without
|
|
||||||
// emitting a partial document.
|
|
||||||
func (h *AdminConsoleHandlers) render(c *gin.Context, status int, page, activeNav, title string, data any) {
|
|
||||||
username, _ := basicauth.UsernameFromContext(c.Request.Context())
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
err := h.renderer.Render(&buf, page, adminconsole.PageData{
|
|
||||||
Title: title,
|
|
||||||
Username: username,
|
|
||||||
CSRFToken: h.csrf.Token(username),
|
|
||||||
ActiveNav: activeNav,
|
|
||||||
Data: data,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("render admin console page", zap.String("page", page), zap.Error(err))
|
|
||||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "failed to render page")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Data(status, "text/html; charset=utf-8", buf.Bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderMessage renders the generic message page (not-found, validation, or
|
|
||||||
// operation-failure notices). class selects the CSS styling and backHref, when
|
|
||||||
// non-empty, adds a back link.
|
|
||||||
func (h *AdminConsoleHandlers) renderMessage(c *gin.Context, status int, activeNav, title, message, class, backHref string) {
|
|
||||||
h.render(c, status, "message", activeNav, title, adminconsole.MessageData{
|
|
||||||
Message: message,
|
|
||||||
Class: class,
|
|
||||||
BackHref: backHref,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// isSafeHTTPMethod reports whether method is a read-only HTTP method that the
|
|
||||||
// CSRF guard may let through without a token.
|
|
||||||
func isSafeHTTPMethod(method string) bool {
|
|
||||||
switch method {
|
|
||||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sameOriginRequest reports whether the request's Origin (or, failing that,
|
|
||||||
// Referer) names the same host as the request itself. A request that carries
|
|
||||||
// neither header is treated as same-origin, leaving the CSRF token as the sole
|
|
||||||
// guard; a malformed or cross-host value is rejected. This relies on the
|
|
||||||
// gateway reverse proxy preserving the inbound Host header.
|
|
||||||
func sameOriginRequest(r *http.Request) bool {
|
|
||||||
source := r.Header.Get("Origin")
|
|
||||||
if source == "" {
|
|
||||||
source = r.Header.Get("Referer")
|
|
||||||
}
|
|
||||||
if source == "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
parsed, err := url.Parse(source)
|
|
||||||
if err != nil || parsed.Host == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return strings.EqualFold(parsed.Host, r.Host)
|
|
||||||
}
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"galaxy/backend/internal/adminconsole"
|
|
||||||
"galaxy/backend/internal/lobby"
|
|
||||||
"galaxy/backend/internal/runtime"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GameAdmin is the subset of the lobby service the console uses for games.
|
|
||||||
type GameAdmin interface {
|
|
||||||
ListAdminGames(ctx context.Context, page, pageSize int) (lobby.GamePage, error)
|
|
||||||
GetGame(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error)
|
|
||||||
CreateGame(ctx context.Context, input lobby.CreateGameInput) (lobby.GameRecord, error)
|
|
||||||
AdminForceStart(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error)
|
|
||||||
AdminForceStop(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error)
|
|
||||||
AdminBanMember(ctx context.Context, gameID, userID uuid.UUID, reason string) (lobby.Membership, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RuntimeAdmin is the subset of the runtime service the console uses.
|
|
||||||
type RuntimeAdmin interface {
|
|
||||||
GetRuntime(ctx context.Context, gameID uuid.UUID) (runtime.RuntimeRecord, error)
|
|
||||||
AdminRestart(ctx context.Context, gameID uuid.UUID) (runtime.OperationLog, error)
|
|
||||||
AdminPatch(ctx context.Context, gameID uuid.UUID, targetVersion string) (runtime.OperationLog, error)
|
|
||||||
AdminForceNextTurn(ctx context.Context, gameID uuid.UUID) (runtime.OperationLog, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// EngineVersionAdmin is the subset of the engine-version service the console uses.
|
|
||||||
type EngineVersionAdmin interface {
|
|
||||||
List(ctx context.Context) ([]runtime.EngineVersion, error)
|
|
||||||
Register(ctx context.Context, in runtime.RegisterInput) (runtime.EngineVersion, error)
|
|
||||||
Disable(ctx context.Context, version string) (runtime.EngineVersion, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GamesList renders GET /_gm/games.
|
|
||||||
func (h *AdminConsoleHandlers) GamesList() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if h.games == nil {
|
|
||||||
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
page := parsePositiveQueryInt(c.Query("page"), 1)
|
|
||||||
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
|
||||||
result, err := h.games.ListAdminGames(c.Request.Context(), page, pageSize)
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("admin console: list games", zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "games", "Games", "Failed to load games.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.render(c, http.StatusOK, "games", "games", "Games", toGamesListData(result))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GameCreate handles POST /_gm/games — create a public game.
|
|
||||||
func (h *AdminConsoleHandlers) GameCreate() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if h.games == nil {
|
|
||||||
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
enrollmentEndsAt, err := parseConsoleDateTime(c.PostForm("enrollment_ends_at"))
|
|
||||||
if err != nil {
|
|
||||||
h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "Enrollment end must be a valid date/time.", "bad", "/_gm/games")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
game, err := h.games.CreateGame(c.Request.Context(), lobby.CreateGameInput{
|
|
||||||
OwnerUserID: nil,
|
|
||||||
Visibility: lobby.VisibilityPublic,
|
|
||||||
GameName: strings.TrimSpace(c.PostForm("game_name")),
|
|
||||||
Description: strings.TrimSpace(c.PostForm("description")),
|
|
||||||
MinPlayers: formInt32(c, "min_players"),
|
|
||||||
MaxPlayers: formInt32(c, "max_players"),
|
|
||||||
StartGapHours: formInt32(c, "start_gap_hours"),
|
|
||||||
StartGapPlayers: formInt32(c, "start_gap_players"),
|
|
||||||
EnrollmentEndsAt: enrollmentEndsAt,
|
|
||||||
TurnSchedule: strings.TrimSpace(c.PostForm("turn_schedule")),
|
|
||||||
TargetEngineVersion: strings.TrimSpace(c.PostForm("target_engine_version")),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, lobby.ErrInvalidInput) {
|
|
||||||
h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "The game could not be created: check the fields.", "bad", "/_gm/games")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.logger.Error("admin console: create game", zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "games", "Create failed", "Failed to create the game.", "bad", "/_gm/games")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Redirect(http.StatusSeeOther, "/_gm/games/"+game.GameID.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GameDetail renders GET /_gm/games/:game_id with the runtime snapshot.
|
|
||||||
func (h *AdminConsoleHandlers) GameDetail() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if h.games == nil {
|
|
||||||
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
gameID, ok := parseGameIDParam(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
game, err := h.games.GetGame(c.Request.Context(), gameID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, lobby.ErrNotFound) {
|
|
||||||
h.renderMessage(c, http.StatusNotFound, "games", "Game not found", "No such game.", "bad", "/_gm/games")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.logger.Error("admin console: get game", zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "games", "Games", "Failed to load the game.", "bad", "/_gm/games")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var runtimeRecord *runtime.RuntimeRecord
|
|
||||||
if h.runtime != nil {
|
|
||||||
if record, rtErr := h.runtime.GetRuntime(c.Request.Context(), gameID); rtErr == nil {
|
|
||||||
runtimeRecord = &record
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.render(c, http.StatusOK, "game_detail", "games", game.GameName, toGameDetailData(game, runtimeRecord))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GameForceStart handles POST /_gm/games/:game_id/force-start.
|
|
||||||
func (h *AdminConsoleHandlers) GameForceStart() gin.HandlerFunc {
|
|
||||||
return h.gameAction("force-start", func(ctx context.Context, gameID uuid.UUID) error {
|
|
||||||
_, err := h.games.AdminForceStart(ctx, gameID)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GameForceStop handles POST /_gm/games/:game_id/force-stop.
|
|
||||||
func (h *AdminConsoleHandlers) GameForceStop() gin.HandlerFunc {
|
|
||||||
return h.gameAction("force-stop", func(ctx context.Context, gameID uuid.UUID) error {
|
|
||||||
_, err := h.games.AdminForceStop(ctx, gameID)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GameBanMember handles POST /_gm/games/:game_id/ban-member.
|
|
||||||
func (h *AdminConsoleHandlers) GameBanMember() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if h.games == nil {
|
|
||||||
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
gameID, ok := parseGameIDParam(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
back := "/_gm/games/" + gameID.String()
|
|
||||||
userID, err := uuid.Parse(strings.TrimSpace(c.PostForm("user_id")))
|
|
||||||
if err != nil {
|
|
||||||
h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "User ID must be a valid UUID.", "bad", back)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err := h.games.AdminBanMember(c.Request.Context(), gameID, userID, strings.TrimSpace(c.PostForm("reason"))); err != nil {
|
|
||||||
h.logger.Error("admin console: ban member", zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "games", "Ban failed", "Failed to ban the member.", "bad", back)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Redirect(http.StatusSeeOther, back)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RuntimeRestart handles POST /_gm/games/:game_id/runtime/restart.
|
|
||||||
func (h *AdminConsoleHandlers) RuntimeRestart() gin.HandlerFunc {
|
|
||||||
return h.runtimeAction("restart", func(ctx context.Context, gameID uuid.UUID) error {
|
|
||||||
_, err := h.runtime.AdminRestart(ctx, gameID)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// RuntimeForceNextTurn handles POST /_gm/games/:game_id/runtime/force-next-turn.
|
|
||||||
func (h *AdminConsoleHandlers) RuntimeForceNextTurn() gin.HandlerFunc {
|
|
||||||
return h.runtimeAction("force-next-turn", func(ctx context.Context, gameID uuid.UUID) error {
|
|
||||||
_, err := h.runtime.AdminForceNextTurn(ctx, gameID)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// RuntimePatch handles POST /_gm/games/:game_id/runtime/patch.
|
|
||||||
func (h *AdminConsoleHandlers) RuntimePatch() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if h.runtime == nil {
|
|
||||||
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Runtime", "Runtime administration is not available.", "bad", "/_gm/games")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
gameID, ok := parseGameIDParam(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
back := "/_gm/games/" + gameID.String()
|
|
||||||
target := strings.TrimSpace(c.PostForm("target_version"))
|
|
||||||
if target == "" {
|
|
||||||
h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "A target version is required.", "bad", back)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err := h.runtime.AdminPatch(c.Request.Context(), gameID, target); err != nil {
|
|
||||||
h.logger.Error("admin console: runtime patch", zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "games", "Patch failed", "Failed to patch the runtime.", "bad", back)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Redirect(http.StatusSeeOther, back)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// gameAction is the shared shape for game-state POST actions that take only the
|
|
||||||
// game id and redirect back to the detail page.
|
|
||||||
func (h *AdminConsoleHandlers) gameAction(label string, run func(context.Context, uuid.UUID) error) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if h.games == nil {
|
|
||||||
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
gameID, ok := parseGameIDParam(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
back := "/_gm/games/" + gameID.String()
|
|
||||||
if err := run(c.Request.Context(), gameID); err != nil {
|
|
||||||
h.logger.Error("admin console: game "+label, zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "games", "Action failed", "The "+label+" action failed.", "bad", back)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Redirect(http.StatusSeeOther, back)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// runtimeAction is the shared shape for runtime POST actions that take only the
|
|
||||||
// game id and redirect back to the detail page.
|
|
||||||
func (h *AdminConsoleHandlers) runtimeAction(label string, run func(context.Context, uuid.UUID) error) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if h.runtime == nil {
|
|
||||||
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Runtime", "Runtime administration is not available.", "bad", "/_gm/games")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
gameID, ok := parseGameIDParam(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
back := "/_gm/games/" + gameID.String()
|
|
||||||
if err := run(c.Request.Context(), gameID); err != nil {
|
|
||||||
h.logger.Error("admin console: runtime "+label, zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "games", "Action failed", "The runtime "+label+" action failed.", "bad", back)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Redirect(http.StatusSeeOther, back)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// EngineVersionsList renders GET /_gm/engine-versions.
|
|
||||||
func (h *AdminConsoleHandlers) EngineVersionsList() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if h.engineVersions == nil {
|
|
||||||
h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items, err := h.engineVersions.List(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("admin console: list engine versions", zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Engine versions", "Failed to load engine versions.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.render(c, http.StatusOK, "engine_versions", "games", "Engine versions", toEngineVersionsData(items))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// EngineVersionRegister handles POST /_gm/engine-versions.
|
|
||||||
func (h *AdminConsoleHandlers) EngineVersionRegister() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if h.engineVersions == nil {
|
|
||||||
h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
enabled := c.PostForm("enabled") == "true"
|
|
||||||
_, err := h.engineVersions.Register(c.Request.Context(), runtime.RegisterInput{
|
|
||||||
Version: strings.TrimSpace(c.PostForm("version")),
|
|
||||||
ImageRef: strings.TrimSpace(c.PostForm("image_ref")),
|
|
||||||
Enabled: &enabled,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, runtime.ErrInvalidInput) || errors.Is(err, runtime.ErrEngineVersionTaken) {
|
|
||||||
h.renderMessage(c, http.StatusBadRequest, "engine-versions", "Invalid input", "The version could not be registered (invalid semver, missing image, or duplicate).", "bad", "/_gm/engine-versions")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.logger.Error("admin console: register engine version", zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Register failed", "Failed to register the engine version.", "bad", "/_gm/engine-versions")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Redirect(http.StatusSeeOther, "/_gm/engine-versions")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// EngineVersionDisable handles POST /_gm/engine-versions/:version/disable.
|
|
||||||
func (h *AdminConsoleHandlers) EngineVersionDisable() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if h.engineVersions == nil {
|
|
||||||
h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
version := strings.TrimSpace(c.Param("version"))
|
|
||||||
if _, err := h.engineVersions.Disable(c.Request.Context(), version); err != nil {
|
|
||||||
h.logger.Error("admin console: disable engine version", zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Disable failed", "Failed to disable the engine version.", "bad", "/_gm/engine-versions")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Redirect(http.StatusSeeOther, "/_gm/engine-versions")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// formInt32 reads a non-negative int32 form field, defaulting to 0.
|
|
||||||
func formInt32(c *gin.Context, name string) int32 {
|
|
||||||
parsed, err := strconv.Atoi(strings.TrimSpace(c.PostForm(name)))
|
|
||||||
if err != nil || parsed < 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return int32(parsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseConsoleDateTime parses the value of an <input type="datetime-local">
|
|
||||||
// (or an RFC 3339 timestamp) as UTC.
|
|
||||||
func parseConsoleDateTime(raw string) (time.Time, error) {
|
|
||||||
raw = strings.TrimSpace(raw)
|
|
||||||
for _, layout := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05", time.RFC3339} {
|
|
||||||
if t, err := time.ParseInLocation(layout, raw, time.UTC); err == nil {
|
|
||||||
return t.UTC(), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return time.Time{}, errors.New("invalid date/time")
|
|
||||||
}
|
|
||||||
|
|
||||||
// toGamesListData maps a game page into the games list view model.
|
|
||||||
func toGamesListData(page lobby.GamePage) adminconsole.GamesListData {
|
|
||||||
data := adminconsole.GamesListData{
|
|
||||||
Items: make([]adminconsole.GameRow, 0, len(page.Items)),
|
|
||||||
Page: page.Page,
|
|
||||||
PageSize: page.PageSize,
|
|
||||||
Total: page.Total,
|
|
||||||
PrevPage: page.Page - 1,
|
|
||||||
NextPage: page.Page + 1,
|
|
||||||
HasPrev: page.Page > 1,
|
|
||||||
HasNext: page.Page*page.PageSize < page.Total,
|
|
||||||
}
|
|
||||||
for _, game := range page.Items {
|
|
||||||
data.Items = append(data.Items, adminconsole.GameRow{
|
|
||||||
GameID: game.GameID.String(),
|
|
||||||
GameName: game.GameName,
|
|
||||||
Visibility: game.Visibility,
|
|
||||||
Status: game.Status,
|
|
||||||
Owner: ownerLabel(game.OwnerUserID),
|
|
||||||
Players: strconv.Itoa(int(game.MinPlayers)) + "–" + strconv.Itoa(int(game.MaxPlayers)),
|
|
||||||
TurnSchedule: game.TurnSchedule,
|
|
||||||
CreatedAt: fmtConsoleTime(game.CreatedAt),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// toGameDetailData maps a game record and optional runtime record into the
|
|
||||||
// detail view model.
|
|
||||||
func toGameDetailData(game lobby.GameRecord, rec *runtime.RuntimeRecord) adminconsole.GameDetailData {
|
|
||||||
data := adminconsole.GameDetailData{
|
|
||||||
GameID: game.GameID.String(),
|
|
||||||
GameName: game.GameName,
|
|
||||||
Description: game.Description,
|
|
||||||
Visibility: game.Visibility,
|
|
||||||
Status: game.Status,
|
|
||||||
Owner: ownerLabel(game.OwnerUserID),
|
|
||||||
MinPlayers: game.MinPlayers,
|
|
||||||
MaxPlayers: game.MaxPlayers,
|
|
||||||
StartGapHours: game.StartGapHours,
|
|
||||||
StartGapPlayers: game.StartGapPlayers,
|
|
||||||
TurnSchedule: game.TurnSchedule,
|
|
||||||
TargetEngineVersion: game.TargetEngineVersion,
|
|
||||||
EnrollmentEndsAt: fmtConsoleTime(game.EnrollmentEndsAt),
|
|
||||||
CreatedAt: fmtConsoleTime(game.CreatedAt),
|
|
||||||
StartedAt: fmtConsoleTimePtr(game.StartedAt),
|
|
||||||
FinishedAt: fmtConsoleTimePtr(game.FinishedAt),
|
|
||||||
}
|
|
||||||
if rec != nil {
|
|
||||||
data.HasRuntime = true
|
|
||||||
data.RuntimeStatus = rec.Status
|
|
||||||
data.CurrentEngineVersion = rec.CurrentEngineVersion
|
|
||||||
data.EngineHealth = rec.EngineHealth
|
|
||||||
data.CurrentTurn = rec.CurrentTurn
|
|
||||||
data.NextGenerationAt = fmtConsoleTimePtr(rec.NextGenerationAt)
|
|
||||||
data.Paused = rec.Paused
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// toEngineVersionsData maps engine versions into the registry view model.
|
|
||||||
func toEngineVersionsData(items []runtime.EngineVersion) adminconsole.EngineVersionsData {
|
|
||||||
data := adminconsole.EngineVersionsData{Items: make([]adminconsole.EngineVersionRow, 0, len(items))}
|
|
||||||
for _, v := range items {
|
|
||||||
data.Items = append(data.Items, adminconsole.EngineVersionRow{
|
|
||||||
Version: v.Version,
|
|
||||||
ImageRef: v.ImageRef,
|
|
||||||
Enabled: v.Enabled,
|
|
||||||
CreatedAt: fmtConsoleTime(v.CreatedAt),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// ownerLabel renders an optional owner id; public games have no owner.
|
|
||||||
func ownerLabel(ownerID *uuid.UUID) string {
|
|
||||||
if ownerID == nil {
|
|
||||||
return "—"
|
|
||||||
}
|
|
||||||
return ownerID.String()
|
|
||||||
}
|
|
||||||
@@ -1,353 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"galaxy/backend/internal/adminconsole"
|
|
||||||
"galaxy/backend/internal/lobby"
|
|
||||||
"galaxy/backend/internal/runtime"
|
|
||||||
"galaxy/backend/internal/server/middleware/basicauth"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fakeGameAdmin struct {
|
|
||||||
page lobby.GamePage
|
|
||||||
game lobby.GameRecord
|
|
||||||
getErr error
|
|
||||||
created lobby.CreateGameInput
|
|
||||||
|
|
||||||
createCalls int
|
|
||||||
forceStartCalls int
|
|
||||||
forceStopCalls int
|
|
||||||
banCalls int
|
|
||||||
lastBanUser uuid.UUID
|
|
||||||
lastBanReason string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeGameAdmin) ListAdminGames(context.Context, int, int) (lobby.GamePage, error) {
|
|
||||||
return f.page, nil
|
|
||||||
}
|
|
||||||
func (f *fakeGameAdmin) GetGame(context.Context, uuid.UUID) (lobby.GameRecord, error) {
|
|
||||||
return f.game, f.getErr
|
|
||||||
}
|
|
||||||
func (f *fakeGameAdmin) CreateGame(_ context.Context, in lobby.CreateGameInput) (lobby.GameRecord, error) {
|
|
||||||
f.createCalls++
|
|
||||||
f.created = in
|
|
||||||
return f.game, nil
|
|
||||||
}
|
|
||||||
func (f *fakeGameAdmin) AdminForceStart(context.Context, uuid.UUID) (lobby.GameRecord, error) {
|
|
||||||
f.forceStartCalls++
|
|
||||||
return f.game, nil
|
|
||||||
}
|
|
||||||
func (f *fakeGameAdmin) AdminForceStop(context.Context, uuid.UUID) (lobby.GameRecord, error) {
|
|
||||||
f.forceStopCalls++
|
|
||||||
return f.game, nil
|
|
||||||
}
|
|
||||||
func (f *fakeGameAdmin) AdminBanMember(_ context.Context, _, userID uuid.UUID, reason string) (lobby.Membership, error) {
|
|
||||||
f.banCalls++
|
|
||||||
f.lastBanUser = userID
|
|
||||||
f.lastBanReason = reason
|
|
||||||
return lobby.Membership{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeRuntimeAdmin struct {
|
|
||||||
record runtime.RuntimeRecord
|
|
||||||
getErr error
|
|
||||||
restartCalls int
|
|
||||||
forceNextCalls int
|
|
||||||
patchCalls int
|
|
||||||
lastPatchVersion string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeRuntimeAdmin) GetRuntime(context.Context, uuid.UUID) (runtime.RuntimeRecord, error) {
|
|
||||||
return f.record, f.getErr
|
|
||||||
}
|
|
||||||
func (f *fakeRuntimeAdmin) AdminRestart(context.Context, uuid.UUID) (runtime.OperationLog, error) {
|
|
||||||
f.restartCalls++
|
|
||||||
return runtime.OperationLog{}, nil
|
|
||||||
}
|
|
||||||
func (f *fakeRuntimeAdmin) AdminPatch(_ context.Context, _ uuid.UUID, target string) (runtime.OperationLog, error) {
|
|
||||||
f.patchCalls++
|
|
||||||
f.lastPatchVersion = target
|
|
||||||
return runtime.OperationLog{}, nil
|
|
||||||
}
|
|
||||||
func (f *fakeRuntimeAdmin) AdminForceNextTurn(context.Context, uuid.UUID) (runtime.OperationLog, error) {
|
|
||||||
f.forceNextCalls++
|
|
||||||
return runtime.OperationLog{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeEngineVersionAdmin struct {
|
|
||||||
list []runtime.EngineVersion
|
|
||||||
registered runtime.RegisterInput
|
|
||||||
registerCalls int
|
|
||||||
disableCalls int
|
|
||||||
lastDisabled string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeEngineVersionAdmin) List(context.Context) ([]runtime.EngineVersion, error) {
|
|
||||||
return f.list, nil
|
|
||||||
}
|
|
||||||
func (f *fakeEngineVersionAdmin) Register(_ context.Context, in runtime.RegisterInput) (runtime.EngineVersion, error) {
|
|
||||||
f.registerCalls++
|
|
||||||
f.registered = in
|
|
||||||
return runtime.EngineVersion{}, nil
|
|
||||||
}
|
|
||||||
func (f *fakeEngineVersionAdmin) Disable(_ context.Context, version string) (runtime.EngineVersion, error) {
|
|
||||||
f.disableCalls++
|
|
||||||
f.lastDisabled = version
|
|
||||||
return runtime.EngineVersion{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newGamesConsoleRouter(t *testing.T, games GameAdmin, rt RuntimeAdmin, ev EngineVersionAdmin) (http.Handler, *adminconsole.CSRF) {
|
|
||||||
t.Helper()
|
|
||||||
csrf := adminconsole.NewCSRF([]byte("test-key"))
|
|
||||||
handler, err := NewRouter(RouterDependencies{
|
|
||||||
Logger: zap.NewNop(),
|
|
||||||
AdminVerifier: basicauth.NewStaticVerifier("secret"),
|
|
||||||
AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{
|
|
||||||
CSRF: csrf, Games: games, Runtime: rt, EngineVersions: ev,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewRouter: %v", err)
|
|
||||||
}
|
|
||||||
return handler, csrf
|
|
||||||
}
|
|
||||||
|
|
||||||
func consoleGet(t *testing.T, router http.Handler, path string) *httptest.ResponseRecorder {
|
|
||||||
t.Helper()
|
|
||||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
||||||
req.SetBasicAuth("ops", "secret")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
return rec
|
|
||||||
}
|
|
||||||
|
|
||||||
func consolePost(t *testing.T, router http.Handler, path, form string) *httptest.ResponseRecorder {
|
|
||||||
t.Helper()
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan"+path, strings.NewReader(form))
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("Origin", "https://galaxy.lan")
|
|
||||||
req.SetBasicAuth("ops", "secret")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
return rec
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleGamesList(t *testing.T) {
|
|
||||||
games := &fakeGameAdmin{page: lobby.GamePage{
|
|
||||||
Items: []lobby.GameRecord{{GameID: uuid.New(), GameName: "Nova", Visibility: "public", Status: "enrollment_open"}},
|
|
||||||
Page: 1, PageSize: 50, Total: 1,
|
|
||||||
}}
|
|
||||||
router, _ := newGamesConsoleRouter(t, games, nil, nil)
|
|
||||||
|
|
||||||
rec := consoleGet(t, router, "/_gm/games")
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
for _, want := range []string{"Nova", "public", "enrollment_open", "Create public game"} {
|
|
||||||
if !strings.Contains(rec.Body.String(), want) {
|
|
||||||
t.Errorf("games list missing %q", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleGameDetailWithRuntime(t *testing.T) {
|
|
||||||
id := uuid.New()
|
|
||||||
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id, GameName: "Nova", Status: "running"}}
|
|
||||||
rt := &fakeRuntimeAdmin{record: runtime.RuntimeRecord{GameID: id, Status: "running", CurrentEngineVersion: "0.1.0", CurrentTurn: 7}}
|
|
||||||
router, csrf := newGamesConsoleRouter(t, games, rt, nil)
|
|
||||||
|
|
||||||
rec := consoleGet(t, router, "/_gm/games/"+id.String())
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
for _, want := range []string{"Nova", "Force start", "Force stop", "0.1.0", "Patch", "Ban member", csrf.Token("ops")} {
|
|
||||||
if !strings.Contains(rec.Body.String(), want) {
|
|
||||||
t.Errorf("game detail missing %q", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleGameDetailNoRuntime(t *testing.T) {
|
|
||||||
id := uuid.New()
|
|
||||||
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id, GameName: "Nova"}}
|
|
||||||
rt := &fakeRuntimeAdmin{getErr: errors.New("not found")}
|
|
||||||
router, _ := newGamesConsoleRouter(t, games, rt, nil)
|
|
||||||
|
|
||||||
rec := consoleGet(t, router, "/_gm/games/"+id.String())
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", rec.Code)
|
|
||||||
}
|
|
||||||
if !strings.Contains(rec.Body.String(), "No runtime record") {
|
|
||||||
t.Error("expected a no-runtime note")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleGameCreate(t *testing.T) {
|
|
||||||
id := uuid.New()
|
|
||||||
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
|
|
||||||
router, csrf := newGamesConsoleRouter(t, games, nil, nil)
|
|
||||||
|
|
||||||
form := "_csrf=" + csrf.Token("ops") +
|
|
||||||
"&game_name=Nova&description=d&min_players=2&max_players=8&start_gap_hours=0&start_gap_players=0" +
|
|
||||||
"&enrollment_ends_at=2030-01-02T15:04&turn_schedule=@every+24h&target_engine_version=0.1.0"
|
|
||||||
rec := consolePost(t, router, "/_gm/games", form)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusSeeOther {
|
|
||||||
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
if got := rec.Header().Get("Location"); got != "/_gm/games/"+id.String() {
|
|
||||||
t.Errorf("redirect = %q, want detail page", got)
|
|
||||||
}
|
|
||||||
if games.createCalls != 1 {
|
|
||||||
t.Fatalf("CreateGame called %d times, want 1", games.createCalls)
|
|
||||||
}
|
|
||||||
if games.created.Visibility != lobby.VisibilityPublic {
|
|
||||||
t.Errorf("visibility = %q, want public", games.created.Visibility)
|
|
||||||
}
|
|
||||||
if games.created.GameName != "Nova" {
|
|
||||||
t.Errorf("game name = %q", games.created.GameName)
|
|
||||||
}
|
|
||||||
if games.created.EnrollmentEndsAt.Year() != 2030 {
|
|
||||||
t.Errorf("enrollment year = %d, want 2030", games.created.EnrollmentEndsAt.Year())
|
|
||||||
}
|
|
||||||
if games.created.OwnerUserID != nil {
|
|
||||||
t.Error("public game must have a nil owner")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleGameForceStart(t *testing.T) {
|
|
||||||
id := uuid.New()
|
|
||||||
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
|
|
||||||
router, csrf := newGamesConsoleRouter(t, games, nil, nil)
|
|
||||||
|
|
||||||
rec := consolePost(t, router, "/_gm/games/"+id.String()+"/force-start", "_csrf="+csrf.Token("ops"))
|
|
||||||
if rec.Code != http.StatusSeeOther {
|
|
||||||
t.Fatalf("status = %d, want 303", rec.Code)
|
|
||||||
}
|
|
||||||
if games.forceStartCalls != 1 {
|
|
||||||
t.Errorf("AdminForceStart called %d times, want 1", games.forceStartCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleGameForceStartRejectsBadCSRF(t *testing.T) {
|
|
||||||
id := uuid.New()
|
|
||||||
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
|
|
||||||
router, _ := newGamesConsoleRouter(t, games, nil, nil)
|
|
||||||
|
|
||||||
rec := consolePost(t, router, "/_gm/games/"+id.String()+"/force-start", "")
|
|
||||||
if rec.Code != http.StatusForbidden {
|
|
||||||
t.Fatalf("status = %d, want 403", rec.Code)
|
|
||||||
}
|
|
||||||
if games.forceStartCalls != 0 {
|
|
||||||
t.Error("force-start must not run without a CSRF token")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleGameBanMember(t *testing.T) {
|
|
||||||
gameID := uuid.New()
|
|
||||||
target := uuid.New()
|
|
||||||
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: gameID}}
|
|
||||||
router, csrf := newGamesConsoleRouter(t, games, nil, nil)
|
|
||||||
|
|
||||||
form := "_csrf=" + csrf.Token("ops") + "&user_id=" + target.String() + "&reason=cheating"
|
|
||||||
rec := consolePost(t, router, "/_gm/games/"+gameID.String()+"/ban-member", form)
|
|
||||||
if rec.Code != http.StatusSeeOther {
|
|
||||||
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
if games.banCalls != 1 || games.lastBanUser != target || games.lastBanReason != "cheating" {
|
|
||||||
t.Errorf("ban recorded %d user=%s reason=%q", games.banCalls, games.lastBanUser, games.lastBanReason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleGameBanMemberRejectsBadUUID(t *testing.T) {
|
|
||||||
gameID := uuid.New()
|
|
||||||
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: gameID}}
|
|
||||||
router, csrf := newGamesConsoleRouter(t, games, nil, nil)
|
|
||||||
|
|
||||||
rec := consolePost(t, router, "/_gm/games/"+gameID.String()+"/ban-member", "_csrf="+csrf.Token("ops")+"&user_id=not-a-uuid")
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want 400", rec.Code)
|
|
||||||
}
|
|
||||||
if games.banCalls != 0 {
|
|
||||||
t.Error("ban must not run with an invalid user id")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleRuntimePatch(t *testing.T) {
|
|
||||||
id := uuid.New()
|
|
||||||
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
|
|
||||||
rt := &fakeRuntimeAdmin{}
|
|
||||||
router, csrf := newGamesConsoleRouter(t, games, rt, nil)
|
|
||||||
|
|
||||||
rec := consolePost(t, router, "/_gm/games/"+id.String()+"/runtime/patch", "_csrf="+csrf.Token("ops")+"&target_version=0.1.1")
|
|
||||||
if rec.Code != http.StatusSeeOther {
|
|
||||||
t.Fatalf("status = %d, want 303", rec.Code)
|
|
||||||
}
|
|
||||||
if rt.patchCalls != 1 || rt.lastPatchVersion != "0.1.1" {
|
|
||||||
t.Errorf("patch recorded %d version=%q", rt.patchCalls, rt.lastPatchVersion)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleRuntimePatchMissingVersion(t *testing.T) {
|
|
||||||
id := uuid.New()
|
|
||||||
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
|
|
||||||
rt := &fakeRuntimeAdmin{}
|
|
||||||
router, csrf := newGamesConsoleRouter(t, games, rt, nil)
|
|
||||||
|
|
||||||
rec := consolePost(t, router, "/_gm/games/"+id.String()+"/runtime/patch", "_csrf="+csrf.Token("ops"))
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want 400", rec.Code)
|
|
||||||
}
|
|
||||||
if rt.patchCalls != 0 {
|
|
||||||
t.Error("patch must not run without a target version")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleEngineVersions(t *testing.T) {
|
|
||||||
ev := &fakeEngineVersionAdmin{list: []runtime.EngineVersion{{Version: "0.1.0", ImageRef: "img:0.1.0", Enabled: true}}}
|
|
||||||
router, csrf := newGamesConsoleRouter(t, nil, nil, ev)
|
|
||||||
|
|
||||||
rec := consoleGet(t, router, "/_gm/engine-versions")
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
for _, want := range []string{"0.1.0", "img:0.1.0", "Register version", "Disable"} {
|
|
||||||
if !strings.Contains(rec.Body.String(), want) {
|
|
||||||
t.Errorf("engine versions page missing %q", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rec = consolePost(t, router, "/_gm/engine-versions", "_csrf="+csrf.Token("ops")+"&version=0.2.0&image_ref=img:0.2.0&enabled=true")
|
|
||||||
if rec.Code != http.StatusSeeOther {
|
|
||||||
t.Fatalf("register status = %d, want 303", rec.Code)
|
|
||||||
}
|
|
||||||
if ev.registerCalls != 1 || ev.registered.Version != "0.2.0" || ev.registered.Enabled == nil || !*ev.registered.Enabled {
|
|
||||||
t.Errorf("register recorded %d version=%q enabled=%v", ev.registerCalls, ev.registered.Version, ev.registered.Enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec = consolePost(t, router, "/_gm/engine-versions/0.1.0/disable", "_csrf="+csrf.Token("ops"))
|
|
||||||
if rec.Code != http.StatusSeeOther {
|
|
||||||
t.Fatalf("disable status = %d, want 303", rec.Code)
|
|
||||||
}
|
|
||||||
if ev.disableCalls != 1 || ev.lastDisabled != "0.1.0" {
|
|
||||||
t.Errorf("disable recorded %d version=%q", ev.disableCalls, ev.lastDisabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleGamesUnavailable(t *testing.T) {
|
|
||||||
router, _ := newGamesConsoleRouter(t, nil, nil, nil)
|
|
||||||
|
|
||||||
rec := consoleGet(t, router, "/_gm/games")
|
|
||||||
if rec.Code != http.StatusServiceUnavailable {
|
|
||||||
t.Fatalf("status = %d, want 503", rec.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"galaxy/backend/internal/adminconsole"
|
|
||||||
"galaxy/backend/internal/diplomail"
|
|
||||||
"galaxy/backend/internal/mail"
|
|
||||||
"galaxy/backend/internal/notification"
|
|
||||||
"galaxy/backend/internal/server/clientip"
|
|
||||||
"galaxy/backend/internal/server/middleware/basicauth"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MailAdmin is the subset of the mail service the console uses.
|
|
||||||
type MailAdmin interface {
|
|
||||||
AdminListDeliveries(ctx context.Context, page, pageSize int) (mail.AdminListDeliveriesPage, error)
|
|
||||||
AdminGetDelivery(ctx context.Context, deliveryID uuid.UUID) (mail.Delivery, error)
|
|
||||||
AdminListAttempts(ctx context.Context, deliveryID uuid.UUID) ([]mail.Attempt, error)
|
|
||||||
AdminResendDelivery(ctx context.Context, deliveryID uuid.UUID) (mail.Delivery, error)
|
|
||||||
AdminListDeadLetters(ctx context.Context, page, pageSize int) (mail.AdminListDeadLettersPage, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NotificationAdmin is the subset of the notification service the console uses.
|
|
||||||
type NotificationAdmin interface {
|
|
||||||
AdminListNotifications(ctx context.Context, page, pageSize int) (notification.AdminListNotificationsPage, error)
|
|
||||||
AdminListDeadLetters(ctx context.Context, page, pageSize int) (notification.AdminListDeadLettersPage, error)
|
|
||||||
AdminListMalformed(ctx context.Context, page, pageSize int) (notification.AdminListMalformedPage, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DiplomailAdmin is the subset of the diplomail service the console uses.
|
|
||||||
type DiplomailAdmin interface {
|
|
||||||
SendAdminMultiGameBroadcast(ctx context.Context, in diplomail.SendMultiGameBroadcastInput) ([]diplomail.Message, int, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
const consoleSnapshotPageSize = 50
|
|
||||||
|
|
||||||
// MailPage renders GET /_gm/mail — paginated deliveries plus a dead-letter snapshot.
|
|
||||||
func (h *AdminConsoleHandlers) MailPage() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if h.mail == nil {
|
|
||||||
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
page := parsePositiveQueryInt(c.Query("page"), 1)
|
|
||||||
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
deliveries, err := h.mail.AdminListDeliveries(ctx, page, pageSize)
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("admin console: list deliveries", zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load deliveries.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dead, err := h.mail.AdminListDeadLetters(ctx, 1, consoleSnapshotPageSize)
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("admin console: list mail dead-letters", zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load dead-letters.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.render(c, http.StatusOK, "mail", "mail", "Mail", toMailData(deliveries, dead))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MailDeliveryDetail renders GET /_gm/mail/deliveries/:delivery_id.
|
|
||||||
func (h *AdminConsoleHandlers) MailDeliveryDetail() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if h.mail == nil {
|
|
||||||
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
deliveryID, ok := parseConsoleDeliveryID(c, h)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
delivery, err := h.mail.AdminGetDelivery(ctx, deliveryID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, mail.ErrDeliveryNotFound) {
|
|
||||||
h.renderMessage(c, http.StatusNotFound, "mail", "Delivery not found", "No such delivery.", "bad", "/_gm/mail")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.logger.Error("admin console: get delivery", zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load the delivery.", "bad", "/_gm/mail")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
attempts, err := h.mail.AdminListAttempts(ctx, deliveryID)
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("admin console: list attempts", zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load attempts.", "bad", "/_gm/mail")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.render(c, http.StatusOK, "mail_delivery", "mail", "Delivery", toMailDeliveryDetail(delivery, attempts))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MailResend handles POST /_gm/mail/deliveries/:delivery_id/resend.
|
|
||||||
func (h *AdminConsoleHandlers) MailResend() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if h.mail == nil {
|
|
||||||
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
deliveryID, ok := parseConsoleDeliveryID(c, h)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
back := "/_gm/mail/deliveries/" + deliveryID.String()
|
|
||||||
if _, err := h.mail.AdminResendDelivery(c.Request.Context(), deliveryID); err != nil {
|
|
||||||
h.logger.Error("admin console: resend delivery", zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Resend failed", "Failed to resend the delivery (it may already be sent).", "bad", back)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Redirect(http.StatusSeeOther, back)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NotificationsPage renders GET /_gm/notifications — notifications, dead-letters,
|
|
||||||
// and malformed intents on one overview page.
|
|
||||||
func (h *AdminConsoleHandlers) NotificationsPage() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if h.notifications == nil {
|
|
||||||
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Notifications", "Notification administration is not available.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
notifications, err := h.notifications.AdminListNotifications(ctx, 1, consoleSnapshotPageSize)
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("admin console: list notifications", zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load notifications.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dead, err := h.notifications.AdminListDeadLetters(ctx, 1, consoleSnapshotPageSize)
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("admin console: list notification dead-letters", zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load dead-letters.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
malformed, err := h.notifications.AdminListMalformed(ctx, 1, consoleSnapshotPageSize)
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("admin console: list malformed intents", zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load malformed intents.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.render(c, http.StatusOK, "notifications", "mail", "Notifications", toNotificationsData(notifications, dead, malformed))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// BroadcastForm renders GET /_gm/broadcast.
|
|
||||||
func (h *AdminConsoleHandlers) BroadcastForm() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if h.diplomail == nil {
|
|
||||||
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Broadcast", "Broadcast is not available.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.render(c, http.StatusOK, "broadcast", "mail", "Broadcast", nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// BroadcastSend handles POST /_gm/broadcast — multi-game admin broadcast.
|
|
||||||
func (h *AdminConsoleHandlers) BroadcastSend() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if h.diplomail == nil {
|
|
||||||
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Broadcast", "Broadcast is not available.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
username, _ := basicauth.UsernameFromContext(c.Request.Context())
|
|
||||||
gameIDs, err := parseGameIDList(c.PostForm("game_ids"))
|
|
||||||
if err != nil {
|
|
||||||
h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "Game IDs must be valid UUIDs.", "bad", "/_gm/broadcast")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, total, err := h.diplomail.SendAdminMultiGameBroadcast(c.Request.Context(), diplomail.SendMultiGameBroadcastInput{
|
|
||||||
CallerUsername: username,
|
|
||||||
Scope: strings.TrimSpace(c.PostForm("scope")),
|
|
||||||
GameIDs: gameIDs,
|
|
||||||
RecipientScope: strings.TrimSpace(c.PostForm("recipients")),
|
|
||||||
Subject: strings.TrimSpace(c.PostForm("subject")),
|
|
||||||
Body: c.PostForm("body"),
|
|
||||||
SenderIP: clientip.ExtractSourceIP(c),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, diplomail.ErrInvalidInput) {
|
|
||||||
h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "The broadcast was rejected: check the scope, recipients, and body.", "bad", "/_gm/broadcast")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.logger.Error("admin console: broadcast", zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Broadcast failed", "Failed to send the broadcast.", "bad", "/_gm/broadcast")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.renderMessage(c, http.StatusOK, "mail", "Broadcast sent", fmt.Sprintf("Broadcast delivered to %d recipients.", total), "ok", "/_gm/broadcast")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseConsoleDeliveryID parses the delivery_id path parameter, rendering a
|
|
||||||
// console message page on failure.
|
|
||||||
func parseConsoleDeliveryID(c *gin.Context, h *AdminConsoleHandlers) (uuid.UUID, bool) {
|
|
||||||
parsed, err := uuid.Parse(c.Param("delivery_id"))
|
|
||||||
if err != nil {
|
|
||||||
h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "delivery_id must be a valid UUID.", "bad", "/_gm/mail")
|
|
||||||
return uuid.Nil, false
|
|
||||||
}
|
|
||||||
return parsed, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseGameIDList parses a comma-separated list of UUIDs, ignoring blanks.
|
|
||||||
func parseGameIDList(raw string) ([]uuid.UUID, error) {
|
|
||||||
fields := strings.Split(raw, ",")
|
|
||||||
ids := make([]uuid.UUID, 0, len(fields))
|
|
||||||
for _, field := range fields {
|
|
||||||
field = strings.TrimSpace(field)
|
|
||||||
if field == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parsed, err := uuid.Parse(field)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ids = append(ids, parsed)
|
|
||||||
}
|
|
||||||
return ids, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func toMailData(deliveries mail.AdminListDeliveriesPage, dead mail.AdminListDeadLettersPage) adminconsole.MailData {
|
|
||||||
data := adminconsole.MailData{
|
|
||||||
Deliveries: make([]adminconsole.MailDeliveryRow, 0, len(deliveries.Items)),
|
|
||||||
DeadLetters: make([]adminconsole.MailDeadLetterRow, 0, len(dead.Items)),
|
|
||||||
Page: deliveries.Page,
|
|
||||||
PageSize: deliveries.PageSize,
|
|
||||||
Total: deliveries.Total,
|
|
||||||
PrevPage: deliveries.Page - 1,
|
|
||||||
NextPage: deliveries.Page + 1,
|
|
||||||
HasPrev: deliveries.Page > 1,
|
|
||||||
HasNext: int64(deliveries.Page*deliveries.PageSize) < deliveries.Total,
|
|
||||||
}
|
|
||||||
for _, d := range deliveries.Items {
|
|
||||||
data.Deliveries = append(data.Deliveries, adminconsole.MailDeliveryRow{
|
|
||||||
DeliveryID: d.DeliveryID.String(),
|
|
||||||
Template: d.TemplateID,
|
|
||||||
Status: d.Status,
|
|
||||||
Attempts: d.Attempts,
|
|
||||||
NextAttempt: fmtConsoleTimePtr(d.NextAttemptAt),
|
|
||||||
Created: fmtConsoleTime(d.CreatedAt),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for _, d := range dead.Items {
|
|
||||||
data.DeadLetters = append(data.DeadLetters, adminconsole.MailDeadLetterRow{
|
|
||||||
DeliveryID: d.DeliveryID.String(),
|
|
||||||
Reason: d.Reason,
|
|
||||||
Archived: fmtConsoleTime(d.ArchivedAt),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
func toMailDeliveryDetail(d mail.Delivery, attempts []mail.Attempt) adminconsole.MailDeliveryDetail {
|
|
||||||
detail := adminconsole.MailDeliveryDetail{
|
|
||||||
DeliveryID: d.DeliveryID.String(),
|
|
||||||
Template: d.TemplateID,
|
|
||||||
Status: d.Status,
|
|
||||||
Attempts: d.Attempts,
|
|
||||||
NextAttempt: fmtConsoleTimePtr(d.NextAttemptAt),
|
|
||||||
LastError: d.LastError,
|
|
||||||
Created: fmtConsoleTime(d.CreatedAt),
|
|
||||||
Sent: fmtConsoleTimePtr(d.SentAt),
|
|
||||||
DeadLettered: fmtConsoleTimePtr(d.DeadLetteredAt),
|
|
||||||
CanResend: d.Status != mail.StatusSent,
|
|
||||||
AttemptRows: make([]adminconsole.MailAttemptRow, 0, len(attempts)),
|
|
||||||
}
|
|
||||||
for _, a := range attempts {
|
|
||||||
detail.AttemptRows = append(detail.AttemptRows, adminconsole.MailAttemptRow{
|
|
||||||
AttemptNo: a.AttemptNo,
|
|
||||||
Outcome: a.Outcome,
|
|
||||||
Started: fmtConsoleTime(a.StartedAt),
|
|
||||||
Finished: fmtConsoleTimePtr(a.FinishedAt),
|
|
||||||
Error: a.Error,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return detail
|
|
||||||
}
|
|
||||||
|
|
||||||
func toNotificationsData(notifications notification.AdminListNotificationsPage, dead notification.AdminListDeadLettersPage, malformed notification.AdminListMalformedPage) adminconsole.NotificationsData {
|
|
||||||
data := adminconsole.NotificationsData{
|
|
||||||
Notifications: make([]adminconsole.NotificationRow, 0, len(notifications.Items)),
|
|
||||||
DeadLetters: make([]adminconsole.NotificationDeadLetterRow, 0, len(dead.Items)),
|
|
||||||
Malformed: make([]adminconsole.MalformedRow, 0, len(malformed.Items)),
|
|
||||||
}
|
|
||||||
for _, n := range notifications.Items {
|
|
||||||
data.Notifications = append(data.Notifications, adminconsole.NotificationRow{
|
|
||||||
NotificationID: n.NotificationID.String(),
|
|
||||||
Kind: n.Kind,
|
|
||||||
UserID: optionalUUID(n.UserID),
|
|
||||||
Created: fmtConsoleTime(n.CreatedAt),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for _, d := range dead.Items {
|
|
||||||
data.DeadLetters = append(data.DeadLetters, adminconsole.NotificationDeadLetterRow{
|
|
||||||
NotificationID: d.NotificationID.String(),
|
|
||||||
RouteID: d.RouteID.String(),
|
|
||||||
Reason: d.Reason,
|
|
||||||
Archived: fmtConsoleTime(d.ArchivedAt),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for _, m := range malformed.Items {
|
|
||||||
data.Malformed = append(data.Malformed, adminconsole.MalformedRow{
|
|
||||||
ID: m.ID.String(),
|
|
||||||
Reason: m.Reason,
|
|
||||||
Received: fmtConsoleTime(m.ReceivedAt),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// optionalUUID renders a nullable user id; system-scoped rows have none.
|
|
||||||
func optionalUUID(id *uuid.UUID) string {
|
|
||||||
if id == nil {
|
|
||||||
return "—"
|
|
||||||
}
|
|
||||||
return id.String()
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"galaxy/backend/internal/adminconsole"
|
|
||||||
"galaxy/backend/internal/diplomail"
|
|
||||||
"galaxy/backend/internal/mail"
|
|
||||||
"galaxy/backend/internal/notification"
|
|
||||||
"galaxy/backend/internal/server/middleware/basicauth"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fakeMailAdmin struct {
|
|
||||||
deliveries mail.AdminListDeliveriesPage
|
|
||||||
dead mail.AdminListDeadLettersPage
|
|
||||||
delivery mail.Delivery
|
|
||||||
getErr error
|
|
||||||
attempts []mail.Attempt
|
|
||||||
resendCalls int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeMailAdmin) AdminListDeliveries(context.Context, int, int) (mail.AdminListDeliveriesPage, error) {
|
|
||||||
return f.deliveries, nil
|
|
||||||
}
|
|
||||||
func (f *fakeMailAdmin) AdminGetDelivery(context.Context, uuid.UUID) (mail.Delivery, error) {
|
|
||||||
return f.delivery, f.getErr
|
|
||||||
}
|
|
||||||
func (f *fakeMailAdmin) AdminListAttempts(context.Context, uuid.UUID) ([]mail.Attempt, error) {
|
|
||||||
return f.attempts, nil
|
|
||||||
}
|
|
||||||
func (f *fakeMailAdmin) AdminResendDelivery(context.Context, uuid.UUID) (mail.Delivery, error) {
|
|
||||||
f.resendCalls++
|
|
||||||
return f.delivery, nil
|
|
||||||
}
|
|
||||||
func (f *fakeMailAdmin) AdminListDeadLetters(context.Context, int, int) (mail.AdminListDeadLettersPage, error) {
|
|
||||||
return f.dead, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeNotificationAdmin struct {
|
|
||||||
notifications notification.AdminListNotificationsPage
|
|
||||||
dead notification.AdminListDeadLettersPage
|
|
||||||
malformed notification.AdminListMalformedPage
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeNotificationAdmin) AdminListNotifications(context.Context, int, int) (notification.AdminListNotificationsPage, error) {
|
|
||||||
return f.notifications, nil
|
|
||||||
}
|
|
||||||
func (f *fakeNotificationAdmin) AdminListDeadLetters(context.Context, int, int) (notification.AdminListDeadLettersPage, error) {
|
|
||||||
return f.dead, nil
|
|
||||||
}
|
|
||||||
func (f *fakeNotificationAdmin) AdminListMalformed(context.Context, int, int) (notification.AdminListMalformedPage, error) {
|
|
||||||
return f.malformed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeDiplomailAdmin struct {
|
|
||||||
total int
|
|
||||||
err error
|
|
||||||
broadcastCalls int
|
|
||||||
last diplomail.SendMultiGameBroadcastInput
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeDiplomailAdmin) SendAdminMultiGameBroadcast(_ context.Context, in diplomail.SendMultiGameBroadcastInput) ([]diplomail.Message, int, error) {
|
|
||||||
f.broadcastCalls++
|
|
||||||
f.last = in
|
|
||||||
if f.err != nil {
|
|
||||||
return nil, 0, f.err
|
|
||||||
}
|
|
||||||
return nil, f.total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mailConsoleRouter(t *testing.T, m MailAdmin, n NotificationAdmin, d DiplomailAdmin) (http.Handler, *adminconsole.CSRF) {
|
|
||||||
t.Helper()
|
|
||||||
csrf := adminconsole.NewCSRF([]byte("test-key"))
|
|
||||||
handler, err := NewRouter(RouterDependencies{
|
|
||||||
Logger: zap.NewNop(),
|
|
||||||
AdminVerifier: basicauth.NewStaticVerifier("secret"),
|
|
||||||
AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf, Mail: m, Notifications: n, Diplomail: d}),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewRouter: %v", err)
|
|
||||||
}
|
|
||||||
return handler, csrf
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleMailPage(t *testing.T) {
|
|
||||||
id := uuid.New()
|
|
||||||
m := &fakeMailAdmin{
|
|
||||||
deliveries: mail.AdminListDeliveriesPage{
|
|
||||||
Items: []mail.Delivery{{DeliveryID: id, TemplateID: "auth.login_code", Status: "pending", CreatedAt: time.Now()}},
|
|
||||||
Page: 1, PageSize: 50, Total: 1,
|
|
||||||
},
|
|
||||||
dead: mail.AdminListDeadLettersPage{
|
|
||||||
Items: []mail.DeadLetter{{DeliveryID: uuid.New(), Reason: "smtp 550", ArchivedAt: time.Now()}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
router, _ := mailConsoleRouter(t, m, nil, nil)
|
|
||||||
|
|
||||||
rec := consoleGet(t, router, "/_gm/mail")
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
for _, want := range []string{"auth.login_code", "pending", "Dead-letters", "smtp 550"} {
|
|
||||||
if !strings.Contains(rec.Body.String(), want) {
|
|
||||||
t.Errorf("mail page missing %q", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleMailDeliveryDetail(t *testing.T) {
|
|
||||||
id := uuid.New()
|
|
||||||
m := &fakeMailAdmin{
|
|
||||||
delivery: mail.Delivery{DeliveryID: id, TemplateID: "auth.login_code", Status: "pending", Attempts: 2},
|
|
||||||
attempts: []mail.Attempt{{AttemptNo: 1, Outcome: "transient_failure", StartedAt: time.Now(), Error: "timeout"}},
|
|
||||||
}
|
|
||||||
router, _ := mailConsoleRouter(t, m, nil, nil)
|
|
||||||
|
|
||||||
rec := consoleGet(t, router, "/_gm/mail/deliveries/"+id.String())
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
for _, want := range []string{id.String(), "auth.login_code", "Attempts", "transient_failure", "Resend"} {
|
|
||||||
if !strings.Contains(rec.Body.String(), want) {
|
|
||||||
t.Errorf("delivery detail missing %q", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleMailDeliveryDetailNotFound(t *testing.T) {
|
|
||||||
m := &fakeMailAdmin{getErr: mail.ErrDeliveryNotFound}
|
|
||||||
router, _ := mailConsoleRouter(t, m, nil, nil)
|
|
||||||
|
|
||||||
rec := consoleGet(t, router, "/_gm/mail/deliveries/"+uuid.New().String())
|
|
||||||
if rec.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("status = %d, want 404", rec.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleMailResend(t *testing.T) {
|
|
||||||
id := uuid.New()
|
|
||||||
m := &fakeMailAdmin{delivery: mail.Delivery{DeliveryID: id}}
|
|
||||||
router, csrf := mailConsoleRouter(t, m, nil, nil)
|
|
||||||
|
|
||||||
rec := consolePost(t, router, "/_gm/mail/deliveries/"+id.String()+"/resend", "_csrf="+csrf.Token("ops"))
|
|
||||||
if rec.Code != http.StatusSeeOther {
|
|
||||||
t.Fatalf("status = %d, want 303", rec.Code)
|
|
||||||
}
|
|
||||||
if m.resendCalls != 1 {
|
|
||||||
t.Errorf("AdminResendDelivery called %d times, want 1", m.resendCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleMailResendRejectsBadCSRF(t *testing.T) {
|
|
||||||
id := uuid.New()
|
|
||||||
m := &fakeMailAdmin{delivery: mail.Delivery{DeliveryID: id}}
|
|
||||||
router, _ := mailConsoleRouter(t, m, nil, nil)
|
|
||||||
|
|
||||||
rec := consolePost(t, router, "/_gm/mail/deliveries/"+id.String()+"/resend", "")
|
|
||||||
if rec.Code != http.StatusForbidden {
|
|
||||||
t.Fatalf("status = %d, want 403", rec.Code)
|
|
||||||
}
|
|
||||||
if m.resendCalls != 0 {
|
|
||||||
t.Error("resend must not run without a CSRF token")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleNotificationsPage(t *testing.T) {
|
|
||||||
n := &fakeNotificationAdmin{
|
|
||||||
notifications: notification.AdminListNotificationsPage{Items: []notification.Notification{{NotificationID: uuid.New(), Kind: "lobby.invite.received"}}},
|
|
||||||
dead: notification.AdminListDeadLettersPage{Items: []notification.DeadLetter{{NotificationID: uuid.New(), RouteID: uuid.New(), Reason: "push gone"}}},
|
|
||||||
malformed: notification.AdminListMalformedPage{Items: []notification.MalformedIntent{{ID: uuid.New(), Reason: "bad shape"}}},
|
|
||||||
}
|
|
||||||
router, _ := mailConsoleRouter(t, nil, n, nil)
|
|
||||||
|
|
||||||
rec := consoleGet(t, router, "/_gm/notifications")
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
for _, want := range []string{"lobby.invite.received", "push gone", "bad shape", "Malformed intents"} {
|
|
||||||
if !strings.Contains(rec.Body.String(), want) {
|
|
||||||
t.Errorf("notifications page missing %q", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleBroadcastForm(t *testing.T) {
|
|
||||||
router, _ := mailConsoleRouter(t, nil, nil, &fakeDiplomailAdmin{})
|
|
||||||
|
|
||||||
rec := consoleGet(t, router, "/_gm/broadcast")
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", rec.Code)
|
|
||||||
}
|
|
||||||
if !strings.Contains(rec.Body.String(), "Send broadcast") {
|
|
||||||
t.Error("broadcast form missing")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleBroadcastSend(t *testing.T) {
|
|
||||||
d := &fakeDiplomailAdmin{total: 5}
|
|
||||||
router, csrf := mailConsoleRouter(t, nil, nil, d)
|
|
||||||
|
|
||||||
form := "_csrf=" + csrf.Token("ops") + "&scope=all_running&recipients=active&body=hello"
|
|
||||||
rec := consolePost(t, router, "/_gm/broadcast", form)
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
if !strings.Contains(rec.Body.String(), "5 recipients") {
|
|
||||||
t.Errorf("broadcast result missing recipient count; body=%s", rec.Body.String())
|
|
||||||
}
|
|
||||||
if d.broadcastCalls != 1 || d.last.Scope != "all_running" || d.last.Body != "hello" || d.last.CallerUsername != "ops" {
|
|
||||||
t.Errorf("broadcast input = %+v (calls=%d)", d.last, d.broadcastCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleBroadcastSendBadGameIDs(t *testing.T) {
|
|
||||||
d := &fakeDiplomailAdmin{}
|
|
||||||
router, csrf := mailConsoleRouter(t, nil, nil, d)
|
|
||||||
|
|
||||||
form := "_csrf=" + csrf.Token("ops") + "&scope=selected&game_ids=not-a-uuid&body=hello"
|
|
||||||
rec := consolePost(t, router, "/_gm/broadcast", form)
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want 400", rec.Code)
|
|
||||||
}
|
|
||||||
if d.broadcastCalls != 0 {
|
|
||||||
t.Error("broadcast must not run with invalid game ids")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleMailUnavailable(t *testing.T) {
|
|
||||||
router, _ := mailConsoleRouter(t, nil, nil, nil)
|
|
||||||
|
|
||||||
rec := consoleGet(t, router, "/_gm/mail")
|
|
||||||
if rec.Code != http.StatusServiceUnavailable {
|
|
||||||
t.Fatalf("status = %d, want 503", rec.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"galaxy/backend/internal/admin"
|
|
||||||
"galaxy/backend/internal/adminconsole"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// OperatorAdmin is the subset of the admin-account service the console uses.
|
|
||||||
// *admin.Service satisfies it.
|
|
||||||
type OperatorAdmin interface {
|
|
||||||
List(ctx context.Context) ([]admin.Admin, error)
|
|
||||||
Create(ctx context.Context, in admin.CreateInput) (admin.Admin, error)
|
|
||||||
Disable(ctx context.Context, username string) (admin.Admin, error)
|
|
||||||
Enable(ctx context.Context, username string) (admin.Admin, error)
|
|
||||||
ResetPassword(ctx context.Context, username, password string) (admin.Admin, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// OperatorsList renders GET /_gm/operators.
|
|
||||||
func (h *AdminConsoleHandlers) OperatorsList() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if h.operators == nil {
|
|
||||||
h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
admins, err := h.operators.List(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("admin console: list operators", zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "operators", "Operators", "Failed to load operators.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.render(c, http.StatusOK, "operators", "operators", "Operators", toOperatorsData(admins))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// OperatorCreate handles POST /_gm/operators.
|
|
||||||
func (h *AdminConsoleHandlers) OperatorCreate() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if h.operators == nil {
|
|
||||||
h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, err := h.operators.Create(c.Request.Context(), admin.CreateInput{
|
|
||||||
Username: strings.TrimSpace(c.PostForm("username")),
|
|
||||||
Password: c.PostForm("password"),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, admin.ErrUsernameTaken):
|
|
||||||
h.renderMessage(c, http.StatusConflict, "operators", "Username taken", "That username is already in use.", "bad", "/_gm/operators")
|
|
||||||
case errors.Is(err, admin.ErrInvalidInput):
|
|
||||||
h.renderMessage(c, http.StatusBadRequest, "operators", "Invalid input", "Username and password are required.", "bad", "/_gm/operators")
|
|
||||||
default:
|
|
||||||
h.logger.Error("admin console: create operator", zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "operators", "Create failed", "Failed to create the operator.", "bad", "/_gm/operators")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Redirect(http.StatusSeeOther, "/_gm/operators")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// OperatorDisable handles POST /_gm/operators/:username/disable.
|
|
||||||
func (h *AdminConsoleHandlers) OperatorDisable() gin.HandlerFunc {
|
|
||||||
return h.operatorAction("disable", func(ctx context.Context, username string) error {
|
|
||||||
_, err := h.operators.Disable(ctx, username)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// OperatorEnable handles POST /_gm/operators/:username/enable.
|
|
||||||
func (h *AdminConsoleHandlers) OperatorEnable() gin.HandlerFunc {
|
|
||||||
return h.operatorAction("enable", func(ctx context.Context, username string) error {
|
|
||||||
_, err := h.operators.Enable(ctx, username)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// OperatorResetPassword handles POST /_gm/operators/:username/reset-password.
|
|
||||||
func (h *AdminConsoleHandlers) OperatorResetPassword() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if h.operators == nil {
|
|
||||||
h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
username := c.Param("username")
|
|
||||||
password := c.PostForm("password")
|
|
||||||
if strings.TrimSpace(password) == "" {
|
|
||||||
h.renderMessage(c, http.StatusBadRequest, "operators", "Invalid input", "A new password is required.", "bad", "/_gm/operators")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err := h.operators.ResetPassword(c.Request.Context(), username, password); err != nil {
|
|
||||||
if errors.Is(err, admin.ErrNotFound) {
|
|
||||||
h.renderMessage(c, http.StatusNotFound, "operators", "Operator not found", "No such operator.", "bad", "/_gm/operators")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if errors.Is(err, admin.ErrInvalidInput) {
|
|
||||||
h.renderMessage(c, http.StatusBadRequest, "operators", "Invalid input", "The password was rejected.", "bad", "/_gm/operators")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.logger.Error("admin console: reset operator password", zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "operators", "Reset failed", "Failed to reset the password.", "bad", "/_gm/operators")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Redirect(http.StatusSeeOther, "/_gm/operators")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// operatorAction is the shared shape for operator POST actions that take only
|
|
||||||
// the username and redirect back to the list.
|
|
||||||
func (h *AdminConsoleHandlers) operatorAction(label string, run func(context.Context, string) error) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if h.operators == nil {
|
|
||||||
h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := run(c.Request.Context(), c.Param("username")); err != nil {
|
|
||||||
if errors.Is(err, admin.ErrNotFound) {
|
|
||||||
h.renderMessage(c, http.StatusNotFound, "operators", "Operator not found", "No such operator.", "bad", "/_gm/operators")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.logger.Error("admin console: operator "+label, zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "operators", "Action failed", "The "+label+" action failed.", "bad", "/_gm/operators")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Redirect(http.StatusSeeOther, "/_gm/operators")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// toOperatorsData maps admin accounts into the operators view model.
|
|
||||||
func toOperatorsData(admins []admin.Admin) adminconsole.OperatorsData {
|
|
||||||
data := adminconsole.OperatorsData{Items: make([]adminconsole.OperatorRow, 0, len(admins))}
|
|
||||||
for _, a := range admins {
|
|
||||||
data.Items = append(data.Items, adminconsole.OperatorRow{
|
|
||||||
Username: a.Username,
|
|
||||||
CreatedAt: fmtConsoleTime(a.CreatedAt),
|
|
||||||
LastUsedAt: fmtConsoleTimePtr(a.LastUsedAt),
|
|
||||||
Disabled: a.DisabledAt != nil,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"galaxy/backend/internal/admin"
|
|
||||||
"galaxy/backend/internal/adminconsole"
|
|
||||||
"galaxy/backend/internal/server/middleware/basicauth"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fakeOperatorAdmin struct {
|
|
||||||
list []admin.Admin
|
|
||||||
createErr error
|
|
||||||
|
|
||||||
created admin.CreateInput
|
|
||||||
createCalls int
|
|
||||||
disableCalls int
|
|
||||||
enableCalls int
|
|
||||||
resetCalls int
|
|
||||||
lastResetUser string
|
|
||||||
lastResetPass string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeOperatorAdmin) List(context.Context) ([]admin.Admin, error) { return f.list, nil }
|
|
||||||
func (f *fakeOperatorAdmin) Create(_ context.Context, in admin.CreateInput) (admin.Admin, error) {
|
|
||||||
f.createCalls++
|
|
||||||
f.created = in
|
|
||||||
if f.createErr != nil {
|
|
||||||
return admin.Admin{}, f.createErr
|
|
||||||
}
|
|
||||||
return admin.Admin{Username: in.Username}, nil
|
|
||||||
}
|
|
||||||
func (f *fakeOperatorAdmin) Disable(_ context.Context, username string) (admin.Admin, error) {
|
|
||||||
f.disableCalls++
|
|
||||||
return admin.Admin{Username: username}, nil
|
|
||||||
}
|
|
||||||
func (f *fakeOperatorAdmin) Enable(_ context.Context, username string) (admin.Admin, error) {
|
|
||||||
f.enableCalls++
|
|
||||||
return admin.Admin{Username: username}, nil
|
|
||||||
}
|
|
||||||
func (f *fakeOperatorAdmin) ResetPassword(_ context.Context, username, password string) (admin.Admin, error) {
|
|
||||||
f.resetCalls++
|
|
||||||
f.lastResetUser = username
|
|
||||||
f.lastResetPass = password
|
|
||||||
return admin.Admin{Username: username}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func operatorsRouter(t *testing.T, operators OperatorAdmin) (http.Handler, *adminconsole.CSRF) {
|
|
||||||
t.Helper()
|
|
||||||
csrf := adminconsole.NewCSRF([]byte("test-key"))
|
|
||||||
handler, err := NewRouter(RouterDependencies{
|
|
||||||
Logger: zap.NewNop(),
|
|
||||||
AdminVerifier: basicauth.NewStaticVerifier("secret"),
|
|
||||||
AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf, Operators: operators}),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewRouter: %v", err)
|
|
||||||
}
|
|
||||||
return handler, csrf
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleOperatorsList(t *testing.T) {
|
|
||||||
fake := &fakeOperatorAdmin{list: []admin.Admin{{Username: "root"}}}
|
|
||||||
router, _ := operatorsRouter(t, fake)
|
|
||||||
|
|
||||||
rec := consoleGet(t, router, "/_gm/operators")
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
for _, want := range []string{"root", "Create operator", "Reset"} {
|
|
||||||
if !strings.Contains(rec.Body.String(), want) {
|
|
||||||
t.Errorf("operators page missing %q", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleOperatorCreate(t *testing.T) {
|
|
||||||
fake := &fakeOperatorAdmin{}
|
|
||||||
router, csrf := operatorsRouter(t, fake)
|
|
||||||
|
|
||||||
rec := consolePost(t, router, "/_gm/operators", "_csrf="+csrf.Token("ops")+"&username=mod&password=s3cret")
|
|
||||||
if rec.Code != http.StatusSeeOther {
|
|
||||||
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
if fake.createCalls != 1 || fake.created.Username != "mod" || fake.created.Password != "s3cret" {
|
|
||||||
t.Errorf("create recorded %d username=%q", fake.createCalls, fake.created.Username)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleOperatorCreateConflict(t *testing.T) {
|
|
||||||
fake := &fakeOperatorAdmin{createErr: admin.ErrUsernameTaken}
|
|
||||||
router, csrf := operatorsRouter(t, fake)
|
|
||||||
|
|
||||||
rec := consolePost(t, router, "/_gm/operators", "_csrf="+csrf.Token("ops")+"&username=root&password=x")
|
|
||||||
if rec.Code != http.StatusConflict {
|
|
||||||
t.Fatalf("status = %d, want 409", rec.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleOperatorDisableEnable(t *testing.T) {
|
|
||||||
fake := &fakeOperatorAdmin{}
|
|
||||||
router, csrf := operatorsRouter(t, fake)
|
|
||||||
|
|
||||||
if rec := consolePost(t, router, "/_gm/operators/root/disable", "_csrf="+csrf.Token("ops")); rec.Code != http.StatusSeeOther {
|
|
||||||
t.Fatalf("disable status = %d, want 303", rec.Code)
|
|
||||||
}
|
|
||||||
if rec := consolePost(t, router, "/_gm/operators/root/enable", "_csrf="+csrf.Token("ops")); rec.Code != http.StatusSeeOther {
|
|
||||||
t.Fatalf("enable status = %d, want 303", rec.Code)
|
|
||||||
}
|
|
||||||
if fake.disableCalls != 1 || fake.enableCalls != 1 {
|
|
||||||
t.Errorf("disable=%d enable=%d, want 1/1", fake.disableCalls, fake.enableCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleOperatorResetPassword(t *testing.T) {
|
|
||||||
fake := &fakeOperatorAdmin{}
|
|
||||||
router, csrf := operatorsRouter(t, fake)
|
|
||||||
|
|
||||||
rec := consolePost(t, router, "/_gm/operators/root/reset-password", "_csrf="+csrf.Token("ops")+"&password=newpass")
|
|
||||||
if rec.Code != http.StatusSeeOther {
|
|
||||||
t.Fatalf("status = %d, want 303", rec.Code)
|
|
||||||
}
|
|
||||||
if fake.resetCalls != 1 || fake.lastResetUser != "root" || fake.lastResetPass != "newpass" {
|
|
||||||
t.Errorf("reset recorded %d user=%q", fake.resetCalls, fake.lastResetUser)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleOperatorResetPasswordMissing(t *testing.T) {
|
|
||||||
fake := &fakeOperatorAdmin{}
|
|
||||||
router, csrf := operatorsRouter(t, fake)
|
|
||||||
|
|
||||||
rec := consolePost(t, router, "/_gm/operators/root/reset-password", "_csrf="+csrf.Token("ops"))
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want 400", rec.Code)
|
|
||||||
}
|
|
||||||
if fake.resetCalls != 0 {
|
|
||||||
t.Error("reset must not run without a password")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleOperatorRejectsBadCSRF(t *testing.T) {
|
|
||||||
fake := &fakeOperatorAdmin{}
|
|
||||||
router, _ := operatorsRouter(t, fake)
|
|
||||||
|
|
||||||
rec := consolePost(t, router, "/_gm/operators/root/disable", "")
|
|
||||||
if rec.Code != http.StatusForbidden {
|
|
||||||
t.Fatalf("status = %d, want 403", rec.Code)
|
|
||||||
}
|
|
||||||
if fake.disableCalls != 0 {
|
|
||||||
t.Error("disable must not run without a CSRF token")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleOperatorsUnavailable(t *testing.T) {
|
|
||||||
router, _ := operatorsRouter(t, nil)
|
|
||||||
|
|
||||||
rec := consoleGet(t, router, "/_gm/operators")
|
|
||||||
if rec.Code != http.StatusServiceUnavailable {
|
|
||||||
t.Fatalf("status = %d, want 503", rec.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"galaxy/backend/internal/adminconsole"
|
|
||||||
"galaxy/backend/internal/opsstatus"
|
|
||||||
"galaxy/backend/internal/server/middleware/basicauth"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fakeMonitor is a static opsstatus.Reader for dashboard rendering tests.
|
|
||||||
type fakeMonitor struct {
|
|
||||||
snapshot opsstatus.Snapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f fakeMonitor) Collect(context.Context) opsstatus.Snapshot {
|
|
||||||
return f.snapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
func newConsoleTestRouter(t *testing.T) http.Handler {
|
|
||||||
t.Helper()
|
|
||||||
handler, err := NewRouter(RouterDependencies{
|
|
||||||
Logger: zap.NewNop(),
|
|
||||||
AdminVerifier: basicauth.NewStaticVerifier("secret"),
|
|
||||||
AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: adminconsole.NewCSRF([]byte("test-key"))}),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewRouter: %v", err)
|
|
||||||
}
|
|
||||||
return handler
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAdminConsoleRequiresAuth(t *testing.T) {
|
|
||||||
router := newConsoleTestRouter(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/_gm/", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusUnauthorized {
|
|
||||||
t.Fatalf("status = %d, want 401", rec.Code)
|
|
||||||
}
|
|
||||||
if got := rec.Header().Get("WWW-Authenticate"); !strings.Contains(got, "Basic") {
|
|
||||||
t.Fatalf("WWW-Authenticate = %q, want a Basic challenge", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAdminConsoleDashboardRenders(t *testing.T) {
|
|
||||||
router := newConsoleTestRouter(t)
|
|
||||||
|
|
||||||
for _, path := range []string{"/_gm", "/_gm/"} {
|
|
||||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
||||||
req.SetBasicAuth("ops", "secret")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("GET %s status = %d, want 200; body=%s", path, rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
|
||||||
t.Errorf("GET %s content-type = %q, want text/html", path, ct)
|
|
||||||
}
|
|
||||||
body := rec.Body.String()
|
|
||||||
if !strings.Contains(body, "Dashboard") {
|
|
||||||
t.Errorf("GET %s body missing the dashboard heading", path)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "ops") {
|
|
||||||
t.Errorf("GET %s body missing the operator name", path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAdminConsoleDashboardShowsMonitoring(t *testing.T) {
|
|
||||||
monitor := fakeMonitor{snapshot: opsstatus.Snapshot{
|
|
||||||
PostgresHealthy: true,
|
|
||||||
Runtimes: []opsstatus.StatusCount{{Status: "running", Count: 3}, {Status: "stopped", Count: 1}},
|
|
||||||
MailDeliveries: []opsstatus.StatusCount{{Status: "pending", Count: 2}},
|
|
||||||
NotificationRoutes: []opsstatus.StatusCount{{Status: "published", Count: 9}},
|
|
||||||
NotificationMalformed: 4,
|
|
||||||
Errors: []string{"notification route counts: boom"},
|
|
||||||
}}
|
|
||||||
|
|
||||||
handler, err := NewRouter(RouterDependencies{
|
|
||||||
Logger: zap.NewNop(),
|
|
||||||
AdminVerifier: basicauth.NewStaticVerifier("secret"),
|
|
||||||
AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{
|
|
||||||
CSRF: adminconsole.NewCSRF([]byte("test-key")),
|
|
||||||
Monitor: monitor,
|
|
||||||
Ready: func() bool { return true },
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewRouter: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/_gm/", nil)
|
|
||||||
req.SetBasicAuth("ops", "secret")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
body := rec.Body.String()
|
|
||||||
for _, want := range []string{
|
|
||||||
"Game runtimes", "running", "stopped",
|
|
||||||
"Mail deliveries", "pending",
|
|
||||||
"Notification routes", "published",
|
|
||||||
"Malformed notifications",
|
|
||||||
"notification route counts: boom",
|
|
||||||
"healthy",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(body, want) {
|
|
||||||
t.Errorf("dashboard body missing %q", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAdminConsoleDashboardWithoutMonitor(t *testing.T) {
|
|
||||||
router := newConsoleTestRouter(t) // no monitor wired
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/_gm/", nil)
|
|
||||||
req.SetBasicAuth("ops", "secret")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", rec.Code)
|
|
||||||
}
|
|
||||||
if !strings.Contains(rec.Body.String(), "Monitoring is not wired") {
|
|
||||||
t.Error("dashboard without a monitor should note that monitoring is unavailable")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAdminConsoleServesAsset(t *testing.T) {
|
|
||||||
router := newConsoleTestRouter(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/_gm/assets/console.css", nil)
|
|
||||||
req.SetBasicAuth("ops", "secret")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("asset status = %d, want 200", rec.Code)
|
|
||||||
}
|
|
||||||
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/css") {
|
|
||||||
t.Errorf("asset content-type = %q, want text/css", ct)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAdminConsoleRequireCSRF(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
csrf := adminconsole.NewCSRF([]byte("test-key"))
|
|
||||||
console := NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf})
|
|
||||||
|
|
||||||
engine := gin.New()
|
|
||||||
engine.Use(func(c *gin.Context) {
|
|
||||||
c.Request = c.Request.WithContext(basicauth.WithUsername(c.Request.Context(), "ops"))
|
|
||||||
c.Next()
|
|
||||||
})
|
|
||||||
engine.Use(console.RequireCSRF())
|
|
||||||
engine.GET("/x", func(c *gin.Context) { c.Status(http.StatusOK) })
|
|
||||||
engine.POST("/x", func(c *gin.Context) { c.Status(http.StatusOK) })
|
|
||||||
|
|
||||||
token := csrf.Token("ops")
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
method string
|
|
||||||
form string
|
|
||||||
origin string
|
|
||||||
host string
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{"get is a safe method", http.MethodGet, "", "", "galaxy.lan", http.StatusOK},
|
|
||||||
{"valid token, same origin", http.MethodPost, "_csrf=" + token, "https://galaxy.lan", "galaxy.lan", http.StatusOK},
|
|
||||||
{"valid token, no origin header", http.MethodPost, "_csrf=" + token, "", "galaxy.lan", http.StatusOK},
|
|
||||||
{"missing token", http.MethodPost, "", "https://galaxy.lan", "galaxy.lan", http.StatusForbidden},
|
|
||||||
{"wrong token", http.MethodPost, "_csrf=bogus", "https://galaxy.lan", "galaxy.lan", http.StatusForbidden},
|
|
||||||
{"cross-origin", http.MethodPost, "_csrf=" + token, "https://evil.example", "galaxy.lan", http.StatusForbidden},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
var body io.Reader
|
|
||||||
if tc.form != "" {
|
|
||||||
body = strings.NewReader(tc.form)
|
|
||||||
}
|
|
||||||
req := httptest.NewRequest(tc.method, "/x", body)
|
|
||||||
if tc.form != "" {
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
}
|
|
||||||
if tc.origin != "" {
|
|
||||||
req.Header.Set("Origin", tc.origin)
|
|
||||||
}
|
|
||||||
req.Host = tc.host
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
engine.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != tc.want {
|
|
||||||
t.Fatalf("status = %d, want %d (body=%s)", rec.Code, tc.want, rec.Body.String())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"galaxy/backend/internal/adminconsole"
|
|
||||||
"galaxy/backend/internal/server/middleware/basicauth"
|
|
||||||
"galaxy/backend/internal/user"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// UserAdmin is the subset of the user service the operator console depends on.
|
|
||||||
// *user.Service satisfies it; tests supply a fake so the console pages render
|
|
||||||
// without a database.
|
|
||||||
type UserAdmin interface {
|
|
||||||
ListAccounts(ctx context.Context, page, pageSize int) (user.AccountPage, error)
|
|
||||||
GetAccount(ctx context.Context, userID uuid.UUID) (user.Account, error)
|
|
||||||
ApplySanction(ctx context.Context, input user.ApplySanctionInput) (user.Account, error)
|
|
||||||
ApplyEntitlement(ctx context.Context, input user.ApplyEntitlementInput) (user.Account, error)
|
|
||||||
SoftDelete(ctx context.Context, userID uuid.UUID, actor user.ActorRef) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// consoleTiers lists the selectable entitlement tiers in display order.
|
|
||||||
var consoleTiers = []string{user.TierFree, user.TierMonthly, user.TierYearly, user.TierPermanent}
|
|
||||||
|
|
||||||
// UsersList renders GET /_gm/users — the paginated account list.
|
|
||||||
func (h *AdminConsoleHandlers) UsersList() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if h.users == nil {
|
|
||||||
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
page := parsePositiveQueryInt(c.Query("page"), 1)
|
|
||||||
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
|
||||||
|
|
||||||
result, err := h.users.ListAccounts(c.Request.Context(), page, pageSize)
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("admin console: list users", zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "users", "Users", "Failed to load users.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.render(c, http.StatusOK, "users", "users", "Users", toUsersListData(result))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserDetail renders GET /_gm/users/:user_id.
|
|
||||||
func (h *AdminConsoleHandlers) UserDetail() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if h.users == nil {
|
|
||||||
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userID, ok := parseUserIDParam(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
account, err := h.users.GetAccount(c.Request.Context(), userID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, user.ErrAccountNotFound) {
|
|
||||||
h.renderMessage(c, http.StatusNotFound, "users", "User not found", "No such user, or the account has been soft-deleted.", "bad", "/_gm/users")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.logger.Error("admin console: get user", zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "users", "Users", "Failed to load the user.", "bad", "/_gm/users")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.render(c, http.StatusOK, "user_detail", "users", account.Email, toUserDetailData(account))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserBlock handles POST /_gm/users/:user_id/block — applies a permanent block.
|
|
||||||
func (h *AdminConsoleHandlers) UserBlock() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if h.users == nil {
|
|
||||||
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userID, ok := parseUserIDParam(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
back := "/_gm/users/" + userID.String()
|
|
||||||
reason := strings.TrimSpace(c.PostForm("reason_code"))
|
|
||||||
if reason == "" {
|
|
||||||
h.renderMessage(c, http.StatusBadRequest, "users", "Invalid input", "A reason is required to block a user.", "bad", back)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, err := h.users.ApplySanction(c.Request.Context(), user.ApplySanctionInput{
|
|
||||||
UserID: userID,
|
|
||||||
SanctionCode: user.SanctionCodePermanentBlock,
|
|
||||||
Scope: "account",
|
|
||||||
ReasonCode: reason,
|
|
||||||
Actor: actorFromContext(c),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("admin console: block user", zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "users", "Block failed", "Failed to block the user.", "bad", back)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Redirect(http.StatusSeeOther, back)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserEntitlement handles POST /_gm/users/:user_id/entitlement.
|
|
||||||
func (h *AdminConsoleHandlers) UserEntitlement() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if h.users == nil {
|
|
||||||
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userID, ok := parseUserIDParam(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
back := "/_gm/users/" + userID.String()
|
|
||||||
tier := strings.TrimSpace(c.PostForm("tier"))
|
|
||||||
source := strings.TrimSpace(c.PostForm("source"))
|
|
||||||
if source == "" {
|
|
||||||
source = "admin"
|
|
||||||
}
|
|
||||||
_, err := h.users.ApplyEntitlement(c.Request.Context(), user.ApplyEntitlementInput{
|
|
||||||
UserID: userID,
|
|
||||||
Tier: tier,
|
|
||||||
Source: source,
|
|
||||||
Actor: actorFromContext(c),
|
|
||||||
ReasonCode: strings.TrimSpace(c.PostForm("reason_code")),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, user.ErrInvalidInput) {
|
|
||||||
h.renderMessage(c, http.StatusBadRequest, "users", "Invalid input", "The entitlement request was rejected: check the tier.", "bad", back)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.logger.Error("admin console: apply entitlement", zap.Error(err))
|
|
||||||
h.renderMessage(c, http.StatusInternalServerError, "users", "Entitlement failed", "Failed to update the entitlement.", "bad", back)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Redirect(http.StatusSeeOther, back)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserSoftDelete handles POST /_gm/users/:user_id/soft-delete.
|
|
||||||
func (h *AdminConsoleHandlers) UserSoftDelete() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if h.users == nil {
|
|
||||||
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userID, ok := parseUserIDParam(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.users.SoftDelete(c.Request.Context(), userID, actorFromContext(c)); err != nil {
|
|
||||||
if errors.Is(err, user.ErrAccountNotFound) {
|
|
||||||
h.renderMessage(c, http.StatusNotFound, "users", "User not found", "No such user.", "bad", "/_gm/users")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// A cascade error does not undo the soft delete; log and proceed.
|
|
||||||
h.logger.Warn("admin console: soft-delete cascade returned error", zap.Error(err))
|
|
||||||
}
|
|
||||||
c.Redirect(http.StatusSeeOther, "/_gm/users")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// actorFromContext builds the admin ActorRef for audit trails from the
|
|
||||||
// authenticated operator username stored by the Basic Auth middleware.
|
|
||||||
func actorFromContext(c *gin.Context) user.ActorRef {
|
|
||||||
username, _ := basicauth.UsernameFromContext(c.Request.Context())
|
|
||||||
return user.ActorRef{Type: "admin", ID: username}
|
|
||||||
}
|
|
||||||
|
|
||||||
// toUsersListData maps an account page into the users list view model.
|
|
||||||
func toUsersListData(page user.AccountPage) adminconsole.UsersListData {
|
|
||||||
data := adminconsole.UsersListData{
|
|
||||||
Items: make([]adminconsole.UserRow, 0, len(page.Items)),
|
|
||||||
Page: page.Page,
|
|
||||||
PageSize: page.PageSize,
|
|
||||||
Total: page.Total,
|
|
||||||
PrevPage: page.Page - 1,
|
|
||||||
NextPage: page.Page + 1,
|
|
||||||
HasPrev: page.Page > 1,
|
|
||||||
HasNext: page.Page*page.PageSize < page.Total,
|
|
||||||
}
|
|
||||||
for _, account := range page.Items {
|
|
||||||
data.Items = append(data.Items, adminconsole.UserRow{
|
|
||||||
UserID: account.UserID.String(),
|
|
||||||
Email: account.Email,
|
|
||||||
UserName: account.UserName,
|
|
||||||
DisplayName: account.DisplayName,
|
|
||||||
Tier: account.Entitlement.Tier,
|
|
||||||
Blocked: account.PermanentBlock,
|
|
||||||
Deleted: account.DeletedAt != nil,
|
|
||||||
CreatedAt: fmtConsoleTime(account.CreatedAt),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// toUserDetailData maps an account aggregate into the detail view model.
|
|
||||||
func toUserDetailData(account user.Account) adminconsole.UserDetailData {
|
|
||||||
data := adminconsole.UserDetailData{
|
|
||||||
UserID: account.UserID.String(),
|
|
||||||
Email: account.Email,
|
|
||||||
UserName: account.UserName,
|
|
||||||
DisplayName: account.DisplayName,
|
|
||||||
PreferredLanguage: account.PreferredLanguage,
|
|
||||||
TimeZone: account.TimeZone,
|
|
||||||
DeclaredCountry: account.DeclaredCountry,
|
|
||||||
Blocked: account.PermanentBlock,
|
|
||||||
Deleted: account.DeletedAt != nil,
|
|
||||||
CreatedAt: fmtConsoleTime(account.CreatedAt),
|
|
||||||
UpdatedAt: fmtConsoleTime(account.UpdatedAt),
|
|
||||||
Tier: account.Entitlement.Tier,
|
|
||||||
IsPaid: account.Entitlement.IsPaid,
|
|
||||||
EntitlementSource: account.Entitlement.Source,
|
|
||||||
EntitlementReason: account.Entitlement.ReasonCode,
|
|
||||||
EntitlementEnds: fmtConsoleTimePtr(account.Entitlement.EndsAt),
|
|
||||||
Tiers: consoleTiers,
|
|
||||||
}
|
|
||||||
for _, sanction := range account.ActiveSanctions {
|
|
||||||
data.Sanctions = append(data.Sanctions, adminconsole.SanctionView{
|
|
||||||
SanctionCode: sanction.SanctionCode,
|
|
||||||
Scope: sanction.Scope,
|
|
||||||
ReasonCode: sanction.ReasonCode,
|
|
||||||
AppliedAt: fmtConsoleTime(sanction.AppliedAt),
|
|
||||||
ExpiresAt: fmtConsoleTimePtr(sanction.ExpiresAt),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// fmtConsoleTime renders a timestamp for display in the console.
|
|
||||||
func fmtConsoleTime(t time.Time) string {
|
|
||||||
if t.IsZero() {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return t.UTC().Format("2006-01-02 15:04 UTC")
|
|
||||||
}
|
|
||||||
|
|
||||||
// fmtConsoleTimePtr renders an optional timestamp, returning "" when nil.
|
|
||||||
func fmtConsoleTimePtr(t *time.Time) string {
|
|
||||||
if t == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return fmtConsoleTime(*t)
|
|
||||||
}
|
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"galaxy/backend/internal/adminconsole"
|
|
||||||
"galaxy/backend/internal/server/middleware/basicauth"
|
|
||||||
"galaxy/backend/internal/user"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fakeUserAdmin records calls so the console handlers can be exercised without
|
|
||||||
// a database.
|
|
||||||
type fakeUserAdmin struct {
|
|
||||||
page user.AccountPage
|
|
||||||
account user.Account
|
|
||||||
getErr error
|
|
||||||
|
|
||||||
sanctionCalls int
|
|
||||||
lastSanction user.ApplySanctionInput
|
|
||||||
entitlementCall int
|
|
||||||
lastEntitlement user.ApplyEntitlementInput
|
|
||||||
softDeleteCalls int
|
|
||||||
lastSoftActor user.ActorRef
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeUserAdmin) ListAccounts(context.Context, int, int) (user.AccountPage, error) {
|
|
||||||
return f.page, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeUserAdmin) GetAccount(context.Context, uuid.UUID) (user.Account, error) {
|
|
||||||
return f.account, f.getErr
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeUserAdmin) ApplySanction(_ context.Context, in user.ApplySanctionInput) (user.Account, error) {
|
|
||||||
f.sanctionCalls++
|
|
||||||
f.lastSanction = in
|
|
||||||
return f.account, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeUserAdmin) ApplyEntitlement(_ context.Context, in user.ApplyEntitlementInput) (user.Account, error) {
|
|
||||||
f.entitlementCall++
|
|
||||||
f.lastEntitlement = in
|
|
||||||
return f.account, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeUserAdmin) SoftDelete(_ context.Context, _ uuid.UUID, actor user.ActorRef) error {
|
|
||||||
f.softDeleteCalls++
|
|
||||||
f.lastSoftActor = actor
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newUsersConsoleRouter(t *testing.T, users UserAdmin) (http.Handler, *adminconsole.CSRF) {
|
|
||||||
t.Helper()
|
|
||||||
csrf := adminconsole.NewCSRF([]byte("test-key"))
|
|
||||||
handler, err := NewRouter(RouterDependencies{
|
|
||||||
Logger: zap.NewNop(),
|
|
||||||
AdminVerifier: basicauth.NewStaticVerifier("secret"),
|
|
||||||
AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf, Users: users}),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewRouter: %v", err)
|
|
||||||
}
|
|
||||||
return handler, csrf
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleUsersList(t *testing.T) {
|
|
||||||
fake := &fakeUserAdmin{page: user.AccountPage{
|
|
||||||
Items: []user.Account{
|
|
||||||
{UserID: uuid.New(), Email: "alice@example.test", UserName: "alice"},
|
|
||||||
{UserID: uuid.New(), Email: "bob@example.test", UserName: "bob", PermanentBlock: true},
|
|
||||||
},
|
|
||||||
Page: 1, PageSize: 50, Total: 2,
|
|
||||||
}}
|
|
||||||
router, _ := newUsersConsoleRouter(t, fake)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/_gm/users", nil)
|
|
||||||
req.SetBasicAuth("ops", "secret")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
body := rec.Body.String()
|
|
||||||
for _, want := range []string{"alice@example.test", "bob@example.test", "blocked", "page 1"} {
|
|
||||||
if !strings.Contains(body, want) {
|
|
||||||
t.Errorf("users list missing %q", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleUserDetailRendersForms(t *testing.T) {
|
|
||||||
id := uuid.New()
|
|
||||||
fake := &fakeUserAdmin{account: user.Account{
|
|
||||||
UserID: id, Email: "alice@example.test", UserName: "alice",
|
|
||||||
Entitlement: user.EntitlementSnapshot{Tier: user.TierFree},
|
|
||||||
}}
|
|
||||||
router, csrf := newUsersConsoleRouter(t, fake)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/_gm/users/"+id.String(), nil)
|
|
||||||
req.SetBasicAuth("ops", "secret")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
body := rec.Body.String()
|
|
||||||
for _, want := range []string{
|
|
||||||
"alice@example.test",
|
|
||||||
"Permanently block",
|
|
||||||
"Update entitlement",
|
|
||||||
"Soft-delete account",
|
|
||||||
csrf.Token("ops"),
|
|
||||||
} {
|
|
||||||
if !strings.Contains(body, want) {
|
|
||||||
t.Errorf("user detail missing %q", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleUserDetailNotFound(t *testing.T) {
|
|
||||||
fake := &fakeUserAdmin{getErr: user.ErrAccountNotFound}
|
|
||||||
router, _ := newUsersConsoleRouter(t, fake)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/_gm/users/"+uuid.New().String(), nil)
|
|
||||||
req.SetBasicAuth("ops", "secret")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("status = %d, want 404", rec.Code)
|
|
||||||
}
|
|
||||||
if !strings.Contains(rec.Body.String(), "not found") {
|
|
||||||
t.Error("expected a not-found message")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleUserBlock(t *testing.T) {
|
|
||||||
id := uuid.New()
|
|
||||||
fake := &fakeUserAdmin{account: user.Account{UserID: id}}
|
|
||||||
router, csrf := newUsersConsoleRouter(t, fake)
|
|
||||||
|
|
||||||
form := "_csrf=" + csrf.Token("ops") + "&reason_code=spam"
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/block", strings.NewReader(form))
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("Origin", "https://galaxy.lan")
|
|
||||||
req.SetBasicAuth("ops", "secret")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusSeeOther {
|
|
||||||
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
if fake.sanctionCalls != 1 {
|
|
||||||
t.Fatalf("ApplySanction called %d times, want 1", fake.sanctionCalls)
|
|
||||||
}
|
|
||||||
if fake.lastSanction.SanctionCode != user.SanctionCodePermanentBlock {
|
|
||||||
t.Errorf("sanction code = %q, want permanent_block", fake.lastSanction.SanctionCode)
|
|
||||||
}
|
|
||||||
if fake.lastSanction.Scope != "account" {
|
|
||||||
t.Errorf("scope = %q, want account", fake.lastSanction.Scope)
|
|
||||||
}
|
|
||||||
if fake.lastSanction.ReasonCode != "spam" {
|
|
||||||
t.Errorf("reason = %q, want spam", fake.lastSanction.ReasonCode)
|
|
||||||
}
|
|
||||||
if fake.lastSanction.Actor.Type != "admin" || fake.lastSanction.Actor.ID != "ops" {
|
|
||||||
t.Errorf("actor = %+v, want admin/ops", fake.lastSanction.Actor)
|
|
||||||
}
|
|
||||||
if fake.lastSanction.UserID != id {
|
|
||||||
t.Errorf("sanction user id = %s, want %s", fake.lastSanction.UserID, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleUserBlockMissingReason(t *testing.T) {
|
|
||||||
id := uuid.New()
|
|
||||||
fake := &fakeUserAdmin{account: user.Account{UserID: id}}
|
|
||||||
router, csrf := newUsersConsoleRouter(t, fake)
|
|
||||||
|
|
||||||
form := "_csrf=" + csrf.Token("ops")
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/block", strings.NewReader(form))
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("Origin", "https://galaxy.lan")
|
|
||||||
req.SetBasicAuth("ops", "secret")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want 400", rec.Code)
|
|
||||||
}
|
|
||||||
if fake.sanctionCalls != 0 {
|
|
||||||
t.Errorf("ApplySanction must not be called without a reason")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleUserBlockRejectsBadCSRF(t *testing.T) {
|
|
||||||
id := uuid.New()
|
|
||||||
fake := &fakeUserAdmin{account: user.Account{UserID: id}}
|
|
||||||
router, _ := newUsersConsoleRouter(t, fake)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/block", strings.NewReader("reason_code=spam"))
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("Origin", "https://galaxy.lan")
|
|
||||||
req.SetBasicAuth("ops", "secret")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusForbidden {
|
|
||||||
t.Fatalf("status = %d, want 403", rec.Code)
|
|
||||||
}
|
|
||||||
if fake.sanctionCalls != 0 {
|
|
||||||
t.Errorf("ApplySanction must not run when the CSRF token is missing")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleUserEntitlement(t *testing.T) {
|
|
||||||
id := uuid.New()
|
|
||||||
fake := &fakeUserAdmin{account: user.Account{UserID: id}}
|
|
||||||
router, csrf := newUsersConsoleRouter(t, fake)
|
|
||||||
|
|
||||||
form := "_csrf=" + csrf.Token("ops") + "&tier=monthly&source=admin&reason_code=promo"
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/entitlement", strings.NewReader(form))
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("Origin", "https://galaxy.lan")
|
|
||||||
req.SetBasicAuth("ops", "secret")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusSeeOther {
|
|
||||||
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
if fake.entitlementCall != 1 {
|
|
||||||
t.Fatalf("ApplyEntitlement called %d times, want 1", fake.entitlementCall)
|
|
||||||
}
|
|
||||||
if fake.lastEntitlement.Tier != user.TierMonthly {
|
|
||||||
t.Errorf("tier = %q, want monthly", fake.lastEntitlement.Tier)
|
|
||||||
}
|
|
||||||
if fake.lastEntitlement.Actor.ID != "ops" {
|
|
||||||
t.Errorf("actor id = %q, want ops", fake.lastEntitlement.Actor.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleUserSoftDelete(t *testing.T) {
|
|
||||||
id := uuid.New()
|
|
||||||
fake := &fakeUserAdmin{account: user.Account{UserID: id}}
|
|
||||||
router, csrf := newUsersConsoleRouter(t, fake)
|
|
||||||
|
|
||||||
form := "_csrf=" + csrf.Token("ops")
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/soft-delete", strings.NewReader(form))
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("Origin", "https://galaxy.lan")
|
|
||||||
req.SetBasicAuth("ops", "secret")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusSeeOther {
|
|
||||||
t.Fatalf("status = %d, want 303", rec.Code)
|
|
||||||
}
|
|
||||||
if got := rec.Header().Get("Location"); got != "/_gm/users" {
|
|
||||||
t.Errorf("redirect Location = %q, want /_gm/users", got)
|
|
||||||
}
|
|
||||||
if fake.softDeleteCalls != 1 {
|
|
||||||
t.Fatalf("SoftDelete called %d times, want 1", fake.softDeleteCalls)
|
|
||||||
}
|
|
||||||
if fake.lastSoftActor.ID != "ops" {
|
|
||||||
t.Errorf("soft-delete actor = %q, want ops", fake.lastSoftActor.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleUsersUnavailable(t *testing.T) {
|
|
||||||
router, _ := newUsersConsoleRouter(t, nil) // no user service wired
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/_gm/users", nil)
|
|
||||||
req.SetBasicAuth("ops", "secret")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusServiceUnavailable {
|
|
||||||
t.Fatalf("status = %d, want 503", rec.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -60,21 +60,16 @@ func (h *AdminDiplomailHandlers) Send() gin.HandlerFunc {
|
|||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
switch req.Target {
|
switch req.Target {
|
||||||
case "", "user":
|
case "", "user":
|
||||||
var recipientID uuid.UUID
|
recipientID, parseErr := uuid.Parse(req.RecipientUserID)
|
||||||
if req.RecipientUserID != "" {
|
|
||||||
parsed, parseErr := uuid.Parse(req.RecipientUserID)
|
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recipientID = parsed
|
|
||||||
}
|
|
||||||
msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
|
msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
|
||||||
GameID: gameID,
|
GameID: gameID,
|
||||||
CallerKind: diplomail.CallerKindAdmin,
|
CallerKind: diplomail.CallerKindAdmin,
|
||||||
CallerUsername: username,
|
CallerUsername: username,
|
||||||
RecipientUserID: recipientID,
|
RecipientUserID: recipientID,
|
||||||
RecipientRaceName: req.RecipientRaceName,
|
|
||||||
Subject: req.Subject,
|
Subject: req.Subject,
|
||||||
Body: req.Body,
|
Body: req.Body,
|
||||||
SenderIP: clientip.ExtractSourceIP(c),
|
SenderIP: clientip.ExtractSourceIP(c),
|
||||||
|
|||||||
@@ -39,6 +39,55 @@ func NewUserGamesHandlers(rt *runtime.Service, engine *engineclient.Client, logg
|
|||||||
return &UserGamesHandlers{runtime: rt, engine: engine, logger: logger.Named("http.user.games")}
|
return &UserGamesHandlers{runtime: rt, engine: engine, logger: logger.Named("http.user.games")}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Commands handles POST /api/v1/user/games/{game_id}/commands.
|
||||||
|
func (h *UserGamesHandlers) Commands() gin.HandlerFunc {
|
||||||
|
if h == nil || h.runtime == nil || h.engine == nil {
|
||||||
|
return handlers.NotImplemented("userGamesCommands")
|
||||||
|
}
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
gameID, ok := parseGameIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, ok := userid.FromContext(c.Request.Context())
|
||||||
|
if !ok {
|
||||||
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user id missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body could not be read")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
if err := h.runtime.CheckOrdersAccept(ctx, gameID); err != nil {
|
||||||
|
respondGameProxyError(c, h.logger, "user games commands", ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID)
|
||||||
|
if err != nil {
|
||||||
|
respondGameProxyError(c, h.logger, "user games commands", ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
endpoint, err := h.runtime.EngineEndpoint(ctx, gameID)
|
||||||
|
if err != nil {
|
||||||
|
respondGameProxyError(c, h.logger, "user games commands", ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, err := rebindActor(body, mapping.RaceName)
|
||||||
|
if err != nil {
|
||||||
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be a JSON object")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := h.engine.ExecuteCommands(ctx, endpoint, payload)
|
||||||
|
if err != nil {
|
||||||
|
respondEngineProxyError(c, h.logger, "user games commands", ctx, resp, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Data(http.StatusOK, "application/json", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Orders handles POST /api/v1/user/games/{game_id}/orders.
|
// Orders handles POST /api/v1/user/games/{game_id}/orders.
|
||||||
func (h *UserGamesHandlers) Orders() gin.HandlerFunc {
|
func (h *UserGamesHandlers) Orders() gin.HandlerFunc {
|
||||||
if h == nil || h.runtime == nil || h.engine == nil {
|
if h == nil || h.runtime == nil || h.engine == nil {
|
||||||
|
|||||||
@@ -86,15 +86,6 @@ func (h *UserLobbyGamesHandlers) Create() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
paid, err := h.svc.IsPaid(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
respondLobbyError(c, h.logger, "user lobby games create", ctx, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !paid {
|
|
||||||
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "creating private games requires a paid subscription")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
owner := userID
|
owner := userID
|
||||||
game, err := h.svc.CreateGame(ctx, lobby.CreateGameInput{
|
game, err := h.svc.CreateGame(ctx, lobby.CreateGameInput{
|
||||||
OwnerUserID: &owner,
|
OwnerUserID: &owner,
|
||||||
|
|||||||
@@ -87,21 +87,16 @@ func (h *UserMailHandlers) SendPersonal() gin.HandlerFunc {
|
|||||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var recipientID uuid.UUID
|
recipientID, err := uuid.Parse(req.RecipientUserID)
|
||||||
if req.RecipientUserID != "" {
|
if err != nil {
|
||||||
parsed, perr := uuid.Parse(req.RecipientUserID)
|
|
||||||
if perr != nil {
|
|
||||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recipientID = parsed
|
|
||||||
}
|
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
msg, rcpt, err := h.svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
msg, rcpt, err := h.svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
||||||
GameID: gameID,
|
GameID: gameID,
|
||||||
SenderUserID: userID,
|
SenderUserID: userID,
|
||||||
RecipientUserID: recipientID,
|
RecipientUserID: recipientID,
|
||||||
RecipientRaceName: req.RecipientRaceName,
|
|
||||||
Subject: req.Subject,
|
Subject: req.Subject,
|
||||||
Body: req.Body,
|
Body: req.Body,
|
||||||
SenderIP: clientip.ExtractSourceIP(c),
|
SenderIP: clientip.ExtractSourceIP(c),
|
||||||
@@ -194,9 +189,9 @@ func (h *UserMailHandlers) Sent() gin.HandlerFunc {
|
|||||||
respondDiplomailError(c, h.logger, "user mail sent", ctx, err)
|
respondDiplomailError(c, h.logger, "user mail sent", ctx, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := userMailSentListWire{Items: make([]userMailMessageDetailWire, 0, len(items))}
|
out := userMailSentListWire{Items: make([]userMailSentSummaryWire, 0, len(items))}
|
||||||
for _, entry := range items {
|
for _, m := range items {
|
||||||
out.Items = append(out.Items, mailMessageDetailToWire(entry, false))
|
out.Items = append(out.Items, mailMessageSummaryToWire(m))
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, out)
|
c.JSON(http.StatusOK, out)
|
||||||
}
|
}
|
||||||
@@ -346,15 +341,11 @@ func (h *UserMailHandlers) SendAdmin() gin.HandlerFunc {
|
|||||||
|
|
||||||
switch req.Target {
|
switch req.Target {
|
||||||
case "", "user":
|
case "", "user":
|
||||||
var recipientID uuid.UUID
|
recipientID, parseErr := uuid.Parse(req.RecipientUserID)
|
||||||
if req.RecipientUserID != "" {
|
|
||||||
parsed, parseErr := uuid.Parse(req.RecipientUserID)
|
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recipientID = parsed
|
|
||||||
}
|
|
||||||
callerUserID := userID
|
callerUserID := userID
|
||||||
msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
|
msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
|
||||||
GameID: gameID,
|
GameID: gameID,
|
||||||
@@ -362,7 +353,6 @@ func (h *UserMailHandlers) SendAdmin() gin.HandlerFunc {
|
|||||||
CallerUserID: &callerUserID,
|
CallerUserID: &callerUserID,
|
||||||
CallerUsername: account.UserName,
|
CallerUsername: account.UserName,
|
||||||
RecipientUserID: recipientID,
|
RecipientUserID: recipientID,
|
||||||
RecipientRaceName: req.RecipientRaceName,
|
|
||||||
Subject: req.Subject,
|
Subject: req.Subject,
|
||||||
Body: req.Body,
|
Body: req.Body,
|
||||||
SenderIP: clientip.ExtractSourceIP(c),
|
SenderIP: clientip.ExtractSourceIP(c),
|
||||||
@@ -459,11 +449,8 @@ func parseMessageIDParam(c *gin.Context) (uuid.UUID, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// userMailSendRequestWire mirrors the request body for SendPersonal.
|
// userMailSendRequestWire mirrors the request body for SendPersonal.
|
||||||
// Exactly one of `recipient_user_id` and `recipient_race_name` must
|
|
||||||
// be supplied; the service rejects ambiguous and empty inputs.
|
|
||||||
type userMailSendRequestWire struct {
|
type userMailSendRequestWire struct {
|
||||||
RecipientUserID string `json:"recipient_user_id,omitempty"`
|
RecipientUserID string `json:"recipient_user_id"`
|
||||||
RecipientRaceName string `json:"recipient_race_name,omitempty"`
|
|
||||||
Subject string `json:"subject,omitempty"`
|
Subject string `json:"subject,omitempty"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
}
|
}
|
||||||
@@ -477,13 +464,12 @@ type userMailSendBroadcastRequestWire struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// userMailSendAdminRequestWire mirrors the request body for the
|
// userMailSendAdminRequestWire mirrors the request body for the
|
||||||
// owner-only admin send. `target="user"` requires exactly one of
|
// owner-only admin send. `target="user"` requires
|
||||||
// `recipient_user_id` and `recipient_race_name`; `target="all"`
|
// `recipient_user_id`; `target="all"` accepts the optional
|
||||||
// accepts the optional `recipients` scope (default `active`).
|
// `recipients` scope (default `active`).
|
||||||
type userMailSendAdminRequestWire struct {
|
type userMailSendAdminRequestWire struct {
|
||||||
Target string `json:"target"`
|
Target string `json:"target"`
|
||||||
RecipientUserID string `json:"recipient_user_id,omitempty"`
|
RecipientUserID string `json:"recipient_user_id,omitempty"`
|
||||||
RecipientRaceName string `json:"recipient_race_name,omitempty"`
|
|
||||||
Recipients string `json:"recipients,omitempty"`
|
Recipients string `json:"recipients,omitempty"`
|
||||||
Subject string `json:"subject,omitempty"`
|
Subject string `json:"subject,omitempty"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
@@ -538,7 +524,6 @@ type userMailMessageDetailWire struct {
|
|||||||
SenderKind string `json:"sender_kind"`
|
SenderKind string `json:"sender_kind"`
|
||||||
SenderUserID *string `json:"sender_user_id,omitempty"`
|
SenderUserID *string `json:"sender_user_id,omitempty"`
|
||||||
SenderUsername *string `json:"sender_username,omitempty"`
|
SenderUsername *string `json:"sender_username,omitempty"`
|
||||||
SenderRaceName *string `json:"sender_race_name,omitempty"`
|
|
||||||
Subject string `json:"subject,omitempty"`
|
Subject string `json:"subject,omitempty"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
BodyLang string `json:"body_lang"`
|
BodyLang string `json:"body_lang"`
|
||||||
@@ -555,18 +540,27 @@ type userMailMessageDetailWire struct {
|
|||||||
Translator *string `json:"translator,omitempty"`
|
Translator *string `json:"translator,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// userMailSentSummaryWire mirrors the response shape for the
|
||||||
|
// sender-side listing. Recipient state is intentionally omitted (one
|
||||||
|
// author may have N recipients per broadcast in later stages).
|
||||||
|
type userMailSentSummaryWire struct {
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
GameID string `json:"game_id"`
|
||||||
|
GameName string `json:"game_name,omitempty"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
BodyLang string `json:"body_lang"`
|
||||||
|
BroadcastScope string `json:"broadcast_scope"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type userMailInboxListWire struct {
|
type userMailInboxListWire struct {
|
||||||
Items []userMailMessageDetailWire `json:"items"`
|
Items []userMailMessageDetailWire `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// userMailSentListWire mirrors the response shape for the
|
|
||||||
// sender-side listing. Phase 28's in-game UI threads sent messages
|
|
||||||
// by the recipient's race name, so the wire carries the full
|
|
||||||
// message detail (including the recipient snapshot) — single sends
|
|
||||||
// contribute one row per message, broadcasts contribute one row per
|
|
||||||
// addressee and the UI collapses them by `message_id`.
|
|
||||||
type userMailSentListWire struct {
|
type userMailSentListWire struct {
|
||||||
Items []userMailMessageDetailWire `json:"items"`
|
Items []userMailSentSummaryWire `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type userMailUnreadCountWire struct {
|
type userMailUnreadCountWire struct {
|
||||||
@@ -603,10 +597,6 @@ func mailMessageDetailToWire(entry diplomail.InboxEntry, justCreated bool) userM
|
|||||||
s := *entry.SenderUsername
|
s := *entry.SenderUsername
|
||||||
out.SenderUsername = &s
|
out.SenderUsername = &s
|
||||||
}
|
}
|
||||||
if entry.SenderRaceName != nil {
|
|
||||||
s := *entry.SenderRaceName
|
|
||||||
out.SenderRaceName = &s
|
|
||||||
}
|
|
||||||
if entry.Recipient.RecipientRaceName != nil {
|
if entry.Recipient.RecipientRaceName != nil {
|
||||||
s := *entry.Recipient.RecipientRaceName
|
s := *entry.Recipient.RecipientRaceName
|
||||||
out.RecipientRaceName = &s
|
out.RecipientRaceName = &s
|
||||||
@@ -634,6 +624,20 @@ func mailMessageDetailToWire(entry diplomail.InboxEntry, justCreated bool) userM
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mailMessageSummaryToWire(m diplomail.Message) userMailSentSummaryWire {
|
||||||
|
return userMailSentSummaryWire{
|
||||||
|
MessageID: m.MessageID.String(),
|
||||||
|
GameID: m.GameID.String(),
|
||||||
|
GameName: m.GameName,
|
||||||
|
Kind: m.Kind,
|
||||||
|
Subject: m.Subject,
|
||||||
|
Body: m.Body,
|
||||||
|
BodyLang: m.BodyLang,
|
||||||
|
BroadcastScope: m.BroadcastScope,
|
||||||
|
CreatedAt: m.CreatedAt.UTC().Format(timestampLayout),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// mailRecipientStateToWire renders the recipient row after a
|
// mailRecipientStateToWire renders the recipient row after a
|
||||||
// mark-read or soft-delete call. The caller only needs the per-user
|
// mark-read or soft-delete call. The caller only needs the per-user
|
||||||
// state, not the full message body again.
|
// state, not the full message body again.
|
||||||
|
|||||||
@@ -81,13 +81,6 @@ type RouterDependencies struct {
|
|||||||
AdminGeo *AdminGeoHandlers
|
AdminGeo *AdminGeoHandlers
|
||||||
InternalSessions *InternalSessionsHandlers
|
InternalSessions *InternalSessionsHandlers
|
||||||
InternalUsers *InternalUsersHandlers
|
InternalUsers *InternalUsersHandlers
|
||||||
|
|
||||||
// AdminConsole, when non-nil, mounts the server-rendered operator
|
|
||||||
// console under the `/_gm` route group behind the same admin Basic
|
|
||||||
// Auth verifier as `/api/v1/admin`. A nil value leaves the console
|
|
||||||
// unmounted, which keeps routers built without console wiring (the
|
|
||||||
// contract test, most unit tests) unchanged.
|
|
||||||
AdminConsole *AdminConsoleHandlers
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRouter constructs the backend gin engine wired with the documented
|
// NewRouter constructs the backend gin engine wired with the documented
|
||||||
@@ -130,7 +123,6 @@ func NewRouter(deps RouterDependencies) (http.Handler, error) {
|
|||||||
registerUserRoutes(router, instruments, deps)
|
registerUserRoutes(router, instruments, deps)
|
||||||
registerAdminRoutes(router, instruments, deps)
|
registerAdminRoutes(router, instruments, deps)
|
||||||
registerInternalRoutes(router, instruments, deps)
|
registerInternalRoutes(router, instruments, deps)
|
||||||
registerAdminConsoleRoutes(router, deps)
|
|
||||||
|
|
||||||
router.NoMethod(func(c *gin.Context) {
|
router.NoMethod(func(c *gin.Context) {
|
||||||
if allow := allowedMethodsForPath(c.Request.URL.Path); allow != "" {
|
if allow := allowedMethodsForPath(c.Request.URL.Path); allow != "" {
|
||||||
@@ -278,6 +270,7 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de
|
|||||||
raceNames.POST("/register", deps.UserLobbyRaceNames.Register())
|
raceNames.POST("/register", deps.UserLobbyRaceNames.Register())
|
||||||
|
|
||||||
userGames := group.Group("/games")
|
userGames := group.Group("/games")
|
||||||
|
userGames.POST("/:game_id/commands", deps.UserGames.Commands())
|
||||||
userGames.POST("/:game_id/orders", deps.UserGames.Orders())
|
userGames.POST("/:game_id/orders", deps.UserGames.Orders())
|
||||||
userGames.GET("/:game_id/orders", deps.UserGames.GetOrders())
|
userGames.GET("/:game_id/orders", deps.UserGames.GetOrders())
|
||||||
userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report())
|
userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report())
|
||||||
@@ -372,57 +365,6 @@ func registerInternalRoutes(router *gin.Engine, instruments *metrics.Instruments
|
|||||||
users.GET("/:user_id/account-internal", deps.InternalUsers.GetAccountInternal())
|
users.GET("/:user_id/account-internal", deps.InternalUsers.GetAccountInternal())
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerAdminConsoleRoutes mounts the server-rendered operator console under
|
|
||||||
// `/_gm` when deps.AdminConsole is wired. The group reuses the same admin Basic
|
|
||||||
// Auth verifier as `/api/v1/admin`; the CSRF guard then protects every
|
|
||||||
// state-changing request. A nil AdminConsole leaves the surface unmounted.
|
|
||||||
func registerAdminConsoleRoutes(router *gin.Engine, deps RouterDependencies) {
|
|
||||||
if deps.AdminConsole == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
group := router.Group("/_gm")
|
|
||||||
group.Use(basicauth.Middleware(deps.AdminVerifier, adminBasicAuthRealm))
|
|
||||||
group.Use(deps.AdminConsole.RequireCSRF())
|
|
||||||
|
|
||||||
group.GET("/assets/*filepath", deps.AdminConsole.Asset())
|
|
||||||
group.GET("", deps.AdminConsole.Dashboard())
|
|
||||||
group.GET("/", deps.AdminConsole.Dashboard())
|
|
||||||
|
|
||||||
group.GET("/users", deps.AdminConsole.UsersList())
|
|
||||||
group.GET("/users/:user_id", deps.AdminConsole.UserDetail())
|
|
||||||
group.POST("/users/:user_id/block", deps.AdminConsole.UserBlock())
|
|
||||||
group.POST("/users/:user_id/entitlement", deps.AdminConsole.UserEntitlement())
|
|
||||||
group.POST("/users/:user_id/soft-delete", deps.AdminConsole.UserSoftDelete())
|
|
||||||
|
|
||||||
group.GET("/games", deps.AdminConsole.GamesList())
|
|
||||||
group.POST("/games", deps.AdminConsole.GameCreate())
|
|
||||||
group.GET("/games/:game_id", deps.AdminConsole.GameDetail())
|
|
||||||
group.POST("/games/:game_id/force-start", deps.AdminConsole.GameForceStart())
|
|
||||||
group.POST("/games/:game_id/force-stop", deps.AdminConsole.GameForceStop())
|
|
||||||
group.POST("/games/:game_id/ban-member", deps.AdminConsole.GameBanMember())
|
|
||||||
group.POST("/games/:game_id/runtime/restart", deps.AdminConsole.RuntimeRestart())
|
|
||||||
group.POST("/games/:game_id/runtime/patch", deps.AdminConsole.RuntimePatch())
|
|
||||||
group.POST("/games/:game_id/runtime/force-next-turn", deps.AdminConsole.RuntimeForceNextTurn())
|
|
||||||
|
|
||||||
group.GET("/engine-versions", deps.AdminConsole.EngineVersionsList())
|
|
||||||
group.POST("/engine-versions", deps.AdminConsole.EngineVersionRegister())
|
|
||||||
group.POST("/engine-versions/:version/disable", deps.AdminConsole.EngineVersionDisable())
|
|
||||||
|
|
||||||
group.GET("/operators", deps.AdminConsole.OperatorsList())
|
|
||||||
group.POST("/operators", deps.AdminConsole.OperatorCreate())
|
|
||||||
group.POST("/operators/:username/disable", deps.AdminConsole.OperatorDisable())
|
|
||||||
group.POST("/operators/:username/enable", deps.AdminConsole.OperatorEnable())
|
|
||||||
group.POST("/operators/:username/reset-password", deps.AdminConsole.OperatorResetPassword())
|
|
||||||
|
|
||||||
group.GET("/mail", deps.AdminConsole.MailPage())
|
|
||||||
group.GET("/mail/deliveries/:delivery_id", deps.AdminConsole.MailDeliveryDetail())
|
|
||||||
group.POST("/mail/deliveries/:delivery_id/resend", deps.AdminConsole.MailResend())
|
|
||||||
group.GET("/notifications", deps.AdminConsole.NotificationsPage())
|
|
||||||
group.GET("/broadcast", deps.AdminConsole.BroadcastForm())
|
|
||||||
group.POST("/broadcast", deps.AdminConsole.BroadcastSend())
|
|
||||||
}
|
|
||||||
|
|
||||||
// allowedMethodsForPath returns the comma-separated list of methods
|
// allowedMethodsForPath returns the comma-separated list of methods
|
||||||
// the gin router accepts on requestPath. Only the probe paths declare
|
// the gin router accepts on requestPath. Only the probe paths declare
|
||||||
// a non-empty list so NoMethod can advertise a useful `Allow` header
|
// a non-empty list so NoMethod can advertise a useful `Allow` header
|
||||||
|
|||||||
+81
-51
@@ -265,12 +265,7 @@ paths:
|
|||||||
summary: Create a new private lobby game owned by the caller
|
summary: Create a new private lobby game owned by the caller
|
||||||
description: |
|
description: |
|
||||||
Always emits a `private` game owned by `X-User-ID`. Public games
|
Always emits a `private` game owned by `X-User-ID`. Public games
|
||||||
are created via `POST /api/v1/admin/games`. The endpoint is
|
are created via `POST /api/v1/admin/games`.
|
||||||
gated by the caller's paid tier: free-tier accounts receive
|
|
||||||
`403 forbidden` (code `forbidden`) and no `draft` row is
|
|
||||||
created. The tier is read through
|
|
||||||
`EntitlementProvider.IsPaid(userID)` from the user-domain
|
|
||||||
service.
|
|
||||||
security:
|
security:
|
||||||
- UserHeader: []
|
- UserHeader: []
|
||||||
parameters:
|
parameters:
|
||||||
@@ -290,8 +285,6 @@ paths:
|
|||||||
$ref: "#/components/schemas/LobbyGameDetail"
|
$ref: "#/components/schemas/LobbyGameDetail"
|
||||||
"400":
|
"400":
|
||||||
$ref: "#/components/responses/InvalidRequestError"
|
$ref: "#/components/responses/InvalidRequestError"
|
||||||
"403":
|
|
||||||
$ref: "#/components/responses/ForbiddenError"
|
|
||||||
"501":
|
"501":
|
||||||
$ref: "#/components/responses/NotImplementedError"
|
$ref: "#/components/responses/NotImplementedError"
|
||||||
"500":
|
"500":
|
||||||
@@ -981,6 +974,37 @@ paths:
|
|||||||
$ref: "#/components/responses/NotImplementedError"
|
$ref: "#/components/responses/NotImplementedError"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalError"
|
$ref: "#/components/responses/InternalError"
|
||||||
|
/api/v1/user/games/{game_id}/commands:
|
||||||
|
post:
|
||||||
|
tags: [User]
|
||||||
|
operationId: userGamesCommands
|
||||||
|
summary: Forward an engine command batch
|
||||||
|
security:
|
||||||
|
- UserHeader: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/XUserID"
|
||||||
|
- $ref: "#/components/parameters/GameID"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/EngineCommand"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Engine command result passed through.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PassthroughObject"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/InvalidRequestError"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFoundError"
|
||||||
|
"501":
|
||||||
|
$ref: "#/components/responses/NotImplementedError"
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
/api/v1/user/games/{game_id}/orders:
|
/api/v1/user/games/{game_id}/orders:
|
||||||
post:
|
post:
|
||||||
tags: [User]
|
tags: [User]
|
||||||
@@ -3507,6 +3531,14 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
|
EngineCommand:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
description: |
|
||||||
|
Engine command request body. The schema is permissive because the
|
||||||
|
engine proxy passes the body through verbatim; the typed shape
|
||||||
|
lives in `pkg/model/rest.Command` and is enforced by
|
||||||
|
`internal/engineclient` before the engine call leaves backend.
|
||||||
EngineOrder:
|
EngineOrder:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
@@ -4036,22 +4068,11 @@ components:
|
|||||||
UserMailSendRequest:
|
UserMailSendRequest:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required: [body]
|
required: [recipient_user_id, body]
|
||||||
properties:
|
properties:
|
||||||
recipient_user_id:
|
recipient_user_id:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
description: |
|
|
||||||
Either `recipient_user_id` or `recipient_race_name` must
|
|
||||||
be supplied; supplying both is rejected as
|
|
||||||
`invalid_request`.
|
|
||||||
recipient_race_name:
|
|
||||||
type: string
|
|
||||||
description: |
|
|
||||||
Resolves to the active member with this race name in the
|
|
||||||
game. Mutually exclusive with `recipient_user_id`. The
|
|
||||||
server returns `forbidden` when the matching member is no
|
|
||||||
longer active (lobby-removed / blocked).
|
|
||||||
subject:
|
subject:
|
||||||
type: string
|
type: string
|
||||||
description: |
|
description: |
|
||||||
@@ -4072,18 +4093,10 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
description: |
|
description: |
|
||||||
One of `recipient_user_id` and `recipient_race_name` is
|
Required when `target="user"`. Identifies the recipient
|
||||||
required when `target="user"`. Identifies the recipient
|
of the personal admin message; the recipient may be in
|
||||||
of the personal admin message by uuid; the recipient may
|
any membership status (admin notifications can reach
|
||||||
be in any membership status (admin notifications can
|
kicked players).
|
||||||
reach kicked players when addressed by user_id).
|
|
||||||
recipient_race_name:
|
|
||||||
type: string
|
|
||||||
description: |
|
|
||||||
Optional alternative to `recipient_user_id` when
|
|
||||||
`target="user"`. Resolves to the active member with this
|
|
||||||
race name in the game; lobby-removed and blocked members
|
|
||||||
cannot be reached through the race-name shortcut.
|
|
||||||
recipients:
|
recipients:
|
||||||
type: string
|
type: string
|
||||||
enum: [active, active_and_removed, all_members]
|
enum: [active, active_and_removed, all_members]
|
||||||
@@ -4310,17 +4323,6 @@ components:
|
|||||||
sender_username:
|
sender_username:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
sender_race_name:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
description: |
|
|
||||||
Snapshot of the sender's race name in this game at send
|
|
||||||
time. Populated when `sender_kind="player"` and the
|
|
||||||
sender had an active membership at send time; nil for
|
|
||||||
admin and system messages, and for player messages sent
|
|
||||||
by a private-game owner who was not an active member at
|
|
||||||
send time. The in-game UI keys per-race threading on this
|
|
||||||
field.
|
|
||||||
subject:
|
subject:
|
||||||
type: string
|
type: string
|
||||||
body:
|
body:
|
||||||
@@ -4368,6 +4370,41 @@ components:
|
|||||||
translator:
|
translator:
|
||||||
type: string
|
type: string
|
||||||
description: Identifier of the translation engine that produced the cached row.
|
description: Identifier of the translation engine that produced the cached row.
|
||||||
|
UserMailSentSummary:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- message_id
|
||||||
|
- game_id
|
||||||
|
- kind
|
||||||
|
- body
|
||||||
|
- body_lang
|
||||||
|
- broadcast_scope
|
||||||
|
- created_at
|
||||||
|
properties:
|
||||||
|
message_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
game_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
game_name:
|
||||||
|
type: string
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
enum: [personal, admin]
|
||||||
|
subject:
|
||||||
|
type: string
|
||||||
|
body:
|
||||||
|
type: string
|
||||||
|
body_lang:
|
||||||
|
type: string
|
||||||
|
broadcast_scope:
|
||||||
|
type: string
|
||||||
|
enum: [single, game_broadcast, multi_game_broadcast]
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
UserMailInboxList:
|
UserMailInboxList:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
@@ -4378,13 +4415,6 @@ components:
|
|||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/UserMailMessageDetail"
|
$ref: "#/components/schemas/UserMailMessageDetail"
|
||||||
UserMailSentList:
|
UserMailSentList:
|
||||||
description: |
|
|
||||||
Sender-side listing of personal messages authored by the
|
|
||||||
caller. Each item carries the same shape as inbox entries
|
|
||||||
(including the recipient snapshot); single sends contribute
|
|
||||||
one row per message, broadcasts contribute one row per
|
|
||||||
addressee so the in-game UI can collapse them by
|
|
||||||
`message_id` into a single stand-alone item.
|
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required: [items]
|
required: [items]
|
||||||
@@ -4392,7 +4422,7 @@ components:
|
|||||||
items:
|
items:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/UserMailMessageDetail"
|
$ref: "#/components/schemas/UserMailSentSummary"
|
||||||
UserMailUnreadCount:
|
UserMailUnreadCount:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Client for Galaxy Plus
|
||||||
|
|
||||||
|
UI Client is capable of:
|
||||||
|
|
||||||
|
- Register a new player and login for an existing player using only e-mail and one-time codes,
|
||||||
|
- Enlist to a new Game from available onboard Games list,
|
||||||
|
- Request list of Games in which Player participating,
|
||||||
|
- Request, store and display particular Game data,
|
||||||
|
- Use push-like mechanism for receiving asynchronous updates from Server,
|
||||||
|
- Offline mode when no internet connection is available or user desired to work offline.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Package appmeta provides shared application metadata used by both the
|
||||||
|
// bootstrap loader process and the standalone UI client process.
|
||||||
|
package appmeta
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AppID is the shared Fyne application identifier used for a common storage root.
|
||||||
|
AppID = "GalaxyPlus"
|
||||||
|
// DefaultBackendURL is the default backend HTTP endpoint used by local runs.
|
||||||
|
DefaultBackendURL = "http://127.0.0.1:8080"
|
||||||
|
)
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
gerr "galaxy/error"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
checkConnectionInterval = 5 * time.Second
|
||||||
|
checkVersionInterval = time.Hour
|
||||||
|
statePersistInterval = time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *client) startBackground() {
|
||||||
|
if e.conn == nil || e.updater == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go e.backgroundLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *client) stopBackground() {
|
||||||
|
e.backgroundOnce.Do(func() {
|
||||||
|
close(e.backgroundStop)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *client) backgroundLoop() {
|
||||||
|
checkConnTimer := time.NewTimer(checkConnectionInterval)
|
||||||
|
checkVersionTimer := time.NewTimer(checkVersionInterval)
|
||||||
|
persistStateTimer := time.NewTimer(statePersistInterval)
|
||||||
|
defer func() {
|
||||||
|
checkConnTimer.Stop()
|
||||||
|
checkVersionTimer.Stop()
|
||||||
|
persistStateTimer.Stop()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-e.backgroundStop:
|
||||||
|
return
|
||||||
|
case <-checkConnTimer.C:
|
||||||
|
if e.conn != nil {
|
||||||
|
e.OnConnection(e.conn.CheckConnection())
|
||||||
|
}
|
||||||
|
checkConnTimer.Reset(checkConnectionInterval)
|
||||||
|
case <-checkVersionTimer.C:
|
||||||
|
if e.updater != nil {
|
||||||
|
if err := e.updater.CheckAndPrepareLatest(); err != nil {
|
||||||
|
e.handlerError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkVersionTimer.Reset(checkVersionInterval)
|
||||||
|
case <-persistStateTimer.C:
|
||||||
|
e.ensureStatePersist()
|
||||||
|
persistStateTimer.Reset(statePersistInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *client) ensureStatePersist() {
|
||||||
|
param := e.GetParams()
|
||||||
|
needSaving := false
|
||||||
|
e.stateMu.Lock()
|
||||||
|
if e.world != nil {
|
||||||
|
if param.CameraZoom > 0 && param.CameraZoom != e.state.CameraZoom {
|
||||||
|
e.state.CameraZoom = param.CameraZoom
|
||||||
|
needSaving = true
|
||||||
|
}
|
||||||
|
if param.CameraXWorldFp != e.state.CameraXFp {
|
||||||
|
e.state.CameraXFp = param.CameraXWorldFp
|
||||||
|
needSaving = true
|
||||||
|
}
|
||||||
|
if param.CameraYWorldFp != e.state.CameraYFp {
|
||||||
|
e.state.CameraYFp = param.CameraYWorldFp
|
||||||
|
needSaving = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.mapSplitter != nil && e.mapSplitter.Offset != e.state.MapSplitterOffset {
|
||||||
|
e.state.MapSplitterOffset = e.mapSplitter.Offset
|
||||||
|
needSaving = true
|
||||||
|
}
|
||||||
|
if e.accInfo.Open != e.state.AccordionInfoOpen {
|
||||||
|
e.state.AccordionInfoOpen = e.accInfo.Open
|
||||||
|
needSaving = true
|
||||||
|
}
|
||||||
|
if e.accCalc.Open != e.state.AccordionCalcOpen {
|
||||||
|
e.state.AccordionCalcOpen = e.accCalc.Open
|
||||||
|
needSaving = true
|
||||||
|
}
|
||||||
|
if needSaving {
|
||||||
|
if err := e.s.SaveState(*e.state); err != nil {
|
||||||
|
e.handlerError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.stateMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *client) handlerError(err error) {
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("ERROR: %s\n", err)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case gerr.IsConnection(err):
|
||||||
|
e.OnConnectionError(err)
|
||||||
|
case gerr.IsStorage(err):
|
||||||
|
e.OnStorageError(err)
|
||||||
|
default:
|
||||||
|
e.OnServiceError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
gerr "galaxy/error"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandlerErrorDispatchesByClass(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
wantEvent string
|
||||||
|
}{
|
||||||
|
{name: "connection", err: gerr.WrapConnection(errors.New("dial")), wantEvent: "connection"},
|
||||||
|
{name: "storage", err: gerr.WrapStorage(errors.New("write file")), wantEvent: "storage"},
|
||||||
|
{name: "service", err: gerr.WrapService(errors.New("bad response")), wantEvent: "service"},
|
||||||
|
{name: "unclassified defaults to service", err: errors.New("plain"), wantEvent: "service"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var got string
|
||||||
|
c := &client{
|
||||||
|
onConnectionErrFn: func(error) { got = "connection" },
|
||||||
|
onStorageErrFn: func(error) { got = "storage" },
|
||||||
|
onServiceErrFn: func(error) { got = "service" },
|
||||||
|
}
|
||||||
|
|
||||||
|
c.handlerError(tt.err)
|
||||||
|
|
||||||
|
require.Equal(t, tt.wantEvent, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/canvas"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
)
|
||||||
|
|
||||||
|
type interactiveRaster struct {
|
||||||
|
widget.BaseWidget
|
||||||
|
|
||||||
|
min fyne.Size
|
||||||
|
raster *canvas.Raster
|
||||||
|
onLayout func(fyne.Size)
|
||||||
|
onScrolled func(*fyne.ScrollEvent)
|
||||||
|
onDragged func(*fyne.DragEvent)
|
||||||
|
onDragEnd func()
|
||||||
|
onTapped func(*fyne.PointEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *interactiveRaster) SetMinSize(size fyne.Size) {
|
||||||
|
r.min = size
|
||||||
|
r.Resize(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *interactiveRaster) MinSize() fyne.Size {
|
||||||
|
return r.min
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *interactiveRaster) CreateRenderer() fyne.WidgetRenderer {
|
||||||
|
return &rasterWidgetRender{
|
||||||
|
canvas: r,
|
||||||
|
bg: canvas.NewRasterWithPixels(bgPattern),
|
||||||
|
onLayout: r.onLayout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tapped is a left-click event
|
||||||
|
func (r *interactiveRaster) Tapped(ev *fyne.PointEvent) {
|
||||||
|
if r.onTapped == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.onTapped(ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TappedSecondary is a right-click event
|
||||||
|
func (r *interactiveRaster) TappedSecondary(*fyne.PointEvent) {}
|
||||||
|
|
||||||
|
func newInteractiveRaster(
|
||||||
|
raster *canvas.Raster,
|
||||||
|
onLayout func(fyne.Size),
|
||||||
|
onScrolled func(*fyne.ScrollEvent),
|
||||||
|
onDragged func(*fyne.DragEvent),
|
||||||
|
onDragEnd func(),
|
||||||
|
onTapped func(*fyne.PointEvent),
|
||||||
|
) *interactiveRaster {
|
||||||
|
r := &interactiveRaster{
|
||||||
|
raster: raster,
|
||||||
|
onLayout: onLayout,
|
||||||
|
onScrolled: onScrolled,
|
||||||
|
onDragged: onDragged,
|
||||||
|
onDragEnd: onDragEnd,
|
||||||
|
onTapped: onTapped,
|
||||||
|
}
|
||||||
|
r.ExtendBaseWidget(r)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func bgPattern(x, y, _, _ int) color.Color {
|
||||||
|
const boxSize = 25
|
||||||
|
|
||||||
|
if (x/boxSize)%2 == (y/boxSize)%2 {
|
||||||
|
return color.Gray{Y: 58}
|
||||||
|
}
|
||||||
|
|
||||||
|
return color.Gray{Y: 84}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *interactiveRaster) Scrolled(e *fyne.ScrollEvent) {
|
||||||
|
if r.onScrolled == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.onScrolled(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *interactiveRaster) Dragged(e *fyne.DragEvent) {
|
||||||
|
if r.onDragged == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.onDragged(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *interactiveRaster) DragEnd() {
|
||||||
|
if r.onDragEnd == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.onDragEnd()
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"galaxy/client/updater"
|
||||||
|
"galaxy/client/widget/calculator"
|
||||||
|
"galaxy/client/world"
|
||||||
|
"galaxy/connector"
|
||||||
|
mc "galaxy/model/client"
|
||||||
|
"galaxy/storage"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/canvas"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/lang"
|
||||||
|
"fyne.io/fyne/v2/theme"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
)
|
||||||
|
|
||||||
|
const version = "1.0.0"
|
||||||
|
|
||||||
|
type client struct {
|
||||||
|
s storage.Storage
|
||||||
|
conn connector.Connector
|
||||||
|
app fyne.App
|
||||||
|
window fyne.Window
|
||||||
|
|
||||||
|
state *mc.State
|
||||||
|
stateMu sync.RWMutex
|
||||||
|
|
||||||
|
reg *registry
|
||||||
|
|
||||||
|
calculator *calculator.Calculator
|
||||||
|
mapSplitter *container.Split
|
||||||
|
accInfo *widget.AccordionItem
|
||||||
|
accCalc *widget.AccordionItem
|
||||||
|
|
||||||
|
// loadReportFunc func(uint)
|
||||||
|
|
||||||
|
world *world.World
|
||||||
|
drawer *world.GGDrawer
|
||||||
|
raster *canvas.Raster
|
||||||
|
co *RasterCoalescer[world.RenderParams]
|
||||||
|
pan *PanController
|
||||||
|
|
||||||
|
// Protected camera/options state (UI-facing). This is the "base" params snapshot.
|
||||||
|
// Viewport/margins are NOT stored here; they come from raster draw callback.
|
||||||
|
mu sync.RWMutex
|
||||||
|
wp *world.RenderParams
|
||||||
|
canvasScale float32
|
||||||
|
|
||||||
|
// Latest raster geometry metadata for correct event->pixel conversion:
|
||||||
|
// - logical size: raster.Size() (Fyne units)
|
||||||
|
// - pixel size: last (wPx,hPx) passed to draw callback
|
||||||
|
metaMu sync.RWMutex
|
||||||
|
lastRasterLogicW float32
|
||||||
|
lastRasterLogicH float32
|
||||||
|
lastRasterPxW int
|
||||||
|
lastRasterPxH int
|
||||||
|
lastCanvasScale float32 // optional, useful for debugging
|
||||||
|
|
||||||
|
// Snapshot of params actually used for the last render (includes viewport/margins).
|
||||||
|
// Used for HitTest and to keep UI interactions consistent with what the user sees.
|
||||||
|
lastRenderedMu sync.RWMutex
|
||||||
|
lastRenderedParams world.RenderParams
|
||||||
|
|
||||||
|
// Indexing / backing-canvas caches (owned by client because it depends on UI geometry)
|
||||||
|
lastIndexedViewportW int
|
||||||
|
lastIndexedViewportH int
|
||||||
|
lastIndexedZoomFp int
|
||||||
|
|
||||||
|
lastCanvasW int
|
||||||
|
lastCanvasH int
|
||||||
|
|
||||||
|
viewportImg *image.RGBA
|
||||||
|
viewportW int
|
||||||
|
viewportH int
|
||||||
|
|
||||||
|
hits []world.Hit
|
||||||
|
|
||||||
|
updater *updater.Manager
|
||||||
|
backgroundStop chan struct{}
|
||||||
|
backgroundOnce sync.Once
|
||||||
|
|
||||||
|
onConnectionFn func(bool)
|
||||||
|
onConnectionErrFn func(error)
|
||||||
|
onStorageErrFn func(error)
|
||||||
|
onServiceErrFn func(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(s storage.Storage, conn connector.Connector, app fyne.App) (mc.Client, error) {
|
||||||
|
e := &client{
|
||||||
|
s: s,
|
||||||
|
conn: conn,
|
||||||
|
app: app,
|
||||||
|
window: app.NewWindow("Galaxy Plus"),
|
||||||
|
reg: newRegistry(),
|
||||||
|
lastCanvasScale: 1.0,
|
||||||
|
world: nil,
|
||||||
|
hits: make([]world.Hit, 5),
|
||||||
|
backgroundStop: make(chan struct{}),
|
||||||
|
}
|
||||||
|
e.calculator = calculator.NewCaclulator(calculator.WithCreateHandler(e.createShipClass))
|
||||||
|
e.updater = updater.NewManager(e.s, e.conn)
|
||||||
|
|
||||||
|
stateExists, err := e.s.StateExists()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if stateExists {
|
||||||
|
state, err := e.s.LoadState()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.state = &state
|
||||||
|
} else {
|
||||||
|
e.state = &mc.State{
|
||||||
|
ClientCurrentVersion: e.Version(),
|
||||||
|
CameraZoom: 1.0,
|
||||||
|
MapSplitterOffset: 0.5,
|
||||||
|
AccordionInfoOpen: false,
|
||||||
|
AccordionCalcOpen: false,
|
||||||
|
}
|
||||||
|
if err := e.s.SaveState(*e.state); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.state.CameraZoom <= 0 {
|
||||||
|
e.state.CameraZoom = 1.0
|
||||||
|
}
|
||||||
|
if e.state.MapSplitterOffset <= 0 {
|
||||||
|
e.state.MapSplitterOffset = 0.5
|
||||||
|
}
|
||||||
|
e.wp = &world.RenderParams{
|
||||||
|
Options: &world.RenderOptions{DisableWrapScroll: false},
|
||||||
|
CameraZoom: e.state.CameraZoom,
|
||||||
|
CameraXWorldFp: e.state.CameraXFp,
|
||||||
|
CameraYWorldFp: e.state.CameraYFp,
|
||||||
|
}
|
||||||
|
|
||||||
|
e.drawer = &world.GGDrawer{DC: nil}
|
||||||
|
|
||||||
|
e.raster = canvas.NewRaster(func(wPx, hPx int) image.Image {
|
||||||
|
return e.draw(wPx, hPx)
|
||||||
|
})
|
||||||
|
|
||||||
|
e.pan = NewPanController(e)
|
||||||
|
|
||||||
|
e.co = NewRasterCoalescer(
|
||||||
|
FyneExecutor{},
|
||||||
|
e.raster,
|
||||||
|
func(wPx, hPx int, p world.RenderParams) image.Image {
|
||||||
|
return e.renderRasterImage(wPx, hPx, p)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *client) BuildUI(w fyne.Window) {
|
||||||
|
mapCanvasObject := newInteractiveRaster(e.raster, e.onRasterWidgetLayout, e.onScrolled, e.onDragged, e.onDradEnd, e.onTapped)
|
||||||
|
|
||||||
|
toolbar := widget.NewToolbar(
|
||||||
|
widget.NewToolbarAction(
|
||||||
|
theme.FolderIcon(),
|
||||||
|
func() { e.initReportAsync("GAME_ID", 0) }),
|
||||||
|
widget.NewToolbarSeparator(),
|
||||||
|
widget.NewToolbarAction(
|
||||||
|
theme.NavigateBackIcon(),
|
||||||
|
func() {}),
|
||||||
|
widget.NewToolbarAction(
|
||||||
|
theme.NavigateNextIcon(),
|
||||||
|
func() {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
e.accInfo = widget.NewAccordionItem(lang.L("title.info"), container.NewStack())
|
||||||
|
e.accInfo.Open = e.state.AccordionInfoOpen
|
||||||
|
e.accCalc = widget.NewAccordionItem(lang.L("title.calculator"), e.calculator.CanvasObject)
|
||||||
|
e.accCalc.Open = e.state.AccordionCalcOpen
|
||||||
|
|
||||||
|
accordion := widget.NewAccordion()
|
||||||
|
accordion.MultiOpen = true
|
||||||
|
accordion.Append(e.accCalc)
|
||||||
|
accordion.Append(e.accInfo)
|
||||||
|
|
||||||
|
e.mapSplitter = container.NewHSplit(mapCanvasObject, container.NewHScroll(accordion))
|
||||||
|
e.mapSplitter.SetOffset(e.state.MapSplitterOffset)
|
||||||
|
|
||||||
|
tabs := container.NewAppTabs(
|
||||||
|
container.NewTabItemWithIcon(
|
||||||
|
lang.L("title.map"),
|
||||||
|
theme.GridIcon(),
|
||||||
|
e.mapSplitter),
|
||||||
|
container.NewTabItemWithIcon(
|
||||||
|
"Calculator",
|
||||||
|
theme.ComputerIcon(),
|
||||||
|
container.NewStack(widget.NewButton("Calc", func() {})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
th := tabs.Theme()
|
||||||
|
icon := canvas.NewImageFromResource(th.Icon(theme.IconNameInfo))
|
||||||
|
|
||||||
|
statusLeft := widget.NewTextGridFromString("Status")
|
||||||
|
statusAd := widget.NewTextGridFromString("")
|
||||||
|
|
||||||
|
statusBar := container.NewBorder(
|
||||||
|
nil, // top
|
||||||
|
nil, // bottom
|
||||||
|
container.NewHBox(statusLeft, widget.NewSeparator()), // left
|
||||||
|
container.NewHBox(widget.NewSeparator(), icon), // right
|
||||||
|
statusAd, // center
|
||||||
|
)
|
||||||
|
|
||||||
|
content := container.NewBorder(
|
||||||
|
toolbar, // top
|
||||||
|
statusBar, // bottom
|
||||||
|
nil, // left
|
||||||
|
nil, // right
|
||||||
|
tabs, // center
|
||||||
|
)
|
||||||
|
|
||||||
|
w.CenterOnScreen()
|
||||||
|
w.SetContent(content)
|
||||||
|
s := statusBar.Size()
|
||||||
|
icon.SetMinSize(fyne.NewSize(s.Height, s.Height))
|
||||||
|
e.initLatestReport()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *client) loadWorld(w *world.World) {
|
||||||
|
if w == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.SetCircleRadiusScaleFp(world.SCALE / 1000)
|
||||||
|
e.world = w
|
||||||
|
// TODO: store camera position in user settings
|
||||||
|
e.wp.CameraXWorldFp = w.W / 2
|
||||||
|
e.wp.CameraYWorldFp = w.H / 2
|
||||||
|
e.world.SetTheme(world.ThemeDark)
|
||||||
|
|
||||||
|
e.RequestRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *client) Run() error {
|
||||||
|
e.BuildUI(e.window)
|
||||||
|
e.startBackground()
|
||||||
|
e.RequestRefresh()
|
||||||
|
e.window.SetMaster()
|
||||||
|
e.window.Resize(fyne.NewSize(800, 600))
|
||||||
|
e.window.CenterOnScreen()
|
||||||
|
e.window.SetOnClosed(e.Shutdown)
|
||||||
|
e.window.ShowAndRun()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *client) Shutdown() {
|
||||||
|
e.stopBackground()
|
||||||
|
e.ensureStatePersist()
|
||||||
|
e.window.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: remove func?
|
||||||
|
func (e *client) Version() string { return version }
|
||||||
|
|
||||||
|
func (e *client) OnConnection(isGood bool) {
|
||||||
|
if e.onConnectionFn != nil {
|
||||||
|
e.onConnectionFn(isGood)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *client) OnConnectionError(err error) {
|
||||||
|
if e.onConnectionErrFn != nil {
|
||||||
|
e.onConnectionErrFn(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *client) OnStorageError(err error) {
|
||||||
|
if e.onStorageErrFn != nil {
|
||||||
|
e.onStorageErrFn(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *client) OnServiceError(err error) {
|
||||||
|
if e.onServiceErrFn != nil {
|
||||||
|
e.onServiceErrFn(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testExecutor struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
queue []func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *testExecutor) Post(fn func()) {
|
||||||
|
e.mu.Lock()
|
||||||
|
e.queue = append(e.queue, fn)
|
||||||
|
e.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *testExecutor) FlushAll() {
|
||||||
|
for {
|
||||||
|
var fn func()
|
||||||
|
e.mu.Lock()
|
||||||
|
if len(e.queue) > 0 {
|
||||||
|
fn = e.queue[0]
|
||||||
|
e.queue = e.queue[1:]
|
||||||
|
}
|
||||||
|
e.mu.Unlock()
|
||||||
|
if fn == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type testRefresher struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *testRefresher) Refresh() {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.count++
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *testRefresher) Count() int {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRasterCoalescer_RequestBeforeDraw_CoalescesToLatest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
exec := &testExecutor{}
|
||||||
|
ref := &testRefresher{}
|
||||||
|
|
||||||
|
var got []int
|
||||||
|
|
||||||
|
co := NewRasterCoalescer(exec, ref, func(w, h int, p int) image.Image {
|
||||||
|
got = append(got, p)
|
||||||
|
return image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
})
|
||||||
|
|
||||||
|
co.Request(1)
|
||||||
|
co.Request(2)
|
||||||
|
co.Request(3)
|
||||||
|
|
||||||
|
// Only a single refresh should be scheduled before the next Draw().
|
||||||
|
exec.FlushAll()
|
||||||
|
require.Equal(t, 1, ref.Count())
|
||||||
|
|
||||||
|
_ = co.Draw(10, 10)
|
||||||
|
require.Equal(t, []int{3}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRasterCoalescer_RequestDuringDraw_SchedulesOneFollowUpRefresh(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
exec := &testExecutor{}
|
||||||
|
ref := &testRefresher{}
|
||||||
|
var got []int
|
||||||
|
|
||||||
|
var co *RasterCoalescer[int]
|
||||||
|
co = NewRasterCoalescer(exec, ref, func(w, h int, p int) image.Image {
|
||||||
|
got = append(got, p)
|
||||||
|
if p == 1 {
|
||||||
|
co.Request(2)
|
||||||
|
co.Request(3)
|
||||||
|
}
|
||||||
|
return image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
})
|
||||||
|
|
||||||
|
co.Request(1)
|
||||||
|
|
||||||
|
exec.FlushAll()
|
||||||
|
require.Equal(t, 1, ref.Count())
|
||||||
|
|
||||||
|
// First draw renders 1 and schedules exactly one additional refresh.
|
||||||
|
_ = co.Draw(10, 10)
|
||||||
|
exec.FlushAll()
|
||||||
|
require.Equal(t, 2, ref.Count())
|
||||||
|
|
||||||
|
// Second draw renders latest (3).
|
||||||
|
_ = co.Draw(10, 10)
|
||||||
|
require.Equal(t, []int{1, 3}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRasterCoalescer_ManyRequestsWhileDrawing_StillOnlyOneExtraRefresh(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
exec := &testExecutor{}
|
||||||
|
ref := &testRefresher{}
|
||||||
|
var got []int
|
||||||
|
|
||||||
|
var co *RasterCoalescer[int]
|
||||||
|
co = NewRasterCoalescer(exec, ref, func(w, h int, p int) image.Image {
|
||||||
|
got = append(got, p)
|
||||||
|
if p == 1 {
|
||||||
|
for i := 2; i <= 50; i++ {
|
||||||
|
co.Request(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
})
|
||||||
|
|
||||||
|
co.Request(1)
|
||||||
|
exec.FlushAll()
|
||||||
|
require.Equal(t, 1, ref.Count())
|
||||||
|
|
||||||
|
_ = co.Draw(10, 10)
|
||||||
|
exec.FlushAll()
|
||||||
|
require.Equal(t, 2, ref.Count())
|
||||||
|
|
||||||
|
_ = co.Draw(10, 10)
|
||||||
|
require.Equal(t, []int{1, 50}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyViewportRGBA_CopiesROIAndIsIndependentFromSource(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
src := image.NewRGBA(image.Rect(0, 0, 20, 20))
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, 5, 6))
|
||||||
|
|
||||||
|
// Fill src with a pattern: pixel (x,y) has RGBA = (x, y, 0, 255).
|
||||||
|
for y := 0; y < 20; y++ {
|
||||||
|
for x := 0; x < 20; x++ {
|
||||||
|
off := y*src.Stride + x*4
|
||||||
|
src.Pix[off+0] = byte(x)
|
||||||
|
src.Pix[off+1] = byte(y)
|
||||||
|
src.Pix[off+2] = 0
|
||||||
|
src.Pix[off+3] = 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
marginX, marginY := 7, 9
|
||||||
|
copyViewportRGBA(dst, src, marginX, marginY, 5, 6)
|
||||||
|
|
||||||
|
// Verify a few pixels in dst match the expected source ROI.
|
||||||
|
// dst(0,0) == src(marginX, marginY)
|
||||||
|
{
|
||||||
|
off := 0*dst.Stride + 0*4
|
||||||
|
require.Equal(t, byte(marginX), dst.Pix[off+0])
|
||||||
|
require.Equal(t, byte(marginY), dst.Pix[off+1])
|
||||||
|
require.Equal(t, byte(255), dst.Pix[off+3])
|
||||||
|
}
|
||||||
|
// dst(4,5) == src(marginX+4, marginY+5)
|
||||||
|
{
|
||||||
|
off := 5*dst.Stride + 4*4
|
||||||
|
require.Equal(t, byte(marginX+4), dst.Pix[off+0])
|
||||||
|
require.Equal(t, byte(marginY+5), dst.Pix[off+1])
|
||||||
|
require.Equal(t, byte(255), dst.Pix[off+3])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutate src ROI after copy and ensure dst is unchanged (no aliasing).
|
||||||
|
{
|
||||||
|
off := (marginY+0)*src.Stride + (marginX+0)*4
|
||||||
|
src.Pix[off+0] = 200
|
||||||
|
src.Pix[off+1] = 201
|
||||||
|
src.Pix[off+3] = 123
|
||||||
|
}
|
||||||
|
|
||||||
|
offDst := 0*dst.Stride + 0*4
|
||||||
|
require.Equal(t, byte(marginX), dst.Pix[offDst+0])
|
||||||
|
require.Equal(t, byte(marginY), dst.Pix[offDst+1])
|
||||||
|
require.Equal(t, byte(255), dst.Pix[offDst+3])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventPosToPixel_FloorMapping(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
e := &client{}
|
||||||
|
|
||||||
|
// Pretend raster logical is 100x50, pixel is 1000x500.
|
||||||
|
e.metaMu.Lock()
|
||||||
|
e.lastRasterLogicW = 100
|
||||||
|
e.lastRasterLogicH = 50
|
||||||
|
e.lastRasterPxW = 1000
|
||||||
|
e.lastRasterPxH = 500
|
||||||
|
e.metaMu.Unlock()
|
||||||
|
|
||||||
|
x, y, ok := e.eventPosToPixel(0, 0)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, 0, x)
|
||||||
|
require.Equal(t, 0, y)
|
||||||
|
|
||||||
|
// Middle
|
||||||
|
x, y, ok = e.eventPosToPixel(50, 25)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, 500, x)
|
||||||
|
require.Equal(t, 250, y)
|
||||||
|
|
||||||
|
// Near max logical should map near max pixel with floor.
|
||||||
|
x, y, ok = e.eventPosToPixel(99.9, 49.9)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.GreaterOrEqual(t, x, 998)
|
||||||
|
require.GreaterOrEqual(t, y, 498)
|
||||||
|
|
||||||
|
// Clamp
|
||||||
|
x, y, ok = e.eventPosToPixel(-10, 999)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, 0, x)
|
||||||
|
require.Equal(t, 500, y)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"galaxy/client"
|
||||||
|
"galaxy/client/appmeta"
|
||||||
|
"galaxy/client/loader"
|
||||||
|
"galaxy/connector/http"
|
||||||
|
"galaxy/storage/fs"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2/app"
|
||||||
|
"fyne.io/fyne/v2/lang"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var err error
|
||||||
|
defer func() {
|
||||||
|
if err == nil {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf("panic: %v", r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
app := app.NewWithID(appmeta.AppID)
|
||||||
|
if err = lang.AddTranslationsFS(client.Translations, "resource/lang"); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s, err := fs.NewFS(app.Storage().RootURI().Path())
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, err := http.NewHttpConnector(ctx, appmeta.DefaultBackendURL)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l, err := loader.NewLoader(s, c, app)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = l.Run(ctx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"galaxy/client"
|
||||||
|
"galaxy/client/appmeta"
|
||||||
|
"galaxy/connector/http"
|
||||||
|
"galaxy/storage/fs"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2/app"
|
||||||
|
"fyne.io/fyne/v2/lang"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var err error
|
||||||
|
defer func() {
|
||||||
|
if err == nil {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf("panic: %v", r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
app := app.NewWithID(appmeta.AppID)
|
||||||
|
if err = lang.AddTranslationsFS(client.Translations, "resource/lang"); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s, err := fs.NewFS(app.Storage().RootURI().Path())
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn, err := http.NewHttpConnector(ctx, appmeta.DefaultBackendURL)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, err := client.NewClient(s, conn, app)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = c.Run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
/*
|
||||||
|
Fyne-friendly latest-wins coalescing for canvas.NewRaster(draw func(w,h int) image.Image).
|
||||||
|
|
||||||
|
Key property:
|
||||||
|
- draw() renders at most once per invocation (never loops).
|
||||||
|
- if new requests arrived while drawing, we schedule exactly one extra Refresh.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UIExecutor posts a function to run on the UI/main thread.
|
||||||
|
type UIExecutor interface {
|
||||||
|
Post(fn func())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresher is the minimal interface we need from fyne.CanvasObject / Raster.
|
||||||
|
type Refresher interface {
|
||||||
|
Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RasterRenderer renders the latest params and returns an image.
|
||||||
|
// Must be called on the UI thread (inside draw callback).
|
||||||
|
type RasterRenderer[P any] func(wPx, hPx int, params P) image.Image
|
||||||
|
|
||||||
|
// RasterCoalescer implements latest-wins coalescing for raster rendering.
|
||||||
|
// It is designed specifically for toolkits like fyne where the system calls draw(w,h)
|
||||||
|
// and expects a returned image.
|
||||||
|
type RasterCoalescer[P any] struct {
|
||||||
|
exec UIExecutor
|
||||||
|
refresher Refresher
|
||||||
|
renderer RasterRenderer[P]
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
// inDraw == true while Draw() is running on UI thread.
|
||||||
|
inDraw bool
|
||||||
|
|
||||||
|
// refreshQueued == true when we have already posted a Refresh() that has not yet
|
||||||
|
// resulted in a Draw() call (or is expected to call Draw soon).
|
||||||
|
refreshQueued bool
|
||||||
|
|
||||||
|
// pending == true when new params arrived while inDraw==true.
|
||||||
|
// Draw() will schedule exactly one follow-up Refresh after it returns.
|
||||||
|
pending bool
|
||||||
|
|
||||||
|
latest P
|
||||||
|
have bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRasterCoalescer creates a new coalescer.
|
||||||
|
// - exec.Post must run fn on UI thread.
|
||||||
|
// - refresher.Refresh will trigger the framework to call draw(w,h).
|
||||||
|
func NewRasterCoalescer[P any](exec UIExecutor, refresher Refresher, renderer RasterRenderer[P]) *RasterCoalescer[P] {
|
||||||
|
if exec == nil {
|
||||||
|
panic("RasterCoalescer: nil executor")
|
||||||
|
}
|
||||||
|
if refresher == nil {
|
||||||
|
panic("RasterCoalescer: nil refresher")
|
||||||
|
}
|
||||||
|
if renderer == nil {
|
||||||
|
panic("RasterCoalescer: nil renderer")
|
||||||
|
}
|
||||||
|
return &RasterCoalescer[P]{exec: exec, refresher: refresher, renderer: renderer}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request stores the latest params and schedules exactly one refresh (latest-wins).
|
||||||
|
// Can be called from any goroutine.
|
||||||
|
func (c *RasterCoalescer[P]) Request(params P) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.latest = params
|
||||||
|
c.have = true
|
||||||
|
|
||||||
|
// If we are currently inside Draw(), don't schedule refresh immediately.
|
||||||
|
// Just mark pending; Draw() will schedule one follow-up refresh after it returns.
|
||||||
|
if c.inDraw {
|
||||||
|
c.pending = true
|
||||||
|
c.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not drawing. Schedule at most one refresh until the next Draw() happens.
|
||||||
|
if c.refreshQueued {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.refreshQueued = true
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
c.exec.Post(c.refresher.Refresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw must be called from the raster draw callback on the UI thread.
|
||||||
|
// It renders exactly once with the latest snapshot.
|
||||||
|
// If more requests arrived while drawing, it schedules exactly one extra refresh.
|
||||||
|
func (c *RasterCoalescer[P]) Draw(wPx, hPx int) image.Image {
|
||||||
|
c.mu.Lock()
|
||||||
|
// A Draw call corresponds to a previously scheduled refresh being serviced.
|
||||||
|
c.refreshQueued = false
|
||||||
|
|
||||||
|
if !c.have {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return image.NewRGBA(image.Rect(0, 0, wPx, hPx))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.inDraw = true
|
||||||
|
c.pending = false
|
||||||
|
params := c.latest
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
img := c.renderer(wPx, hPx, params)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
needAnother := c.pending
|
||||||
|
c.pending = false
|
||||||
|
c.inDraw = false
|
||||||
|
|
||||||
|
// If we need another frame, schedule exactly one refresh (if not already queued).
|
||||||
|
if needAnother && !c.refreshQueued {
|
||||||
|
c.refreshQueued = true
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.exec.Post(c.refresher.Refresh)
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Unlock()
|
||||||
|
return img
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed resource/lang
|
||||||
|
var Translations embed.FS
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
module galaxy/client
|
||||||
|
|
||||||
|
go 1.26.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
fyne.io/fyne/v2 v2.7.3
|
||||||
|
github.com/fogleman/gg v1.3.0
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
fyne.io/systray v1.12.0 // indirect
|
||||||
|
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
|
github.com/fredbi/uri v1.1.1 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/fyne-io/gl-js v0.2.0 // indirect
|
||||||
|
github.com/fyne-io/glfw-js v0.3.0 // indirect
|
||||||
|
github.com/fyne-io/image v0.1.1 // indirect
|
||||||
|
github.com/fyne-io/oksvg v0.2.0 // indirect
|
||||||
|
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 // indirect
|
||||||
|
github.com/go-text/render v0.2.0 // indirect
|
||||||
|
github.com/go-text/typesetting v0.3.3 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
|
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
|
||||||
|
github.com/hack-pad/safejs v0.1.1 // indirect
|
||||||
|
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
|
||||||
|
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
|
||||||
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
|
github.com/rymdport/portal v0.4.2 // indirect
|
||||||
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
||||||
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
||||||
|
github.com/yuin/goldmark v1.7.16 // indirect
|
||||||
|
golang.org/x/image v0.36.0 // indirect
|
||||||
|
golang.org/x/net v0.53.0 // indirect
|
||||||
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
|
golang.org/x/text v0.36.0 // indirect
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
fyne.io/fyne/v2 v2.7.3 h1:xBT/iYbdnNHONWO38fZMBrVBiJG8rV/Jypmy4tVfRWE=
|
||||||
|
fyne.io/fyne/v2 v2.7.3/go.mod h1:gu+dlIcZWSzKZmnrY8Fbnj2Hirabv2ek+AKsfQ2bBlw=
|
||||||
|
fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
|
||||||
|
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||||
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
|
||||||
|
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
|
||||||
|
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||||
|
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||||
|
github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko=
|
||||||
|
github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs=
|
||||||
|
github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
|
||||||
|
github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk=
|
||||||
|
github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
|
||||||
|
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
|
||||||
|
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
|
||||||
|
github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8=
|
||||||
|
github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
|
||||||
|
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
|
||||||
|
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 h1:RkGhqHxEVAvPM0/R+8g7XRwQnHatO0KAuVcwHo8q9W8=
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728/go.mod h1:SyRD8YfuKk+ZXlDqYiqe1qMSqjNgtHzBTG810KUagMc=
|
||||||
|
github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
|
||||||
|
github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
|
||||||
|
github.com/go-text/typesetting v0.3.3 h1:ihGNJU9KzdK2QRDy1Bm7FT5RFQoYb+3n3EIhI/4eaQc=
|
||||||
|
github.com/go-text/typesetting v0.3.3/go.mod h1:vIRUT25mLQaSh4C8H/lIsKppQz/Gdb8Pu/tNwpi52ts=
|
||||||
|
github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8 h1:4KCscI9qYWMGTuz6BpJtbUSRzcBrUSSE0ENMJbNSrFs=
|
||||||
|
github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||||
|
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
|
||||||
|
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
|
||||||
|
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
|
||||||
|
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
|
||||||
|
github.com/hack-pad/safejs v0.1.1 h1:d5qPO0iQ7h2oVtpzGnLExE+Wn9AtytxIfltcS2b9KD8=
|
||||||
|
github.com/hack-pad/safejs v0.1.1/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
|
||||||
|
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
|
||||||
|
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
|
||||||
|
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
|
||||||
|
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||||
|
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
|
||||||
|
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
|
||||||
|
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
|
||||||
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
|
||||||
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
|
||||||
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
|
||||||
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||||
|
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||||
|
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||||
|
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||||
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user