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/comments/*", s.handler.LibraryComments)
58 s.router.Get("/library/item/*", s.handler.LibraryItem)
59 s.router.Post("/library/item/*", s.handler.LibraryItem)
60
61 s.router.Get("/queue", s.handler.Downloads)
62 s.router.Get("/queue/{id}", s.handler.DownloadDetail)
63 s.router.Post("/queue/{id}/delete", s.handler.DeleteDownload)
64 s.router.Post("/queue/clear", s.handler.ClearAllDownloads)
65
66 s.router.Get("/download", s.handler.DownloadForm)
67 s.router.Post("/download", s.handler.CreateDownload)
68
69 s.router.Get("/subscriptions", s.handler.Subscriptions)
70 s.router.Post("/subscriptions", s.handler.CreateSubscription)
71 s.router.Post("/subscriptions/{id}", s.handler.UpdateSubscription)
72 s.router.Post("/subscriptions/{id}/delete", s.handler.DeleteSubscription)
73 s.router.Post("/subscriptions/{id}/toggle", s.handler.ToggleSubscription)
74 s.router.Post("/subscriptions/{id}/run", s.handler.RunSubscription)
75
76 s.router.Get("/settings", s.handler.Settings)
77 s.router.Post("/settings/presets", s.handler.CreatePreset)
78 s.router.Post("/settings/presets/{id}", s.handler.UpdatePreset)
79 s.router.Post("/settings/presets/{id}/delete", s.handler.DeletePreset)
80 s.router.Post("/settings", s.handler.UpdateSettings)
81
82 s.router.Post("/theme", s.handler.Theme)
83}
84
85func (s *Server) securityHeaders(next http.Handler) http.Handler {
86 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87 w.Header().Set("X-Content-Type-Options", "nosniff")
88 w.Header().Set("X-Frame-Options", "DENY")
89 w.Header().Set("X-XSS-Protection", "1; mode=block")
90 w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
91
92 // The app ships no JavaScript, so the strict policy costs nothing and is
93 // sent regardless of scheme — plain-HTTP deployments were previously left
94 // with no CSP at all.
95 w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; media-src 'self' blob:;")
96
97 if s.cfg.IsHTTPS() {
98 w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
99 }
100
101 next.ServeHTTP(w, r)
102 })
103}
104
105func (s *Server) Router() http.Handler {
106 return s.router
107}
108
109// Start serves until ctx is cancelled, then drains in-flight requests within
110// shutdownTimeout. Media streaming rules out a WriteTimeout, but a header
111// deadline still bounds a client that connects and never completes a request.
112func (s *Server) Start(ctx context.Context) error {
113 srv := &http.Server{
114 Addr: fmt.Sprintf(":%d", s.cfg.Port),
115 Handler: s.router,
116 ReadHeaderTimeout: 15 * time.Second,
117 IdleTimeout: 120 * time.Second,
118 }
119
120 fmt.Printf("Starting server on %s\n", srv.Addr)
121 if s.cfg.BaseURL != "" {
122 fmt.Printf("Base URL: %s\n", s.cfg.BaseURL)
123 fmt.Printf("HTTPS mode: %v\n", s.cfg.IsHTTPS())
124 }
125
126 errCh := make(chan error, 1)
127 go func() {
128 err := srv.ListenAndServe()
129 // A shutdown is the expected way this returns, not a startup failure.
130 if errors.Is(err, http.ErrServerClosed) {
131 err = nil
132 }
133 errCh <- err
134 }()
135
136 select {
137 case err := <-errCh:
138 return err
139 case <-ctx.Done():
140 shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
141 defer cancel()
142 return srv.Shutdown(shutdownCtx)
143 }
144}
145