Files
Felis/internal/panel/panel.go
T
flyemoji 0c1cc598c1 feat(auth): migrate console login to passwordless
Replace console password auth with a passwordless surface — the pre-session
login doors plus an identifier-first discovery endpoint — and remove the
password paths.

- Login doors (Public, pre-session): email-OTP, passkey assertion, op.console
  login with in-game approval, and setup-token redeem.
- /api/v1/auth/options: identifier-first discovery reporting which console
  methods an email can use. The single sanctioned existence oracle; methods
  are computed with no role branch, so staff and player accounts in the same
  credential state return byte-identical bodies (staffness invisible by
  construction).
- Remove password auth: drop StaffUser.PasswordHash and the /auth/login,
  /auth/change-password and /users/{id}/reset-password endpoints (and test).
- Data layer: UserByEmail, verified-email uniqueness, setup-token store
  (migration 0012).
- Reconcile docs/openapi.yaml with the served surface; the method/path/face/
  tier parity gate (TestOpenAPIMatchesServedRoutes) passes.
- felis TUI: in-game MC bind, owner/break-glass OP provisioning, version.
- Velocity /felis command suite.

Consolidates the accumulated backend migration work; the frontend (panel/)
is left untouched. Full Go tree green on WSL (go build ./... && go test ./...).
2026-07-04 21:47:12 +09:00

180 lines
6.0 KiB
Go

// Package panel serves the embedded Felis control panel next to the external API.
package panel
import (
"bytes"
"embed"
"encoding/json"
"errors"
"io"
"io/fs"
"net/http"
"path"
"regexp"
"strings"
)
//go:embed static
var static embed.FS
type runtimeConfig struct {
APIBase string `json:"apiBase"`
RootDomain string `json:"rootDomain"`
PanelHostname string `json:"panelHostname,omitempty"`
AdminHostname string `json:"adminHostname,omitempty"`
Build buildInfo `json:"build"`
}
// buildInfo is the resolved build stamp the panel renders in its version badge.
// It is derived once, server-side, from the binary's `main.version` (a
// `git describe --tags --always --dirty` string) so the panel needs no brittle
// string parsing — it just renders `Release`, appending `+Commit` when `Dev`.
type buildInfo struct {
// Version is the raw resolved stamp (e.g. "v1.0.0-earlyAccess-3-g1a2b3c4").
Version string `json:"version"`
// Release is the "big version" — the newest tag with any git-describe suffix
// stripped (e.g. "v1.0.0-earlyAccess"). It is what the release channel shows.
Release string `json:"release"`
// Commit is the short commit the dev channel was built from (e.g. "1a2b3c4"),
// empty for a clean release build.
Commit string `json:"commit,omitempty"`
// Dev is true for a non-release build — the dev channel (main past the tag), an
// untagged/bare-SHA build, a dirty tree, or an un-stamped local `go build`.
Dev bool `json:"dev"`
}
// describeSuffix matches the trailing "-<commits>-g<sha>" that `git describe`
// appends to the newest tag once HEAD is past it — the shape the dev channel
// (main HEAD) produces. The release channel builds the exact tag, so its stamp
// carries no such suffix. Match is anchored at end so a tag whose prerelease part
// itself contains hyphens (v1.0.0-earlyAccess) keeps that part in Release.
var describeSuffix = regexp.MustCompile(`-([0-9]+)-g([0-9a-f]+)$`)
// releaseTag matches a clean released semantic-version tag (vMAJOR.MINOR.PATCH
// with an optional -prerelease), i.e. the name the release channel builds. It is
// permissive on the prerelease so tags like v1.0.0-earlyAccess qualify.
var releaseTag = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$`)
// parseBuildVersion splits a `git describe` build stamp into the fields the panel
// version badge renders. Cases: a dev stamp "<tag>-N-gSHA" → Release=<tag>,
// Commit=SHA, Dev=true; a clean release tag "vX.Y.Z[-pre]" → Release=tag, Dev=false;
// anything else ("dev", "unknown", a bare short SHA, a dirty tree) → best-effort
// Release with Dev=true.
func parseBuildVersion(raw string) buildInfo {
raw = strings.TrimSpace(raw)
if raw == "" {
raw = "dev"
}
bi := buildInfo{Version: raw}
core := strings.TrimSuffix(raw, "-dirty")
dirty := core != raw
switch {
case describeSuffix.MatchString(core):
m := describeSuffix.FindStringSubmatch(core)
bi.Release = core[:len(core)-len(m[0])]
bi.Commit = m[2]
bi.Dev = true
case releaseTag.MatchString(core) && !dirty:
bi.Release = core
bi.Dev = false
default:
bi.Release = core
bi.Dev = true
}
return bi
}
// Handler wraps the external API handler with the panel SPA. version is the
// binary's resolved build stamp (cmd/felis resolvedVersion()); it is surfaced to
// the SPA via /config.json for the version badge. panelHost and adminHost are the
// configured console.<root_domain> and op.console.<root_domain> hostnames (either
// may be empty when that face is not deployed); they let the SPA detect which home
// it is being served from by comparing location.host, so one bundle can render the
// right surface (player console vs SysAdmin console) without a rebuild.
func Handler(api http.Handler, rootDomain, panelHost, adminHost, version string) http.Handler {
files, err := fs.Sub(static, "static")
if err != nil {
panic(err)
}
return &handler{
api: api,
rootDomain: rootDomain,
panelHostname: panelHost,
adminHostname: adminHost,
build: parseBuildVersion(version),
files: files,
fileServer: http.FileServer(http.FS(files)),
}
}
type handler struct {
api http.Handler
rootDomain string
panelHostname string
adminHostname string
build buildInfo
files fs.FS
fileServer http.Handler
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// WeChat/QQ in-app browsers cannot run WebAuthn, so steer their document
// navigations to a "open in your system browser" interstitial before the SPA
// (which is built around passkey enrollment) ever loads. See webview.go.
if guardInAppWebView(w, r) {
return
}
switch {
case r.URL.Path == "/healthz" || r.URL.Path == "/readyz" || strings.HasPrefix(r.URL.Path, "/api/"):
h.api.ServeHTTP(w, r)
case r.URL.Path == "/config.json":
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(runtimeConfig{
APIBase: "/api/v1",
RootDomain: h.rootDomain,
PanelHostname: h.panelHostname,
AdminHostname: h.adminHostname,
Build: h.build,
})
case h.hasStaticFile(r.URL.Path):
h.fileServer.ServeHTTP(w, r)
default:
h.serveIndex(w, r)
}
}
func (h *handler) hasStaticFile(urlPath string) bool {
name := strings.TrimPrefix(path.Clean("/"+urlPath), "/")
if name == "" {
name = "index.html"
}
info, err := fs.Stat(h.files, name)
return err == nil && !info.IsDir()
}
func (h *handler) serveIndex(w http.ResponseWriter, r *http.Request) {
f, err := h.files.Open("index.html")
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
http.Error(w, "panel assets missing", http.StatusServiceUnavailable)
return
}
http.Error(w, "open panel index", http.StatusInternalServerError)
return
}
defer f.Close()
info, err := f.Stat()
if err != nil {
http.Error(w, "stat panel index", http.StatusInternalServerError)
return
}
body, err := io.ReadAll(f)
if err != nil {
http.Error(w, "read panel index", http.StatusInternalServerError)
return
}
http.ServeContent(w, r, "index.html", info.ModTime(), bytes.NewReader(body))
}