handler.go
⎇
Raw
1package handler
2
3import (
4 "bytes"
5 "fmt"
6 "html/template"
7 "log/slog"
8 "net/http"
9 "net/url"
10 "path/filepath"
11 "strings"
12 "sync"
13
14 "github.com/gabriel-vasile/mimetype"
15
16 "vidarchive"
17 "vidarchive/internal/config"
18 "vidarchive/internal/models"
19 "vidarchive/internal/service"
20 "vidarchive/internal/util"
21 "vidarchive/internal/worker"
22)
23
24type Handler struct {
25 cfg *config.Config
26 templates *template.Template
27 presetSvc *service.PresetService
28 downloadSvc *service.DownloadService
29 librarySvc *service.LibraryService
30 settingsSvc *service.SettingsService
31 subscriptionSvc *service.SubscriptionService
32 workerPool *worker.Pool
33
34 // loginSem caps concurrent password checks; see maxConcurrentLogins.
35 loginSem chan struct{}
36 // sessionSecret is the stored cookie signing secret, cached here because
37 // every guarded request reads it. Logout rotates it.
38 sessionMu sync.RWMutex
39 sessionSecret string
40 tools toolCache
41}
42
43func New(cfg *config.Config, presetSvc *service.PresetService, downloadSvc *service.DownloadService, librarySvc *service.LibraryService, settingsSvc *service.SettingsService, subscriptionSvc *service.SubscriptionService, workerPool *worker.Pool) (*Handler, error) {
44 tmpl, err := loadTemplates(presetSvc)
45 if err != nil {
46 return nil, fmt.Errorf("load templates: %w", err)
47 }
48
49 sessionSecret, err := loadSessionSecret(settingsSvc)
50 if err != nil {
51 return nil, fmt.Errorf("load session secret: %w", err)
52 }
53
54 return &Handler{
55 cfg: cfg,
56 templates: tmpl,
57 presetSvc: presetSvc,
58 downloadSvc: downloadSvc,
59 librarySvc: librarySvc,
60 settingsSvc: settingsSvc,
61 subscriptionSvc: subscriptionSvc,
62 workerPool: workerPool,
63 loginSem: make(chan struct{}, maxConcurrentLogins),
64 sessionSecret: sessionSecret,
65 }, nil
66}
67
68// loadTemplates builds the template set.
69func loadTemplates(presetSvc *service.PresetService) (*template.Template, error) {
70 tmpl := template.New("").Funcs(template.FuncMap{
71 "formatDuration": formatDuration,
72 "formatFileSize": util.FormatBytes,
73 "heatmapRange": func(hm models.HeatmapSegment) string {
74 return fmt.Sprintf("%s to %s, activity %.2f", util.FormatClock(int(hm.StartTime+0.5)), util.FormatClock(int(hm.EndTime+0.5)), hm.Value)
75 },
76 "splitPath": func(p string) []string { return strings.Split(strings.Trim(p, "/"), "/") },
77 "baseName": filepath.Base,
78 "mimeType": mimeTypeFromFile,
79 "presetFlags": func(p *models.Preset) string { return presetSvc.EffectiveFlags(p, "", "") },
80 "urlEncodePath": util.URLEncodePath,
81 "sub": func(a, b int) int { return a - b },
82 // emptyPreset / newSubscription supply a zero value so the shared create and
83 // edit form partials can be rendered from the create page too. newSubscription
84 // carries the create-time defaults (overwrite mode, daily schedule).
85 "emptyPreset": func() *models.Preset { return nil },
86 "newSubscription": func() *models.Subscription {
87 return &models.Subscription{RefreshMode: "overwrite", ScheduleKind: "daily"}
88 },
89 // dict builds a map from alternating key/value args, so a template can pass
90 // more than one value into a sub-template (e.g. the subscription form needs
91 // both the subscription and the preset list).
92 "dict": func(values ...interface{}) (map[string]interface{}, error) {
93 if len(values)%2 != 0 {
94 return nil, fmt.Errorf("dict expects an even number of arguments")
95 }
96 m := make(map[string]interface{}, len(values)/2)
97 for i := 0; i < len(values); i += 2 {
98 key, ok := values[i].(string)
99 if !ok {
100 return nil, fmt.Errorf("dict keys must be strings")
101 }
102 m[key] = values[i+1]
103 }
104 return m, nil
105 },
106 // isLongText reports whether text spans more than ~2 lines, so the detail
107 // view can make long descriptions collapsible. Uses rune count (not bytes)
108 // so CJK text isn't flagged early.
109 "isLongText": func(s string) bool {
110 return strings.Count(s, "\n") >= 2 || len([]rune(s)) > 180
111 },
112 })
113
114 return tmpl.ParseFS(vidarchive.TemplatesFS, "web/templates/*.html")
115}
116
117func formatDuration(seconds int) string {
118 if seconds <= 0 {
119 return "--:--"
120 }
121 return util.FormatClock(seconds)
122}
123
124func mimeTypeFromFile(path string) string {
125 mtype, err := mimetype.DetectFile(path)
126 if err != nil || mtype == nil {
127 return "application/octet-stream"
128 }
129 return mtype.String()
130}
131
132type PageData struct {
133 Title string
134 ActiveTab string
135 Data interface{}
136 BaseURL string
137 AutoRefresh bool
138 RefreshSec int
139 Theme string
140 Flash *Flash
141}
142
143// Flash is a one-shot message shown to the user after a redirect (the
144// Post/Redirect/Get pattern). Kind is "success" or "error".
145type Flash struct {
146 Kind string
147 Message string
148}
149
150const cookieMaxAge = 365 * 24 * 60 * 60
151
152const flashCookie = "flash"
153
154// setFlash stashes a one-shot message in a short-lived cookie. The next rendered
155// page reads and clears it (see consumeFlash), so the message appears once after
156// the redirect and never again.
157func setFlash(w http.ResponseWriter, kind, message string) {
158 http.SetCookie(w, &http.Cookie{
159 Name: flashCookie,
160 Value: url.QueryEscape(kind + "|" + message),
161 Path: "/",
162 MaxAge: 30,
163 HttpOnly: true,
164 SameSite: http.SameSiteLaxMode,
165 })
166}
167
168func flashSuccess(w http.ResponseWriter, message string) { setFlash(w, "success", message) }
169func flashError(w http.ResponseWriter, message string) { setFlash(w, "error", message) }
170
171// consumeFlash reads the flash cookie (if any) and immediately expires it, so a
172// message is shown exactly once.
173func consumeFlash(w http.ResponseWriter, r *http.Request) *Flash {
174 c, err := r.Cookie(flashCookie)
175 if err != nil || c.Value == "" {
176 return nil
177 }
178 http.SetCookie(w, &http.Cookie{
179 Name: flashCookie,
180 Value: "",
181 Path: "/",
182 MaxAge: -1,
183 HttpOnly: true,
184 SameSite: http.SameSiteLaxMode,
185 })
186 decoded, err := url.QueryUnescape(c.Value)
187 if err != nil {
188 return nil
189 }
190 kind, message, ok := strings.Cut(decoded, "|")
191 if !ok {
192 return nil
193 }
194 // The cookie is client-editable, so don't let an arbitrary kind flow into the
195 // banner's class name — clamp it to the two we render.
196 if kind != "success" {
197 kind = "error"
198 }
199 return &Flash{Kind: kind, Message: message}
200}
201
202func getCookie(r *http.Request, name, defaultVal string) string {
203 cookie, err := r.Cookie(name)
204 if err != nil {
205 return defaultVal
206 }
207 return cookie.Value
208}
209
210func setCookie(w http.ResponseWriter, name, value string) {
211 http.SetCookie(w, &http.Cookie{
212 Name: name,
213 Value: value,
214 Path: "/",
215 MaxAge: cookieMaxAge,
216 HttpOnly: true,
217 SameSite: http.SameSiteLaxMode,
218 })
219}
220
221func (h *Handler) renderWithRequest(w http.ResponseWriter, r *http.Request, contentTmpl string, data PageData) {
222 data.BaseURL = h.cfg.BaseURL
223 if data.Theme == "" {
224 data.Theme = getCookie(r, "theme", "auto")
225 }
226 if data.Flash == nil {
227 data.Flash = consumeFlash(w, r)
228 }
229 w.Header().Set("Content-Type", "text/html; charset=utf-8")
230
231 var contentBuf bytes.Buffer
232 if err := h.templates.ExecuteTemplate(&contentBuf, contentTmpl, data); err != nil {
233 http.Error(w, fmt.Sprintf("render content: %v", err), http.StatusInternalServerError)
234 return
235 }
236
237 layoutData := struct {
238 PageData
239 Content template.HTML
240 }{
241 PageData: data,
242 Content: template.HTML(contentBuf.String()),
243 }
244
245 if err := h.templates.ExecuteTemplate(w, "layout", layoutData); err != nil {
246 http.Error(w, fmt.Sprintf("render layout: %v", err), http.StatusInternalServerError)
247 }
248}
249
250// settingsOrDefault loads settings for page rendering, falling back to sane
251// defaults (rather than a nil deref) if the store can't be read.
252func (h *Handler) settingsOrDefault() *models.Settings {
253 settings, err := h.settingsSvc.GetAll()
254 if err != nil {
255 slog.Error("settings load failed, using defaults", "err", err)
256 return &models.Settings{RefreshInterval: 5, AutoRefreshDownloads: true}
257 }
258 return settings
259}
260