feat(landing): join-flow rework, player rosters, account page, mod list
Execute plan/18 (WS1-6) + plan/17.
- WS1: homepage join = 3 steps off packwiz (register -> UlicraftLauncher
-> join); per-OS downloads from a synced launcher-manifest.json (schema +
example committed); Fjord moved to its own /fjord page. Drop packwizUrl.
- WS2/3: mc-status gains an isolated /players endpoint (admin login ->
Drasl GET /players, own client+TTL+token cache, never blocks the status
path); online + registered rosters render shared name+avatar tiles;
AVATAR_MODE env (default headiso).
- WS4: /account skin CRUD via browser-direct Drasl (login -> upload ->
reset), in-memory token, players[0]; register relinks "Manage it here".
- WS5: footer link to status.${BASE_DOMAIN} in every footer.
- WS6: tooling/build-modlist.py generates mods.json + extracted logos from
the distribution forgemods; ModList.astro renders a flat list; stat tile
fed from the count.
Manifest/mods loaders read at build with a process.cwd() fallback (the
import.meta.url-only path does not resolve in Astro's bundled build).
New env: AVATAR_MODE, DRASL_ADMIN_USERNAME, DRASL_ADMIN_PASSWORD (mc-status
only). Generated mods.json / launcher-manifest.json / logos are gitignored;
.example.json files document the shapes. Build: 12 pages; mc-status builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -15,8 +15,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -81,6 +83,166 @@ func deref[T any](p *T) (v T) {
|
||||
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")
|
||||
@@ -89,9 +251,25 @@ func main() {
|
||||
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).
|
||||
@@ -119,11 +297,34 @@ func main() {
|
||||
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", listen, host, port, ttl)
|
||||
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))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user