package video import ( "fmt" "html/template" "net/http" "net/url" "path/filepath" "sort" "strconv" "strings" "time" "github.com/domagojzecevic/cammonitor/internal/auth" "github.com/domagojzecevic/cammonitor/internal/config" "github.com/domagojzecevic/cammonitor/internal/footage" webtemplates "github.com/domagojzecevic/cammonitor/internal/web/templates" "github.com/go-chi/chi/v5" ) type Handler struct { cfg *config.Config idx *footage.Index thumbs *Cache ffmpegPath string } type ShellData struct { Title string User *auth.User Months []MonthGroup CurrentDate string ActiveTab string Content any } type MonthGroup struct { Label string Days []string } type PageData struct { Date string Videos []VideoItem ActiveIndex int PrevIndex int NextIndex int Current VideoItem } type VideoItem struct { Index int Filename string RelPath string StreamURL string ThumbURL string DurationLabel string Active bool } func NewHandler(cfg *config.Config, idx *footage.Index) *Handler { return &Handler{ cfg: cfg, idx: idx, ffmpegPath: defaultFFmpegPath, thumbs: NewCache(defaultCacheEntries, defaultFFmpegPath), } } func (h *Handler) ServeStream(w http.ResponseWriter, r *http.Request) { absPath, ok := h.resolveRequestPath(w, r) if !ok { return } if err := Stream(w, r, absPath, h.ffmpegPath); err != nil { http.Error(w, "stream video failed", http.StatusBadGateway) } } func (h *Handler) ServeThumb(w http.ResponseWriter, r *http.Request) { absPath, ok := h.resolveRequestPath(w, r) if !ok { return } thumb, err := h.thumbs.Thumbnail(absPath) if err != nil { http.NotFound(w, r) return } w.Header().Set("Cache-Control", "max-age=3600") w.Header().Set("Content-Type", "image/jpeg") w.WriteHeader(http.StatusOK) _, _ = w.Write(thumb) } func (h *Handler) ServePage(w http.ResponseWriter, r *http.Request) { date := chi.URLParam(r, "date") day, ok := h.day(date) if !ok { http.NotFound(w, r) return } data := h.pageData(date, day.Videos, parseIndex(r.URL.Query().Get("idx"), len(day.Videos))) h.render(w, r, ShellData{ Title: "CamMonitor " + date + " videos", CurrentDate: date, ActiveTab: "videos", Content: data, }) } func (h *Handler) pageData(date string, files []footage.VideoFile, active int) PageData { items := make([]VideoItem, 0, len(files)) for i, file := range files { items = append(items, VideoItem{ Index: i, Filename: file.Filename, RelPath: filepath.ToSlash(file.RelPath), StreamURL: "/stream/video/" + pathEscape(file.RelPath), ThumbURL: "/thumb/video/" + pathEscape(file.RelPath), DurationLabel: formatDuration(file.Duration), Active: i == active, }) } page := PageData{ Date: date, Videos: items, ActiveIndex: active, } if len(items) > 0 { page.PrevIndex = (active - 1 + len(items)) % len(items) page.NextIndex = (active + 1) % len(items) page.Current = items[active] } return page } func (h *Handler) resolveRequestPath(w http.ResponseWriter, r *http.Request) (string, bool) { relPath := chi.URLParam(r, "*") if routePattern := chi.RouteContext(r.Context()).RoutePattern(); routePattern != "" { prefix := strings.TrimSuffix(routePattern, "*") escaped := strings.TrimPrefix(r.URL.EscapedPath(), prefix) if unescaped, err := url.PathUnescape(escaped); err == nil && unescaped != "" { relPath = unescaped } } absPath, err := h.resolveRelPath(relPath) if err != nil { http.Error(w, "invalid video path", http.StatusBadRequest) return "", false } return absPath, true } func (h *Handler) resolveRelPath(relPath string) (string, error) { if h.cfg == nil || h.cfg.FootageRoot == "" { return "", fmt.Errorf("missing footage root") } if relPath == "" { return "", fmt.Errorf("missing path") } clean := filepath.Clean(filepath.FromSlash(relPath)) if clean == "." || clean == ".." || filepath.IsAbs(clean) || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { return "", fmt.Errorf("unsafe path") } root, err := filepath.Abs(h.cfg.FootageRoot) if err != nil { return "", err } target, err := filepath.Abs(filepath.Join(root, clean)) if err != nil { return "", err } rel, err := filepath.Rel(root, target) if err != nil { return "", err } if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return "", fmt.Errorf("path escapes footage root") } return target, nil } func (h *Handler) render(w http.ResponseWriter, r *http.Request, data ShellData) { data.User, _ = auth.UserFromContext(r.Context()) data.Months = groupDaysByMonth(h.dayList()) w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := videoTemplate.ExecuteTemplate(w, "base", data); err != nil { http.Error(w, "render video page failed", http.StatusInternalServerError) } } func (h *Handler) dayList() []string { if h.idx == nil { return nil } return h.idx.DayList() } func (h *Handler) day(date string) (*footage.DayEntry, bool) { if h.idx == nil { return nil, false } return h.idx.Day(date) } func parseIndex(value string, length int) int { if length <= 0 { return 0 } index, err := strconv.Atoi(value) if err != nil || index < 0 || index >= length { return 0 } return index } func pathEscape(relPath string) string { parts := strings.Split(filepath.ToSlash(relPath), "/") for i, part := range parts { parts[i] = url.PathEscape(part) } return strings.Join(parts, "/") } func groupDaysByMonth(days []string) []MonthGroup { groups := make(map[string][]string) labels := make([]string, 0) for _, day := range days { if len(day) != len("20060102") { continue } parsed, err := time.Parse("20060102", day) if err != nil { continue } label := parsed.Format("2006-01") if _, ok := groups[label]; !ok { labels = append(labels, label) } groups[label] = append(groups[label], day) } sort.Sort(sort.Reverse(sort.StringSlice(labels))) monthGroups := make([]MonthGroup, 0, len(labels)) for _, label := range labels { monthGroups = append(monthGroups, MonthGroup{ Label: label, Days: groups[label], }) } return monthGroups } func formatDuration(duration time.Duration) string { totalSeconds := int(duration.Round(time.Second) / time.Second) if totalSeconds < 0 { totalSeconds = 0 } hours := totalSeconds / 3600 minutes := (totalSeconds % 3600) / 60 seconds := totalSeconds % 60 if hours > 0 { return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds) } return fmt.Sprintf("%02d:%02d", minutes, seconds) } var videoTemplate = template.Must(template.ParseFS( webtemplates.FS, webtemplates.Base, webtemplates.Videos, ))