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