package server import ( "context" "errors" "fmt" "io/fs" "net/http" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "vidarchive" "vidarchive/internal/config" "vidarchive/internal/handler" ) // shutdownTimeout bounds how long in-flight HTTP requests may take to finish // once shutdown starts. Media streams are the long pole here. const shutdownTimeout = 20 * time.Second type Server struct { router *chi.Mux handler *handler.Handler cfg *config.Config } func New(cfg *config.Config, h *handler.Handler) *Server { s := &Server{ router: chi.NewRouter(), handler: h, cfg: cfg, } s.setupRoutes() return s } func (s *Server) setupRoutes() { s.router.Use(middleware.Logger) s.router.Use(middleware.Recoverer) s.router.Use(s.securityHeaders) // Serve embedded static assets (fs.Sub strips the web/static prefix). The // error is only possible for an invalid constant path, so it can't occur here. staticFS, _ := fs.Sub(vidarchive.StaticFS, "web/static") s.router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) s.router.Get("/healthz", s.handler.Health) s.router.Get("/media/item/*", s.handler.ServeMediaItem) s.router.Get("/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/library", http.StatusSeeOther) }) s.router.Get("/library", s.handler.Library) s.router.Get("/library/item/*", s.handler.LibraryItem) s.router.Post("/library/item/*", s.handler.LibraryItem) s.router.Get("/queue", s.handler.Downloads) s.router.Get("/queue/{id}", s.handler.DownloadDetail) s.router.Post("/queue/{id}/delete", s.handler.DeleteDownload) s.router.Post("/queue/clear", s.handler.ClearAllDownloads) s.router.Get("/download", s.handler.DownloadForm) s.router.Post("/download", s.handler.CreateDownload) s.router.Get("/subscriptions", s.handler.Subscriptions) s.router.Post("/subscriptions", s.handler.CreateSubscription) s.router.Post("/subscriptions/{id}", s.handler.UpdateSubscription) s.router.Post("/subscriptions/{id}/delete", s.handler.DeleteSubscription) s.router.Post("/subscriptions/{id}/toggle", s.handler.ToggleSubscription) s.router.Post("/subscriptions/{id}/run", s.handler.RunSubscription) s.router.Get("/settings", s.handler.Settings) s.router.Post("/settings/presets", s.handler.CreatePreset) s.router.Post("/settings/presets/{id}", s.handler.UpdatePreset) s.router.Post("/settings/presets/{id}/delete", s.handler.DeletePreset) s.router.Post("/settings", s.handler.UpdateSettings) s.router.Post("/theme", s.handler.Theme) s.router.Get("/api/presets/{id}/flags", s.handler.GetPresetFlags) } func (s *Server) securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-XSS-Protection", "1; mode=block") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") // The app ships no JavaScript, so the strict policy costs nothing and is // sent regardless of scheme — plain-HTTP deployments were previously left // with no CSP at all. w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; media-src 'self' blob:;") if s.cfg.IsHTTPS() { w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") } next.ServeHTTP(w, r) }) } func (s *Server) Router() http.Handler { return s.router } // Start serves until ctx is cancelled, then drains in-flight requests within // shutdownTimeout. Media streaming rules out a WriteTimeout, but a header // deadline still bounds a client that connects and never completes a request. func (s *Server) Start(ctx context.Context) error { srv := &http.Server{ Addr: fmt.Sprintf(":%d", s.cfg.Port), Handler: s.router, ReadHeaderTimeout: 15 * time.Second, IdleTimeout: 120 * time.Second, } fmt.Printf("Starting server on %s\n", srv.Addr) if s.cfg.BaseURL != "" { fmt.Printf("Base URL: %s\n", s.cfg.BaseURL) fmt.Printf("HTTPS mode: %v\n", s.cfg.IsHTTPS()) } errCh := make(chan error, 1) go func() { err := srv.ListenAndServe() // A shutdown is the expected way this returns, not a startup failure. if errors.Is(err, http.ErrServerClosed) { err = nil } errCh <- err }() select { case err := <-errCh: return err case <-ctx.Done(): shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) defer cancel() return srv.Shutdown(shutdownCtx) } }