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