handler.go
| 1 | package handler |
| 2 | |
| 3 | import ( |
| 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 | |
| 24 | type 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 | |
| 42 | func 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 | |
| 67 | func 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 | |
| 112 | func formatDuration(seconds int) string { |
| 113 | if seconds <= 0 { |
| 114 | return "--:--" |
| 115 | } |
| 116 | return util.FormatClock(seconds) |
| 117 | } |
| 118 | |
| 119 | func 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 | |
| 127 | type 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". |
| 140 | type Flash struct { |
| 141 | Kind string |
| 142 | Message string |
| 143 | } |
| 144 | |
| 145 | const cookieMaxAge = 365 * 24 * 60 * 60 |
| 146 | |
| 147 | const flashCookie = "flash" |
| 148 | |
| 149 | // setFlash stashes the message in a short-lived cookie; consumeFlash clears it. |
| 150 | func 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 | |
| 161 | func flashSuccess(w http.ResponseWriter, message string) { setFlash(w, "success", message) } |
| 162 | func flashError(w http.ResponseWriter, message string) { setFlash(w, "error", message) } |
| 163 | |
| 164 | func 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 | |
| 192 | func 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 | |
| 200 | func 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 | |
| 211 | func (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. |
| 242 | func (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 |