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/tools"
"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
tools *tools.Manager
loginSem chan struct{}
// sessionSecret is cached here because every guarded request reads it; Logout
// rotates it.
sessionMu sync.RWMutex
sessionSecret string
toolCache toolCache
}
func New(cfg *config.Config, presetSvc *service.PresetService, downloadSvc *service.DownloadService, librarySvc *service.LibraryService, settingsSvc *service.SettingsService, subscriptionSvc *service.SubscriptionService, workerPool *worker.Pool, toolMgr *tools.Manager) (*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,
tools: toolMgr,
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
}