package server import ( "context" "errors" "fmt" "io/fs" "log/slog" "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(s.requestLogger) s.router.Use(middleware.Recoverer) s.router.Use(s.securityHeaders) s.router.Use(s.handler.RequireAuth) // The error is only possible for an invalid constant path. 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("/login", s.handler.LoginForm) s.router.Post("/login", s.handler.Login) s.router.Post("/logout", s.handler.Logout) 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/comments/*", s.handler.LibraryComments) 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/{id}/retry", s.handler.RetryDownload) s.router.Post("/queue/{id}/cancel", s.handler.CancelDownload) s.router.Post("/queue/clear", s.handler.ClearDownloads) 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) } // requestLogger replaces chi's middleware.Logger, which writes its own // preformatted line. These are slog attributes, so a request can be filtered and // correlated like anything else. func (s *Server) requestLogger(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) start := time.Now() next.ServeHTTP(ww, r) slog.Info("request", "method", r.Method, "path", r.URL.Path, "status", ww.Status(), "bytes", ww.BytesWritten(), "duration_ms", time.Since(start).Milliseconds()) }) } 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. 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 drains in-flight requests within shutdownTimeout once ctx is cancelled. // Media streaming rules out a WriteTimeout, but the 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, } slog.Info("starting server", "addr", srv.Addr, "base_url", s.cfg.BaseURL, "https", 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) } }