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