You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

257 lines
5.6 KiB

package image
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
}
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
Images []ImageItem
ActiveIndex int
PrevIndex int
NextIndex int
Current ImageItem
}
type ImageItem struct {
Index int
Filename string
RelPath string
RawURL string
ThumbURL string
Active bool
}
func NewHandler(cfg *config.Config, idx *footage.Index) *Handler {
return &Handler{
cfg: cfg,
idx: idx,
thumbs: NewCache(defaultCacheEntries),
}
}
func (h *Handler) ServeRaw(w http.ResponseWriter, r *http.Request) {
absPath, ok := h.resolveRequestPath(w, r)
if !ok {
return
}
http.ServeFile(w, r, absPath)
}
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.Images, parseIndex(r.URL.Query().Get("idx"), len(day.Images)))
h.render(w, r, ShellData{
Title: "CamMonitor " + date + " images",
CurrentDate: date,
ActiveTab: "images",
Content: data,
})
}
func (h *Handler) pageData(date string, files []footage.ImageFile, active int) PageData {
items := make([]ImageItem, 0, len(files))
for i, file := range files {
items = append(items, ImageItem{
Index: i,
Filename: file.Filename,
RelPath: filepath.ToSlash(file.RelPath),
RawURL: "/raw/image/" + pathEscape(file.RelPath),
ThumbURL: "/thumb/image/" + pathEscape(file.RelPath),
Active: i == active,
})
}
page := PageData{
Date: date,
Images: 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) {
absPath, err := h.resolveRelPath(chi.URLParam(r, "*"))
if err != nil {
http.Error(w, "invalid image 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 := imageTemplate.ExecuteTemplate(w, "base", data); err != nil {
http.Error(w, "render image 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
}
var imageTemplate = template.Must(template.ParseFS(
webtemplates.FS,
webtemplates.Base,
webtemplates.Images,
))