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 // Serve embedded static assets (fs.Sub strips the web/static prefix). The
47 // error is only possible for an invalid constant path, so it can't occur here.
48 staticFS, _ := fs.Sub(vidarchive.StaticFS, "web/static")
49 s.router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
50
51 s.router.Get("/healthz", s.handler.Health)
52
53 s.router.Get("/login", s.handler.LoginForm)
54 s.router.Post("/login", s.handler.Login)
55 s.router.Post("/logout", s.handler.Logout)
56
57 s.router.Get("/media/item/*", s.handler.ServeMediaItem)
58
59 s.router.Get("/", func(w http.ResponseWriter, r *http.Request) {
60 http.Redirect(w, r, "/library", http.StatusSeeOther)
61 })
62 s.router.Get("/library", s.handler.Library)
63 s.router.Get("/library/comments/*", s.handler.LibraryComments)
64 s.router.Get("/library/item/*", s.handler.LibraryItem)
65 s.router.Post("/library/item/*", s.handler.LibraryItem)
66
67 s.router.Get("/queue", s.handler.Downloads)
68 s.router.Get("/queue/{id}", s.handler.DownloadDetail)
69 s.router.Post("/queue/{id}/delete", s.handler.DeleteDownload)
70 s.router.Post("/queue/{id}/retry", s.handler.RetryDownload)
71 s.router.Post("/queue/{id}/cancel", s.handler.CancelDownload)
72 s.router.Post("/queue/clear", s.handler.ClearDownloads)
73
74 s.router.Get("/download", s.handler.DownloadForm)
75 s.router.Post("/download", s.handler.CreateDownload)
76
77 s.router.Get("/subscriptions", s.handler.Subscriptions)
78 s.router.Post("/subscriptions", s.handler.CreateSubscription)
79 s.router.Post("/subscriptions/{id}", s.handler.UpdateSubscription)
80 s.router.Post("/subscriptions/{id}/delete", s.handler.DeleteSubscription)
81 s.router.Post("/subscriptions/{id}/toggle", s.handler.ToggleSubscription)
82 s.router.Post("/subscriptions/{id}/run", s.handler.RunSubscription)
83
84 s.router.Get("/settings", s.handler.Settings)
85 s.router.Post("/settings/presets", s.handler.CreatePreset)
86 s.router.Post("/settings/presets/{id}", s.handler.UpdatePreset)
87 s.router.Post("/settings/presets/{id}/delete", s.handler.DeletePreset)
88 s.router.Post("/settings", s.handler.UpdateSettings)
89
90 s.router.Post("/theme", s.handler.Theme)
91}
92
93// requestLogger replaces chi's middleware.Logger, which writes its own
94// preformatted (and ANSI-coloured) line. This one emits the same facts as slog
95// attributes, so a request can be filtered and correlated like anything else.
96func (s *Server) requestLogger(next http.Handler) http.Handler {
97 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
98 ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
99 start := time.Now()
100 next.ServeHTTP(ww, r)
101 slog.Info("request",
102 "method", r.Method, "path", r.URL.Path, "status", ww.Status(),
103 "bytes", ww.BytesWritten(), "duration_ms", time.Since(start).Milliseconds())
104 })
105}
106
107func (s *Server) securityHeaders(next http.Handler) http.Handler {
108 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
109 w.Header().Set("X-Content-Type-Options", "nosniff")
110 w.Header().Set("X-Frame-Options", "DENY")
111 w.Header().Set("X-XSS-Protection", "1; mode=block")
112 w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
113
114 // The app ships no JavaScript, so the strict policy costs nothing and is
115 // sent regardless of scheme — plain-HTTP deployments were previously left
116 // with no CSP at all.
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 serves until ctx is cancelled, then drains in-flight requests within
132// shutdownTimeout. Media streaming rules out a WriteTimeout, but a header
133// deadline still bounds 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