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