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