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,
"mimeType": mimeTypeFromFile,
"presetFlags": func(p *models.Preset) string { return presetSvc.EffectiveFlags(p, "", "") },
"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
}