server.go
⎇
Raw
1package server
2
3import (
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.
22const shutdownTimeout = 20 * time.Second
23
24type Server struct {
25 router *chi.Mux
26 handler *handler.Handler
27 cfg *config.Config
28}
29
30func 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
40func (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 s.router.Post("/settings/tools", s.handler.UpdateToolSettings)
89 s.router.Post("/settings/tools/update", s.handler.UpdateTools)
90
91 s.router.Post("/theme", s.handler.Theme)
92}
93
94// requestLogger replaces chi's middleware.Logger, which writes its own
95// preformatted line. These are slog attributes, so a request can be filtered and
96// correlated like anything else.
97func (s *Server) requestLogger(next http.Handler) http.Handler {
98 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
99 ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
100 start := time.Now()
101 next.ServeHTTP(ww, r)
102 slog.Info("request",
103 "method", r.Method, "path", r.URL.Path, "status", ww.Status(),
104 "bytes", ww.BytesWritten(), "duration_ms", time.Since(start).Milliseconds())
105 })
106}
107
108func (s *Server) securityHeaders(next http.Handler) http.Handler {
109 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
110 w.Header().Set("X-Content-Type-Options", "nosniff")
111 w.Header().Set("X-Frame-Options", "DENY")
112 w.Header().Set("X-XSS-Protection", "1; mode=block")
113 w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
114
115 // The app ships no JavaScript, so the strict policy costs nothing and is
116 // sent regardless of scheme.
117 w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; media-src 'self' blob:;")
118
119 if s.cfg.IsHTTPS() {
120 w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
121 }
122
123 next.ServeHTTP(w, r)
124 })
125}
126
127func (s *Server) Router() http.Handler {
128 return s.router
129}
130
131// Start drains in-flight requests within shutdownTimeout once ctx is cancelled.
132// Media streaming rules out a WriteTimeout, but the header deadline still bounds
133// a client that connects and never completes a request.
134func (s *Server) Start(ctx context.Context) error {
135 srv := &http.Server{
136 Addr: fmt.Sprintf(":%d", s.cfg.Port),
137 Handler: s.router,
138 ReadHeaderTimeout: 15 * time.Second,
139 IdleTimeout: 120 * time.Second,
140 }
141
142 slog.Info("starting server", "addr", srv.Addr, "base_url", s.cfg.BaseURL, "https", s.cfg.IsHTTPS())
143
144 errCh := make(chan error, 1)
145 go func() {
146 err := srv.ListenAndServe()
147 // A shutdown is the expected way this returns, not a startup failure.
148 if errors.Is(err, http.ErrServerClosed) {
149 err = nil
150 }
151 errCh <- err
152 }()
153
154 select {
155 case err := <-errCh:
156 return err
157 case <-ctx.Done():
158 shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
159 defer cancel()
160 return srv.Shutdown(shutdownCtx)
161 }
162}
163