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
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.
95func (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
106func (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
125func (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.
132func (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