Compare commits
2 Commits
067e990306
...
d21a7f0e92
| Author | SHA1 | Date | |
|---|---|---|---|
| d21a7f0e92 | |||
| df8ffca53e |
@@ -45,11 +45,25 @@ invoking this skill is the authorization to proceed, but:
|
|||||||
report (local changes on the host need manual resolution).
|
report (local changes on the host need manual resolution).
|
||||||
- `render-config.sh` re-renders drasl/nmsr/packwiz configs from `.env`
|
- `render-config.sh` re-renders drasl/nmsr/packwiz configs from `.env`
|
||||||
(needs `.env` complete: `BASE_DOMAIN`, `RCON_PASSWORD`,
|
(needs `.env` complete: `BASE_DOMAIN`, `RCON_PASSWORD`,
|
||||||
`DISTRIBUTION_WEB_ROOT`, `CADDY_HTTP_*`).
|
`DISTRIBUTION_WEB_ROOT`, `CADDY_HTTP_*`, `REGISTRATION_MODE`). It halts on
|
||||||
|
an invalid `REGISTRATION_MODE` (must be `invite` or `open`).
|
||||||
- `fetch-authlib.sh` ensures `runtime/authlib-injector.jar` exists (gitignored;
|
- `fetch-authlib.sh` ensures `runtime/authlib-injector.jar` exists (gitignored;
|
||||||
skips if already valid). Missing jar = minecraft crashloops with "Error
|
skips if already valid). Missing jar = minecraft crashloops with "Error
|
||||||
opening zip file or JAR manifest missing".
|
opening zip file or JAR manifest missing".
|
||||||
|
|
||||||
|
2b. **Landing rebuild (only if needed).** The static site `www/` is gitignored —
|
||||||
|
`git pull` does NOT update it. If the pulled commits (from the step-1
|
||||||
|
pre-check) touch `landing/`, or `REGISTRATION_MODE` changed, rebuild it;
|
||||||
|
otherwise skip:
|
||||||
|
```bash
|
||||||
|
ssh cochi 'set -e
|
||||||
|
cd /home/ubuntu/mc/ulicraft-server-v1
|
||||||
|
cd landing && set -a && . ../.env && set +a && pnpm run build'
|
||||||
|
```
|
||||||
|
Sourcing `.env` bakes `BASE_DOMAIN` + `REGISTRATION_MODE` (invite-field on/off)
|
||||||
|
into the output. A flag-only change just needs this rebuild + a `drasl`
|
||||||
|
restart (already covered by step 2's `up`), not the world downtime.
|
||||||
|
|
||||||
3. **Verify.**
|
3. **Verify.**
|
||||||
```bash
|
```bash
|
||||||
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && docker compose ps'
|
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && docker compose ps'
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ BASE_DOMAIN=ulicraft.net
|
|||||||
|
|
||||||
RCON_PASSWORD=change-me-to-something-random
|
RCON_PASSWORD=change-me-to-something-random
|
||||||
|
|
||||||
|
# Registration mode for self-hosted accounts (Drasl) + landing register form.
|
||||||
|
# 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 BOTH render-config.sh (-> Drasl RegistrationNewPlayer.RequireInvite)
|
||||||
|
# and the landing build (-> show/hide the invite-code field). Default: invite.
|
||||||
|
REGISTRATION_MODE=invite
|
||||||
|
|
||||||
# caddy host-published port. The host's own nginx terminates TLS and
|
# 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
|
# 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.
|
# bind caddy to a high localhost-only port — only nginx should reach it.
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ http://{$BASE_DOMAIN} {
|
|||||||
root * /srv/launcher
|
root * /srv/launcher
|
||||||
file_server browse
|
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 {
|
handle {
|
||||||
root * /srv/www
|
root * /srv/www
|
||||||
file_server
|
file_server
|
||||||
|
|||||||
@@ -164,6 +164,22 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- mcnet
|
- 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"
|
||||||
|
networks:
|
||||||
|
- mcnet
|
||||||
|
|
||||||
# Status monitoring + public status page. caddy proxies status. -> :3001.
|
# Status monitoring + public status page. caddy proxies status. -> :3001.
|
||||||
# Joins mcnet so monitors probe the stack by internal service name.
|
# Joins mcnet so monitors probe the stack by internal service name.
|
||||||
uptime-kuma:
|
uptime-kuma:
|
||||||
|
|||||||
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=
|
||||||
196
docker/mc-status/main.go
Normal file
196
docker/mc-status/main.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
// 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 (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
host, port := splitHostPort(target)
|
||||||
|
c := &cache{}
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
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", listen, host, port, ttl)
|
||||||
|
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,6 +1,7 @@
|
|||||||
# Drasl config — password-login mode (NO Keycloak/OIDC for now).
|
# Drasl config — password-login mode (NO Keycloak/OIDC for now).
|
||||||
# TOML only (drasl has no env support). Rendered by tooling/render-config.sh
|
# 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.
|
# Reached only via Caddy at auth.${BASE_DOMAIN}; drasl has no published host port.
|
||||||
|
|
||||||
# Public scheme is HTTPS — the host nginx terminates TLS in front of caddy.
|
# Public scheme is HTTPS — the host nginx terminates TLS in front of caddy.
|
||||||
@@ -21,5 +22,27 @@ SignPublicKeys = false
|
|||||||
# Free-text owner label shown in the web UI (a string, not a table).
|
# Free-text owner label shown in the web UI (a string, not a table).
|
||||||
ApplicationOwner = "Ulicraft"
|
ApplicationOwner = "Ulicraft"
|
||||||
|
|
||||||
|
# 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}
|
||||||
|
|
||||||
|
# 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).
|
||||||
|
CORSAllowOrigins = ["https://${BASE_DOMAIN}"]
|
||||||
|
|
||||||
|
# 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).
|
# OIDC block intentionally omitted for now (no Keycloak).
|
||||||
# [[RegistrationOIDC]] <- future task
|
# [[RegistrationOIDC]] <- future task
|
||||||
|
|||||||
232
landing/src/components/ServerCard.astro
Normal file
232
landing/src/components/ServerCard.astro
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
---
|
||||||
|
// 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 Creeper from "./Creeper.astro";
|
||||||
|
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 — be the first.",
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const { status, avatarUrl } = site;
|
||||||
|
const HEAD_MODE = "headiso"; // NMSR render mode for the head row
|
||||||
|
---
|
||||||
|
<div
|
||||||
|
class="servercard"
|
||||||
|
data-api={status.statusApi}
|
||||||
|
data-avatar={avatarUrl}
|
||||||
|
data-mode={HEAD_MODE}
|
||||||
|
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; Creeper otherwise. -->
|
||||||
|
<img class="sc-favicon" alt="" hidden />
|
||||||
|
<span class="sc-creeper"><Creeper /></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>
|
||||||
|
|
||||||
|
<!-- Head row + empty state. Populated client-side. -->
|
||||||
|
<div class="sc-players" hidden>
|
||||||
|
<div class="sc-heads" 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);
|
||||||
|
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
||||||
|
}
|
||||||
|
.sc-favicon { width: 64px; height: 64px; image-rendering: pixelated; }
|
||||||
|
.sc-creeper :global(.creeper) { width: 48px; height: 48px; }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
.sc-heads { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.sc-heads img {
|
||||||
|
width: 34px; height: 34px; image-rendering: pixelated;
|
||||||
|
background: var(--bg-2);
|
||||||
|
box-shadow: inset 1px 1px 0 var(--bevel-lo), inset -1px -1px 0 var(--bevel-hi);
|
||||||
|
}
|
||||||
|
.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 heads = q(".sc-heads");
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Player heads from OUR NMSR (Drasl skins). Filter promo/placeholder rows.
|
||||||
|
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 && heads) {
|
||||||
|
players.hidden = false;
|
||||||
|
if (real.length === 0) {
|
||||||
|
if (emptyEl) {
|
||||||
|
emptyEl.textContent = empty ?? "Nobody online.";
|
||||||
|
emptyEl.hidden = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
heads.innerHTML = "";
|
||||||
|
for (const p of real.slice(0, 16)) {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.loading = "lazy";
|
||||||
|
img.alt = p.name_clean ?? "";
|
||||||
|
img.title = p.name_clean ?? "";
|
||||||
|
img.src = `${avatar}/${mode}/${p.uuid}?size=64`;
|
||||||
|
heads.appendChild(img);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll<HTMLElement>(".servercard").forEach(hydrate);
|
||||||
|
</script>
|
||||||
@@ -4,6 +4,10 @@
|
|||||||
// BASE_DOMAIN is read at build time; falls back to ulicraft.local.
|
// BASE_DOMAIN is read at build time; falls back to ulicraft.local.
|
||||||
const BASE_DOMAIN = process.env.BASE_DOMAIN ?? "ulicraft.local";
|
const BASE_DOMAIN = process.env.BASE_DOMAIN ?? "ulicraft.local";
|
||||||
|
|
||||||
|
// 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";
|
||||||
|
|
||||||
// Minecraft / pack facts, surfaced in a few places.
|
// Minecraft / pack facts, surfaced in a few places.
|
||||||
const MC_VERSION = "1.21.1";
|
const MC_VERSION = "1.21.1";
|
||||||
|
|
||||||
@@ -12,6 +16,11 @@ export const site = {
|
|||||||
baseDomain: BASE_DOMAIN,
|
baseDomain: BASE_DOMAIN,
|
||||||
mcVersion: MC_VERSION,
|
mcVersion: MC_VERSION,
|
||||||
|
|
||||||
|
// Register form behaviour, driven by REGISTRATION_MODE in .env.
|
||||||
|
// true => show the invite-code field (Drasl RequireInvite = true)
|
||||||
|
// false => open self-registration
|
||||||
|
registrationRequiresInvite: REGISTRATION_MODE === "invite",
|
||||||
|
|
||||||
// Design knobs (replace the design's in-browser TweaksPanel). Baked at build.
|
// Design knobs (replace the design's in-browser TweaksPanel). Baked at build.
|
||||||
// mood: "grass" | "nether" | "end"
|
// mood: "grass" | "nether" | "end"
|
||||||
// hero: "centered" | "split" | "spotlight"
|
// hero: "centered" | "split" | "spotlight"
|
||||||
@@ -30,14 +39,26 @@ export const site = {
|
|||||||
// Drasl auth web UI + authlib-injector API root (public HTTPS).
|
// Drasl auth web UI + authlib-injector API root (public HTTPS).
|
||||||
authUrl: `https://auth.${BASE_DOMAIN}`,
|
authUrl: `https://auth.${BASE_DOMAIN}`,
|
||||||
authlibUrl: `https://auth.${BASE_DOMAIN}/authlib-injector`,
|
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 (public HTTPS, pack. subdomain).
|
// packwiz modpack entrypoint (public HTTPS, pack. subdomain).
|
||||||
packwizUrl: `https://pack.${BASE_DOMAIN}/pack.toml`,
|
packwizUrl: `https://pack.${BASE_DOMAIN}/pack.toml`,
|
||||||
|
|
||||||
// Static server-list panel (Status section). No fake live count — see plan
|
// NMSR avatar renderer root. Player heads come from OUR Drasl skins, not
|
||||||
// 09-landing.md option B.
|
// Mojang/Crafatar — head URL is `${avatarUrl}/headiso/<uuid>?size=64`.
|
||||||
|
avatarUrl: `https://avatar.${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: {
|
status: {
|
||||||
slots: 10,
|
slots: 10,
|
||||||
|
statusApi: `/api/mcstatus/v2/status/java/${BASE_DOMAIN}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Honest stat tiles. Keys (numbers/versions) are language-neutral; labels
|
// Honest stat tiles. Keys (numbers/versions) are language-neutral; labels
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ export const LANGS: Lang[] = ["en", "es", "eu"];
|
|||||||
export const LANG_LABEL: Record<Lang, string> = { en: "EN", es: "ES", eu: "EU" };
|
export const LANG_LABEL: Record<Lang, string> = { en: "EN", es: "ES", eu: "EU" };
|
||||||
// URL path per locale (en is default, served at root).
|
// URL path per locale (en is default, served at root).
|
||||||
export const LANG_PATH: Record<Lang, string> = { en: "/", es: "/es/", eu: "/eu/" };
|
export const LANG_PATH: Record<Lang, string> = { en: "/", es: "/es/", eu: "/eu/" };
|
||||||
|
// Register page path per locale (mirrors LANG_PATH: en at /register).
|
||||||
|
export const LANG_REGISTER_PATH: Record<Lang, string> = {
|
||||||
|
en: "/register",
|
||||||
|
es: "/es/register",
|
||||||
|
eu: "/eu/register",
|
||||||
|
};
|
||||||
|
|
||||||
export interface UI {
|
export interface UI {
|
||||||
htmlLang: string;
|
htmlLang: string;
|
||||||
@@ -65,6 +71,39 @@ export interface UI {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
footer: { join: string; disc: string };
|
footer: { join: 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 Drasl web UI
|
||||||
|
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;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ui: Record<Lang, UI> = {
|
export const ui: Record<Lang, UI> = {
|
||||||
@@ -152,6 +191,39 @@ export const ui: Record<Lang, UI> = {
|
|||||||
join: "How to Join",
|
join: "How to Join",
|
||||||
disc: "Not affiliated with Mojang or Microsoft. Minecraft is a trademark of Mojang AB.",
|
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.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
es: {
|
es: {
|
||||||
@@ -238,6 +310,39 @@ export const ui: Record<Lang, UI> = {
|
|||||||
join: "Cómo entrar",
|
join: "Cómo entrar",
|
||||||
disc: "No afiliado a Mojang ni Microsoft. Minecraft es una marca de Mojang AB.",
|
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.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
eu: {
|
eu: {
|
||||||
@@ -324,5 +429,38 @@ export const ui: Record<Lang, UI> = {
|
|||||||
join: "Nola sartu",
|
join: "Nola sartu",
|
||||||
disc: "Ez dago Mojang edo Microsoft-ekin lotuta. Minecraft Mojang AB-ren marka da.",
|
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.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ui, LANGS, LANG_LABEL, LANG_PATH, type Lang } from "../i18n/ui";
|
|||||||
import Creeper from "../components/Creeper.astro";
|
import Creeper from "../components/Creeper.astro";
|
||||||
import PixelIcon from "../components/PixelIcon.astro";
|
import PixelIcon from "../components/PixelIcon.astro";
|
||||||
import CopyChip from "../components/CopyChip.astro";
|
import CopyChip from "../components/CopyChip.astro";
|
||||||
import ServerListPanel from "../components/ServerListPanel.astro";
|
import ServerCard from "../components/ServerCard.astro";
|
||||||
import "../styles/main.css";
|
import "../styles/main.css";
|
||||||
|
|
||||||
// One static page per locale: en at "/", es at "/es/", eu at "/eu/".
|
// One static page per locale: en at "/", es at "/es/", eu at "/eu/".
|
||||||
@@ -108,7 +108,7 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-status">
|
<div class="hero-status">
|
||||||
<ServerListPanel
|
<ServerCard
|
||||||
modded={t.status.modded}
|
modded={t.status.modded}
|
||||||
motd={t.status.motd}
|
motd={t.status.motd}
|
||||||
upTo={t.status.upTo}
|
upTo={t.status.upTo}
|
||||||
@@ -126,7 +126,7 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
|||||||
<p class="lead">{t.status.lead}</p>
|
<p class="lead">{t.status.lead}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="reveal">
|
<div class="reveal">
|
||||||
<ServerListPanel
|
<ServerCard
|
||||||
modded={t.status.modded}
|
modded={t.status.modded}
|
||||||
motd={t.status.motd}
|
motd={t.status.motd}
|
||||||
upTo={t.status.upTo}
|
upTo={t.status.upTo}
|
||||||
|
|||||||
310
landing/src/pages/[...lang]/register.astro
Normal file
310
landing/src/pages/[...lang]/register.astro
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
---
|
||||||
|
import { site, HEAD_FONTS } from "../../data/site";
|
||||||
|
import {
|
||||||
|
ui,
|
||||||
|
LANGS,
|
||||||
|
LANG_LABEL,
|
||||||
|
LANG_PATH,
|
||||||
|
LANG_REGISTER_PATH,
|
||||||
|
type Lang,
|
||||||
|
} from "../../i18n/ui";
|
||||||
|
import Creeper from "../../components/Creeper.astro";
|
||||||
|
import "../../styles/main.css";
|
||||||
|
|
||||||
|
// One static register page per locale: /register, /es/register, /eu/register.
|
||||||
|
export function getStaticPaths() {
|
||||||
|
return [
|
||||||
|
{ params: { lang: undefined }, props: { locale: "en" as Lang } },
|
||||||
|
{ params: { lang: "es" }, props: { locale: "es" 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 requiresInvite = site.registrationRequiresInvite;
|
||||||
|
|
||||||
|
// Strings the client script needs at runtime (kept minimal, passed via define:vars).
|
||||||
|
const clientCfg = {
|
||||||
|
apiUrl: site.draslApiUrl,
|
||||||
|
requiresInvite,
|
||||||
|
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="/logo.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}>
|
||||||
|
<Creeper />
|
||||||
|
<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 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>
|
||||||
|
{requiresInvite && (
|
||||||
|
<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={site.authUrl}>{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}>
|
||||||
|
<Creeper />
|
||||||
|
<span class="name">ULICRAFT</span>
|
||||||
|
</a>
|
||||||
|
<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.requiresInvite && !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>
|
||||||
@@ -588,6 +588,64 @@ section { position: relative; z-index: 1; }
|
|||||||
.reveal.anim { animation: reveal-in .6s cubic-bezier(.2,.7,.3,1) both; }
|
.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
|
RESPONSIVE
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# 10 — Uptime Kuma
|
# 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`
|
Status monitoring + public status page. Service `uptime-kuma`
|
||||||
(`louislam/uptime-kuma:1`), web UI on `:3001`, reached only through caddy at
|
(`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
|
`https://status.${BASE_DOMAIN}`. Joined to `mcnet`, so it can probe every other
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ Dependency-ordered. Each numbered item = one git commit. Operational steps
|
|||||||
## Phase 5 — landing
|
## Phase 5 — landing
|
||||||
10. `feat(landing): Astro+TS onboarding site → www/`
|
10. `feat(landing): Astro+TS onboarding site → www/`
|
||||||
11. `chore(landing): wire logo + .gitignore for 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
|
## Phase 6 — launcher distribution
|
||||||
12. `feat(tooling): add fetch-launcher.sh (all platforms + stable symlinks)`
|
12. `feat(tooling): add fetch-launcher.sh (all platforms + stable symlinks)`
|
||||||
@@ -49,8 +52,9 @@ Dependency-ordered. Each numbered item = one git commit. Operational steps
|
|||||||
|
|
||||||
## Phase 10 — operational (no commits)
|
## Phase 10 — operational (no commits)
|
||||||
- [ops] Prep online (once): `tooling/render-config.sh`, build landing
|
- [ops] Prep online (once): `tooling/render-config.sh`, build landing
|
||||||
(`cd landing && pnpm run build`), `tooling/fetch-launcher.sh`, fetch
|
(`cd landing && set -a && . ../.env && set +a && pnpm run build` — sources
|
||||||
authlib-injector into `runtime/`.
|
`.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
|
- [ops] TLS: `tooling/issue-letsencrypt.sh`, then
|
||||||
`tooling/render-nginx.sh --install`.
|
`tooling/render-nginx.sh --install`.
|
||||||
- [ops] Bring up: `docker compose up -d --build`.
|
- [ops] Bring up: `docker compose up -d --build`.
|
||||||
|
|||||||
@@ -37,6 +37,21 @@ ssh cochi 'set -e
|
|||||||
`render-config.sh` re-renders configs (and `render-pack.sh` for the packwiz
|
`render-config.sh` re-renders configs (and `render-pack.sh` for the packwiz
|
||||||
pack) from `.env` before the stack comes back up.
|
pack) from `.env` before the stack comes back up.
|
||||||
|
|
||||||
|
### Landing rebuild (not part of `compose`)
|
||||||
|
|
||||||
|
The landing site (incl. `/register`) is static `www/`, gitignored — a `git pull`
|
||||||
|
does **not** update it. Rebuild on the host whenever `landing/` changed or you
|
||||||
|
toggled `REGISTRATION_MODE` (it bakes the invite-field on/off at build, and Drasl's
|
||||||
|
`RequireInvite` is derived from the same key by `render-config.sh`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
( cd landing && set -a && . ../.env && set +a && pnpm run build ) # → www/
|
||||||
|
docker compose restart drasl # apply CORS / RateLimit / RequireInvite
|
||||||
|
```
|
||||||
|
|
||||||
|
`down`/`up` is not needed for a landing-only or flag-only change — rebuild `www/`
|
||||||
|
and restart `drasl`.
|
||||||
|
|
||||||
## What survives a hard restart
|
## What survives a hard restart
|
||||||
|
|
||||||
Named volumes persist — world and auth data are safe:
|
Named volumes persist — world and auth data are safe:
|
||||||
@@ -52,8 +67,9 @@ restart (a few minutes); they reconnect once `minecraft` is healthy again.
|
|||||||
|
|
||||||
- SSH access as the `cochi` alias.
|
- SSH access as the `cochi` alias.
|
||||||
- `.env` present and complete: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT`,
|
- `.env` present and complete: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT`,
|
||||||
`CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`, and the Let's Encrypt block
|
`CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`, `REGISTRATION_MODE` (`invite`|`open`,
|
||||||
(`render-config.sh` halts on an unset `BASE_DOMAIN`).
|
defaults `invite`), and the Let's Encrypt block (`render-config.sh` halts on an
|
||||||
|
unset `BASE_DOMAIN` or an invalid `REGISTRATION_MODE`).
|
||||||
- Docker + compose plugin, `packwiz`, `envsubst`, `git`.
|
- Docker + compose plugin, `packwiz`, `envsubst`, `git`.
|
||||||
- Host nginx + Let's Encrypt certs installed once — see `15-letsencrypt.md`.
|
- Host nginx + Let's Encrypt certs installed once — see `15-letsencrypt.md`.
|
||||||
- **First deploy only** — online prep (build landing, fetch launcher, fetch
|
- **First deploy only** — online prep (build landing, fetch launcher, fetch
|
||||||
|
|||||||
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>
|
||||||
|
```
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# render-config.sh — turn *.tmpl files into real configs via envsubst.
|
# render-config.sh — turn *.tmpl files into real configs via envsubst.
|
||||||
# Substitutes ONLY ${BASE_DOMAIN} so literal $-syntax that belongs to the target
|
# Substitutes ONLY a small whitelist so literal $-syntax that belongs to the
|
||||||
# file (caddy, toml) is left untouched.
|
# target file (caddy, toml) is left untouched.
|
||||||
# Halts on any unset var or missing template (cautious: no half-rendered output).
|
# Halts on any unset var or missing template (cautious: no half-rendered output).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -10,9 +10,19 @@ cd "$(dirname "$0")/.." # repo root
|
|||||||
[ -f .env ] && { set -a; . ./.env; set +a; }
|
[ -f .env ] && { set -a; . ./.env; set +a; }
|
||||||
: "${BASE_DOMAIN:?BASE_DOMAIN unset (set in .env)}"
|
: "${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
|
render() { # $1=template $2=output
|
||||||
[ -f "$1" ] || { echo "missing template: $1" >&2; exit 1; }
|
[ -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"
|
echo "rendered $2"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user