package games import ( "database/sql" "errors" "io" "log/slog" "net/http" "strconv" "strings" "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/catalog" "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 } // authorized loads an item, confirms access, and checks it is a game. // // The load and the access check both live in catalog.LoadFor; this package no // longer reimplements "may this user read this item". 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 := catalog.LoadFor(r.Context(), h.db, u, id) if errors.Is(err, catalog.ErrNotFound) { 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 } 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") }