diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-17 15:25:52 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-17 15:25:52 +0300 |
| commit | 914bd7cd6aa14e839332a98d91c30b19865b0cf2 (patch) | |
| tree | 02b11537237a204048589e14ed24938b805e42f7 /player-server/internal/api/server.go | |
| parent | 3b24f0e1be832584d6550e8cc3e5d24329f66f90 (diff) | |
Restructure repo: move Go server into player-server/
Diffstat (limited to 'player-server/internal/api/server.go')
| -rw-r--r-- | player-server/internal/api/server.go | 296 |
1 files changed, 296 insertions, 0 deletions
diff --git a/player-server/internal/api/server.go b/player-server/internal/api/server.go new file mode 100644 index 0000000..7a816c2 --- /dev/null +++ b/player-server/internal/api/server.go @@ -0,0 +1,296 @@ +package api + +import ( + "context" + "log/slog" + "net/http" + "strconv" + "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 + 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 + 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 +} + +// 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, + streamer: deps.MediaStreamer, + staticFS: deps.StaticFS, + logger: logger, + mw: NewMiddleware(deps.Services.Auth, deps.SessionManager), + } + s.routes() + return s +} + +// ServeHTTP implements http.Handler. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.mw.BootstrapRedirect(s.mux).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) + } +} + +// 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/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.mux.Handle("POST /api/logout", s.requireSession(s.handleLogout)) +} + +// routesConfig wires authenticated client configuration. +func (s *Server) routesConfig() { + s.mux.Handle("GET /api/config", s.requireSession(s.handleConfig)) +} + +// routesSets wires the set-related API routes. +func (s *Server) routesSets() { + s.mux.Handle("GET /api/sets", s.requireSession(s.handleListSets)) + s.mux.Handle("GET /api/sets/{id}/browse", s.requireSession(s.handleBrowseSet)) + s.mux.Handle("GET /api/sets/{id}/cover", s.requireSession(s.handleGetSetCover)) + s.mux.Handle("POST /api/sets/{id}/cover", s.requireSession(s.handlePostSetCover)) + s.mux.Handle("POST /api/sets/{id}/upload", s.requireSession(s.handleUpload)) +} + +// routesMedia wires the media-related API routes. +func (s *Server) routesMedia() { + s.mux.Handle("GET /api/media", s.requireSession(s.handleListMedia)) + s.mux.Handle("GET /api/media/{id}", s.requireSession(s.handleGetMedia)) + s.mux.Handle("GET /api/media/{id}/stream", s.requireSession(s.handleStream)) + s.mux.Handle("GET /api/media/{id}/download", s.requireSession(s.handleDownload)) + s.mux.Handle("GET /api/media/{id}/thumbnail", s.requireSession(s.handleThumbnail)) + s.mux.Handle("POST /api/media/{id}/thumbnail", s.requireSession(s.handleRegenThumbnail)) + s.mux.Handle("POST /api/media/{id}/favorite", s.requireSession(s.handleFavorite)) + s.mux.Handle("GET /api/tags", s.requireSession(s.handleListTags)) + s.mux.Handle("POST /api/media/{id}/tags", s.requireSession(s.handleAddTag)) + s.mux.Handle("DELETE /api/media/{id}/tags/{tag}", s.requireSession(s.handleRemoveTag)) + s.mux.Handle("DELETE /api/media/{id}", s.requireSession(s.handleSoftDelete)) + s.mux.Handle("POST /api/media/{id}/restore", s.requireSession(s.handleRestore)) + s.mux.Handle("POST /api/media/{id}/shares", s.requireSession(s.handleCreateShare)) + s.mux.Handle("GET /api/media/{id}/shares", s.requireSession(s.handleListShares)) +} + +// routesNotes wires the notes API routes. +func (s *Server) routesNotes() { + s.mux.Handle("GET /api/media/{id}/notes", s.requireSession(s.handleGetNote)) + s.mux.Handle("POST /api/media/{id}/notes", s.requireSession(s.handleUpsertNote)) + s.mux.Handle("DELETE /api/media/{id}/notes", s.requireSession(s.handleDeleteNote)) +} + +// routesProgress wires the progress API routes. +func (s *Server) routesProgress() { + s.mux.Handle("POST /api/progress", s.requireSession(s.handleProgress)) + s.mux.Handle("POST /api/progress/status", s.requireSession(s.handleProgressStatus)) + s.mux.Handle("GET /api/in-progress", s.requireSession(s.handleInProgress)) +} + +// routesShares wires the share-management API routes. +func (s *Server) routesShares() { + s.mux.Handle("DELETE /api/shares/{token}", s.requireSession(s.handleRevokeShare)) + s.mux.Handle("GET /api/shares", s.requireSession(s.handleMyShares)) +} + +// routesAdmin wires the admin-only API routes. +func (s *Server) routesAdmin() { + s.mux.Handle("GET /api/admin/trash", s.requireAdmin(s.handleListTrash)) + s.mux.Handle("POST /api/admin/rescan", s.requireAdmin(s.handleRescan)) + s.mux.Handle("GET /api/admin/scan-progress", s.requireAdmin(s.handleScanProgress)) + s.mux.Handle("GET /api/admin/users", s.requireAdmin(s.handleListUsers)) + s.mux.Handle("POST /api/admin/users", s.requireAdmin(s.handleCreateUser)) + s.mux.Handle("DELETE /api/admin/users/{id}", s.requireAdmin(s.handleDeleteUser)) + s.mux.Handle("GET /api/admin/permissions", s.requireAdmin(s.handleListPermissions)) + s.mux.Handle("POST /api/admin/permissions", s.requireAdmin(s.handleGrantPermission)) + s.mux.Handle("DELETE /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.mux.Handle("GET /api/podcasts", s.requireSession(s.handleListPodcasts)) + s.mux.Handle("POST /api/podcasts", s.requireAdmin(s.handleSubscribePodcast)) + s.mux.Handle("GET /api/podcasts/{id}/episodes", s.requireSession(s.handleListEpisodes)) + s.mux.Handle("POST /api/podcasts/episodes/{episode_id}/download", s.requireSession(s.handleDownloadEpisode)) + s.mux.Handle("POST /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) +} |
