Vellum/internal/modules/games/handlers.go
mithral b82090ed29 Phase 7: games — ROM scanning, platforms, cloud saves, EmulatorJS
The RomM replacement.

- registry: a games media type with ~35 ROM extensions across NES, SNES, GB/GBA,
  N64, NDS, Genesis/Master System/Game Gear, PC Engine, Neo Geo Pocket,
  WonderSwan, Atari, Coleco, Intellivision, and PlayStation.
- games: platform detection from the file extension, mapping each to an
  EmulatorJS core. Platforms with no browser core are recognised but marked
  not-playable so the UI offers a download instead of a broken player.
- migration 0005: game_saves keyed by (user, item, slot). Save states are user
  data, not media — two people playing the same ROM keep their own progress.
- save endpoints with a 16 MiB cap and a validated slot name; the upload is
  read through a LimitReader so an unbounded body is refused rather than
  buffered.
- igdb provider: Twitch client-credentials flow with the token cached for its
  ~2 month lifetime, Apicalypse query body, and cover URLs normalised from
  protocol-relative t_thumb to https t_cover_big.
- searchTitle: strips "(USA)", "[!]", "(Rev 1)" and similar release tags before
  searching. "Sonic the Hedgehog (USA)" matched nothing; "Sonic the Hedgehog"
  matches immediately. Stored titles keep the original.
- web: GamePlayer embeds EmulatorJS and adds Load/Save-to-server buttons.

Verified live against IGDB: Sonic matched (id 316900) with real cover art; a
4 KB save uploaded, listed, downloaded byte-identical, and deleted.
2026-10-09 21:54:49 -04:00

209 lines
5.7 KiB
Go

package games
import (
"database/sql"
"errors"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/httpx"
"github.com/mithral/vellum/internal/media"
)
// Handlers serves game-specific endpoints.
type Handlers struct {
db *sql.DB
log *slog.Logger
auth *auth.Authenticator
}
// NewHandlers builds the games HTTP layer.
func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator) *Handlers {
return &Handlers{db: db, log: log, auth: a}
}
// Register mounts the routes.
func (h *Handlers) Register(mux *http.ServeMux) {
mux.Handle("GET /api/v1/items/{id}/game", h.auth.Require(h.handleGame))
mux.Handle("GET /api/v1/items/{id}/saves", h.auth.Require(h.handleListSaves))
mux.Handle("GET /api/v1/items/{id}/saves/{slot}", h.auth.Require(h.handleGetSave))
mux.Handle("PUT /api/v1/items/{id}/saves/{slot}", h.auth.Require(h.handlePutSave))
mux.Handle("DELETE /api/v1/items/{id}/saves/{slot}", h.auth.Require(h.handleDeleteSave))
}
// handleGame reports the platform and which emulator core to load.
func (h *Handlers) handleGame(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
platform, found := PlatformFor(it.Path)
saves, err := ListSaves(r.Context(), h.db, u.ID, it.ID)
if err != nil {
h.fail(w, "list saves", err)
return
}
resp := map[string]any{
"item_id": it.ID,
"saves": saves,
}
if found {
resp["platform"] = platform.Name
resp["core"] = platform.Core
resp["playable"] = platform.Playable()
} else {
resp["platform"] = ""
resp["core"] = ""
resp["playable"] = false
}
httpx.WriteJSON(w, http.StatusOK, resp)
}
func (h *Handlers) handleListSaves(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
saves, err := ListSaves(r.Context(), h.db, u.ID, it.ID)
if err != nil {
h.fail(w, "list saves", err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"saves": saves})
}
func (h *Handlers) handleGetSave(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
slot, ok := slotName(w, r)
if !ok {
return
}
data, found, err := GetSave(r.Context(), h.db, u.ID, it.ID, slot)
if err != nil {
h.fail(w, "get save", err)
return
}
if !found {
httpx.WriteError(w, http.StatusNotFound, "no save in that slot")
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
_, _ = w.Write(data)
}
func (h *Handlers) handlePutSave(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
slot, ok := slotName(w, r)
if !ok {
return
}
// Bounded read: refuse to buffer an unbounded body.
data, err := io.ReadAll(io.LimitReader(r.Body, MaxSaveBytes+1))
if err != nil {
httpx.WriteError(w, http.StatusBadRequest, "could not read the save")
return
}
if len(data) == 0 {
httpx.WriteError(w, http.StatusBadRequest, "save is empty")
return
}
if len(data) > MaxSaveBytes {
httpx.WriteError(w, http.StatusRequestEntityTooLarge, "save is too large")
return
}
if err := PutSave(r.Context(), h.db, u.ID, it.ID, slot, data); err != nil {
h.fail(w, "put save", err)
return
}
h.log.Info("save stored", "item", it.ID, "slot", slot, "bytes", len(data), "user", u.Username)
httpx.WriteJSON(w, http.StatusOK, map[string]any{"slot": slot, "size": len(data)})
}
func (h *Handlers) handleDeleteSave(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
slot, ok := slotName(w, r)
if !ok {
return
}
if err := DeleteSave(r.Context(), h.db, u.ID, it.ID, slot); err != nil {
h.fail(w, "delete save", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ── helpers ────────────────────────────────────────────────────────────
// slotName validates the slot path segment. Slots are user-visible labels, so
// they are restricted to a safe character set rather than trusted as-is.
func slotName(w http.ResponseWriter, r *http.Request) (string, bool) {
slot := strings.TrimSpace(r.PathValue("slot"))
if slot == "" {
slot = "default"
}
if len(slot) > 64 {
httpx.WriteError(w, http.StatusBadRequest, "slot name is too long")
return "", false
}
for _, c := range slot {
if !(c == '-' || c == '_' || c == '.' ||
(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
httpx.WriteError(w, http.StatusBadRequest, "slot name has invalid characters")
return "", false
}
}
return slot, true
}
func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.User) (media.Item, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
httpx.WriteError(w, http.StatusBadRequest, "invalid item id")
return media.Item{}, false
}
it, err := media.LoadItem(r.Context(), h.db, id)
if errors.Is(err, media.ErrItemNotFound) {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return media.Item{}, false
}
if err != nil {
h.fail(w, "load item", err)
return media.Item{}, false
}
allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, it.LibraryID)
if err != nil || !allowed {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return media.Item{}, false
}
if it.Kind != Kind {
httpx.WriteError(w, http.StatusBadRequest, "item is not a game")
return media.Item{}, false
}
return it, true
}
func (h *Handlers) fail(w http.ResponseWriter, what string, err error) {
h.log.Error(what, "err", err)
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
}