server.go
⎇
Raw
1package server
2
3import (
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.
21const shutdownTimeout = 20 * time.Second
22
23type Server struct {
24 router *chi.Mux
25 handler *handler.Handler
26 cfg *config.Config
27}
28
29func 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
39func (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
86func (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
106func (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.
113func (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