package handler import ( "bytes" "fmt" "html/template" "log" "net/http" "net/url" "path/filepath" "strings" "github.com/gabriel-vasile/mimetype" "vidarchive" "vidarchive/internal/config" "vidarchive/internal/models" "vidarchive/internal/service" "vidarchive/internal/util" "vidarchive/internal/worker" ) type Handler struct { cfg *config.Config templates *template.Template presetSvc *service.PresetService downloadSvc *service.DownloadService librarySvc *service.LibraryService settingsSvc *service.SettingsService subscriptionSvc *service.SubscriptionService workerPool *worker.Pool } 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) { tmpl, err := loadTemplates(presetSvc) if err != nil { return nil, fmt.Errorf("load templates: %w", err) } return &Handler{ cfg: cfg, templates: tmpl, presetSvc: presetSvc, downloadSvc: downloadSvc, librarySvc: librarySvc, settingsSvc: settingsSvc, subscriptionSvc: subscriptionSvc, workerPool: workerPool, }, nil } // loadTemplates builds the template set. func loadTemplates(presetSvc *service.PresetService) (*template.Template, error) { tmpl := template.New("").Funcs(template.FuncMap{ "formatDuration": formatDuration, "formatFileSize": util.FormatBytes, "heatmapRange": func(hm models.HeatmapSegment) string { return fmt.Sprintf("%s to %s, activity %.2f", util.FormatClock(int(hm.StartTime+0.5)), util.FormatClock(int(hm.EndTime+0.5)), hm.Value) }, "splitPath": func(p string) []string { return strings.Split(strings.Trim(p, "/"), "/") }, "baseName": filepath.Base, "ext": filepath.Ext, "mimeType": mimeTypeFromFile, "presetFlags": func(p *models.Preset) string { return presetSvc.EffectiveFlags(p, "", "") }, "urlEncode": template.URLQueryEscaper, "urlEncodePath": util.URLEncodePath, "sub": func(a, b int) int { return a - b }, // emptyPreset / newSubscription supply a zero value so the shared create and // edit form partials can be rendered from the create page too. newSubscription // carries the create-time defaults (overwrite mode, daily schedule). "emptyPreset": func() *models.Preset { return nil }, "newSubscription": func() *models.Subscription { return &models.Subscription{RefreshMode: "overwrite", ScheduleKind: "daily"} }, // dict builds a map from alternating key/value args, so a template can pass // more than one value into a sub-template (e.g. the subscription form needs // both the subscription and the preset list). "dict": func(values ...interface{}) (map[string]interface{}, error) { if len(values)%2 != 0 { return nil, fmt.Errorf("dict expects an even number of arguments") } m := make(map[string]interface{}, len(values)/2) for i := 0; i < len(values); i += 2 { key, ok := values[i].(string) if !ok { return nil, fmt.Errorf("dict keys must be strings") } m[key] = values[i+1] } return m, nil }, // isLongText reports whether text spans more than ~2 lines, so the detail // view can make long descriptions collapsible. Uses rune count (not bytes) // so CJK text isn't flagged early. "isLongText": func(s string) bool { return strings.Count(s, "\n") >= 2 || len([]rune(s)) > 180 }, }) return tmpl.ParseFS(vidarchive.TemplatesFS, "web/templates/*.html") } func formatDuration(seconds int) string { if seconds <= 0 { return "--:--" } return util.FormatClock(seconds) } func mimeTypeFromFile(path string) string { mtype, err := mimetype.DetectFile(path) if err != nil || mtype == nil { return "application/octet-stream" } return mtype.String() } type PageData struct { Title string ActiveTab string Data interface{} BaseURL string AutoRefresh bool RefreshSec int Theme string Flash *Flash } // Flash is a one-shot message shown to the user after a redirect (the // Post/Redirect/Get pattern). Kind is "success" or "error". type Flash struct { Kind string Message string } const cookieMaxAge = 365 * 24 * 60 * 60 const flashCookie = "flash" // setFlash stashes a one-shot message in a short-lived cookie. The next rendered // page reads and clears it (see consumeFlash), so the message appears once after // the redirect and never again. func setFlash(w http.ResponseWriter, kind, message string) { http.SetCookie(w, &http.Cookie{ Name: flashCookie, Value: url.QueryEscape(kind + "|" + message), Path: "/", MaxAge: 30, HttpOnly: true, SameSite: http.SameSiteLaxMode, }) } func flashSuccess(w http.ResponseWriter, message string) { setFlash(w, "success", message) } func flashError(w http.ResponseWriter, message string) { setFlash(w, "error", message) } // consumeFlash reads the flash cookie (if any) and immediately expires it, so a // message is shown exactly once. func consumeFlash(w http.ResponseWriter, r *http.Request) *Flash { c, err := r.Cookie(flashCookie) if err != nil || c.Value == "" { return nil } http.SetCookie(w, &http.Cookie{ Name: flashCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode, }) decoded, err := url.QueryUnescape(c.Value) if err != nil { return nil } kind, message, ok := strings.Cut(decoded, "|") if !ok { return nil } // The cookie is client-editable, so don't let an arbitrary kind flow into the // banner's class name — clamp it to the two we render. if kind != "success" { kind = "error" } return &Flash{Kind: kind, Message: message} } func getCookie(r *http.Request, name, defaultVal string) string { cookie, err := r.Cookie(name) if err != nil { return defaultVal } return cookie.Value } func setCookie(w http.ResponseWriter, name, value string) { http.SetCookie(w, &http.Cookie{ Name: name, Value: value, Path: "/", MaxAge: cookieMaxAge, HttpOnly: true, SameSite: http.SameSiteLaxMode, }) } func (h *Handler) renderWithRequest(w http.ResponseWriter, r *http.Request, contentTmpl string, data PageData) { data.BaseURL = h.cfg.BaseURL if data.Theme == "" { data.Theme = getCookie(r, "theme", "auto") } if data.Flash == nil { data.Flash = consumeFlash(w, r) } w.Header().Set("Content-Type", "text/html; charset=utf-8") var contentBuf bytes.Buffer if err := h.templates.ExecuteTemplate(&contentBuf, contentTmpl, data); err != nil { http.Error(w, fmt.Sprintf("render content: %v", err), http.StatusInternalServerError) return } layoutData := struct { PageData Content template.HTML }{ PageData: data, Content: template.HTML(contentBuf.String()), } if err := h.templates.ExecuteTemplate(w, "layout", layoutData); err != nil { http.Error(w, fmt.Sprintf("render layout: %v", err), http.StatusInternalServerError) } } // settingsOrDefault loads settings for page rendering, falling back to sane // defaults (rather than a nil deref) if the store can't be read. func (h *Handler) settingsOrDefault() *models.Settings { settings, err := h.settingsSvc.GetAll() if err != nil { log.Printf("settings load failed, using defaults: %v", err) return &models.Settings{RefreshInterval: 5, AutoRefreshDownloads: true} } return settings }