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.
209 lines
5.7 KiB
Go
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")
|
|
}
|