package handler import ( "bytes" "fmt" "html/template" "log/slog" "net/http" "net/url" "path/filepath" "strings" "sync" "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 loginSem chan struct{} // sessionSecret is cached here because every guarded request reads it; Logout // rotates it. sessionMu sync.RWMutex sessionSecret string tools toolCache } 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) } sessionSecret, err := loadSessionSecret(settingsSvc) if err != nil { return nil, fmt.Errorf("load session secret: %w", err) } return &Handler{ cfg: cfg, templates: tmpl, presetSvc: presetSvc, downloadSvc: downloadSvc, librarySvc: librarySvc, settingsSvc: settingsSvc, subscriptionSvc: subscriptionSvc, workerPool: workerPool, loginSem: make(chan struct{}, maxConcurrentLogins), sessionSecret: sessionSecret, }, nil } 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, "mimeType": mimeTypeFromFile, "presetFlags": func(p *models.Preset) string { return presetSvc.EffectiveFlags(p, "", "") }, "urlEncodePath": util.URLEncodePath, "sub": func(a, b int) int { return a - b }, // A zero value lets the create page render the form partials it shares with // the edit page. "emptyPreset": func() *models.Preset { return nil }, "newSubscription": func() *models.Subscription { return &models.Subscription{RefreshMode: "overwrite", ScheduleKind: "daily"} }, // dict takes alternating key/value args, so a template can pass more than // one value into a sub-template. "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 drives the collapsible description. Rune count, not bytes, so // CJK text is not 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 after a redirect. Kind is "success" or // "error". type Flash struct { Kind string Message string } const cookieMaxAge = 365 * 24 * 60 * 60 const flashCookie = "flash" // setFlash stashes the message in a short-lived cookie; consumeFlash clears it. 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) } 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, and kind becomes the banner's class name. 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 falls back to defaults rather than a nil deref when the // store can't be read. func (h *Handler) settingsOrDefault() *models.Settings { settings, err := h.settingsSvc.GetAll() if err != nil { slog.Error("settings load failed, using defaults", "err", err) return &models.Settings{RefreshInterval: 5, AutoRefreshDownloads: true} } return settings }