package api import ( "context" "log/slog" "net/http" "strconv" "strings" "time" "codeberg.org/snonux/player/internal" "codeberg.org/snonux/player/internal/auth" "codeberg.org/snonux/player/internal/repository" "codeberg.org/snonux/player/internal/service" ) // Server holds HTTP handlers and dependencies. type Server struct { store repository.Store hasher auth.Hasher sm auth.SessionManager cfg *internal.Config mux *http.ServeMux handler http.Handler browseSvc service.MediaBrowseService writeSvc service.MediaWriteService shareSvc service.MediaShareService tagSvc service.MediaTagService favSvc service.MediaFavoriteService noteSvc service.MediaNoteService adminSvc service.AdminService progressSvc service.ProgressService authSvc service.AuthService podcastSvc service.PodcastEpisodeService playbackHintSvc service.PlaybackHintsService streamer service.MediaStreamer staticFS http.FileSystem logger *slog.Logger mw *Middleware } // ServerServices groups the optional service dependencies used by route handlers. // If any service is nil, its respective routes return 501. type ServerServices struct { Browse service.MediaBrowseService Write service.MediaWriteService Share service.MediaShareService Tag service.MediaTagService Favorite service.MediaFavoriteService Note service.MediaNoteService Admin service.AdminService Progress service.ProgressService Auth service.AuthService Podcast service.PodcastEpisodeService PlaybackHints service.PlaybackHintsService } // ServerDeps contains the dependencies needed to construct a Server. type ServerDeps struct { Store repository.Store Hasher auth.Hasher SessionManager auth.SessionManager Config *internal.Config Services ServerServices StaticFS http.FileSystem MediaStreamer service.MediaStreamer } // NewServer creates a Server with routes. func NewServer(deps ServerDeps) *Server { return NewServerWithLogger(deps, slog.Default()) } // NewServerWithLogger creates a Server with routes and an injected logger. func NewServerWithLogger(deps ServerDeps, logger *slog.Logger) *Server { if deps.Config == nil { panic("api.NewServerWithLogger: Config is nil") } if deps.StaticFS == nil { deps.StaticFS = http.Dir("web") } if logger == nil { logger = slog.Default() } s := &Server{ store: deps.Store, hasher: deps.Hasher, sm: deps.SessionManager, cfg: deps.Config, mux: http.NewServeMux(), browseSvc: deps.Services.Browse, writeSvc: deps.Services.Write, shareSvc: deps.Services.Share, tagSvc: deps.Services.Tag, favSvc: deps.Services.Favorite, noteSvc: deps.Services.Note, adminSvc: deps.Services.Admin, progressSvc: deps.Services.Progress, authSvc: deps.Services.Auth, podcastSvc: deps.Services.Podcast, playbackHintSvc: deps.Services.PlaybackHints, streamer: deps.MediaStreamer, staticFS: deps.StaticFS, logger: logger, mw: NewMiddleware(deps.Services.Auth, deps.SessionManager), } s.routes() s.handler = withCORS(s.cfg.CORSAllowedOrigins, s.mw.BootstrapRedirect(s.mux)) return s } // ServeHTTP implements http.Handler. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handler.ServeHTTP(w, r) } // requireSession wraps a handler with the session requirement middleware. func (s *Server) requireSession(h http.HandlerFunc) http.HandlerFunc { return s.mw.RequireSession(h).(http.HandlerFunc) } // requireAdmin wraps a handler with both session and admin middleware. func (s *Server) requireAdmin(h http.HandlerFunc) http.HandlerFunc { return s.mw.RequireSession(s.mw.RequireAdmin(h)).(http.HandlerFunc) } // publicMethod wraps a handler so only the given HTTP method is allowed; // other methods yield 405 instead of falling through. func publicMethod(method string, handler http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != method { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } handler(w, r) } } func (s *Server) handleBoth(method, path string, h http.Handler) { s.mux.Handle(method+" "+path, h) s.mux.Handle(method+" "+apiV1Path(path), h) } func apiV1Path(path string) string { const apiPrefix = "/api/" if !strings.HasPrefix(path, apiPrefix) { panic("apiV1Path: path must start with /api/") } return "/api/v1/" + strings.TrimPrefix(path, apiPrefix) } // routesPublic wires the fully-public API endpoints (bootstrap, login, probes). func (s *Server) routesPublic() { s.mux.HandleFunc("/api/bootstrap", publicMethod(http.MethodPost, s.handleBootstrap)) s.mux.HandleFunc("/api/v1/auth/bootstrap", publicMethod(http.MethodPost, s.handleBootstrap)) s.mux.HandleFunc("/api/login", publicMethod(http.MethodPost, s.handleLogin)) s.mux.HandleFunc("/api/v1/auth/login", publicMethod(http.MethodPost, s.handleLogin)) s.mux.HandleFunc("/healthz", publicMethod(http.MethodGet, s.handleHealthz)) s.mux.HandleFunc("/readyz", publicMethod(http.MethodGet, s.handleReadyz)) } // routesSharePublic wires public share routes (no session required). func (s *Server) routesSharePublic() { s.mux.HandleFunc("GET /s/{token}", s.handleSharePage) s.mux.HandleFunc("GET /s/{token}/stream", s.handleShareStream) s.mux.HandleFunc("GET /s/{token}/thumbnail", s.handleShareThumbnail) s.mux.HandleFunc("GET /s/{token}/download", s.handleShareDownload) } // routesStatic wires static CSS/JS asset serving. func (s *Server) routesStatic() { staticHandler := http.FileServer(s.staticFS) s.mux.Handle("/css/", staticHandler) s.mux.Handle("/js/", staticHandler) s.mux.Handle("/logo.png", staticHandler) s.mux.Handle("/logo.svg", staticHandler) s.mux.Handle("/favicon.ico", staticHandler) s.mux.Handle("/favicon.svg", staticHandler) s.mux.Handle("/manifest.json", staticHandler) s.mux.Handle("/sw.js", staticHandler) } // routesHTML wires the SPA HTML page routes. func (s *Server) routesHTML() { s.mux.Handle("/login.html", http.HandlerFunc(s.serveLogin)) s.mux.Handle("/bootstrap.html", http.HandlerFunc(s.serveBootstrap)) s.mux.Handle("/", s.mw.RequireSession(http.HandlerFunc(s.serveIndex))) s.mux.Handle("GET /index.html", s.mw.RequireSession(http.HandlerFunc(s.serveIndex))) s.mux.Handle("GET /detach.html", s.mw.RequireSession(http.HandlerFunc(s.serveDetach))) } // routesAuth wires the logout route. func (s *Server) routesAuth() { s.handleBoth(http.MethodPost, "/api/logout", s.requireSession(s.handleLogout)) s.handleBoth(http.MethodPost, "/api/auth/tokens", s.requireSession(s.handleCreateAPIToken)) s.handleBoth(http.MethodGet, "/api/auth/tokens", s.requireSession(s.handleListAPITokens)) s.handleBoth(http.MethodDelete, "/api/auth/tokens/{id}", s.requireSession(s.handleRevokeAPIToken)) } // routesConfig wires authenticated client configuration. func (s *Server) routesConfig() { s.handleBoth(http.MethodGet, "/api/config", s.requireSession(s.handleConfig)) } // routesSets wires the set-related API routes. func (s *Server) routesSets() { s.handleBoth(http.MethodGet, "/api/sets", s.requireSession(s.handleListSets)) s.handleBoth(http.MethodGet, "/api/sets/{id}/browse", s.requireSession(s.handleBrowseSet)) s.handleBoth(http.MethodGet, "/api/sets/{id}/cover", s.requireSession(s.handleGetSetCover)) s.handleBoth(http.MethodPost, "/api/sets/{id}/cover", s.requireSession(s.handlePostSetCover)) s.handleBoth(http.MethodPost, "/api/sets/{id}/upload", s.requireSession(s.handleUpload)) } // routesMedia wires the media-related API routes. func (s *Server) routesMedia() { s.handleBoth(http.MethodGet, "/api/media", s.requireSession(s.handleListMedia)) s.handleBoth(http.MethodGet, "/api/media/{id}", s.requireSession(s.handleGetMedia)) s.handleBoth(http.MethodGet, "/api/media/{id}/stream", s.requireSession(s.handleStream)) s.handleBoth(http.MethodGet, "/api/media/{id}/download", s.requireSession(s.handleDownload)) s.handleBoth(http.MethodGet, "/api/media/{id}/thumbnail", s.requireSession(s.handleThumbnail)) s.handleBoth(http.MethodPost, "/api/media/{id}/thumbnail", s.requireSession(s.handleRegenThumbnail)) s.handleBoth(http.MethodPost, "/api/media/{id}/favorite", s.requireSession(s.handleFavorite)) s.handleBoth(http.MethodGet, "/api/tags", s.requireSession(s.handleListTags)) s.handleBoth(http.MethodPost, "/api/media/{id}/tags", s.requireSession(s.handleAddTag)) s.handleBoth(http.MethodDelete, "/api/media/{id}/tags/{tag}", s.requireSession(s.handleRemoveTag)) s.handleBoth(http.MethodDelete, "/api/media/{id}", s.requireSession(s.handleSoftDelete)) s.handleBoth(http.MethodPost, "/api/media/{id}/restore", s.requireSession(s.handleRestore)) s.handleBoth(http.MethodPost, "/api/media/{id}/shares", s.requireSession(s.handleCreateShare)) s.handleBoth(http.MethodGet, "/api/media/{id}/shares", s.requireSession(s.handleListShares)) s.handleBoth(http.MethodGet, "/api/media/{id}/playback", s.requireSession(s.handlePlaybackHints)) } // routesNotes wires the notes API routes. func (s *Server) routesNotes() { s.handleBoth(http.MethodGet, "/api/media/{id}/notes", s.requireSession(s.handleGetNote)) s.handleBoth(http.MethodPost, "/api/media/{id}/notes", s.requireSession(s.handleUpsertNote)) s.handleBoth(http.MethodDelete, "/api/media/{id}/notes", s.requireSession(s.handleDeleteNote)) } // routesProgress wires the progress API routes. func (s *Server) routesProgress() { s.handleBoth(http.MethodPost, "/api/progress", s.requireSession(s.handleProgress)) s.handleBoth(http.MethodPost, "/api/progress/batch", s.requireSession(s.handleBatchProgress)) s.handleBoth(http.MethodPost, "/api/progress/status", s.requireSession(s.handleProgressStatus)) s.handleBoth(http.MethodGet, "/api/in-progress", s.requireSession(s.handleInProgress)) } // routesShares wires the share-management API routes. func (s *Server) routesShares() { s.handleBoth(http.MethodDelete, "/api/shares/{token}", s.requireSession(s.handleRevokeShare)) s.handleBoth(http.MethodGet, "/api/shares", s.requireSession(s.handleMyShares)) } // routesAdmin wires the admin-only API routes. func (s *Server) routesAdmin() { s.handleBoth(http.MethodGet, "/api/admin/trash", s.requireAdmin(s.handleListTrash)) s.handleBoth(http.MethodPost, "/api/admin/rescan", s.requireAdmin(s.handleRescan)) s.handleBoth(http.MethodGet, "/api/admin/scan-progress", s.requireAdmin(s.handleScanProgress)) s.handleBoth(http.MethodGet, "/api/admin/users", s.requireAdmin(s.handleListUsers)) s.handleBoth(http.MethodPost, "/api/admin/users", s.requireAdmin(s.handleCreateUser)) s.handleBoth(http.MethodDelete, "/api/admin/users/{id}", s.requireAdmin(s.handleDeleteUser)) s.handleBoth(http.MethodGet, "/api/admin/permissions", s.requireAdmin(s.handleListPermissions)) s.handleBoth(http.MethodPost, "/api/admin/permissions", s.requireAdmin(s.handleGrantPermission)) s.handleBoth(http.MethodDelete, "/api/admin/permissions", s.requireAdmin(s.handleRevokePermission)) } func (s *Server) routes() { s.routesPublic() s.routesSharePublic() s.routesStatic() s.routesHTML() s.routesAuth() s.routesConfig() s.routesSets() s.routesMedia() s.routesNotes() s.routesProgress() s.routesShares() s.routesAdmin() s.routesPodcast() } // routesPodcast wires the podcast API routes. func (s *Server) routesPodcast() { s.handleBoth(http.MethodGet, "/api/podcasts", s.requireSession(s.handleListPodcasts)) s.handleBoth(http.MethodPost, "/api/podcasts", s.requireAdmin(s.handleSubscribePodcast)) s.handleBoth(http.MethodGet, "/api/podcasts/{id}/episodes", s.requireSession(s.handleListEpisodes)) s.handleBoth(http.MethodPost, "/api/podcasts/episodes/{episode_id}/download", s.requireSession(s.handleDownloadEpisode)) s.handleBoth(http.MethodPost, "/api/podcasts/episodes/{episode_id}/complete", s.requireSession(s.handleToggleComplete)) } func (s *Server) pingStore(ctx context.Context) error { type pinger interface { Ping(ctx context.Context) error } if p, ok := s.store.(pinger); ok { return p.Ping(ctx) } return nil } // GracefulServer wraps an http.Server with graceful shutdown support. type GracefulServer struct { Server *http.Server } // NewGracefulServer creates a GracefulServer with the given handler and config. func NewGracefulServer(handler http.Handler, cfg *internal.Config) *GracefulServer { return &GracefulServer{ Server: &http.Server{ Addr: addrFromPort(cfg.Port), Handler: handler, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, }, } } func addrFromPort(port int) string { return ":" + strconv.Itoa(port) }