Compare commits
59 Commits
1709bcd340
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 08fe8c9c53 | |||
| 10b86ae23e | |||
| cd79b0e540 | |||
| 25df9f9398 | |||
| 1958a96acf | |||
| e45ce210ba | |||
| d12071df04 | |||
| a67b9cf59e | |||
| d303d23d18 | |||
| 614e47598b | |||
| 69871c8a93 | |||
| 015bf7fbe3 | |||
| d0bc81f0f7 | |||
| 35be2e6b76 | |||
| 33b9ebc7f6 | |||
| b9929da54d | |||
| 36c28362bc | |||
| 07a4ae469d | |||
| 2c3e4cf385 | |||
| 4855447b29 | |||
| 14ef545bbb | |||
| 7222904c91 | |||
| 8a71709048 | |||
| db63aa7d10 | |||
| 39b9262ee9 | |||
| 037b1777d6 | |||
| 1019da66a3 | |||
| 0b098ae06e | |||
| db270a9695 | |||
| 4750c0d6c7 | |||
| 220b43d007 | |||
| 37d21cacf9 | |||
| 3af14d0144 | |||
| 369b4dc0a0 | |||
| c3b2d89ddc | |||
| 362228f985 | |||
| c8a6c77a8e | |||
| c233b1bfbc | |||
| de1ac2ee17 | |||
| 5d5602fbd7 | |||
| 97347693b6 | |||
| a138bbfd72 | |||
| 6c7e259fcb | |||
| 096ef82420 | |||
| c249172baa | |||
| fa8cede6ae | |||
| 84196d59d0 | |||
| 27237bc7c1 | |||
| 90c3be7bb5 | |||
| d21a7f0e92 | |||
| df8ffca53e | |||
| 067e990306 | |||
| 673202e1e4 | |||
| 1ec3a9c1ee | |||
| 663b87a4e4 | |||
| a2821f2bbc | |||
| cfd60131fb | |||
| 95c63e1ee0 | |||
| 46103495fb |
@@ -14,7 +14,7 @@ Redeploy the running stack on the production host. Full reference:
|
||||
- Path: `/home/ubuntu/mc/ulicraft-server-v1`
|
||||
- Branch: `main`
|
||||
- Stack: single unified `docker-compose.yml` (drasl, minecraft, mc-backup,
|
||||
caddy, nmsr, uptime-kuma)
|
||||
caddy, nmsr, mc-status, uptime-kuma, filestash)
|
||||
- Auth: Drasl
|
||||
- Restart: **hard** (down → up) — players are disconnected for a few minutes
|
||||
|
||||
@@ -31,36 +31,95 @@ invoking this skill is the authorization to proceed, but:
|
||||
already up to date and ask whether to restart anyway (they may want a plain
|
||||
restart). If `git fetch` fails (host unreachable, auth), STOP and report.
|
||||
|
||||
2. **Pull + hard restart** (single SSH session, halts on any error):
|
||||
**If the incoming commits ADD A SERVICE, check `.env` on the host first.**
|
||||
A new service with `${FOO:?...}` guards (filestash uses them) fails the
|
||||
**whole compose file**, not just that service — so a missing var means
|
||||
`up` refuses to start *anything*, and a `--hard` deploy has already run
|
||||
`down`. The stack stays down. Check before tearing it down:
|
||||
```bash
|
||||
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && grep -c "^FILES_" .env'
|
||||
```
|
||||
Same for a new **subdomain**: it needs a cert (`LE_SUBDOMAINS` +
|
||||
`issue-letsencrypt.sh`) and an nginx vhost — see step 2b, which the deploy
|
||||
does NOT do for you.
|
||||
|
||||
2. **Pull, ensure authlib, then `update-all.sh --hard`** (single SSH session,
|
||||
halts on any error):
|
||||
```bash
|
||||
ssh cochi 'set -e
|
||||
cd /home/ubuntu/mc/ulicraft-server-v1
|
||||
git pull --ff-only
|
||||
tooling/render-config.sh
|
||||
docker compose down --remove-orphans
|
||||
docker compose up -d --build'
|
||||
tooling/fetch-authlib.sh
|
||||
./update-all.sh --hard'
|
||||
```
|
||||
- `--ff-only` refuses to merge — if the pull is not a fast-forward, STOP and
|
||||
report (local changes on the host need manual resolution).
|
||||
- `render-config.sh` re-renders drasl/nmsr/packwiz configs from `.env`
|
||||
(needs `.env` complete: `BASE_DOMAIN`, `RCON_PASSWORD`,
|
||||
`DISTRIBUTION_WEB_ROOT`, `CADDY_HTTP_*`).
|
||||
- `fetch-authlib.sh` ensures `runtime/authlib-injector.jar` exists (gitignored;
|
||||
skips if already valid; NOT part of update-all since it rarely changes).
|
||||
Missing jar = minecraft crashloops with "Error opening zip file or JAR
|
||||
manifest missing". Run it before `update-all` so the jar is present when
|
||||
compose brings minecraft up.
|
||||
- **`update-all.sh` is the single source of truth** for the post-pull build +
|
||||
restart steps (render drasl/nmsr configs from `.env`, sync server mods, regen
|
||||
the landing mod list, rebuild `www/`, then apply via docker compose). It is
|
||||
also runnable on its own for a lighter rolling update (no `--hard` =
|
||||
change-detected restart, no world downtime). `--hard` here does
|
||||
`down --remove-orphans && up -d --build` for the deliberate full restart.
|
||||
`render-config.sh` halts on an invalid `REGISTRATION_MODE` (must be `invite`
|
||||
or `open`); a missing distribution jar halts `sync-server-mods.sh`. The
|
||||
landing rebuild is unconditional (the gitignored `www/` is never updated by
|
||||
`git pull`), so no separate landing step is needed.
|
||||
|
||||
2b. **Only if the deploy changed `nginx/ulicraft-caddy.conf.tmpl`** (a new
|
||||
subdomain, a new body-size/rate-limit rule, a changed `CADDY_HTTP_PORT`).
|
||||
`update-all.sh` does **not** touch the host nginx, so a new vhost simply
|
||||
won't exist and the subdomain 404s / hits the wrong server block:
|
||||
```bash
|
||||
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && tooling/render-nginx.sh --install'
|
||||
```
|
||||
It runs `nginx -t` before reloading, so a bad render fails safe. The cert
|
||||
must already exist (`certs/<name>/cert.pem`) or nginx won't load the block —
|
||||
issue it first with `tooling/issue-letsencrypt.sh` (reads `LE_SUBDOMAINS`).
|
||||
|
||||
3. **Verify.**
|
||||
```bash
|
||||
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && docker compose ps'
|
||||
```
|
||||
Confirm `caddy`, `drasl`, `minecraft`, `mc-backup`, `nmsr`, `uptime-kuma` are
|
||||
Up. Tail the minecraft log briefly and confirm it reaches `Done` (server
|
||||
ready):
|
||||
Confirm `caddy`, `drasl`, `minecraft`, `mc-backup`, `nmsr`, `mc-status`,
|
||||
`uptime-kuma`, `filestash` are Up. Tail the minecraft log briefly and confirm
|
||||
it reaches the ready marker (match `Done (Ns)! For help` — a bare `Done`
|
||||
also matches unrelated mod-loading lines):
|
||||
```bash
|
||||
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && timeout 120 docker compose logs -f minecraft' 2>/dev/null | grep -m1 "Done"
|
||||
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && timeout 180 docker compose logs -f minecraft' 2>/dev/null | grep -m1 'Done ([0-9.]*s)! For help'
|
||||
```
|
||||
If a public vhost changed, verify it end-to-end from your workstation rather
|
||||
than trusting `compose ps` (a container can be Up while nginx never routes to
|
||||
it): `curl -s -o /dev/null -w '%{http_code}' https://<vhost>/`.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Never `git reset --hard` or discard changes on `cochi` without telling the user
|
||||
first — there may be host-local edits.
|
||||
- **Caddy conf changes need a recreate, not reload/restart.** If the deploy
|
||||
touched any `caddy/conf.d/*.caddy` or the Caddyfile and you did a *rolling*
|
||||
`update-all.sh` (not `--hard`), the change WON'T apply: those are single-file
|
||||
bind mounts pinned to the host inode, and `git pull` gives the file a new inode.
|
||||
`restart`/`caddy reload` reuse the stale inode. Run
|
||||
`docker compose up -d --force-recreate caddy`, then verify with
|
||||
`docker compose exec caddy cat /etc/caddy/conf.d/<file>`. The `--hard` path
|
||||
(full `down`/`up`) already recreates, so it's unaffected.
|
||||
- **Same trap for `filestash/config.json`** — also a single-file bind mount, and
|
||||
it is *re-rendered on every run* by `render-config.sh`, so a rolling
|
||||
`update-all.sh` leaves filestash reading the stale inode after any `FILES_*`
|
||||
change (rotating the shared password, flipping `FILES_AUTH`). Apply with
|
||||
`docker compose up -d --force-recreate filestash`. `--hard` is unaffected.
|
||||
- **Never let `filestash/config.json` be missing when compose runs.** It is
|
||||
gitignored and rendered; if it doesn't exist, Docker creates a *directory* at
|
||||
that path and filestash breaks in a confusing way. `update-all.sh` renders
|
||||
before `up`, so the normal path is safe — just don't hand-run `docker compose
|
||||
up` on the host without rendering first. If it happened: `rm -rf
|
||||
filestash/config.json && tooling/render-config.sh && docker compose up -d
|
||||
--force-recreate filestash`.
|
||||
- Volumes (`mc_data`, `drasl_state`, `kuma_data`) persist across `down`; the
|
||||
world and monitors are safe. Do NOT pass `-v`/`--volumes` to `down`.
|
||||
- If a step fails, STOP and surface the exact error + the failing command. Do not
|
||||
|
||||
82
.env.example
82
.env.example
@@ -2,11 +2,43 @@
|
||||
|
||||
# Single base domain for the whole stack; every service is a subdomain of this.
|
||||
# DNS is handled OUTSIDE this repo — point ${BASE_DOMAIN} and every subdomain
|
||||
# (auth. pack. avatar. status. distribution. www.) at the host running nginx.
|
||||
# (auth. avatar. status. distribution. www.) at the host running nginx.
|
||||
BASE_DOMAIN=ulicraft.net
|
||||
|
||||
RCON_PASSWORD=change-me-to-something-random
|
||||
|
||||
# Registration mode (BACKEND gate) for self-hosted accounts (Drasl).
|
||||
# invite = closed; guests need an admin-minted invite code (recommended on a
|
||||
# public, internet-present host). Admin mints via POST /drasl/api/v2/invites.
|
||||
# open = anyone can self-register with username+password (set [RateLimit]!).
|
||||
# Consumed by render-config.sh (-> Drasl RegistrationNewPlayer.RequireInvite).
|
||||
# Default: invite. NOTE: this no longer controls the landing invite FIELD — that's
|
||||
# REGISTRATION_SHOW_INVITE below.
|
||||
REGISTRATION_MODE=invite
|
||||
|
||||
# Show the invite-code FIELD on the landing register form. Read by the landing
|
||||
# BUILD (data/site.ts) only; default false (hidden). Independent of the backend
|
||||
# gate above. CAVEAT: if REGISTRATION_MODE=invite (Drasl requires a code) but this
|
||||
# is false, public self-registration can't supply one — enable both together, or
|
||||
# mint accounts admin-side. true | false.
|
||||
REGISTRATION_SHOW_INVITE=false
|
||||
|
||||
# NMSR avatar render mode for the landing's avatar tiles. Read by the landing
|
||||
# BUILD (data/site.ts) only. Avatar URL =
|
||||
# https://avatar.${BASE_DOMAIN}/<mode>/<uuid>?size=N. e.g. headiso | head | fullbody.
|
||||
# AVATAR_MODE → ServerCard online row + account preview (heads)
|
||||
# ROSTER_AVATAR_MODE → Members grid (full body; default fullbodyiso)
|
||||
AVATAR_MODE=headiso
|
||||
ROSTER_AVATAR_MODE=fullbodyiso
|
||||
|
||||
# Drasl admin creds for the registered-players roster (/api/mcstatus/players).
|
||||
# Consumed ONLY by the mc-status container (server-side login → sanitized
|
||||
# [{uuid,name}] list); these NEVER reach the browser. Use a DEDICATED, rotatable
|
||||
# admin account (Drasl has no read-only role) to limit blast radius. Leave blank
|
||||
# to disable the roster (the Members grid then just shows its empty state).
|
||||
DRASL_ADMIN_USERNAME=
|
||||
DRASL_ADMIN_PASSWORD=
|
||||
|
||||
# caddy host-published port. The host's own nginx terminates TLS and
|
||||
# reverse-proxies to caddy by Host header (nginx/ulicraft-caddy.conf.tmpl), so
|
||||
# bind caddy to a high localhost-only port — only nginx should reach it.
|
||||
@@ -17,6 +49,52 @@ CADDY_HTTP_BIND=127.0.0.1
|
||||
# static site — it lives in ANOTHER repo. Bind-mounted read-only into caddy.
|
||||
DISTRIBUTION_WEB_ROOT=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-distribution/dist
|
||||
|
||||
# ── files.${BASE_DOMAIN} — Filestash player file share ────────────────
|
||||
# Screenshots / schematics / maps. ONE shared login for all players, writable.
|
||||
# Full design + gotchas: plan/20-files.md.
|
||||
|
||||
# Host path holding the shared files. Keep it OUTSIDE the repo — guest-writable
|
||||
# data must not live in a git tree we `git pull` into.
|
||||
FILES_DATA_DIR=/srv/ulicraft-files
|
||||
|
||||
# Literal mount flag for FILES_DATA_DIR: rw | ro. `ro` makes the share
|
||||
# read-only at the KERNEL, regardless of what the Filestash UI thinks. It is a
|
||||
# mount flag rather than a boolean because compose has no conditionals — the
|
||||
# value is substituted straight into the volume string.
|
||||
FILES_MOUNT_MODE=rw
|
||||
|
||||
# Auth middleware (rendered into config.json by render-config.sh):
|
||||
# htpasswd one shared user/pass — the only internet-safe value
|
||||
# passthrough NO LOGIN. Anyone reaching the vhost is in.
|
||||
# none alias of passthrough
|
||||
# passthrough/none on a WRITABLE, internet-reachable share is an open upload
|
||||
# relay (malware/phishing hosted on your domain, under your cert, with your
|
||||
# bandwidth — and a domain blocklisting would take auth. and the apex down
|
||||
# with it). Only set it once this host is unreachable from the internet.
|
||||
FILES_AUTH=htpasswd
|
||||
|
||||
# The shared player credential (FILES_AUTH=htpasswd).
|
||||
# FILES_PASSWORD_HASH: openssl passwd -6 '<password>'
|
||||
# MUST be $6$ (sha512). The htpasswd plugin's bcrypt branch only accepts $2a$,
|
||||
# so a $2y$ hash from `htpasswd -B` fails EVERY login, silently.
|
||||
# render-config.sh rejects the wrong format rather than shipping it.
|
||||
FILES_USER=uli
|
||||
FILES_PASSWORD_HASH=
|
||||
|
||||
# Filestash /admin console password — bcrypt, and /admin IS publicly exposed, so
|
||||
# use a 20+ char generated password. It is the only credential guarding config.
|
||||
# docker run --rm caddy:alpine caddy hash-password --plaintext '<password>'
|
||||
FILES_ADMIN_PASSWORD_HASH=
|
||||
|
||||
# Session key (openssl rand -hex 16). Must be non-empty: an empty secret_key
|
||||
# makes filestash rewrite config.json at boot, which fails on the :ro mount.
|
||||
FILES_SECRET_KEY=
|
||||
|
||||
# Unlocks the local storage backend, which filestash admin-gates: it refuses to
|
||||
# init unless the connection params carry this secret. config.json supplies it
|
||||
# server-side; players never see it. openssl rand -hex 24.
|
||||
FILES_LOCAL_SECRET=
|
||||
|
||||
# Only needed if using CurseForge-exclusive mods:
|
||||
# CF_API_KEY=your-curseforge-api-key
|
||||
|
||||
@@ -24,7 +102,7 @@ DISTRIBUTION_WEB_ROOT=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-distri
|
||||
# Only needed to issue real TLS certs. DNS-01 needs no inbound ports.
|
||||
LE_EMAIL=you@example.com
|
||||
# space-separated subdomains; apex ${BASE_DOMAIN} is always included
|
||||
LE_SUBDOMAINS=auth pack distribution www avatar status
|
||||
LE_SUBDOMAINS=auth distribution www avatar status files
|
||||
# OVH API creds (DNS zone write). Create at https://eu.api.ovh.com/createToken/
|
||||
# OVH_CK can be blank on first run — acme.sh prints an auth URL, then paste it.
|
||||
OVH_END_POINT=ovh-eu
|
||||
|
||||
26
.gitignore
vendored
26
.gitignore
vendored
@@ -6,15 +6,16 @@ runtime/authlib-injector.jar
|
||||
# rendered configs (from tooling/render-config.sh)
|
||||
drasl/config/config.toml
|
||||
nmsr/config.toml
|
||||
# packwiz pack files rendered from pack/**/*.tmpl by tooling/render-pack.sh
|
||||
# (domain-dependent build output); source of truth is the .tmpl + custom/ jars.
|
||||
pack/mods/*.pw.toml
|
||||
pack/index.toml
|
||||
pack/CustomSkinLoader/CustomSkinLoader.json
|
||||
# holds the admin bcrypt hash, the shared player hash, the session key and the
|
||||
# local-backend secret — only the .tmpl is tracked
|
||||
filestash/config.json
|
||||
|
||||
# server mod subset synced from the distribution repo by
|
||||
# tooling/sync-server-mods.sh (source of truth is mods-sides.json + $DIST jars)
|
||||
server-mods/
|
||||
|
||||
# vendored binaries
|
||||
launcher/
|
||||
pack/mods-files/
|
||||
|
||||
# landing page: generated build output + deps
|
||||
www/
|
||||
@@ -22,5 +23,18 @@ landing/node_modules/
|
||||
landing/.astro/
|
||||
landing/dist/
|
||||
|
||||
# launcher download list: the custom-launcher build output launcher.json,
|
||||
# synced from UlicraftLauncher/dist/launcher.json (launcher.example.json is
|
||||
# committed as the documented shape)
|
||||
landing/src/data/launcher.json
|
||||
|
||||
# mod catalogue + extracted logos: generated by tooling/build-modlist.py from
|
||||
# the distribution forgemods (the .example.json is committed as the empty shape)
|
||||
landing/src/data/mods.json
|
||||
landing/public/mod-logos/
|
||||
|
||||
# compiled mc-status binary (built into the image; stray local `go build` output)
|
||||
docker/mc-status/mc-status
|
||||
|
||||
# Let's Encrypt certs + private keys (issued by tooling/issue-letsencrypt.sh)
|
||||
certs/
|
||||
|
||||
126
CLAUDE.md
126
CLAUDE.md
@@ -1,7 +1,7 @@
|
||||
# Ulicraft Server — Project Context
|
||||
|
||||
> Self-hosted modded Minecraft (NeoForge 1.21.1) with self-hosted auth/skins
|
||||
> (Drasl), a packwiz modpack, avatar rendering, a guest landing page, and uptime
|
||||
> (Drasl), a distribution-fed modpack, avatar rendering, a guest landing page, and uptime
|
||||
> monitoring. Public, internet-present deployment behind the host's nginx + TLS.
|
||||
|
||||
**`plan/` is the source of truth for the design.** Start at `plan/00-overview.md`.
|
||||
@@ -23,25 +23,25 @@ with `plan/` or the code, those win.
|
||||
One unified `docker-compose.yml`. The host's own nginx terminates TLS (Let's
|
||||
Encrypt) and reverse-proxies every vhost to caddy (published localhost-only) by
|
||||
Host header. caddy is the internal router; containers reach the stack's own names
|
||||
via caddy's `mcnet` aliases (`auth.`, `pack.`).
|
||||
via caddy's `mcnet` aliases (`auth.`).
|
||||
|
||||
```
|
||||
Internet ── host nginx (TLS, LE certs, HSTS) ──► caddy (127.0.0.1:8880)
|
||||
│ routes by Host:
|
||||
${BASE_DOMAIN} ─► /srv/www landing + /launcher/ downloads
|
||||
auth.${BASE_DOMAIN} ─► drasl (Yggdrasil API + web UI)
|
||||
pack.${BASE_DOMAIN} ─► packwiz files + /custom/ jars
|
||||
avatar.${BASE_DOMAIN} ─► nmsr (skin/avatar renderer, Drasl-backed)
|
||||
status.${BASE_DOMAIN} ─► uptime-kuma (status page + monitors)
|
||||
files.${BASE_DOMAIN} ─► filestash (player file share, ONE shared login, writable)
|
||||
distribution.${BASE} ─► static site from ANOTHER repo (DISTRIBUTION_WEB_ROOT)
|
||||
|
||||
minecraft :25565 (+ 24454/udp Simple Voice Chat)
|
||||
└ ONLINE_MODE=false, -javaagent authlib-injector → http://auth.${BASE_DOMAIN}/authlib-injector
|
||||
└ mods via PACKWIZ_URL → http://pack.${BASE_DOMAIN}/pack.toml
|
||||
└ ONLINE_MODE=true (validated against Drasl, NOT offline), -javaagent authlib-injector → http://auth.${BASE_DOMAIN}/authlib-injector
|
||||
└ mods from ./server-mods volume (/mods, REMOVE_OLD_MODS) — see plan/04-mods.md
|
||||
mc-backup ── RCON → minecraft (6h interval, prune 14d, world-only)
|
||||
```
|
||||
|
||||
Bring-up: render configs → build landing → fetch launcher → `docker compose up -d --build`.
|
||||
Bring-up: render configs → sync server mods → build landing → fetch launcher → `docker compose up -d --build`.
|
||||
See `plan/12-build-order.md` and `plan/14-deploy.md`. Deploy to prod host `cochi`
|
||||
via the `deploy` skill.
|
||||
|
||||
@@ -55,8 +55,11 @@ via the `deploy` skill.
|
||||
- **Manual curation** (user choice, flagged expensive) — if it gets painful,
|
||||
fork-and-trim Leaking Kitchen Sink is still on the table (closest vibe match).
|
||||
- **itzg/minecraft-server** base image — de-facto standard, auto-installs NeoForge.
|
||||
- **packwiz** for the modpack — `PACKWIZ_URL` drives server mod install; clients
|
||||
import the same `pack.toml`. Jars come from the upstream CDN per `.pw.toml`.
|
||||
- **Distribution-fed mod volume (replaced packwiz)** — the modset's source of
|
||||
truth is the HeliosLauncher distribution repo (`$DISTRIBUTION_WEB_ROOT`).
|
||||
Clients install the full set via `distribution.json`. The SERVER loads a
|
||||
filtered subset (`both`/`server`) from a mounted `./server-mods` volume
|
||||
(`REMOVE_OLD_MODS=TRUE`). See `plan/04-mods.md`.
|
||||
|
||||
## Critical Gotchas
|
||||
|
||||
@@ -65,6 +68,14 @@ via the `deploy` skill.
|
||||
- Drasl: `SignPublicKeys = false`
|
||||
- Linked. Drasl docs: *"Mixed authentication does not work with
|
||||
`SignPublicKeys = true` on Minecraft 1.21+."*
|
||||
- **This is the ONLY auth flag that goes off. `ONLINE_MODE` stays TRUE** — see
|
||||
next gotcha. Do not conflate "secure profile off" with "offline mode".
|
||||
|
||||
### ONLINE_MODE must be TRUE (authlib-injector ≠ offline)
|
||||
authlib-injector redirects the server's normal **online**-auth handshake from
|
||||
Mojang to Drasl. `ONLINE_MODE=FALSE` skips that handshake → offline-hash UUIDs,
|
||||
no Drasl session validation, and **no skins** (everyone joins but everyone is
|
||||
Steve; `usercache.json` shows offline-hash UUIDs). Keep `ONLINE_MODE=TRUE`.
|
||||
|
||||
### authlib-injector JVM agent
|
||||
- Syntax: `-javaagent:/extras/authlib-injector.jar=<yggdrasil-api-url>`
|
||||
@@ -78,13 +89,45 @@ via the `deploy` skill.
|
||||
|
||||
### NeoForge version pinning
|
||||
Mods are picky about exact minor versions. Compose pins `NEOFORGE_VERSION:
|
||||
"21.1.209"`. **Verify against https://projects.neoforged.net/neoforged/neoforge
|
||||
before deploying.** Never `"latest"` for a stable server.
|
||||
"21.1.233"` — **must match the distribution's NeoForge** (`distribution.json`),
|
||||
since mods are built against it (e.g. ferritecore≥218, farmersdelight≥219).
|
||||
**Verify against https://projects.neoforged.net/neoforged/neoforge before
|
||||
deploying.** Never `"latest"` for a stable server.
|
||||
|
||||
### Mod source preference
|
||||
Prefer **Modrinth over CurseForge** (`packwiz modrinth add <slug>`). CF needs
|
||||
`CF_API_KEY` and is flakier. Tag client-only mods `side = "client"` so the server
|
||||
doesn't pull and crash on them.
|
||||
### Mod source + server filtering
|
||||
The full modset lives in the distribution repo (`$DISTRIBUTION_WEB_ROOT/servers/
|
||||
ulicraft-1.21.1/forgemods/required/*.jar`), managed by Nebula. Clients get
|
||||
everything via `distribution.json`. The server runs a **filtered subset**:
|
||||
`tooling/classify-mods.py` reads each jar's `neoforge.mods.toml` with tomllib and
|
||||
seeds `mods-sides.json` (`client|server|both`, default `both`), then
|
||||
`tooling/sync-server-mods.sh` copies the `both`/`server` jars into `./server-mods`
|
||||
(mounted at `/mods`). Hard client-only mods (sodium/iris) classify as `client`
|
||||
automatically; cosmetic-client mods that declare `BOTH` stay `both` until you
|
||||
hand-edit `mods-sides.json` to `client`. No Modrinth/packwiz, no network at
|
||||
classify/sync time. See `plan/04-mods.md`.
|
||||
|
||||
### Filestash (files.) — four traps
|
||||
Full list in `plan/20-files.md`; the ones that cost real time:
|
||||
- **`general.host` is a BARE HOSTNAME** (`files.${BASE_DOMAIN}`, no scheme) +
|
||||
`force_ssl: true`. The SPA builds its origin as `scheme + host`, so a URL there
|
||||
yields `http://https://files...` and every client-side redirect dies. The
|
||||
server strips the scheme before its own Host check, so **curl and the uptime
|
||||
monitor stay green while every browser is broken**. Cost a debugging round on
|
||||
2026-07-14.
|
||||
- The `local` backend is **admin-gated**: without `LOCAL_BACKEND_SECRET` in the
|
||||
connection params, every login fails with `backend error - Not Allowed` (looks
|
||||
like bad credentials, isn't).
|
||||
- `FILES_PASSWORD_HASH` must be **`$6$`** (`openssl passwd -6`). A `$2y$` bcrypt
|
||||
from `htpasswd -B` fails every login silently — the plugin only matches `$2a$`.
|
||||
- `config.json` is rendered + mounted `:ro` (the admin console can't save, on
|
||||
purpose). Single-file bind mount ⇒ **`--force-recreate` after re-rendering**,
|
||||
never `restart`.
|
||||
|
||||
**Verifying an SPA:** a 200 on `/` proves the server is up, not that the app
|
||||
works. After any filestash config change, check the browser-facing values
|
||||
(`curl /api/config` → `origin`) and re-read `docker compose logs filestash` —
|
||||
the broken redirect announced itself there (`msg=Redirecting to http://https://`)
|
||||
while every other check passed.
|
||||
|
||||
### Memory sizing
|
||||
~50–100 mods, 8 players, 1.21.1: `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G`,
|
||||
@@ -93,9 +136,11 @@ doesn't pull and crash on them.
|
||||
## Config (.env)
|
||||
|
||||
`BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT`/`CADDY_HTTP_BIND`,
|
||||
`DISTRIBUTION_WEB_ROOT`, the Let's Encrypt block (`LE_EMAIL`, `LE_SUBDOMAINS`,
|
||||
`OVH_*`), optional `CF_API_KEY`. Rendered configs (drasl, nmsr, packwiz) come
|
||||
from `*.tmpl` via `tooling/render-config.sh` (substitutes `${BASE_DOMAIN}` only).
|
||||
`DISTRIBUTION_WEB_ROOT` (source of the modset + caddy `distribution.` mount), the
|
||||
Let's Encrypt block (`LE_EMAIL`, `LE_SUBDOMAINS`, `OVH_*`). Rendered configs
|
||||
(drasl, nmsr) come from `*.tmpl` via `tooling/render-config.sh` (substitutes
|
||||
`${BASE_DOMAIN}` only). Server mods are synced (not rendered) by
|
||||
`tooling/sync-server-mods.sh`.
|
||||
|
||||
## Client Distribution (guests)
|
||||
|
||||
@@ -105,20 +150,51 @@ mirrored at apex `/launcher/`. Per guest:
|
||||
2. Add an authlib-injector account, URL
|
||||
`https://auth.${BASE_DOMAIN}/authlib-injector` (register/login at
|
||||
`https://auth.${BASE_DOMAIN}`).
|
||||
3. Import the pack `https://pack.${BASE_DOMAIN}/pack.toml`.
|
||||
3. The launcher installs the modpack from the distribution (`distribution.json`).
|
||||
(packwiz and the `pack.` service/subdomain have been fully removed.)
|
||||
4. Connect to `${BASE_DOMAIN}` (`:25565`).
|
||||
|
||||
## Open / Pending Work
|
||||
|
||||
- [ ] **Mod shortlist** for the kitchen-sink pack: Performance (Embeddium,
|
||||
FerriteCore, ModernFix), Tech (Mekanism, Create, IE, AE2), Magic (Ars Nouveau,
|
||||
Botania, Iron's Spells), Storage (Sophisticated, Functional), Exploration
|
||||
(YUNG's, Repurposed Structures), QoL (JEI, Jade, IPN, AppleSkin), World gen
|
||||
(Tectonic, Terralith — verify NeoForge 1.21.1), Compat (Polymorph), Map (Xaero),
|
||||
Voice (Simple Voice Chat — 24454/udp already in compose).
|
||||
### Roadmap — mod migration follow-ups (post packwiz→04-mods)
|
||||
- [ ] **Curate `mods-sides.json` cosmetic-client mods.** tomllib auto-flagged 11
|
||||
hard client-only mods; the other 65 default `both`, including client-cosmetic
|
||||
ones (BetterF3, entityculling, MouseTweaks, ponderjs, fancymenu, drippy, melody,
|
||||
welcomescreen, Searchables, JustEnoughResources, TravelersTitles, yeetus…) that
|
||||
load harmlessly on the server but waste RAM. Hand-edit them to `client` so
|
||||
`sync-server-mods.sh` drops them from `./server-mods`. (Also review `appleskin`,
|
||||
flagged `client` — flip to `both` if server-side food data wanted.)
|
||||
### Landing rework + mod list (PLANNED — see `plan/18-landing-rework.md`)
|
||||
Full design settled across WS1–WS6; all tasks unchecked, ready to execute.
|
||||
- [ ] **WS1 — Join flow off packwiz.** Homepage = UlicraftLauncher, 3 steps
|
||||
(register → download launcher → join); drop `packwizUrl` + packwiz step.
|
||||
Per-OS downloads from a synced `launcher.json` (shape
|
||||
`productName/version/files[]`, the build output of the `custom-launcher`
|
||||
repo; `launcher.example.json` committed as the documented fallback). Fjord
|
||||
moves to its own `/fjord` page.
|
||||
- [ ] **WS2/3 — Player rosters.** Online (mc-status, live) + all-registered (new
|
||||
`mc-status /api/mcstatus/players`, admin-login → Drasl `GET /players`, ~60s
|
||||
cache). Shared avatar tile, `AVATAR_MODE` env (default `headiso`). Admin creds
|
||||
→ mc-status only.
|
||||
- [ ] **WS4 — Account page** (`/account`): skin CRUD via browser-direct Drasl
|
||||
(reuses `register.astro` skin JS). Skins only, in-memory token, `players[0]`.
|
||||
- [ ] **WS5 — Footer status link** to `status.${BASE_DOMAIN}` (nav unchanged).
|
||||
|
||||
- [ ] **WS6 — Mod shortlist + list component (`plan/17-mod-shortlist.md`).**
|
||||
Light gap doc: current 76-jar pack is vanilla+ QoL/perf; **empty** on tech &
|
||||
magic, thin worldgen, no map; orphan deps `ponderjs`/`Necronomicon`. Landing
|
||||
mod-list component = auto-generated `mods.json` + extracted logos from the
|
||||
distribution forgemods (`tooling/build-modlist.py`), flat alphabetical, pretty
|
||||
names, no categories/links; feeds the "50+" stat tile.
|
||||
- [ ] Server-side perf mods (C2ME, etc.).
|
||||
- [x] **Filestash file share** (`files.`, 2026-07-14) — live in prod. One shared
|
||||
htpasswd login, writable, rendered `:ro` config. See `plan/20-files.md`.
|
||||
Follow-ups: add the Uptime Kuma HTTP monitor for `files.`; the share is
|
||||
**outside mc-backup** and unquota'd (accepted — eyeballed).
|
||||
- [ ] **Verify NeoForge version** against current stable before first deploy.
|
||||
- [ ] **Bootstrap Drasl admin** account.
|
||||
- [x] **Bootstrap Drasl admin** account (`admin`, 2026-06-10). Key is
|
||||
`DefaultAdmins` (not `DefaultAdminUsernames`); first admin needs an invite-flip.
|
||||
See `plan/03-drasl.md` gotchas.
|
||||
|
||||
## Reference URLs
|
||||
|
||||
|
||||
109
README.md
109
README.md
@@ -1,9 +1,9 @@
|
||||
# Ulicraft Server
|
||||
|
||||
Self-hosted modded Minecraft (NeoForge 1.21.1) with self-hosted auth/skins
|
||||
(Drasl), a packwiz modpack, avatar rendering, a guest landing page, and uptime
|
||||
monitoring. One unified `docker-compose.yml` runs the whole stack behind the
|
||||
host's nginx + Let's Encrypt.
|
||||
(Drasl), a distribution-fed modpack, avatar rendering, a guest landing page, and
|
||||
uptime monitoring. One unified `docker-compose.yml` runs the whole stack behind
|
||||
the host's nginx + Let's Encrypt.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -15,30 +15,31 @@ One compose file, one `docker compose up -d`:
|
||||
| `drasl` | unmojang/drasl | Yggdrasil auth + skins (password login) |
|
||||
| `caddy` | caddy:alpine | internal ingress: routes every vhost by Host header |
|
||||
| `nmsr` | built from source | skin/avatar renderer at `avatar.${BASE_DOMAIN}` |
|
||||
| `mc-status` | built from source | live server-status JSON for the landing card |
|
||||
| `uptime-kuma` | louislam/uptime-kuma | status page at `status.${BASE_DOMAIN}` |
|
||||
| `filestash` | machines/filestash (digest-pinned) | player file share at `files.${BASE_DOMAIN}` |
|
||||
| `mc-backup` | itzg/mc-backup | world backups every 6h via RCON |
|
||||
|
||||
Vhosts (all proxied through the host nginx → caddy):
|
||||
|
||||
- apex `${BASE_DOMAIN}` — landing page + `/launcher/` downloads
|
||||
- apex `${BASE_DOMAIN}` — landing page + `/launcher/` downloads + `/api/mcstatus/*`
|
||||
- `auth.` — Drasl
|
||||
- `pack.` — packwiz metadata + `/custom/` jars
|
||||
- `avatar.` — NMSR
|
||||
- `status.` — Uptime Kuma
|
||||
- `files.` — Filestash player file share (one shared login, writable)
|
||||
- `distribution.` — static site from another repo (`DISTRIBUTION_WEB_ROOT`)
|
||||
|
||||
Config lives in `.env`: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT` /
|
||||
`CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`. See `.env.example`.
|
||||
`CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`, the `FILES_*` block. See `.env.example`.
|
||||
|
||||
## DNS
|
||||
|
||||
Handled **outside this repo**. Point `${BASE_DOMAIN}` and every subdomain
|
||||
(`auth. pack. avatar. status. distribution. www.`) at the host running nginx.
|
||||
(`auth. avatar. status. files. distribution. www.`) at the host running nginx.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Docker + Docker Compose; for prep also `pnpm`, `packwiz`, `jq`, `curl`,
|
||||
`envsubst`.
|
||||
Docker + Docker Compose; for prep also `pnpm`, `jq`, `curl`, `envsubst`.
|
||||
|
||||
## Launch
|
||||
|
||||
@@ -46,16 +47,17 @@ Docker + Docker Compose; for prep also `pnpm`, `packwiz`, `jq`, `curl`,
|
||||
cp .env.example .env # set BASE_DOMAIN, RCON_PASSWORD, DISTRIBUTION_WEB_ROOT
|
||||
|
||||
# One-time / on change — render configs + build content:
|
||||
tooling/render-config.sh # drasl + nmsr + packwiz configs from *.tmpl
|
||||
tooling/render-config.sh # drasl + nmsr configs from *.tmpl
|
||||
tooling/sync-server-mods.sh # filtered server mods → ./server-mods
|
||||
( cd landing && pnpm install && BASE_DOMAIN="$BASE_DOMAIN" pnpm run build ) # → www/
|
||||
tooling/fetch-launcher.sh # FjordLauncher assets → launcher/ (served at /launcher/)
|
||||
# authlib-injector.jar → runtime/ (server JVM agent; see drasl docs)
|
||||
tooling/fetch-authlib.sh # authlib-injector.jar → runtime/ (server JVM agent)
|
||||
|
||||
docker compose up -d --build # first run installs NeoForge + mods, builds nmsr
|
||||
docker compose down # stop
|
||||
```
|
||||
|
||||
First boot: itzg installs NeoForge + the packwiz mods into the `mc_data` volume;
|
||||
First boot: itzg installs NeoForge + the synced server mods into the `mc_data` volume;
|
||||
nmsr does a one-time Rust compile. Watch `docker compose logs -f minecraft` until
|
||||
`Done`.
|
||||
|
||||
@@ -69,7 +71,7 @@ certs and reverse-proxies every vhost to caddy by Host header. HTTPS-only: HTTP
|
||||
1. **Issue certs** (OVH DNS-01, no inbound ports needed):
|
||||
```sh
|
||||
# .env: BASE_DOMAIN, LE_EMAIL, OVH_* creds,
|
||||
# LE_SUBDOMAINS="auth pack distribution www avatar status"
|
||||
# LE_SUBDOMAINS="auth distribution www avatar status"
|
||||
tooling/issue-letsencrypt.sh # → certs/<name>/{cert,key}.pem
|
||||
```
|
||||
|
||||
@@ -120,22 +122,93 @@ persists in the `kuma_data` volume):
|
||||
| Minecraft | Minecraft Server | `minecraft` : `25565` |
|
||||
| Drasl auth | HTTP(s) | `https://auth.${BASE_DOMAIN}` |
|
||||
| Landing (apex) | HTTP(s) | `https://${BASE_DOMAIN}` |
|
||||
| Packwiz | HTTP(s) | `https://pack.${BASE_DOMAIN}` |
|
||||
| Distribution | HTTP(s) | `https://distribution.${BASE_DOMAIN}` |
|
||||
| Avatar (NMSR) | HTTP(s) | `https://avatar.${BASE_DOMAIN}` |
|
||||
| Files (Filestash) | HTTP(s) | `https://files.${BASE_DOMAIN}` (expect 200 — the login page is a 200) |
|
||||
|
||||
Internal-name targets (`minecraft`, `drasl:25585`, `nmsr:8080`) isolate "service
|
||||
down" from "ingress/TLS/DNS down"; public-URL targets test the whole chain.
|
||||
`status` is in the default `LE_SUBDOMAINS`; the nginx vhost adds websocket
|
||||
upgrade headers for Kuma's live UI.
|
||||
|
||||
## Server status JSON (mc-status)
|
||||
|
||||
`mc-status` is a self-hosted SLP→JSON pinger (built from `docker/mc-status`, an
|
||||
mcutil wrapper) that the landing's `ServerCard` reads for live player count + MOTD.
|
||||
It emits **mcstatus.io v2** JSON, so the card consumes it unchanged — but
|
||||
same-origin, with no CORS and no third-party calls. caddy proxies the apex
|
||||
`/api/mcstatus/*` path to `mc-status:8080` (strips the prefix); the container has
|
||||
no published port. The pinger only ever probes its configured `MC_STATUS_TARGET`,
|
||||
so the path after the prefix is ignored.
|
||||
|
||||
Endpoint (apex, same-origin):
|
||||
|
||||
```
|
||||
https://${BASE_DOMAIN}/api/mcstatus/v2/status/java/${BASE_DOMAIN}
|
||||
```
|
||||
|
||||
Hitting caddy directly (localhost-only, routes by Host header) — e.g. `ulicraft.net`
|
||||
on `127.0.0.1:8880`:
|
||||
|
||||
```sh
|
||||
curl -H "Host: ulicraft.net" \
|
||||
http://127.0.0.1:8880/api/mcstatus/v2/status/java/ulicraft.net
|
||||
```
|
||||
|
||||
Wired in `landing/src/data/site.ts` (`statusApi`) and `caddy/conf.d/10-static.caddy`.
|
||||
|
||||
## Player file share (Filestash)
|
||||
|
||||
`files.${BASE_DOMAIN}` is a web file share for player media — screenshots,
|
||||
schematics, maps, guides. **One shared username/password for everybody**
|
||||
(`FILES_USER` / `FILES_PASSWORD_HASH`), read **and** write. It is *not* a backup
|
||||
browser and *not* a mod mirror: world snapshots contain every player's inventory
|
||||
and coordinates, and the modset already ships via `distribution.`.
|
||||
|
||||
Full design, deploy steps and gotchas: **`plan/20-files.md`**. The short version:
|
||||
|
||||
- Config is `filestash/config.json`, **rendered from a template and mounted
|
||||
`:ro`** — git is the source of truth. The admin console at `/admin` loads but
|
||||
**cannot save**; that's deliberate. Change config by editing the `.tmpl`,
|
||||
re-rendering, and **recreating** the container (`up -d --force-recreate
|
||||
filestash` — a single-file bind mount pins the inode, so `restart` reads the
|
||||
stale file).
|
||||
- Files live in `${FILES_DATA_DIR}` on the host, outside the repo. **Not backed
|
||||
up** by mc-backup — treat the share as disposable.
|
||||
- `FILES_MOUNT_MODE=ro|rw` is the literal mount flag: `ro` makes the share
|
||||
read-only at the *kernel*, whatever the UI thinks.
|
||||
- `FILES_AUTH=htpasswd|passthrough` — `passthrough` means **no login at all**.
|
||||
Only valid once the host is off the public internet: an unauthenticated
|
||||
*writable* share on a public domain is an open upload relay.
|
||||
|
||||
Four traps that cost real time (all enforced or documented in the tooling):
|
||||
|
||||
- **`general.host` is a bare hostname**, no scheme (`files.${BASE_DOMAIN}`), with
|
||||
`force_ssl: true`. The SPA builds its redirect origin as `scheme + host`, so a
|
||||
URL there produces `http://https://files...` and every client-side redirect
|
||||
breaks. The server strips the scheme before its *own* Host check, so `curl`
|
||||
and the uptime monitor stay **green while every browser is broken**.
|
||||
- **`FILES_PASSWORD_HASH` must be `$6$`** (`openssl passwd -6`). The htpasswd
|
||||
plugin's bcrypt branch only matches `$2a$`, so a `$2y$` hash from
|
||||
`htpasswd -B` fails *every* login, silently. `render-config.sh` rejects it.
|
||||
- **The `local` storage backend is admin-gated** — without `FILES_LOCAL_SECRET`
|
||||
reaching it through the connection params, every login dies with
|
||||
`backend error - Not Allowed`, which looks like bad credentials and isn't.
|
||||
- **Don't set `APPLICATION_URL`/`ADMIN_PASSWORD` env** — either makes Filestash
|
||||
rewrite `config.json` at boot, which fails against the `:ro` mount.
|
||||
|
||||
> Verifying this service: a 200 on `/` only proves the server is up. Check the
|
||||
> browser-facing origin — `curl -s -H 'X-Requested-With: XmlHttpRequest`
|
||||
> `https://files.${BASE_DOMAIN}/api/config | jq -r .result.origin` must print
|
||||
> `https://files.${BASE_DOMAIN}` — and re-read `docker compose logs filestash`.
|
||||
|
||||
## Join (guests)
|
||||
|
||||
1. Open `https://${BASE_DOMAIN}`, download FjordLauncherUnlocked for your OS.
|
||||
2. Add an **authlib-injector** account, URL
|
||||
`https://auth.${BASE_DOMAIN}/authlib-injector` (register/login at
|
||||
`https://auth.${BASE_DOMAIN}`).
|
||||
3. Import the pack: `https://pack.${BASE_DOMAIN}/pack.toml`.
|
||||
3. The launcher installs the modpack from the distribution automatically.
|
||||
4. Connect to **`${BASE_DOMAIN}`** (Minecraft, `:25565`).
|
||||
|
||||
## Layout
|
||||
@@ -147,11 +220,11 @@ caddy/Caddyfile + conf.d/*.caddy # ingress vhost snippets
|
||||
drasl/config/config.toml.tmpl # auth config (rendered)
|
||||
nmsr/config.toml.tmpl # avatar renderer config, Drasl-backed (rendered)
|
||||
docker/nmsr/ # built-from-source NMSR image
|
||||
pack/ # packwiz modpack source
|
||||
landing/ # Astro site → www/
|
||||
launcher/ # FjordLauncher assets (gitignored)
|
||||
tooling/ # render-config, render-pack, render-nginx,
|
||||
# issue-letsencrypt, fetch-launcher, add-custom-mod
|
||||
server-mods/ # filtered server mod jars (synced, gitignored)
|
||||
tooling/ # render-config, sync-server-mods, render-nginx,
|
||||
# issue-letsencrypt, fetch-launcher
|
||||
nginx/ulicraft-caddy.conf.tmpl # host-nginx TLS vhost template (front of caddy)
|
||||
plan/ # design docs
|
||||
```
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Base Caddy config. Vhosts live in conf.d/*.caddy snippets:
|
||||
# conf.d/00-core.caddy auth + packwiz
|
||||
# conf.d/00-core.caddy auth
|
||||
# conf.d/10-static.caddy apex landing + launcher downloads
|
||||
# conf.d/30-distribution.caddy static site from another repo
|
||||
# conf.d/40-avatar.caddy nmsr skin/avatar renderer
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
# Core ingress — always mounted. Drasl auth + packwiz pack metadata.
|
||||
# Core ingress — always mounted. Drasl auth.
|
||||
http://auth.{$BASE_DOMAIN} {
|
||||
reverse_proxy drasl:25585
|
||||
}
|
||||
|
||||
http://pack.{$BASE_DOMAIN} {
|
||||
root * /srv/pack
|
||||
file_server browse
|
||||
}
|
||||
|
||||
@@ -6,6 +6,13 @@ http://{$BASE_DOMAIN} {
|
||||
root * /srv/launcher
|
||||
file_server browse
|
||||
}
|
||||
# Self-hosted Minecraft status JSON (mc-status service). Same-origin, so the
|
||||
# landing's ServerCard fetch needs no CORS. The <addr> in the path is
|
||||
# ignored by mc-status — it only ever pings its configured MC_STATUS_TARGET.
|
||||
handle /api/mcstatus/* {
|
||||
uri strip_prefix /api/mcstatus
|
||||
reverse_proxy mc-status:8080
|
||||
}
|
||||
handle {
|
||||
root * /srv/www
|
||||
file_server
|
||||
|
||||
@@ -3,5 +3,8 @@
|
||||
# /srv/distribution by the caddy service in docker-compose.yml.
|
||||
http://distribution.{$BASE_DOMAIN} {
|
||||
root * /srv/distribution
|
||||
# The landing page (apex origin) fetches launcher.json cross-origin to render
|
||||
# the download buttons. Allow it — launcher assets are fully public, no creds.
|
||||
header /launcher/* Access-Control-Allow-Origin "*"
|
||||
file_server browse
|
||||
}
|
||||
|
||||
10
caddy/conf.d/60-files.caddy
Normal file
10
caddy/conf.d/60-files.caddy
Normal file
@@ -0,0 +1,10 @@
|
||||
# Files — Filestash player file share (filestash service). Shared login,
|
||||
# writable. See plan/20-files.md.
|
||||
#
|
||||
# Filestash blocks any request whose Host header doesn't match its configured
|
||||
# `general.host` (https://files.${BASE_DOMAIN}), with "only traffic from X is
|
||||
# allowed". The host nginx forwards the original Host, and caddy passes it
|
||||
# through untouched — do NOT rewrite it here or every request 403s.
|
||||
http://files.{$BASE_DOMAIN} {
|
||||
reverse_proxy filestash:8334
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,9 +3,9 @@
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Single, unified compose. One file holds the whole stack:
|
||||
# drasl auth + skins (Yggdrasil), internal only
|
||||
# minecraft the server (itzg/NEOFORGE), installs mods via packwiz
|
||||
# minecraft the server (itzg/NEOFORGE), mods from ./server-mods volume
|
||||
# mc-backup world backups every 6h via RCON
|
||||
# caddy internal ingress — routes auth./pack./apex/launcher/avatar.
|
||||
# caddy internal ingress — routes auth./apex/launcher/avatar.
|
||||
# /distribution. by Host header (conf.d/*.caddy)
|
||||
# nmsr skin/avatar renderer at avatar.${BASE_DOMAIN}
|
||||
# uptime-kuma status monitoring at status.${BASE_DOMAIN}
|
||||
@@ -16,7 +16,7 @@
|
||||
# Production TLS terminates at the HOST's nginx in front of caddy
|
||||
# (nginx/ulicraft-caddy.conf.tmpl + Let's Encrypt); caddy is published on a
|
||||
# localhost-only port (CADDY_HTTP_BIND/CADDY_HTTP_PORT in .env). Containers reach
|
||||
# the stack's own names internally via caddy's mcnet aliases (auth./pack.).
|
||||
# the stack's own names internally via caddy's mcnet alias (auth.).
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
|
||||
services:
|
||||
@@ -48,7 +48,9 @@ services:
|
||||
TYPE: "NEOFORGE"
|
||||
VERSION: "1.21.1"
|
||||
# Verify NEOFORGE_VERSION against projects.neoforged.net before deploy.
|
||||
NEOFORGE_VERSION: "21.1.209" # pin a known-good build; update deliberately
|
||||
# Must match the distribution's NeoForge (distribution.json → 21.1.233);
|
||||
# several mods (ferritecore≥218, farmersdelight≥219, configured≥211) need it.
|
||||
NEOFORGE_VERSION: "21.1.233" # pin a known-good build; update deliberately
|
||||
|
||||
# ── Memory / performance ────────────────────────────────────
|
||||
INIT_MEMORY: "4G"
|
||||
@@ -56,23 +58,37 @@ services:
|
||||
USE_AIKAR_FLAGS: "TRUE"
|
||||
JVM_XX_OPTS: "-XX:+UseG1GC"
|
||||
|
||||
# ── Auth: offline mode + authlib-injector pointing at Drasl ─
|
||||
ONLINE_MODE: "FALSE"
|
||||
# ── Auth: ONLINE mode validated against Drasl via authlib-injector ─
|
||||
# MUST be true: authlib-injector redirects the normal online-auth
|
||||
# handshake from Mojang to Drasl. online-mode=false skips the handshake
|
||||
# entirely -> offline UUIDs, no session validation, NO SKINS for anyone.
|
||||
# Secure-profile is the part that must stay off on 1.21+ (see
|
||||
# ENFORCE_SECURE_PROFILE + Drasl SignPublicKeys=false), NOT online-mode.
|
||||
ONLINE_MODE: "TRUE"
|
||||
# Resolves over mcnet to caddy (alias auth.${BASE_DOMAIN}) -> drasl.
|
||||
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=http://auth.${BASE_DOMAIN}/authlib-injector"
|
||||
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=https://auth.${BASE_DOMAIN}/authlib-injector"
|
||||
|
||||
# ── Server config ───────────────────────────────────────────
|
||||
DIFFICULTY: "normal"
|
||||
MODE: "survival"
|
||||
MOTD: "Uli LAN party"
|
||||
# Server-list icon (auto-scaled to 64x64). PNG lives in ./runtime (mounted
|
||||
# /extras); OVERRIDE_ICON re-applies it on every boot. Also surfaces in the
|
||||
# landing ServerCard via the SLP favicon.
|
||||
ICON: "/extras/server-icon.png"
|
||||
OVERRIDE_ICON: "TRUE"
|
||||
MAX_PLAYERS: "10"
|
||||
VIEW_DISTANCE: "10"
|
||||
SIMULATION_DISTANCE: "8"
|
||||
ENFORCE_SECURE_PROFILE: "FALSE" # required for authlib-injector on 1.21+
|
||||
ALLOW_FLIGHT: "TRUE" # many tech mods need this
|
||||
|
||||
# ── Mod source: packwiz pack served by caddy (alias pack.) ──
|
||||
PACKWIZ_URL: "http://pack.${BASE_DOMAIN}/pack.toml"
|
||||
# ── Mod source: filtered subset of the distribution modset ──
|
||||
# Jars are synced into ./server-mods by tooling/sync-server-mods.sh
|
||||
# (both/server entries from mods-sides.json) and mounted at /mods below;
|
||||
# itzg auto-syncs /mods → /data/mods. REMOVE_OLD_MODS makes that mirror
|
||||
# authoritative so removed mods get pruned. See plan/04-mods.md.
|
||||
REMOVE_OLD_MODS: "TRUE"
|
||||
|
||||
# ── RCON for remote admin ───────────────────────────────────
|
||||
ENABLE_RCON: "TRUE"
|
||||
@@ -82,10 +98,26 @@ services:
|
||||
TZ: "Europe/Madrid"
|
||||
volumes:
|
||||
- mc_data:/data
|
||||
- ./runtime:/extras:ro # authlib-injector.jar lives here
|
||||
- ./runtime:/extras:ro # authlib-injector.jar lives here
|
||||
- ./server-mods:/mods:ro # itzg auto-syncs /mods → /data/mods
|
||||
# Distribution client-override files the server also needs, from the live
|
||||
# distribution (DISTRIBUTION_WEB_ROOT), same source as caddy. Only the kubejs
|
||||
# *source* subdirs are bind-mounted ro — kubejs itself writes config/, cache/,
|
||||
# generated/, logs/ at boot, so the kubejs ROOT must stay writable (lives in
|
||||
# mc_data). Mounting the whole kubejs dir ro makes BaseProperties.save() NPE
|
||||
# and kubejs never initialises. CustomSkinLoader is ro (read-only json).
|
||||
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}/servers/ulicraft-1.21.1/files/kubejs/server_scripts:/data/kubejs/server_scripts:ro
|
||||
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/startup_scripts:/data/kubejs/startup_scripts:ro
|
||||
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/data:/data/kubejs/data:ro
|
||||
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/assets:/data/kubejs/assets:ro
|
||||
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/CustomSkinLoader:/data/CustomSkinLoader:ro
|
||||
depends_on:
|
||||
- drasl
|
||||
- caddy # pack./auth. must resolve at boot
|
||||
- drasl # authlib (auth.) must resolve at boot
|
||||
# Containers don't inherit the host's /etc/hosts; the LAN resolver returns a
|
||||
# dead address for the public names. Pin auth. to the host so HTTPS to it
|
||||
# lands on the host's nginx (valid LE cert) -> caddy -> drasl.
|
||||
extra_hosts:
|
||||
- "auth.${BASE_DOMAIN}:host-gateway"
|
||||
networks:
|
||||
- mcnet
|
||||
|
||||
@@ -108,7 +140,7 @@ services:
|
||||
- mcnet
|
||||
|
||||
# Internal ingress. Routes every vhost by Host header (conf.d/*.caddy):
|
||||
# auth. -> drasl, pack. -> packwiz files, apex -> landing + /launcher,
|
||||
# auth. -> drasl, apex -> landing + /launcher,
|
||||
# avatar. -> nmsr, status. -> uptime-kuma, distribution. -> static site.
|
||||
# Published on a localhost-only port; the host's nginx terminates TLS in
|
||||
# front of it (nginx/ulicraft-caddy.conf.tmpl).
|
||||
@@ -127,12 +159,9 @@ services:
|
||||
- ./caddy/conf.d/30-distribution.caddy:/etc/caddy/conf.d/30-distribution.caddy:ro
|
||||
- ./caddy/conf.d/40-avatar.caddy:/etc/caddy/conf.d/40-avatar.caddy:ro
|
||||
- ./caddy/conf.d/50-status.caddy:/etc/caddy/conf.d/50-status.caddy:ro
|
||||
- ./caddy/conf.d/60-files.caddy:/etc/caddy/conf.d/60-files.caddy:ro
|
||||
- ./www:/srv/www:ro
|
||||
- ./launcher:/srv/launcher:ro
|
||||
- ./pack:/srv/pack:ro
|
||||
# Custom (locally-hosted) mod jars live OUTSIDE ./pack so packwiz refresh
|
||||
# doesn't index them as direct files. Served at pack.${BASE_DOMAIN}/custom/.
|
||||
- ./custom:/srv/pack/custom:ro
|
||||
# Static site from ANOTHER repo (absolute host path in .env).
|
||||
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}:/srv/distribution:ro
|
||||
networks:
|
||||
@@ -140,7 +169,6 @@ services:
|
||||
# Containers resolve the stack's own public names to caddy internally.
|
||||
aliases:
|
||||
- "auth.${BASE_DOMAIN}"
|
||||
- "pack.${BASE_DOMAIN}"
|
||||
|
||||
# Avatar/skin renderer — built from source (docker/nmsr/Dockerfile), pulls
|
||||
# player profiles from Drasl over mcnet. caddy proxies avatar. -> nmsr:8080.
|
||||
@@ -152,6 +180,69 @@ services:
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./nmsr/config.toml:/nmsr/config.toml:ro
|
||||
# Drasl embeds absolute HTTPS skin URLs (BaseURL is https) in the profile.
|
||||
# The mcnet alias resolves auth. -> caddy (http :80 only), so NMSR's https
|
||||
# skin fetch to auth.:443 finds no listener and every avatar falls back to
|
||||
# the default skin. Pin auth. to the host (same as the minecraft service) so
|
||||
# NMSR reaches the host nginx on :443 with the real LE cert.
|
||||
extra_hosts:
|
||||
- "auth.${BASE_DOMAIN}:host-gateway"
|
||||
networks:
|
||||
- mcnet
|
||||
|
||||
# Self-hosted SLP→JSON pinger (built from docker/mc-status, mcutil wrapper).
|
||||
# Emits mcstatus.io v2 JSON so the landing's ServerCard reads it unchanged.
|
||||
# No published port — caddy proxies apex /api/mcstatus/* -> mc-status:8080.
|
||||
# Pings the minecraft service internally over mcnet; result cached 30s.
|
||||
mc-status:
|
||||
build:
|
||||
context: ./docker/mc-status
|
||||
image: ulicraft/mc-status:local
|
||||
container_name: mc-status
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MC_STATUS_TARGET: "minecraft:25565"
|
||||
MC_STATUS_CACHE_TTL: "30s"
|
||||
# Registered-players roster (/api/mcstatus/players). mc-status logs into
|
||||
# the Drasl admin API server-side and exposes a sanitized [{uuid,name}]
|
||||
# list; these creds NEVER reach the browser. Use a dedicated, rotatable
|
||||
# admin account. Isolated from the status pinger (own TTL/client).
|
||||
DRASL_API_URL: "http://drasl:25585/drasl/api/v2"
|
||||
DRASL_ADMIN_USERNAME: ${DRASL_ADMIN_USERNAME}
|
||||
DRASL_ADMIN_PASSWORD: ${DRASL_ADMIN_PASSWORD}
|
||||
MC_STATUS_ROSTER_TTL: "60s"
|
||||
networks:
|
||||
- mcnet
|
||||
|
||||
# Player file share (screenshots, schematics, maps) at files.${BASE_DOMAIN}.
|
||||
# ONE shared login for everybody (FILES_AUTH=htpasswd), writable. See
|
||||
# plan/20-files.md — it documents every non-obvious bit of this service.
|
||||
#
|
||||
# Config is rendered read-only from filestash/config.json.tmpl: filestash has
|
||||
# no env-var config, and its admin console writes config.json back at runtime,
|
||||
# so pinning it :ro is what keeps git authoritative. The console still loads —
|
||||
# it just cannot save. That is intentional, not a bug.
|
||||
filestash:
|
||||
image: machines/filestash@sha256:8cb09d42470328a02aec6e3861f912addf8a9d54ad63d9c965bcb48df5ad36b1
|
||||
container_name: filestash
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# The `local` storage backend is admin-gated in filestash: it refuses to
|
||||
# init unless the connection params carry this secret (or the admin
|
||||
# password). config.json's attribute_mapping supplies it server-side, so
|
||||
# players never see it. Without this, every login dies on "Not Allowed".
|
||||
LOCAL_BACKEND_SECRET: ${FILES_LOCAL_SECRET:?FILES_LOCAL_SECRET unset in .env}
|
||||
# Do NOT set APPLICATION_URL / ADMIN_PASSWORD here: either one makes
|
||||
# filestash rewrite config.json at boot, which fails on the :ro mount.
|
||||
# Both live in the rendered config instead (general.host / auth.admin).
|
||||
volumes:
|
||||
# /app/data/state holds config + sqlite + search index + logs, so the
|
||||
# DIRECTORY must stay writable. Only config.json is pinned read-only.
|
||||
- filestash_state:/app/data/state
|
||||
- ./filestash/config.json:/app/data/state/config/config.json:ro
|
||||
# The share itself. FILES_MOUNT_MODE is the literal mount flag (ro|rw) —
|
||||
# `ro` enforces a read-only share at the KERNEL, whatever the UI thinks.
|
||||
- ${FILES_DATA_DIR:?FILES_DATA_DIR unset in .env}:/data/files:${FILES_MOUNT_MODE:-ro}
|
||||
networks:
|
||||
- mcnet
|
||||
|
||||
@@ -170,6 +261,7 @@ volumes:
|
||||
drasl_state:
|
||||
mc_data:
|
||||
kuma_data:
|
||||
filestash_state:
|
||||
|
||||
networks:
|
||||
mcnet:
|
||||
|
||||
15
docker/mc-status/Dockerfile
Normal file
15
docker/mc-status/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
# mc-status — self-hosted SLP→JSON pinger (mcutil wrapper). Multi-stage:
|
||||
# build a static binary, ship it on scratch.
|
||||
FROM golang:1.25-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/mc-status .
|
||||
|
||||
FROM scratch
|
||||
# CA roots so SRV/DNS over the resolver and any TLS lookups work.
|
||||
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
COPY --from=build /out/mc-status /mc-status
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/mc-status"]
|
||||
5
docker/mc-status/go.mod
Normal file
5
docker/mc-status/go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module ulicraft/mc-status
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/mcstatus-io/mcutil/v4 v4.0.1
|
||||
2
docker/mc-status/go.sum
Normal file
2
docker/mc-status/go.sum
Normal file
@@ -0,0 +1,2 @@
|
||||
github.com/mcstatus-io/mcutil/v4 v4.0.1 h1:/AQkHrz7irCU7USGnrH3kneQw80aDQOVdOWc8xu/NUY=
|
||||
github.com/mcstatus-io/mcutil/v4 v4.0.1/go.mod h1:yC91WInI1U2GAMFWgpPgsAULPVS2o+4JCZbiiWhHwxM=
|
||||
397
docker/mc-status/main.go
Normal file
397
docker/mc-status/main.go
Normal file
@@ -0,0 +1,397 @@
|
||||
// mc-status — a tiny self-hosted Minecraft Server List Ping → JSON service.
|
||||
//
|
||||
// It wraps github.com/mcstatus-io/mcutil (the same library that powers
|
||||
// api.mcstatus.io) and re-emits the result in mcstatus.io's *v2* JSON shape, so
|
||||
// the landing's ServerCard.astro can talk to it with zero changes — just point
|
||||
// `statusApi` at this service instead of the public API.
|
||||
//
|
||||
// Hardening notes:
|
||||
// - The address in the request path is IGNORED. We only ever ping the single
|
||||
// allow-listed MC_STATUS_TARGET. This is deliberate: a path-controlled
|
||||
// pinger would be an open SSRF relay. The path segment is kept only so the
|
||||
// URL stays drop-in compatible with api.mcstatus.io/v2/status/java/<addr>.
|
||||
// - Results are cached in-memory for MC_STATUS_CACHE_TTL so a busy landing
|
||||
// page can't hammer the Minecraft server with a ping per visitor.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mcstatus-io/mcutil/v4/status"
|
||||
)
|
||||
|
||||
// --- mcstatus.io v2 response shape (only the fields ServerCard reads) ---
|
||||
|
||||
type v2Version struct {
|
||||
NameRaw string `json:"name_raw"`
|
||||
NameClean string `json:"name_clean"`
|
||||
NameHTML string `json:"name_html"`
|
||||
Protocol int64 `json:"protocol"`
|
||||
}
|
||||
|
||||
type v2Player struct {
|
||||
UUID string `json:"uuid"`
|
||||
NameRaw string `json:"name_raw"`
|
||||
NameClean string `json:"name_clean"`
|
||||
NameHTML string `json:"name_html"`
|
||||
}
|
||||
|
||||
type v2Players struct {
|
||||
Online int64 `json:"online"`
|
||||
Max int64 `json:"max"`
|
||||
List []v2Player `json:"list"`
|
||||
}
|
||||
|
||||
type v2MOTD struct {
|
||||
Raw string `json:"raw"`
|
||||
Clean string `json:"clean"`
|
||||
HTML string `json:"html"`
|
||||
}
|
||||
|
||||
type v2Response struct {
|
||||
Online bool `json:"online"`
|
||||
Host string `json:"host"`
|
||||
Port uint16 `json:"port"`
|
||||
Version *v2Version `json:"version,omitempty"`
|
||||
Players *v2Players `json:"players,omitempty"`
|
||||
MOTD *v2MOTD `json:"motd,omitempty"`
|
||||
Icon *string `json:"icon"`
|
||||
}
|
||||
|
||||
// --- tiny TTL cache (single target → single entry) ---
|
||||
|
||||
type cache struct {
|
||||
mu sync.Mutex
|
||||
payload []byte
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
func deref[T any](p *T) (v T) {
|
||||
if p != nil {
|
||||
v = *p
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// --- registered-players roster (Drasl admin) ---
|
||||
//
|
||||
// The roster path is DELIBERATELY isolated from the status path: its own
|
||||
// http.Client (with a timeout), its own TTL cache, and its own cached admin
|
||||
// token. Drasl being slow or down must never block or break /v2/status — on any
|
||||
// failure we serve stale-or-empty JSON rather than hanging. Mirrors the status
|
||||
// cache style above (single entry, mutex-guarded).
|
||||
|
||||
// rosterPlayer is the sanitized shape we expose publicly: uuid + name only,
|
||||
// everything else from Drasl stripped (no emails, capes, admin flags, etc.).
|
||||
type rosterPlayer struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// draslPlayer is the subset of Drasl's GET /players entries we read.
|
||||
type draslPlayer struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// roster holds the cached public payload plus the cached admin token. Both are
|
||||
// guarded by the same mutex; token reuse avoids a login per request.
|
||||
type roster struct {
|
||||
mu sync.Mutex
|
||||
payload []byte
|
||||
expires time.Time
|
||||
|
||||
token string
|
||||
|
||||
apiURL string
|
||||
username string
|
||||
password string
|
||||
ttl time.Duration
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// payloadOrEmpty returns the cached payload, or an empty JSON array if nothing
|
||||
// has ever been fetched. Used so a Drasl failure on the very first request
|
||||
// still yields valid JSON ("[]") instead of an error.
|
||||
func (rs *roster) payloadOrEmpty() []byte {
|
||||
if rs.payload != nil {
|
||||
return rs.payload
|
||||
}
|
||||
return []byte("[]")
|
||||
}
|
||||
|
||||
// fetch logs in (if no token) and lists players, re-logging in once on a 401.
|
||||
// On any error it returns the previous payload (stale-or-empty), never an error
|
||||
// to the caller — the handler always serves something.
|
||||
func (rs *roster) fetch() []byte {
|
||||
players, err := rs.listPlayers(false)
|
||||
if err != nil {
|
||||
log.Printf("roster: list players failed: %v", err)
|
||||
return rs.payloadOrEmpty()
|
||||
}
|
||||
|
||||
out := make([]rosterPlayer, 0, len(players))
|
||||
for _, p := range players {
|
||||
out = append(out, rosterPlayer{UUID: p.UUID, Name: p.Name})
|
||||
}
|
||||
b, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return rs.payloadOrEmpty()
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// listPlayers performs GET {apiURL}/players with the cached token, logging in
|
||||
// first if needed. On 401 it clears the token and retries once (retried=true
|
||||
// prevents a loop). Caller holds rs.mu.
|
||||
func (rs *roster) listPlayers(retried bool) ([]draslPlayer, error) {
|
||||
if rs.token == "" {
|
||||
if err := rs.login(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, rs.apiURL+"/players", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+rs.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := rs.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized && !retried {
|
||||
// Token expired/invalid — drop it and re-login once.
|
||||
rs.token = ""
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return rs.listPlayers(true)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return nil, &httpError{status: resp.StatusCode, op: "list players"}
|
||||
}
|
||||
|
||||
var players []draslPlayer
|
||||
if err := json.NewDecoder(resp.Body).Decode(&players); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return players, nil
|
||||
}
|
||||
|
||||
// login posts admin creds to {apiURL}/login and caches the returned apiToken.
|
||||
// Caller holds rs.mu.
|
||||
func (rs *roster) login() error {
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"username": rs.username,
|
||||
"password": rs.password,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, rs.apiURL+"/login", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := rs.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return &httpError{status: resp.StatusCode, op: "login"}
|
||||
}
|
||||
|
||||
var out struct {
|
||||
APIToken string `json:"apiToken"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return err
|
||||
}
|
||||
if out.APIToken == "" {
|
||||
return &httpError{status: resp.StatusCode, op: "login (no apiToken)"}
|
||||
}
|
||||
rs.token = out.APIToken
|
||||
return nil
|
||||
}
|
||||
|
||||
type httpError struct {
|
||||
status int
|
||||
op string
|
||||
}
|
||||
|
||||
func (e *httpError) Error() string {
|
||||
return "drasl " + e.op + ": status " + strconv.Itoa(e.status)
|
||||
}
|
||||
|
||||
func main() {
|
||||
target := envOr("MC_STATUS_TARGET", "minecraft:25565")
|
||||
listen := envOr("MC_STATUS_LISTEN", ":8080")
|
||||
ttl, err := time.ParseDuration(envOr("MC_STATUS_CACHE_TTL", "30s"))
|
||||
if err != nil {
|
||||
log.Fatalf("invalid MC_STATUS_CACHE_TTL: %v", err)
|
||||
}
|
||||
|
||||
rosterTTL, err := time.ParseDuration(envOr("MC_STATUS_ROSTER_TTL", "60s"))
|
||||
if err != nil {
|
||||
log.Fatalf("invalid MC_STATUS_ROSTER_TTL: %v", err)
|
||||
}
|
||||
|
||||
host, port := splitHostPort(target)
|
||||
c := &cache{}
|
||||
|
||||
// Registered-players roster, fed by the Drasl admin API. Isolated from the
|
||||
// status pinger above (own client + timeout + cache). If the admin creds are
|
||||
// unset, /players still works — it just always serves "[]".
|
||||
rs := &roster{
|
||||
apiURL: strings.TrimRight(envOr("DRASL_API_URL", "http://drasl:25585/drasl/api/v2"), "/"),
|
||||
username: os.Getenv("DRASL_ADMIN_USERNAME"),
|
||||
password: os.Getenv("DRASL_ADMIN_PASSWORD"),
|
||||
ttl: rosterTTL,
|
||||
client: &http.Client{Timeout: 5 * time.Second},
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Drop-in path: /v2/status/java/<addr> — <addr> is ignored (see file header).
|
||||
mux.HandleFunc("/v2/status/java/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
c.mu.Lock()
|
||||
fresh := c.payload != nil && time.Now().Before(c.expires)
|
||||
if fresh {
|
||||
payload := c.payload
|
||||
c.mu.Unlock()
|
||||
w.Write(payload)
|
||||
return
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
payload := buildPayload(host, port)
|
||||
|
||||
c.mu.Lock()
|
||||
c.payload = payload
|
||||
c.expires = time.Now().Add(ttl)
|
||||
c.mu.Unlock()
|
||||
|
||||
w.Write(payload)
|
||||
})
|
||||
|
||||
// Registered-players roster — public path /api/mcstatus/players (caddy
|
||||
// strips the /api/mcstatus prefix → internal /players). Serves a sanitized
|
||||
// [{uuid,name}] of all Drasl players. Same CORS:* as the status route.
|
||||
// Isolated from the status path: own TTL cache, own token, own client; on
|
||||
// any Drasl failure it serves stale-or-empty rather than hanging.
|
||||
mux.HandleFunc("/players", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
rs.mu.Lock()
|
||||
defer rs.mu.Unlock()
|
||||
|
||||
if rs.payload != nil && time.Now().Before(rs.expires) {
|
||||
w.Write(rs.payload)
|
||||
return
|
||||
}
|
||||
|
||||
payload := rs.fetch()
|
||||
rs.payload = payload
|
||||
rs.expires = time.Now().Add(rs.ttl)
|
||||
w.Write(payload)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
log.Printf("mc-status listening on %s, target=%s:%d, ttl=%s, roster-ttl=%s", listen, host, port, ttl, rosterTTL)
|
||||
log.Fatal(http.ListenAndServe(listen, mux))
|
||||
}
|
||||
|
||||
// buildPayload pings the target and marshals the v2 response. On any ping
|
||||
// failure it returns a minimal {"online":false} so the frontend can show an
|
||||
// offline state without erroring.
|
||||
func buildPayload(host string, port uint16) []byte {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := status.Modern(ctx, host, port)
|
||||
if err != nil {
|
||||
b, _ := json.Marshal(v2Response{Online: false, Host: host, Port: port})
|
||||
return b
|
||||
}
|
||||
|
||||
out := v2Response{
|
||||
Online: true,
|
||||
Host: host,
|
||||
Port: port,
|
||||
Icon: resp.Favicon,
|
||||
Version: &v2Version{
|
||||
NameRaw: resp.Version.Name.Raw,
|
||||
NameClean: resp.Version.Name.Clean,
|
||||
NameHTML: resp.Version.Name.HTML,
|
||||
Protocol: resp.Version.Protocol,
|
||||
},
|
||||
MOTD: &v2MOTD{
|
||||
Raw: resp.MOTD.Raw,
|
||||
Clean: resp.MOTD.Clean,
|
||||
HTML: resp.MOTD.HTML,
|
||||
},
|
||||
Players: &v2Players{
|
||||
Online: deref(resp.Players.Online),
|
||||
Max: deref(resp.Players.Max),
|
||||
List: make([]v2Player, 0, len(resp.Players.Sample)),
|
||||
},
|
||||
}
|
||||
|
||||
for _, p := range resp.Players.Sample {
|
||||
out.Players.List = append(out.Players.List, v2Player{
|
||||
UUID: p.ID,
|
||||
NameRaw: p.Name.Raw,
|
||||
NameClean: p.Name.Clean,
|
||||
NameHTML: p.Name.HTML,
|
||||
})
|
||||
}
|
||||
|
||||
b, _ := json.Marshal(out)
|
||||
return b
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func splitHostPort(target string) (string, uint16) {
|
||||
host, portStr, found := strings.Cut(target, ":")
|
||||
if !found {
|
||||
return target, 25565
|
||||
}
|
||||
p, err := strconv.ParseUint(portStr, 10, 16)
|
||||
if err != nil {
|
||||
return host, 25565
|
||||
}
|
||||
return host, uint16(p)
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
# Drasl config — password-login mode (NO Keycloak/OIDC for now).
|
||||
# TOML only (drasl has no env support). Rendered by tooling/render-config.sh
|
||||
# -> drasl/config/config.toml (gitignored). Only ${BASE_DOMAIN} is substituted.
|
||||
# -> drasl/config/config.toml (gitignored). Substitutes ${BASE_DOMAIN} and
|
||||
# ${REGISTRATION_REQUIRE_INVITE} (derived from REGISTRATION_MODE in .env).
|
||||
# Reached only via Caddy at auth.${BASE_DOMAIN}; drasl has no published host port.
|
||||
|
||||
BaseURL = "http://auth.${BASE_DOMAIN}"
|
||||
# Public scheme is HTTPS — the host nginx terminates TLS in front of caddy.
|
||||
# Drasl builds absolute URLs (textures, authlib-injector API location) from this;
|
||||
# an http:// value triggers mixed-content + broken login on the https origin.
|
||||
BaseURL = "https://auth.${BASE_DOMAIN}"
|
||||
Domain = "auth.${BASE_DOMAIN}"
|
||||
ListenAddress = "0.0.0.0:25585" # internal; Caddy reverse-proxies to it
|
||||
DefaultAdminUsernames = ["admin"]
|
||||
DefaultAdmins = ["admin"]
|
||||
|
||||
# Password login: admin + guests register a username/password on the web UI.
|
||||
AllowPasswordLogin = true
|
||||
@@ -18,5 +22,29 @@ SignPublicKeys = false
|
||||
# Free-text owner label shown in the web UI (a string, not a table).
|
||||
ApplicationOwner = "Ulicraft"
|
||||
|
||||
# CORS: the landing register/account form (served from the apex) calls the
|
||||
# Drasl API from the browser. Without this the API has NO CORS headers and the
|
||||
# browser blocks every cross-origin call. Scope to the apex only — never "*",
|
||||
# which would let any site script the auth API. Echo backfills AllowMethods
|
||||
# (incl. PATCH/DELETE) and reflects requested headers (Content-Type/Authorization).
|
||||
# MUST stay top-level (before any [Section]) — under a table it parses as
|
||||
# <table>.CORSAllowOrigins and drasl ignores it (silent: no CORS headers).
|
||||
CORSAllowOrigins = ["https://${BASE_DOMAIN}"]
|
||||
|
||||
# New-account registration (username+password). RequireInvite is derived from
|
||||
# REGISTRATION_MODE in .env by render-config.sh: invite -> true, open -> false.
|
||||
# When true, non-admin callers (web UI + landing register form) must supply a
|
||||
# valid invite code; admins bypass. Mint codes via POST /drasl/api/v2/invites
|
||||
# with an admin api token.
|
||||
[RegistrationNewPlayer]
|
||||
Allow = true
|
||||
RequireInvite = ${REGISTRATION_REQUIRE_INVITE}
|
||||
|
||||
# Rate limit the now public-facing API (anonymous POST /users etc). Token
|
||||
# bucket; admins are exempt. Conservative for a 4-10 player server.
|
||||
[RateLimit]
|
||||
RequestsPerSecond = 5
|
||||
Burst = 10
|
||||
|
||||
# OIDC block intentionally omitted for now (no Keycloak).
|
||||
# [[RegistrationOIDC]] <- future task
|
||||
|
||||
31
filestash/config.json.tmpl
Normal file
31
filestash/config.json.tmpl
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"general": {
|
||||
"name": "Ulicraft Files",
|
||||
"host": "files.${BASE_DOMAIN}",
|
||||
"force_ssl": true,
|
||||
"secret_key": "${FILES_SECRET_KEY}",
|
||||
"editor": "base",
|
||||
"display_hidden": false,
|
||||
"upload_button": true,
|
||||
"fork_button": false
|
||||
},
|
||||
"auth": {
|
||||
"admin": "${FILES_ADMIN_PASSWORD_HASH}"
|
||||
},
|
||||
"middleware": {
|
||||
"identity_provider": {
|
||||
"type": "${FILES_IDP_TYPE}",
|
||||
"params": "${FILES_IDP_PARAMS}"
|
||||
},
|
||||
"attribute_mapping": {
|
||||
"related_backend": "files",
|
||||
"params": "{\"files\":{\"type\":\"local\",\"path\":\"/data/files/\",\"password\":\"${FILES_LOCAL_SECRET}\"}}"
|
||||
}
|
||||
},
|
||||
"connections": [
|
||||
{
|
||||
"label": "files",
|
||||
"type": "local"
|
||||
}
|
||||
]
|
||||
}
|
||||
5
landing/pnpm-workspace.yaml
Normal file
5
landing/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
packages:
|
||||
- '.'
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- sharp
|
||||
BIN
landing/public/Goat_JE1_BE1.webp
Normal file
BIN
landing/public/Goat_JE1_BE1.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
landing/public/favicon.png
Normal file
BIN
landing/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
BIN
landing/public/fonts/BlockBlueprint.ttf
Normal file
BIN
landing/public/fonts/BlockBlueprint.ttf
Normal file
Binary file not shown.
@@ -268,3 +268,13 @@
|
||||
src: url(/fonts/pxiKyp0ihIEF2isfFJU.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* Block Blueprint — vendored from 1001fonts (NOT on Google Fonts; appended by
|
||||
fetch-fonts.sh after the Google rewrite). License: 1001Fonts Free For Personal
|
||||
Use. Single weight, TTF (no woff2 toolchain on the build box). */
|
||||
@font-face {
|
||||
font-family: 'Block Blueprint';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/BlockBlueprint.ttf) format('truetype');
|
||||
}
|
||||
|
||||
52
landing/public/ulicraft-logo-mini.svg
Normal file
52
landing/public/ulicraft-logo-mini.svg
Normal file
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 9.677 9.667"
|
||||
version="1.1"
|
||||
id="svg3"
|
||||
sodipodi:docname="mojang.svg"
|
||||
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs3" />
|
||||
<sodipodi:namedview
|
||||
id="namedview3"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="2.0467427"
|
||||
inkscape:cx="-92.830429"
|
||||
inkscape:cy="96.006206"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="992"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="g3" />
|
||||
<path
|
||||
d="M1.54 2.844c.314-.76 1.31-.46 1.954-.528.785-.083 1.503.272 2.1.758l.164-.9c.327.345.587.756.964 1.052.28.254.655-.342.86-.013.42.864.408 1.86.54 2.795l-.788-.373C6.9 4.17 5.126 3.052 3.656 3.685c-1.294.592-1.156 2.65.06 3.255 1.354.703 2.953.51 4.405.292-.07.42-.34.87-.834.816l-4.95.002c-.5.055-.886-.413-.838-.89l.04-4.315z"
|
||||
fill="#fff"
|
||||
id="path3" />
|
||||
<g
|
||||
id="g3"
|
||||
transform="matrix(0.33310606,0,0,0.33310606,8.7658015,-1.332)">
|
||||
<circle
|
||||
style="fill:#283c63;fill-opacity:1;stroke:none;stroke-width:4.73305"
|
||||
id="path1-9"
|
||||
r="14.345329"
|
||||
cy="18.66217"
|
||||
cx="-11.946259" />
|
||||
<path
|
||||
id="logo"
|
||||
d="m -16.05726,28.400159 c -0.04546,-0.07393 -0.04146,-0.951547 0.009,-1.949545 l 0.09191,-1.81518 -0.526472,-0.542457 c -0.672825,-0.692806 -1.103893,-0.809189 -1.679816,-0.453545 -0.538461,0.333165 -0.830668,0.335663 -1.972023,0.01948 -0.862137,-0.23926 -1.268729,-0.459538 -1.120876,-0.607391 0.03746,-0.03797 0.539459,-0.211288 1.113883,-0.385613 1.338658,-0.405094 1.827167,-0.597401 1.740754,-0.684314 -0.03847,-0.03746 -0.615382,0.08842 -1.283213,0.280718 -1.289707,0.370628 -1.760235,0.354645 -1.986009,-0.06693 -0.109385,-0.204296 -0.07793,-0.311188 0.153347,-0.526972 0.159839,-0.148851 0.329669,-0.270729 0.376622,-0.270729 0.04745,0 0.407591,-0.182817 0.801196,-0.406593 l 0.714784,-0.406592 -0.627871,0.07393 c -0.554444,0.06494 -0.62787,0.04396 -0.62787,-0.177322 0,-0.497002 0.182316,-0.679319 1.807687,-1.811185 2.161834,-1.50599 2.788706,-2.085909 3.82167,-3.534457 0.472526,-0.662836 1.121376,-1.3966 1.441555,-1.630866 0.416083,-0.304693 0.627371,-0.578419 0.741756,-0.959537 0.174825,-0.584415 0.423575,-0.866132 0.643355,-0.730268 0.07793,0.04795 0.376124,0.554444 0.663335,1.124871 0.495503,0.985014 0.792705,1.382116 0.792705,1.057441 0,-0.08142 -0.279719,-0.693305 -0.620877,-1.359636 -0.56843,-1.107891 -0.770727,-1.918578 -0.537462,-2.151844 0.143857,-0.143855 0.308192,0.03196 1.558938,1.673323 1.3971002,1.833162 1.6083883,2.205789 1.7192766,3.026966 0.076421,0.567931 0.084919,0.57792 0.1873128,0.217782 0.07543,-0.264735 0.041463,-0.515983 -0.1143866,-0.842656 -0.1218821,-0.255744 -0.1923077,-0.494004 -0.1568436,-0.52947 0.076922,-0.07692 3.2342583,1.562934 4.8366523,2.511483 1.3516446,0.800198 1.8501452,1.238759 1.8501452,1.62637 0,0.467532 -1.0164816,2.36463 -1.8831125,3.514478 -1.1038935,1.465529 -3.3566354,3.276715 -4.0799095,3.280711 -0.2612379,0.0015 -2.1603357,-1.960035 -2.2577377,-2.332162 -0.04496,-0.173826 0.17882,-0.701797 0.63936,-1.505991 0.6992989,-1.221275 0.9160807,-1.784211 0.4735251,-1.227769 l -0.8541441,1.073923 c -0.469529,0.590908 -0.860637,0.910587 -1.561435,1.278718 -0.513984,0.270729 -0.969029,0.580419 -1.010487,0.689309 -0.04146,0.108396 0,0.510987 0.09141,0.894104 0.132368,0.551947 0.133367,0.803694 0.006,1.211286 -0.253246,0.809189 -1.090407,2.56343 -1.223774,2.56343 -0.06593,0 -0.0889,-0.104396 -0.05095,-0.231267 0.03747,-0.127373 0.104397,-0.40959 0.147852,-0.627872 0.07543,-0.376622 0.05095,-0.363135 -0.489509,0.264236 -0.742257,0.863135 -1.41708,1.519976 -1.561435,1.519976 -0.06293,0 -0.152347,-0.06044 -0.197802,-0.134865 z m 3.54145,-11.877094 c 0.213785,-0.214286 -0.03447,-0.411587 -0.516982,-0.411587 -0.567931,0 -0.974523,0.202296 -0.974523,0.485512 0,0.177822 0.110388,0.198801 0.683815,0.130869 0.375623,-0.04446 0.739259,-0.136863 0.80769,-0.204794 z m 2.467027,-4.450039 c -0.167332,-0.201798 0.631368,-0.91908 1.5134836,-1.359638 1.1278687,-0.563436 2.9155763,-0.601397 3.9170734,-0.08341 0.3346641,0.172825 0.9740234,0.845152 0.8866115,0.932564 -0.019979,0.01998 -0.3626379,-0.07642 -0.7617364,-0.214285 -1.3331643,-0.460038 -3.0534394,-0.26973 -4.6588303,0.514484 -0.5889094,0.288211 -0.7927059,0.335664 -0.8966018,0.210289 z m -0.83716,-0.906592 c -0.262737,-0.321178 -0.478521,-0.643355 -0.478521,-0.716281 0,-0.204296 1.587908,-1.2002978 2.160335,-1.3746227 0.5724257,-0.173826 1.252744,-0.2857133 2.3086856,-0.2857133 1.0559416,0 1.7432527,0.1608381 2.547446,0.5429553 0.8121863,0.3861131 2.468526,1.7382577 2.1448505,1.7507447 -0.085909,0.0035 -0.650848,-0.243257 -1.2557409,-0.547452 -1.0034949,-0.504993 -1.2057923,-0.5584397 -2.3261694,-0.6108863 -1.6183763,-0.07543 -2.2127818,0.1048983 -3.4750167,1.0559403 -0.5614371,0.423076 -1.0489481,0.769229 -1.0839131,0.769229 -0.03497,0 -0.27872,-0.262735 -0.541956,-0.584413 z"
|
||||
style="fill:#bbbbbb;fill-opacity:1;stroke:none;stroke-width:0.499498;stroke-opacity:1" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
@@ -1,8 +1,8 @@
|
||||
---
|
||||
// MC-GUI slot + copy button. variant "ip" = big gold mono (server address);
|
||||
// "url" = smaller, wraps (auth / packwiz URLs). Copy handled by the global
|
||||
// [data-copy] script in [...lang].astro; data-label / data-copied drive the
|
||||
// (localized) button text and transient feedback.
|
||||
// "url" = smaller, wraps (auth / authlib URLs). Copy handled by the global
|
||||
// [data-copy] script in [...lang].astro (and the equivalent on /fjord);
|
||||
// data-label / data-copied drive the (localized) button text + feedback.
|
||||
interface Props {
|
||||
value: string;
|
||||
variant?: "ip" | "url";
|
||||
|
||||
84
landing/src/components/ModList.astro
Normal file
84
landing/src/components/ModList.astro
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
// Installed-pack mod list (the "Mods" section). Build-time baked from the
|
||||
// auto-generated catalogue (tooling/build-modlist.py → src/data/mods.json),
|
||||
// loaded via ./data/mods.ts (resilient: missing file → empty list, build never
|
||||
// fails). Flat alphabetical grid: extracted logo (or a pixel placeholder) +
|
||||
// pretty name + version. description is exposed as a tooltip. No categories, no
|
||||
// external links. Tiles share the MC-GUI bevel treatment used by .feat.
|
||||
import { MODS } from "../data/mods";
|
||||
|
||||
interface Props {
|
||||
emptyLabel?: string;
|
||||
}
|
||||
const { emptyLabel = "Mod list not generated yet." } = Astro.props;
|
||||
|
||||
const mods = MODS.mods;
|
||||
---
|
||||
{mods.length === 0 ? (
|
||||
<p class="mods-empty">{emptyLabel}</p>
|
||||
) : (
|
||||
<div class="mod-grid">
|
||||
{mods.map((m) => (
|
||||
<div class="mod" title={m.description || m.name}>
|
||||
<div class="mod-logo">
|
||||
{m.logo ? (
|
||||
<img src={m.logo} alt="" width="40" height="40" loading="lazy" />
|
||||
) : (
|
||||
<span class="mod-noicon" aria-hidden="true">{m.name.slice(0, 1).toUpperCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
<div class="mod-meta">
|
||||
<span class="mod-name">{m.name}</span>
|
||||
{m.version && <span class="mod-ver">{m.version}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>
|
||||
.mod-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.mod {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: var(--surface);
|
||||
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
|
||||
transition: transform .14s ease, filter .14s ease;
|
||||
}
|
||||
.mod:hover { transform: translateY(-2px); filter: brightness(1.06); }
|
||||
.mod-logo {
|
||||
width: 40px; height: 40px; flex-shrink: 0;
|
||||
display: grid; place-items: center;
|
||||
background: var(--bg-2);
|
||||
box-shadow: inset 1px 1px 0 var(--bevel-lo), inset -1px -1px 0 var(--bevel-hi);
|
||||
}
|
||||
.mod-logo img { width: 40px; height: 40px; image-rendering: pixelated; }
|
||||
.mod-noicon {
|
||||
font-family: var(--font-head);
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
color: var(--accent);
|
||||
line-height: 1;
|
||||
}
|
||||
.mod-meta { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.mod-name {
|
||||
font-size: 15px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mod-ver {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.mods-empty { margin: 0; color: var(--dim); font-size: 14px; }
|
||||
</style>
|
||||
@@ -1,10 +1,13 @@
|
||||
---
|
||||
// 7x7 feature glyphs — styled by .picon in main.css.
|
||||
// Feature icon. Default: a 7x7 procedural glyph (styled by .picon in main.css).
|
||||
// Optional: pass `img` (an absolute path like "/icons/foo.png|svg" under
|
||||
// landing/public/) to render a custom image instead of a glyph.
|
||||
interface Props {
|
||||
glyph: "shield" | "diamond" | "orb" | "heart";
|
||||
gold?: boolean;
|
||||
img?: string; // custom icon path in /public/icons/ — overrides the glyph
|
||||
}
|
||||
const { glyph, gold = false } = Astro.props;
|
||||
const { glyph, gold = false, img } = Astro.props;
|
||||
|
||||
const GLYPHS: Record<Props["glyph"], string[]> = {
|
||||
shield: ["0111110", "1111111", "1111111", "1111111", "0111110", "0011100", "0001000"],
|
||||
@@ -15,6 +18,10 @@ const GLYPHS: Record<Props["glyph"], string[]> = {
|
||||
const cells = GLYPHS[glyph].join("").split("");
|
||||
const onClass = gold ? "go" : "on";
|
||||
---
|
||||
<div class="picon" aria-hidden="true">
|
||||
{cells.map((c) => <i class={c === "1" ? onClass : undefined} />)}
|
||||
</div>
|
||||
{img ? (
|
||||
<img class="picon-img" src={img} alt="" aria-hidden="true" width="52" height="52" />
|
||||
) : (
|
||||
<div class="picon" aria-hidden="true">
|
||||
{cells.map((c) => <i class={c === "1" ? onClass : undefined} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
91
landing/src/components/PlayerRoster.astro
Normal file
91
landing/src/components/PlayerRoster.astro
Normal file
@@ -0,0 +1,91 @@
|
||||
---
|
||||
// Registered-players roster (the "Members" section). Progressive enhancement,
|
||||
// same shape as ServerCard:
|
||||
// SSG → renders an empty skeleton (a single .roster grid + an empty-state
|
||||
// line) so the page is valid with JS off.
|
||||
// Client → fetches OUR mc-status roster endpoint (site.status.rosterApi,
|
||||
// same-origin via caddy /api/mcstatus/* → internal /players). It
|
||||
// returns a sanitized [{uuid,name}] of every Drasl player (admin login
|
||||
// is server-side in mc-status — no admin token reaches the page). Each
|
||||
// player renders as a SHARED avatar tile (.roster-tile, styled in
|
||||
// main.css alongside ServerCard's online tiles).
|
||||
//
|
||||
// Empty/error → hide the grid and show the "no members yet" line (data-empty).
|
||||
import { site } from "../data/site";
|
||||
|
||||
interface Props {
|
||||
// Empty-state copy (passed from the page for i18n; English default).
|
||||
emptyLabel?: string;
|
||||
}
|
||||
const { emptyLabel = "No members yet." } = Astro.props;
|
||||
|
||||
const { status, avatarUrl, rosterAvatarMode } = site;
|
||||
---
|
||||
<div
|
||||
class="roster-block"
|
||||
data-api={status.rosterApi}
|
||||
data-avatar={avatarUrl}
|
||||
data-mode={rosterAvatarMode}
|
||||
data-empty={emptyLabel}
|
||||
>
|
||||
<div class="roster" aria-label="Registered players"></div>
|
||||
<p class="roster-empty" hidden></p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function hydrate(el: HTMLElement) {
|
||||
const { api, avatar, mode, empty } = el.dataset;
|
||||
if (!api || !avatar || !mode) return;
|
||||
|
||||
const grid = el.querySelector<HTMLElement>(".roster");
|
||||
const emptyEl = el.querySelector<HTMLElement>(".roster-empty");
|
||||
if (!grid) return;
|
||||
|
||||
const showEmpty = () => {
|
||||
grid.hidden = true;
|
||||
if (emptyEl) {
|
||||
emptyEl.textContent = empty ?? "No members yet.";
|
||||
emptyEl.hidden = false;
|
||||
}
|
||||
};
|
||||
|
||||
let data: Array<{ uuid?: string; name?: string }>;
|
||||
try {
|
||||
const r = await fetch(api, { headers: { Accept: "application/json" } });
|
||||
if (!r.ok) return showEmpty();
|
||||
data = await r.json();
|
||||
} catch {
|
||||
return showEmpty();
|
||||
}
|
||||
|
||||
// Drop the service `admin` account from the public members grid.
|
||||
const players = Array.isArray(data)
|
||||
? data.filter((p) => p && p.uuid && p.name?.toLowerCase() !== "admin")
|
||||
: [];
|
||||
if (players.length === 0) return showEmpty();
|
||||
|
||||
grid.innerHTML = "";
|
||||
for (const p of players) {
|
||||
grid.appendChild(rosterTile(p.uuid!, p.name ?? "", avatar, mode));
|
||||
}
|
||||
}
|
||||
|
||||
// Shared avatar tile (avatar + name) — identical markup to ServerCard's
|
||||
// online tiles, so .roster / .roster-tile (global, main.css) style both.
|
||||
function rosterTile(uuid: string, name: string, avatar: string, mode: string) {
|
||||
const tile = document.createElement("div");
|
||||
tile.className = "roster-tile";
|
||||
const img = document.createElement("img");
|
||||
img.loading = "lazy";
|
||||
img.alt = name;
|
||||
img.src = `${avatar}/${mode}/${uuid}?size=256`;
|
||||
const label = document.createElement("span");
|
||||
label.className = "roster-name";
|
||||
label.textContent = name;
|
||||
label.title = name;
|
||||
tile.append(img, label);
|
||||
return tile;
|
||||
}
|
||||
|
||||
document.querySelectorAll<HTMLElement>(".roster-block").forEach(hydrate);
|
||||
</script>
|
||||
239
landing/src/components/ServerCard.astro
Normal file
239
landing/src/components/ServerCard.astro
Normal file
@@ -0,0 +1,239 @@
|
||||
---
|
||||
// Featured LIVE server card. Progressive enhancement:
|
||||
// SSG → renders a static skeleton (server icon, MOTD, slot cap) identical in
|
||||
// spirit to the old ServerListPanel, so it looks right with JS off or
|
||||
// if the status API is unreachable.
|
||||
// Client → fetches mcstatus.io (CORS:*, no key, 60s cache) for the public
|
||||
// address and fills in: live online/offline pill, players online/max +
|
||||
// fill bar, the real server favicon, color MOTD, and a row of player
|
||||
// HEADS rendered by OUR NMSR from Drasl skins (not Mojang/Crafatar).
|
||||
//
|
||||
// Live labels are passed as data-* so copy stays translatable from the page
|
||||
// (defaults are English). Heads use /headiso. Promo/placeholder players with
|
||||
// the all-zero UUID are filtered out.
|
||||
import { site } from "../data/site";
|
||||
|
||||
interface Props {
|
||||
// SSG fallback copy (same props the old ServerListPanel took).
|
||||
modded: string;
|
||||
motd: string;
|
||||
upTo: string;
|
||||
// Live labels (optional, English defaults). Pass t.* from the page to i18n.
|
||||
onlineLabel?: string;
|
||||
offlineLabel?: string;
|
||||
emptyLabel?: string;
|
||||
}
|
||||
const {
|
||||
modded,
|
||||
motd,
|
||||
upTo,
|
||||
onlineLabel = "Online",
|
||||
offlineLabel = "Offline",
|
||||
emptyLabel = "Nobody online",
|
||||
} = Astro.props;
|
||||
|
||||
const { status, avatarUrl, avatarMode } = site;
|
||||
---
|
||||
<div
|
||||
class="servercard"
|
||||
data-api={status.statusApi}
|
||||
data-avatar={avatarUrl}
|
||||
data-mode={avatarMode}
|
||||
data-slots={status.slots}
|
||||
data-online={onlineLabel}
|
||||
data-offline={offlineLabel}
|
||||
data-empty={emptyLabel}
|
||||
>
|
||||
<div class="sc-top">
|
||||
<div class="sc-icon">
|
||||
<!-- Replaced by the real server favicon on success; Ulicraft logo otherwise. -->
|
||||
<img class="sc-favicon" alt="" hidden />
|
||||
<span class="sc-creeper"><img src="/ulicraft-logo-mini.svg" alt="" width="56" height="56" /></span>
|
||||
</div>
|
||||
|
||||
<div class="sc-meta">
|
||||
<div class="sc-row1">
|
||||
<span class="sc-title">{site.name}</span>
|
||||
<span class="sc-ver">Java {site.mcVersion}</span>
|
||||
</div>
|
||||
<p class="sc-motd"><span class="a">⛏ {modded}</span> · <span class="sc-motd-text">{motd}</span></p>
|
||||
</div>
|
||||
|
||||
<div class="sc-state">
|
||||
<span class="sc-pill" data-up="">
|
||||
<span class="sc-dot"></span>
|
||||
<span class="sc-pill-txt">·</span>
|
||||
</span>
|
||||
<div class="sc-count">
|
||||
<span class="sc-on">{upTo}</span> <b class="sc-max">{status.slots}</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player fill bar (online/max). Hidden until a live count arrives. -->
|
||||
<div class="sc-bar" hidden><span class="sc-bar-fill"></span></div>
|
||||
|
||||
<!-- Avatar tiles + empty state. Populated client-side. -->
|
||||
<div class="sc-players" hidden>
|
||||
<div class="roster" aria-label="Players online"></div>
|
||||
<p class="sc-empty" hidden></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.servercard {
|
||||
background: var(--slot);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
box-shadow:
|
||||
inset 2px 2px 0 var(--bevel-hi),
|
||||
inset -2px -2px 0 var(--bevel-lo),
|
||||
0 8px 24px oklch(0 0 0 / 0.4);
|
||||
}
|
||||
.sc-top { display: flex; gap: 18px; align-items: center; }
|
||||
|
||||
.sc-icon {
|
||||
width: 72px; height: 72px; flex-shrink: 0;
|
||||
display: grid; place-items: center; position: relative;
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.sc-favicon { width: 64px; height: 64px; image-rendering: pixelated; }
|
||||
.sc-creeper img { width: 56px; height: 56px; }
|
||||
|
||||
.sc-meta { flex: 1; min-width: 0; }
|
||||
.sc-row1 { display: flex; align-items: baseline; gap: 12px; }
|
||||
.sc-title { font-family: var(--font-head); font-weight: 600; font-size: 22px; white-space: nowrap; }
|
||||
.sc-ver { color: var(--dim); font-family: var(--font-mono); font-size: 17px; white-space: nowrap; }
|
||||
.sc-motd { margin: 6px 0 0; color: var(--muted); font-size: 15px; }
|
||||
.sc-motd .a { color: var(--accent); }
|
||||
/* mcstatus.io motd.html ships inline color styles; keep its spans inline. */
|
||||
.sc-motd-text :global(span) { display: inline; }
|
||||
|
||||
.sc-state { text-align: right; flex-shrink: 0; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
|
||||
.sc-pill {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 11px; font-family: var(--font-pixel, var(--font-mono));
|
||||
letter-spacing: 0.04em; text-transform: uppercase;
|
||||
padding: 3px 8px; color: var(--dim);
|
||||
box-shadow: inset 1px 1px 0 var(--bevel-hi), inset -1px -1px 0 var(--bevel-lo);
|
||||
}
|
||||
.sc-dot { width: 9px; height: 9px; background: var(--dim); image-rendering: pixelated; }
|
||||
.sc-pill[data-up="1"] { color: var(--accent-hi); }
|
||||
.sc-pill[data-up="1"] .sc-dot { background: var(--accent); box-shadow: 0 0 6px var(--glow); }
|
||||
.sc-pill[data-up="0"] { color: oklch(0.65 0.18 25); }
|
||||
.sc-pill[data-up="0"] .sc-dot { background: oklch(0.62 0.2 25); }
|
||||
.sc-count { font-variant-numeric: tabular-nums; font-size: 15px; color: var(--muted); }
|
||||
.sc-count b { color: var(--text); }
|
||||
|
||||
.sc-bar { height: 6px; background: var(--bg-2); box-shadow: inset 1px 1px 0 var(--bevel-lo); }
|
||||
.sc-bar-fill { display: block; height: 100%; width: 0; background: var(--accent); transition: width 0.6s ease; }
|
||||
|
||||
/* Online avatar tiles use the shared .roster / .roster-tile styles in
|
||||
main.css (also used by PlayerRoster.astro). */
|
||||
.sc-empty { margin: 0; color: var(--dim); font-size: 13px; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const ZERO_UUID = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
async function hydrate(el: HTMLElement) {
|
||||
const { api, avatar, mode, slots, online, offline, empty } = el.dataset;
|
||||
if (!api || !avatar || !mode) return;
|
||||
|
||||
const q = <T extends HTMLElement>(s: string) => el.querySelector<T>(s);
|
||||
const pill = q(".sc-pill");
|
||||
const pillTxt = q(".sc-pill-txt");
|
||||
const favicon = q<HTMLImageElement>(".sc-favicon");
|
||||
const creeper = q(".sc-creeper");
|
||||
const onEl = q(".sc-on");
|
||||
const maxEl = q(".sc-max");
|
||||
const motdEl = q(".sc-motd-text");
|
||||
const bar = q(".sc-bar");
|
||||
const fill = q<HTMLElement>(".sc-bar-fill");
|
||||
const players = q(".sc-players");
|
||||
const roster = q(".roster");
|
||||
const emptyEl = q(".sc-empty");
|
||||
|
||||
let data: any;
|
||||
try {
|
||||
const r = await fetch(api, { headers: { Accept: "application/json" } });
|
||||
if (!r.ok) return; // keep SSG skeleton
|
||||
data = await r.json();
|
||||
} catch {
|
||||
return; // offline/network → keep SSG skeleton
|
||||
}
|
||||
|
||||
const up = data.online === true;
|
||||
pill?.setAttribute("data-up", up ? "1" : "0");
|
||||
if (pillTxt) pillTxt.textContent = up ? (online ?? "Online") : (offline ?? "Offline");
|
||||
|
||||
if (!up) return; // offline pill set; leave the rest as the static fallback
|
||||
|
||||
// Live count + bar.
|
||||
const on = data.players?.online ?? 0;
|
||||
const max = data.players?.max ?? (Number(slots) || 0);
|
||||
if (onEl) onEl.textContent = String(on);
|
||||
if (maxEl) maxEl.textContent = String(max);
|
||||
if (bar && fill) {
|
||||
bar.hidden = false;
|
||||
fill.style.width = max > 0 ? `${Math.min(100, (on / max) * 100)}%` : "0%";
|
||||
}
|
||||
|
||||
// Real server favicon.
|
||||
if (favicon && typeof data.icon === "string" && data.icon.startsWith("data:image")) {
|
||||
favicon.src = data.icon;
|
||||
favicon.hidden = false;
|
||||
creeper?.setAttribute("hidden", "");
|
||||
}
|
||||
|
||||
// Color MOTD (mcstatus ships inline-styled spans for our own server).
|
||||
if (motdEl && data.motd?.html) motdEl.innerHTML = data.motd.html;
|
||||
|
||||
// Labeled avatar tiles from OUR NMSR (Drasl skins). Filter promo/placeholder
|
||||
// rows (all-zero UUID) and dedupe. Tiles share .roster styles with the
|
||||
// Members grid (PlayerRoster.astro) — see rosterTile() below.
|
||||
const list: Array<{ uuid?: string; name_clean?: string }> = data.players?.list ?? [];
|
||||
const seen = new Set<string>();
|
||||
const real = list.filter((p) => {
|
||||
const u = (p.uuid ?? "").toLowerCase();
|
||||
if (!u || u === ZERO_UUID || seen.has(u)) return false;
|
||||
seen.add(u);
|
||||
return true;
|
||||
});
|
||||
if (players && roster) {
|
||||
players.hidden = false;
|
||||
if (real.length === 0) {
|
||||
if (emptyEl) {
|
||||
emptyEl.textContent = empty ?? "Nobody online";
|
||||
emptyEl.hidden = false;
|
||||
}
|
||||
} else {
|
||||
roster.innerHTML = "";
|
||||
for (const p of real.slice(0, 16)) {
|
||||
roster.appendChild(rosterTile(p.uuid!, p.name_clean ?? "", avatar, mode));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build one shared avatar tile (avatar + name). Same markup PlayerRoster
|
||||
// builds, so .roster / .roster-tile (global, in main.css) style both.
|
||||
function rosterTile(uuid: string, name: string, avatar: string, mode: string) {
|
||||
const tile = document.createElement("div");
|
||||
tile.className = "roster-tile";
|
||||
const img = document.createElement("img");
|
||||
img.loading = "lazy";
|
||||
img.alt = name;
|
||||
img.src = `${avatar}/${mode}/${uuid}?size=64`;
|
||||
const label = document.createElement("span");
|
||||
label.className = "roster-name";
|
||||
label.textContent = name;
|
||||
label.title = name;
|
||||
tile.append(img, label);
|
||||
return tile;
|
||||
}
|
||||
|
||||
document.querySelectorAll<HTMLElement>(".servercard").forEach(hydrate);
|
||||
</script>
|
||||
39
landing/src/data/launcher.example.json
Normal file
39
landing/src/data/launcher.example.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"productName": "Ulicraft",
|
||||
"version": "1.0.0",
|
||||
"baseUrl": "https://distribution.ulicraft.net/launcher",
|
||||
"files": [
|
||||
{
|
||||
"name": "Ulicraft-1.0.0.exe",
|
||||
"os": "windows",
|
||||
"arch": "x64",
|
||||
"ext": "exe",
|
||||
"size": 90893289,
|
||||
"url": "https://distribution.ulicraft.net/launcher/Ulicraft-1.0.0.exe"
|
||||
},
|
||||
{
|
||||
"name": "Ulicraft-1.0.0.AppImage",
|
||||
"os": "linux",
|
||||
"arch": "x64",
|
||||
"ext": "AppImage",
|
||||
"size": 95722608,
|
||||
"url": "https://distribution.ulicraft.net/launcher/Ulicraft-1.0.0.AppImage"
|
||||
},
|
||||
{
|
||||
"name": "Ulicraft-1.0.0.deb",
|
||||
"os": "linux",
|
||||
"arch": "x64",
|
||||
"ext": "deb",
|
||||
"size": 87751876,
|
||||
"url": "https://distribution.ulicraft.net/launcher/Ulicraft-1.0.0.deb"
|
||||
},
|
||||
{
|
||||
"name": "Ulicraft-1.0.0.tar.gz",
|
||||
"os": "linux",
|
||||
"arch": "x64",
|
||||
"ext": "tar.gz",
|
||||
"size": 116347746,
|
||||
"url": "https://distribution.ulicraft.net/launcher/Ulicraft-1.0.0.tar.gz"
|
||||
}
|
||||
]
|
||||
}
|
||||
5
landing/src/data/mods.example.json
Normal file
5
landing/src/data/mods.example.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"count": 0,
|
||||
"mods": []
|
||||
}
|
||||
56
landing/src/data/mods.ts
Normal file
56
landing/src/data/mods.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// Build-time loader for the auto-generated mod catalogue. tooling/build-modlist.py
|
||||
// writes src/data/mods.json (gitignored, generated) from the distribution's full
|
||||
// client modset; it MAY NOT EXIST on a fresh checkout, so we read it at runtime
|
||||
// with fs (a static `import` would hard-fail the build when absent). Fall back to
|
||||
// the committed .example.json (an empty catalogue); if even that is unreadable,
|
||||
// expose an empty catalogue so the page simply renders no mods and the stat tile
|
||||
// keeps its default. Same pattern as loadLauncherManifest in site.ts.
|
||||
// @ts-ignore — node builtins (no @types/node in this Astro SSG; same reason
|
||||
// `process`/`import.meta.url` reads work at build time, as in site.ts).
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export type Mod = {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
authors: string;
|
||||
description: string;
|
||||
logo: string | null;
|
||||
};
|
||||
|
||||
export type ModCatalogue = {
|
||||
schemaVersion: number;
|
||||
count: number;
|
||||
mods: Mod[];
|
||||
};
|
||||
|
||||
export function loadMods(): ModCatalogue {
|
||||
const empty: ModCatalogue = { schemaVersion: 1, count: 0, mods: [] };
|
||||
// Resolve relative to this module first (works when import.meta.url points at
|
||||
// the source dir), then fall back to a cwd-relative path (Astro runs the build
|
||||
// from the landing root, so src/data/* resolves there even when the module has
|
||||
// been bundled to a different location). Prefer the generated mods.json, then
|
||||
// the committed empty mods.example.json.
|
||||
// @ts-ignore — `process` is untyped here (no @types/node), same as site.ts.
|
||||
const cwd: string = typeof process !== "undefined" ? process.cwd() : ".";
|
||||
const candidates: Array<string | URL> = [];
|
||||
for (const name of ["mods.json", "mods.example.json"]) {
|
||||
try {
|
||||
candidates.push(new URL(name, import.meta.url));
|
||||
} catch {
|
||||
// import.meta.url unavailable — skip the module-relative candidate.
|
||||
}
|
||||
candidates.push(`${cwd}/src/data/${name}`);
|
||||
}
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(c, "utf8")) as ModCatalogue;
|
||||
if (parsed && Array.isArray(parsed.mods)) return parsed;
|
||||
} catch {
|
||||
// try the next candidate (file missing/unreadable → fall back)
|
||||
}
|
||||
}
|
||||
return empty;
|
||||
}
|
||||
|
||||
export const MODS = loadMods();
|
||||
@@ -2,8 +2,114 @@
|
||||
// domains, URLs, theme, launcher files, and the structural shape of the
|
||||
// stat/feature lists. Translatable copy lives in src/i18n/ui.ts.
|
||||
// BASE_DOMAIN is read at build time; falls back to ulicraft.local.
|
||||
// @ts-ignore — node builtins (no @types/node in this Astro SSG; same reason
|
||||
// `process` below is untyped). Used only at build time to read the manifest.
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const BASE_DOMAIN = process.env.BASE_DOMAIN ?? "ulicraft.local";
|
||||
|
||||
// NMSR render mode for avatar tiles. AVATAR_MODE is the ServerCard online row +
|
||||
// account preview (heads); ROSTER_AVATAR_MODE is the Members grid (full body by
|
||||
// default). Read at build time, same as BASE_DOMAIN. e.g. "headiso" | "head" |
|
||||
// "fullbody" | "fullbodyiso".
|
||||
// @ts-ignore — `process` is untyped here (no @types/node), same as BASE_DOMAIN.
|
||||
const AVATAR_MODE = process.env.AVATAR_MODE ?? "headiso";
|
||||
// @ts-ignore — `process` is untyped here (no @types/node).
|
||||
const ROSTER_AVATAR_MODE = process.env.ROSTER_AVATAR_MODE ?? "fullbodyiso";
|
||||
|
||||
// UlicraftLauncher download list. Source of truth is the custom-launcher repo's
|
||||
// build output `launcher.json` (shape: productName/version/baseUrl/files[]),
|
||||
// published to https://distribution.${BASE}/launcher/launcher.json. The release
|
||||
// flow syncs that file into this dir as `launcher.json` (gitignored, generated)
|
||||
// — same pattern as sync-server-mods.sh. It MAY NOT EXIST at build time, so we
|
||||
// read it at runtime with fs (a static `import` would hard-fail the build when
|
||||
// absent). Fall back to the committed `launcher.example.json` (the documented
|
||||
// shape); if even that is unreadable, expose an empty file list so the homepage
|
||||
// simply renders no download buttons. The raw launcher.json is normalized into
|
||||
// the internal shape the page renders against (builds[]).
|
||||
type LauncherBuild = {
|
||||
os: string; // "windows" | "macos" | "linux"
|
||||
arch: string; // "x64" | "arm64" | "universal"
|
||||
format: string; // file extension: "exe" | "dmg" | "AppImage" | "deb" | "tar.gz" | …
|
||||
filename: string;
|
||||
url: string;
|
||||
size?: number;
|
||||
};
|
||||
type LauncherFile = { filename: string; url: string; size?: number };
|
||||
type LauncherManifest = {
|
||||
product: string;
|
||||
version: string;
|
||||
builds: LauncherBuild[];
|
||||
// Not present in launcher.json; the Fjord page degrades gracefully when absent.
|
||||
fjordPack?: LauncherFile;
|
||||
};
|
||||
|
||||
// Raw shape as produced by the custom-launcher build (UlicraftLauncher/dist/launcher.json).
|
||||
type RawLauncherJson = {
|
||||
productName?: string;
|
||||
version?: string;
|
||||
baseUrl?: string;
|
||||
files?: Array<{ name: string; os: string; arch: string; ext: string; url: string; size?: number }>;
|
||||
};
|
||||
|
||||
function normalizeLauncher(raw: RawLauncherJson): LauncherManifest {
|
||||
return {
|
||||
product: raw.productName ?? "UlicraftLauncher",
|
||||
version: raw.version ?? "0.0.0",
|
||||
builds: (raw.files ?? []).map((f) => ({
|
||||
os: f.os,
|
||||
arch: f.arch,
|
||||
format: f.ext,
|
||||
filename: f.name,
|
||||
url: f.url,
|
||||
size: f.size,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function loadLauncherManifest(): LauncherManifest {
|
||||
const empty: LauncherManifest = { product: "UlicraftLauncher", version: "0.0.0", builds: [] };
|
||||
// Resolve relative to this module first, then fall back to a cwd-relative path:
|
||||
// Astro bundles this module so import.meta.url may not point at src/data/ at
|
||||
// build time, but the build runs from the landing root, so <cwd>/src/data/*
|
||||
// resolves. Prefer the synced launcher.json, then the committed example.
|
||||
// @ts-ignore — `process` is untyped here (no @types/node).
|
||||
const cwd: string = typeof process !== "undefined" ? process.cwd() : ".";
|
||||
const candidates: Array<string | URL> = [];
|
||||
for (const name of ["launcher.json", "launcher.example.json"]) {
|
||||
try {
|
||||
candidates.push(new URL(name, import.meta.url));
|
||||
} catch {
|
||||
// import.meta.url unavailable — skip the module-relative candidate.
|
||||
}
|
||||
candidates.push(`${cwd}/src/data/${name}`);
|
||||
}
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(c, "utf8")) as RawLauncherJson;
|
||||
if (raw && Array.isArray(raw.files)) return normalizeLauncher(raw);
|
||||
} catch {
|
||||
// try the next candidate (synced file missing → fall back to example)
|
||||
}
|
||||
}
|
||||
return empty;
|
||||
}
|
||||
|
||||
const LAUNCHER_MANIFEST = loadLauncherManifest();
|
||||
|
||||
// REGISTRATION_MODE (invite|open) is read at build time, same .env as Drasl's
|
||||
// render-config.sh. "invite" => the register form must collect an invite code.
|
||||
const REGISTRATION_MODE = process.env.REGISTRATION_MODE ?? "invite";
|
||||
|
||||
// REGISTRATION_SHOW_INVITE (true|false, default false=hidden) ONLY toggles the
|
||||
// visibility of the invite-code field on the register form. The backend invite
|
||||
// gate stays on Drasl's RequireInvite (driven by REGISTRATION_MODE). Decoupled on
|
||||
// purpose: invites are usually admin-minted and the field is hidden by default.
|
||||
// CAVEAT: if REGISTRATION_MODE=invite (backend requires a code) but this is
|
||||
// false, public self-registration can't supply one — set both together.
|
||||
// @ts-ignore — `process` is untyped here (no @types/node).
|
||||
const REGISTRATION_SHOW_INVITE = process.env.REGISTRATION_SHOW_INVITE ?? "false";
|
||||
|
||||
// Minecraft / pack facts, surfaced in a few places.
|
||||
const MC_VERSION = "1.21.1";
|
||||
|
||||
@@ -12,47 +118,65 @@ export const site = {
|
||||
baseDomain: BASE_DOMAIN,
|
||||
mcVersion: MC_VERSION,
|
||||
|
||||
// Register form behaviour, driven by REGISTRATION_MODE in .env.
|
||||
// true => Drasl RequireInvite = true (backend gate)
|
||||
// false => open self-registration
|
||||
registrationRequiresInvite: REGISTRATION_MODE === "invite",
|
||||
// Whether the invite-code FIELD is shown on the register form (default false).
|
||||
// Independent of the backend gate above — see REGISTRATION_SHOW_INVITE note.
|
||||
registrationShowInvite: REGISTRATION_SHOW_INVITE === "true",
|
||||
|
||||
// Design knobs (replace the design's in-browser TweaksPanel). Baked at build.
|
||||
// mood: "grass" | "nether" | "end"
|
||||
// hero: "centered" | "split" | "spotlight"
|
||||
// headFont: "pixelify" | "8bit" | "silkscreen"
|
||||
// headFont: "blockblueprint" | "pixelify" | "8bit" | "silkscreen"
|
||||
// dust: floating pixel particles in the hero
|
||||
theme: {
|
||||
mood: "grass",
|
||||
hero: "centered",
|
||||
headFont: "pixelify",
|
||||
headFont: "blockblueprint",
|
||||
dust: true,
|
||||
},
|
||||
|
||||
// Connect target shown to guests. No port needed — a dnsmasq SRV record
|
||||
// (_minecraft._tcp.mc.<domain>) points clients at 25565.
|
||||
serverAddress: `mc.${BASE_DOMAIN}`,
|
||||
// Connect target shown to guests. No port needed (defaults to 25565).
|
||||
serverAddress: `${BASE_DOMAIN}`,
|
||||
|
||||
// Drasl auth web UI + authlib-injector API root.
|
||||
authUrl: `http://auth.${BASE_DOMAIN}`,
|
||||
authlibUrl: `http://auth.${BASE_DOMAIN}/authlib-injector`,
|
||||
// Drasl auth web UI + authlib-injector API root (public HTTPS).
|
||||
authUrl: `https://auth.${BASE_DOMAIN}`,
|
||||
authlibUrl: `https://auth.${BASE_DOMAIN}/authlib-injector`,
|
||||
// Drasl REST API v2 root — the /register form calls this from the browser.
|
||||
// Requires CORSAllowOrigins to include the apex in drasl config.toml.
|
||||
draslApiUrl: `https://auth.${BASE_DOMAIN}/drasl/api/v2`,
|
||||
|
||||
// packwiz modpack entrypoint.
|
||||
packwizUrl: `http://packwiz.${BASE_DOMAIN}/pack.toml`,
|
||||
// NMSR avatar renderer root. Player heads come from OUR Drasl skins, not
|
||||
// Mojang/Crafatar — head URL is `${avatarUrl}/headiso/<uuid>?size=64`.
|
||||
avatarUrl: `https://avatar.${BASE_DOMAIN}`,
|
||||
|
||||
// Caddy local CA root — guests import this to trust the offline asset mirror.
|
||||
caCertUrl: `http://${BASE_DOMAIN}/ca.crt`,
|
||||
// NMSR render mode for avatar tiles. Avatar URL = `${avatarUrl}/<mode>/<uuid>?size=N`.
|
||||
// avatarMode → ServerCard online row + account preview (heads)
|
||||
// rosterAvatarMode → PlayerRoster members grid (full body)
|
||||
avatarMode: AVATAR_MODE,
|
||||
rosterAvatarMode: ROSTER_AVATAR_MODE,
|
||||
|
||||
// Static server-list panel (Status section). No fake live count — see plan
|
||||
// 09-landing.md option B.
|
||||
// Uptime Kuma status page (external link in footer).
|
||||
statusUrl: `https://status.${BASE_DOMAIN}`,
|
||||
|
||||
// Live server card (ServerCard.astro). `slots` is the SSG fallback cap shown
|
||||
// before/without JS; live data comes from our SELF-HOSTED mc-status service
|
||||
// (docker/mc-status, mcutil wrapper) reverse-proxied same-origin at the apex
|
||||
// /api/mcstatus/* path — no CORS, no third-party calls. It emits mcstatus.io
|
||||
// v2 JSON, so the URL stays drop-in compatible. Uptime Kuma stays the history
|
||||
// + alerting backend (plan/10).
|
||||
status: {
|
||||
slots: 10,
|
||||
statusApi: `/api/mcstatus/v2/status/java/${BASE_DOMAIN}`,
|
||||
// Registered-players roster (PlayerRoster.astro). Same mc-status service,
|
||||
// same-origin via caddy /api/mcstatus/* → internal /players. Returns a
|
||||
// sanitized [{uuid,name}] of every Drasl player (admin login is server-side
|
||||
// in mc-status; no admin token in the page).
|
||||
rosterApi: `/api/mcstatus/players`,
|
||||
},
|
||||
|
||||
// Honest stat tiles. Keys (numbers/versions) are language-neutral; labels
|
||||
// come from ui.ts (same order).
|
||||
stats: [
|
||||
{ key: "50+" },
|
||||
{ key: MC_VERSION },
|
||||
{ key: "10" },
|
||||
{ key: "6h" },
|
||||
],
|
||||
|
||||
// Feature cards. glyph ∈ shield|diamond|orb|heart; gold = gold pixel fill.
|
||||
// Text (h/p) comes from ui.ts (same order).
|
||||
features: [
|
||||
@@ -62,10 +186,16 @@ export const site = {
|
||||
{ glyph: "heart", gold: true },
|
||||
],
|
||||
|
||||
// Launcher downloads. Stable filenames are symlinks created by
|
||||
// UlicraftLauncher download manifest — feeds the homepage join step 2
|
||||
// per-OS buttons from `builds[]` (and the Fjord page's .mrpack via
|
||||
// `fjordPack`). Synced from the custom-launcher repo; see loader above.
|
||||
launcherManifest: LAUNCHER_MANIFEST,
|
||||
|
||||
// Fjord Launcher downloads (the alternative manual path on /fjord, OFF the
|
||||
// homepage flow). Stable filenames are symlinks created by
|
||||
// tooling/fetch-launcher.sh — keep these in sync with that script. OS names
|
||||
// and hints are technical, kept language-neutral here.
|
||||
launcher: {
|
||||
fjord: {
|
||||
name: "Fjord Launcher",
|
||||
base: "/launcher/latest",
|
||||
downloads: [
|
||||
@@ -86,6 +216,7 @@ export const LITERALS = {
|
||||
|
||||
// Head-font CSS stacks, keyed by theme.headFont.
|
||||
export const HEAD_FONTS: Record<string, string> = {
|
||||
blockblueprint: "'Block Blueprint', system-ui, sans-serif",
|
||||
pixelify: "'Pixelify Sans', system-ui, sans-serif",
|
||||
"8bit": "'Press Start 2P', monospace",
|
||||
silkscreen: "'Silkscreen', system-ui, sans-serif",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// locales; the page (src/pages/[...lang].astro) renders against it.
|
||||
//
|
||||
// Conventions:
|
||||
// - "{name}" in step1 is replaced with the launcher name at render time.
|
||||
// - "{name}" is replaced with the launcher name at render time.
|
||||
// - Sentences containing links/kbd/literals are split into parts (a/b/pre/
|
||||
// post/link) so the markup stays in the template, not in the strings.
|
||||
// - Product / in-game literals (authlib-injector, Multiplayer, Add Server)
|
||||
@@ -11,13 +11,31 @@
|
||||
export type Lang = "en" | "es" | "eu";
|
||||
export const LANGS: Lang[] = ["en", "es", "eu"];
|
||||
export const LANG_LABEL: Record<Lang, string> = { en: "EN", es: "ES", eu: "EU" };
|
||||
// URL path per locale (en is default, served at root).
|
||||
export const LANG_PATH: Record<Lang, string> = { en: "/", es: "/es/", eu: "/eu/" };
|
||||
// URL path per locale (es is default, served at root; en moves to /en/).
|
||||
export const LANG_PATH: Record<Lang, string> = { es: "/", en: "/en/", eu: "/eu/" };
|
||||
// Register page path per locale (mirrors LANG_PATH: es at /register).
|
||||
export const LANG_REGISTER_PATH: Record<Lang, string> = {
|
||||
es: "/register",
|
||||
en: "/en/register",
|
||||
eu: "/eu/register",
|
||||
};
|
||||
// Fjord (alternative path) page path per locale (mirrors LANG_REGISTER_PATH).
|
||||
export const LANG_FJORD_PATH: Record<Lang, string> = {
|
||||
es: "/fjord",
|
||||
en: "/en/fjord",
|
||||
eu: "/eu/fjord",
|
||||
};
|
||||
// Account page path per locale (mirrors LANG_REGISTER_PATH).
|
||||
export const LANG_ACCOUNT_PATH: Record<Lang, string> = {
|
||||
es: "/account",
|
||||
en: "/en/account",
|
||||
eu: "/eu/account",
|
||||
};
|
||||
|
||||
export interface UI {
|
||||
htmlLang: string;
|
||||
copied: string; // transient copy-button feedback
|
||||
nav: { status: string; features: string; join: string; play: string };
|
||||
nav: { status: string; members: string; features: string; mods: string; join: string; play: string; register: string; login: string };
|
||||
hero: {
|
||||
eyebrow: string; // "Modded LAN · NeoForge" — version appended at render
|
||||
tagline: string;
|
||||
@@ -25,7 +43,6 @@ export interface UI {
|
||||
copyIp: string;
|
||||
howToJoin: string;
|
||||
metaModpack: string;
|
||||
metaLan: string;
|
||||
};
|
||||
status: {
|
||||
eyebrow: string;
|
||||
@@ -34,15 +51,41 @@ export interface UI {
|
||||
modded: string;
|
||||
motd: string;
|
||||
upTo: string;
|
||||
empty: string; // shown on the ServerCard when nobody is online
|
||||
};
|
||||
members: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
lead: string;
|
||||
empty: string; // shown when the roster is empty or unreachable
|
||||
};
|
||||
statLabels: [string, string, string, string];
|
||||
features: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
lead: string;
|
||||
items: { h: string; p: string }[];
|
||||
};
|
||||
mods: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
lead: string;
|
||||
empty: string; // shown when the catalogue is absent/empty (not yet generated)
|
||||
};
|
||||
join: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
lead: string;
|
||||
s1: {
|
||||
kicker: string;
|
||||
h: string;
|
||||
pa: string; // before the register link
|
||||
pb: string; // after it
|
||||
registerLink: string;
|
||||
};
|
||||
s2: { kicker: string; h: string; p: string };
|
||||
};
|
||||
fjord: {
|
||||
navHome: string; // "← Home" link in nav
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
lead: string;
|
||||
@@ -53,10 +96,6 @@ export interface UI {
|
||||
pa: string; // before the authlib-injector literal
|
||||
pb: string; // after it
|
||||
copy: string;
|
||||
registerPre: string;
|
||||
caPre: string;
|
||||
caLink: string;
|
||||
caPost: string;
|
||||
};
|
||||
s3: { kicker: string; h: string; p: string };
|
||||
s4: {
|
||||
@@ -66,26 +105,94 @@ export interface UI {
|
||||
pb: string; // after them
|
||||
copy: string;
|
||||
};
|
||||
noPack: string; // shown when fjordPack is missing from the manifest
|
||||
};
|
||||
footer: { join: string; status: string; disc: string };
|
||||
register: {
|
||||
navHome: string; // "← Home" link in nav
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
lead: string;
|
||||
usernameLabel: string;
|
||||
usernamePlaceholder: string;
|
||||
passwordLabel: string;
|
||||
passwordPlaceholder: string;
|
||||
inviteLabel: string;
|
||||
invitePlaceholder: string;
|
||||
inviteHint: string; // where to get a code
|
||||
submit: string;
|
||||
submitting: string;
|
||||
haveAccount: string; // "Already have an account?"
|
||||
loginLink: string; // links to the account page
|
||||
successTitle: string;
|
||||
successBody: string; // {name} = player name
|
||||
uuidLabel: string;
|
||||
skinTitle: string;
|
||||
skinLead: string;
|
||||
skinChoose: string;
|
||||
modelLabel: string;
|
||||
modelClassic: string;
|
||||
modelSlim: string;
|
||||
skinUpload: string;
|
||||
skinUploading: string;
|
||||
skinOk: string;
|
||||
finish: string; // proceed to How to Join
|
||||
errFields: string;
|
||||
errSkinType: string;
|
||||
errNetwork: string;
|
||||
};
|
||||
account: {
|
||||
navHome: string; // "← Home" link in nav
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
lead: string;
|
||||
// Login form
|
||||
usernameLabel: string;
|
||||
usernamePlaceholder: string;
|
||||
passwordLabel: string;
|
||||
passwordPlaceholder: string;
|
||||
submit: string;
|
||||
submitting: string;
|
||||
noAccount: string; // "No account yet?"
|
||||
registerLink: string; // links to /register
|
||||
// Skin panel
|
||||
refresh: string; // re-fetch the NMSR avatar
|
||||
playerLabel: string; // "Logged in as"
|
||||
skinTitle: string;
|
||||
skinLead: string; // reuses register.skinLead concept
|
||||
skinChoose: string;
|
||||
modelLabel: string;
|
||||
modelClassic: string;
|
||||
modelSlim: string;
|
||||
skinUpload: string;
|
||||
skinUploading: string;
|
||||
skinOk: string;
|
||||
skinPreviewNote: string; // "NMSR takes ~1 min to update" hint
|
||||
resetSkin: string;
|
||||
resetConfirm: string; // window.confirm text
|
||||
resetOk: string;
|
||||
// Errors
|
||||
errFields: string;
|
||||
errSkinType: string;
|
||||
errNetwork: string;
|
||||
};
|
||||
footer: { join: string; disc: string };
|
||||
}
|
||||
|
||||
export const ui: Record<Lang, UI> = {
|
||||
en: {
|
||||
htmlLang: "en",
|
||||
copied: "Copied!",
|
||||
nav: { status: "Status", features: "Features", join: "How to Join", play: "Play Now" },
|
||||
nav: { status: "Status", members: "Members", features: "Features", mods: "Mods", join: "How to Join", play: "Play", register: "Register", login: "Account" },
|
||||
hero: {
|
||||
eyebrow: "Modded LAN · NeoForge",
|
||||
tagline: "Kitchen-sink survival, built to outlast the weekend.",
|
||||
sub:
|
||||
"A self-hosted modded server for the crew — a curated tech + magic + " +
|
||||
"A self-hosted modded server for El valle — a curated tech + magic + " +
|
||||
"exploration pack, your own skins, one-click setup. Grab the address, " +
|
||||
"import the pack, dig in.",
|
||||
copyIp: "Copy IP",
|
||||
howToJoin: "How to Join",
|
||||
metaModpack: "NeoForge modpack",
|
||||
metaLan: "LAN-only",
|
||||
},
|
||||
status: {
|
||||
eyebrow: "Server",
|
||||
@@ -94,11 +201,17 @@ export const ui: Record<Lang, UI> = {
|
||||
modded: "Modded",
|
||||
motd: "NeoForge modpack · Simple Voice Chat · LAN-only",
|
||||
upTo: "up to",
|
||||
empty: "Nobody online",
|
||||
},
|
||||
members: {
|
||||
eyebrow: "El valle",
|
||||
title: "Who's on the server.",
|
||||
lead: "Everyone registered on Ulicraft, with their own self-hosted skin.",
|
||||
empty: "No members yet — be the first to register.",
|
||||
},
|
||||
statLabels: ["Mods", "NeoForge", "Player slots", "World backups"],
|
||||
features: {
|
||||
eyebrow: "What makes it Ulicraft",
|
||||
title: "Built for the crew.",
|
||||
title: "Built for El valle.",
|
||||
lead:
|
||||
"Self-hosted, curated, and persistent — a modded server that respects " +
|
||||
"your time and outlives the LAN.",
|
||||
@@ -113,7 +226,7 @@ export const ui: Record<Lang, UI> = {
|
||||
},
|
||||
{
|
||||
h: "One-click setup",
|
||||
p: "packwiz installs and updates the whole modpack inside your launcher, so everyone stays in sync.",
|
||||
p: "The Ulicraft Launcher installs and updates the whole modpack from our distribution, so everyone stays in sync.",
|
||||
},
|
||||
{
|
||||
h: "Built to last",
|
||||
@@ -121,30 +234,50 @@ export const ui: Record<Lang, UI> = {
|
||||
},
|
||||
],
|
||||
},
|
||||
mods: {
|
||||
eyebrow: "The pack",
|
||||
title: "Every mod in the pack.",
|
||||
lead: "The full client modset, installed automatically by the launcher. No hunting, no version mismatches.",
|
||||
empty: "Mod list not generated yet — check back soon.",
|
||||
},
|
||||
join: {
|
||||
eyebrow: "How to Join · 4 Steps",
|
||||
eyebrow: "How to Join · 2 Steps",
|
||||
title: "From zero to spawning in.",
|
||||
lead: "One-time setup. After this, the launcher keeps you in sync.",
|
||||
s1: {
|
||||
kicker: "Account",
|
||||
h: "Register",
|
||||
pa: "Create your Ulicraft account at",
|
||||
pb: "— one username and password for the server and your skin.",
|
||||
registerLink: "Register",
|
||||
},
|
||||
s2: {
|
||||
kicker: "Launcher",
|
||||
h: "Download {name}",
|
||||
p: "Grab {name} for your system — a Prism-based launcher with authlib-injector support built in.",
|
||||
p: "Grab {name}, log in with your Ulicraft credentials, and hit play. Everything else — the modpack, the server address, updates — is handled inside the launcher.",
|
||||
},
|
||||
},
|
||||
fjord: {
|
||||
navHome: "← Home",
|
||||
eyebrow: "Alternative · Fjord Launcher",
|
||||
title: "Play with Fjord Launcher.",
|
||||
lead: "Prefer a vanilla Prism-based launcher? Set it up manually with Fjord and import the Ulicraft pack.",
|
||||
s1: {
|
||||
kicker: "Launcher",
|
||||
h: "Download Fjord",
|
||||
p: "Grab Fjord Launcher for your system — a Prism-based launcher with authlib-injector support built in.",
|
||||
},
|
||||
s2: {
|
||||
kicker: "Account",
|
||||
h: "Add your account",
|
||||
pa: "In the launcher, add an",
|
||||
pa: "In Fjord, add an",
|
||||
pb: "account using this URL, then log in with your Ulicraft credentials.",
|
||||
copy: "Copy",
|
||||
registerPre: "Need credentials? Register at",
|
||||
caPre: "Offline party? Download and trust the",
|
||||
caLink: "local CA certificate",
|
||||
caPost: "so the game-file mirror works over HTTPS.",
|
||||
},
|
||||
s3: {
|
||||
kicker: "Modpack",
|
||||
h: "Import the modpack",
|
||||
p: "Add a new instance from this packwiz URL — it installs and updates the whole pack.",
|
||||
h: "Import the Ulicraft pack",
|
||||
p: "Download the Ulicraft modpack and import it into Fjord as a new instance — it installs the whole pack.",
|
||||
},
|
||||
s4: {
|
||||
kicker: "Connect",
|
||||
@@ -153,28 +286,94 @@ export const ui: Record<Lang, UI> = {
|
||||
pb: ", paste the address, and join.",
|
||||
copy: "Copy IP",
|
||||
},
|
||||
noPack: "The pack download isn't available yet — check back soon.",
|
||||
},
|
||||
footer: {
|
||||
join: "How to Join",
|
||||
status: "Server status",
|
||||
disc: "Not affiliated with Mojang or Microsoft. Minecraft is a trademark of Mojang AB.",
|
||||
},
|
||||
register: {
|
||||
navHome: "← Home",
|
||||
eyebrow: "Account",
|
||||
title: "Create your Ulicraft account.",
|
||||
lead: "One username and password for the server, your skin, and your cape. Takes a minute.",
|
||||
usernameLabel: "Username",
|
||||
usernamePlaceholder: "your in-game name",
|
||||
passwordLabel: "Password",
|
||||
passwordPlaceholder: "choose a password",
|
||||
inviteLabel: "Invite code",
|
||||
invitePlaceholder: "paste your invite code",
|
||||
inviteHint: "Ask an admin for an invite code.",
|
||||
submit: "Create account",
|
||||
submitting: "Creating…",
|
||||
haveAccount: "Already have an account?",
|
||||
loginLink: "Manage it here",
|
||||
successTitle: "Account created.",
|
||||
successBody: "Welcome, {name}. You can set a skin now, or head straight to How to Join.",
|
||||
uuidLabel: "Player UUID",
|
||||
skinTitle: "Set your skin (optional)",
|
||||
skinLead: "Upload a Minecraft skin PNG. You can always change it later in the account page.",
|
||||
skinChoose: "Choose PNG…",
|
||||
modelLabel: "Model",
|
||||
modelClassic: "Classic",
|
||||
modelSlim: "Slim",
|
||||
skinUpload: "Upload skin",
|
||||
skinUploading: "Uploading…",
|
||||
skinOk: "Skin set!",
|
||||
finish: "Continue to How to Join",
|
||||
errFields: "Fill in every field.",
|
||||
errSkinType: "Pick a PNG image.",
|
||||
errNetwork: "Network error — try again.",
|
||||
},
|
||||
account: {
|
||||
navHome: "← Home",
|
||||
eyebrow: "Account",
|
||||
title: "Manage your skin.",
|
||||
lead: "Log in to upload or reset your Minecraft skin.",
|
||||
usernameLabel: "Username",
|
||||
usernamePlaceholder: "your in-game name",
|
||||
passwordLabel: "Password",
|
||||
passwordPlaceholder: "your password",
|
||||
submit: "Log in",
|
||||
submitting: "Logging in…",
|
||||
noAccount: "No account yet?",
|
||||
registerLink: "Register here",
|
||||
refresh: "Refresh",
|
||||
playerLabel: "Logged in as",
|
||||
skinTitle: "Your skin",
|
||||
skinLead: "Upload a Minecraft skin PNG. The 3D preview below updates instantly; the in-game avatar takes ~1 minute.",
|
||||
skinChoose: "Choose PNG…",
|
||||
modelLabel: "Model",
|
||||
modelClassic: "Classic",
|
||||
modelSlim: "Slim",
|
||||
skinUpload: "Upload skin",
|
||||
skinUploading: "Uploading…",
|
||||
skinOk: "Skin updated!",
|
||||
skinPreviewNote: "Avatar updates in ~1 min.",
|
||||
resetSkin: "Reset to default",
|
||||
resetConfirm: "Reset your skin to the default? This cannot be undone.",
|
||||
resetOk: "Skin reset to default.",
|
||||
errFields: "Fill in every field.",
|
||||
errSkinType: "Pick a PNG image.",
|
||||
errNetwork: "Network error — try again.",
|
||||
},
|
||||
},
|
||||
|
||||
es: {
|
||||
htmlLang: "es",
|
||||
copied: "¡Copiado!",
|
||||
nav: { status: "Estado", features: "Características", join: "Cómo entrar", play: "Jugar ahora" },
|
||||
nav: { status: "Estado", members: "Miembros", features: "Características", mods: "Mods", join: "Cómo entrar", play: "Jugar", register: "Registro", login: "Cuenta" },
|
||||
hero: {
|
||||
eyebrow: "LAN con mods · NeoForge",
|
||||
tagline: "Supervivencia todo incluido, para durar más que el finde.",
|
||||
sub:
|
||||
"Un servidor con mods autoalojado para la cuadrilla: un pack curado de " +
|
||||
"Un servidor con mods autoalojado para El valle: un pack curado de " +
|
||||
"tecnología, magia y exploración, tus propias skins y configuración en un " +
|
||||
"clic. Copia la dirección, importa el pack y a cavar.",
|
||||
copyIp: "Copiar IP",
|
||||
howToJoin: "Cómo entrar",
|
||||
metaModpack: "Pack NeoForge",
|
||||
metaLan: "Solo LAN",
|
||||
},
|
||||
status: {
|
||||
eyebrow: "Servidor",
|
||||
@@ -183,11 +382,17 @@ export const ui: Record<Lang, UI> = {
|
||||
modded: "Con mods",
|
||||
motd: "Pack NeoForge · Simple Voice Chat · Solo LAN",
|
||||
upTo: "hasta",
|
||||
empty: "No hay nadie conectado",
|
||||
},
|
||||
members: {
|
||||
eyebrow: "El valle",
|
||||
title: "Quién está en el servidor.",
|
||||
lead: "Todos los registrados en Ulicraft, cada uno con su skin autoalojada.",
|
||||
empty: "Aún no hay miembros — sé el primero en registrarte.",
|
||||
},
|
||||
statLabels: ["Mods", "NeoForge", "Plazas", "Copias del mundo"],
|
||||
features: {
|
||||
eyebrow: "Qué hace especial a Ulicraft",
|
||||
title: "Hecho para la cuadrilla.",
|
||||
title: "Hecho para El valle.",
|
||||
lead:
|
||||
"Autoalojado, curado y persistente: un servidor con mods que respeta tu " +
|
||||
"tiempo y sobrevive a la LAN.",
|
||||
@@ -202,7 +407,7 @@ export const ui: Record<Lang, UI> = {
|
||||
},
|
||||
{
|
||||
h: "Configuración en un clic",
|
||||
p: "packwiz instala y actualiza todo el modpack dentro de tu launcher, así todos vais sincronizados.",
|
||||
p: "El Ulicraft Launcher instala y actualiza todo el modpack desde nuestra distribución, así todos vais sincronizados.",
|
||||
},
|
||||
{
|
||||
h: "Hecho para durar",
|
||||
@@ -210,30 +415,50 @@ export const ui: Record<Lang, UI> = {
|
||||
},
|
||||
],
|
||||
},
|
||||
mods: {
|
||||
eyebrow: "El pack",
|
||||
title: "Todos los mods del pack.",
|
||||
lead: "El modset completo del cliente, instalado automáticamente por el launcher. Sin búsquedas ni versiones incompatibles.",
|
||||
empty: "La lista de mods aún no está generada — vuelve pronto.",
|
||||
},
|
||||
join: {
|
||||
eyebrow: "Cómo entrar · 4 pasos",
|
||||
eyebrow: "Cómo entrar · 2 pasos",
|
||||
title: "De cero a aparecer en el mundo.",
|
||||
lead: "Configuración única. Después, el launcher te mantiene sincronizado.",
|
||||
s1: {
|
||||
kicker: "Cuenta",
|
||||
h: "Regístrate",
|
||||
pa: "Crea tu cuenta de Ulicraft en",
|
||||
pb: "— un usuario y contraseña para el servidor y tu skin.",
|
||||
registerLink: "Registro",
|
||||
},
|
||||
s2: {
|
||||
kicker: "Launcher",
|
||||
h: "Descarga {name}",
|
||||
p: "Coge {name} para tu sistema: un launcher basado en Prism con soporte authlib-injector integrado.",
|
||||
p: "Coge {name}, inicia sesión con tus credenciales de Ulicraft y dale a jugar. Todo lo demás — el modpack, la dirección del servidor, las actualizaciones — se gestiona dentro del launcher.",
|
||||
},
|
||||
},
|
||||
fjord: {
|
||||
navHome: "← Inicio",
|
||||
eyebrow: "Alternativa · Fjord Launcher",
|
||||
title: "Juega con Fjord Launcher.",
|
||||
lead: "¿Prefieres un launcher vanilla basado en Prism? Configúralo a mano con Fjord e importa el pack de Ulicraft.",
|
||||
s1: {
|
||||
kicker: "Launcher",
|
||||
h: "Descarga Fjord",
|
||||
p: "Coge Fjord Launcher para tu sistema: un launcher basado en Prism con soporte authlib-injector integrado.",
|
||||
},
|
||||
s2: {
|
||||
kicker: "Cuenta",
|
||||
h: "Añade tu cuenta",
|
||||
pa: "En el launcher, añade una cuenta",
|
||||
pa: "En Fjord, añade una cuenta",
|
||||
pb: "con esta URL y luego inicia sesión con tus credenciales de Ulicraft.",
|
||||
copy: "Copiar",
|
||||
registerPre: "¿Sin credenciales? Regístrate en",
|
||||
caPre: "¿Fiesta sin internet? Descarga y confía en el",
|
||||
caLink: "certificado CA local",
|
||||
caPost: "para que el mirror de archivos funcione por HTTPS.",
|
||||
},
|
||||
s3: {
|
||||
kicker: "Modpack",
|
||||
h: "Importa el modpack",
|
||||
p: "Añade una instancia nueva desde esta URL de packwiz: instala y actualiza todo el pack.",
|
||||
h: "Importa el pack de Ulicraft",
|
||||
p: "Descarga el modpack de Ulicraft e impórtalo en Fjord como instancia nueva: instala todo el pack.",
|
||||
},
|
||||
s4: {
|
||||
kicker: "Conectar",
|
||||
@@ -242,28 +467,94 @@ export const ui: Record<Lang, UI> = {
|
||||
pb: ", pega la dirección y entra.",
|
||||
copy: "Copiar IP",
|
||||
},
|
||||
noPack: "La descarga del pack aún no está disponible — vuelve pronto.",
|
||||
},
|
||||
footer: {
|
||||
join: "Cómo entrar",
|
||||
status: "Estado del servidor",
|
||||
disc: "No afiliado a Mojang ni Microsoft. Minecraft es una marca de Mojang AB.",
|
||||
},
|
||||
register: {
|
||||
navHome: "← Inicio",
|
||||
eyebrow: "Cuenta",
|
||||
title: "Crea tu cuenta de Ulicraft.",
|
||||
lead: "Un usuario y contraseña para el servidor, tu skin y tu capa. Cosa de un minuto.",
|
||||
usernameLabel: "Usuario",
|
||||
usernamePlaceholder: "tu nombre en el juego",
|
||||
passwordLabel: "Contraseña",
|
||||
passwordPlaceholder: "elige una contraseña",
|
||||
inviteLabel: "Código de invitación",
|
||||
invitePlaceholder: "pega tu código de invitación",
|
||||
inviteHint: "Pide un código de invitación a un admin.",
|
||||
submit: "Crear cuenta",
|
||||
submitting: "Creando…",
|
||||
haveAccount: "¿Ya tienes cuenta?",
|
||||
loginLink: "Gestiónala aquí",
|
||||
successTitle: "Cuenta creada.",
|
||||
successBody: "Bienvenido, {name}. Puedes poner una skin ahora o ir directo a Cómo entrar.",
|
||||
uuidLabel: "UUID del jugador",
|
||||
skinTitle: "Pon tu skin (opcional)",
|
||||
skinLead: "Sube un PNG de skin de Minecraft. Siempre puedes cambiarla luego en tu cuenta.",
|
||||
skinChoose: "Elegir PNG…",
|
||||
modelLabel: "Modelo",
|
||||
modelClassic: "Clásico",
|
||||
modelSlim: "Fino",
|
||||
skinUpload: "Subir skin",
|
||||
skinUploading: "Subiendo…",
|
||||
skinOk: "¡Skin puesta!",
|
||||
finish: "Continuar a Cómo entrar",
|
||||
errFields: "Rellena todos los campos.",
|
||||
errSkinType: "Elige una imagen PNG.",
|
||||
errNetwork: "Error de red: inténtalo de nuevo.",
|
||||
},
|
||||
account: {
|
||||
navHome: "← Inicio",
|
||||
eyebrow: "Cuenta",
|
||||
title: "Gestiona tu skin.",
|
||||
lead: "Inicia sesión para subir o restablecer tu skin de Minecraft.",
|
||||
usernameLabel: "Usuario",
|
||||
usernamePlaceholder: "tu nombre en el juego",
|
||||
passwordLabel: "Contraseña",
|
||||
passwordPlaceholder: "tu contraseña",
|
||||
submit: "Iniciar sesión",
|
||||
submitting: "Entrando…",
|
||||
noAccount: "¿Aún no tienes cuenta?",
|
||||
registerLink: "Regístrate aquí",
|
||||
refresh: "Actualizar",
|
||||
playerLabel: "Sesión iniciada como",
|
||||
skinTitle: "Tu skin",
|
||||
skinLead: "Sube un PNG de skin de Minecraft. La vista previa 3D se actualiza al instante; el avatar en el juego tarda ~1 minuto.",
|
||||
skinChoose: "Elegir PNG…",
|
||||
modelLabel: "Modelo",
|
||||
modelClassic: "Clásico",
|
||||
modelSlim: "Fino",
|
||||
skinUpload: "Subir skin",
|
||||
skinUploading: "Subiendo…",
|
||||
skinOk: "¡Skin actualizada!",
|
||||
skinPreviewNote: "El avatar se actualiza en ~1 min.",
|
||||
resetSkin: "Restablecer skin",
|
||||
resetConfirm: "¿Restablecer tu skin a la predeterminada? Esta acción no se puede deshacer.",
|
||||
resetOk: "Skin restablecida.",
|
||||
errFields: "Rellena todos los campos.",
|
||||
errSkinType: "Elige una imagen PNG.",
|
||||
errNetwork: "Error de red: inténtalo de nuevo.",
|
||||
},
|
||||
},
|
||||
|
||||
eu: {
|
||||
htmlLang: "eu",
|
||||
copied: "Kopiatuta!",
|
||||
nav: { status: "Egoera", features: "Ezaugarriak", join: "Nola sartu", play: "Jokatu orain" },
|
||||
nav: { status: "Egoera", members: "Kideak", features: "Ezaugarriak", mods: "Mods", join: "Nola sartu", play: "Jolastu", register: "Erregistroa", login: "Kontua" },
|
||||
hero: {
|
||||
eyebrow: "Mod-dun LANa · NeoForge",
|
||||
tagline: "Mod ugariko biziraupena, irauteko egina.",
|
||||
sub:
|
||||
"Koadrilarentzako mod-dun zerbitzari auto-ostatatua: teknologia, magia eta " +
|
||||
"El valle-rentzako mod-dun zerbitzari auto-ostatatua: teknologia, magia eta " +
|
||||
"esplorazio pack zaindua, zure azalak eta konfigurazioa klik bakarrean. " +
|
||||
"Hartu helbidea, inportatu packa eta hasi zulatzen.",
|
||||
copyIp: "Kopiatu IPa",
|
||||
howToJoin: "Nola sartu",
|
||||
metaModpack: "NeoForge packa",
|
||||
metaLan: "LAN soilik",
|
||||
},
|
||||
status: {
|
||||
eyebrow: "Zerbitzaria",
|
||||
@@ -272,11 +563,17 @@ export const ui: Record<Lang, UI> = {
|
||||
modded: "Mod-duna",
|
||||
motd: "NeoForge packa · Simple Voice Chat · LAN soilik",
|
||||
upTo: "gehienez",
|
||||
empty: "Inor ez dago konektatuta",
|
||||
},
|
||||
members: {
|
||||
eyebrow: "El valle",
|
||||
title: "Nor dago zerbitzarian.",
|
||||
lead: "Ulicraft-en erregistratutako guztiak, bakoitza bere azal auto-ostatatuarekin.",
|
||||
empty: "Oraindik ez dago kiderik — izan zaitez lehena erregistratzen.",
|
||||
},
|
||||
statLabels: ["Modak", "NeoForge", "Plazak", "Munduaren babeskopiak"],
|
||||
features: {
|
||||
eyebrow: "Zerk egiten du Ulicraft berezi",
|
||||
title: "Koadrilarentzat eginda.",
|
||||
title: "El valle-rentzat eginda.",
|
||||
lead:
|
||||
"Auto-ostatatua, zaindua eta iraunkorra: zure denbora errespetatzen duen " +
|
||||
"eta LANa gainditzen duen mod-dun zerbitzaria.",
|
||||
@@ -291,7 +588,7 @@ export const ui: Record<Lang, UI> = {
|
||||
},
|
||||
{
|
||||
h: "Konfigurazioa klik batean",
|
||||
p: "packwiz-ek modpack osoa instalatu eta eguneratzen du zure launcherrean, denok sinkronizatuta egoteko.",
|
||||
p: "Ulicraft Launcher-ek modpack osoa instalatu eta eguneratzen du gure banaketatik, denok sinkronizatuta egoteko.",
|
||||
},
|
||||
{
|
||||
h: "Irauteko eginda",
|
||||
@@ -299,30 +596,50 @@ export const ui: Record<Lang, UI> = {
|
||||
},
|
||||
],
|
||||
},
|
||||
mods: {
|
||||
eyebrow: "Packa",
|
||||
title: "Packeko mod guztiak.",
|
||||
lead: "Bezeroaren mod-multzo osoa, launcherrak automatikoki instalatua. Bilaketarik gabe, bertsio-gatazkarik gabe.",
|
||||
empty: "Mod zerrenda oraindik ez da sortu — itzuli laster.",
|
||||
},
|
||||
join: {
|
||||
eyebrow: "Nola sartu · 4 urrats",
|
||||
eyebrow: "Nola sartu · 2 urrats",
|
||||
title: "Zerotik mundura agertzera.",
|
||||
lead: "Behin bakarrik konfiguratu. Gero, launcherrak sinkronizatuta mantenduko zaitu.",
|
||||
s1: {
|
||||
kicker: "Kontua",
|
||||
h: "Erregistratu",
|
||||
pa: "Sortu zure Ulicraft kontua hemen:",
|
||||
pb: "— erabiltzaile eta pasahitz bat zerbitzarirako eta zure azalerako.",
|
||||
registerLink: "Erregistroa",
|
||||
},
|
||||
s2: {
|
||||
kicker: "Launcherra",
|
||||
h: "Deskargatu {name}",
|
||||
p: "Hartu {name} zure sistemarako: Prism-en oinarritutako launcherra, authlib-injector euskarriarekin.",
|
||||
p: "Hartu {name}, hasi saioa zure Ulicraft kredentzialekin eta sakatu jolastu. Gainerako guztia — modpacka, zerbitzariaren helbidea, eguneraketak — launcherraren barruan kudeatzen da.",
|
||||
},
|
||||
},
|
||||
fjord: {
|
||||
navHome: "← Hasiera",
|
||||
eyebrow: "Alternatiba · Fjord Launcher",
|
||||
title: "Jokatu Fjord Launcher-ekin.",
|
||||
lead: "Prism-en oinarritutako launcher vanilla nahiago duzu? Konfiguratu eskuz Fjord-ekin eta inportatu Ulicraft packa.",
|
||||
s1: {
|
||||
kicker: "Launcherra",
|
||||
h: "Deskargatu Fjord",
|
||||
p: "Hartu Fjord Launcher zure sistemarako: Prism-en oinarritutako launcherra, authlib-injector euskarriarekin.",
|
||||
},
|
||||
s2: {
|
||||
kicker: "Kontua",
|
||||
h: "Gehitu zure kontua",
|
||||
pa: "Launcherrean, gehitu",
|
||||
pa: "Fjord-en, gehitu",
|
||||
pb: "kontu bat URL honekin, eta gero hasi saioa zure Ulicraft kredentzialekin.",
|
||||
copy: "Kopiatu",
|
||||
registerPre: "Kredentzialik ez? Erregistratu hemen:",
|
||||
caPre: "Internetik gabeko festa? Deskargatu eta fidatu",
|
||||
caLink: "tokiko CA ziurtagiriaz",
|
||||
caPost: "fitxategi-mirrorrak HTTPS bidez ibil dadin.",
|
||||
},
|
||||
s3: {
|
||||
kicker: "Modpacka",
|
||||
h: "Inportatu modpacka",
|
||||
p: "Gehitu instantzia berri bat packwiz URL honetatik: pack osoa instalatu eta eguneratzen du.",
|
||||
h: "Inportatu Ulicraft packa",
|
||||
p: "Deskargatu Ulicraft modpacka eta inportatu Fjord-en instantzia berri gisa: pack osoa instalatzen du.",
|
||||
},
|
||||
s4: {
|
||||
kicker: "Konektatu",
|
||||
@@ -331,10 +648,77 @@ export const ui: Record<Lang, UI> = {
|
||||
pb: ", itsatsi helbidea eta sartu.",
|
||||
copy: "Kopiatu IPa",
|
||||
},
|
||||
noPack: "Packaren deskarga ez dago oraindik eskuragarri — itzuli laster.",
|
||||
},
|
||||
footer: {
|
||||
join: "Nola sartu",
|
||||
status: "Zerbitzariaren egoera",
|
||||
disc: "Ez dago Mojang edo Microsoft-ekin lotuta. Minecraft Mojang AB-ren marka da.",
|
||||
},
|
||||
register: {
|
||||
navHome: "← Hasiera",
|
||||
eyebrow: "Kontua",
|
||||
title: "Sortu zure Ulicraft kontua.",
|
||||
lead: "Erabiltzaile eta pasahitz bat zerbitzarirako, zure azalerako eta kaparako. Minutu batean.",
|
||||
usernameLabel: "Erabiltzailea",
|
||||
usernamePlaceholder: "zure jokoko izena",
|
||||
passwordLabel: "Pasahitza",
|
||||
passwordPlaceholder: "aukeratu pasahitz bat",
|
||||
inviteLabel: "Gonbidapen-kodea",
|
||||
invitePlaceholder: "itsatsi zure gonbidapen-kodea",
|
||||
inviteHint: "Eskatu gonbidapen-kode bat admin bati.",
|
||||
submit: "Sortu kontua",
|
||||
submitting: "Sortzen…",
|
||||
haveAccount: "Baduzu kontua?",
|
||||
loginLink: "Kudeatu hemen",
|
||||
successTitle: "Kontua sortuta.",
|
||||
successBody: "Ongi etorri, {name}. Azala orain jar dezakezu, edo zuzenean Nola sartu atalera joan.",
|
||||
uuidLabel: "Jokalariaren UUIDa",
|
||||
skinTitle: "Jarri zure azala (aukerakoa)",
|
||||
skinLead: "Igo Minecraft azal PNG bat. Beti alda dezakezu gero zure kontuan.",
|
||||
skinChoose: "Aukeratu PNGa…",
|
||||
modelLabel: "Eredua",
|
||||
modelClassic: "Klasikoa",
|
||||
modelSlim: "Mehea",
|
||||
skinUpload: "Igo azala",
|
||||
skinUploading: "Igotzen…",
|
||||
skinOk: "Azala jarrita!",
|
||||
finish: "Jarraitu Nola sartura",
|
||||
errFields: "Bete eremu guztiak.",
|
||||
errSkinType: "Aukeratu PNG irudi bat.",
|
||||
errNetwork: "Sare-errorea: saiatu berriro.",
|
||||
},
|
||||
account: {
|
||||
navHome: "← Hasiera",
|
||||
eyebrow: "Kontua",
|
||||
title: "Kudeatu zure azala.",
|
||||
lead: "Hasi saioa zure Minecraft azala igotzeko edo berrezartzeko.",
|
||||
usernameLabel: "Erabiltzailea",
|
||||
usernamePlaceholder: "zure jokoko izena",
|
||||
passwordLabel: "Pasahitza",
|
||||
passwordPlaceholder: "zure pasahitza",
|
||||
submit: "Hasi saioa",
|
||||
submitting: "Sartzen…",
|
||||
noAccount: "Oraindik konturik ez?",
|
||||
registerLink: "Erregistratu hemen",
|
||||
refresh: "Eguneratu",
|
||||
playerLabel: "Saioa hasita:",
|
||||
skinTitle: "Zure azala",
|
||||
skinLead: "Igo Minecraft azal PNG bat. 3D aurrebista berehala eguneratzen da; jokoko avatarra ~1 minutuan.",
|
||||
skinChoose: "Aukeratu PNGa…",
|
||||
modelLabel: "Eredua",
|
||||
modelClassic: "Klasikoa",
|
||||
modelSlim: "Mehea",
|
||||
skinUpload: "Igo azala",
|
||||
skinUploading: "Igotzen…",
|
||||
skinOk: "Azala eguneratuta!",
|
||||
skinPreviewNote: "Avatarra ~1 min barru eguneratzen da.",
|
||||
resetSkin: "Berrezarri azala",
|
||||
resetConfirm: "Zure azala lehenetsi nahi duzu? Ezin da desegin.",
|
||||
resetOk: "Azala berrezarrita.",
|
||||
errFields: "Bete eremu guztiak.",
|
||||
errSkinType: "Aukeratu PNG irudi bat.",
|
||||
errNetwork: "Sare-errorea: saiatu berriro.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
---
|
||||
import { site, LITERALS, HEAD_FONTS } from "../data/site";
|
||||
import { ui, LANGS, LANG_LABEL, LANG_PATH, type Lang } from "../i18n/ui";
|
||||
import Creeper from "../components/Creeper.astro";
|
||||
import { site, HEAD_FONTS } from "../data/site";
|
||||
import { ui, LANGS, LANG_LABEL, LANG_PATH, LANG_REGISTER_PATH, LANG_ACCOUNT_PATH, type Lang } from "../i18n/ui";
|
||||
import PixelIcon from "../components/PixelIcon.astro";
|
||||
import CopyChip from "../components/CopyChip.astro";
|
||||
import ServerListPanel from "../components/ServerListPanel.astro";
|
||||
import ServerCard from "../components/ServerCard.astro";
|
||||
import PlayerRoster from "../components/PlayerRoster.astro";
|
||||
import ModList from "../components/ModList.astro";
|
||||
import "../styles/main.css";
|
||||
|
||||
// One static page per locale: en at "/", es at "/es/", eu at "/eu/".
|
||||
export function getStaticPaths() {
|
||||
return [
|
||||
{ params: { lang: undefined }, props: { locale: "en" as Lang } },
|
||||
{ params: { lang: "es" }, props: { locale: "es" as Lang } },
|
||||
{ params: { lang: undefined }, props: { locale: "es" as Lang } },
|
||||
{ params: { lang: "en" }, props: { locale: "en" as Lang } },
|
||||
{ params: { lang: "eu" }, props: { locale: "eu" as Lang } },
|
||||
];
|
||||
}
|
||||
|
||||
const { locale } = Astro.props;
|
||||
const t = ui[locale];
|
||||
const { theme, launcher } = site;
|
||||
const { theme, launcherManifest } = site;
|
||||
const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify;
|
||||
const launcherName = launcher.name;
|
||||
const launcherName = launcherManifest.product;
|
||||
const registerPath = LANG_REGISTER_PATH[locale];
|
||||
const accountPath = LANG_ACCOUNT_PATH[locale];
|
||||
|
||||
// Per-OS download buttons (join step 2) are fetched client-side from the live
|
||||
// launcher.json on page load (no build-time baking, no polling) — see the
|
||||
// script below. We only pass the URL; buttons render into the #launcher-dl
|
||||
// skeleton. URL derives from the distribution subdomain.
|
||||
const launcherJsonUrl = `https://distribution.${site.baseDomain}/launcher/launcher.json`;
|
||||
|
||||
// Build-time floating dust bits (index-derived, no runtime RNG).
|
||||
const dustBits = Array.from({ length: 16 }, (_, i) => {
|
||||
@@ -48,7 +56,7 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{site.name} — {t.hero.howToJoin}</title>
|
||||
<meta name="description" content={`${site.name}: ${t.hero.tagline}`} />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||
{LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_PATH[l]} />)}
|
||||
</head>
|
||||
@@ -57,13 +65,12 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
||||
<header class="nav">
|
||||
<div class="wrap nav-in">
|
||||
<a class="brand" href="#top">
|
||||
<Creeper />
|
||||
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
||||
<span class="name">ULICRAFT</span>
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href="#status">{t.nav.status}</a>
|
||||
<a href="#features">{t.nav.features}</a>
|
||||
<a href="#join">{t.nav.join}</a>
|
||||
<a href="#status">{t.nav.status}</a>
|
||||
</nav>
|
||||
<span class="nav-spacer"></span>
|
||||
<div class="lang-switch" aria-label="Language">
|
||||
@@ -71,6 +78,8 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
||||
<a href={LANG_PATH[l]} class:list={[l === locale && "on"]} aria-current={l === locale ? "true" : undefined}>{LANG_LABEL[l]}</a>
|
||||
))}
|
||||
</div>
|
||||
<a class="mc-btn sm" href={registerPath}>{t.nav.register}</a>
|
||||
<a class="mc-btn sm" href={accountPath}>{t.nav.login}</a>
|
||||
<a class="mc-btn primary sm" href="#join">{t.nav.play}</a>
|
||||
</div>
|
||||
</header>
|
||||
@@ -96,75 +105,25 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
||||
<h1 class="hero-tagline">{t.hero.tagline}</h1>
|
||||
<p class="hero-sub">{t.hero.sub}</p>
|
||||
<div class="hero-ip-row">
|
||||
<CopyChip value={site.serverAddress} variant="ip" label={t.hero.copyIp} copiedLabel={t.copied} />
|
||||
<a class="mc-btn" href="#join">{t.hero.howToJoin}</a>
|
||||
<a class="mc-btn primary" href="#join">{t.hero.howToJoin}</a>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span>Java {site.mcVersion}</span>
|
||||
<span class="dot"></span>
|
||||
<span>{t.hero.metaModpack}</span>
|
||||
<span class="dot"></span>
|
||||
<span>{t.hero.metaLan}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-status">
|
||||
<ServerListPanel
|
||||
<ServerCard
|
||||
modded={t.status.modded}
|
||||
motd={t.status.motd}
|
||||
upTo={t.status.upTo}
|
||||
emptyLabel={t.status.empty}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- STATUS -->
|
||||
<section id="status" class="pad">
|
||||
<div class="wrap">
|
||||
<div class="sec-head reveal">
|
||||
<span class="eyebrow">{t.status.eyebrow}</span>
|
||||
<h2 class="section-title">{t.status.title}</h2>
|
||||
<p class="lead">{t.status.lead}</p>
|
||||
</div>
|
||||
<div class="reveal">
|
||||
<ServerListPanel
|
||||
modded={t.status.modded}
|
||||
motd={t.status.motd}
|
||||
upTo={t.status.upTo}
|
||||
/>
|
||||
</div>
|
||||
<div class="stat-grid">
|
||||
{site.stats.map((s, i) => (
|
||||
<div class="tile reveal" style={`transition-delay:${i * 60}ms`}>
|
||||
<div class="k">{s.key}</div>
|
||||
<div class="l">{t.statLabels[i]}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FEATURES -->
|
||||
<section id="features" class="pad" style="background: var(--bg-2)">
|
||||
<div class="wrap">
|
||||
<div class="sec-head reveal">
|
||||
<span class="eyebrow">{t.features.eyebrow}</span>
|
||||
<h2 class="section-title">{t.features.title}</h2>
|
||||
<p class="lead">{t.features.lead}</p>
|
||||
</div>
|
||||
<div class="feat-grid">
|
||||
{site.features.map((f, i) => (
|
||||
<div class="feat reveal" style={`transition-delay:${(i % 2) * 80}ms`}>
|
||||
<PixelIcon glyph={f.glyph} gold={f.gold} />
|
||||
<div>
|
||||
<h3>{t.features.items[i].h}</h3>
|
||||
<p>{t.features.items[i].p}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- HOW TO JOIN -->
|
||||
<section id="join" class="pad">
|
||||
<div class="wrap">
|
||||
@@ -180,15 +139,10 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
||||
<div class="num">1</div>
|
||||
<div class="body">
|
||||
<span class="kicker">{t.join.s1.kicker}</span>
|
||||
<h3>{t.join.s1.h.replace("{name}", launcherName)}</h3>
|
||||
<p>{t.join.s1.p.replace(/\{name\}/g, launcherName)}</p>
|
||||
<div class="actions">
|
||||
{launcher.downloads.map((d) => (
|
||||
<a class="mc-btn sm" href={`${launcher.base}/${d.file}`}>
|
||||
{d.os} <span class="hint">· {d.hint}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<h3>{t.join.s1.h}</h3>
|
||||
<p>
|
||||
{t.join.s1.pa} <a href={registerPath}>{t.join.s1.registerLink}</a> {t.join.s1.pb}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -197,48 +151,88 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
||||
<div class="num">2</div>
|
||||
<div class="body">
|
||||
<span class="kicker">{t.join.s2.kicker}</span>
|
||||
<h3>{t.join.s2.h}</h3>
|
||||
<p>
|
||||
{t.join.s2.pa} <strong style="color:var(--text)">{LITERALS.authlib}</strong> {t.join.s2.pb}
|
||||
</p>
|
||||
<div class="actions" style="margin-bottom:14px">
|
||||
<CopyChip value={site.authlibUrl} variant="url" label={t.join.s2.copy} copiedLabel={t.copied} />
|
||||
</div>
|
||||
<p>
|
||||
{t.join.s2.registerPre} <a href={site.authUrl}>{site.authUrl}</a>.
|
||||
</p>
|
||||
<span class="hint">
|
||||
{t.join.s2.caPre} <a href={site.caCertUrl}>{t.join.s2.caLink}</a> {t.join.s2.caPost}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 3 -->
|
||||
<div class="step reveal">
|
||||
<div class="num">3</div>
|
||||
<div class="body">
|
||||
<span class="kicker">{t.join.s3.kicker}</span>
|
||||
<h3>{t.join.s3.h}</h3>
|
||||
<p>{t.join.s3.p}</p>
|
||||
<div class="actions">
|
||||
<CopyChip value={site.packwizUrl} variant="url" label={t.join.s2.copy} copiedLabel={t.copied} />
|
||||
<h3>{t.join.s2.h.replace("{name}", launcherName)}</h3>
|
||||
<p>{t.join.s2.p.replace(/\{name\}/g, launcherName)}</p>
|
||||
<div class="actions" id="launcher-dl" data-src={launcherJsonUrl}>
|
||||
<span class="mc-btn sm dl-skeleton" aria-hidden="true"></span>
|
||||
<span class="mc-btn sm dl-skeleton" aria-hidden="true"></span>
|
||||
<span class="mc-btn sm dl-skeleton" aria-hidden="true"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- STEP 4 -->
|
||||
<div class="step reveal">
|
||||
<div class="num">4</div>
|
||||
<div class="body">
|
||||
<span class="kicker">{t.join.s4.kicker}</span>
|
||||
<h3>{t.join.s4.h}</h3>
|
||||
<p>
|
||||
{t.join.s4.pa} <kbd>{LITERALS.multiplayer}</kbd> → <kbd>{LITERALS.addServer}</kbd>{t.join.s4.pb}
|
||||
</p>
|
||||
<div class="actions">
|
||||
<CopyChip value={site.serverAddress} variant="ip" label={t.join.s4.copy} copiedLabel={t.copied} />
|
||||
<!-- STATUS -->
|
||||
<section id="status" class="pad">
|
||||
<div class="wrap">
|
||||
<div class="sec-head reveal">
|
||||
<span class="eyebrow">{t.status.eyebrow}</span>
|
||||
<h2 class="section-title">{t.status.title}</h2>
|
||||
<p class="lead">{t.status.lead}</p>
|
||||
</div>
|
||||
<div class="reveal">
|
||||
<ServerCard
|
||||
modded={t.status.modded}
|
||||
motd={t.status.motd}
|
||||
upTo={t.status.upTo}
|
||||
emptyLabel={t.status.empty}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- MEMBERS (registered players roster) -->
|
||||
<section id="members" class="pad" style="background: var(--bg-2)">
|
||||
<div class="wrap">
|
||||
<div class="sec-head reveal">
|
||||
<span class="eyebrow">{t.members.eyebrow}</span>
|
||||
<h2 class="section-title">{t.members.title}</h2>
|
||||
<p class="lead">{t.members.lead}</p>
|
||||
</div>
|
||||
<div class="reveal">
|
||||
<PlayerRoster emptyLabel={t.members.empty} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- MODS (installed-pack list, auto-generated) -->
|
||||
<section id="mods" class="pad" style="background: var(--bg-2)">
|
||||
<div class="wrap">
|
||||
<div class="sec-head reveal">
|
||||
<span class="eyebrow">{t.mods.eyebrow}</span>
|
||||
<h2 class="section-title">{t.mods.title}</h2>
|
||||
<p class="lead">{t.mods.lead}</p>
|
||||
</div>
|
||||
<div class="reveal">
|
||||
<ModList emptyLabel={t.mods.empty} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FEATURES -->
|
||||
<section id="features" class="pad">
|
||||
<div class="wrap">
|
||||
<div class="sec-head reveal">
|
||||
<span class="eyebrow">{t.features.eyebrow}</span>
|
||||
<h2 class="section-title">{t.features.title}</h2>
|
||||
<p class="lead">{t.features.lead}</p>
|
||||
</div>
|
||||
<div class="feat-split">
|
||||
<div class="feat-grid">
|
||||
{site.features.map((f, i) => (
|
||||
<div class="feat reveal" style={`transition-delay:${(i % 2) * 80}ms`}>
|
||||
<PixelIcon glyph={f.glyph} gold={f.gold} />
|
||||
<div>
|
||||
<h3>{t.features.items[i].h}</h3>
|
||||
<p>{t.features.items[i].p}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class="feat-aside reveal">
|
||||
<img src="/Goat_JE1_BE1.webp" alt="" width="1200" height="1200" loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -249,11 +243,12 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
||||
<footer class="footer">
|
||||
<div class="wrap footer-in">
|
||||
<a class="brand" href="#top">
|
||||
<Creeper />
|
||||
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
||||
<span class="name">ULICRAFT</span>
|
||||
</a>
|
||||
<div class="footer-links">
|
||||
<a class="mc-btn primary sm" href="#join">{t.footer.join}</a>
|
||||
<a class="mc-btn sm" href={site.statusUrl} target="_blank" rel="noopener">{t.footer.status}</a>
|
||||
</div>
|
||||
<p class="disc">
|
||||
{t.footer.disc} {site.name} · {site.baseDomain}
|
||||
@@ -312,6 +307,35 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
||||
);
|
||||
els.forEach((el) => io.observe(el));
|
||||
}
|
||||
|
||||
// Launcher downloads — fetch the live launcher.json once on load (no
|
||||
// polling) and render per-OS buttons into the #launcher-dl skeleton.
|
||||
// launcher.json shape: { files: [{ name, os, arch, ext, url }] }.
|
||||
const dl = document.getElementById("launcher-dl");
|
||||
const src = dl?.dataset.src;
|
||||
if (dl && src) {
|
||||
const OS: Record<string, string> = { windows: "Windows", macos: "macOS", linux: "Linux" };
|
||||
fetch(src, { cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
||||
.then((data) => {
|
||||
const files = Array.isArray(data?.files) ? data.files : [];
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const f of files) {
|
||||
if (!f?.url) continue;
|
||||
const a = document.createElement("a");
|
||||
a.className = "mc-btn sm";
|
||||
a.href = f.url;
|
||||
a.append(`${OS[f.os] ?? f.os ?? "Download"} `);
|
||||
const hint = document.createElement("span");
|
||||
hint.className = "hint";
|
||||
hint.textContent = `· ${[f.arch, f.ext].filter(Boolean).join(" · ")}`;
|
||||
a.append(hint);
|
||||
frag.append(a);
|
||||
}
|
||||
dl.replaceChildren(frag); // empty list → no buttons (skeleton cleared)
|
||||
})
|
||||
.catch(() => dl.replaceChildren()); // unreachable/error → clear skeleton
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
370
landing/src/pages/[...lang]/account.astro
Normal file
370
landing/src/pages/[...lang]/account.astro
Normal file
@@ -0,0 +1,370 @@
|
||||
---
|
||||
import { site, HEAD_FONTS } from "../../data/site";
|
||||
import {
|
||||
ui,
|
||||
LANGS,
|
||||
LANG_LABEL,
|
||||
LANG_PATH,
|
||||
LANG_ACCOUNT_PATH,
|
||||
LANG_REGISTER_PATH,
|
||||
type Lang,
|
||||
} from "../../i18n/ui";
|
||||
import "../../styles/main.css";
|
||||
|
||||
// One static account page per locale: /account, /es/account, /eu/account.
|
||||
export function getStaticPaths() {
|
||||
return [
|
||||
{ params: { lang: undefined }, props: { locale: "es" as Lang } },
|
||||
{ params: { lang: "en" }, props: { locale: "en" as Lang } },
|
||||
{ params: { lang: "eu" }, props: { locale: "eu" as Lang } },
|
||||
];
|
||||
}
|
||||
|
||||
const { locale } = Astro.props;
|
||||
const t = ui[locale];
|
||||
const a = t.account;
|
||||
const { theme } = site;
|
||||
const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify;
|
||||
const homePath = LANG_PATH[locale];
|
||||
const registerPath = LANG_REGISTER_PATH[locale];
|
||||
const accountPath = LANG_ACCOUNT_PATH[locale];
|
||||
|
||||
// Strings the client script needs at runtime (kept minimal, passed via define:vars).
|
||||
const clientCfg = {
|
||||
apiUrl: site.draslApiUrl,
|
||||
avatarUrl: site.avatarUrl,
|
||||
avatarMode: site.avatarMode,
|
||||
submit: a.submit,
|
||||
submitting: a.submitting,
|
||||
skinUpload: a.skinUpload,
|
||||
skinUploading: a.skinUploading,
|
||||
skinOk: a.skinOk,
|
||||
refresh: a.refresh,
|
||||
skinPreviewNote: a.skinPreviewNote,
|
||||
resetSkin: a.resetSkin,
|
||||
resetConfirm: a.resetConfirm,
|
||||
resetOk: a.resetOk,
|
||||
errFields: a.errFields,
|
||||
errSkinType: a.errSkinType,
|
||||
errNetwork: a.errNetwork,
|
||||
};
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html
|
||||
lang={t.htmlLang}
|
||||
data-mood={theme.mood}
|
||||
data-head={theme.headFont}
|
||||
style={`--font-head: ${headFont}`}
|
||||
>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{site.name} — {a.title}</title>
|
||||
<meta name="description" content={a.lead} />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||
{LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_ACCOUNT_PATH[l]} />)}
|
||||
</head>
|
||||
<body>
|
||||
<!-- NAV -->
|
||||
<header class="nav">
|
||||
<div class="wrap nav-in">
|
||||
<a class="brand" href={homePath}>
|
||||
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
||||
<span class="name">ULICRAFT</span>
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href={homePath}>{a.navHome}</a>
|
||||
</nav>
|
||||
<span class="nav-spacer"></span>
|
||||
<div class="lang-switch" aria-label="Language">
|
||||
{LANGS.map((l) => (
|
||||
<a
|
||||
href={LANG_ACCOUNT_PATH[l]}
|
||||
class:list={[l === locale && "on"]}
|
||||
aria-current={l === locale ? "true" : undefined}
|
||||
>{LANG_LABEL[l]}</a>
|
||||
))}
|
||||
</div>
|
||||
<a class="mc-btn sm" href={registerPath}>{t.nav.register}</a>
|
||||
<a class="mc-btn sm" href={accountPath}>{t.nav.login}</a>
|
||||
<a class="mc-btn primary sm" href={`${homePath}#join`}>{t.nav.play}</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="top">
|
||||
<section class="pad">
|
||||
<div class="reg-wrap">
|
||||
<div class="reg-card">
|
||||
<span class="eyebrow">{a.eyebrow}</span>
|
||||
<h1>{a.title}</h1>
|
||||
<p class="lead">{a.lead}</p>
|
||||
|
||||
<!-- LOGIN (hidden once authenticated) -->
|
||||
<div id="acc-login">
|
||||
<form class="reg-form" id="acc-form" novalidate>
|
||||
<div class="reg-field">
|
||||
<label for="acc-username">{a.usernameLabel}</label>
|
||||
<input class="reg-input" id="acc-username" name="username"
|
||||
type="text" autocomplete="username" placeholder={a.usernamePlaceholder} required />
|
||||
</div>
|
||||
<div class="reg-field">
|
||||
<label for="acc-password">{a.passwordLabel}</label>
|
||||
<input class="reg-input" id="acc-password" name="password"
|
||||
type="password" autocomplete="current-password" placeholder={a.passwordPlaceholder} required />
|
||||
</div>
|
||||
<div class="reg-msg err" id="acc-error" role="alert" hidden></div>
|
||||
<button class="mc-btn primary" id="acc-submit" type="submit">{a.submit}</button>
|
||||
</form>
|
||||
|
||||
<p class="reg-foot">
|
||||
{a.noAccount} <a href={registerPath}>{a.registerLink}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- SKIN PANEL (revealed after login) -->
|
||||
<div class="reg-success" id="acc-panel" hidden>
|
||||
<div>
|
||||
<span class="eyebrow" style="color:var(--accent)">{a.playerLabel}</span>
|
||||
<p class="lead" id="acc-player-name" style="margin-top:6px;font-size:20px;color:var(--text)"></p>
|
||||
</div>
|
||||
|
||||
<!-- Current avatar preview (NMSR) -->
|
||||
<div id="acc-avatar-wrap" style="display:flex;align-items:flex-start;gap:20px;flex-wrap:wrap">
|
||||
<img id="acc-avatar" src="" alt="avatar" width="96" height="96"
|
||||
style="image-rendering:pixelated;width:96px;height:96px;background:var(--slot)" />
|
||||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:10px;align-items:flex-start">
|
||||
<button class="mc-btn sm" id="acc-avatar-refresh" type="button">{a.refresh}</button>
|
||||
<p class="reg-skin" style="border:none;padding-top:0;margin:0;color:var(--dim);font-size:13px" id="acc-preview-note"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="reg-skin" id="acc-skin">
|
||||
<div>
|
||||
<h3>{a.skinTitle}</h3>
|
||||
<p class="lead">{a.skinLead}</p>
|
||||
</div>
|
||||
<div class="reg-field">
|
||||
<label class="mc-btn sm" for="acc-skin-file" style="align-self:flex-start">{a.skinChoose}</label>
|
||||
<input id="acc-skin-file" type="file" accept="image/png" hidden />
|
||||
<span class="reg-file-name" id="acc-skin-name"></span>
|
||||
</div>
|
||||
<div class="reg-field">
|
||||
<label>{a.modelLabel}</label>
|
||||
<div class="reg-models">
|
||||
<label><input type="radio" name="skin-model" value="classic" checked /> {a.modelClassic}</label>
|
||||
<label><input type="radio" name="skin-model" value="slim" /> {a.modelSlim}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reg-msg" id="acc-skin-msg" role="status" hidden></div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:12px;align-items:center">
|
||||
<button class="mc-btn primary" id="acc-skin-upload" type="button">{a.skinUpload}</button>
|
||||
<button class="mc-btn" id="acc-skin-reset" type="button" style="color:var(--dim)">{a.resetSkin}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="mc-btn sm" href={homePath}>{a.navHome}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer class="footer">
|
||||
<div class="wrap footer-in">
|
||||
<a class="brand" href={homePath}>
|
||||
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
||||
<span class="name">ULICRAFT</span>
|
||||
</a>
|
||||
<div class="footer-links">
|
||||
<a class="mc-btn sm" href={site.statusUrl} target="_blank" rel="noopener">{t.footer.status}</a>
|
||||
</div>
|
||||
<p class="disc">
|
||||
{t.footer.disc} {site.name} · {site.baseDomain}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script define:vars={{ cfg: clientCfg }}>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const form = $("acc-form");
|
||||
const loginBox = $("acc-login");
|
||||
const errBox = $("acc-error");
|
||||
const submitBtn = $("acc-submit");
|
||||
const panel = $("acc-panel");
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
|
||||
// Token + player kept in memory only — never stored in localStorage.
|
||||
let apiToken = null;
|
||||
let player = null;
|
||||
|
||||
const showMsg = (box, msg) => {
|
||||
box.textContent = msg;
|
||||
box.hidden = false;
|
||||
};
|
||||
const apiMessage = async (res, fallback) => {
|
||||
try {
|
||||
const data = await res.json();
|
||||
return data && data.message ? data.message : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Login ----
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
errBox.hidden = true;
|
||||
const username = $("acc-username").value.trim();
|
||||
const password = $("acc-password").value;
|
||||
if (!username || !password) {
|
||||
errBox.classList.add("err");
|
||||
showMsg(errBox, cfg.errFields);
|
||||
return;
|
||||
}
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = cfg.submitting;
|
||||
try {
|
||||
const res = await fetch(`${cfg.apiUrl}/login`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
errBox.classList.add("err");
|
||||
showMsg(errBox, await apiMessage(res, cfg.errNetwork));
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
apiToken = data.apiToken;
|
||||
player = data.user.players[0];
|
||||
|
||||
// Show panel — hide the whole login block (form + heading foot).
|
||||
loginBox.hidden = true;
|
||||
$("acc-player-name").textContent = player.name;
|
||||
// Set NMSR avatar
|
||||
const avatarImg = $("acc-avatar");
|
||||
avatarImg.src = `${cfg.avatarUrl}/${cfg.avatarMode}/${player.uuid}?size=96`;
|
||||
avatarImg.alt = player.name;
|
||||
// Show NMSR lag hint
|
||||
$("acc-preview-note").textContent = cfg.skinPreviewNote;
|
||||
panel.hidden = false;
|
||||
} catch {
|
||||
errBox.classList.add("err");
|
||||
showMsg(errBox, cfg.errNetwork);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = cfg.submit;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Avatar refresh (re-fetch NMSR with a cache-bust) ----
|
||||
$("acc-avatar-refresh").addEventListener("click", () => {
|
||||
if (!player) return;
|
||||
$("acc-avatar").src = `${cfg.avatarUrl}/${cfg.avatarMode}/${player.uuid}?size=96&t=${Date.now()}`;
|
||||
});
|
||||
|
||||
// ---- Skin upload ----
|
||||
const fileInput = $("acc-skin-file");
|
||||
const fileName = $("acc-skin-name");
|
||||
const skinMsg = $("acc-skin-msg");
|
||||
const skinBtn = $("acc-skin-upload");
|
||||
const resetBtn = $("acc-skin-reset");
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
skinMsg.hidden = true;
|
||||
skinMsg.classList.remove("ok", "err");
|
||||
fileName.textContent = fileInput.files && fileInput.files[0] ? fileInput.files[0].name : "";
|
||||
});
|
||||
|
||||
const readBase64 = (file) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => resolve(String(fr.result).split(",")[1]);
|
||||
fr.onerror = reject;
|
||||
fr.readAsDataURL(file);
|
||||
});
|
||||
|
||||
skinBtn.addEventListener("click", async () => {
|
||||
skinMsg.hidden = true;
|
||||
skinMsg.classList.remove("ok", "err");
|
||||
const file = fileInput.files && fileInput.files[0];
|
||||
if (!file) {
|
||||
skinMsg.classList.add("err");
|
||||
showMsg(skinMsg, cfg.errSkinType);
|
||||
return;
|
||||
}
|
||||
if (file.type !== "image/png") {
|
||||
skinMsg.classList.add("err");
|
||||
showMsg(skinMsg, cfg.errSkinType);
|
||||
return;
|
||||
}
|
||||
const model = document.querySelector('input[name="skin-model"]:checked').value;
|
||||
skinBtn.disabled = true;
|
||||
resetBtn.disabled = true;
|
||||
skinBtn.textContent = cfg.skinUploading;
|
||||
try {
|
||||
const skinBase64 = await readBase64(file);
|
||||
const res = await fetch(`${cfg.apiUrl}/players/${player.uuid}`, {
|
||||
method: "PATCH",
|
||||
headers: { ...headers, Authorization: `Bearer ${apiToken}` },
|
||||
body: JSON.stringify({ skinBase64, skinModel: model }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
skinMsg.classList.add("err");
|
||||
showMsg(skinMsg, await apiMessage(res, cfg.errNetwork));
|
||||
return;
|
||||
}
|
||||
// Show the local file immediately as instant confirmation (NMSR lags ~1m).
|
||||
const dataUrl = URL.createObjectURL(file);
|
||||
$("acc-avatar").src = dataUrl;
|
||||
skinMsg.classList.add("ok");
|
||||
showMsg(skinMsg, cfg.skinOk);
|
||||
// Clear file picker
|
||||
fileInput.value = "";
|
||||
fileName.textContent = "";
|
||||
} catch {
|
||||
skinMsg.classList.add("err");
|
||||
showMsg(skinMsg, cfg.errNetwork);
|
||||
} finally {
|
||||
skinBtn.disabled = false;
|
||||
resetBtn.disabled = false;
|
||||
skinBtn.textContent = cfg.skinUpload;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Reset to default ----
|
||||
resetBtn.addEventListener("click", async () => {
|
||||
if (!window.confirm(cfg.resetConfirm)) return;
|
||||
skinMsg.hidden = true;
|
||||
skinMsg.classList.remove("ok", "err");
|
||||
skinBtn.disabled = true;
|
||||
resetBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch(`${cfg.apiUrl}/players/${player.uuid}`, {
|
||||
method: "PATCH",
|
||||
headers: { ...headers, Authorization: `Bearer ${apiToken}` },
|
||||
body: JSON.stringify({ deleteSkin: true }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
skinMsg.classList.add("err");
|
||||
showMsg(skinMsg, await apiMessage(res, cfg.errNetwork));
|
||||
return;
|
||||
}
|
||||
// Reset preview back to NMSR (will eventually show the default skin).
|
||||
$("acc-avatar").src = `${cfg.avatarUrl}/${cfg.avatarMode}/${player.uuid}?size=96&t=${Date.now()}`;
|
||||
skinMsg.classList.add("ok");
|
||||
showMsg(skinMsg, cfg.resetOk);
|
||||
} catch {
|
||||
skinMsg.classList.add("err");
|
||||
showMsg(skinMsg, cfg.errNetwork);
|
||||
} finally {
|
||||
skinBtn.disabled = false;
|
||||
resetBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
231
landing/src/pages/[...lang]/fjord.astro
Normal file
231
landing/src/pages/[...lang]/fjord.astro
Normal file
@@ -0,0 +1,231 @@
|
||||
---
|
||||
import { site, LITERALS, HEAD_FONTS } from "../../data/site";
|
||||
import {
|
||||
ui,
|
||||
LANGS,
|
||||
LANG_LABEL,
|
||||
LANG_PATH,
|
||||
LANG_FJORD_PATH,
|
||||
LANG_REGISTER_PATH,
|
||||
LANG_ACCOUNT_PATH,
|
||||
type Lang,
|
||||
} from "../../i18n/ui";
|
||||
import CopyChip from "../../components/CopyChip.astro";
|
||||
import "../../styles/main.css";
|
||||
|
||||
// One static Fjord page per locale: /fjord, /es/fjord, /eu/fjord. This is the
|
||||
// alternative manual path — intentionally NOT linked from the homepage nav.
|
||||
export function getStaticPaths() {
|
||||
return [
|
||||
{ params: { lang: undefined }, props: { locale: "es" as Lang } },
|
||||
{ params: { lang: "en" }, props: { locale: "en" as Lang } },
|
||||
{ params: { lang: "eu" }, props: { locale: "eu" as Lang } },
|
||||
];
|
||||
}
|
||||
|
||||
const { locale } = Astro.props;
|
||||
const t = ui[locale];
|
||||
const f = t.fjord;
|
||||
const { theme, fjord } = site;
|
||||
const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify;
|
||||
const homePath = LANG_PATH[locale];
|
||||
const registerPath = LANG_REGISTER_PATH[locale];
|
||||
const accountPath = LANG_ACCOUNT_PATH[locale];
|
||||
const fjordPack = site.launcherManifest.fjordPack;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html
|
||||
lang={t.htmlLang}
|
||||
data-mood={theme.mood}
|
||||
data-head={theme.headFont}
|
||||
style={`--font-head: ${headFont}`}
|
||||
>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{site.name} — {f.title}</title>
|
||||
<meta name="description" content={f.lead} />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||
{LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_FJORD_PATH[l]} />)}
|
||||
</head>
|
||||
<body>
|
||||
<!-- NAV -->
|
||||
<header class="nav">
|
||||
<div class="wrap nav-in">
|
||||
<a class="brand" href={homePath}>
|
||||
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
||||
<span class="name">ULICRAFT</span>
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href={homePath}>{f.navHome}</a>
|
||||
</nav>
|
||||
<span class="nav-spacer"></span>
|
||||
<div class="lang-switch" aria-label="Language">
|
||||
{LANGS.map((l) => (
|
||||
<a
|
||||
href={LANG_FJORD_PATH[l]}
|
||||
class:list={[l === locale && "on"]}
|
||||
aria-current={l === locale ? "true" : undefined}
|
||||
>{LANG_LABEL[l]}</a>
|
||||
))}
|
||||
</div>
|
||||
<a class="mc-btn sm" href={registerPath}>{t.nav.register}</a>
|
||||
<a class="mc-btn sm" href={accountPath}>{t.nav.login}</a>
|
||||
<a class="mc-btn primary sm" href={`${homePath}#join`}>{t.nav.play}</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="top">
|
||||
<section id="join" class="pad">
|
||||
<div class="wrap">
|
||||
<div class="sec-head reveal">
|
||||
<span class="eyebrow">{f.eyebrow}</span>
|
||||
<h2 class="section-title">{f.title}</h2>
|
||||
<p class="lead">{f.lead}</p>
|
||||
</div>
|
||||
|
||||
<div class="steps">
|
||||
<!-- STEP 1 -->
|
||||
<div class="step reveal">
|
||||
<div class="num">1</div>
|
||||
<div class="body">
|
||||
<span class="kicker">{f.s1.kicker}</span>
|
||||
<h3>{f.s1.h}</h3>
|
||||
<p>{f.s1.p}</p>
|
||||
<div class="actions">
|
||||
{fjord.downloads.map((d) => (
|
||||
<a class="mc-btn sm" href={`${fjord.base}/${d.file}`}>
|
||||
{d.os} <span class="hint">· {d.hint}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 2 -->
|
||||
<div class="step reveal">
|
||||
<div class="num">2</div>
|
||||
<div class="body">
|
||||
<span class="kicker">{f.s2.kicker}</span>
|
||||
<h3>{f.s2.h}</h3>
|
||||
<p>
|
||||
{f.s2.pa} <strong style="color:var(--text)">{LITERALS.authlib}</strong> {f.s2.pb}
|
||||
</p>
|
||||
<div class="actions">
|
||||
<CopyChip value={site.authlibUrl} variant="url" label={f.s2.copy} copiedLabel={t.copied} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 3 -->
|
||||
<div class="step reveal">
|
||||
<div class="num">3</div>
|
||||
<div class="body">
|
||||
<span class="kicker">{f.s3.kicker}</span>
|
||||
<h3>{f.s3.h}</h3>
|
||||
<p>{f.s3.p}</p>
|
||||
<div class="actions">
|
||||
{fjordPack ? (
|
||||
<a class="mc-btn sm" href={fjordPack.url}>
|
||||
{fjordPack.filename} <span class="hint">· .mrpack</span>
|
||||
</a>
|
||||
) : (
|
||||
<p class="hint">{f.noPack}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 4 -->
|
||||
<div class="step reveal">
|
||||
<div class="num">4</div>
|
||||
<div class="body">
|
||||
<span class="kicker">{f.s4.kicker}</span>
|
||||
<h3>{f.s4.h}</h3>
|
||||
<p>
|
||||
{f.s4.pa} <kbd>{LITERALS.multiplayer}</kbd> → <kbd>{LITERALS.addServer}</kbd>{f.s4.pb}
|
||||
</p>
|
||||
<div class="actions">
|
||||
<CopyChip value={site.serverAddress} variant="ip" label={f.s4.copy} copiedLabel={t.copied} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer class="footer">
|
||||
<div class="wrap footer-in">
|
||||
<a class="brand" href={homePath}>
|
||||
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
||||
<span class="name">ULICRAFT</span>
|
||||
</a>
|
||||
<div class="footer-links">
|
||||
<a class="mc-btn primary sm" href={`${homePath}#join`}>{t.footer.join}</a>
|
||||
<a class="mc-btn sm" href={site.statusUrl} target="_blank" rel="noopener">{t.footer.status}</a>
|
||||
</div>
|
||||
<p class="disc">
|
||||
{t.footer.disc} {site.name} · {site.baseDomain}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Copy-to-clipboard for every [data-copy] button (HTTP-LAN safe fallback).
|
||||
// Mirrors the global handler in [...lang].astro (CopyChip depends on it).
|
||||
function copyText(text: string) {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
} catch {}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
document.querySelectorAll<HTMLButtonElement>("button[data-copy]").forEach((btn) => {
|
||||
let timer: number | undefined;
|
||||
btn.addEventListener("click", () => {
|
||||
copyText(btn.dataset.copy ?? "");
|
||||
const label = btn.dataset.label ?? "Copy";
|
||||
btn.textContent = btn.dataset.copied ?? "Copied!";
|
||||
btn.classList.add("copied");
|
||||
clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
btn.textContent = label;
|
||||
btn.classList.remove("copied");
|
||||
}, 1600);
|
||||
});
|
||||
});
|
||||
|
||||
// Scroll reveal — visible at rest; .anim added when scrolled into view.
|
||||
if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
const els = [...document.querySelectorAll<HTMLElement>(".reveal")];
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
e.target.classList.add("anim");
|
||||
io.unobserve(e.target);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: "0px 0px -10% 0px" }
|
||||
);
|
||||
els.forEach((el) => io.observe(el));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
319
landing/src/pages/[...lang]/register.astro
Normal file
319
landing/src/pages/[...lang]/register.astro
Normal file
@@ -0,0 +1,319 @@
|
||||
---
|
||||
import { site, HEAD_FONTS } from "../../data/site";
|
||||
import {
|
||||
ui,
|
||||
LANGS,
|
||||
LANG_LABEL,
|
||||
LANG_PATH,
|
||||
LANG_REGISTER_PATH,
|
||||
LANG_ACCOUNT_PATH,
|
||||
type Lang,
|
||||
} from "../../i18n/ui";
|
||||
import "../../styles/main.css";
|
||||
|
||||
// One static register page per locale: /register, /es/register, /eu/register.
|
||||
export function getStaticPaths() {
|
||||
return [
|
||||
{ params: { lang: undefined }, props: { locale: "es" as Lang } },
|
||||
{ params: { lang: "en" }, props: { locale: "en" as Lang } },
|
||||
{ params: { lang: "eu" }, props: { locale: "eu" as Lang } },
|
||||
];
|
||||
}
|
||||
|
||||
const { locale } = Astro.props;
|
||||
const t = ui[locale];
|
||||
const r = t.register;
|
||||
const { theme } = site;
|
||||
const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify;
|
||||
const homePath = LANG_PATH[locale];
|
||||
const registerPath = LANG_REGISTER_PATH[locale];
|
||||
const accountPath = LANG_ACCOUNT_PATH[locale];
|
||||
// Visibility of the invite-code field (default hidden). Independent of the
|
||||
// backend gate (site.registrationRequiresInvite / Drasl RequireInvite).
|
||||
const showInvite = site.registrationShowInvite;
|
||||
|
||||
// Strings the client script needs at runtime (kept minimal, passed via define:vars).
|
||||
const clientCfg = {
|
||||
apiUrl: site.draslApiUrl,
|
||||
showInvite,
|
||||
submit: r.submit,
|
||||
submitting: r.submitting,
|
||||
skinUpload: r.skinUpload,
|
||||
skinUploading: r.skinUploading,
|
||||
skinOk: r.skinOk,
|
||||
successBody: r.successBody,
|
||||
errFields: r.errFields,
|
||||
errSkinType: r.errSkinType,
|
||||
errNetwork: r.errNetwork,
|
||||
};
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html
|
||||
lang={t.htmlLang}
|
||||
data-mood={theme.mood}
|
||||
data-head={theme.headFont}
|
||||
style={`--font-head: ${headFont}`}
|
||||
>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{site.name} — {r.title}</title>
|
||||
<meta name="description" content={r.lead} />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||
{LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_REGISTER_PATH[l]} />)}
|
||||
</head>
|
||||
<body>
|
||||
<!-- NAV -->
|
||||
<header class="nav">
|
||||
<div class="wrap nav-in">
|
||||
<a class="brand" href={homePath}>
|
||||
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
||||
<span class="name">ULICRAFT</span>
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href={homePath}>{r.navHome}</a>
|
||||
</nav>
|
||||
<span class="nav-spacer"></span>
|
||||
<div class="lang-switch" aria-label="Language">
|
||||
{LANGS.map((l) => (
|
||||
<a
|
||||
href={LANG_REGISTER_PATH[l]}
|
||||
class:list={[l === locale && "on"]}
|
||||
aria-current={l === locale ? "true" : undefined}
|
||||
>{LANG_LABEL[l]}</a>
|
||||
))}
|
||||
</div>
|
||||
<a class="mc-btn sm" href={registerPath}>{t.nav.register}</a>
|
||||
<a class="mc-btn sm" href={accountPath}>{t.nav.login}</a>
|
||||
<a class="mc-btn primary sm" href={`${homePath}#join`}>{t.nav.play}</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="top">
|
||||
<section class="pad">
|
||||
<div class="reg-wrap">
|
||||
<div class="reg-card">
|
||||
<span class="eyebrow">{r.eyebrow}</span>
|
||||
<h1>{r.title}</h1>
|
||||
<p class="lead">{r.lead}</p>
|
||||
|
||||
<!-- REGISTER FORM -->
|
||||
<form class="reg-form" id="reg-form" novalidate>
|
||||
<div class="reg-field">
|
||||
<label for="reg-username">{r.usernameLabel}</label>
|
||||
<input class="reg-input" id="reg-username" name="username"
|
||||
type="text" autocomplete="username" placeholder={r.usernamePlaceholder} required />
|
||||
</div>
|
||||
<div class="reg-field">
|
||||
<label for="reg-password">{r.passwordLabel}</label>
|
||||
<input class="reg-input" id="reg-password" name="password"
|
||||
type="password" autocomplete="new-password" placeholder={r.passwordPlaceholder} required />
|
||||
</div>
|
||||
{showInvite && (
|
||||
<div class="reg-field">
|
||||
<label for="reg-invite">{r.inviteLabel}</label>
|
||||
<input class="reg-input" id="reg-invite" name="invite"
|
||||
type="text" placeholder={r.invitePlaceholder} required />
|
||||
<span class="hint">{r.inviteHint}</span>
|
||||
</div>
|
||||
)}
|
||||
<div class="reg-msg err" id="reg-error" role="alert" hidden></div>
|
||||
<button class="mc-btn primary" id="reg-submit" type="submit">{r.submit}</button>
|
||||
</form>
|
||||
|
||||
<p class="reg-foot">
|
||||
{r.haveAccount} <a href={LANG_ACCOUNT_PATH[locale]}>{r.loginLink}</a>
|
||||
</p>
|
||||
|
||||
<!-- SUCCESS + SKIN -->
|
||||
<div class="reg-success" id="reg-success" hidden>
|
||||
<div>
|
||||
<span class="eyebrow" style="color:var(--accent)">{r.successTitle}</span>
|
||||
<p class="lead" id="reg-success-body" style="margin-top:10px"></p>
|
||||
</div>
|
||||
<div class="reg-field">
|
||||
<label>{r.uuidLabel}</label>
|
||||
<div class="reg-uuid" id="reg-uuid"></div>
|
||||
</div>
|
||||
|
||||
<div class="reg-skin" id="reg-skin">
|
||||
<div>
|
||||
<h3>{r.skinTitle}</h3>
|
||||
<p class="lead">{r.skinLead}</p>
|
||||
</div>
|
||||
<div class="reg-field">
|
||||
<label class="mc-btn sm" for="reg-skin-file" style="align-self:flex-start">{r.skinChoose}</label>
|
||||
<input id="reg-skin-file" type="file" accept="image/png" hidden />
|
||||
<span class="reg-file-name" id="reg-skin-name"></span>
|
||||
</div>
|
||||
<div class="reg-field">
|
||||
<label>{r.modelLabel}</label>
|
||||
<div class="reg-models">
|
||||
<label><input type="radio" name="skin-model" value="classic" checked /> {r.modelClassic}</label>
|
||||
<label><input type="radio" name="skin-model" value="slim" /> {r.modelSlim}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reg-msg" id="reg-skin-msg" role="status" hidden></div>
|
||||
<button class="mc-btn" id="reg-skin-upload" type="button">{r.skinUpload}</button>
|
||||
</div>
|
||||
|
||||
<a class="mc-btn primary" href={`${homePath}#join`}>{r.finish}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer class="footer">
|
||||
<div class="wrap footer-in">
|
||||
<a class="brand" href={homePath}>
|
||||
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
||||
<span class="name">ULICRAFT</span>
|
||||
</a>
|
||||
<div class="footer-links">
|
||||
<a class="mc-btn sm" href={site.statusUrl} target="_blank" rel="noopener">{t.footer.status}</a>
|
||||
</div>
|
||||
<p class="disc">
|
||||
{t.footer.disc} {site.name} · {site.baseDomain}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script define:vars={{ cfg: clientCfg }}>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const form = $("reg-form");
|
||||
const errBox = $("reg-error");
|
||||
const submitBtn = $("reg-submit");
|
||||
const successBox = $("reg-success");
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
|
||||
let apiToken = null;
|
||||
let player = null;
|
||||
|
||||
const showErr = (box, msg) => {
|
||||
box.textContent = msg;
|
||||
box.hidden = false;
|
||||
};
|
||||
const apiMessage = async (res, fallback) => {
|
||||
try {
|
||||
const data = await res.json();
|
||||
return data && data.message ? data.message : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
errBox.hidden = true;
|
||||
const username = $("reg-username").value.trim();
|
||||
const password = $("reg-password").value;
|
||||
const inviteEl = $("reg-invite");
|
||||
const invite = inviteEl ? inviteEl.value.trim() : null;
|
||||
if (!username || !password || (cfg.showInvite && !invite)) {
|
||||
showErr(errBox, cfg.errFields);
|
||||
return;
|
||||
}
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = cfg.submitting;
|
||||
try {
|
||||
const body = { username, password, playerName: username };
|
||||
if (invite) body.inviteCode = invite;
|
||||
let res = await fetch(`${cfg.apiUrl}/users`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
showErr(errBox, await apiMessage(res, cfg.errNetwork));
|
||||
return;
|
||||
}
|
||||
// Log in to get an API token for the (optional) skin upload.
|
||||
res = await fetch(`${cfg.apiUrl}/login`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
showErr(errBox, await apiMessage(res, cfg.errNetwork));
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
apiToken = data.apiToken;
|
||||
player = data.user.players[0];
|
||||
form.hidden = true;
|
||||
$("reg-uuid").textContent = player.uuid;
|
||||
$("reg-success-body").textContent = cfg.successBody.replace("{name}", player.name);
|
||||
successBox.hidden = false;
|
||||
} catch {
|
||||
showErr(errBox, cfg.errNetwork);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = cfg.submit;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Skin upload ----
|
||||
const fileInput = $("reg-skin-file");
|
||||
const fileName = $("reg-skin-name");
|
||||
const skinMsg = $("reg-skin-msg");
|
||||
const skinBtn = $("reg-skin-upload");
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
skinMsg.hidden = true;
|
||||
fileName.textContent = fileInput.files && fileInput.files[0] ? fileInput.files[0].name : "";
|
||||
});
|
||||
|
||||
const readBase64 = (file) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => resolve(String(fr.result).split(",")[1]);
|
||||
fr.onerror = reject;
|
||||
fr.readAsDataURL(file);
|
||||
});
|
||||
|
||||
skinBtn.addEventListener("click", async () => {
|
||||
skinMsg.hidden = true;
|
||||
skinMsg.classList.remove("ok", "err");
|
||||
const file = fileInput.files && fileInput.files[0];
|
||||
if (!file) {
|
||||
skinMsg.classList.add("err");
|
||||
showErr(skinMsg, cfg.errSkinType);
|
||||
return;
|
||||
}
|
||||
if (file.type !== "image/png") {
|
||||
skinMsg.classList.add("err");
|
||||
showErr(skinMsg, cfg.errSkinType);
|
||||
return;
|
||||
}
|
||||
const model = document.querySelector('input[name="skin-model"]:checked').value;
|
||||
skinBtn.disabled = true;
|
||||
skinBtn.textContent = cfg.skinUploading;
|
||||
try {
|
||||
const skinBase64 = await readBase64(file);
|
||||
const res = await fetch(`${cfg.apiUrl}/players/${player.uuid}`, {
|
||||
method: "PATCH",
|
||||
headers: { ...headers, Authorization: `Bearer ${apiToken}` },
|
||||
body: JSON.stringify({ skinBase64, skinModel: model }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
skinMsg.classList.add("err");
|
||||
showErr(skinMsg, await apiMessage(res, cfg.errNetwork));
|
||||
return;
|
||||
}
|
||||
skinMsg.classList.add("ok");
|
||||
showErr(skinMsg, cfg.skinOk);
|
||||
} catch {
|
||||
skinMsg.classList.add("err");
|
||||
showErr(skinMsg, cfg.errNetwork);
|
||||
} finally {
|
||||
skinBtn.disabled = false;
|
||||
skinBtn.textContent = cfg.skinUpload;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -207,6 +207,26 @@ section { position: relative; z-index: 1; }
|
||||
}
|
||||
.mc-btn.sm { font-size: 14px; padding: 10px 16px 12px; }
|
||||
|
||||
/* Launcher download skeleton (placeholder while launcher.json loads). */
|
||||
.dl-skeleton {
|
||||
min-width: 9.5rem;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
oklch(1 0 0 / 0.04),
|
||||
oklch(1 0 0 / 0.14),
|
||||
oklch(1 0 0 / 0.04)
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: dl-shimmer 1.2s linear infinite;
|
||||
}
|
||||
.dl-skeleton::after { content: "\00a0"; } /* keep height when text is empty */
|
||||
@keyframes dl-shimmer {
|
||||
from { background-position: 200% 0; }
|
||||
to { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* ---- Copy-IP chip ---- */
|
||||
.ip-chip {
|
||||
display: inline-flex;
|
||||
@@ -245,7 +265,7 @@ section { position: relative; z-index: 1; }
|
||||
.ip-chip button:hover { filter: brightness(1.1); }
|
||||
.ip-chip button.copied { background: var(--gold); color: oklch(0.20 0.05 80); }
|
||||
|
||||
/* url variant: long auth/packwiz URLs — smaller, wraps instead of overflowing */
|
||||
/* url variant: long auth URLs — smaller, wraps instead of overflowing */
|
||||
.ip-chip.url { max-width: 100%; }
|
||||
.ip-chip.url .ip-val {
|
||||
font-family: var(--font-mono);
|
||||
@@ -275,6 +295,7 @@ section { position: relative; z-index: 1; }
|
||||
height: 68px;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 12px; text-decoration: none; color: var(--text); }
|
||||
.brand-logo { width: 34px; height: 34px; flex-shrink: 0; }
|
||||
.brand .name {
|
||||
font-family: var(--font-head);
|
||||
font-weight: 600;
|
||||
@@ -451,30 +472,69 @@ section { position: relative; z-index: 1; }
|
||||
.bars i:nth-child(4){height:85%}
|
||||
.bars i:nth-child(5){height:100%}
|
||||
|
||||
/* stat tiles */
|
||||
.stat-grid {
|
||||
/* ============================================================
|
||||
PLAYER ROSTER (shared avatar tiles)
|
||||
Used by ServerCard.astro (online row) and PlayerRoster.astro
|
||||
(Members grid). A tile is an NMSR avatar + the player name.
|
||||
============================================================ */
|
||||
.roster {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-top: 28px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.tile {
|
||||
background: var(--surface);
|
||||
padding: 22px;
|
||||
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
|
||||
.roster-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 8px;
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.tile .k { font-family: var(--font-head); font-weight: 600; font-size: 38px; color: var(--text); line-height: 1; font-variant-numeric: tabular-nums; }
|
||||
.tile .k .u { color: var(--accent); font-size: 22px; }
|
||||
.tile .l { margin-top: 8px; color: var(--dim); font-size: 13px; letter-spacing: 0.04em; text-transform: uppercase; font-family: var(--font-pixel); font-size: 9px; line-height: 1.8; }
|
||||
.roster-tile img {
|
||||
width: auto;
|
||||
height: 230px;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.roster-name {
|
||||
max-width: 100%;
|
||||
color: var(--muted);
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.roster-empty { margin: 0; color: var(--dim); font-size: 14px; }
|
||||
|
||||
/* ============================================================
|
||||
FEATURES
|
||||
============================================================ */
|
||||
.feat-split {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr;
|
||||
gap: 28px;
|
||||
align-items: center;
|
||||
}
|
||||
.feat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
.feat-aside {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.feat-aside img {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
height: auto;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.feat-split { grid-template-columns: 1fr; }
|
||||
}
|
||||
.feat {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
@@ -487,7 +547,7 @@ section { position: relative; z-index: 1; }
|
||||
.feat h3 { font-size: 21px; margin-bottom: 8px; }
|
||||
.feat p { margin: 0; color: var(--muted); font-size: 16px; }
|
||||
|
||||
/* pixel icon */
|
||||
/* pixel icon — plain (no bevel) */
|
||||
.picon {
|
||||
width: 52px; height: 52px; flex-shrink: 0;
|
||||
display: grid;
|
||||
@@ -495,12 +555,17 @@ section { position: relative; z-index: 1; }
|
||||
grid-template-rows: repeat(7, 1fr);
|
||||
background: var(--slot);
|
||||
padding: 4px;
|
||||
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.picon i { background: transparent; }
|
||||
.picon i.on { background: var(--accent); }
|
||||
.picon i.go { background: var(--gold); }
|
||||
/* custom image icon (PixelIcon `img` prop) */
|
||||
.picon-img {
|
||||
width: 52px; height: 52px; flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
HOW TO JOIN — steps
|
||||
@@ -569,7 +634,6 @@ section { position: relative; z-index: 1; }
|
||||
:root[data-head="8bit"] .step .num { font-size: 30px; }
|
||||
:root[data-head="8bit"] .feat h3 { font-size: 15px; line-height: 1.4; }
|
||||
:root[data-head="8bit"] .serverlist .title { font-size: 16px; }
|
||||
:root[data-head="8bit"] .tile .k { font-size: 26px; }
|
||||
:root[data-head="8bit"] .ip-chip button { font-size: 12px; }
|
||||
:root[data-head="8bit"] .live-pill { font-size: 12px; }
|
||||
|
||||
@@ -588,13 +652,70 @@ section { position: relative; z-index: 1; }
|
||||
.reveal.anim { animation: reveal-in .6s cubic-bezier(.2,.7,.3,1) both; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
REGISTER PAGE
|
||||
============================================================ */
|
||||
.reg-wrap { width: min(560px, calc(100% - 48px)); margin-inline: auto; }
|
||||
.reg-card {
|
||||
background: var(--surface);
|
||||
padding: clamp(24px, 4vw, 40px);
|
||||
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo), 0 8px 24px oklch(0 0 0 / 0.4);
|
||||
}
|
||||
.reg-card .eyebrow { display: block; margin-bottom: 12px; }
|
||||
.reg-card h1 { font-size: clamp(28px, 4vw, 40px); }
|
||||
.reg-card .lead { margin: 14px 0 0; font-size: 17px; }
|
||||
|
||||
.reg-form { display: flex; flex-direction: column; gap: 18px; margin-top: 28px; }
|
||||
.reg-field { display: flex; flex-direction: column; gap: 7px; }
|
||||
.reg-field label {
|
||||
font-family: var(--font-pixel); font-size: 9px; letter-spacing: 0.1em;
|
||||
color: var(--dim); text-transform: uppercase; line-height: 1.8;
|
||||
}
|
||||
.reg-input {
|
||||
font-family: var(--font-body); font-size: 16px; color: var(--text);
|
||||
background: var(--slot); border: 0; padding: 13px 14px;
|
||||
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
||||
}
|
||||
.reg-input::placeholder { color: var(--dim); }
|
||||
.reg-input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.reg-field .hint { color: var(--dim); font-size: 13px; }
|
||||
|
||||
.reg-models { display: flex; gap: 10px; }
|
||||
.reg-models label {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
font-family: var(--font-body); font-size: 15px; letter-spacing: 0; text-transform: none;
|
||||
color: var(--muted); cursor: pointer;
|
||||
background: var(--slot); padding: 9px 14px;
|
||||
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
||||
}
|
||||
.reg-models input { accent-color: var(--accent); }
|
||||
|
||||
.reg-msg { font-size: 15px; padding: 12px 14px; box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi); }
|
||||
.reg-msg[hidden] { display: none; }
|
||||
.reg-msg.err { background: oklch(0.30 0.10 30); color: oklch(0.92 0.04 40); }
|
||||
.reg-msg.ok { background: var(--slot); color: var(--accent); }
|
||||
|
||||
.reg-foot { margin-top: 22px; color: var(--dim); font-size: 14.5px; }
|
||||
.reg-foot a { color: var(--accent); }
|
||||
|
||||
.reg-success[hidden], .reg-skin[hidden] { display: none; }
|
||||
.reg-success { margin-top: 26px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.reg-uuid {
|
||||
font-family: var(--font-mono); font-size: 17px; color: var(--gold);
|
||||
background: var(--slot); padding: 8px 12px; word-break: break-all;
|
||||
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
||||
}
|
||||
.reg-skin { margin-top: 8px; display: flex; flex-direction: column; gap: 16px; border-top: 1px solid var(--line); padding-top: 22px; }
|
||||
.reg-skin h3 { font-size: 22px; }
|
||||
.reg-skin .lead { font-size: 15px; }
|
||||
.reg-file-name { color: var(--muted); font-size: 14px; }
|
||||
|
||||
/* ============================================================
|
||||
RESPONSIVE
|
||||
============================================================ */
|
||||
@media (max-width: 880px) {
|
||||
:root[data-hero="split"] .hero-grid { grid-template-columns: 1fr; }
|
||||
:root[data-hero="split"] .hero-status { display: block; }
|
||||
.stat-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.feat-grid { grid-template-columns: 1fr; }
|
||||
.nav-links { display: none; }
|
||||
.serverlist { flex-wrap: wrap; }
|
||||
@@ -604,5 +725,4 @@ section { position: relative; z-index: 1; }
|
||||
.step { grid-template-columns: 1fr; gap: 18px; }
|
||||
.step .num { width: 64px; height: 64px; font-size: 32px; }
|
||||
.ip-chip .ip-val { font-size: 22px; }
|
||||
.stat-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
163
mods-sides.json
Normal file
163
mods-sides.json
Normal file
@@ -0,0 +1,163 @@
|
||||
{
|
||||
"BetterF3-11.0.3-NeoForge-1.21.1.jar": "client",
|
||||
"BridgingMod-2.6.2+1.21.1.neoforge-release.jar": "client",
|
||||
"CodeChickenLib-1.21.1-4.6.1.526.jar": "both",
|
||||
"Controlling-neoforge-1.21.1-19.0.5.jar": "client",
|
||||
"CreateDragonsPlus-1.11.2b.jar": "both",
|
||||
"CreativeCore_NEOFORGE_v2.13.41_mc1.21.1.jar": "both",
|
||||
"CustomSkinLoader_Universal-14.28.jar": "client",
|
||||
"EasierSleeping-1.21.1-4.0.1.jar": "both",
|
||||
"EasyAnvils-v21.1.0-1.21.1-NeoForge.jar": "both",
|
||||
"EasyMagic-v21.1.4-1.21.1-NeoForge.jar": "both",
|
||||
"EnderStorage-1.21.1-2.13.0.191.jar": "both",
|
||||
"ExplorersCompass-1.21.1-3.4.0-neoforge.jar": "both",
|
||||
"ExtremeSoundMuffler-3.56_NeoForge-1.21.jar": "client",
|
||||
"FarmersDelight-1.21.1-1.3.2.jar": "both",
|
||||
"HangGlider-v21.1.0-1.21.1-NeoForge.jar": "both",
|
||||
"ImmediatelyFast-NeoForge-1.6.10+1.21.1.jar": "client",
|
||||
"Jade-1.21.1-NeoForge-15.10.5.jar": "both",
|
||||
"JadeAddons-1.21.1-NeoForge-6.1.0.jar": "both",
|
||||
"JustEnoughProfessions-neoforge-1.21.1-4.0.5.jar": "both",
|
||||
"JustEnoughResources-NeoForge-1.21.1-1.6.0.17.jar": "client",
|
||||
"MoogsEndStructures-1.21-2.0.3.jar": "both",
|
||||
"MoogsMissingVillages-1.21-2.1.1.jar": "both",
|
||||
"MoogsNetherStructures-1.21-3.0.0-alpha.2.jar": "both",
|
||||
"MoogsVoyagerStructures-1.21-5.0.11.jar": "both",
|
||||
"MouseTweaks-neoforge-mc1.21-2.26.1.jar": "client",
|
||||
"Neat-1.21-47-NEOFORGE.jar": "both",
|
||||
"Necronomicon-NeoForge-1.6.0+1.21.jar": "both",
|
||||
"Patchouli-1.21.1-93-NEOFORGE.jar": "both",
|
||||
"Ping-Wheel-1.12.2-neoforge-1.21.1.jar": "both",
|
||||
"Placebo-1.21.1-9.9.1.jar": "both",
|
||||
"PuzzlesLib-v21.1.51-1.21.1-NeoForge.jar": "both",
|
||||
"Searchables-neoforge-1.21.1-1.0.2.jar": "client",
|
||||
"TerraBlender-neoforge-1.21.1-4.1.0.8.jar": "both",
|
||||
"ToastControl-1.21.1-9.0.1.jar": "client",
|
||||
"TravelersTitles-1.21.1-NeoForge-5.1.3.jar": "client",
|
||||
"UsefulSlime-neoforge-1.21-1.12.1.jar": "both",
|
||||
"YungsApi-1.21.1-NeoForge-5.1.6.jar": "both",
|
||||
"YungsBetterCaves-1.21.1-NeoForge-3.1.4.jar": "both",
|
||||
"YungsBetterDesertTemples-1.21.1-NeoForge-4.1.5.jar": "both",
|
||||
"YungsBetterDungeons-1.21.1-NeoForge-5.1.4.jar": "both",
|
||||
"YungsBetterEndIsland-1.21.1-NeoForge-3.1.2.jar": "both",
|
||||
"YungsBetterJungleTemples-1.21.1-NeoForge-3.1.2.jar": "both",
|
||||
"YungsBetterMineshafts-1.21.1-NeoForge-5.1.1.jar": "both",
|
||||
"YungsBetterNetherFortresses-1.21.1-NeoForge-3.1.5.jar": "both",
|
||||
"YungsBetterOceanMonuments-1.21.1-NeoForge-4.1.2.jar": "both",
|
||||
"YungsBetterStrongholds-1.21.1-NeoForge-5.1.3.jar": "both",
|
||||
"YungsBetterWitchHuts-1.21.1-NeoForge-4.1.1.jar": "both",
|
||||
"YungsBridges-1.21.1-NeoForge-5.1.1.jar": "both",
|
||||
"YungsCaveBiomes-1.21.1-NeoForge-3.1.1.jar": "both",
|
||||
"amendments-neoforge-1.21-2.1.5.jar": "both",
|
||||
"appleskin-neoforge-mc1.21-3.0.9.jar": "client",
|
||||
"architectury-13.0.8-neoforge.jar": "both",
|
||||
"baguettelib-1.21.1-NeoForge-2.0.3.jar": "both",
|
||||
"balm-neoforge-1.21.1-21.0.58.jar": "both",
|
||||
"buildinggadgets2-1.3.9.jar": "both",
|
||||
"caelus-neoforge-7.0.1+1.21.1.jar": "both",
|
||||
"cardboardboxes-1.21.1-0.1.2.jar": "both",
|
||||
"charginggadgets-1.14.1.jar": "both",
|
||||
"cloth-config-15.0.140-neoforge.jar": "both",
|
||||
"colorfulhearts-neoforge-1.21.1-10.5.9.jar": "client",
|
||||
"comforts-neoforge-9.0.5+1.21.1.jar": "both",
|
||||
"configured-neoforge-1.21.1-2.6.3.jar": "both",
|
||||
"copycats-3.0.4+mc.1.21.1-neoforge.jar": "both",
|
||||
"corpse-neoforge-1.21.1-1.1.13.jar": "both",
|
||||
"corpsecurioscompat-1.21.1-NeoForge-4.0.1.jar": "both",
|
||||
"create-1.21.1-6.0.10.jar": "both",
|
||||
"create-central-kitchen-2.5.0.jar": "both",
|
||||
"create-enchantment-industry-2.4.1.jar": "both",
|
||||
"create-integrated-farming-1.2.6.jar": "both",
|
||||
"create_connected-1.2.2-mc1.21.1.jar": "both",
|
||||
"create_jetpack-forge-5.1.2.jar": "both",
|
||||
"create_jetpack_curios-1.2.0-neoforge-1.21.1.jar": "both",
|
||||
"create_mechanical_chicken-1.21.1-1.3.3-6.0.6.jar": "both",
|
||||
"create_mechanical_extruder-1.21.1-2.2.0-6.0.8.jar": "both",
|
||||
"create_mechanical_spawner-1.21.1-1.3.0-6.0.8.jar": "both",
|
||||
"createaddition-1.6.0.jar": "both",
|
||||
"createdeco-2.1.3.jar": "both",
|
||||
"createornithopterglider-1.2.0-1.21.1.jar": "both",
|
||||
"createsifter-1.21.1-2.2.1.jar": "both",
|
||||
"createultimine-1.21.1-neoforge-1.3.2.jar": "both",
|
||||
"curios-neoforge-9.5.1+1.21.1.jar": "both",
|
||||
"cwb-neoforge-3.0.0+mc1.21.jar": "client",
|
||||
"defaultoptions-neoforge-1.21.1-21.1.6.jar": "both",
|
||||
"drippyloadingscreen_neoforge_3.1.2_MC_1.21.1.jar": "client",
|
||||
"easy-piglins-neoforge-1.21.1-1.1.0.jar": "both",
|
||||
"easy-villagers-neoforge-1.21.1-1.1.42.jar": "both",
|
||||
"easydisenchanting-neoforge-1.0.0-1.21.1.jar": "both",
|
||||
"emotecraft-for-MC1.21.1-2.4.12-neoforge.jar": "both",
|
||||
"enderrf-1.21.1-1.0.1.jar": "both",
|
||||
"entity_model_features-3.2.4-1.21-neoforge.jar": "client",
|
||||
"entity_texture_features_1.21-neoforge-7.1.jar": "client",
|
||||
"entityculling-neoforge-1.10.2-mc1.21.1.jar": "client",
|
||||
"etched-5.1.0.jar": "both",
|
||||
"fancymenu_neoforge_3.9.1_MC_1.21.1.jar": "client",
|
||||
"fastleafdecay-35.jar": "both",
|
||||
"ferritecore-7.0.3-neoforge.jar": "both",
|
||||
"fishingoverhaul-1.21.1-1.2.1.jar": "both",
|
||||
"ftb-chunks-neoforge-2101.1.17.jar": "both",
|
||||
"ftb-essentials-neoforge-2101.1.9.jar": "both",
|
||||
"ftb-library-neoforge-2101.1.31.jar": "both",
|
||||
"ftb-teams-neoforge-2101.1.10.jar": "both",
|
||||
"ftb-ultimine-neoforge-2101.1.14.jar": "both",
|
||||
"functionalstorage-1.21.1-1.5.7.jar": "both",
|
||||
"fusion-1.3.2b-neoforge-mc1.21.1.jar": "client",
|
||||
"fzzy_config-0.7.6+1.21+neoforge.jar": "both",
|
||||
"geckolib-neoforge-1.21.1-4.8.4.jar": "both",
|
||||
"handcrafted-neoforge-1.21.1-4.0.3.jar": "both",
|
||||
"immersive_paintings-neoforge-1.21.1-0.7.8.jar": "both",
|
||||
"inventoryessentials-neoforge-1.21.1-21.1.15.jar": "both",
|
||||
"iris-neoforge-1.8.12+mc1.21.1.jar": "client",
|
||||
"jamlib-neoforge-1.3.6+1.21.1.jar": "both",
|
||||
"jei-1.21.1-neoforge-19.27.0.340.jar": "both",
|
||||
"konkrete_neoforge_1.9.9_MC_1.21.jar": "client",
|
||||
"kotlinforforge-5.11.0-all.jar": "both",
|
||||
"kubejs-create-neoforge-2101.3.1-build.18.jar": "both",
|
||||
"kubejs-neoforge-2101.7.2-build.368.jar": "both",
|
||||
"kubejsdelight-1.1.6.jar": "both",
|
||||
"laserio-1.9.11.jar": "both",
|
||||
"lithium-neoforge-0.15.3+mc1.21.1.jar": "both",
|
||||
"lootjs-neoforge-1.21.1-3.7.0.jar": "both",
|
||||
"lootr-neoforge-1.21.1-1.11.37.120.jar": "both",
|
||||
"mechanical_cow-1.21.1-1.2.0-6.0.8.jar": "both",
|
||||
"mechanicals-1.21.1-1.1.0.jar": "both",
|
||||
"melody_neoforge_1.0.10_MC_1.21.jar": "client",
|
||||
"melter-1.21.1-1.8.2.jar": "both",
|
||||
"mob_grinding_utils-1.1.10+mc1.21.1.jar": "both",
|
||||
"modernfix-neoforge-5.27.12+mc1.21.1.jar": "both",
|
||||
"moogs_structures-neoforge-1.21.1-3.0.0.jar": "both",
|
||||
"moonlight-neoforge-1.21.1-3.0.22.jar": "both",
|
||||
"morefunctionalstorage-1.2.3.jar": "both",
|
||||
"moreoverlays-1.24.2-mc1.21.1-neoforge.jar": "client",
|
||||
"polymorph-neoforge-1.1.0+1.21.1.jar": "both",
|
||||
"ponderjs-neoforge-1.21.1-2.4.0.jar": "client",
|
||||
"rechiseled-1.2.5-neoforge-mc1.21.jar": "both",
|
||||
"resourcefullib-neoforge-1.21-3.0.12.jar": "both",
|
||||
"rhino-2101.2.7-build.85.jar": "both",
|
||||
"rightclickharvest-neoforge-4.6.1+1.21.1.jar": "both",
|
||||
"seamless-loading-screen-2.2.1+1.21-neoforge.jar": "client",
|
||||
"simplemagnets-1.1.12c-neoforge-mc1.21.jar": "both",
|
||||
"sliceanddice-forge-4.2.4.jar": "both",
|
||||
"sodium-extra-neoforge-0.8.7+mc1.21.1.jar": "client",
|
||||
"sodium-neoforge-0.8.12-alpha.4+mc1.21.1.jar": "client",
|
||||
"solcarrot-1.21.1-1.16.6.jar": "both",
|
||||
"sophisticatedbackpacks-1.21.1-3.25.55.1852.jar": "both",
|
||||
"sophisticatedbackpackscreateintegration-1.21.1-0.1.6.99.jar": "both",
|
||||
"sophisticatedcore-1.21.1-1.4.49.1976.jar": "both",
|
||||
"spark-1.10.124-neoforge.jar": "both",
|
||||
"supermartijn642configlib-1.1.8-neoforge-mc1.21.jar": "both",
|
||||
"supermartijn642corelib-1.1.21-neoforge-mc1.21.jar": "both",
|
||||
"tagtooltips-neoforge-1.21.1-1.2.0.jar": "client",
|
||||
"titanium-1.21-4.0.43.jar": "both",
|
||||
"toolsjs-0.1.1.jar": "both",
|
||||
"torchmaster-neoforge-1.21.1-21.1.9.jar": "both",
|
||||
"trashcans-1.0.18c-neoforge-mc1.21.jar": "both",
|
||||
"txnilib-neoforge-1.0.24-1.21.1.jar": "both",
|
||||
"ulicraftmod-1.21.1-1.0.0-6.0.10.jar": "both",
|
||||
"utilitarian-1.21.1-0.19.2.jar": "both",
|
||||
"voicechat-neoforge-1.21.1-2.6.18.jar": "both",
|
||||
"wm_binaries-3.0.0-rc.1.jar": "client",
|
||||
"yeetusexperimentus-neoforge-87.0.0.jar": "client",
|
||||
"yet_another_config_lib_v3-3.8.2+1.21.1-neoforge.jar": "both"
|
||||
}
|
||||
@@ -26,17 +26,20 @@ upstream ulicraft_caddy {
|
||||
server 127.0.0.1:${CADDY_HTTP_PORT};
|
||||
}
|
||||
|
||||
# Brute-force throttle for the publicly-exposed Filestash /admin console (it is
|
||||
# the only credential on files.), applied in its location block below.
|
||||
limit_req_zone $binary_remote_addr zone=files_admin:10m rate=6r/m;
|
||||
|
||||
# HTTP -> HTTPS for every name.
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ${BASE_DOMAIN} www.${BASE_DOMAIN} auth.${BASE_DOMAIN} pack.${BASE_DOMAIN} distribution.${BASE_DOMAIN} avatar.${BASE_DOMAIN} status.${BASE_DOMAIN};
|
||||
server_name ${BASE_DOMAIN} www.${BASE_DOMAIN} auth.${BASE_DOMAIN} distribution.${BASE_DOMAIN} avatar.${BASE_DOMAIN} status.${BASE_DOMAIN} files.${BASE_DOMAIN};
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# One TLS server block per name (each has its own LE cert). All proxy to caddy;
|
||||
# caddy routes by the forwarded Host header (apex -> www, auth -> drasl,
|
||||
# pack -> packwiz files).
|
||||
# caddy routes by the forwarded Host header (apex -> www, auth -> drasl).
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
@@ -90,24 +93,6 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name pack.${BASE_DOMAIN};
|
||||
|
||||
ssl_certificate ${APP_DIR}/certs/pack.${BASE_DOMAIN}/cert.pem;
|
||||
ssl_certificate_key ${APP_DIR}/certs/pack.${BASE_DOMAIN}/key.pem;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
location / {
|
||||
proxy_pass http://ulicraft_caddy;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
@@ -144,6 +129,44 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
# Filestash player file share. Uploads need a real body limit (nginx defaults to
|
||||
# 1m, which rejects almost anything worth sharing) and streaming rather than
|
||||
# spooling — a 2g upload buffered to disk would write it twice on a host whose
|
||||
# disk is already the shared failure domain for Minecraft + Drasl + backups.
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name files.${BASE_DOMAIN};
|
||||
|
||||
ssl_certificate ${APP_DIR}/certs/files.${BASE_DOMAIN}/cert.pem;
|
||||
ssl_certificate_key ${APP_DIR}/certs/files.${BASE_DOMAIN}/key.pem;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
client_max_body_size 2g; # uploads; other vhosts stay at 4m
|
||||
proxy_request_buffering off; # stream uploads, don't spool them to disk
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
|
||||
# /admin is reachable on purpose, so throttle it: it is the only password
|
||||
# on this vhost. 6 req/min, small burst.
|
||||
location /admin {
|
||||
limit_req zone=files_admin burst=3 nodelay;
|
||||
proxy_pass http://ulicraft_caddy;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://ulicraft_caddy;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
|
||||
@@ -5,10 +5,13 @@
|
||||
# the bare BaseURL:
|
||||
# session_server -> {url}/session/minecraft/profile/{uuid}
|
||||
# mojang_api_server -> {url}/users/profiles/minecraft/{name}
|
||||
# and embeds absolute skin URLs (http://auth.${BASE_DOMAIN}/web/texture/skin/…)
|
||||
# in the profile, which NMSR fetches directly (textures_server is only a
|
||||
# fallback). NMSR resolves auth.${BASE_DOMAIN} internally over the docker
|
||||
# network (caddy holds the mcnet alias → drasl), so no public round-trip.
|
||||
# and embeds absolute skin URLs (https://auth.${BASE_DOMAIN}/web/texture/skin/…)
|
||||
# in the profile — Drasl's BaseURL is https, so these are https and NMSR must
|
||||
# fetch them over :443. The mcnet alias only resolves auth. -> caddy on :80, so
|
||||
# the nmsr service pins auth.${BASE_DOMAIN} to the host (extra_hosts in
|
||||
# docker-compose.yml) and talks to the host nginx on :443 (real LE cert). Hence
|
||||
# the https:// endpoints below — they must match the scheme of the embedded
|
||||
# skin URLs, or the skin fetch Connect-fails and avatars render the default skin.
|
||||
|
||||
[server]
|
||||
address = "0.0.0.0"
|
||||
@@ -16,16 +19,27 @@ port = 8080
|
||||
|
||||
[caching]
|
||||
cleanup_interval = "1h"
|
||||
resolve_cache_duration = "15m"
|
||||
# resolve_cache_duration caches the uuid -> profile (incl. skin URL) lookup, so
|
||||
# it bounds how long a changed skin keeps showing the OLD avatar. Kept short so
|
||||
# skin changes on the account page propagate within ~1 min (NMSR has no
|
||||
# per-request force-refresh; a `?skin=` override is ignored by this build). Cost
|
||||
# is one extra internal Drasl profile lookup per avatar per minute — trivial over
|
||||
# mcnet for a 4-10 player server. For an instant global flush: restart nmsr.
|
||||
resolve_cache_duration = "60s"
|
||||
# texture cache stays long: Drasl skin URLs are content-hashed
|
||||
# (/web/texture/skin/<sha256>.png), so a new skin = new URL = fresh fetch; this
|
||||
# 48h cache never serves a stale skin.
|
||||
texture_cache_duration = "48h"
|
||||
|
||||
[caching.cache_biases]
|
||||
|
||||
[mojank]
|
||||
# Point every Mojang endpoint at Drasl (internal, plain http via the caddy alias).
|
||||
session_server = "http://auth.${BASE_DOMAIN}"
|
||||
textures_server = "http://auth.${BASE_DOMAIN}"
|
||||
mojang_api_server = "http://auth.${BASE_DOMAIN}"
|
||||
# Point every Mojang endpoint at Drasl over https — auth. is pinned to the host
|
||||
# (extra_hosts), so this reaches the host nginx on :443 with the real LE cert.
|
||||
# Must stay https to match the https skin URLs Drasl embeds in the profile.
|
||||
session_server = "https://auth.${BASE_DOMAIN}"
|
||||
textures_server = "https://auth.${BASE_DOMAIN}"
|
||||
mojang_api_server = "https://auth.${BASE_DOMAIN}"
|
||||
session_server_rate_limit = 10
|
||||
# This is an offline-mode (authlib-injector) server — Drasl issues v3 offline
|
||||
# UUIDs, so they must be accepted.
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Template sources for pack metadata/config — rendered to their real files by
|
||||
# tooling/render-pack.sh. packwiz must NOT index the templates themselves.
|
||||
*.tmpl
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"enable": true,
|
||||
"loadlist": [
|
||||
{
|
||||
"name": "Ulicraft Skins",
|
||||
"type": "MojangAPI",
|
||||
"apiRoot": "https://auth.${BASE_DOMAIN}/account/",
|
||||
"sessionRoot": "https://auth.${BASE_DOMAIN}/session/"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# Ulicraft modpack (packwiz)
|
||||
|
||||
This directory is the **single source of truth** for the modpack (MC 1.21.1, NeoForge 21.1.209). Curate mods from here with `packwiz modrinth add <slug>` (prefer Modrinth over CurseForge); each mod produces a `mods/<slug>.pw.toml` metadata file referencing the upstream CDN. **Client-only mods must be tagged `side = "client"`** in their `.pw.toml` so the itzg server does not pull and crash on them. After adding/removing mods run `packwiz refresh` to recompute `index.toml`. Clients and the itzg server fetch jars from the upstream CDN referenced in each `.pw.toml`. Curation is an ongoing manual task — no mods are added yet.
|
||||
@@ -1,8 +0,0 @@
|
||||
name = "balm-neoforge-1.21.1-21.0.58"
|
||||
filename = "balm-neoforge-1.21.1-21.0.58.jar"
|
||||
side = "both"
|
||||
|
||||
[download]
|
||||
url = "http://pack.${BASE_DOMAIN}/custom/balm-neoforge-1.21.1-21.0.58.jar"
|
||||
hash-format = "sha256"
|
||||
hash = "aa2463747bad4c2351869226ea7bce7839510dfd2ac387f8d1588b1ce9309c67"
|
||||
@@ -1,8 +0,0 @@
|
||||
name = "CustomSkinLoader_Universal-14.28"
|
||||
filename = "CustomSkinLoader_Universal-14.28.jar"
|
||||
side = "both"
|
||||
|
||||
[download]
|
||||
url = "http://pack.${BASE_DOMAIN}/custom/CustomSkinLoader_Universal-14.28.jar"
|
||||
hash-format = "sha256"
|
||||
hash = "0008fb0a91dd1e84d52162cfe96addb5f6aed3b76d426ea7305d7624e31f1894"
|
||||
@@ -1,8 +0,0 @@
|
||||
name = "inventoryessentials-neoforge-1.21.1-21.1.15"
|
||||
filename = "inventoryessentials-neoforge-1.21.1-21.1.15.jar"
|
||||
side = "both"
|
||||
|
||||
[download]
|
||||
url = "http://pack.${BASE_DOMAIN}/custom/inventoryessentials-neoforge-1.21.1-21.1.15.jar"
|
||||
hash-format = "sha256"
|
||||
hash = "b7a5c5526a45256551543419ba2b5d54e56c585d76a645a8cea8cb8ea843ae2c"
|
||||
@@ -1,8 +0,0 @@
|
||||
name = "Jade-1.21.1-NeoForge-15.10.5"
|
||||
filename = "Jade-1.21.1-NeoForge-15.10.5.jar"
|
||||
side = "both"
|
||||
|
||||
[download]
|
||||
url = "http://pack.${BASE_DOMAIN}/custom/Jade-1.21.1-NeoForge-15.10.5.jar"
|
||||
hash-format = "sha256"
|
||||
hash = "067bb4b007e1d6f6b79f0afe99c91252aa825472b99a76d33a60d24442f9e92d"
|
||||
@@ -1,8 +0,0 @@
|
||||
name = "JadeAddons-1.21.1-NeoForge-6.1.0"
|
||||
filename = "JadeAddons-1.21.1-NeoForge-6.1.0.jar"
|
||||
side = "both"
|
||||
|
||||
[download]
|
||||
url = "http://pack.${BASE_DOMAIN}/custom/JadeAddons-1.21.1-NeoForge-6.1.0.jar"
|
||||
hash-format = "sha256"
|
||||
hash = "5f242668ad710092964d4d9afda8a94fa2401665ea13eea67d55d07e4dff8a64"
|
||||
@@ -1,8 +0,0 @@
|
||||
name = "jei-1.21.1-neoforge-19.27.0.340"
|
||||
filename = "jei-1.21.1-neoforge-19.27.0.340.jar"
|
||||
side = "both"
|
||||
|
||||
[download]
|
||||
url = "http://pack.${BASE_DOMAIN}/custom/jei-1.21.1-neoforge-19.27.0.340.jar"
|
||||
hash-format = "sha256"
|
||||
hash = "8aaf547432f1b4958239b036356b910692fe40f858c3073d996f56bbf7c99826"
|
||||
@@ -1,8 +0,0 @@
|
||||
name = "JustEnoughProfessions-neoforge-1.21.1-4.0.5"
|
||||
filename = "JustEnoughProfessions-neoforge-1.21.1-4.0.5.jar"
|
||||
side = "both"
|
||||
|
||||
[download]
|
||||
url = "http://pack.${BASE_DOMAIN}/custom/JustEnoughProfessions-neoforge-1.21.1-4.0.5.jar"
|
||||
hash-format = "sha256"
|
||||
hash = "b320b47f1791f56df9e1a05138f0e312e6e54541945ff15f2f1fd398cd1d4d9d"
|
||||
@@ -1,8 +0,0 @@
|
||||
name = "JustEnoughResources-NeoForge-1.21.1-1.6.0.17"
|
||||
filename = "JustEnoughResources-NeoForge-1.21.1-1.6.0.17.jar"
|
||||
side = "both"
|
||||
|
||||
[download]
|
||||
url = "http://pack.${BASE_DOMAIN}/custom/JustEnoughResources-NeoForge-1.21.1-1.6.0.17.jar"
|
||||
hash-format = "sha256"
|
||||
hash = "7a47d69b5530704690d5a5f0726cd74a2d6c57df17dd4a8589c4ab5212f91460"
|
||||
@@ -1,8 +0,0 @@
|
||||
name = "MouseTweaks-neoforge-mc1.21-2.26.1"
|
||||
filename = "MouseTweaks-neoforge-mc1.21-2.26.1.jar"
|
||||
side = "both"
|
||||
|
||||
[download]
|
||||
url = "http://pack.${BASE_DOMAIN}/custom/MouseTweaks-neoforge-mc1.21-2.26.1.jar"
|
||||
hash-format = "sha256"
|
||||
hash = "68e6f4201c5de97b77929a7215c9552495696ca6a3bf3ae4eacc34e135f6cc8b"
|
||||
@@ -1,13 +0,0 @@
|
||||
name = "Ulicraft"
|
||||
author = "Oier"
|
||||
version = "1.0.0"
|
||||
pack-format = "packwiz:1.1.0"
|
||||
|
||||
[index]
|
||||
file = "index.toml"
|
||||
hash-format = "sha256"
|
||||
hash = "9bca0b1a4434050835cab46116d7c14aafec971ff5a95a1ee0c4232c8bcbc355"
|
||||
|
||||
[versions]
|
||||
minecraft = "1.21.1"
|
||||
neoforge = "21.1.209"
|
||||
@@ -13,9 +13,10 @@ Everything is driven by one env var in `.env`:
|
||||
BASE_DOMAIN=ulicraft.net # all services are subdomains of this
|
||||
```
|
||||
|
||||
`render-config.sh` substitutes **only** `${BASE_DOMAIN}` into the templates.
|
||||
`render-config.sh` substitutes `${BASE_DOMAIN}` into the templates (plus the
|
||||
derived `REGISTRATION_*` and `FILES_*` values — see `20-files.md`).
|
||||
DNS is **not** this repo's concern: point `${BASE_DOMAIN}` and all subdomains
|
||||
(`auth.` `pack.` `avatar.` `status.` `distribution.` `www.`) at the host running
|
||||
(`auth.` `avatar.` `status.` `files.` `distribution.` `www.`) at the host running
|
||||
nginx; that is configured outside this repo.
|
||||
|
||||
## Subdomain / ingress map
|
||||
@@ -23,9 +24,9 @@ nginx; that is configured outside this repo.
|
||||
```
|
||||
ulicraft.net → nginx → caddy → landing page (/srv/www) + /launcher/
|
||||
auth.ulicraft.net → nginx → caddy → drasl:25585 (auth web UI + Yggdrasil API)
|
||||
pack.ulicraft.net → nginx → caddy → /srv/pack (packwiz pack metadata)
|
||||
avatar.ulicraft.net → nginx → caddy → nmsr:8080 (skin/avatar renderer)
|
||||
status.ulicraft.net → nginx → caddy → uptime-kuma:3001 (status page)
|
||||
files.ulicraft.net → nginx → caddy → filestash:8334 (player file share, shared login)
|
||||
distribution.ulicraft.net → nginx → caddy → /srv/distribution (static, another repo)
|
||||
mc.ulicraft.net:25565 → minecraft (raw MC protocol, not HTTP)
|
||||
```
|
||||
@@ -45,12 +46,35 @@ resolves its own subdomains internally:
|
||||
caddy:
|
||||
networks:
|
||||
mcnet:
|
||||
aliases: [ "auth.${BASE_DOMAIN}", "pack.${BASE_DOMAIN}" ]
|
||||
aliases: [ "auth.${BASE_DOMAIN}" ]
|
||||
```
|
||||
|
||||
Result: `http://auth.ulicraft.net/authlib-injector` and
|
||||
`http://pack.ulicraft.net/pack.toml` resolve identically inside the stack
|
||||
(minecraft → caddy) as the public names do outside it (client → nginx → caddy).
|
||||
Result: `http://auth.ulicraft.net/authlib-injector` resolves identically inside
|
||||
the stack (minecraft → caddy) as the public name does outside it (client → nginx
|
||||
→ caddy).
|
||||
|
||||
#### HTTPS variant (`extra_hosts: host-gateway`)
|
||||
|
||||
The caddy alias only serves **http** on `mcnet` (port 80). Once the stack moved
|
||||
to https URLs (drasl `BaseURL`; the authlib-injector agent must match the
|
||||
client's https endpoint), the internal http alias is no longer enough — the
|
||||
container needs to reach a **TLS** terminator holding a valid cert.
|
||||
|
||||
Caddy does not terminate TLS internally; the host's nginx does (on `0.0.0.0:443`).
|
||||
So pin the public name to the host in the service, not to caddy:
|
||||
|
||||
```yaml
|
||||
minecraft:
|
||||
extra_hosts:
|
||||
- "auth.${BASE_DOMAIN}:host-gateway"
|
||||
```
|
||||
|
||||
`/etc/hosts` (extra_hosts) wins over the Docker resolver, so the container reaches
|
||||
the host's nginx (valid LE cert) → caddy → service. This is required because
|
||||
**containers do not inherit the host's `/etc/hosts`** — on cochi the host resolves
|
||||
`auth.` to the public IP, but inside a container the LAN resolver returns a
|
||||
dead address (`192.168.0.3:443`, connection refused), which crash-loops session
|
||||
validation on boot. `host-gateway` sidesteps DNS entirely.
|
||||
|
||||
## Build / run flow
|
||||
|
||||
@@ -59,7 +83,7 @@ PREP (run once, online):
|
||||
1. render configs → tooling/render-config.sh (08-tooling.md)
|
||||
2. build landing page → cd landing && pnpm run build → www/ (09-landing.md)
|
||||
3. download launcher releases → tooling/fetch-launcher.sh (06-launcher.md)
|
||||
4. curate packwiz pack → see 04-packwiz.md
|
||||
4. sync server mods → tooling/sync-server-mods.sh (04-mods.md)
|
||||
5. (TLS) issue Let's Encrypt certs → tooling/issue-letsencrypt.sh (15-letsencrypt.md)
|
||||
6. (ingress) render+install nginx → tooling/render-nginx.sh --install (15-letsencrypt.md)
|
||||
|
||||
@@ -68,15 +92,16 @@ UP:
|
||||
```
|
||||
|
||||
Bring-up is a single command. There are no run modes and no override compose
|
||||
files — one `docker-compose.yml` holds drasl, minecraft, mc-backup, caddy, nmsr
|
||||
and uptime-kuma.
|
||||
files — one `docker-compose.yml` holds drasl, minecraft, mc-backup, caddy, nmsr,
|
||||
mc-status, uptime-kuma and filestash.
|
||||
|
||||
## Why each hard call was made
|
||||
|
||||
- **No OIDC/Keycloak (for now)** — drasl password login. Keycloak left out of
|
||||
this stack's scope. Re-add later if desired.
|
||||
- **packwiz only serves metadata** — `.pw.toml` points at Modrinth/CF CDNs;
|
||||
server and clients fetch jars from there. The internet is assumed present.
|
||||
- **Distribution-fed mods** — the modset's source of truth is the HeliosLauncher
|
||||
distribution repo; clients install the full set, the server loads a filtered
|
||||
`both`/`server` subset from `./server-mods`. See `04-mods.md`.
|
||||
- **Fjord launcher** has authlib-injector built in → no manual JVM agent on
|
||||
clients.
|
||||
- **host nginx in front of caddy** — real Let's Encrypt certs (JVM trusts them
|
||||
@@ -96,8 +121,11 @@ and uptime-kuma.
|
||||
│ ├── config.toml.tmpl # render-config.sh source
|
||||
│ └── config.toml # generated (gitignored)
|
||||
├── nmsr/ # avatar renderer config + Dockerfile context
|
||||
├── filestash/
|
||||
│ ├── config.json.tmpl # render-config.sh source (files. share)
|
||||
│ └── config.json # generated, mounted :ro (gitignored — holds hashes)
|
||||
├── docker/nmsr/ # nmsr image build context
|
||||
├── pack/ # packwiz source of truth
|
||||
├── server-mods/ # filtered server modset (synced, gitignored)
|
||||
├── launcher/ # vendored Fjord launcher releases (gitignored)
|
||||
├── runtime/ # authlib-injector.jar
|
||||
├── tooling/ # render/fetch/issue scripts (see 08-tooling.md)
|
||||
@@ -111,7 +139,7 @@ and uptime-kuma.
|
||||
|
||||
- `02-caddy.md` — internal router (vhost conf.d snippets) behind host nginx
|
||||
- `03-drasl.md` — auth (password login)
|
||||
- `04-packwiz.md` — modpack source
|
||||
- `04-mods.md` — distribution-fed mod volume + server filtering (supersedes `04-packwiz.md`)
|
||||
- `05-minecraft.md` — itzg NeoForge server
|
||||
- `06-launcher.md` — Fjord launcher fetch + distribution
|
||||
- `07-mc-backup.md` — world backups
|
||||
@@ -120,8 +148,13 @@ and uptime-kuma.
|
||||
- `12-build-order.md` — commit-per-task build sequence
|
||||
- `14-deploy.md` — redeploy to the production host
|
||||
- `15-letsencrypt.md` — Let's Encrypt (OVH DNS-01) + host nginx ingress
|
||||
- `17-mod-shortlist.md` — kitchen-sink gap doc + landing mod-list dataset (PLANNED)
|
||||
- `18-landing-rework.md` — join flow / rosters / account / mod list / footer (PLANNED)
|
||||
- `19-routes.md` — HTTP route reference (nmsr / landing / auth / files + internal)
|
||||
- `20-files.md` — Filestash player file share (`files.`, one shared login)
|
||||
|
||||
## Boot/dependency order
|
||||
|
||||
`caddy` (aliases) → `drasl` → `minecraft` (depends on drasl + pack served by
|
||||
caddy) → `mc-backup`. `nmsr` and `uptime-kuma` are always-on and join `mcnet`.
|
||||
`caddy` (aliases) → `drasl` → `minecraft` (depends on drasl) → `mc-backup`.
|
||||
`nmsr`, `mc-status`, `uptime-kuma` and `filestash` are always-on and join `mcnet`
|
||||
(no ordering constraints — none of them are on the auth/boot path).
|
||||
|
||||
@@ -7,9 +7,10 @@ nginx (which terminates TLS — see `15-letsencrypt.md`). caddy is published onl
|
||||
on a localhost-only port (`CADDY_HTTP_BIND=127.0.0.1`, `CADDY_HTTP_PORT=8880`);
|
||||
nginx reverse-proxies every public vhost to it by Host header. Roles:
|
||||
|
||||
1. **Reverse proxy** — `auth.` → drasl, `avatar.` → nmsr, `status.` → uptime-kuma.
|
||||
2. **Static** — `pack.` serves packwiz metadata; apex serves the landing page +
|
||||
`/launcher/` downloads; `distribution.` serves a static site from another repo.
|
||||
1. **Reverse proxy** — `auth.` → drasl, `avatar.` → nmsr, `status.` → uptime-kuma,
|
||||
`files.` → filestash.
|
||||
2. **Static** — apex serves the landing page + `/launcher/` downloads;
|
||||
`distribution.` serves a static site from another repo.
|
||||
|
||||
Image: `caddy:alpine`. caddy natively reads `{$BASE_DOMAIN}` env placeholders —
|
||||
no template render needed.
|
||||
@@ -22,31 +23,35 @@ caddy:
|
||||
mcnet:
|
||||
aliases:
|
||||
- "auth.${BASE_DOMAIN}"
|
||||
- "pack.${BASE_DOMAIN}"
|
||||
```
|
||||
Lets `minecraft` reach `http://auth.ulicraft.net/authlib-injector` and
|
||||
`http://pack.ulicraft.net/pack.toml` from inside the stack.
|
||||
Lets `minecraft` reach `http://auth.ulicraft.net/authlib-injector` from inside
|
||||
the stack.
|
||||
|
||||
## conf.d snippets
|
||||
|
||||
The Caddyfile imports per-vhost snippets from `caddy/conf.d/`:
|
||||
|
||||
- `00-core.caddy` — `auth.` → drasl, `pack.` → `/srv/pack` (browse).
|
||||
- `00-core.caddy` — `auth.` → drasl.
|
||||
- `10-static.caddy` — apex landing (`/srv/www`) + `/launcher/*` (`/srv/launcher`).
|
||||
- `30-distribution.caddy` — `distribution.` → `/srv/distribution` (static, from
|
||||
`DISTRIBUTION_WEB_ROOT`, another repo).
|
||||
- `40-avatar.caddy` — `avatar.` → `nmsr:8080` (skin/avatar renderer).
|
||||
- `50-status.caddy` — `status.` → `uptime-kuma:3001` (status page).
|
||||
- `60-files.caddy` — `files.` → `filestash:8334` (player file share, `20-files.md`).
|
||||
|
||||
All vhosts are plain `http://…`; TLS is nginx's job. Mounts: `./www:/srv/www:ro`,
|
||||
`./pack:/srv/pack:ro`, `./launcher:/srv/launcher:ro`,
|
||||
`${DISTRIBUTION_WEB_ROOT}:/srv/distribution:ro`.
|
||||
`./launcher:/srv/launcher:ro`, `${DISTRIBUTION_WEB_ROOT}:/srv/distribution:ro`.
|
||||
|
||||
> **Don't rewrite the `Host` header for `files.`** — filestash rejects any
|
||||
> request whose Host doesn't match its configured `general.host`, with *"only
|
||||
> traffic from X is allowed"* (a 403 that reads like an auth failure). nginx
|
||||
> forwards the original Host and caddy passes it through untouched. Keep it that
|
||||
> way.
|
||||
|
||||
## Notes / risks
|
||||
|
||||
- drasl behind a proxy: with matching `BaseURL` and nginx terminating TLS it is
|
||||
fine; nginx passes the original Host through to caddy → drasl.
|
||||
- `file_server browse` gives a directory listing — handy for debugging the pack.
|
||||
- `/launcher/` is a sibling mount (`/srv/launcher`), not nested under the
|
||||
read-only `/srv/www`; `handle_path` strips the prefix.
|
||||
|
||||
@@ -55,8 +60,8 @@ All vhosts are plain `http://…`; TLS is nginx's job. Mounts: `./www:/srv/www:r
|
||||
- [ ] `caddy` service in `docker-compose.yml`, published `127.0.0.1:${CADDY_HTTP_PORT}:80`
|
||||
- [ ] Pass `BASE_DOMAIN` + `DISTRIBUTION_WEB_ROOT` into caddy env
|
||||
- [ ] Caddyfile imports `conf.d/*.caddy`
|
||||
- [ ] Network aliases for `auth.` and `pack.` subdomains
|
||||
- [ ] Mount `./www`, `./pack`, `./launcher`, `${DISTRIBUTION_WEB_ROOT}`
|
||||
- [ ] Network alias for `auth.` subdomain
|
||||
- [ ] Mount `./www`, `./launcher`, `${DISTRIBUTION_WEB_ROOT}`
|
||||
- [ ] Confirm reverse_proxy to drasl works (auth web UI loads, Yggdrasil API responds)
|
||||
- [ ] Verify internal alias resolution from `minecraft` container (curl pack.toml)
|
||||
- [ ] Verify internal alias resolution from `minecraft` container (curl authlib-injector)
|
||||
- [ ] Verify nmsr + uptime-kuma proxied (`avatar.` renders, `status.` loads)
|
||||
|
||||
@@ -16,13 +16,16 @@ Config is **TOML only** (no env var support). So `config.toml` is rendered from
|
||||
## config.toml.tmpl (key fields)
|
||||
|
||||
```toml
|
||||
BaseURL = "http://auth.${BASE_DOMAIN}"
|
||||
BaseURL = "https://auth.${BASE_DOMAIN}" # public scheme is HTTPS (nginx TLS)
|
||||
Domain = "auth.${BASE_DOMAIN}"
|
||||
ListenAddress = "0.0.0.0:25585" # internal; Caddy proxies
|
||||
DefaultAdminUsernames = ["admin"]
|
||||
DefaultAdmins = ["admin"] # NOT DefaultAdminUsernames (see gotchas)
|
||||
|
||||
AllowPasswordLogin = true
|
||||
|
||||
# Top-level — NOT under any [Section] (see gotchas).
|
||||
CORSAllowOrigins = ["https://${BASE_DOMAIN}"]
|
||||
|
||||
[ApplicationOwner]
|
||||
# ...
|
||||
|
||||
@@ -38,6 +41,28 @@ AllowPasswordLogin = true
|
||||
`http://auth.ulicraft.net/authlib-injector`. Must match on server and client.
|
||||
Network alias makes this identical inside/outside the stack.
|
||||
- **`Domain` affects skins** — if wrong, authlib clients may not see skins.
|
||||
- **Config keys are validated loosely — wrong keys are silently ignored** (drasl
|
||||
logs `Warning: unknown config option <key>` and moves on). Two that bit us:
|
||||
- Admin list key is **`DefaultAdmins`** (top-level), NOT `DefaultAdminUsernames`
|
||||
(that key never existed → no admin → admin-only routes like `GET /players`
|
||||
return 403).
|
||||
- **`CORSAllowOrigins` must be top-level**, before any `[Section]`. Placed after
|
||||
a table header it parses as `<table>.CORSAllowOrigins`, is ignored, and the
|
||||
API serves no CORS headers → landing register/account browser calls blocked.
|
||||
- `RateLimit.Burst` is valid in drasl master but warns on `unmojang/drasl:latest`
|
||||
(image lags) — harmless.
|
||||
- **First-admin bootstrap with invite mode on is a chicken-egg.** `DefaultAdmins`
|
||||
only promotes the account at startup reconciliation, and `POST /drasl/api/v2/users`
|
||||
is invite-gated (admins do NOT bypass the *register* endpoint). To create the
|
||||
first admin: temporarily set `RequireInvite = false` in the rendered config,
|
||||
`docker compose restart drasl`, `POST /users` to register `admin`, then revert +
|
||||
restart (always trap-revert). API recap: `POST /login {username,password}` →
|
||||
`{apiToken}`; admin-only `GET /players` with `Authorization: Bearer <token>`.
|
||||
- **NMSR avatars need https + a host-gateway pin** (see `plan/19-routes.md`).
|
||||
Drasl's profile embeds **https** skin URLs (BaseURL is https). NMSR reaches
|
||||
those via `extra_hosts: auth.${BASE_DOMAIN}:host-gateway` (host nginx :443, real
|
||||
LE cert) with https mojank endpoints. Without it the skin fetch Connect-fails to
|
||||
caddy:443 and every avatar renders the DEFAULT skin.
|
||||
|
||||
## Tasks
|
||||
|
||||
@@ -47,6 +72,7 @@ AllowPasswordLogin = true
|
||||
- [ ] Wire `render-config.sh` → `drasl/config/config.toml` (gitignored)
|
||||
- [ ] Remove drasl host port from compose (internal only)
|
||||
- [ ] Update compose: drasl on `mcnet`, no `25585:25585` publish
|
||||
- [ ] Bootstrap admin account on first run; create guest accounts
|
||||
- [x] Bootstrap admin account (`admin`, done 2026-06-10 via invite-flip; creds in
|
||||
cochi `.env` for the mc-status roster). Create guest accounts as needed.
|
||||
- [ ] Verify authlib endpoint responds through Caddy
|
||||
- [ ] Future: re-add `[[RegistrationOIDC]]` Keycloak block + secret file
|
||||
|
||||
212
plan/04-mods.md
Normal file
212
plan/04-mods.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# 04 — Mods (custom volume, distribution-fed)
|
||||
|
||||
> **Supersedes `04-packwiz.md`.** packwiz is being removed. This describes the
|
||||
> replacement: the server loads mods from a filtered local volume; clients keep
|
||||
> getting the full set from the HeliosLauncher `distribution.json`.
|
||||
|
||||
## Why drop packwiz
|
||||
|
||||
- The packwiz index tracked only **9** mods while the real modset (76 jars) lives
|
||||
in the distribution repo — packwiz was half-built and stale.
|
||||
- Manual packwiz curation (`.tmpl` render → `refresh` → re-hash) is the flagged
|
||||
pain point, and a single missing file (`custom/.gitkeep`) crashlooped the
|
||||
server (404 → installer aborts → minecraft never starts).
|
||||
- The launcher (HeliosLauncher) already distributes mods to clients via a
|
||||
Nebula-generated `distribution.json`. That is the canonical client mod list.
|
||||
The server just needs the same jars, minus client-only ones.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
ulicraft-distribution/dist (another repo, = $DISTRIBUTION_WEB_ROOT)
|
||||
servers/ulicraft-1.21.1/
|
||||
forgemods/required/*.jar ← 76 jars, Nebula-managed (SOURCE OF TRUTH)
|
||||
distribution.json ← generated by Nebula; CLIENT install list
|
||||
│ │
|
||||
│ caddy distribution. vhost │ HeliosLauncher reads it →
|
||||
▼ ▼ installs ALL listed mods (client)
|
||||
guests' launcher ◄───────────────────────────────────
|
||||
|
||||
ulicraft-server (this repo)
|
||||
mods-sides.json ← { "<jar>": "client|server|both" } (committed, editable)
|
||||
tooling/classify-mods ← tomllib: seeds/updates mods-sides.json from jar manifests
|
||||
tooling/sync-server-mods ← copies both+server jars from $DIST → ./server-mods/
|
||||
server-mods/ (gitignored) ──mount──► minecraft /mods (REMOVE_OLD_MODS=TRUE)
|
||||
```
|
||||
|
||||
- **Client distribution: unchanged.** HeliosLauncher installs everything in
|
||||
`distribution.json`. No packwiz, no client zip.
|
||||
- **Server: filtered subset.** Only `both` + `server` jars are copied into the
|
||||
mounted `server-mods/`. `client`-tagged jars never reach the server.
|
||||
- **Single source of jars:** `$DISTRIBUTION_WEB_ROOT/.../forgemods/required/`.
|
||||
This repo stores no jars — only `mods-sides.json`.
|
||||
|
||||
## Classification — tomllib only (no Modrinth)
|
||||
|
||||
`tooling/classify-mods.py` reads each jar's `META-INF/neoforge.mods.toml` with
|
||||
Python `tomllib` and derives a side from the **`minecraft`/`neoforge` dependency
|
||||
`side`** (the field modders use to declare a mod's environment):
|
||||
|
||||
| manifest signal | derived side |
|
||||
|---|---|
|
||||
| `minecraft` (or `neoforge`) dep `side = "CLIENT"` | `client` |
|
||||
| dep `side = "SERVER"` | `server` |
|
||||
| dep `side = "BOTH"`, unspecified, or no manifest | `both` (default) |
|
||||
|
||||
Output is **merged** into `mods-sides.json`: existing (human-edited) entries are
|
||||
**preserved**; only new/unknown jars get a derived default. So the file is a
|
||||
one-time seed + permanent manual override surface. New jars default to `both`
|
||||
(your chosen default) unless their manifest says otherwise.
|
||||
|
||||
### Accuracy + the manual step (important)
|
||||
|
||||
tomllib reliably catches **hard client-only** mods — ones that declare their
|
||||
`minecraft`/`neoforge` dep as `CLIENT` (e.g. `sodium`, `iris`). Those are exactly
|
||||
the mods that refuse/break on a dedicated server, so the must-exclude set is
|
||||
handled automatically.
|
||||
|
||||
It will **not** auto-exclude client-*cosmetic* mods that declare `BOTH` in their
|
||||
manifest (e.g. `BetterF3`, `entityculling`, `sodium-extra`, `entity_texture_features`,
|
||||
`entity_model_features`, `ImmediatelyFast`, `drippyloadingscreen`, `melody`,
|
||||
`welcomescreen`, `Controlling`, `MouseTweaks`, `Searchables`, `tagtooltips`,
|
||||
`JustEnoughResources`, `TravelersTitles`, `BridgingMod`, `ponderjs`,
|
||||
`yeetusexperimentus`). These load on the server harmlessly (wasted RAM, no crash)
|
||||
unless you mark them `client` in `mods-sides.json`. **That manual curation is the
|
||||
"filter manually" step** — `classify-mods` seeds, you trim cosmetic-client mods.
|
||||
|
||||
Also worth an explicit decision in the override file:
|
||||
- `CustomSkinLoader` — server doesn't need it (skins resolve via Drasl/authlib);
|
||||
set `client` to keep it off the server.
|
||||
|
||||
## `mods-sides.json` format
|
||||
|
||||
```json
|
||||
{
|
||||
"jei-1.21.1-neoforge-19.27.0.340.jar": "both",
|
||||
"sodium-neoforge-0.8.12-alpha.4+mc1.21.1.jar": "client",
|
||||
"ftb-essentials-neoforge-2101.1.9.jar": "both",
|
||||
"rightclickharvest-neoforge-4.6.1+1.21.1.jar": "server"
|
||||
}
|
||||
```
|
||||
|
||||
Key = exact jar filename (matches `forgemods/required/*.jar`). Value ∈
|
||||
`client | server | both`. Hand-edit freely; `classify-mods` won't clobber it.
|
||||
|
||||
## Tooling
|
||||
|
||||
### `tooling/classify-mods.py` (dev-time, no network)
|
||||
1. Enumerate `$DISTRIBUTION_WEB_ROOT/servers/ulicraft-1.21.1/forgemods/required/*.jar`.
|
||||
2. For each jar not already in `mods-sides.json`: parse manifest → derive side
|
||||
(table above) → add entry.
|
||||
3. Report: counts per side, and **which jars were defaulted to `both`** (so you
|
||||
know what to review). Never overwrites existing entries.
|
||||
4. Optionally warn on entries in `mods-sides.json` whose jar no longer exists.
|
||||
|
||||
### `tooling/sync-server-mods.sh` (deploy-time, offline) — replaces `render-pack.sh`
|
||||
1. Read `mods-sides.json`.
|
||||
2. `mkdir -p server-mods`; for each `both`/`server` jar, copy from
|
||||
`$DISTRIBUTION_WEB_ROOT/.../forgemods/required/` → `server-mods/`.
|
||||
3. Remove any `server-mods/*.jar` not currently selected (keeps it authoritative;
|
||||
`REMOVE_OLD_MODS=TRUE` in the container also enforces this on `/data/mods`).
|
||||
4. **Halt** (non-zero exit) if a selected jar is missing from `$DIST`, or if
|
||||
`mods-sides.json` is absent — cautious, no silent partial sync.
|
||||
|
||||
## docker-compose.yml changes
|
||||
|
||||
```yaml
|
||||
# remove:
|
||||
# PACKWIZ_URL: ...
|
||||
# extra_hosts: pack.${BASE_DOMAIN}:host-gateway (auth. stays — authlib)
|
||||
environment:
|
||||
MODS_FILE: "" # ensure unset
|
||||
REMOVE_OLD_MODS: "TRUE" # mounted folder is authoritative
|
||||
volumes:
|
||||
- mc_data:/data
|
||||
- ./runtime:/extras:ro
|
||||
- ./server-mods:/mods:ro # itzg auto-syncs /mods → /data/mods
|
||||
```
|
||||
|
||||
`depends_on: caddy` can drop (server no longer fetches `pack.` at boot); keep
|
||||
`drasl` (authlib). `pack.` caddy vhost + the `./pack`/`./custom` mounts become
|
||||
dead — remove in the same pass.
|
||||
|
||||
## Deploy flow (updated `plan/14`)
|
||||
|
||||
```
|
||||
git pull --ff-only
|
||||
tooling/render-config.sh # drasl/nmsr/etc. (unchanged)
|
||||
tooling/sync-server-mods.sh # NEW — replaces render-pack.sh
|
||||
tooling/fetch-authlib.sh
|
||||
docker compose down --remove-orphans
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
`$DISTRIBUTION_WEB_ROOT` must be present on the host (already is — caddy mounts
|
||||
it). `classify-mods.py` is a dev step run when the modset changes; it is **not**
|
||||
in the deploy path (keeps deploy offline + deterministic).
|
||||
|
||||
## Decommission checklist (on execution)
|
||||
|
||||
Done at first cutover (commits `27237bc`, `84196d5`, `c249172`):
|
||||
- [x] `tooling/classify-mods.py` + generated initial `mods-sides.json`.
|
||||
- [x] `tooling/sync-server-mods.sh`.
|
||||
- [x] compose: dropped `PACKWIZ_URL`, `pack.` extra_host, `./pack`/`./custom`
|
||||
mounts, `pack.` caddy vhost; added `server-mods` mount + `REMOVE_OLD_MODS`.
|
||||
- [x] deleted `tooling/render-pack.sh` (+ dead `add-custom-mod.sh`).
|
||||
- [x] deleted `pack/` and `custom/` (jars now sourced from `$DIST`).
|
||||
- [x] `.gitignore`: dropped packwiz lines; added `server-mods/`.
|
||||
- [x] superseded `plan/04-packwiz.md`; updated `05-minecraft.md`, `14-deploy.md`,
|
||||
`CLAUDE.md`.
|
||||
- [x] resolved the host-local divergence around `pack/`
|
||||
([[cochi-host-local-divergence]]).
|
||||
- [ ] **STILL OPEN (roadmap):** landing join steps still reference the dead
|
||||
`pack.toml` URL (`site.ts packwizUrl`, `i18n/ui.ts` step 3) — rework around
|
||||
the HeliosLauncher/`distribution.json` flow.
|
||||
- [x] deployed to `cochi`: NeoForge bumped to 21.1.233, 52 server mods, `Done`,
|
||||
mc-status `online:true`.
|
||||
|
||||
## Deployment gotchas (learned 2026-06-10, first cutover)
|
||||
|
||||
### NeoForge version MUST match the distribution
|
||||
The distribution's mods are built against a specific NeoForge (in
|
||||
`distribution.json` → `21.1.233`). Several hard-require it (ferritecore ≥21.1.218,
|
||||
farmersdelight ≥21.1.219, configured ≥21.1.211). A lower `NEOFORGE_VERSION` pin
|
||||
fails pre-load with `Missing or unsupported mandatory dependencies: neoforge`.
|
||||
Keep `docker-compose.yml`'s pin in lockstep with the distribution on every mod
|
||||
update.
|
||||
|
||||
### Client-only mods that declare `BOTH` crash the dedicated server
|
||||
tomllib classifies by the `minecraft`/`neoforge` dependency `side`. Mods that
|
||||
declare **`BOTH`** but are actually client-only (touch `net.minecraft.client.*`)
|
||||
pass through as `both`, load on the server, and crash hard:
|
||||
|
||||
```
|
||||
java.lang.RuntimeException: Attempted to load class
|
||||
net/minecraft/client/gui/screens/Screen for invalid dist DEDICATED_SERVER
|
||||
- <Mod> has failed to load correctly
|
||||
```
|
||||
|
||||
This is **not** mere RAM waste — it's a fatal crashloop. tomllib cannot detect
|
||||
it (the manifest lies). **Fix = hand-set the mod to `client` in
|
||||
`mods-sides.json`, re-sync, recreate minecraft.** First cutover required marking
|
||||
these (leaf client-GUI mods, nothing server-loaded depends on them):
|
||||
`BetterF3, JustEnoughResources, MouseTweaks, Searchables, TravelersTitles,
|
||||
drippyloadingscreen, entityculling, fancymenu, konkrete, melody, ponderjs,
|
||||
welcomescreen, yeetusexperimentus` — on top of the 11 tomllib auto-caught
|
||||
(sodium, sodium-extra, iris, ImmediatelyFast, appleskin, Controlling, BridgingMod,
|
||||
CustomSkinLoader, entity_model_features, entity_texture_features, tagtooltips).
|
||||
|
||||
**Current state: 24 `client` / 52 `both`.** The 52 may still hide a client-only
|
||||
mod that loaded without crashing; if a future restart trips another
|
||||
`invalid dist DEDICATED_SERVER`, flip that one jar to `client` and re-sync — same
|
||||
one-line loop. When excluding, prefer leaf mods; if a `both` mod hard-depends on
|
||||
the one you exclude you'll get a `Missing mandatory dependency` instead — put it
|
||||
back.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
- **No manifest** in a jar → defaults `both`. Acceptable; review the defaulted
|
||||
list `classify-mods` prints.
|
||||
- **Nebula regen** may rename jars (version bumps) → `mods-sides.json` keys go
|
||||
stale; `classify-mods` re-seeds new names as `both`, and `sync` warns on
|
||||
missing-from-DIST. Re-curate on big mod updates.
|
||||
@@ -1,37 +1,7 @@
|
||||
# packwiz — modpack source of truth
|
||||
# packwiz — modpack source of truth (SUPERSEDED)
|
||||
|
||||
## Summary
|
||||
|
||||
`packwiz` (installed at `~/go/bin/packwiz`) authors the modpack. The `./pack`
|
||||
directory is the **single source of truth** for mods, consumed by both:
|
||||
- the **server** (itzg `PACKWIZ_URL`), and
|
||||
- **clients** (Fjord launcher imports the pack / packwiz-installer).
|
||||
|
||||
Caddy serves `./pack` at `pack.ulicraft.net`. packwiz emits **metadata**
|
||||
(`pack.toml`, `index.toml`, `mods/*.pw.toml`); the `.pw.toml` files reference
|
||||
Modrinth/CF **CDN URLs**, so server and clients fetch the jars from those CDNs.
|
||||
The stack assumes the internet is present.
|
||||
|
||||
## Behaviors confirmed
|
||||
|
||||
- **Prune**: packwiz-installer tracks a manifest and **removes** client mods no
|
||||
longer in the index on re-run. (Server side via itzg likewise re-syncs.)
|
||||
- **Download source**: jars come from the CDN URLs in `.pw.toml`, not the pack
|
||||
host (which serves only metadata).
|
||||
- **itzg side filtering**: itzg downloads **server-side mods only**. Client-only
|
||||
mods MUST be tagged `side = "client"` (not `both`) or the server may pull and
|
||||
crash on them.
|
||||
|
||||
## NeoForge pinning
|
||||
|
||||
`pack.toml` pins MC `1.21.1` + NeoForge `21.1.x`. Verify exact NeoForge build
|
||||
against projects.neoforged.net before locking.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] `packwiz init` → `./pack` (MC 1.21.1, modloader neoforge, pinned version)
|
||||
- [ ] Curate mod shortlist (perf/tech/magic/storage/QoL/worldgen/map/voice)
|
||||
- [ ] Tag client-only mods `side = "client"`
|
||||
- [ ] `packwiz refresh`; verify `index.toml` committed (clients fetch it)
|
||||
- [ ] Point server `PACKWIZ_URL=http://pack.ulicraft.net/pack.toml`
|
||||
- [ ] Test install end-to-end on a fresh client
|
||||
> **Superseded by [`04-mods.md`](04-mods.md).** packwiz has been removed.
|
||||
> The server now loads a filtered mod subset from a local `server-mods/` volume
|
||||
> (synced from the distribution repo by `tooling/sync-server-mods.sh`); clients
|
||||
> still get the full set from the HeliosLauncher `distribution.json`.
|
||||
> See `04-mods.md` for the current design.
|
||||
|
||||
@@ -3,38 +3,124 @@
|
||||
## Summary
|
||||
|
||||
`itzg/minecraft-server:java21`, NeoForge 1.21.1, offline-mode + authlib-injector
|
||||
pointing at drasl. Mods via `PACKWIZ_URL`. itzg installs MC + NeoForge +
|
||||
libraries + server mods on first boot and re-syncs on restart; the stack assumes
|
||||
the internet is present, so `TYPE=NEOFORGE` boots normally every time.
|
||||
pointing at drasl. Mods come from a local `./server-mods` volume (mounted at
|
||||
`/mods`), NOT packwiz — see `04-mods.md`. itzg installs MC + NeoForge + libraries
|
||||
on first boot and auto-syncs `/mods` → `/data/mods` every boot; the stack assumes
|
||||
the internet is present (for the NeoForge installer), so `TYPE=NEOFORGE` boots
|
||||
normally every time.
|
||||
|
||||
## Server config
|
||||
|
||||
```
|
||||
TYPE=NEOFORGE, VERSION=1.21.1, NEOFORGE_VERSION=21.1.x
|
||||
PACKWIZ_URL=http://pack.${BASE_DOMAIN}/pack.toml
|
||||
REMOVE_OLD_MODS=TRUE # ./server-mods is the authoritative mod set
|
||||
volume ./server-mods:/mods:ro
|
||||
```
|
||||
|
||||
First boot installs MC + NeoForge + libraries + server mods into the `mc_data`
|
||||
volume. Subsequent boots re-sync the pack against `PACKWIZ_URL`.
|
||||
`./server-mods` is populated by `tooling/sync-server-mods.sh` (the `both`/`server`
|
||||
subset of `mods-sides.json`, copied from `$DISTRIBUTION_WEB_ROOT`). First boot
|
||||
installs MC + NeoForge + libraries into the `mc_data` volume; every boot itzg
|
||||
mirrors `/mods` into `/data/mods` and prunes removed ones (`REMOVE_OLD_MODS`).
|
||||
|
||||
## Auth / config
|
||||
|
||||
```
|
||||
ONLINE_MODE=FALSE
|
||||
ONLINE_MODE=TRUE
|
||||
ENFORCE_SECURE_PROFILE=FALSE # pairs with drasl SignPublicKeys=false
|
||||
JVM_OPTS=-javaagent:/extras/authlib-injector.jar=http://auth.${BASE_DOMAIN}/authlib-injector
|
||||
```
|
||||
authlib URL uses the `auth` subdomain; resolves internally via the caddy alias.
|
||||
(Server still needs the agent even though Fjord clients have it built in.)
|
||||
|
||||
**`ONLINE_MODE` MUST be true.** authlib-injector works by redirecting the
|
||||
server's normal online-auth handshake from Mojang to Drasl. With
|
||||
`ONLINE_MODE=FALSE` the server skips that handshake entirely → players get
|
||||
offline-hash UUIDs, no session validation, and **no skins** (the server never
|
||||
fetches textures from Drasl). The thing that must stay *off* for 1.21+ is the
|
||||
**secure profile** (`ENFORCE_SECURE_PROFILE=FALSE` + Drasl `SignPublicKeys=false`),
|
||||
not online-mode. Symptom of getting this wrong: everyone can join but everyone is
|
||||
Steve, and `usercache.json` shows offline-hash UUIDs.
|
||||
|
||||
Memory: `INIT_MEMORY 4G`, `MAX_MEMORY 10G`, `USE_AIKAR_FLAGS TRUE`.
|
||||
RCON enabled for mc-backup.
|
||||
|
||||
## Server operators (OP)
|
||||
|
||||
**Do NOT use the itzg `OPS` env var.** It resolves usernames to UUIDs via a
|
||||
Mojang lookup, but this server is `ONLINE_MODE=TRUE` validated against Drasl —
|
||||
Drasl assigns its own UUIDs (not Mojang, not the offline-hash). A Mojang-resolved UUID
|
||||
would not match the player's real session UUID, so the op entry would be inert
|
||||
(and could clobber a correct `ops.json` on reboot).
|
||||
|
||||
**Op via RCON instead.** The player must have connected at least once so their
|
||||
real Drasl UUID is cached in `/data/usercache.json`; then `op <name>` writes that
|
||||
cached UUID to `ops.json`. On the host:
|
||||
|
||||
```
|
||||
ssh cochi
|
||||
docker exec minecraft rcon-cli list # confirm cache / who's on
|
||||
docker exec minecraft sh -c 'cat /data/usercache.json' # verify name→UUID cached
|
||||
docker exec minecraft rcon-cli op <name>
|
||||
docker exec minecraft sh -c 'cat /data/ops.json' # verify level 4 + right UUID
|
||||
```
|
||||
|
||||
`ops.json` lives in the `mc_data` volume → persists across restarts; no reboot
|
||||
needed (takes effect live). Current op: `oier`
|
||||
(`7fbc9adb-bcf4-3913-9a99-46d46acd1009`), level 4, set 2026-06-21.
|
||||
|
||||
## Simple Voice Chat — `voice_host` (NOT repo-tracked)
|
||||
|
||||
Voice chat is UDP `24454/udp`, published in compose. The mod auto-generates
|
||||
`/data/config/voicechat/voicechat-server.properties` in the `mc_data` volume.
|
||||
|
||||
**Gotcha — `voice_host` must be set or remote clients get a red/crossed icon.**
|
||||
Behind any NAT/Docker the server announces an address for clients' UDP
|
||||
connection; if `voice_host=` is empty it auto-detects the internal Docker IP →
|
||||
unreachable → the secret (TCP) is sent but the UDP handshake never completes
|
||||
("Sent secret" with no following "successfully connected to voice chat").
|
||||
|
||||
Set on prod:
|
||||
|
||||
```
|
||||
docker exec minecraft sed -i 's/^voice_host=.*/voice_host=ulicraft.net/' \
|
||||
/data/config/voicechat/voicechat-server.properties
|
||||
docker restart minecraft # mod reads props only at startup
|
||||
```
|
||||
|
||||
**⚠️ This value lives in the `mc_data` volume, NOT auto-managed from this repo.**
|
||||
It survives restarts and `compose down/up`, but a **world wipe / fresh `mc_data`**
|
||||
regenerates the file with `voice_host=` empty → voice silently breaks again.
|
||||
|
||||
A known-good prod snapshot is committed at
|
||||
`server-configs/voicechat-server.properties` (reference copy, `voice_host=ulicraft.net`).
|
||||
After a world reset, either re-run the `sed` + `docker restart` above, or restore
|
||||
the snapshot:
|
||||
|
||||
```
|
||||
docker cp server-configs/voicechat-server.properties \
|
||||
minecraft:/data/config/voicechat/voicechat-server.properties
|
||||
docker restart minecraft
|
||||
```
|
||||
|
||||
(Snapshot is not auto-mounted — to make it durable, add a bind mount of
|
||||
`server-configs/voicechat-server.properties` into `/data/config/voicechat/` in
|
||||
compose.)
|
||||
|
||||
Verify after a client joins:
|
||||
`docker logs minecraft 2>&1 | grep -i "connected to voice"` → expect
|
||||
`Player <name> ... successfully connected to voice chat`.
|
||||
|
||||
Note: a red voice icon can ALSO be client-side — a VPN/zero-trust client
|
||||
(Cloudflare One/WARP, FortiClient) on the player's machine silently drops the
|
||||
voice UDP. Rule out VPNs before touching the server.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Compose: authlib URL `http://auth.${BASE_DOMAIN}/authlib-injector`
|
||||
- [ ] Compose: `PACKWIZ_URL=http://pack.${BASE_DOMAIN}/pack.toml`
|
||||
- [ ] Compose: mount `./server-mods:/mods:ro` + `REMOVE_OLD_MODS=TRUE`
|
||||
- [ ] Run `tooling/sync-server-mods.sh` before bring-up (populates `./server-mods`)
|
||||
- [ ] Mount `./runtime` for authlib-injector.jar at `/extras`
|
||||
- [ ] `depends_on`: drasl + caddy (pack served before server installs)
|
||||
- [ ] `depends_on`: drasl (authlib must resolve at boot)
|
||||
- [ ] Confirm `ENFORCE_SECURE_PROFILE=FALSE` ↔ drasl `SignPublicKeys=false`
|
||||
- [ ] Verify a client joins with their drasl skin
|
||||
- [ ] After any world wipe: re-set `voice_host` (see Simple Voice Chat section)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
`itzg/mc-backup` alongside the server, talking via RCON. Snapshots the world every
|
||||
6h, prunes after 14 days. World-only (no mod jars — mods are reproducible from the
|
||||
packwiz pack). Unchanged from the original design; lowest-priority service.
|
||||
distribution). Unchanged from the original design; lowest-priority service.
|
||||
|
||||
## Config (carry-over)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Tooling — config rendering, pack render, ingress, fetch scripts
|
||||
# Tooling — config rendering, mod sync, ingress, fetch scripts
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -10,11 +10,13 @@ half-broken output.
|
||||
Surviving scripts:
|
||||
|
||||
- `render-config.sh` — render `*.tmpl` configs (BASE_DOMAIN only)
|
||||
- `render-pack.sh` — render packwiz pack templates + index
|
||||
- `classify-mods.py` — read each jar's `neoforge.mods.toml`, seed `mods-sides.json` (see 04-mods.md)
|
||||
- `sync-server-mods.sh` — copy `both`/`server` jars from the distribution → `./server-mods` (see 04-mods.md)
|
||||
- `build-modlist.py` — generate the landing `mods.json` + extract logos (see 17-mod-shortlist.md)
|
||||
- `render-nginx.sh` — render/install the host nginx vhost (TLS terminator → caddy)
|
||||
- `issue-letsencrypt.sh` — issue LE certs via OVH DNS-01 (see 15-letsencrypt.md)
|
||||
- `fetch-launcher.sh` — download Fjord launcher release assets (see 06-launcher.md)
|
||||
- `add-custom-mod.sh` — add a custom (non-CDN) mod to the pack
|
||||
- `fetch-authlib.sh` — vendor the authlib-injector jar → `runtime/`
|
||||
- `fetch-fonts.sh` — vendor the landing page fonts (see 09-landing.md)
|
||||
|
||||
## render-config.sh
|
||||
@@ -28,8 +30,8 @@ Targets:
|
||||
- `drasl/config/config.toml.tmpl` → `drasl/config/config.toml`
|
||||
- `nmsr/config.toml.tmpl` → `nmsr/config.toml`
|
||||
|
||||
It then delegates pack templates (`mods/*.pw.toml.tmpl`, CustomSkinLoader, …) +
|
||||
the packwiz index to `render-pack.sh` — the single source of pack render.
|
||||
Mods are **not** rendered — they are synced from the distribution by
|
||||
`sync-server-mods.sh` (see 04-mods.md), no templating involved.
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
@@ -46,7 +48,6 @@ render() { # $1=tmpl $2=out
|
||||
|
||||
render drasl/config/config.toml.tmpl drasl/config/config.toml
|
||||
render nmsr/config.toml.tmpl nmsr/config.toml
|
||||
tooling/render-pack.sh
|
||||
```
|
||||
|
||||
## render-nginx.sh
|
||||
@@ -81,7 +82,7 @@ certs/ # Let's Encrypt certs
|
||||
## Tasks
|
||||
|
||||
- [ ] Write `tooling/render-config.sh` (above), `chmod +x`
|
||||
- [ ] Write `tooling/render-pack.sh` (pack templates + index)
|
||||
- [ ] Write `tooling/sync-server-mods.sh` (see 04-mods.md)
|
||||
- [ ] Write `tooling/render-nginx.sh` (see 15)
|
||||
- [ ] Write `tooling/issue-letsencrypt.sh` (see 15)
|
||||
- [ ] Write `tooling/fetch-launcher.sh` (see 06)
|
||||
|
||||
@@ -61,11 +61,15 @@ script only if the font set changes (keep families in sync with `main.css`
|
||||
|
||||
## Languages (i18n)
|
||||
|
||||
Three locales, static, build-time — **English** (default, `/`),
|
||||
**Spanish** (`/es/`), **Euskera** (`/eu/`).
|
||||
Three locales, static, build-time — **Spanish** (default, `/`),
|
||||
**English** (`/en/`), **Euskera** (`/eu/`).
|
||||
|
||||
- One template `src/pages/[...lang].astro` with `getStaticPaths` emits all three
|
||||
routes (`/`, `/es/`, `/eu/`). No runtime/JS routing.
|
||||
routes (`/`, `/en/`, `/eu/`). No runtime/JS routing. The default locale lives at
|
||||
the bare root: its `getStaticPaths` entry is `{ lang: undefined }` → `locale:
|
||||
"es"`; en/eu get an explicit prefix. The `LANG_*_PATH` maps in `ui.ts` mirror
|
||||
this (es → no prefix). Changing the default = swap which locale is `undefined`
|
||||
in all four pages' `getStaticPaths` + the `LANG_*_PATH` maps.
|
||||
- Translatable copy lives in `src/i18n/ui.ts` (`ui[locale]`), keyed identically
|
||||
across locales. `site.ts` holds only language-neutral config (URLs, theme,
|
||||
launcher files, stat/feature *structure*).
|
||||
@@ -103,6 +107,30 @@ Page links to fixed names so they survive launcher version bumps:
|
||||
sync** between `site.ts` and the script. Mounted so they resolve at
|
||||
`/launcher/latest/…` (see Caddy `10-static.caddy`).
|
||||
|
||||
## Assets — where to drop images
|
||||
|
||||
All static images live under `landing/public/` and are referenced by an absolute
|
||||
`/path` (Astro copies `public/` to the site root verbatim). After changing any,
|
||||
rebuild the landing (`pnpm run build`).
|
||||
|
||||
- **Header / hero logo** — `landing/public/logo.png` (current 707×148). Referenced
|
||||
as `/logo.png` in the hero (`[...lang].astro`, the `.hero-logo img`). Replace the
|
||||
file; keep a wide transparent PNG. Update the `width`/`height` attrs if the
|
||||
aspect changes.
|
||||
- **Favicon** — `landing/public/favicon.png` (square, ~175×175). Referenced as
|
||||
`/favicon.png` in the `<link rel="icon">` of all four pages
|
||||
(`[...lang].astro`, `register`, `account`, `fjord`). Replace the file.
|
||||
- **Server-status icon** — NOT a file you place here. The ServerCard pulls the
|
||||
live server icon over the SLP query (`ServerCard.astro`, the favicon data-URI),
|
||||
falling back to the `Creeper.astro` SVG. To change it, set `server-icon.png` on
|
||||
the Minecraft server (64×64), not in the landing.
|
||||
- **Features icons** — two options (`PixelIcon.astro`, fed by `site.ts` `features[]`):
|
||||
1. *Procedural glyph* (default): pick `glyph ∈ shield|diamond|orb|heart` and
|
||||
`gold` per feature in `site.ts`. Add a glyph by editing the 7×7 `GLYPHS` map.
|
||||
2. *Custom image*: drop a file in `landing/public/icons/<name>.(png|svg)` and add
|
||||
`img: "/icons/<name>.png"` to that feature's entry in `site.ts` `features[]`.
|
||||
`PixelIcon` renders the image (`.picon-img`) instead of the glyph.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Vendor fonts (`tooling/fetch-fonts.sh`) → `public/fonts/`
|
||||
|
||||
63
plan/10-uptime-kuma.md
Normal file
63
plan/10-uptime-kuma.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# 10 — Uptime Kuma
|
||||
|
||||
> Distinct from **`plan/15-mc-status.md`**: Kuma is the uptime *history* +
|
||||
> alerting backend; mc-status is the live data feed for the landing's server
|
||||
> card. Both ping `minecraft` over `mcnet` but serve different purposes.
|
||||
|
||||
Status monitoring + public status page. Service `uptime-kuma`
|
||||
(`louislam/uptime-kuma:1`), web UI on `:3001`, reached only through caddy at
|
||||
`https://status.${BASE_DOMAIN}`. Joined to `mcnet`, so it can probe every other
|
||||
service by its internal container name.
|
||||
|
||||
## Add a Minecraft monitor
|
||||
|
||||
Uptime Kuma 1.x ships a built-in **Minecraft Server** monitor type. It uses the
|
||||
Server List Ping protocol (the same ping the vanilla multiplayer list uses):
|
||||
unauthenticated, so it works fine with `ONLINE_MODE=FALSE` /
|
||||
`ENFORCE_SECURE_PROFILE=FALSE`. It reports latency, online/max players, and MOTD.
|
||||
|
||||
1. Open `https://status.${BASE_DOMAIN}` and log in (admin account created on
|
||||
first run).
|
||||
2. **+ Add New Monitor**.
|
||||
3. **Monitor Type**: `Minecraft Server`.
|
||||
4. Fill in one of the two probe targets below.
|
||||
5. **Heartbeat Interval**: `60` s (fine for a friends' server).
|
||||
6. **Retries**: `2`, **Retry Interval**: `30` s — avoids flapping on a GC pause.
|
||||
7. **Save**.
|
||||
|
||||
### Two targets — add both
|
||||
|
||||
| Monitor | Server | Port | What it proves |
|
||||
|---|---|---|---|
|
||||
| `MC (internal)` | `minecraft` | `25565` | The server process is up and accepting pings inside `mcnet`. |
|
||||
| `MC (public)` | `mc.${BASE_DOMAIN}` | `25565` | The full path works: DNS → host firewall → docker port publish → server. |
|
||||
|
||||
The **public** monitor is the one that catches "I can't connect" outages —
|
||||
firewall, DNS, or an unpublished port all show red here while the internal
|
||||
monitor stays green. Run both to tell *server down* apart from *path broken*.
|
||||
|
||||
> Internal `minecraft` resolves because uptime-kuma is on `mcnet` (the container
|
||||
> name is the hostname). It is **not** routed through caddy — caddy only fronts
|
||||
> HTTP vhosts, and MC is raw TCP. Do not point the monitor at `caddy` or any
|
||||
> `status.` alias.
|
||||
|
||||
## Notes
|
||||
|
||||
- The Minecraft monitor pings TCP `25565`; it does **not** check Simple Voice
|
||||
Chat (`24454/udp`). Add a separate **TCP Port** monitor against UDP if you want
|
||||
voice coverage — but Kuma's TCP monitor is TCP-only, so UDP voice has no clean
|
||||
probe; rely on the player report instead.
|
||||
- Put both MC monitors on the public **status page**
|
||||
(Settings → Status Pages) so guests can self-check before pinging you.
|
||||
- Optional companion HTTP monitors for the web stack:
|
||||
`https://auth.${BASE_DOMAIN}`, `https://distribution.${BASE_DOMAIN}`,
|
||||
`https://${BASE_DOMAIN}`, `https://files.${BASE_DOMAIN}` — a red
|
||||
`distribution.` here explains client mod-mismatch join failures.
|
||||
|
||||
> **An HTTP monitor cannot see an SPA break.** It asserts "the server returned
|
||||
> 200", which is a much weaker claim than "the app works". `files.` (Filestash)
|
||||
> proved this on 2026-07-14: a bad `general.host` made every client-side redirect
|
||||
> resolve to `http://https://files…`, so the site was unusable in every browser
|
||||
> while `/` kept returning 200 and the monitor stayed **green**. If you want a
|
||||
> monitor with teeth on a JS app, use Kuma's **Keyword** type against a string
|
||||
> the *rendered* page must contain, and don't read green as "users can log in".
|
||||
@@ -31,6 +31,9 @@ Dependency-ordered. Each numbered item = one git commit. Operational steps
|
||||
## Phase 5 — landing
|
||||
10. `feat(landing): Astro+TS onboarding site → www/`
|
||||
11. `chore(landing): wire logo + .gitignore for www/`
|
||||
11b. `feat(landing): /register page → Drasl API (B1) + REGISTRATION_MODE flag`
|
||||
(CORS/RateLimit/RegistrationNewPlayer in drasl config.toml.tmpl; see
|
||||
`16-landing-registration.md`).
|
||||
|
||||
## Phase 6 — launcher distribution
|
||||
12. `feat(tooling): add fetch-launcher.sh (all platforms + stable symlinks)`
|
||||
@@ -47,10 +50,20 @@ Dependency-ordered. Each numbered item = one git commit. Operational steps
|
||||
17. `feat(nginx): add ulicraft-caddy.conf.tmpl + render-nginx.sh`
|
||||
18. `feat(tls): add issue-letsencrypt.sh (OVH DNS-01) + LE env block`
|
||||
|
||||
## Phase 11 — file share (DONE, 2026-07-14)
|
||||
19. `feat(files): add Filestash player file share at files.${BASE_DOMAIN}` —
|
||||
compose service (digest-pinned) + `60-files.caddy` + nginx vhost (2g uploads,
|
||||
`/admin` rate limit) + `filestash/config.json.tmpl` rendered `:ro` +
|
||||
`FILES_*` env block. One shared htpasswd login, writable. See `20-files.md`.
|
||||
- [ops] `files` added to `LE_SUBDOMAINS` → `issue-letsencrypt.sh`, then
|
||||
`render-nginx.sh --install` (a new subdomain needs BOTH; `update-all.sh`
|
||||
does not touch host nginx).
|
||||
|
||||
## Phase 10 — operational (no commits)
|
||||
- [ops] Prep online (once): `tooling/render-config.sh`, build landing
|
||||
(`cd landing && pnpm run build`), `tooling/fetch-launcher.sh`, fetch
|
||||
authlib-injector into `runtime/`.
|
||||
(`cd landing && set -a && . ../.env && set +a && pnpm run build` — sources
|
||||
`.env` so `BASE_DOMAIN` + `REGISTRATION_MODE` bake into the static output),
|
||||
`tooling/fetch-launcher.sh`, fetch authlib-injector into `runtime/`.
|
||||
- [ops] TLS: `tooling/issue-letsencrypt.sh`, then
|
||||
`tooling/render-nginx.sh --install`.
|
||||
- [ops] Bring up: `docker compose up -d --build`.
|
||||
|
||||
@@ -14,28 +14,97 @@ Production host for the Ulicraft stack.
|
||||
|
||||
## Routine redeploy
|
||||
|
||||
```bash
|
||||
ssh cochi
|
||||
cd /home/ubuntu/mc/ulicraft-server-v1
|
||||
git pull --ff-only
|
||||
tooling/render-config.sh # re-render drasl/nmsr/pack configs from .env
|
||||
docker compose down --remove-orphans
|
||||
docker compose up -d --build
|
||||
```
|
||||
`update-all.sh` (repo root) is the **single source of truth** for the post-pull
|
||||
build + restart steps. Run it AFTER a `git pull` (it never pulls or pushes).
|
||||
Strict halt on any error — never a partial deploy.
|
||||
|
||||
Or as a one-shot from your workstation:
|
||||
Two modes:
|
||||
|
||||
- `./update-all.sh` — **rolling**: `docker compose up -d --build`, then restart
|
||||
ONLY the services whose mounted inputs changed since a pre-run snapshot
|
||||
(drasl/nmsr on a config re-render, minecraft on a mod change). Minimal
|
||||
downtime. Does **not** detect caddy static-conf (`caddy/conf.d/*.caddy`)
|
||||
changes — apply those with a **recreate**, not a reload/restart (see gotcha
|
||||
below): `docker compose up -d --force-recreate caddy` (no world downtime), or
|
||||
`--hard` for a full redeploy.
|
||||
|
||||
> **Caddy conf bind mounts are inode-pinned — recreate, don't reload.** Each
|
||||
> `caddy/conf.d/*.caddy` is a *single-file* bind mount, which Docker pins to
|
||||
> the host file's inode at container creation. `git pull` rewrites a tracked
|
||||
> file via atomic rename = a NEW inode, so the running container keeps the OLD
|
||||
> config. `docker compose restart caddy` and `caddy reload` reuse the same
|
||||
> container → same stale inode → no effect. Only re-creating the container
|
||||
> (`up -d --force-recreate caddy`, or the `--hard` down/up) re-resolves the
|
||||
> mount. Verify: `docker compose exec caddy cat /etc/caddy/conf.d/<file>`.
|
||||
- `./update-all.sh --hard` — `docker compose down --remove-orphans && up -d
|
||||
--build`. Full restart (Minecraft world downtime); picks up everything.
|
||||
|
||||
One-shot full redeploy from your workstation (the `deploy` skill does exactly
|
||||
this):
|
||||
|
||||
```bash
|
||||
ssh cochi 'set -e
|
||||
cd /home/ubuntu/mc/ulicraft-server-v1
|
||||
git pull --ff-only
|
||||
tooling/render-config.sh
|
||||
docker compose down --remove-orphans
|
||||
docker compose up -d --build'
|
||||
tooling/fetch-authlib.sh # ensure authlib jar (rarely changes; NOT in update-all)
|
||||
./update-all.sh --hard'
|
||||
```
|
||||
|
||||
`render-config.sh` re-renders configs (and `render-pack.sh` for the packwiz
|
||||
pack) from `.env` before the stack comes back up.
|
||||
Everyday landing/config tweak (no world downtime):
|
||||
|
||||
```bash
|
||||
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && git pull --ff-only && ./update-all.sh'
|
||||
```
|
||||
|
||||
> **Same inode trap for `filestash/config.json`.** It is a single-file bind mount
|
||||
> *and* it is re-rendered on every `update-all.sh` run, so a **rolling** update
|
||||
> after any `FILES_*` change (rotating the shared password, flipping
|
||||
> `FILES_AUTH`) leaves filestash reading the stale inode. Apply with
|
||||
> `docker compose up -d --force-recreate filestash`. `--hard` is unaffected.
|
||||
> Also: never run `docker compose up` on the host without rendering first — a
|
||||
> missing `filestash/config.json` makes Docker create a **directory** at that
|
||||
> path. See `plan/20-files.md`.
|
||||
|
||||
Steps `update-all.sh` runs, in order: `render-config.sh` (drasl/nmsr/filestash
|
||||
from `.env`; halts on a bad `REGISTRATION_MODE`, a bad `FILES_AUTH`, or a
|
||||
`FILES_PASSWORD_HASH` in a format the htpasswd plugin can't verify) →
|
||||
`sync-server-mods.sh` (mirror the
|
||||
`both`/`server` subset from `mods-sides.json` out of `$DISTRIBUTION_WEB_ROOT`
|
||||
into `./server-mods`) → `build-modlist.py` (regen landing `mods.json` + logos;
|
||||
**run under `python>=3.11`** — cochi's default `python3` is 3.10, but
|
||||
`python3.11` is installed and the script auto-selects it) → landing `pnpm build`
|
||||
(sources nvm + `.env`, never `npm`) → docker compose. `$DISTRIBUTION_WEB_ROOT`
|
||||
must resolve on the host (it also backs the caddy `distribution.` mount).
|
||||
|
||||
### Landing site + mod list (generated, not in git)
|
||||
|
||||
Both the static site `www/` AND the landing `mods.json` are gitignored — a `git
|
||||
pull` never updates them. `update-all.sh` regenerates both every run
|
||||
(`mods.json` via `build-modlist.py`, `www/` via `pnpm build`). A flag-only
|
||||
`REGISTRATION_MODE` change just needs a rolling `./update-all.sh`: it rebuilds
|
||||
`www/` (bakes the invite-field on/off) and restarts `drasl` only if
|
||||
`render-config.sh` changed its config (Drasl's `RequireInvite`/CORS/RateLimit) —
|
||||
no world downtime.
|
||||
|
||||
## Host-local divergence on `cochi` — RESOLVED (verified 2026-07-14)
|
||||
|
||||
**There are no longer any tracked modifications on the host.** `git diff HEAD`
|
||||
is empty; `git pull --ff-only` fast-forwards cleanly (confirmed on the filestash
|
||||
deploy). The old divergence — `docker-compose.yml` forced `http`→`https`,
|
||||
`landing/package.json`, `landing/pnpm-workspace.yaml` — has been converged into
|
||||
the repo. The stash–pull–pop dance previously documented here is **no longer
|
||||
needed**.
|
||||
|
||||
What remains on the host is **untracked only**, so it never blocks a pull:
|
||||
|
||||
- `dnsmasq/`, `caddy/caddy-root-ca.crt` — internal DNS + private CA so containers
|
||||
resolve/trust `https://auth.` etc. Host-local infra, deliberately not in git.
|
||||
- `pack/` — **stale leftover** from the packwiz era (packwiz was removed; see
|
||||
`04-mods.md`). Nothing reads it. Safe to delete.
|
||||
|
||||
Still true, and worth keeping: never `git reset --hard` or discard on the host
|
||||
without confirming first, and re-check `git status` there before any deploy that
|
||||
touches tracked files — a *new* host-local edit would reintroduce the abort.
|
||||
|
||||
## What survives a hard restart
|
||||
|
||||
@@ -52,9 +121,18 @@ restart (a few minutes); they reconnect once `minecraft` is healthy again.
|
||||
|
||||
- SSH access as the `cochi` alias.
|
||||
- `.env` present and complete: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT`,
|
||||
`CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`, and the Let's Encrypt block
|
||||
(`render-config.sh` halts on an unset `BASE_DOMAIN`).
|
||||
- Docker + compose plugin, `packwiz`, `envsubst`, `git`.
|
||||
`CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`, `REGISTRATION_MODE` (`invite`|`open`,
|
||||
defaults `invite`), the `FILES_*` block (`plan/20-files.md`), and the Let's
|
||||
Encrypt block. `render-config.sh` halts on an unset `BASE_DOMAIN`, an invalid
|
||||
`REGISTRATION_MODE`/`FILES_AUTH`, or a `FILES_PASSWORD_HASH` the htpasswd
|
||||
plugin can't verify.
|
||||
> **A missing `FILES_*` var takes the WHOLE stack down, not just filestash.**
|
||||
> The compose service uses `${FILES_DATA_DIR:?}` / `${FILES_LOCAL_SECRET:?}`
|
||||
> guards, and compose fails the entire file on an unset required var — so
|
||||
> `up` starts *nothing*, and a `--hard` deploy has already run `down`. Check
|
||||
> `.env` on the host **before** deploying a commit that adds a guarded service.
|
||||
- Docker + compose plugin, `envsubst`, `jq` (used by `sync-server-mods.sh`;
|
||||
falls back to `python3` if absent), `git`.
|
||||
- Host nginx + Let's Encrypt certs installed once — see `15-letsencrypt.md`.
|
||||
- **First deploy only** — online prep (build landing, fetch launcher, fetch
|
||||
authlib into `runtime/`). Not needed for routine redeploys.
|
||||
@@ -62,9 +140,15 @@ restart (a few minutes); they reconnect once `minecraft` is healthy again.
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
docker compose ps # caddy, drasl, minecraft, mc-backup, nmsr, uptime-kuma up
|
||||
docker compose logs -f minecraft # watch for "Done"
|
||||
docker compose ps # caddy drasl minecraft mc-backup nmsr mc-status uptime-kuma filestash
|
||||
# self-hosted live status feed (plan/15-mc-status.md):
|
||||
curl -fsS "https://$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/api/mcstatus/v2/status/java/$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)" | jq .online
|
||||
# server ready — match the full marker, not a bare "Done" (that also hits mod-load lines)
|
||||
docker compose logs -f minecraft | grep -m1 'Done ([0-9.]*s)! For help'
|
||||
curl -fsS "https://auth.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/" >/dev/null && echo "auth ok"
|
||||
# files. — 200 = live; anonymous API must be REFUSED (auth is on)
|
||||
curl -s -o /dev/null -w 'files: %{http_code}\n' "https://files.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/"
|
||||
curl -s -H 'X-Requested-With: XmlHttpRequest' "https://files.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/api/session" # want "Not authorised"
|
||||
```
|
||||
|
||||
## Rollback
|
||||
|
||||
123
plan/15-drasl-ui-customization.md
Normal file
123
plan/15-drasl-ui-customization.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Drasl Web UI Customization
|
||||
|
||||
> How far you can reskin Drasl's web frontend, and what it costs.
|
||||
> Source repo: <https://github.com/unmojang/drasl> · config docs:
|
||||
> <https://github.com/unmojang/drasl/blob/master/doc/configuration.md>
|
||||
|
||||
## TL;DR
|
||||
|
||||
Drasl has **no theme/plugin system**. You get three tiers, increasing effort:
|
||||
|
||||
1. **Config knobs** (runtime, trivial) — rename instance, toggle footer / 3D bg.
|
||||
2. **Static-asset override** (mount, easy) — swap `style.css`, `logo.svg`, `icon.png`.
|
||||
3. **Template / fork** (build, heavy) — edit `view/*.tmpl`, rebuild the image.
|
||||
|
||||
For a "very noob-friendly UI" the realistic win is **tier 2** (reskin CSS + logo)
|
||||
plus a **dead-simple guide page on the landing site** that walks guests through
|
||||
register → login → upload skin, screenshots included. Drasl's own pages stay the
|
||||
backend; the landing page is where you make it friendly.
|
||||
|
||||
---
|
||||
|
||||
## Tier 1 — Config knobs (config.toml)
|
||||
|
||||
Live in `drasl/config/config.toml.tmpl`. Already rendered into the container at
|
||||
`/etc/drasl/config.toml`. No rebuild — just `docker compose restart drasl`.
|
||||
|
||||
| Option | Default | Effect |
|
||||
|--------|---------|--------|
|
||||
| `InstanceName` | `"Drasl"` | Site name shown in header/title. Set to `"Ulicraft Accounts"`. |
|
||||
| `ApplicationOwner` | `"Anonymous"` | Owner label (already `"Ulicraft"`). |
|
||||
| `EnableBackgroundEffect` | `true` | 3D animated background. Off = cleaner/faster. |
|
||||
| `EnableFooter` | `true` | Footer block. |
|
||||
| `EnableWebFrontEnd` | `true` | Master switch for the whole web UI. |
|
||||
|
||||
Low effort, low ceiling. Does **not** change layout, colors, or copy.
|
||||
|
||||
---
|
||||
|
||||
## Tier 2 — Static-asset override (CSS + logo) ← recommended
|
||||
|
||||
Drasl serves static files from **`DataDirectory`** (default `/usr/share/drasl`).
|
||||
The shipped assets live in the image's `public/`:
|
||||
|
||||
- `style.css` — primary stylesheet (all colors/spacing/theme)
|
||||
- `logo.svg` — header logo
|
||||
- `icon.png` — favicon
|
||||
- `openid-logo.svg` — OIDC button icon (unused here, no OIDC)
|
||||
|
||||
### Plan
|
||||
|
||||
1. Pull the upstream `style.css` as a baseline:
|
||||
<https://raw.githubusercontent.com/unmojang/drasl/master/public/style.css>
|
||||
2. Create `drasl/public/` in this repo. Drop in your edited `style.css`,
|
||||
`logo.svg`, `icon.png`.
|
||||
3. Bind-mount over the container's asset dir in `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
drasl:
|
||||
volumes:
|
||||
- ./drasl/config:/etc/drasl:ro
|
||||
- ./drasl/public:/usr/share/drasl/public:ro # <-- add
|
||||
- drasl_state:/var/lib/drasl
|
||||
```
|
||||
|
||||
⚠ **Verify the exact in-container path first** — assets may sit at
|
||||
`/usr/share/drasl/public/` or directly under `/usr/share/drasl/`. Check:
|
||||
|
||||
```bash
|
||||
docker compose exec drasl sh -c 'find /usr/share/drasl -name style.css'
|
||||
```
|
||||
|
||||
Mount to match whatever that prints. Mount the *directory*, not the single
|
||||
file, so the logo/icon ride along.
|
||||
4. `docker compose up -d drasl`. Hard-refresh browser (CSS cache).
|
||||
|
||||
Ceiling: full restyle of existing pages — colors, fonts, button sizes, hide
|
||||
clutter, big friendly upload button. Cannot add/remove page elements or reword
|
||||
text (that's the templates → tier 3).
|
||||
|
||||
---
|
||||
|
||||
## Tier 3 — Templates / fork (heavy)
|
||||
|
||||
Page structure + copy live in Go templates `view/*.tmpl`, **embedded into the
|
||||
binary at build time** (Go `embed.FS`) — not overridable by mounting. Files:
|
||||
|
||||
```
|
||||
root.tmpl user.tmpl player.tmpl registration.tmpl
|
||||
complete-registration.tmpl challenge.tmpl admin.tmpl
|
||||
layout.tmpl header.tmpl footer.tmpl error.tmpl
|
||||
```
|
||||
|
||||
To change them you must **fork + rebuild**:
|
||||
|
||||
1. Fork `unmojang/drasl`.
|
||||
2. Edit `view/*.tmpl` (reword, restructure, add help text/links).
|
||||
3. Build a custom image, push to your registry (or build locally).
|
||||
4. Point `image:` in compose at your fork.
|
||||
|
||||
Maintenance cost: you now track upstream and rebase on releases. Only worth it
|
||||
if tier 2 + landing-page guide aren't enough.
|
||||
|
||||
Alternative to forking: **build a thin custom frontend** that talks to Drasl's
|
||||
HTTP API (see `api.go`) for the one flow guests care about (login + skin
|
||||
upload), hosted at e.g. `${BASE_DOMAIN}/skins`. More code, but total UI control
|
||||
and no upstream coupling. Overkill for 4–10 friends.
|
||||
|
||||
---
|
||||
|
||||
## Recommended path for Ulicraft
|
||||
|
||||
1. **Tier 1**: `InstanceName = "Ulicraft Accounts"`, decide on bg/footer.
|
||||
2. **Tier 2**: reskin `style.css` to match the landing page, swap logo + favicon.
|
||||
3. **Landing-page guide**: add a `/skins` (or `/help`) page on the apex landing
|
||||
site — numbered steps + screenshots: register at `auth.`, log in, upload skin,
|
||||
done. This is what actually makes it noob-friendly; Drasl stays the plumbing.
|
||||
4. Skip tier 3 unless a specific page is confusing enough to justify a fork.
|
||||
|
||||
## Related
|
||||
|
||||
- Skin/cape config flags: see `plan/03-drasl.md` and CLAUDE.md gotchas.
|
||||
- `AllowTextureFromURL = true` lets guests paste a skin URL (e.g. from a skin
|
||||
site) instead of uploading a file — friendlier for non-technical users.
|
||||
@@ -9,7 +9,7 @@ path — there are no compose overrides.
|
||||
## Certs: one per name, via OVH DNS-01
|
||||
|
||||
`tooling/issue-letsencrypt.sh` issues a **separate** cert for the apex and each
|
||||
subdomain (`${BASE_DOMAIN}`, `auth.…`, `pack.…`, plus the rest of
|
||||
subdomain (`${BASE_DOMAIN}`, `auth.…`, plus the rest of
|
||||
`LE_SUBDOMAINS`) using acme.sh + the OVH DNS-01 challenge — no inbound ports
|
||||
needed, so it works regardless of public reachability.
|
||||
|
||||
@@ -55,7 +55,7 @@ the reload with `RELOAD_CMD=...` if nginx isn't systemd-managed.
|
||||
## DNS
|
||||
|
||||
Point the public DNS for `${BASE_DOMAIN}` and every subdomain
|
||||
(`auth.` `pack.` `avatar.` `status.` `distribution.` `www.`) at the host running
|
||||
(`auth.` `avatar.` `status.` `distribution.` `www.`) at the host running
|
||||
nginx. DNS is configured **outside this repo**. The Minecraft container reaches
|
||||
the stack's own names via caddy's `mcnet` aliases, not public DNS.
|
||||
|
||||
|
||||
95
plan/15-mc-status.md
Normal file
95
plan/15-mc-status.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# 15 — mc-status (self-hosted live server card backend)
|
||||
|
||||
Self-hosted Minecraft Server List Ping → JSON service that feeds the landing's
|
||||
live **ServerCard**. Replaces the third-party `api.mcstatus.io` dependency.
|
||||
|
||||
## Why not just use api.mcstatus.io
|
||||
|
||||
The public API works (CORS `*`, no key), but mcstatus.io's **API service is not
|
||||
open source** — only its underlying Go library, `mcutil`, is. To self-host we
|
||||
run the *same library* behind our own thin HTTP shell that re-emits mcstatus.io's
|
||||
**v2 JSON shape**, so the frontend (`ServerCard.astro`) stays byte-identical —
|
||||
only the URL changes. itzg/mc-status is deprecated; mc-monitor only exports
|
||||
Prometheus metrics (no icon/MOTD/player sample), so neither fits the card.
|
||||
|
||||
## Service
|
||||
|
||||
`docker/mc-status/` — Go, built from source (multi-stage → scratch), image
|
||||
`ulicraft/mc-status:local`. Wraps `github.com/mcstatus-io/mcutil/v4`
|
||||
`status.Modern()` and maps `StatusModern` → v2 JSON:
|
||||
|
||||
| mcutil field | v2 field ServerCard reads |
|
||||
|---|---|
|
||||
| `Players.Online/Max` | `players.online` / `players.max` |
|
||||
| `Players.Sample[].ID` | `players.list[].uuid` |
|
||||
| `Players.Sample[].Name` (`formatting.Result`) | `players.list[].name_raw/clean/html` |
|
||||
| `MOTD` (`formatting.Result`) | `motd.raw/clean/html` |
|
||||
| `Favicon` (`data:image/png;base64,…`) | `icon` |
|
||||
|
||||
On any ping failure it returns `{"online": false}` so the card shows an offline
|
||||
state without erroring.
|
||||
|
||||
### Hardening
|
||||
|
||||
- **No open SSRF relay.** The `<addr>` segment in `/v2/status/java/<addr>` is
|
||||
**ignored** — the service only ever pings `MC_STATUS_TARGET`. The segment
|
||||
exists only to keep the URL drop-in compatible with the public API.
|
||||
- **TTL cache** (`MC_STATUS_CACHE_TTL`, default `30s`) so a busy landing page
|
||||
can't ping the Minecraft server once per visitor.
|
||||
|
||||
### Env
|
||||
|
||||
| Var | Default | Notes |
|
||||
|---|---|---|
|
||||
| `MC_STATUS_TARGET` | `minecraft:25565` | pinged internally over `mcnet` |
|
||||
| `MC_STATUS_CACHE_TTL` | `30s` | in-memory cache window |
|
||||
| `MC_STATUS_LISTEN` | `:8080` | bind address |
|
||||
|
||||
## Ingress
|
||||
|
||||
Path-on-apex (no new DNS subdomain, no Let's Encrypt cert change — DNS/TLS are
|
||||
managed outside this repo). `caddy/conf.d/10-static.caddy`:
|
||||
|
||||
```caddy
|
||||
handle /api/mcstatus/* {
|
||||
uri strip_prefix /api/mcstatus
|
||||
reverse_proxy mc-status:8080
|
||||
}
|
||||
```
|
||||
|
||||
Same-origin → the ServerCard fetch needs no CORS. `site.ts`:
|
||||
`statusApi: \`/api/mcstatus/v2/status/java/${BASE_DOMAIN}\``.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
guest browser ─ GET /api/mcstatus/v2/status/java/${BASE_DOMAIN}
|
||||
└► caddy (apex) ─ strip_prefix /api/mcstatus ─► mc-status:8080
|
||||
└► mcutil SLP ping ─► minecraft:25565 (internal, mcnet)
|
||||
◄── v2 JSON {online, players.list[uuid,name_*], motd.html, icon}
|
||||
ServerCard → player heads via NMSR: avatar.${BASE_DOMAIN}/headiso/<uuid>?size=64
|
||||
```
|
||||
|
||||
## Relationship to Uptime Kuma (plan/10)
|
||||
|
||||
Distinct roles. **Kuma** = uptime *history* + alerting backend (Minecraft Server
|
||||
monitor, status page). **mc-status** = live, public-facing data for the landing
|
||||
card (richer: icon/MOTD/player heads). Both ping `minecraft` over `mcnet`.
|
||||
|
||||
## Validation notes
|
||||
|
||||
Built + `go vet`/`gofmt` clean. The live ping path can't be exercised from the
|
||||
dev sandbox (egress proxy allows TCP connect but filters the raw SLP handshake →
|
||||
`context deadline exceeded`); it resolves on the prod host where `minecraft` is
|
||||
reachable over `mcnet`. After deploy, confirm:
|
||||
|
||||
```
|
||||
curl -s https://${BASE_DOMAIN}/api/mcstatus/v2/status/java/${BASE_DOMAIN} | jq .
|
||||
```
|
||||
expecting `online:true`, a populated `players.list`, and `motd.html`.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] `docker compose up -d --build mc-status` on prod, verify the curl above.
|
||||
- [ ] Confirm the SLP player sample carries real (Drasl) UUIDs so NMSR heads
|
||||
render; if empty, the card falls back to the count-only / empty state.
|
||||
155
plan/16-landing-registration.md
Normal file
155
plan/16-landing-registration.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Landing-page registration (B1) — register + skin on the apex
|
||||
|
||||
> Guests create their Drasl account and set a skin from the landing site itself
|
||||
> (`${BASE_DOMAIN}/register`), in the pixel design — never touching Drasl's own
|
||||
> web UI. The page is static; the browser calls the Drasl REST API directly
|
||||
> (CORS). See `15-drasl-ui-customization.md` for the option survey this came from.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **Chosen: B1 — browser-direct.** A static Astro `/register` page ships vanilla
|
||||
JS that `fetch()`es the Drasl API v2 from the browser. No backend, no secret in
|
||||
the page, zero coupling to Drasl's internals (survives upstream upgrades).
|
||||
- Rejected **A (fork Drasl + rebuild)** — perpetual rebase cost, and B1 makes
|
||||
guests stop visiting Drasl's pages anyway. Asset-mount reskin (tier 2) is still
|
||||
the cheap way to brand the pages guests *do* hit (admin/login).
|
||||
- Rejected **B2 (server-side proxy)** — adds a runtime + admin secret; only worth
|
||||
it for captcha / invite-bypass. Revisit if open-mode spam becomes a problem.
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
guest @ https://${BASE_DOMAIN}/register (static Astro page + inline JS)
|
||||
1. POST ${draslApiUrl}/users {username,password,playerName[,inviteCode]} (anon)
|
||||
→ 200 APIUser
|
||||
2. POST ${draslApiUrl}/login {username,password}
|
||||
→ 200 {apiToken, user} (token kept in memory)
|
||||
3. (optional) PATCH ${draslApiUrl}/players/{user.players[0].uuid}
|
||||
Authorization: Bearer <apiToken>
|
||||
{skinBase64, skinModel} → skin set
|
||||
```
|
||||
`draslApiUrl = https://auth.${BASE_DOMAIN}/drasl/api/v2` (`site.ts`).
|
||||
|
||||
## Drasl API contract (verified against unmojang/drasl @ master)
|
||||
|
||||
Route prefix `/drasl/api/v2`. Auth = `Authorization: Bearer <api_token>`.
|
||||
Errors are uniform `{ "message": "..." }` with a non-2xx status.
|
||||
|
||||
- `POST /users` — create user. Callable **anonymous**. Key request fields:
|
||||
`username`, `password`, `playerName`, `inviteCode`, `skinBase64`/`skinUrl`.
|
||||
Returns `APIUser` (`uuid`, `username`, `players[]` each with `uuid`,`name`,`skinUrl`).
|
||||
- `POST /login` — `{username,password}` → `{apiToken, user}` (`APILoginResponse`).
|
||||
- `PATCH /players/{uuid}` — `skinBase64`|`skinUrl`, `skinModel` (`classic`|`slim`),
|
||||
`deleteSkin`. Needs Bearer.
|
||||
- `POST /invites` — admin only (mint invite codes).
|
||||
|
||||
## Security findings (read before touching this)
|
||||
|
||||
All verified in Drasl source, not assumed:
|
||||
|
||||
1. **Privileged fields are gated downstream**, not in the handler. `CreateUser`
|
||||
(user.go) rejects non-admin/anonymous callers that set them — so anonymous
|
||||
`POST /users` with `isAdmin:true` returns 400, **not** escalation:
|
||||
- `isAdmin && !callerIsAdmin` → error (user.go:217)
|
||||
- `isLocked && !callerIsAdmin` → error (user.go:221)
|
||||
- `chosenUuid` needs `CreateNewPlayer.AllowChoosingUUID` or admin (user.go:192)
|
||||
- `maxPlayerCount` → admin only (user.go:227)
|
||||
- `RegistrationNewPlayer.Allow` + `RequireInvite` enforced for non-admin (177/181)
|
||||
2. **`CORSAllowOrigins` is the on-switch.** Drasl applies CORS to API routes
|
||||
**only if** `CORSAllowOrigins` is non-empty (main.go). Empty ⇒ no CORS headers
|
||||
⇒ browser blocks every call. Echo backfills `AllowMethods` (incl. PATCH/DELETE)
|
||||
and reflects the requested headers (so `Content-Type`/`Authorization` pass
|
||||
preflight). **Scope to the apex — never `"*"`** (would let any site script the
|
||||
auth API).
|
||||
3. **Rate-limit the now public API** (`[RateLimit]`) — anonymous `POST /users` is
|
||||
internet-facing.
|
||||
4. **TLS only** — passwords cross the wire; already HTTPS via host nginx.
|
||||
5. **Invite vs open** — on a public host, prefer invite. See feature flag below.
|
||||
|
||||
## Feature flag — `REGISTRATION_MODE` (invite | open)
|
||||
|
||||
One `.env` key, two consumers, default `invite`. Drasl config is TOML-only (no
|
||||
env), so the script derives the boolean; the landing reads the same key at build.
|
||||
|
||||
| Consumer | Path |
|
||||
|---|---|
|
||||
| Drasl | `render-config.sh` validates the enum → `REGISTRATION_REQUIRE_INVITE` (true/false) → envsubst into `config.toml.tmpl` `[RegistrationNewPlayer] RequireInvite` |
|
||||
| Landing | `site.ts` `registrationRequiresInvite = REGISTRATION_MODE === "invite"` → register form shows/hides the invite-code field |
|
||||
|
||||
`render-config.sh` **halts** on any value other than `invite`/`open` (no
|
||||
half-rendered config). `invite`→form requires a code (admin mints via
|
||||
`POST /drasl/api/v2/invites`); `open`→self-serve (keep `[RateLimit]`).
|
||||
|
||||
## Drasl config delta (`drasl/config/config.toml.tmpl`)
|
||||
|
||||
```toml
|
||||
[RegistrationNewPlayer]
|
||||
Allow = true
|
||||
RequireInvite = ${REGISTRATION_REQUIRE_INVITE} # derived from REGISTRATION_MODE
|
||||
|
||||
CORSAllowOrigins = ["https://${BASE_DOMAIN}"] # apex only, never "*"
|
||||
|
||||
[RateLimit]
|
||||
RequestsPerSecond = 5
|
||||
Burst = 10
|
||||
```
|
||||
Applied by re-render + `docker compose restart drasl` — no image rebuild.
|
||||
|
||||
## Landing implementation
|
||||
|
||||
- Route: `src/pages/[...lang]/register.astro` → `/register`, `/es/register`,
|
||||
`/eu/register` (mirrors `[...lang].astro`; both coexist, build-verified).
|
||||
- i18n: `i18n/ui.ts` `register` block (en/es/eu) + `LANG_REGISTER_PATH`.
|
||||
- Styles: `.reg-*` block in `styles/main.css` (reuses the bevel/slot design tokens).
|
||||
- `site.ts`: `draslApiUrl` + `registrationRequiresInvite`.
|
||||
- Client JS: inline `<script define:vars={{cfg}}>` (apiUrl + flag + runtime
|
||||
strings). Flow = register → login → reveal success (player UUID) → optional
|
||||
PNG→base64 skin upload (classic/slim). Surfaces Drasl `{message}` errors inline.
|
||||
- The Drasl web UI stays the "manage existing account" target (linked from the
|
||||
form footer). Account *edit*/admin still live there — guests don't need them.
|
||||
|
||||
## Build / deploy wiring
|
||||
|
||||
`REGISTRATION_MODE` must reach **both** the Drasl render and the landing build —
|
||||
both source `.env`. The landing build is NOT a docker step; it emits static
|
||||
`www/` (gitignored), so it must run on the host whenever `landing/` **or**
|
||||
`REGISTRATION_MODE` changes.
|
||||
|
||||
```bash
|
||||
# Re-render Drasl config (reads .env incl. REGISTRATION_MODE)
|
||||
tooling/render-config.sh
|
||||
|
||||
# Rebuild landing into www/ — source .env so BASE_DOMAIN + REGISTRATION_MODE bake in
|
||||
( cd landing && set -a && . ../.env && set +a && pnpm run build )
|
||||
|
||||
docker compose restart drasl # picks up CORS/RateLimit/RequireInvite
|
||||
```
|
||||
|
||||
Toggling the flag = run all three (config restart + landing rebuild). `www/` is a
|
||||
generated artifact and is not in git, so a plain `git pull` does **not** update
|
||||
the served page — the landing rebuild is required for any `landing/` change to go
|
||||
live. See `12-build-order.md` (ops) and `14-deploy.md`.
|
||||
|
||||
## Runtime acceptance (can't verify offline)
|
||||
|
||||
- [ ] `CORSAllowOrigins` live in the running drasl (restart after render).
|
||||
- [ ] Preflight: browser `OPTIONS ${draslApiUrl}/users` from the apex returns
|
||||
`Access-Control-Allow-Origin: https://${BASE_DOMAIN}`.
|
||||
- [ ] Admin account exists; in `invite` mode, mint codes via
|
||||
`POST /drasl/api/v2/invites` (admin Bearer) and share out-of-band.
|
||||
- [ ] End-to-end: register → login → skin → join with the new account.
|
||||
|
||||
## Future / open
|
||||
|
||||
- Account **login + skin-change** panel on the landing (same API, add `/account`).
|
||||
- `AllowTextureFromURL` → let guests paste a skin URL instead of uploading.
|
||||
- B2 proxy only if open-mode abuse forces captcha / invite-bypass.
|
||||
|
||||
## Related
|
||||
|
||||
- `15-drasl-ui-customization.md` — the A/B option survey + asset-mount reskin.
|
||||
- `03-drasl.md` — Drasl config + secure-profile gotcha.
|
||||
- `09-landing.md` — landing stack, i18n, theme.
|
||||
- Drasl API: <https://doc.drasl.unmojang.org> · config:
|
||||
<https://github.com/unmojang/drasl/blob/master/doc/configuration.md>
|
||||
```
|
||||
75
plan/17-mod-shortlist.md
Normal file
75
plan/17-mod-shortlist.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Mod shortlist + landing mod-list component (WS6)
|
||||
|
||||
> **Status: PLANNED — not started.** Two separable deliverables: (1) a curated
|
||||
> kitchen-sink gap doc (this file), (2) an auto-generated `mods.json` + logos that
|
||||
> feed a flat mod-list component on the landing page (see also `18-landing-rework.md`).
|
||||
|
||||
## TL;DR
|
||||
|
||||
- Current pack (76 jars, `mods-sides.json`) is a polished **vanilla+ QoL/perf** base,
|
||||
**not yet kitchen-sink**. Strong perf + QoL + storage + FTB + voice; **empty** on
|
||||
tech and magic, **thin** on worldgen, **no** map mod.
|
||||
- Jars live in the **external distribution repo** (`$DISTRIBUTION_WEB_ROOT/servers/
|
||||
ulicraft-1.21.1/forgemods/required/*.jar`, Nebula-managed). Adding mods happens
|
||||
there, **not** in this repo. This doc is a shortlist only; depth = **light** (names
|
||||
by area, verify NeoForge 1.21.1 at add-time — no per-mod version/link research).
|
||||
- The landing component shows the **installed pack** (full client set), auto-generated
|
||||
from the jars — a different dataset from this shortlist. Flat alphabetical, pretty
|
||||
names, extracted logos, no categories, no Modrinth/CF links.
|
||||
|
||||
## Current pack — what's already in (76 jars)
|
||||
|
||||
- **Perf:** sodium, sodium-extra, iris, immediatelyfast, entityculling, ferritecore,
|
||||
lithium, spark.
|
||||
- **QoL:** JEI, Jade(+addons), JER, JEProfessions, appleskin, MouseTweaks, Controlling,
|
||||
Searchables, inventoryessentials, polymorph, rightclickharvest, simplemagnets,
|
||||
fastleafdecay, torchmaster, trashcans, corpse(+curioscompat), lootr, comforts,
|
||||
BetterF3, tagtooltips, cardboardboxes.
|
||||
- **Storage:** sophisticatedbackpacks(+core), functionalstorage(+more).
|
||||
- **Farm/decor/build:** FarmersDelight, FramedBlocks, handcrafted.
|
||||
- **FTB utils:** chunks, essentials, library, teams, ultimine.
|
||||
- **Voice:** Simple Voice Chat (`voicechat`).
|
||||
- **Scripting/guide:** KubeJS(+rhino, +delight), ponderjs, Patchouli.
|
||||
- **Libs:** architectury, balm, cloth-config, configured, curios, resourcefullib,
|
||||
supermartijn642 core/config, jamlib, baguettelib, titanium, utilitarian, YungsApi,
|
||||
Necronomicon, yacl.
|
||||
|
||||
## Gaps vs kitchen-sink (tech + magic + exploration + QoL)
|
||||
|
||||
- [ ] **Tech — empty.** Candidates: Create, Mekanism, Immersive Engineering, Applied
|
||||
Energistics 2, Thermal Series, Powah. (Pick a subset; Create is the anchor.)
|
||||
- [ ] **Magic — empty.** Candidates: Ars Nouveau, Botania, Iron's Spells 'n Spellbooks,
|
||||
Eidolon.
|
||||
- [ ] **Worldgen — thin** (only `YungsApi` + `ExplorersCompass`). Candidates: Terralith,
|
||||
Tectonic, YUNG's structure set (Better Dungeons/Mineshafts/Strongholds…),
|
||||
Repurposed Structures, William Wythers' Overhauled Overworld.
|
||||
- [ ] **Map — none.** Candidate: Xaero's Minimap + Worldmap.
|
||||
- [ ] **Server-side perf (optional):** ModernFix, C2ME.
|
||||
|
||||
## Orphaned dependencies (cleanup)
|
||||
|
||||
- [ ] **`ponderjs`** present but **no Create** — ponder is Create's. Add Create or drop.
|
||||
- [ ] **`Necronomicon`** present but **no Iron's Spells** — it's Iron's Spells' lib.
|
||||
Add Iron's Spells or drop.
|
||||
|
||||
## Curation tradeoff
|
||||
|
||||
Adding the tech+magic+worldgen anchors pushes 76 → 100+ jars, near the stated
|
||||
50–100 ceiling, and raises the RAM/stability bar (`INIT_MEMORY 4G`/`MAX_MEMORY 10G`).
|
||||
Curate deliberately; verify each candidate's NeoForge **1.21.1** build + the
|
||||
`21.1.233` loader pin at add-time.
|
||||
|
||||
## Execution (external repo, when ready)
|
||||
|
||||
1. Add jars in the distribution repo (Nebula `forgemods/required/`), keep the NeoForge
|
||||
1.21.1 builds matching the `21.1.233` pin.
|
||||
2. `release-distro.sh` regenerates `distribution.json` + uploads.
|
||||
3. Back in this repo: `tooling/classify-mods.py` (seed sides) → hand-edit new
|
||||
client-cosmetic entries to `client` → `tooling/sync-server-mods.sh`.
|
||||
4. `tooling/build-modlist.py` (new, see `18-landing-rework.md`) regenerates
|
||||
`landing/src/data/mods.json` + logos → rebuild landing.
|
||||
|
||||
## Related
|
||||
|
||||
- `04-mods.md` — distribution-fed mod system, classify/sync, sides.
|
||||
- `18-landing-rework.md` — the landing mod-list component + `mods.json` generator.
|
||||
207
plan/18-landing-rework.md
Normal file
207
plan/18-landing-rework.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# Landing rework — join flow, player rosters, account, mod list, footer
|
||||
|
||||
> **Status: PLANNED — not started.** Five landing workstreams (WS1–WS5) plus the
|
||||
> mod-list component from `17-mod-shortlist.md` (WS6). Each is independent; suggested
|
||||
> order: **WS5 → WS1 → WS2/3 → WS4 → WS6** (trivial first; rosters share avatar
|
||||
> plumbing; account reuses register's skin JS; mods last).
|
||||
|
||||
The landing is an **Astro static site** (`landing/`, pnpm, output → `../www/`), built
|
||||
on the host with `.env` sourced. Three locales (en/es/eu) in `i18n/ui.ts`, identical
|
||||
shape; non-translatable config in `data/site.ts`. Runtime data (status, rosters, Drasl
|
||||
API) is fetched client-side; everything else is SSG.
|
||||
|
||||
## New env keys (`.env` + `.env.example`)
|
||||
|
||||
| Key | Consumer | Purpose |
|
||||
|---|---|---|
|
||||
| `AVATAR_MODE` (default `headiso`) | landing build → `site.ts` | NMSR render mode for avatar tiles |
|
||||
| `DRASL_ADMIN_USERNAME` / `DRASL_ADMIN_PASSWORD` | **mc-status** (compose env) | admin login for the registered-players roster — **never** reaches the browser |
|
||||
|
||||
Use a **dedicated, rotatable** Drasl admin account for these creds (Drasl has no
|
||||
read-only role; full-admin, but isolates blast radius from your personal admin).
|
||||
|
||||
---
|
||||
|
||||
## WS1 — Join flow off packwiz (homepage = UlicraftLauncher, 3 steps)
|
||||
|
||||
The modpack now ships via **UlicraftLauncher** (rebranded HeliosLauncher in the
|
||||
`custom-launcher` repo) — it installs mods (packwiz), auth (Drasl, preconfigured) and
|
||||
all files itself. So the homepage join flow drops the packwiz step and the manual
|
||||
authlib step.
|
||||
|
||||
**Homepage steps (3):**
|
||||
1. **Register** — create your Ulicraft account (link `/register`).
|
||||
2. **Download the Ulicraft Launcher** — per-OS download (from manifest); log in with
|
||||
your credentials. (No authlib URL — launcher is preconfigured.)
|
||||
3. **Join the server** — launch, connect to `site.serverAddress` (IP copy chip).
|
||||
|
||||
**Launcher list (delivery = synced local file, option B):**
|
||||
- Source of truth: the `custom-launcher` build output `launcher.json` (shape
|
||||
`productName/version/baseUrl/files[]`, each file `{name,os,arch,ext,size,url}`),
|
||||
published to `https://distribution.${BASE}/launcher/launcher.json`.
|
||||
- The release flow syncs it into `landing/src/data/launcher.json` (gitignored,
|
||||
generated), same pattern as `sync-server-mods.sh`. `launcher.example.json`
|
||||
committed as the documented fallback shape.
|
||||
- Landing reads it at build (`site.ts` `loadLauncherManifest`), normalizes
|
||||
`files[]` → internal `builds[]`, renders per-OS buttons. Missing file → hide
|
||||
the buttons (build must not fail).
|
||||
|
||||
**Fjord — alternative path, OWN page, off the homepage:** new `/fjord` (+ `/es`,
|
||||
`/eu`). Download Fjord (existing `/launcher/` mirror), add authlib-injector account
|
||||
(`site.authlibUrl` chip), import `manifest.fjordPack` (`.mrpack`), connect.
|
||||
|
||||
Tasks:
|
||||
- [ ] `site.ts`: delete `packwizUrl`; replace `launcher` (Fjord hardcode) with the
|
||||
manifest import; rename product display to "Ulicraft Launcher".
|
||||
- [ ] `ui.ts`: rewrite `join` block (3 steps) in **all three** locales; eyebrow "3
|
||||
Steps"; reword the `features` item that name-drops packwiz.
|
||||
- [ ] `[...lang].astro`: collapse the 4-step join markup → 3; remove packwiz `CopyChip`,
|
||||
keep server-IP chip; render download buttons from manifest.
|
||||
- [ ] New `[...lang]/fjord.astro` + `ui.ts` `fjord` block + `LANG_FJORD_PATH`.
|
||||
- [ ] Document the synced-manifest step in `12-build-order.md` / `14-deploy.md`.
|
||||
|
||||
---
|
||||
|
||||
## WS2 — Live server status: online players with names + 3D avatars
|
||||
|
||||
`ServerCard.astro` already fetches mc-status (`/api/mcstatus/v2/status/java/${BASE_DOMAIN}`,
|
||||
mcstatus.io v2 JSON: `players.online/max/list[{uuid,name_clean}]`) and renders NMSR
|
||||
`headiso` heads (filters the zero-UUID placeholder). Enhancement: labeled avatar tiles
|
||||
(name + avatar) using `site.avatarMode`.
|
||||
|
||||
Tasks:
|
||||
- [ ] `ServerCard.astro`: per online player render a **shared avatar tile** (name +
|
||||
`${avatarUrl}/${avatarMode}/<uuid>?size=N`). Keep count + fill bar.
|
||||
- [ ] `main.css`: `.roster`/tile styles (shared with WS3).
|
||||
|
||||
---
|
||||
|
||||
## WS3 — Registered players roster (live, via mc-status)
|
||||
|
||||
`GET /drasl/api/v2/players` (list all) is **admin-only** → cannot be a browser fetch.
|
||||
Serve it **live** through mc-status (the trusted internal proxy that already emits
|
||||
sanitized public JSON). **Coupling accepted** (4–10 friend server) with two mitigations:
|
||||
isolate the roster fetch (own timeout + cache; the status path never blocks on Drasl),
|
||||
and use a dedicated rotatable admin account.
|
||||
|
||||
mc-status (`docker/mc-status/main.go`) gains a `/players` endpoint:
|
||||
- On request (cache TTL ~60s): if no cached token, `POST http://drasl:25585/drasl/api/v2/login`
|
||||
with admin creds → cache `apiToken` (re-login on 401). `GET …/players` (Bearer) →
|
||||
map to `[{uuid,name}]` → cache → serve JSON, CORS `*` (like the status route).
|
||||
- Caddy already `handle /api/mcstatus/*` → mc-status, so the public path is
|
||||
`/api/mcstatus/players` (browser fetches **same-origin**, no CORS, no admin token in
|
||||
the page).
|
||||
|
||||
`PlayerRoster.astro` (new) fetches `/api/mcstatus/players` client-side → live grid of
|
||||
shared avatar tiles. Roster is **public** (uuid+name) — accepted.
|
||||
|
||||
Tasks:
|
||||
- [ ] `main.go`: `/players` handler (Drasl login + list, token cache, TTL cache,
|
||||
isolated from the status path).
|
||||
- [ ] `docker-compose.yml`: pass `DRASL_ADMIN_*` env to mc-status.
|
||||
- [ ] New `PlayerRoster.astro` + a "Members" section in `[...lang].astro` + `ui.ts`
|
||||
keys (3 langs).
|
||||
|
||||
---
|
||||
|
||||
## WS4 — Player account page (skin CRUD)
|
||||
|
||||
New `/account` (+ `/es`, `/eu`), browser-direct Drasl (proven by `/register`; CORS
|
||||
already scoped to the apex). **Skins only** (capes out). One player per user
|
||||
(`players[0]`, no switcher). Token **in-memory only** (refresh = re-login; no
|
||||
localStorage → no XSS token theft).
|
||||
|
||||
- **Read:** `POST /login {username,password}` → `{apiToken, user.players[0]{uuid,name,skinUrl}}`.
|
||||
- **Create/Update:** `PATCH /players/{uuid} {skinBase64, skinModel}` (Bearer).
|
||||
- **Delete:** `PATCH /players/{uuid} {deleteSkin:true}` → reverts to default.
|
||||
|
||||
UI: login form → reveal skin panel (current avatar via NMSR `site.avatarMode`, model
|
||||
radio classic/slim, file picker, [Upload], [Reset to default]). After upload, NMSR lags
|
||||
~15m → show the **just-picked file** locally (FileReader dataURL) as instant
|
||||
confirmation, not a fresh NMSR render.
|
||||
|
||||
Tasks:
|
||||
- [ ] New `[...lang]/account.astro` — lift skin JS near-verbatim from
|
||||
`register.astro:250-307` (`readBase64`, PATCH, `apiMessage`).
|
||||
- [ ] `ui.ts`: `account` block (reuse `register.skin*`) + `LANG_ACCOUNT_PATH`.
|
||||
- [ ] `main.css`: reuse `.reg-*` tokens.
|
||||
- [ ] `register.astro`: repoint the "Manage it here" footer link from the Drasl web UI
|
||||
to `/account`.
|
||||
|
||||
---
|
||||
|
||||
## WS5 — Footer status link
|
||||
|
||||
Add a link to `https://status.${BASE_DOMAIN}` in every footer. Nav unchanged.
|
||||
|
||||
Tasks:
|
||||
- [ ] `site.ts`: add `statusUrl`.
|
||||
- [ ] `ui.ts`: add `footer.status` (3 langs).
|
||||
- [ ] Add `<a target="_blank" rel="noopener">` in `.footer-links` of `[...lang].astro`,
|
||||
`register.astro`, and the new `account.astro` / `fjord.astro` footers.
|
||||
|
||||
---
|
||||
|
||||
## WS6 — Mod-list component (auto-generated, see `17-mod-shortlist.md`)
|
||||
|
||||
Shows the **installed pack** (full client set from the distribution forgemods), flat
|
||||
alphabetical, pretty names + extracted logos, no categories, no external links.
|
||||
Build-time baked.
|
||||
|
||||
New `tooling/build-modlist.py` (mirrors `classify-mods.py`: `tomllib` + `zipfile`,
|
||||
offline) reads `$DISTRIBUTION_WEB_ROOT/servers/ulicraft-1.21.1/forgemods/required/*.jar`:
|
||||
- Per jar: parse `neoforge.mods.toml`, take the primary `[[mods]]` → `displayName`
|
||||
(fallback modId), `version`, `description`, `authors`; extract `logoFile` PNG → write
|
||||
`landing/public/mod-logos/<modid>.png` (skip if absent → `logo: null`).
|
||||
- Write `landing/src/data/mods.json` (gitignored, generated), sorted by `name`:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"count": 76,
|
||||
"mods": [
|
||||
{ "id": "jei", "name": "Just Enough Items (JEI)", "version": "19.27.0.340",
|
||||
"authors": "mezz", "description": "…", "logo": "/mod-logos/jei.png" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`ModList.astro` (new) imports `mods.json` → flat grid (logo + name + version). The
|
||||
homepage stat tile (`site.ts` `stats[0].key "50+"`) is fed from `mods.json.count`.
|
||||
|
||||
Tasks:
|
||||
- [ ] `tooling/build-modlist.py` (jar parse + logo extract + `mods.json`).
|
||||
- [ ] New `ModList.astro` + a "Mods" section in `[...lang].astro` + `ui.ts` heading
|
||||
keys (3 langs).
|
||||
- [ ] `site.ts`: feed `stats[0]` count from `mods.json`.
|
||||
- [ ] Run the generator pre-`pnpm build`; document in `12-build-order.md` /
|
||||
`14-deploy.md`. `.gitignore`: `landing/src/data/mods.json`,
|
||||
`landing/src/data/launcher.json`, `landing/src/data/players.json` (if
|
||||
any baked), `landing/public/mod-logos/`.
|
||||
|
||||
---
|
||||
|
||||
## Verification (whole rework)
|
||||
|
||||
- **Build:** `cd landing && set -a && . ../.env && set +a && pnpm run build` then
|
||||
`pnpm check`. Must pass offline even if `launcher.json` / `mods.json` are
|
||||
absent (buttons/list hidden).
|
||||
- `grep -ri packwiz landing/` → no live join references remain (Fjord page may mention
|
||||
its own import).
|
||||
- **Local preview** (`pnpm dev`): all three locales — 3-step join, online roster, members
|
||||
grid, account page, footer status link, mod grid, `/fjord` page.
|
||||
- **Rosters (running stack):** online tiles appear with players on; `/api/mcstatus/players`
|
||||
returns `[{uuid,name}]`; the members grid renders avatars.
|
||||
- **Account (running stack):** login → upload PNG → instant local preview → in-game/NMSR
|
||||
reflects after ~15m → reset reverts to default.
|
||||
- Deploy via the `deploy` skill; the manifest sync, `build-modlist.py`, and landing
|
||||
rebuild are **host** steps (not compose) — fold into `14-deploy.md`.
|
||||
|
||||
## Related
|
||||
|
||||
- `09-landing.md` — landing stack, i18n, theme.
|
||||
- `15-mc-status.md` — the mc-status service (now also serves the roster).
|
||||
- `16-landing-registration.md` — the browser-direct Drasl pattern WS4 reuses.
|
||||
- `17-mod-shortlist.md` — the mod gap doc + the `mods.json` dataset.
|
||||
- `launcher.json` — the `custom-launcher` build output (download list); landing
|
||||
syncs it to `landing/src/data/launcher.json`, `launcher.example.json` committed.
|
||||
116
plan/19-routes.md
Normal file
116
plan/19-routes.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# 19 — HTTP route reference (nmsr, landing, auth)
|
||||
|
||||
> Quick map of the public + internal HTTP routes. caddy (`caddy/conf.d/*.caddy`)
|
||||
> is the internal router; the host nginx terminates TLS and forwards each vhost
|
||||
> to it by Host header. All `${BASE_DOMAIN}` = `ulicraft.net` in prod.
|
||||
|
||||
## Vhost → service (caddy)
|
||||
|
||||
| Vhost | caddy file | Upstream |
|
||||
|-------|-----------|----------|
|
||||
| `${BASE_DOMAIN}` (apex) | `10-static.caddy` | static `/srv/www` + `/srv/launcher` + mc-status |
|
||||
| `auth.${BASE_DOMAIN}` | `00-core.caddy` | `drasl:25585` |
|
||||
| `avatar.${BASE_DOMAIN}` | `40-avatar.caddy` | `nmsr:8080` |
|
||||
| `status.${BASE_DOMAIN}` | `50-status.caddy` | `uptime-kuma:3001` |
|
||||
| `files.${BASE_DOMAIN}` | `60-files.caddy` | `filestash:8334` |
|
||||
| `distribution.${BASE_DOMAIN}` | `30-distribution.caddy` | static `/srv/distribution` (other repo) |
|
||||
|
||||
---
|
||||
|
||||
## NMSR — `avatar.${BASE_DOMAIN}` (skin/avatar renderer)
|
||||
|
||||
`reverse_proxy nmsr:8080`. Stateless renderer; pulls profiles+skins from Drasl
|
||||
over mcnet (no public round-trip).
|
||||
|
||||
- `GET /{mode}/{uuid}?size=N` — render an avatar PNG.
|
||||
- `mode` = any NMSR render mode (`headiso` is the configured default via
|
||||
`AVATAR_MODE`; others incl. `head`, `face`, `fullbody`, `fullbodyiso`).
|
||||
- `uuid` = Drasl player UUID; `size` = px.
|
||||
- Landing usage: `${avatarUrl}/${avatarMode}/<uuid>?size=64|96`.
|
||||
- **No per-request refresh.** A `?skin=<url>` override is **ignored** by this
|
||||
build (tested). Skin changes propagate via the resolve cache only.
|
||||
- Caching (`nmsr/config.toml`): `resolve_cache_duration = 60s` (uuid→profile,
|
||||
bounds stale-avatar window), `texture_cache_duration = 48h` (safe — Drasl skin
|
||||
URLs are content-hashed, new skin = new URL). Instant global flush = restart
|
||||
nmsr. See `plan/03-drasl.md` for the Drasl-backed mojank wiring.
|
||||
|
||||
---
|
||||
|
||||
## Landing — `${BASE_DOMAIN}` (apex)
|
||||
|
||||
`10-static.caddy`. Three handlers:
|
||||
|
||||
- `handle_path /launcher/*` → `/srv/launcher` (file browse) — launcher downloads.
|
||||
- `handle /api/mcstatus/*` → `uri strip_prefix /api/mcstatus` → `mc-status:8080`:
|
||||
- `/api/mcstatus/v2/status/java/<addr>` → internal `/v2/status/java/` — live
|
||||
server status JSON (mcstatus.io-compatible, `CORS: *`, `<addr>` ignored — it
|
||||
only pings `MC_STATUS_TARGET`). Landing `statusApi`.
|
||||
- `/api/mcstatus/players` → internal `/players` — registered-players roster,
|
||||
sanitized `[{uuid,name}]`, ~60s cache (mc-status admin-logs-in to Drasl
|
||||
`GET /players`). Landing `rosterApi`.
|
||||
- `/healthz` — mc-status liveness (internal).
|
||||
- `handle {}` → `/srv/www` static (Astro output). Pages: `/`, `/es`, `/eu`
|
||||
(index), `/{lang}/register`, `/{lang}/account`, `/{lang}/fjord`.
|
||||
|
||||
Browser → Drasl **directly** (CORS-scoped to apex via Drasl `CORSAllowOrigins`),
|
||||
base `https://auth.${BASE_DOMAIN}/drasl/api/v2` (`site.draslApiUrl`):
|
||||
- `register.astro`: `POST /users` (register), `POST /login`, `PATCH /players/{uuid}` (set initial skin).
|
||||
- `account.astro`: `POST /login`, `PATCH /players/{uuid}` with `{skinBase64,skinModel}` (upload) or `{deleteSkin:true}` (reset).
|
||||
|
||||
---
|
||||
|
||||
## Files — `files.${BASE_DOMAIN}` (Filestash player share)
|
||||
|
||||
`reverse_proxy filestash:8334`. Screenshots / schematics / maps. **One shared
|
||||
login** for every player (`FILES_AUTH=htpasswd`), writable. Full design:
|
||||
`plan/20-files.md`.
|
||||
|
||||
- `/` — SPA. The login form renders **only** at
|
||||
`GET /api/session/auth/?action=redirect&label=files`. Without `action=redirect`
|
||||
the request falls through to the credential check, fails with empty creds and
|
||||
303s back to `?action=redirect` — so a bare `/api/session/auth/?label=files`
|
||||
returning a 303 is **normal, not a bug**. `POST` with `user`/`password`
|
||||
authenticates.
|
||||
- `GET /api/config` → `result.origin` must be `https://files.${BASE_DOMAIN}`.
|
||||
It's built as `(force_ssl ? https:// : http://) + general.host`, so a scheme in
|
||||
`general.host` yields `http://https://…` and silently breaks every browser
|
||||
while the server keeps answering 200. Check this after any config change.
|
||||
- `/api/*` — JSON API. Requires the header `X-Requested-With: XmlHttpRequest`
|
||||
(the SPA sends it); without it Filestash's intrusion detection 403s the
|
||||
request. Relevant when curling it by hand.
|
||||
- `/admin` — admin console, own bcrypt password (`auth.admin`). Deliberately
|
||||
public; nginx throttles it (`limit_req`, 6r/m). It **cannot save** — the
|
||||
config is a read-only mount.
|
||||
- **`?label=files` matters.** The attribute mapping is keyed by the connection
|
||||
label; auth without it yields `backend error - Not Allowed`.
|
||||
|
||||
Filestash blocks any request whose `Host` doesn't match its `general.host`
|
||||
(`https://files.${BASE_DOMAIN}`) — nginx forwards the original Host and caddy
|
||||
passes it through, so don't rewrite it anywhere in the chain.
|
||||
|
||||
---
|
||||
|
||||
## Auth / Drasl — `auth.${BASE_DOMAIN}`
|
||||
|
||||
`reverse_proxy drasl:25585`. One service exposes three surfaces:
|
||||
|
||||
- **Web UI:** `/` (login / register / profile pages), `/web/texture/skin/<sha256>.png`, `/web/texture/cape/<sha256>.png`.
|
||||
- **Yggdrasil / authlib-injector:** `/authlib-injector` (API root — server JVM
|
||||
agent + clients point here), `/session/minecraft/...`, mojang-compat
|
||||
`/users/profiles/minecraft/{name}` and `/session/minecraft/profile/{uuid}`
|
||||
(also what NMSR calls internally).
|
||||
- **Drasl REST API v2** (`/drasl/api/v2`):
|
||||
- `POST /login` `{username,password}` → `{apiToken}`
|
||||
- `POST /users` `{username,password[,inviteCode]}` — register (invite-gated when `RequireInvite=true`; admins from `DefaultAdmins` are promoted at startup, see `plan/03-drasl.md`)
|
||||
- `GET /players` — **admin only**, full roster
|
||||
- `PATCH /players/{uuid}` — skin CRUD (own player, or admin)
|
||||
- `POST /invites` — admin, mint invite codes
|
||||
|
||||
---
|
||||
|
||||
## Internal (service → service, over mcnet)
|
||||
|
||||
- `minecraft` → `-javaagent authlib-injector=http://auth.${BASE_DOMAIN}/authlib-injector` (caddy mcnet alias → drasl).
|
||||
- `nmsr` → `http://auth.${BASE_DOMAIN}` (mojang-compat session/textures/api).
|
||||
- `mc-status` → `http://drasl:25585/drasl/api/v2` (`DRASL_API_URL`) for the roster; pings `MC_STATUS_TARGET` for live status.
|
||||
- `mc-backup` → RCON `minecraft:25575`.
|
||||
203
plan/20-files.md
Normal file
203
plan/20-files.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# 20 — Filestash (`files.${BASE_DOMAIN}`)
|
||||
|
||||
> Web file share for player media (screenshots, schematics, maps, guides).
|
||||
> One [Filestash](https://github.com/mickael-kerjean/filestash) container behind
|
||||
> caddy, **one shared username/password for everybody**, **writable**.
|
||||
> Config is rendered read-only from a template, like drasl/nmsr.
|
||||
>
|
||||
> **Not** a backup browser, **not** a mod mirror. See "What goes in it".
|
||||
>
|
||||
> Everything below was verified against the real image (digest pinned in
|
||||
> compose) and against the upstream source, not inferred from the docs — the
|
||||
> docs omit most of it.
|
||||
|
||||
## Shape
|
||||
|
||||
```
|
||||
Internet ── host nginx (TLS, LE cert for files.) ──► caddy ──► filestash:8334
|
||||
│
|
||||
${FILES_DATA_DIR} ──┘ (bind mount)
|
||||
```
|
||||
|
||||
| Thing | Value |
|
||||
|---|---|
|
||||
| Vhost | `files.${BASE_DOMAIN}` |
|
||||
| Image | `machines/filestash` **pinned by digest** (upstream ships only `latest`) |
|
||||
| Container port | `8334`, runs as **uid/gid 1000** (`filestash`) |
|
||||
| Auth | htpasswd, **one shared account**, `FILES_USER` / `FILES_PASSWORD_HASH` |
|
||||
| Access | read **+ write** for anyone with the password |
|
||||
| Data | `${FILES_DATA_DIR}` on the host, **outside the repo** |
|
||||
| State | named volume `filestash_state` → `/app/data/state` |
|
||||
| Config | `filestash/config.json` rendered from `.tmpl`, mounted `:ro` |
|
||||
| Backups | **none** — outside mc-backup's scope. Treat as disposable. |
|
||||
| Quota | none — eyeballed |
|
||||
|
||||
## Decisions (settled — don't relitigate)
|
||||
|
||||
**Auth is htpasswd with one shared credential, not SSO.** Filestash's OIDC and
|
||||
SAML middlewares are **enterprise-only**; the community build compiles in
|
||||
`htpasswd`, `ldap`, `passthrough`, `wordpress`, `local` only (verified in
|
||||
`server/plugin/index.go`). Keycloak was considered and dropped — it would need an
|
||||
oauth2-proxy sidecar in front + `passthrough`. That path stays open but is not
|
||||
built.
|
||||
|
||||
**One instance, everything behind the password.** No anonymous read tier.
|
||||
Filestash's auth middleware is **global to the instance**, so "anonymous read +
|
||||
authenticated write" is impossible in one container (that's the enterprise
|
||||
RBAC). A two-instance split was designed and rejected as not worth it.
|
||||
|
||||
**`FILES_AUTH=none` is gated on the host being internal-only.** The off-switch
|
||||
exists for when this server moves behind a LAN/VPN. **Do not set it while
|
||||
`files.` is internet-reachable** — auth-off on a *writable* public share is an
|
||||
open upload relay: someone parks malware or a phishing kit on your domain, under
|
||||
your LE cert, and the abuse mail, the bandwidth, and a possible domain
|
||||
blocklisting (which would take `auth.` and the apex down with it, breaking logins
|
||||
and the launcher for every player) all land on you. `render-config.sh` prints a
|
||||
loud warning when it renders this mode.
|
||||
|
||||
**Shared credential ⇒ no audit trail, no per-person revocation.** You will never
|
||||
know who deleted a folder; rotating means re-telling everyone. Fine for a
|
||||
friends' media share — but nothing here is backed up, so treat the share as
|
||||
disposable and keep anything you'd miss elsewhere.
|
||||
|
||||
## What goes in it
|
||||
|
||||
| Content | Verdict |
|
||||
|---|---|
|
||||
| Screenshots, schematics, maps, guides | ✅ the point |
|
||||
| World downloads / `./backups/*.tgz` | ❌ contains `playerdata/`, `stats/`, the full map — every player's inventory and coords, handed to anyone with the shared password |
|
||||
| Mods / jars | ❌ redistribution; `distribution.${BASE_DOMAIN}` already serves the modset to launchers |
|
||||
|
||||
## `.env`
|
||||
|
||||
See `.env.example` for the annotated block. Summary:
|
||||
|
||||
| Var | What |
|
||||
|---|---|
|
||||
| `FILES_DATA_DIR` | host path of the share, outside the repo |
|
||||
| `FILES_MOUNT_MODE` | literal mount flag, `rw` \| `ro` |
|
||||
| `FILES_AUTH` | `htpasswd` \| `passthrough` \| `none` |
|
||||
| `FILES_USER` | shared login name |
|
||||
| `FILES_PASSWORD_HASH` | **`$6$`**, from `openssl passwd -6 '<pw>'` |
|
||||
| `FILES_ADMIN_PASSWORD_HASH` | bcrypt, from `docker run --rm caddy:alpine caddy hash-password --plaintext '<pw>'` |
|
||||
| `FILES_SECRET_KEY` | `openssl rand -hex 16`, must be non-empty |
|
||||
| `FILES_LOCAL_SECRET` | `openssl rand -hex 24`, unlocks the local backend |
|
||||
| `LE_SUBDOMAINS` | must include `files` |
|
||||
|
||||
`filestash/config.json` is **rendered and gitignored** — it holds every hash and
|
||||
both secrets. Only the `.tmpl` is tracked.
|
||||
|
||||
## Deploy
|
||||
|
||||
```sh
|
||||
tooling/render-config.sh # -> filestash/config.json (validates the JSON)
|
||||
tooling/issue-letsencrypt.sh # picks up `files` from LE_SUBDOMAINS
|
||||
tooling/render-nginx.sh --install # adds the files. vhost + reloads nginx
|
||||
mkdir -p "$FILES_DATA_DIR" # uid 1000 must be able to write it
|
||||
docker compose up -d filestash caddy
|
||||
```
|
||||
|
||||
Then add an Uptime Kuma HTTP monitor for `https://files.${BASE_DOMAIN}` (expect
|
||||
`200` — the login page is a 200) and put it on the status page with the others.
|
||||
|
||||
## Gotchas (all of these were hit for real)
|
||||
|
||||
### The `local` backend is admin-gated — this is the one that wastes an evening
|
||||
`plg_backend_local.Init()` **refuses to start** unless the connection params
|
||||
carry `$LOCAL_BACKEND_SECRET` or the bcrypt admin password. Miss it and every
|
||||
login dies at `backend error - Not Allowed`, which reads like a credentials
|
||||
problem and isn't. `config.json`'s `attribute_mapping` injects the secret
|
||||
server-side (Filestash calls this the "facade pattern"), so players never see it.
|
||||
`FILES_LOCAL_SECRET` must be **identical** in the compose env and the rendered
|
||||
config — `render-config.sh` renders both from the same var.
|
||||
|
||||
### `$2y$` bcrypt silently fails every login
|
||||
The htpasswd plugin's bcrypt branch matches on the literal prefix **`$2a$`**.
|
||||
`htpasswd -B` emits `$2y$`, which falls through to `return false` — so every
|
||||
login fails with correct credentials and no useful log line. Use
|
||||
**`openssl passwd -6`** (`$6$`, sha512) for `FILES_PASSWORD_HASH`.
|
||||
`render-config.sh` hard-fails on a `$2y$`/`$2b$` hash rather than shipping a
|
||||
config nobody can log into. (The *admin* password is checked by Go's bcrypt
|
||||
directly, which does accept `$2y$` — but `caddy hash-password` gives `$2a$`
|
||||
anyway, so just use that for both.)
|
||||
|
||||
### `general.host` is a BARE HOSTNAME — a scheme breaks the browser only
|
||||
`"host": "files.ulicraft.net"`, **not** `"https://files.ulicraft.net"`, plus
|
||||
`"force_ssl": true`. The SPA computes its redirect origin as
|
||||
`(force_ssl ? "https://" : "http://") + host`, so a scheme in `host` yields
|
||||
**`http://https://files.ulicraft.net`** and every client-side redirect dies —
|
||||
the page loads, then silently fails to route anywhere.
|
||||
|
||||
The vicious part: the server-side Host check (`SecureOrigin`) **strips** the
|
||||
scheme before comparing, so the server is perfectly happy. `curl /` returns 200,
|
||||
the API answers, and the Uptime Kuma monitor stays green — **only real browsers
|
||||
break**. Symptom in the log is a `POST /report?...msg=Redirecting to
|
||||
http://https://...` line. Hit this on 2026-07-14; fixed in `config.json.tmpl`.
|
||||
|
||||
`force_ssl` only sets an HSTS header — it does **not** redirect, so it can't loop
|
||||
behind the nginx→caddy (plain http) hop. It must be `true` or the SPA builds
|
||||
`http://` origins on an https page.
|
||||
|
||||
### `general.host` must also match the incoming `Host` header
|
||||
Filestash blocks every non-`/admin/` request whose `Host` differs from
|
||||
`general.host`, with *"only traffic from X is allowed"* — a 403 that looks like
|
||||
an auth failure. nginx forwards the original Host and caddy passes it through —
|
||||
don't rewrite it anywhere.
|
||||
|
||||
### The login form only renders at `?action=redirect`
|
||||
`GET /api/session/auth/` renders the htpasswd form **only** when
|
||||
`action=redirect` is present; any other request falls through to the credential
|
||||
check, fails with empty creds, and 303s back to `?action=redirect`. So the real
|
||||
form URL is `/api/session/auth/?action=redirect&label=files`. A bare
|
||||
`/api/session/auth/?label=files` returning a 303 is **normal**, not a bug.
|
||||
|
||||
### `config.json` is a single-file bind mount → stale inode
|
||||
Same shape as the caddy `conf.d` gotcha. The mount pins the **inode**, so
|
||||
re-rendering (or a `git pull`) writes a *new* file while the container keeps
|
||||
reading the *old* one.
|
||||
|
||||
**After re-rendering: `docker compose up -d --force-recreate filestash`.**
|
||||
`restart` is not enough. Rotating the shared password needs a recreate too.
|
||||
|
||||
### Don't set `APPLICATION_URL` or `ADMIN_PASSWORD` env
|
||||
Either one makes Filestash **rewrite `config.json` at boot**, which fails against
|
||||
the `:ro` mount. Both live in the rendered config instead (`general.host` /
|
||||
`auth.admin`). `general.secret_key` must likewise be non-empty, or Filestash
|
||||
generates one and tries to save that. With all three set in the file, Filestash
|
||||
never attempts a boot write and the `:ro` mount is clean — you get one harmless
|
||||
`cannot chmod config file` WARN in the log, and nothing else.
|
||||
|
||||
### The admin console cannot save
|
||||
`/admin` loads and its login works, but `config.json` is `:ro`, so every save
|
||||
silently fails. That's the deliberate trade for keeping git authoritative
|
||||
(drasl/nmsr never write their configs; Filestash does, which is exactly why it
|
||||
needs pinning). To change config: edit the `.tmpl`, re-render, recreate. To
|
||||
re-derive the schema after an upstream upgrade: run the image locally against a
|
||||
*writable* state volume, click it into shape, `docker cp` the result out.
|
||||
|
||||
### The API needs `X-Requested-With: XmlHttpRequest`
|
||||
The SPA sends it; anything else (curl, a naive monitor) trips intrusion
|
||||
detection and gets a 403 that looks like an auth bug. Only matters when probing
|
||||
by hand — the Kuma monitor hits `/`, which is fine.
|
||||
|
||||
### Disk is the shared failure domain
|
||||
`FILES_DATA_DIR` grows without a quota, driven by whoever has the password. A
|
||||
full disk on `cochi` does not just break `files.` — it takes **Minecraft, Drasl,
|
||||
and mc-backup** down with it. Eyeballing is the accepted plan. Revisit (quota,
|
||||
separate partition, or a Kuma disk monitor) if the share sees real use.
|
||||
|
||||
### Pinned digest, not `latest`
|
||||
`machines/filestash` publishes essentially only `latest`. Unpinned, any
|
||||
`docker compose pull` silently ships a new upstream build of an internet-facing,
|
||||
writable file manager. Pin the digest; upgrade deliberately — same discipline as
|
||||
`NEOFORGE_VERSION`.
|
||||
|
||||
## Deferred
|
||||
|
||||
- **Keycloak SSO** — needs oauth2-proxy + `FILES_AUTH=passthrough`. Not built.
|
||||
- **Anonymous access** (`FILES_AUTH=none`) — only once the host is internal-only.
|
||||
- **Landing page link** — deliberately none on day one; `plan/18-landing-rework.md`
|
||||
is mid-rework, so revisit with WS5 (footer) rather than merging into a moving
|
||||
design.
|
||||
- **Per-user accounts / roles** — htpasswd RBAC is enterprise-only.
|
||||
- **Backups / quota** — none.
|
||||
@@ -4,8 +4,10 @@ Runtime drop-ins mounted read-only into the Minecraft container at `/extras`.
|
||||
|
||||
## Required files
|
||||
|
||||
- `authlib-injector.jar` — download latest from
|
||||
https://github.com/yushijinhun/authlib-injector/releases
|
||||
- `authlib-injector.jar` — fetch with `tooling/fetch-authlib.sh` (downloads the
|
||||
latest from https://github.com/yushijinhun/authlib-injector/releases and
|
||||
verifies it's a valid jar). Gitignored.
|
||||
|
||||
Mods are no longer listed here; the pack is served via packwiz
|
||||
(`PACKWIZ_URL`, see plan/04-packwiz.md).
|
||||
Mods are not listed here; the server loads a filtered subset from the
|
||||
`./server-mods` volume (synced from the distribution repo by
|
||||
`tooling/sync-server-mods.sh` — see plan/04-mods.md).
|
||||
|
||||
BIN
runtime/server-icon.png
Normal file
BIN
runtime/server-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
56
server-configs/voicechat-server.properties
Normal file
56
server-configs/voicechat-server.properties
Normal file
@@ -0,0 +1,56 @@
|
||||
# Simple Voice Chat server config v1.21.1-2.6.18
|
||||
|
||||
# The port number to use for the voice chat communication.
|
||||
# Audio packets are always transmitted via the UDP protocol on the port number
|
||||
# specified here, independently of other networking used for the game server.
|
||||
# Set this to '-1' to use the same port number that is used by the Minecraft server.
|
||||
# However, it is strongly recommended NOT to use the same port number because UDP on
|
||||
# it is also used by default for the server query. Doing so may crash the server!
|
||||
port=24454
|
||||
# The server IP address to bind the voice chat to
|
||||
# Leave blank to use the 'server-ip' property from the 'server.properties' config file
|
||||
# To bind to the wildcard IP address, use '*'
|
||||
bind_address=
|
||||
# The distance to which the voice can be heard
|
||||
max_voice_distance=48.0
|
||||
# The distance to which the voice can be heard when whispering
|
||||
whisper_distance=24.0
|
||||
# The Opus codec
|
||||
# Valid values are 'VOIP', 'AUDIO', and 'RESTRICTED_LOWDELAY'
|
||||
codec=VOIP
|
||||
# The maximum size that audio packets are allowed to have (in bytes)
|
||||
# Setting this to lower values might cause issues
|
||||
mtu_size=1275
|
||||
# The maximum number of packets a player can send per second
|
||||
# Set this to -1 to disable the rate limit - This must be greater than 0 in all other cases
|
||||
# This only applies to voice chat packets that are sent through Minecrafts networking
|
||||
# This affects actions like opening/joining/leaving voice chat groups or general state changes like disabling/enabling the voice chat
|
||||
tcp_rate_limit=16
|
||||
# The frequency at which keep-alive packets are sent (in milliseconds)
|
||||
# Setting this to a higher value may result in timeouts
|
||||
keep_alive=1000
|
||||
# If group chats are allowed
|
||||
enable_groups=true
|
||||
# The hostname that clients should use to connect to the voice chat
|
||||
# This may also include a port, e.g. 'example.com:24454' or just a port, e.g. '24454'
|
||||
# Do NOT change this value if you don't know what you're doing
|
||||
voice_host=ulicraft.net
|
||||
# If players are allowed to record the voice chat audio
|
||||
allow_recording=true
|
||||
# If spectators are allowed to talk to other players
|
||||
spectator_interaction=false
|
||||
# If spectators can talk to players they are spectating
|
||||
spectator_player_possession=false
|
||||
# If players without the voice chat mod should be kicked from the server
|
||||
force_voice_chat=false
|
||||
# The amount of time the server should wait to check if a player has the mod installed (in milliseconds)
|
||||
# Only relevant when 'force_voice_chat' is set to 'true'
|
||||
login_timeout=10000
|
||||
# The range in which the voice chat should broadcast audio
|
||||
# A value less than 0 means 'max_voice_distance'
|
||||
broadcast_range=-1.0
|
||||
# If the voice chat server should reply to external pings
|
||||
allow_pings=true
|
||||
# If the mod should load native libraries on dedicated servers
|
||||
# This is mostly relevant for voice chat addons
|
||||
use_natives=true
|
||||
@@ -1,132 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# add-custom-mod.sh — add LOCAL jar(s) to the packwiz pack as "external files".
|
||||
#
|
||||
# Implements the "Other external files" flow from
|
||||
# https://packwiz.infra.link/tutorials/creating/adding-mods/ : a <slug>.pw.toml
|
||||
# that points at a stable download URL + sha256, then refresh. Here the metadata
|
||||
# is written as a TEMPLATE (<slug>.pw.toml.tmpl) with a ${BASE_DOMAIN}
|
||||
# placeholder and rendered by render-config.sh, so the domain is never frozen.
|
||||
#
|
||||
# Since the jars are local (no public URL), we HOST them on the caddy pack
|
||||
# server: each jar is copied to the top-level ./custom/ dir (OUTSIDE ./pack, so
|
||||
# packwiz refresh won't index it as a direct file). Caddy bind-mounts ./custom
|
||||
# at /srv/pack/custom, so it is served at
|
||||
# http://pack.${BASE_DOMAIN}/custom/<filename>
|
||||
# which is exactly what minecraft fetches via PACKWIZ_URL on install.
|
||||
#
|
||||
# Usage:
|
||||
# tooling/add-custom-mod.sh <mod.jar> [--name "Nice Name"] [--side S] [--force]
|
||||
# tooling/add-custom-mod.sh <dir/> [--side S] [--force] # all *.jar in dir
|
||||
# # (--name not allowed)
|
||||
# S = both|client|server (default both)
|
||||
#
|
||||
# Single-jar name defaults to the filename without .jar; folder mode derives
|
||||
# each name from its filename. packwiz refresh runs ONCE at the end.
|
||||
# Cautious: halts on missing tool/var, bad side, whitespace in a filename, an
|
||||
# empty folder, or an existing metadata/custom jar (unless --force).
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.." # repo root
|
||||
|
||||
# ---- helpers ---------------------------------------------------------------
|
||||
log() { printf '\n\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
slugify() { # lowercase, non-alnum -> hyphen, squeeze + trim hyphens
|
||||
printf '%s' "$1" | tr '[:upper:]' '[:lower:]' \
|
||||
| sed -E 's/[^a-z0-9]+/-/g; s/-+/-/g; s/^-//; s/-$//'
|
||||
}
|
||||
|
||||
# ---- args ------------------------------------------------------------------
|
||||
TARGET=""; NAME=""; SIDE="both"; FORCE=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--name) NAME="${2:?--name needs a value}"; shift 2 ;;
|
||||
--side) SIDE="${2:?--side needs a value}"; shift 2 ;;
|
||||
--force) FORCE=1; shift ;;
|
||||
-h|--help) sed -n '2,24p' "$0"; exit 0 ;;
|
||||
-*) die "unknown flag: $1" ;;
|
||||
*) [ -z "$TARGET" ] || die "only one jar or dir at a time (got extra: $1)"; TARGET="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$TARGET" ] || die "no jar/dir given. usage: tooling/add-custom-mod.sh <mod.jar|dir/> [--side both|client|server]"
|
||||
[ -e "$TARGET" ] || die "path not found: $TARGET"
|
||||
case "$SIDE" in both|client|server) ;; *) die "invalid --side '$SIDE' (both|client|server)" ;; esac
|
||||
|
||||
# ---- preflight -------------------------------------------------------------
|
||||
command -v packwiz >/dev/null 2>&1 || die "missing required tool: packwiz (https://packwiz.infra.link)"
|
||||
command -v sha256sum >/dev/null 2>&1 || die "missing required tool: sha256sum"
|
||||
command -v envsubst >/dev/null 2>&1 || die "missing required tool: envsubst (gettext)"
|
||||
[ -f .env ] || die ".env not found (copy .env.example and fill it)"
|
||||
# shellcheck disable=SC1091
|
||||
set -a; . ./.env; set +a
|
||||
: "${BASE_DOMAIN:?BASE_DOMAIN unset in .env}"
|
||||
[ -f pack/pack.toml ] || die "pack/pack.toml not found — run from a packwiz pack (see plan/04-packwiz.md)"
|
||||
|
||||
# ---- collect jars ----------------------------------------------------------
|
||||
declare -a JARS=()
|
||||
if [ -d "$TARGET" ]; then
|
||||
[ -z "$NAME" ] || die "--name not allowed with a folder (names are derived per-jar)"
|
||||
shopt -s nullglob
|
||||
for f in "$TARGET"/*.jar; do JARS+=("$f"); done
|
||||
shopt -u nullglob
|
||||
[ "${#JARS[@]}" -gt 0 ] || die "no .jar files in folder: $TARGET"
|
||||
else
|
||||
JARS=("$TARGET")
|
||||
fi
|
||||
|
||||
mkdir -p custom pack/mods
|
||||
|
||||
# ---- per-jar processing (no refresh; refresh once at the end) --------------
|
||||
add_one() { # $1 = jar path. Uses NAME (single-jar only), SIDE, FORCE, BASE_DOMAIN.
|
||||
local jar="$1" filename name slug meta dest url hash
|
||||
[ -f "$jar" ] || die "not a file: $jar"
|
||||
filename="$(basename -- "$jar")"
|
||||
case "$filename" in *.jar) ;; *) die "not a .jar: $filename" ;; esac
|
||||
case "$filename" in *[[:space:]]*) die "filename has whitespace: '$filename' — rename it (URL-unsafe)" ;; esac
|
||||
|
||||
name="$NAME"; [ -n "$name" ] || name="${filename%.jar}"
|
||||
slug="$(slugify "$name")"
|
||||
[ -n "$slug" ] || die "could not derive a slug from name '$name'"
|
||||
|
||||
# Metadata is a TEMPLATE: the download URL keeps a literal ${BASE_DOMAIN}
|
||||
# placeholder, rendered to the real .pw.toml by render-config.sh at deploy so
|
||||
# the domain is never frozen into a committed file.
|
||||
meta="pack/mods/${slug}.pw.toml.tmpl"
|
||||
dest="custom/${filename}"
|
||||
url_tmpl='http://pack.${BASE_DOMAIN}/custom/'"${filename}"
|
||||
|
||||
if [ "$FORCE" -ne 1 ]; then
|
||||
[ -e "$meta" ] && die "metadata exists: $meta (use --force to overwrite)"
|
||||
[ -e "$dest" ] && die "hosted jar exists: $dest (use --force to overwrite)"
|
||||
fi
|
||||
|
||||
hash="$(sha256sum -- "$jar" | cut -d' ' -f1)"
|
||||
# Skip the copy when the source already IS the hosted file (regen-in-place,
|
||||
# e.g. `add-custom-mod.sh custom --force` to rewrite metadata).
|
||||
[ "$dest" -ef "$jar" ] || cp -- "$jar" "$dest"
|
||||
cat > "$meta" <<EOF
|
||||
name = "${name}"
|
||||
filename = "${filename}"
|
||||
side = "${SIDE}"
|
||||
|
||||
[download]
|
||||
url = "${url_tmpl}"
|
||||
hash-format = "sha256"
|
||||
hash = "${hash}"
|
||||
EOF
|
||||
echo " + ${filename} -> http://pack.${BASE_DOMAIN}/custom/${filename} (${SIDE}, sha256 ${hash:0:12}…)"
|
||||
}
|
||||
|
||||
log "adding ${#JARS[@]} jar(s), side=${SIDE}"
|
||||
for j in "${JARS[@]}"; do add_one "$j"; done
|
||||
|
||||
# ---- render + refresh once -------------------------------------------------
|
||||
# render-pack.sh renders all pack templates (BASE_DOMAIN only) + rebuilds the
|
||||
# packwiz index. Single source of pack-render logic, shared with render-config.
|
||||
log "rendering metadata + refreshing index"
|
||||
tooling/render-pack.sh
|
||||
log "done — ${#JARS[@]} mod(s) added to the pack"
|
||||
echo " commit: custom/*.jar + pack/mods/*.pw.toml.tmpl (rendered .pw.toml/index are gitignored)"
|
||||
253
tooling/build-modlist.py
Executable file
253
tooling/build-modlist.py
Executable file
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
# build-modlist.py — generate landing/src/data/mods.json (+ extracted logos)
|
||||
# from the distribution's full client modset (offline, tomllib + zipfile only).
|
||||
#
|
||||
# Mirrors classify-mods.py: enumerates the distribution's forgemods/required
|
||||
# jars, reads each jar's META-INF/neoforge.mods.toml, takes the PRIMARY [[mods]]
|
||||
# entry, and emits a flat, alphabetically-sorted catalogue for the landing
|
||||
# mod-list component (no categories, no external links). Each mod's logoFile PNG
|
||||
# (if declared and present in the jar) is extracted to
|
||||
# landing/public/mod-logos/<modid>.png. No network calls. Pure stdlib (Python
|
||||
# 3.11+ for tomllib).
|
||||
#
|
||||
# Source dir resolution (first match wins) — identical to classify-mods.py:
|
||||
# 1. CLI arg: build-modlist.py <forgemods-required-dir-or-dist-root>
|
||||
# 2. env MODS_DIST_DIR
|
||||
# 3. $DISTRIBUTION_WEB_ROOT from .env / environment
|
||||
# The path may point at the dist ROOT or directly at the
|
||||
# …/servers/ulicraft-1.21.1/forgemods/required dir — both are accepted.
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ModuleNotFoundError:
|
||||
sys.exit("build-modlist.py requires Python 3.11+ (tomllib is stdlib)")
|
||||
|
||||
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
LANDING = os.path.join(REPO_ROOT, "landing")
|
||||
MODS_JSON = os.path.join(LANDING, "src", "data", "mods.json")
|
||||
LOGOS_DIR = os.path.join(LANDING, "public", "mod-logos")
|
||||
REQUIRED_SUBPATH = os.path.join("servers", "ulicraft-1.21.1", "forgemods", "required")
|
||||
MANIFEST = "META-INF/neoforge.mods.toml"
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
def read_env_dist_root():
|
||||
"""Read DISTRIBUTION_WEB_ROOT from environment, else from repo .env."""
|
||||
val = os.environ.get("DISTRIBUTION_WEB_ROOT")
|
||||
if val:
|
||||
return val
|
||||
env_path = os.path.join(REPO_ROOT, ".env")
|
||||
if os.path.isfile(env_path):
|
||||
with open(env_path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if line.startswith("DISTRIBUTION_WEB_ROOT="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return None
|
||||
|
||||
|
||||
def resolve_required_dir():
|
||||
"""Resolve the forgemods/required dir from override or .env. Halt if missing."""
|
||||
src = None
|
||||
origin = None
|
||||
if len(sys.argv) > 1 and sys.argv[1]:
|
||||
src, origin = sys.argv[1], "CLI arg"
|
||||
elif os.environ.get("MODS_DIST_DIR"):
|
||||
src, origin = os.environ["MODS_DIST_DIR"], "MODS_DIST_DIR env"
|
||||
else:
|
||||
src = read_env_dist_root()
|
||||
origin = "$DISTRIBUTION_WEB_ROOT"
|
||||
if not src:
|
||||
sys.exit(
|
||||
"no source dir: pass a path, set MODS_DIST_DIR, or DISTRIBUTION_WEB_ROOT in .env"
|
||||
)
|
||||
candidates = [os.path.join(src, REQUIRED_SUBPATH), src]
|
||||
for cand in candidates:
|
||||
if os.path.isdir(cand) and os.path.basename(os.path.normpath(cand)) == "required":
|
||||
return cand, origin
|
||||
for cand in candidates:
|
||||
if os.path.isdir(cand):
|
||||
return cand, origin
|
||||
sys.exit(
|
||||
f"forgemods/required dir not found (from {origin}={src!r}). "
|
||||
f"Tried: {', '.join(candidates)}"
|
||||
)
|
||||
|
||||
|
||||
def stringify_authors(value):
|
||||
"""authors may be a string or a list; normalise to a comma-joined string."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
if isinstance(value, list):
|
||||
parts = [str(v).strip() for v in value if str(v).strip()]
|
||||
return ", ".join(parts)
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def resolve_version(version, manifest):
|
||||
"""Resolve a trivial ${file.jarVersion} placeholder; else leave as-is.
|
||||
|
||||
NeoForge substitutes ${file.jarVersion} from the jar's MANIFEST.MF at load
|
||||
time. We don't have that here, so only resolve when there's nothing better;
|
||||
leave other ${...} placeholders untouched rather than guess.
|
||||
"""
|
||||
if not isinstance(version, str):
|
||||
return str(version) if version is not None else ""
|
||||
v = version.strip()
|
||||
if v in ("${file.jarVersion}", "${global.mcVersion}"):
|
||||
# Can't resolve from the toml alone — surface empty, caller falls back.
|
||||
return ""
|
||||
return v
|
||||
|
||||
|
||||
def primary_mod_entry(manifest):
|
||||
"""Return the first [[mods]] table, or None."""
|
||||
mods = manifest.get("mods")
|
||||
if isinstance(mods, list) and mods and isinstance(mods[0], dict):
|
||||
return mods[0]
|
||||
return None
|
||||
|
||||
|
||||
def extract_logo(zf, manifest, mod, modid):
|
||||
"""Extract the logoFile PNG to LOGOS_DIR/<modid>.png. Return public path or None.
|
||||
|
||||
logoFile may be declared per-[[mods]] or as a top-level key. If absent, or
|
||||
the named entry isn't in the jar, return None (logo: null).
|
||||
"""
|
||||
logo_file = None
|
||||
if isinstance(mod, dict) and mod.get("logoFile"):
|
||||
logo_file = mod["logoFile"]
|
||||
elif manifest.get("logoFile"):
|
||||
logo_file = manifest["logoFile"]
|
||||
if not logo_file or not isinstance(logo_file, str):
|
||||
return None
|
||||
logo_file = logo_file.strip().lstrip("/")
|
||||
if not logo_file:
|
||||
return None
|
||||
# Logos in NeoForge jars live at the resource root (e.g. "icon.png" or
|
||||
# "assets/<id>/icon.png"). Try the declared path, then a couple of common
|
||||
# fallbacks, all by exact zip member name.
|
||||
candidates = [logo_file]
|
||||
if "/" not in logo_file:
|
||||
candidates.append(f"assets/{modid}/{logo_file}")
|
||||
member = None
|
||||
names = set(zf.namelist())
|
||||
for cand in candidates:
|
||||
if cand in names:
|
||||
member = cand
|
||||
break
|
||||
if member is None:
|
||||
return None
|
||||
try:
|
||||
data = zf.read(member)
|
||||
except (KeyError, zipfile.BadZipFile, OSError):
|
||||
return None
|
||||
if not data:
|
||||
return None
|
||||
out_path = os.path.join(LOGOS_DIR, f"{modid}.png")
|
||||
with open(out_path, "wb") as fh:
|
||||
fh.write(data)
|
||||
return f"/mod-logos/{modid}.png"
|
||||
|
||||
|
||||
def read_jar(jar_path):
|
||||
"""Parse one jar → mod dict, or None to skip (with a warning printed)."""
|
||||
name = os.path.basename(jar_path)
|
||||
try:
|
||||
with zipfile.ZipFile(jar_path) as zf:
|
||||
try:
|
||||
raw = zf.read(MANIFEST)
|
||||
except KeyError:
|
||||
print(f" skip (no {MANIFEST}): {name}", file=sys.stderr)
|
||||
return None
|
||||
try:
|
||||
manifest = tomllib.loads(raw.decode("utf-8"))
|
||||
except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
|
||||
print(f" skip (bad toml: {exc}): {name}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
mod = primary_mod_entry(manifest)
|
||||
if mod is None or not mod.get("modId"):
|
||||
print(f" skip (no [[mods]] / modId): {name}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
modid = str(mod["modId"]).strip()
|
||||
display = mod.get("displayName")
|
||||
display = str(display).strip() if display else modid
|
||||
display = display.replace("${file.jarVersion}", "").strip() or modid
|
||||
|
||||
version = resolve_version(mod.get("version"), manifest) or ""
|
||||
authors = stringify_authors(
|
||||
mod.get("authors", manifest.get("authors"))
|
||||
)
|
||||
description = mod.get("description")
|
||||
description = str(description).strip() if description else ""
|
||||
|
||||
logo = extract_logo(zf, manifest, mod, modid)
|
||||
except (zipfile.BadZipFile, OSError) as exc:
|
||||
print(f" skip (unreadable jar: {exc}): {name}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": modid,
|
||||
"name": display,
|
||||
"version": version,
|
||||
"authors": authors,
|
||||
"description": description,
|
||||
"logo": logo,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
required_dir, origin = resolve_required_dir()
|
||||
jars = sorted(glob.glob(os.path.join(required_dir, "*.jar")))
|
||||
if not jars:
|
||||
sys.exit(f"no jars found in {required_dir} (from {origin})")
|
||||
|
||||
# Deterministic logo dir: clear stale PNGs, recreate.
|
||||
if os.path.isdir(LOGOS_DIR):
|
||||
for f in glob.glob(os.path.join(LOGOS_DIR, "*.png")):
|
||||
os.remove(f)
|
||||
else:
|
||||
os.makedirs(LOGOS_DIR, exist_ok=True)
|
||||
os.makedirs(os.path.dirname(MODS_JSON), exist_ok=True)
|
||||
|
||||
mods = []
|
||||
skipped = 0
|
||||
by_id = {}
|
||||
for jar in jars:
|
||||
entry = read_jar(jar)
|
||||
if entry is None:
|
||||
skipped += 1
|
||||
continue
|
||||
# Dedupe by modId (a modset shouldn't have two, but be safe — first wins).
|
||||
if entry["id"] in by_id:
|
||||
print(f" warn (duplicate modId {entry['id']!r}), keeping first", file=sys.stderr)
|
||||
continue
|
||||
by_id[entry["id"]] = entry
|
||||
mods.append(entry)
|
||||
|
||||
mods.sort(key=lambda m: m["name"].casefold())
|
||||
with_logos = sum(1 for m in mods if m["logo"])
|
||||
|
||||
out = {"schemaVersion": SCHEMA_VERSION, "count": len(mods), "mods": mods}
|
||||
with open(MODS_JSON, "w", encoding="utf-8") as fh:
|
||||
json.dump(out, fh, indent=2, ensure_ascii=False)
|
||||
fh.write("\n")
|
||||
|
||||
print(f"source: {required_dir} (from {origin})")
|
||||
print(f"jars: {len(jars)} | mods written: {len(mods)} | skipped: {skipped}")
|
||||
print(f"logos extracted: {with_logos}/{len(mods)} -> {LOGOS_DIR}")
|
||||
print(f"wrote {MODS_JSON}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
170
tooling/classify-mods.py
Executable file
170
tooling/classify-mods.py
Executable file
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
# classify-mods.py — seed/update mods-sides.json from jar manifests (tomllib only).
|
||||
#
|
||||
# Enumerates the distribution's server-required jars and, for each jar NOT already
|
||||
# a key in mods-sides.json, reads META-INF/neoforge.mods.toml and derives a side
|
||||
# from the minecraft/neoforge dependency `side`:
|
||||
# side == "CLIENT" -> client
|
||||
# side == "SERVER" -> server
|
||||
# "BOTH" / unspecified / no manifest / parse error -> both (the default)
|
||||
# Existing entries are PRESERVED verbatim (never clobbers human edits); only new
|
||||
# jars are added. No network calls. Pure stdlib (Python 3.11+ for tomllib).
|
||||
#
|
||||
# Source dir resolution (first match wins):
|
||||
# 1. CLI arg: classify-mods.py <forgemods-required-dir-or-dist-root>
|
||||
# 2. env MODS_DIST_DIR
|
||||
# 3. $DISTRIBUTION_WEB_ROOT from .env / environment
|
||||
# The path may point at either the dist ROOT (…/distribution[/dist]) or directly
|
||||
# at the …/servers/ulicraft-1.21.1/forgemods/required dir — both are accepted.
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ModuleNotFoundError:
|
||||
sys.exit("classify-mods.py requires Python 3.11+ (tomllib is stdlib)")
|
||||
|
||||
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
SIDES_JSON = os.path.join(REPO_ROOT, "mods-sides.json")
|
||||
REQUIRED_SUBPATH = os.path.join("servers", "ulicraft-1.21.1", "forgemods", "required")
|
||||
MANIFEST = "META-INF/neoforge.mods.toml"
|
||||
|
||||
|
||||
def read_env_dist_root():
|
||||
"""Read DISTRIBUTION_WEB_ROOT from environment, else from repo .env."""
|
||||
val = os.environ.get("DISTRIBUTION_WEB_ROOT")
|
||||
if val:
|
||||
return val
|
||||
env_path = os.path.join(REPO_ROOT, ".env")
|
||||
if os.path.isfile(env_path):
|
||||
with open(env_path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if line.startswith("DISTRIBUTION_WEB_ROOT="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return None
|
||||
|
||||
|
||||
def resolve_required_dir():
|
||||
"""Resolve the forgemods/required dir from override or .env. Halt if missing."""
|
||||
src = None
|
||||
origin = None
|
||||
if len(sys.argv) > 1 and sys.argv[1]:
|
||||
src, origin = sys.argv[1], "CLI arg"
|
||||
elif os.environ.get("MODS_DIST_DIR"):
|
||||
src, origin = os.environ["MODS_DIST_DIR"], "MODS_DIST_DIR env"
|
||||
else:
|
||||
src = read_env_dist_root()
|
||||
origin = "$DISTRIBUTION_WEB_ROOT"
|
||||
if not src:
|
||||
sys.exit(
|
||||
"no source dir: pass a path, set MODS_DIST_DIR, or DISTRIBUTION_WEB_ROOT in .env"
|
||||
)
|
||||
candidates = [os.path.join(src, REQUIRED_SUBPATH), src]
|
||||
for cand in candidates:
|
||||
if os.path.isdir(cand) and os.path.basename(os.path.normpath(cand)) == "required":
|
||||
return cand, origin
|
||||
for cand in candidates:
|
||||
if os.path.isdir(cand):
|
||||
return cand, origin
|
||||
sys.exit(
|
||||
f"forgemods/required dir not found (from {origin}={src!r}). "
|
||||
f"Tried: {', '.join(candidates)}"
|
||||
)
|
||||
|
||||
|
||||
def derive_side(jar_path):
|
||||
"""Return client|server|both from the jar's minecraft/neoforge dep side."""
|
||||
try:
|
||||
with zipfile.ZipFile(jar_path) as z:
|
||||
data = z.read(MANIFEST)
|
||||
manifest = tomllib.loads(data.decode("utf-8"))
|
||||
except (KeyError, zipfile.BadZipFile, tomllib.TOMLDecodeError, OSError, UnicodeDecodeError):
|
||||
return "both"
|
||||
deps = manifest.get("dependencies", {})
|
||||
if not isinstance(deps, dict):
|
||||
return "both"
|
||||
decided = None
|
||||
for dep_list in deps.values():
|
||||
if not isinstance(dep_list, list):
|
||||
continue
|
||||
for d in dep_list:
|
||||
if not isinstance(d, dict):
|
||||
continue
|
||||
if d.get("modId") in ("minecraft", "neoforge"):
|
||||
side = d.get("side")
|
||||
if side == "CLIENT":
|
||||
return "client"
|
||||
if side == "SERVER":
|
||||
return "server"
|
||||
# BOTH or unspecified — keep looking; a later CLIENT/SERVER wins.
|
||||
decided = "both"
|
||||
return decided or "both"
|
||||
|
||||
|
||||
def main():
|
||||
required_dir, origin = resolve_required_dir()
|
||||
jars = sorted(glob.glob(os.path.join(required_dir, "*.jar")))
|
||||
if not jars:
|
||||
sys.exit(f"no jars found in {required_dir} (from {origin})")
|
||||
|
||||
if os.path.isfile(SIDES_JSON):
|
||||
with open(SIDES_JSON, encoding="utf-8") as fh:
|
||||
sides = json.load(fh)
|
||||
if not isinstance(sides, dict):
|
||||
sys.exit(f"{SIDES_JSON} is not a JSON object")
|
||||
else:
|
||||
sides = {}
|
||||
|
||||
existing_count = len(sides)
|
||||
added = {}
|
||||
defaulted_both = [] # newly-added jars that fell back to "both"
|
||||
for jar in jars:
|
||||
name = os.path.basename(jar)
|
||||
if name in sides:
|
||||
continue
|
||||
side = derive_side(jar)
|
||||
sides[name] = side
|
||||
added[name] = side
|
||||
if side == "both":
|
||||
defaulted_both.append(name)
|
||||
|
||||
# Stable, human-diffable output.
|
||||
ordered = {k: sides[k] for k in sorted(sides)}
|
||||
with open(SIDES_JSON, "w", encoding="utf-8") as fh:
|
||||
json.dump(ordered, fh, indent=2, ensure_ascii=False)
|
||||
fh.write("\n")
|
||||
|
||||
# Warn about stale keys (in mods-sides.json but no jar in the dist dir).
|
||||
present = {os.path.basename(j) for j in jars}
|
||||
stale = sorted(k for k in sides if k not in present)
|
||||
|
||||
counts = {"client": 0, "server": 0, "both": 0}
|
||||
for v in sides.values():
|
||||
if v in counts:
|
||||
counts[v] += 1
|
||||
|
||||
print(f"source: {required_dir} (from {origin})")
|
||||
print(f"jars in dist: {len(jars)} | pre-existing entries: {existing_count} | added: {len(added)}")
|
||||
print(f"sides totals — both: {counts['both']} client: {counts['client']} server: {counts['server']}")
|
||||
if added:
|
||||
print(f"\nnewly classified ({len(added)}):")
|
||||
for name, side in sorted(added.items()):
|
||||
print(f" {side:6} {name}")
|
||||
if defaulted_both:
|
||||
print(f"\nDEFAULTED to 'both' ({len(defaulted_both)}) — review for cosmetic-client mods:")
|
||||
for name in defaulted_both:
|
||||
print(f" {name}")
|
||||
if stale:
|
||||
print(f"\nWARNING — {len(stale)} key(s) in mods-sides.json have no jar in the dist dir:")
|
||||
for name in stale:
|
||||
print(f" {name}")
|
||||
print(f"\nwrote {SIDES_JSON}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
51
tooling/fetch-authlib.sh
Executable file
51
tooling/fetch-authlib.sh
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# fetch-authlib.sh — download the latest authlib-injector.jar into runtime/.
|
||||
#
|
||||
# The Minecraft container mounts ./runtime read-only at /extras and loads the
|
||||
# server JVM agent from /extras/authlib-injector.jar (points the server at Drasl
|
||||
# for session validation). The jar is gitignored, so a fresh checkout needs this.
|
||||
#
|
||||
# Usage:
|
||||
# tooling/fetch-authlib.sh # download if missing (or invalid)
|
||||
# tooling/fetch-authlib.sh --force # re-download even if present
|
||||
#
|
||||
# Cautious by design: resolves the release asset by name, halts on any error,
|
||||
# and verifies the result is a real zip/jar before trusting it (a half-written
|
||||
# or HTML error body would otherwise surface later as the cryptic
|
||||
# "Error opening zip file or JAR manifest missing").
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.." # repo root
|
||||
|
||||
log() { printf '\n\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
command -v curl >/dev/null || die "curl required"
|
||||
command -v jq >/dev/null || die "jq required"
|
||||
|
||||
DEST="runtime/authlib-injector.jar"
|
||||
API="https://api.github.com/repos/yushijinhun/authlib-injector/releases/latest"
|
||||
|
||||
# A valid jar is a zip — first two bytes are "PK". Reject anything else.
|
||||
is_jar() { [ -s "$1" ] && [ "$(head -c2 "$1" 2>/dev/null)" = "PK" ]; }
|
||||
|
||||
if [ "${1:-}" != "--force" ] && is_jar "$DEST"; then
|
||||
log "$DEST already present and valid — skipping (use --force to re-download)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "resolving latest authlib-injector release asset"
|
||||
url="$(curl -fsSL "$API" \
|
||||
| jq -r '.assets[] | select(.name|test("authlib-injector-[0-9].*\\.jar$")) | .browser_download_url' \
|
||||
| head -1)"
|
||||
[ -n "$url" ] && [ "$url" != "null" ] || die "could not resolve authlib-injector release asset"
|
||||
|
||||
mkdir -p runtime
|
||||
tmp="${DEST}.part"
|
||||
log "downloading $(basename "$url")"
|
||||
curl -fSL --retry 3 -o "$tmp" "$url"
|
||||
|
||||
is_jar "$tmp" || { rm -f "$tmp"; die "downloaded file is not a valid jar (zip) — aborting"; }
|
||||
mv -f "$tmp" "$DEST"
|
||||
log "done → $DEST ($(wc -c <"$DEST") bytes)"
|
||||
@@ -41,4 +41,30 @@ echo "→ writing fonts.css (URLs rewritten to /fonts/…)"
|
||||
# Rewrite absolute gstatic URLs to local /fonts/<basename>.
|
||||
echo "$raw" | sed -E 's#https://fonts\.gstatic\.com/[^)]+/([^)/]+\.woff2)#/fonts/\1#g' > "$out/fonts.css"
|
||||
|
||||
# Block Blueprint — the heading font. NOT on Google Fonts, so it can't ride the
|
||||
# css2 request above; vendored straight from 1001fonts (License: 1001Fonts Free
|
||||
# For Personal Use — fine for this private friends' server). TTF only, no woff2
|
||||
# (no woff2 toolchain assumed). Appended AFTER the rewrite so it survives re-runs.
|
||||
echo "→ vendoring Block Blueprint (1001fonts)"
|
||||
bb_ttf="$out/BlockBlueprint.ttf"
|
||||
if [ ! -f "$bb_ttf" ]; then
|
||||
tmpzip="$(mktemp)"
|
||||
curl -fsSL -A "$UA" -o "$tmpzip" "https://www.1001fonts.com/download/blockblueprint.zip"
|
||||
unzip -p "$tmpzip" BlockBlueprint.ttf > "$bb_ttf"
|
||||
rm -f "$tmpzip"
|
||||
echo " BlockBlueprint.ttf"
|
||||
fi
|
||||
cat >> "$out/fonts.css" <<'EOF'
|
||||
/* Block Blueprint — vendored from 1001fonts (NOT on Google Fonts; appended by
|
||||
fetch-fonts.sh after the Google rewrite). License: 1001Fonts Free For Personal
|
||||
Use. Single weight, TTF (no woff2 toolchain on the build box). */
|
||||
@font-face {
|
||||
font-family: 'Block Blueprint';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/BlockBlueprint.ttf) format('truetype');
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "✓ fonts vendored into $out"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# render-config.sh — turn *.tmpl files into real configs via envsubst.
|
||||
# Substitutes ONLY ${BASE_DOMAIN} so literal $-syntax that belongs to the target
|
||||
# file (caddy, toml) is left untouched.
|
||||
# Substitutes ONLY a small whitelist so literal $-syntax that belongs to the
|
||||
# target file (caddy, toml) is left untouched.
|
||||
# Halts on any unset var or missing template (cautious: no half-rendered output).
|
||||
set -euo pipefail
|
||||
|
||||
@@ -10,15 +10,86 @@ cd "$(dirname "$0")/.." # repo root
|
||||
[ -f .env ] && { set -a; . ./.env; set +a; }
|
||||
: "${BASE_DOMAIN:?BASE_DOMAIN unset (set in .env)}"
|
||||
|
||||
# REGISTRATION_MODE (invite|open) -> Drasl RequireInvite boolean. drasl is
|
||||
# TOML-only with no env support, so we derive the literal here and substitute it.
|
||||
: "${REGISTRATION_MODE:=invite}"
|
||||
case "$REGISTRATION_MODE" in
|
||||
invite) REGISTRATION_REQUIRE_INVITE=true ;;
|
||||
open) REGISTRATION_REQUIRE_INVITE=false ;;
|
||||
*) echo "REGISTRATION_MODE must be 'invite' or 'open' (got '$REGISTRATION_MODE')" >&2; exit 1 ;;
|
||||
esac
|
||||
export REGISTRATION_REQUIRE_INVITE
|
||||
|
||||
render() { # $1=template $2=output
|
||||
[ -f "$1" ] || { echo "missing template: $1" >&2; exit 1; }
|
||||
envsubst '${BASE_DOMAIN}' < "$1" > "$2"
|
||||
envsubst '${BASE_DOMAIN} ${REGISTRATION_REQUIRE_INVITE}' < "$1" > "$2"
|
||||
echo "rendered $2"
|
||||
}
|
||||
|
||||
render drasl/config/config.toml.tmpl drasl/config/config.toml
|
||||
render nmsr/config.toml.tmpl nmsr/config.toml
|
||||
|
||||
# pack templates (mods/*.pw.toml.tmpl, CustomSkinLoader, …) + packwiz index.
|
||||
# Delegated to render-pack.sh (BASE_DOMAIN only — single source of pack render).
|
||||
tooling/render-pack.sh
|
||||
# ── filestash (files.${BASE_DOMAIN}) ────────────────────────────────────
|
||||
# Filestash has NO env-var config: config.json is the source of truth and the
|
||||
# admin console writes it back at runtime. We render it and mount it read-only,
|
||||
# so git stays authoritative (the console renders but cannot save — by design).
|
||||
#
|
||||
# general.host in the template is a BARE HOSTNAME (files.$BASE_DOMAIN, no
|
||||
# scheme) and general.force_ssl is true. The SPA builds its redirect origin as
|
||||
# (force_ssl ? "https://" : "http://") + host, so a scheme in `host` produces
|
||||
# "http://https://files..." and every client-side redirect breaks. The
|
||||
# server-side Host check strips the scheme first, so curl/monitors see a healthy
|
||||
# 200 and ONLY browsers fail. Don't "fix" it by putting the URL back.
|
||||
#
|
||||
# FILES_AUTH picks the identity_provider block. The params fields are JSON
|
||||
# *strings* holding escaped JSON (that's Filestash's schema, not a typo) —
|
||||
# built here rather than in the template because the shape differs per mode.
|
||||
: "${FILES_AUTH:=htpasswd}"
|
||||
: "${FILES_SECRET_KEY:?FILES_SECRET_KEY unset (openssl rand -hex 16)}"
|
||||
: "${FILES_ADMIN_PASSWORD_HASH:?FILES_ADMIN_PASSWORD_HASH unset (docker run --rm caddy:alpine caddy hash-password --plaintext '<pw>')}"
|
||||
: "${FILES_LOCAL_SECRET:?FILES_LOCAL_SECRET unset (openssl rand -hex 24) — unlocks the admin-gated local backend}"
|
||||
|
||||
case "$FILES_AUTH" in
|
||||
htpasswd)
|
||||
: "${FILES_USER:?FILES_USER unset (shared login for players)}"
|
||||
: "${FILES_PASSWORD_HASH:?FILES_PASSWORD_HASH unset (openssl passwd -6 '<pw>')}"
|
||||
# The htpasswd plugin's bcrypt branch only accepts $2a$ — a $2y$ hash (what
|
||||
# `htpasswd -B` emits) silently fails every login. $6$ (openssl passwd -6)
|
||||
# is the safe, explicitly-supported format. Halt rather than ship a config
|
||||
# nobody can log into.
|
||||
case "$FILES_PASSWORD_HASH" in
|
||||
'$6$'*|'$5$'*|'$2a$'*|'$apr1$'*|'$1$'*) ;;
|
||||
'$2y$'*|'$2b$'*)
|
||||
echo "FILES_PASSWORD_HASH is bcrypt '${FILES_PASSWORD_HASH:0:4}' — the htpasswd plugin only accepts \$2a\$." >&2
|
||||
echo "Use: openssl passwd -6 '<password>'" >&2
|
||||
exit 1 ;;
|
||||
*) echo "FILES_PASSWORD_HASH is not a recognised hash (want \$6\$… from: openssl passwd -6 '<password>')" >&2; exit 1 ;;
|
||||
esac
|
||||
FILES_IDP_TYPE="htpasswd"
|
||||
FILES_IDP_PARAMS="{\\\"type\\\":\\\"htpasswd\\\",\\\"users\\\":\\\"${FILES_USER}:${FILES_PASSWORD_HASH}\\\"}"
|
||||
;;
|
||||
passthrough|none)
|
||||
# NO LOGIN — anyone who reaches the vhost gets in. Only safe once this host
|
||||
# is NOT reachable from the internet (see plan/20-files.md).
|
||||
echo "!! FILES_AUTH=$FILES_AUTH — filestash will have NO authentication." >&2
|
||||
echo "!! Only valid if files.${BASE_DOMAIN} is unreachable from the internet." >&2
|
||||
FILES_IDP_TYPE="passthrough"
|
||||
FILES_IDP_PARAMS="{\\\"type\\\":\\\"passthrough\\\",\\\"strategy\\\":\\\"direct\\\"}"
|
||||
;;
|
||||
*)
|
||||
echo "FILES_AUTH must be 'htpasswd', 'passthrough' or 'none' (got '$FILES_AUTH')" >&2; exit 1 ;;
|
||||
esac
|
||||
export FILES_IDP_TYPE FILES_IDP_PARAMS
|
||||
|
||||
envsubst '${BASE_DOMAIN} ${FILES_IDP_TYPE} ${FILES_IDP_PARAMS} ${FILES_ADMIN_PASSWORD_HASH} ${FILES_SECRET_KEY} ${FILES_LOCAL_SECRET}' \
|
||||
< filestash/config.json.tmpl > filestash/config.json
|
||||
|
||||
# The hashes contain '$' and the params fields are nested JSON — one bad escape
|
||||
# yields a config filestash silently ignores. Fail loudly instead.
|
||||
python3 -c 'import json,sys; json.load(open("filestash/config.json"))' \
|
||||
|| { echo "filestash/config.json is not valid JSON — refusing to ship it" >&2; exit 1; }
|
||||
echo "rendered filestash/config.json (auth=$FILES_AUTH)"
|
||||
|
||||
# Server mods are no longer rendered here — they come from the distribution repo
|
||||
# via tooling/sync-server-mods.sh (run separately in the deploy flow). See
|
||||
# plan/04-mods.md.
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# render-pack.sh — render every pack template (pack/**/*.tmpl) by injecting
|
||||
# ${BASE_DOMAIN}, then rebuild the packwiz index.
|
||||
#
|
||||
# Pack templates are domain-dependent build output (download URLs, skin API
|
||||
# roots, …). This renderer needs ONLY ${BASE_DOMAIN}, so it can run while
|
||||
# curating mods before the full deploy config exists.
|
||||
# Both tooling/render-config.sh (deploy) and tooling/add-custom-mod.sh call it.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.." # repo root
|
||||
|
||||
[ -f .env ] && { set -a; . ./.env; set +a; }
|
||||
: "${BASE_DOMAIN:?BASE_DOMAIN unset (set in .env)}"
|
||||
command -v envsubst >/dev/null 2>&1 || { echo "missing required tool: envsubst (gettext)" >&2; exit 1; }
|
||||
command -v packwiz >/dev/null 2>&1 || { echo "missing required tool: packwiz" >&2; exit 1; }
|
||||
[ -f pack/pack.toml ] || { echo "pack/pack.toml not found" >&2; exit 1; }
|
||||
|
||||
shopt -s globstar nullglob
|
||||
tmpls=(pack/**/*.tmpl)
|
||||
shopt -u globstar nullglob
|
||||
|
||||
for t in "${tmpls[@]}"; do
|
||||
# Substitute ONLY ${BASE_DOMAIN} so any other literal $-syntax is preserved.
|
||||
envsubst '${BASE_DOMAIN}' < "$t" > "${t%.tmpl}"
|
||||
echo "rendered ${t%.tmpl}"
|
||||
done
|
||||
|
||||
# index.toml is gitignored build output; seed a minimal one so packwiz refresh
|
||||
# (which won't bootstrap a missing index) has something to populate.
|
||||
[ -f pack/index.toml ] || printf 'hash-format = "sha256"\n' > pack/index.toml
|
||||
( cd pack && packwiz refresh )
|
||||
echo "packwiz index refreshed (${#tmpls[@]} template(s))"
|
||||
108
tooling/sync-server-mods.sh
Executable file
108
tooling/sync-server-mods.sh
Executable file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
# sync-server-mods.sh — mirror the server's mod subset into ./server-mods/.
|
||||
# Replaces the old render-pack.sh (packwiz). Copies every jar mapped `both` or
|
||||
# `server` in mods-sides.json from the distribution's forgemods/required dir into
|
||||
# ./server-mods/, and removes any stray server-mods/*.jar not in the selection
|
||||
# (authoritative mirror). The minecraft container mounts ./server-mods at /mods
|
||||
# (REMOVE_OLD_MODS=TRUE), so /data/mods ends up matching exactly.
|
||||
#
|
||||
# Cautious by design: halts (non-zero) on a missing mods-sides.json or a selected
|
||||
# jar absent from the source dir — never a silent partial sync.
|
||||
#
|
||||
# Source dir resolution (first match wins):
|
||||
# 1. CLI arg: sync-server-mods.sh <forgemods-required-dir-or-dist-root>
|
||||
# 2. env MODS_DIST_DIR
|
||||
# 3. $DISTRIBUTION_WEB_ROOT from .env
|
||||
# The path may point at the dist ROOT (…/distribution[/dist]) or directly at
|
||||
# …/servers/ulicraft-1.21.1/forgemods/required — both are accepted.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.." # repo root
|
||||
|
||||
[ -f .env ] && { set -a; . ./.env; set +a; }
|
||||
|
||||
REQUIRED_SUBPATH="servers/ulicraft-1.21.1/forgemods/required"
|
||||
SIDES_JSON="mods-sides.json"
|
||||
DEST="server-mods"
|
||||
|
||||
# ── Resolve source dir ──────────────────────────────────────────────
|
||||
if [ "${1:-}" != "" ]; then
|
||||
SRC="$1"; ORIGIN="CLI arg"
|
||||
elif [ "${MODS_DIST_DIR:-}" != "" ]; then
|
||||
SRC="$MODS_DIST_DIR"; ORIGIN="MODS_DIST_DIR env"
|
||||
elif [ "${DISTRIBUTION_WEB_ROOT:-}" != "" ]; then
|
||||
SRC="$DISTRIBUTION_WEB_ROOT"; ORIGIN="\$DISTRIBUTION_WEB_ROOT"
|
||||
else
|
||||
echo "no source dir: pass a path, set MODS_DIST_DIR, or DISTRIBUTION_WEB_ROOT in .env" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -d "$SRC/$REQUIRED_SUBPATH" ]; then
|
||||
REQ="$SRC/$REQUIRED_SUBPATH"
|
||||
elif [ "$(basename "$SRC")" = "required" ] && [ -d "$SRC" ]; then
|
||||
REQ="$SRC"
|
||||
else
|
||||
echo "forgemods/required dir not found (from $ORIGIN=$SRC)" >&2
|
||||
echo "tried: $SRC/$REQUIRED_SUBPATH and $SRC" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[ -f "$SIDES_JSON" ] || { echo "$SIDES_JSON not found — run tooling/classify-mods.py first" >&2; exit 1; }
|
||||
|
||||
# ── Selected jars (both|server) ─────────────────────────────────────
|
||||
select_jars() {
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
jq -r 'to_entries[] | select(.value == "both" or .value == "server") | .key' "$SIDES_JSON"
|
||||
else
|
||||
python3 - "$SIDES_JSON" <<'PY'
|
||||
import json, sys
|
||||
with open(sys.argv[1], encoding="utf-8") as fh:
|
||||
sides = json.load(fh)
|
||||
for name, side in sides.items():
|
||||
if side in ("both", "server"):
|
||||
print(name)
|
||||
PY
|
||||
fi
|
||||
}
|
||||
|
||||
mapfile -t SELECTED < <(select_jars | sort)
|
||||
[ "${#SELECTED[@]}" -gt 0 ] || { echo "no jars selected (both/server) in $SIDES_JSON — nothing to sync" >&2; exit 1; }
|
||||
|
||||
# ── Halt if any selected jar is missing from the source ─────────────
|
||||
missing=()
|
||||
for jar in "${SELECTED[@]}"; do
|
||||
[ -f "$REQ/$jar" ] || missing+=("$jar")
|
||||
done
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
echo "ERROR: ${#missing[@]} selected jar(s) missing from source ($REQ):" >&2
|
||||
printf ' %s\n' "${missing[@]}" >&2
|
||||
echo "refusing to do a partial sync — fix mods-sides.json or the distribution." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Mirror: copy selected, prune everything else ────────────────────
|
||||
mkdir -p "$DEST"
|
||||
|
||||
# Prune stray jars not in the current selection.
|
||||
declare -A keep
|
||||
for jar in "${SELECTED[@]}"; do keep["$jar"]=1; done
|
||||
pruned=0
|
||||
shopt -s nullglob
|
||||
for existing in "$DEST"/*.jar; do
|
||||
base="$(basename "$existing")"
|
||||
if [ -z "${keep[$base]:-}" ]; then
|
||||
rm -f -- "$existing"
|
||||
pruned=$((pruned + 1))
|
||||
fi
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
# Copy selection (cp -p preserves mtime so unchanged jars don't re-touch).
|
||||
copied=0
|
||||
for jar in "${SELECTED[@]}"; do
|
||||
cp -p -- "$REQ/$jar" "$DEST/$jar"
|
||||
copied=$((copied + 1))
|
||||
done
|
||||
|
||||
echo "source: $REQ (from $ORIGIN)"
|
||||
echo "synced $copied jar(s) into $DEST/ (pruned $pruned stray)."
|
||||
121
update-all.sh
Executable file
121
update-all.sh
Executable file
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env bash
|
||||
# update-all.sh — main post-pull orchestrator for the Ulicraft stack on the host.
|
||||
#
|
||||
# Run AFTER `git pull` (it does NOT pull or push). Single source of truth for the
|
||||
# build + restart steps; the `deploy` skill is just `git pull --ff-only` followed
|
||||
# by `./update-all.sh --hard`.
|
||||
#
|
||||
# Steps (always, in order): render configs → sync server mods → regen landing mod
|
||||
# list → rebuild landing (www/) → apply via docker compose.
|
||||
#
|
||||
# Modes:
|
||||
# (default, rolling) `docker compose up -d --build`, then restart ONLY the
|
||||
# services whose mounted inputs actually changed — drasl/nmsr on a config
|
||||
# re-render, minecraft on a mod change. Minimal downtime. NOTE: this path
|
||||
# canNOT detect changes to caddy's static conf (caddy/conf.d/*.caddy) — it
|
||||
# has no pre-pull baseline for them; use --hard for those.
|
||||
# --hard `docker compose down --remove-orphans && up -d --build`.
|
||||
# Full restart (Minecraft world downtime); picks up everything, caddy
|
||||
# static conf included.
|
||||
#
|
||||
# Cautious by design: strict halt on ANY error (set -euo pipefail) — never a
|
||||
# partial deploy. Fix the cause and re-run. Uses pnpm/node only (never npm).
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")" # repo root (this script lives at the root)
|
||||
|
||||
# ── args ────────────────────────────────────────────────────────────
|
||||
HARD=0
|
||||
case "${1:-}" in
|
||||
"") HARD=0 ;;
|
||||
--hard) HARD=1 ;;
|
||||
-h|--help) echo "usage: $0 [--hard]"; exit 0 ;;
|
||||
*) echo "unknown arg: $1" >&2; echo "usage: $0 [--hard]" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
step() { printf '\n\033[1;36m→ %s\033[0m\n' "$*"; }
|
||||
|
||||
cfg_hash() { [ -f "$1" ] && md5sum "$1" | cut -d' ' -f1 || echo absent; }
|
||||
# Cheap content proxy: jar filenames + sizes (content changes ⇒ size changes).
|
||||
mods_hash() {
|
||||
{ find server-mods -maxdepth 1 -name '*.jar' -printf '%f %s\n' 2>/dev/null || true; } \
|
||||
| sort | md5sum | cut -d' ' -f1
|
||||
}
|
||||
|
||||
# ── snapshot inputs (rolling mode only) ─────────────────────────────
|
||||
if [ "$HARD" -eq 0 ]; then
|
||||
before_drasl=$(cfg_hash drasl/config/config.toml)
|
||||
before_nmsr=$(cfg_hash nmsr/config.toml)
|
||||
before_mods=$(mods_hash)
|
||||
fi
|
||||
|
||||
# ── 1. render drasl/nmsr configs from .env (halts on bad input) ─────
|
||||
step "render-config.sh"
|
||||
tooling/render-config.sh
|
||||
|
||||
# ── 2. mirror the server mod subset into ./server-mods ──────────────
|
||||
step "sync-server-mods.sh"
|
||||
tooling/sync-server-mods.sh
|
||||
|
||||
# ── 3. regenerate the landing mod list (mods.json + logos) ──────────
|
||||
# build-modlist.py needs stdlib tomllib (Python >=3.11). The host's default
|
||||
# `python3` may be older (e.g. Ubuntu 22.04 ships 3.10), so pick the first
|
||||
# interpreter that satisfies it rather than relying on the shebang.
|
||||
step "build-modlist.py"
|
||||
PY=""
|
||||
for p in python3.13 python3.12 python3.11 python3; do
|
||||
command -v "$p" >/dev/null 2>&1 || continue
|
||||
if "$p" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' 2>/dev/null; then
|
||||
PY="$p"; break
|
||||
fi
|
||||
done
|
||||
[ -n "$PY" ] || { echo "no python >=3.11 found (build-modlist.py needs stdlib tomllib)" >&2; exit 1; }
|
||||
"$PY" tooling/build-modlist.py
|
||||
|
||||
# ── 4. rebuild the landing site into www/ (gitignored — pull never
|
||||
# updates it, so it must be rebuilt every run) ─────────────────
|
||||
step "landing build"
|
||||
(
|
||||
# Non-interactive shells lack node/pnpm on PATH — source nvm + pnpm.
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
|
||||
export PNPM_HOME="$HOME/.local/share/pnpm"
|
||||
export PATH="$PNPM_HOME/bin:$PATH"
|
||||
command -v pnpm >/dev/null 2>&1 || {
|
||||
echo "pnpm not on PATH (looked in nvm=$NVM_DIR, pnpm=$PNPM_HOME/bin)." >&2
|
||||
echo "Install pnpm + node (never npm) and re-run." >&2
|
||||
exit 1
|
||||
}
|
||||
cd landing
|
||||
set -a; . ../.env; set +a
|
||||
pnpm run build
|
||||
)
|
||||
|
||||
# ── 5. apply via docker compose ─────────────────────────────────────
|
||||
if [ "$HARD" -eq 1 ]; then
|
||||
step "docker compose down + up (--hard, full restart)"
|
||||
docker compose down --remove-orphans
|
||||
docker compose up -d --build
|
||||
else
|
||||
step "docker compose up -d --build"
|
||||
docker compose up -d --build
|
||||
|
||||
# Rolling `up -d` does NOT reload changed bind-mounted configs/mods, so restart
|
||||
# only the services whose inputs actually changed since the snapshot above.
|
||||
restart=()
|
||||
[ "$(cfg_hash drasl/config/config.toml)" != "$before_drasl" ] && restart+=(drasl)
|
||||
[ "$(cfg_hash nmsr/config.toml)" != "$before_nmsr" ] && restart+=(nmsr)
|
||||
[ "$(mods_hash)" != "$before_mods" ] && restart+=(minecraft) # mods ⇒ MC restart (downtime, required)
|
||||
if [ "${#restart[@]}" -gt 0 ]; then
|
||||
step "restart changed services: ${restart[*]}"
|
||||
docker compose restart "${restart[@]}"
|
||||
else
|
||||
echo " (no config/mod changes — nothing to restart)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 6. status summary ───────────────────────────────────────────────
|
||||
step "docker compose ps"
|
||||
docker compose ps
|
||||
|
||||
printf '\n\033[1;32m✓ update-all complete\033[0m\n'
|
||||
Reference in New Issue
Block a user