server.go
| 1 | package server |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "io/fs" |
| 8 | "net/http" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/go-chi/chi/v5" |
| 12 | "github.com/go-chi/chi/v5/middleware" |
| 13 | |
| 14 | "vidarchive" |
| 15 | "vidarchive/internal/config" |
| 16 | "vidarchive/internal/handler" |
| 17 | ) |
| 18 | |
| 19 | // shutdownTimeout bounds how long in-flight HTTP requests may take to finish |
| 20 | // once shutdown starts. Media streams are the long pole here. |
| 21 | const shutdownTimeout = 20 * time.Second |
| 22 | |
| 23 | type Server struct { |
| 24 | router *chi.Mux |
| 25 | handler *handler.Handler |
| 26 | cfg *config.Config |
| 27 | } |
| 28 | |
| 29 | func New(cfg *config.Config, h *handler.Handler) *Server { |
| 30 | s := &Server{ |
| 31 | router: chi.NewRouter(), |
| 32 | handler: h, |
| 33 | cfg: cfg, |
| 34 | } |
| 35 | s.setupRoutes() |
| 36 | return s |
| 37 | } |
| 38 | |
| 39 | func (s *Server) setupRoutes() { |
| 40 | s.router.Use(middleware.Logger) |
| 41 | s.router.Use(middleware.Recoverer) |
| 42 | s.router.Use(s.securityHeaders) |
| 43 | |
| 44 | // Serve embedded static assets (fs.Sub strips the web/static prefix). The |
| 45 | // error is only possible for an invalid constant path, so it can't occur here. |
| 46 | staticFS, _ := fs.Sub(vidarchive.StaticFS, "web/static") |
| 47 | s.router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) |
| 48 | |
| 49 | s.router.Get("/healthz", s.handler.Health) |
| 50 | |
| 51 | s.router.Get("/media/item/*", s.handler.ServeMediaItem) |
| 52 | |
| 53 | s.router.Get("/", func(w http.ResponseWriter, r *http.Request) { |
| 54 | http.Redirect(w, r, "/library", http.StatusSeeOther) |
| 55 | }) |
| 56 | s.router.Get("/library", s.handler.Library) |
| 57 | s.router.Get("/library/item/*", s.handler.LibraryItem) |
| 58 | s.router.Post("/library/item/*", s.handler.LibraryItem) |
| 59 | |
| 60 | s.router.Get("/queue", s.handler.Downloads) |
| 61 | s.router.Get("/queue/{id}", s.handler.DownloadDetail) |
| 62 | s.router.Post("/queue/{id}/delete", s.handler.DeleteDownload) |
| 63 | s.router.Post("/queue/clear", s.handler.ClearAllDownloads) |
| 64 | |
| 65 | s.router.Get("/download", s.handler.DownloadForm) |
| 66 | s.router.Post("/download", s.handler.CreateDownload) |
| 67 | |
| 68 | s.router.Get("/subscriptions", s.handler.Subscriptions) |
| 69 | s.router.Post("/subscriptions", s.handler.CreateSubscription) |
| 70 | s.router.Post("/subscriptions/{id}", s.handler.UpdateSubscription) |
| 71 | s.router.Post("/subscriptions/{id}/delete", s.handler.DeleteSubscription) |
| 72 | s.router.Post("/subscriptions/{id}/toggle", s.handler.ToggleSubscription) |
| 73 | s.router.Post("/subscriptions/{id}/run", s.handler.RunSubscription) |
| 74 | |
| 75 | s.router.Get("/settings", s.handler.Settings) |
| 76 | s.router.Post("/settings/presets", s.handler.CreatePreset) |
| 77 | s.router.Post("/settings/presets/{id}", s.handler.UpdatePreset) |
| 78 | s.router.Post("/settings/presets/{id}/delete", s.handler.DeletePreset) |
| 79 | s.router.Post("/settings", s.handler.UpdateSettings) |
| 80 | |
| 81 | s.router.Post("/theme", s.handler.Theme) |
| 82 | |
| 83 | s.router.Get("/api/presets/{id}/flags", s.handler.GetPresetFlags) |
| 84 | } |
| 85 | |
| 86 | func (s *Server) securityHeaders(next http.Handler) http.Handler { |
| 87 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 88 | w.Header().Set("X-Content-Type-Options", "nosniff") |
| 89 | w.Header().Set("X-Frame-Options", "DENY") |
| 90 | w.Header().Set("X-XSS-Protection", "1; mode=block") |
| 91 | w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") |
| 92 | |
| 93 | // The app ships no JavaScript, so the strict policy costs nothing and is |
| 94 | // sent regardless of scheme — plain-HTTP deployments were previously left |
| 95 | // with no CSP at all. |
| 96 | w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; media-src 'self' blob:;") |
| 97 | |
| 98 | if s.cfg.IsHTTPS() { |
| 99 | w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") |
| 100 | } |
| 101 | |
| 102 | next.ServeHTTP(w, r) |
| 103 | }) |
| 104 | } |
| 105 | |
| 106 | func (s *Server) Router() http.Handler { |
| 107 | return s.router |
| 108 | } |
| 109 | |
| 110 | // Start serves until ctx is cancelled, then drains in-flight requests within |
| 111 | // shutdownTimeout. Media streaming rules out a WriteTimeout, but a header |
| 112 | // deadline still bounds a client that connects and never completes a request. |
| 113 | func (s *Server) Start(ctx context.Context) error { |
| 114 | srv := &http.Server{ |
| 115 | Addr: fmt.Sprintf(":%d", s.cfg.Port), |
| 116 | Handler: s.router, |
| 117 | ReadHeaderTimeout: 15 * time.Second, |
| 118 | IdleTimeout: 120 * time.Second, |
| 119 | } |
| 120 | |
| 121 | fmt.Printf("Starting server on %s\n", srv.Addr) |
| 122 | if s.cfg.BaseURL != "" { |
| 123 | fmt.Printf("Base URL: %s\n", s.cfg.BaseURL) |
| 124 | fmt.Printf("HTTPS mode: %v\n", s.cfg.IsHTTPS()) |
| 125 | } |
| 126 | |
| 127 | errCh := make(chan error, 1) |
| 128 | go func() { |
| 129 | err := srv.ListenAndServe() |
| 130 | // A shutdown is the expected way this returns, not a startup failure. |
| 131 | if errors.Is(err, http.ErrServerClosed) { |
| 132 | err = nil |
| 133 | } |
| 134 | errCh <- err |
| 135 | }() |
| 136 | |
| 137 | select { |
| 138 | case err := <-errCh: |
| 139 | return err |
| 140 | case <-ctx.Done(): |
| 141 | shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) |
| 142 | defer cancel() |
| 143 | return srv.Shutdown(shutdownCtx) |
| 144 | } |
| 145 | } |
| 146 |