photodock/internal/handlers/handlers.go

2114 lines
60 KiB
Go

package handlers
import (
"bytes"
"context"
"crypto/rand"
"database/sql"
"embed"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
"io"
"io/fs"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/Alexander-D-Karpov/photodock/internal/config"
"github.com/Alexander-D-Karpov/photodock/internal/database"
"github.com/Alexander-D-Karpov/photodock/internal/models"
"github.com/Alexander-D-Karpov/photodock/internal/services"
)
type Handlers struct {
db *database.DB
cfg *config.Config
thumbSvc *services.ThumbnailService
scanSvc *services.ScannerService
tmpl *template.Template
webFS embed.FS
uploads map[string]*ChunkedUpload
uploadsMux sync.RWMutex
}
type ChunkedUpload struct {
ID string
Filename string
Size int64
FolderID *int
TempDir string
Chunks map[int]bool
CreatedAt time.Time
}
type IntPtrOrString struct {
V *int
}
func New(db *database.DB, cfg *config.Config, thumbSvc *services.ThumbnailService, scanSvc *services.ScannerService, webFS embed.FS) *Handlers {
funcMap := template.FuncMap{
"json": func(v interface{}) template.JS {
b, _ := json.Marshal(v)
return template.JS(b)
},
"formatSize": formatSize,
"formatDate": func(t time.Time) string {
return t.Format("2006-01-02 15:04")
},
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"int64": func(i int) int64 { return int64(i) },
"urlpath": escapeURLPath,
"mulf": func(a, b float64) float64 { return a * b },
"hasPrefix": strings.HasPrefix,
"iterate": func(n int) []int {
result := make([]int, n)
for i := range result {
result[i] = i
}
return result
},
"divf": func(a, b int) float64 {
if b == 0 {
return 1.0
}
return float64(a) / float64(b)
},
}
tmplFS, _ := fs.Sub(webFS, "web/templates")
tmpl := template.New("").Funcs(funcMap)
err := fs.WalkDir(tmplFS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".html") {
return err
}
content, err := fs.ReadFile(tmplFS, path)
if err != nil {
return err
}
_, err = tmpl.New(path).Parse(string(content))
return err
})
if err != nil {
log.Printf("template walk error: %v", err)
}
return &Handlers{
db: db,
cfg: cfg,
thumbSvc: thumbSvc,
scanSvc: scanSvc,
tmpl: tmpl,
webFS: webFS,
uploads: make(map[string]*ChunkedUpload),
}
}
func (x *IntPtrOrString) UnmarshalJSON(b []byte) error {
s := strings.TrimSpace(string(b))
if s == "null" {
x.V = nil
return nil
}
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
u, err := strconv.Unquote(s)
if err != nil {
return err
}
u = strings.TrimSpace(u)
if u == "" || u == "null" {
x.V = nil
return nil
}
i, err := strconv.Atoi(u)
if err != nil {
return err
}
x.V = &i
return nil
}
i64, err := strconv.ParseInt(s, 10, 0)
if err != nil {
return err
}
i := int(i64)
x.V = &i
return nil
}
func (h *Handlers) RegisterRoutes(mux *http.ServeMux) {
staticFS, _ := fs.Sub(h.webFS, "web/static")
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
mux.HandleFunc("GET /", h.publicIndex)
mux.HandleFunc("GET /folder/{id}", h.publicFolder)
mux.HandleFunc("GET /p/{path...}", h.publicPath)
mux.HandleFunc("GET /photo/{id}", h.publicPhotoByID)
mux.HandleFunc("GET /thumb/{size}/{id}", h.serveThumbnail)
mux.HandleFunc("GET /original/{id}", h.serveOriginal)
mux.HandleFunc("GET /placeholder/{id}", h.servePlaceholder)
mux.HandleFunc("GET /admin", h.adminAuth(h.adminDashboard))
mux.HandleFunc("GET /admin/stats", h.adminAuth(h.adminStats))
mux.HandleFunc("GET /api/stats", h.adminAuth(h.apiStats))
mux.HandleFunc("GET /admin/folders", h.adminAuth(h.adminFolders))
mux.HandleFunc("POST /admin/folders", h.adminAuth(h.adminCreateFolder))
mux.HandleFunc("GET /admin/folders/{id}", h.adminAuth(h.adminEditFolder))
mux.HandleFunc("POST /admin/folders/{id}", h.adminAuth(h.adminUpdateFolder))
mux.HandleFunc("DELETE /admin/folders/{id}", h.adminAuth(h.adminDeleteFolder))
mux.HandleFunc("POST /admin/folders/{id}/cover", h.adminAuth(h.adminSetCover))
mux.HandleFunc("GET /admin/photos", h.adminAuth(h.adminPhotos))
mux.HandleFunc("GET /admin/photos/{id}", h.adminAuth(h.adminEditPhoto))
mux.HandleFunc("POST /admin/photos/{id}", h.adminAuth(h.adminUpdatePhoto))
mux.HandleFunc("DELETE /admin/photos/{id}", h.adminAuth(h.adminDeletePhoto))
mux.HandleFunc("POST /admin/photos/{id}/hide", h.adminAuth(h.adminToggleHide))
mux.HandleFunc("POST /admin/photos/{id}/move", h.adminAuth(h.adminMovePhoto))
mux.HandleFunc("POST /admin/scan", h.adminAuth(h.adminScan))
mux.HandleFunc("POST /admin/scan/{id}", h.adminAuth(h.adminScanFolder))
mux.HandleFunc("POST /admin/clean", h.adminAuth(h.adminClean))
mux.HandleFunc("POST /admin/regenerate-urls", h.adminAuth(h.adminRegenerateURLs))
mux.HandleFunc("POST /admin/upload", h.adminAuth(h.adminUpload))
mux.HandleFunc("POST /admin/upload/file", h.adminAuth(h.adminUploadFile))
mux.HandleFunc("POST /admin/upload/init", h.adminAuth(h.adminUploadInit))
mux.HandleFunc("POST /admin/upload/chunk", h.adminAuth(h.adminUploadChunk))
mux.HandleFunc("POST /admin/upload/finalize", h.adminAuth(h.adminUploadFinalize))
mux.HandleFunc("GET /api/folders", h.apiListFolders)
mux.HandleFunc("GET /api/folders/{id}", h.apiGetFolder)
mux.HandleFunc("GET /api/photos", h.apiListPhotos)
mux.HandleFunc("GET /api/photos/{id}", h.apiGetPhoto)
mux.HandleFunc("GET /api/random", h.apiRandomPhoto)
mux.HandleFunc("GET /random", h.publicRandomPhoto)
mux.HandleFunc("POST /admin/reprocess", h.adminAuth(h.adminReprocess))
}
func (h *Handlers) adminAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || user != h.cfg.AdminUser || pass != h.cfg.AdminPass {
w.Header().Set("WWW-Authenticate", `Basic realm="Admin"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next(w, r)
}
}
func (h *Handlers) publicIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
ctx := r.Context()
if r.URL.Query().Get("ajax") == "1" {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
h.jsonPhotosPage(w, r, ctx, nil, page)
return
}
folders, _ := h.getRootFolders(ctx)
photos, _ := h.getRootPhotos(ctx)
var photoCount, folderCount int
var totalSize int64
_ = h.db.Pool().QueryRow(ctx, "SELECT COUNT(*) FROM photos WHERE hidden = false").Scan(&photoCount)
_ = h.db.Pool().QueryRow(ctx, "SELECT COUNT(*) FROM folders WHERE parent_id IS NULL").Scan(&folderCount)
_ = h.db.Pool().QueryRow(ctx, "SELECT COALESCE(SUM(size_bytes), 0) FROM photos WHERE hidden = false").Scan(&totalSize)
h.render(w, "public/index.html", map[string]interface{}{
"Folders": folders,
"Photos": photos,
"Title": "Index",
"PhotoCount": photoCount,
"FolderCount": folderCount,
"TotalSize": totalSize,
})
}
func (h *Handlers) jsonPhotosPage(w http.ResponseWriter, r *http.Request, ctx context.Context, folderID *int, page int) {
const perPage = 50
offset := (page - 1) * perPage
var where string
var args []interface{}
if folderID != nil {
where = "folder_id = $1 AND hidden = false"
args = []interface{}{*folderID, perPage, offset}
} else {
where = "folder_id IS NULL AND hidden = false"
args = []interface{}{perPage, offset}
}
query := fmt.Sprintf(`
SELECT id, filename, COALESCE(url_path, ''), title, size_bytes, blurhash,
COALESCE(EXTRACT(EPOCH FROM taken_at), EXTRACT(EPOCH FROM created_at))::bigint as date
FROM photos WHERE %s
ORDER BY COALESCE(taken_at, created_at) DESC, id DESC
LIMIT $%d OFFSET $%d`, where, len(args)-1, len(args))
if folderID != nil {
args = []interface{}{*folderID, perPage, offset}
} else {
args = []interface{}{perPage, offset}
}
rows, err := h.db.Pool().Query(ctx, query, args...)
if err != nil {
h.jsonResponse(w, map[string]interface{}{"photos": []interface{}{}, "hasMore": false})
return
}
defer rows.Close()
type photoJSON struct {
ID int `json:"id"`
Filename string `json:"filename"`
URL string `json:"url"`
Title string `json:"title"`
Size int64 `json:"size"`
Blurhash string `json:"blurhash"`
Date int64 `json:"date"`
}
var photos []photoJSON
for rows.Next() {
var p photoJSON
var urlPath string
var title sql.NullString
var blurhash sql.NullString
if err := rows.Scan(&p.ID, &p.Filename, &urlPath, &title, &p.Size, &blurhash, &p.Date); err != nil {
continue
}
if urlPath != "" {
p.URL = "/p/" + urlPath
} else {
p.URL = fmt.Sprintf("/photo/%d", p.ID)
}
if title.Valid {
p.Title = title.String
}
if blurhash.Valid {
p.Blurhash = blurhash.String
}
photos = append(photos, p)
}
var totalCount int
if folderID != nil {
_ = h.db.Pool().QueryRow(ctx, "SELECT COUNT(*) FROM photos WHERE folder_id = $1 AND hidden = false", *folderID).Scan(&totalCount)
} else {
_ = h.db.Pool().QueryRow(ctx, "SELECT COUNT(*) FROM photos WHERE folder_id IS NULL AND hidden = false").Scan(&totalCount)
}
hasMore := page*perPage < totalCount
h.jsonResponse(w, map[string]interface{}{
"photos": photos,
"hasMore": hasMore,
"page": page,
"total": totalCount,
})
}
func (h *Handlers) publicFolder(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
ctx := r.Context()
var folderPath string
if err := h.db.Pool().QueryRow(ctx, "SELECT path FROM folders WHERE id = $1", id).Scan(&folderPath); err != nil {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/p/"+escapeURLPath(folderPath)+"/", http.StatusMovedPermanently)
}
func (h *Handlers) publicPath(w http.ResponseWriter, r *http.Request) {
raw := r.PathValue("path")
if raw == "" {
http.Redirect(w, r, "/", http.StatusMovedPermanently)
return
}
isFolderReq := strings.HasSuffix(r.URL.Path, "/")
cleaned := strings.Trim(raw, "/")
if cleaned == "" {
http.Redirect(w, r, "/", http.StatusMovedPermanently)
return
}
if isFolderReq {
folder, err := h.getFolderByPath(r.Context(), cleaned)
if err != nil {
http.NotFound(w, r)
return
}
h.renderFolder(w, r, folder)
return
}
if _, err := h.getFolderByPath(r.Context(), cleaned); err == nil {
http.Redirect(w, r, "/p/"+escapeURLPath(cleaned)+"/", http.StatusMovedPermanently)
return
}
photo, err := h.getPhotoByURLPath(r.Context(), cleaned)
if err != nil {
http.NotFound(w, r)
return
}
h.renderPhoto(w, r, photo)
}
func (h *Handlers) getFolderByPath(ctx context.Context, path string) (*models.Folder, error) {
var folder models.Folder
err := h.db.Pool().QueryRow(ctx,
"SELECT id, parent_id, name, path FROM folders WHERE path = $1", path).
Scan(&folder.ID, &folder.ParentID, &folder.Name, &folder.Path)
if err != nil {
return nil, err
}
return &folder, nil
}
func (h *Handlers) renderFolder(w http.ResponseWriter, r *http.Request, folder *models.Folder) {
ctx := r.Context()
subfolders, _ := h.getSubfolders(ctx, folder.ID)
photos, _ := h.getFolderPhotos(ctx, folder.ID)
breadcrumbs := h.getBreadcrumbs(ctx, folder)
parentURL := "/"
if folder.ParentID.Valid {
var parentPath string
if err := h.db.Pool().QueryRow(ctx, "SELECT path FROM folders WHERE id = $1", folder.ParentID.Int64).Scan(&parentPath); err == nil {
parentURL = "/p/" + escapeURLPath(parentPath) + "/"
}
}
h.render(w, "public/folder.html", map[string]interface{}{
"Folder": *folder,
"Subfolders": subfolders,
"Photos": photos,
"Breadcrumbs": breadcrumbs,
"ParentURL": parentURL,
"Title": folder.Name,
})
}
func (h *Handlers) publicPhotoByID(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
photo, err := h.getPhotoByID(r.Context(), id)
if err != nil {
http.NotFound(w, r)
return
}
if photo.URLPath != "" {
http.Redirect(w, r, "/p/"+photo.URLPath, http.StatusMovedPermanently)
return
}
h.renderPhoto(w, r, photo)
}
func (h *Handlers) renderPhoto(w http.ResponseWriter, r *http.Request, photo *models.Photo) {
ctx := r.Context()
var exifInfo models.ExifInfo
if photo.ExifData != nil {
_ = json.Unmarshal(photo.ExifData, &exifInfo)
}
prevURL, nextURL, prevID, nextID := h.getAdjacentPhotoInfo(ctx, photo)
breadcrumbs := h.getPhotoBreadcrumbs(ctx, photo)
position, total := h.getPhotoPosition(ctx, photo)
title := photo.Filename
if photo.Title.Valid && photo.Title.String != "" {
title = photo.Title.String
}
folderURL := "/"
if len(breadcrumbs) > 0 {
folderURL = "/p/" + escapeURLPath(breadcrumbs[len(breadcrumbs)-1].Path) + "/"
}
baseURL := "https://" + r.Host
if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
baseURL = "http://" + r.Host
}
previewWidth := 1920
previewHeight := 0
if photo.Width > 0 && photo.Height > 0 {
previewHeight = int(float64(photo.Height) * (float64(previewWidth) / float64(photo.Width)))
if photo.Width < previewWidth {
previewWidth = photo.Width
previewHeight = photo.Height
}
}
var colorInfo *models.ColorInfo
if photo.ExifData != nil {
var combined struct {
Colors *models.ColorInfo `json:"colors"`
}
_ = json.Unmarshal(photo.ExifData, &combined)
colorInfo = combined.Colors
}
h.render(w, "public/photo.html", map[string]interface{}{
"Photo": photo,
"ExifInfo": exifInfo,
"PrevURL": prevURL,
"NextURL": nextURL,
"PrevID": prevID,
"NextID": nextID,
"Breadcrumbs": breadcrumbs,
"Title": title,
"FolderURL": folderURL,
"PhotoPosition": position,
"PhotoTotal": total,
"BaseURL": baseURL,
"PreviewWidth": previewWidth,
"PreviewHeight": previewHeight,
"ColorInfo": colorInfo,
})
}
func escapeURLPath(p string) string {
parts := strings.Split(p, "/")
for i := range parts {
parts[i] = url.PathEscape(parts[i])
}
return strings.Join(parts, "/")
}
func (h *Handlers) serveThumbnail(w http.ResponseWriter, r *http.Request) {
size := r.PathValue("size")
id, _ := strconv.Atoi(r.PathValue("id"))
if size != "small" && size != "medium" && size != "large" {
http.NotFound(w, r)
return
}
var path string
if err := h.db.Pool().QueryRow(r.Context(), "SELECT path FROM photos WHERE id = $1", id).Scan(&path); err != nil {
http.NotFound(w, r)
return
}
thumbPath, err := h.thumbSvc.GetThumbnailPathByID(id, path, size)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
contentType := "image/jpeg"
if strings.HasSuffix(strings.ToLower(path), ".png") {
contentType = "image/png"
}
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("Content-Type", contentType)
if r.Header.Get("X-Real-IP") != "" {
w.Header().Set("X-Accel-Redirect", fmt.Sprintf("/internal/cache/%s/%d%s", size, id, filepath.Ext(thumbPath)))
return
}
http.ServeFile(w, r, thumbPath)
}
func (h *Handlers) servePlaceholder(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
var blurhash string
if err := h.db.Pool().QueryRow(r.Context(), "SELECT COALESCE(blurhash, '') FROM photos WHERE id = $1", id).Scan(&blurhash); err != nil {
http.NotFound(w, r)
return
}
placeholderPath, err := h.thumbSvc.GetPlaceholderPathByID(id, blurhash)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
if r.Header.Get("X-Real-IP") != "" {
w.Header().Set("X-Accel-Redirect", fmt.Sprintf("/internal/cache/placeholder/%d.png", id))
w.Header().Set("Content-Type", "image/png")
return
}
http.ServeFile(w, r, placeholderPath)
}
func (h *Handlers) adminDeletePhoto(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
ctx := r.Context()
var path string
_ = h.db.Pool().QueryRow(ctx, "SELECT path FROM photos WHERE id = $1", id).Scan(&path)
_, _ = h.db.Pool().Exec(ctx, "DELETE FROM photos WHERE id = $1", id)
if path != "" {
_ = h.thumbSvc.DeleteThumbnailsByID(id)
_ = os.Remove(filepath.Join(h.cfg.MediaRoot, path))
}
w.WriteHeader(http.StatusOK)
}
func (h *Handlers) serveOriginal(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
var path string
var hidden bool
err := h.db.Pool().QueryRow(r.Context(), "SELECT path, hidden FROM photos WHERE id = $1", id).Scan(&path, &hidden)
if err != nil || hidden || !h.isPathSafe(path) {
http.NotFound(w, r)
return
}
if r.Header.Get("X-Real-IP") != "" {
w.Header().Set("X-Accel-Redirect", "/internal/photos/"+path)
w.Header().Set("Content-Type", "image/jpeg")
if strings.HasSuffix(strings.ToLower(path), ".png") {
w.Header().Set("Content-Type", "image/png")
}
return
}
w.Header().Set("Cache-Control", "public, max-age=31536000")
http.ServeFile(w, r, filepath.Join(h.cfg.MediaRoot, path))
}
func (h *Handlers) adminDashboard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var photoCount, folderCount, hiddenCount int
var totalSize int64
_ = h.db.Pool().QueryRow(ctx, "SELECT COUNT(*) FROM photos").Scan(&photoCount)
_ = h.db.Pool().QueryRow(ctx, "SELECT COUNT(*) FROM folders").Scan(&folderCount)
_ = h.db.Pool().QueryRow(ctx, "SELECT COUNT(*) FROM photos WHERE hidden = true").Scan(&hiddenCount)
_ = h.db.Pool().QueryRow(ctx, "SELECT COALESCE(SUM(size_bytes), 0) FROM photos").Scan(&totalSize)
folders, _ := h.getAllFolders(ctx)
h.render(w, "admin/dashboard.html", map[string]interface{}{
"PhotoCount": photoCount,
"FolderCount": folderCount,
"HiddenCount": hiddenCount,
"TotalSize": totalSize,
"Folders": folders,
"Title": "Admin Dashboard",
})
}
func (h *Handlers) adminFolders(w http.ResponseWriter, r *http.Request) {
folders, err := h.getFolderTree(r.Context())
if err != nil {
http.Error(w, err.Error(), 500)
return
}
h.render(w, "admin/folders.html", map[string]interface{}{
"Folders": folders,
"Title": "Manage Folders",
})
}
func (h *Handlers) adminCreateFolder(w http.ResponseWriter, r *http.Request) {
name := sanitizeFilename(r.FormValue("name"))
if name == "" || name == "." || name == ".." {
http.Error(w, "Invalid name", 400)
return
}
ctx := r.Context()
var parentID *int
var parentPath string
if pidStr := r.FormValue("parent_id"); pidStr != "" {
pid, _ := strconv.Atoi(pidStr)
parentID = &pid
_ = h.db.Pool().QueryRow(ctx, "SELECT path FROM folders WHERE id = $1", pid).Scan(&parentPath)
}
path := name
if parentPath != "" {
path = filepath.Join(parentPath, name)
}
if err := os.MkdirAll(filepath.Join(h.cfg.MediaRoot, path), 0755); err != nil {
http.Error(w, err.Error(), 500)
return
}
_, _ = h.db.Pool().Exec(ctx,
"INSERT INTO folders (parent_id, name, path) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
parentID, name, path)
http.Redirect(w, r, "/admin/folders", http.StatusSeeOther)
}
func (h *Handlers) adminEditFolder(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
ctx := r.Context()
var folder models.Folder
err := h.db.Pool().QueryRow(ctx,
"SELECT id, parent_id, name, path, cover_photo_id FROM folders WHERE id = $1", id).
Scan(&folder.ID, &folder.ParentID, &folder.Name, &folder.Path, &folder.CoverPhotoID)
if err != nil {
http.NotFound(w, r)
return
}
photos, _ := h.getFolderPhotos(ctx, id)
allFolders, _ := h.getAllFolders(ctx)
h.render(w, "admin/folder_edit.html", map[string]interface{}{
"Folder": folder,
"Photos": photos,
"AllFolders": allFolders,
"Title": "Edit " + folder.Name,
})
}
func (h *Handlers) adminUpdateFolder(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
name := sanitizeFilename(r.FormValue("name"))
if name == "" || name == "." || name == ".." {
http.Error(w, "Invalid name", 400)
return
}
_, _ = h.db.Pool().Exec(r.Context(), "UPDATE folders SET name = $1, updated_at = NOW() WHERE id = $2", name, id)
http.Redirect(w, r, "/admin/folders", http.StatusSeeOther)
}
func (h *Handlers) adminDeleteFolder(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
_, _ = h.db.Pool().Exec(r.Context(), "DELETE FROM folders WHERE id = $1", id)
w.WriteHeader(http.StatusOK)
}
func (h *Handlers) adminSetCover(w http.ResponseWriter, r *http.Request) {
folderID, _ := strconv.Atoi(r.PathValue("id"))
var photoID *int
if pidStr := r.FormValue("photo_id"); pidStr != "" {
pid, _ := strconv.Atoi(pidStr)
photoID = &pid
}
_, _ = h.db.Pool().Exec(r.Context(),
"UPDATE folders SET cover_photo_id = $1, updated_at = NOW() WHERE id = $2",
photoID, folderID)
w.WriteHeader(http.StatusOK)
}
func (h *Handlers) adminPhotos(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
const perPage = 50
offset := (page - 1) * perPage
folderFilter := r.URL.Query().Get("folder")
showHidden := r.URL.Query().Get("hidden") == "1"
searchQuery := r.URL.Query().Get("q")
query := "SELECT id, folder_id, filename, path, title, hidden, width, height FROM photos WHERE 1=1"
countQuery := "SELECT COUNT(*) FROM photos WHERE 1=1"
var args []interface{}
argIdx := 1
if searchQuery != "" {
query += fmt.Sprintf(" AND (filename ILIKE $%d OR title ILIKE $%d OR description ILIKE $%d)", argIdx, argIdx, argIdx)
countQuery += fmt.Sprintf(" AND (filename ILIKE $%d OR title ILIKE $%d OR description ILIKE $%d)", argIdx, argIdx, argIdx)
args = append(args, "%"+searchQuery+"%")
argIdx++
}
if folderFilter == "root" {
query += " AND folder_id IS NULL"
countQuery += " AND folder_id IS NULL"
} else if folderFilter != "" {
fid, _ := strconv.Atoi(folderFilter)
query += fmt.Sprintf(" AND folder_id = $%d", argIdx)
countQuery += fmt.Sprintf(" AND folder_id = $%d", argIdx)
args = append(args, fid)
argIdx++
}
if !showHidden {
query += " AND hidden = false"
countQuery += " AND hidden = false"
}
var totalCount int
_ = h.db.Pool().QueryRow(ctx, countQuery, args...).Scan(&totalCount)
query += fmt.Sprintf(" ORDER BY COALESCE(taken_at, created_at) DESC, id DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
args = append(args, perPage, offset)
rows, _ := h.db.Pool().Query(ctx, query, args...)
defer rows.Close()
var photos []models.Photo
for rows.Next() {
var p models.Photo
if err := rows.Scan(&p.ID, &p.FolderID, &p.Filename, &p.Path, &p.Title, &p.Hidden, &p.Width, &p.Height); err != nil {
continue
}
photos = append(photos, p)
}
folders, _ := h.getAllFolders(ctx)
h.render(w, "admin/photos.html", map[string]interface{}{
"Photos": photos,
"Folders": folders,
"CurrentPage": page,
"TotalPages": (totalCount + perPage - 1) / perPage,
"TotalCount": totalCount,
"FolderFilter": folderFilter,
"ShowHidden": showHidden,
"SearchQuery": searchQuery,
"Title": "Manage Photos",
})
}
func (h *Handlers) adminEditPhoto(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
ctx := r.Context()
var photo models.Photo
err := h.db.Pool().QueryRow(ctx,
`SELECT id, folder_id, filename, path, COALESCE(url_path, ''), title, description, note,
width, height, size_bytes, exif_data, hidden, created_at, taken_at
FROM photos WHERE id = $1`, id).
Scan(&photo.ID, &photo.FolderID, &photo.Filename, &photo.Path, &photo.URLPath,
&photo.Title, &photo.Description, &photo.Note,
&photo.Width, &photo.Height, &photo.SizeBytes,
&photo.ExifData, &photo.Hidden, &photo.CreatedAt, &photo.TakenAt)
if err != nil {
http.NotFound(w, r)
return
}
var exifInfo models.ExifInfo
if photo.ExifData != nil {
_ = json.Unmarshal(photo.ExifData, &exifInfo)
}
folders, _ := h.getAllFolders(ctx)
h.render(w, "admin/photo_edit.html", map[string]interface{}{
"Photo": photo,
"ExifInfo": exifInfo,
"Folders": folders,
"Title": "Edit " + photo.Filename,
})
}
func (h *Handlers) adminUpdatePhoto(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
var folderID *int
if fidStr := r.FormValue("folder_id"); fidStr != "" && fidStr != "null" {
fid, _ := strconv.Atoi(fidStr)
folderID = &fid
}
_, _ = h.db.Pool().Exec(r.Context(),
`UPDATE photos SET title = NULLIF($1, ''), description = NULLIF($2, ''),
note = NULLIF($3, ''), folder_id = $4, updated_at = NOW() WHERE id = $5`,
r.FormValue("title"), r.FormValue("description"), r.FormValue("note"), folderID, id)
http.Redirect(w, r, fmt.Sprintf("/admin/photos/%d", id), http.StatusSeeOther)
}
func (h *Handlers) adminToggleHide(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
_, _ = h.db.Pool().Exec(r.Context(), "UPDATE photos SET hidden = NOT hidden, updated_at = NOW() WHERE id = $1", id)
w.WriteHeader(http.StatusOK)
}
func (h *Handlers) adminMovePhoto(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
var folderID *int
if fidStr := r.FormValue("folder_id"); fidStr != "" {
fid, _ := strconv.Atoi(fidStr)
if fid > 0 {
folderID = &fid
}
}
_, _ = h.db.Pool().Exec(r.Context(), "UPDATE photos SET folder_id = $1, updated_at = NOW() WHERE id = $2", folderID, id)
w.WriteHeader(http.StatusOK)
}
func (h *Handlers) adminScan(w http.ResponseWriter, r *http.Request) {
go func() {
_ = h.scanSvc.ScanAll(context.Background())
}()
h.jsonResponse(w, map[string]string{"status": "started"})
}
func (h *Handlers) adminScanFolder(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
var path string
if err := h.db.Pool().QueryRow(r.Context(), "SELECT path FROM folders WHERE id = $1", id).Scan(&path); err != nil {
http.NotFound(w, r)
return
}
go func() {
_ = h.scanSvc.ScanFolder(context.Background(), path)
}()
h.jsonResponse(w, map[string]string{"status": "started"})
}
func (h *Handlers) adminClean(w http.ResponseWriter, r *http.Request) {
go func() {
_ = h.scanSvc.CleanOrphans(context.Background())
}()
h.jsonResponse(w, map[string]string{"status": "started"})
}
func (h *Handlers) adminRegenerateURLs(w http.ResponseWriter, r *http.Request) {
go func() {
_ = h.scanSvc.RegenerateURLPaths(context.Background())
}()
h.jsonResponse(w, map[string]string{"status": "started"})
}
func (h *Handlers) adminUpload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(100 << 20); err != nil {
http.Error(w, err.Error(), 400)
return
}
ctx := r.Context()
var folderPath string
if fidStr := r.FormValue("folder_id"); fidStr != "" && fidStr != "null" {
fid, _ := strconv.Atoi(fidStr)
_ = h.db.Pool().QueryRow(ctx, "SELECT path FROM folders WHERE id = $1", fid).Scan(&folderPath)
}
for _, fh := range r.MultipartForm.File["files"] {
if !isImageFile(fh.Filename) {
continue
}
filename := sanitizeFilename(fh.Filename)
relPath := filename
if folderPath != "" {
relPath = filepath.Join(folderPath, filename)
}
absPath := h.resolveConflict(filepath.Join(h.cfg.MediaRoot, relPath))
if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
continue
}
file, err := fh.Open()
if err != nil {
continue
}
dst, err := os.Create(absPath)
if err != nil {
_ = file.Close()
continue
}
_, _ = io.Copy(dst, file)
_ = dst.Close()
_ = file.Close()
}
go func() {
_ = h.scanSvc.ScanFolder(context.Background(), folderPath)
}()
http.Redirect(w, r, "/admin/photos", http.StatusSeeOther)
}
func (h *Handlers) adminUploadFile(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, err.Error(), 400)
return
}
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, err.Error(), 400)
return
}
defer func() { _ = file.Close() }()
if !isImageFile(header.Filename) {
http.Error(w, "Invalid file type", 400)
return
}
ctx := r.Context()
var folderPath string
if fidStr := r.FormValue("folder_id"); fidStr != "" {
fid, _ := strconv.Atoi(fidStr)
_ = h.db.Pool().QueryRow(ctx, "SELECT path FROM folders WHERE id = $1", fid).Scan(&folderPath)
}
filename := sanitizeFilename(header.Filename)
relPath := filename
if folderPath != "" {
relPath = filepath.Join(folderPath, filename)
}
absPath := h.resolveConflict(filepath.Join(h.cfg.MediaRoot, relPath))
if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
http.Error(w, err.Error(), 500)
return
}
dst, err := os.Create(absPath)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer func() { _ = dst.Close() }()
if _, err := io.Copy(dst, file); err != nil {
http.Error(w, err.Error(), 500)
return
}
go func() {
_ = h.scanSvc.ScanFolder(context.Background(), folderPath)
}()
h.jsonResponse(w, map[string]string{"status": "ok"})
}
func (h *Handlers) adminUploadFinalize(w http.ResponseWriter, r *http.Request) {
var req struct {
UploadID string `json:"upload_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), 400)
return
}
h.uploadsMux.Lock()
upload, exists := h.uploads[req.UploadID]
if exists {
delete(h.uploads, req.UploadID)
}
h.uploadsMux.Unlock()
if !exists {
http.Error(w, "Upload not found", 404)
return
}
defer func() { _ = os.RemoveAll(upload.TempDir) }()
ctx := r.Context()
var folderPath string
if upload.FolderID != nil {
_ = h.db.Pool().QueryRow(ctx, "SELECT path FROM folders WHERE id = $1", *upload.FolderID).Scan(&folderPath)
}
relPath := upload.Filename
if folderPath != "" {
relPath = filepath.Join(folderPath, upload.Filename)
}
absPath := h.resolveConflict(filepath.Join(h.cfg.MediaRoot, relPath))
if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
http.Error(w, err.Error(), 500)
return
}
dst, err := os.Create(absPath)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer func() { _ = dst.Close() }()
for i := 0; i < len(upload.Chunks); i++ {
chunk, err := os.Open(filepath.Join(upload.TempDir, fmt.Sprintf("chunk_%d", i)))
if err != nil {
http.Error(w, err.Error(), 500)
return
}
_, _ = io.Copy(dst, chunk)
_ = chunk.Close()
}
go func() {
_ = h.scanSvc.ScanFolder(context.Background(), folderPath)
}()
h.jsonResponse(w, map[string]string{"status": "ok"})
}
func (h *Handlers) adminUploadInit(w http.ResponseWriter, r *http.Request) {
var req struct {
Filename string `json:"filename"`
Size int64 `json:"size"`
FolderID IntPtrOrString `json:"folder_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), 400)
return
}
if !isImageFile(req.Filename) {
http.Error(w, "Invalid file type", 400)
return
}
uploadID := fmt.Sprintf("%d-%s", time.Now().UnixNano(), randString(8))
tempDir := filepath.Join(h.cfg.CacheDir, "uploads", uploadID)
if err := os.MkdirAll(tempDir, 0755); err != nil {
http.Error(w, err.Error(), 500)
return
}
h.uploadsMux.Lock()
h.uploads[uploadID] = &ChunkedUpload{
ID: uploadID,
Filename: sanitizeFilename(req.Filename),
Size: req.Size,
FolderID: req.FolderID.V,
TempDir: tempDir,
Chunks: make(map[int]bool),
CreatedAt: time.Now(),
}
h.uploadsMux.Unlock()
h.jsonResponse(w, map[string]string{"upload_id": uploadID})
}
func (h *Handlers) adminUploadChunk(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(2 << 20); err != nil {
http.Error(w, err.Error(), 400)
return
}
uploadID := r.FormValue("upload_id")
chunkIndex, _ := strconv.Atoi(r.FormValue("chunk_index"))
h.uploadsMux.RLock()
upload, exists := h.uploads[uploadID]
h.uploadsMux.RUnlock()
if !exists {
http.Error(w, "Upload not found", 404)
return
}
file, _, err := r.FormFile("chunk")
if err != nil {
http.Error(w, err.Error(), 400)
return
}
defer func() { _ = file.Close() }()
chunkPath := filepath.Join(upload.TempDir, fmt.Sprintf("chunk_%d", chunkIndex))
dst, err := os.Create(chunkPath)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer func() { _ = dst.Close() }()
if _, err := io.Copy(dst, file); err != nil {
http.Error(w, err.Error(), 500)
return
}
h.uploadsMux.Lock()
upload.Chunks[chunkIndex] = true
h.uploadsMux.Unlock()
h.jsonResponse(w, map[string]string{"status": "ok"})
}
func (h *Handlers) render(w http.ResponseWriter, name string, data map[string]interface{}) {
var buf bytes.Buffer
if err := h.tmpl.ExecuteTemplate(&buf, name, data); err != nil {
log.Printf("ERROR render %s: %v", name, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = buf.WriteTo(w)
}
func (h *Handlers) jsonResponse(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(data)
}
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &responseWriter{ResponseWriter: w, status: 200}
next.ServeHTTP(rw, r)
duration := time.Since(start)
if rw.status >= 400 || duration > 2*time.Second {
log.Printf("%s %s %d %s", r.Method, r.URL.Path, rw.status, duration)
}
})
}
type responseWriter struct {
http.ResponseWriter
status int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.status = code
rw.ResponseWriter.WriteHeader(code)
}
func (h *Handlers) getPhotoByID(ctx context.Context, id int) (*models.Photo, error) {
var photo models.Photo
err := h.db.Pool().QueryRow(ctx,
`SELECT id, folder_id, filename, path, COALESCE(url_path, ''), title, description, note,
width, height, size_bytes, blurhash, exif_data, hidden, created_at, taken_at
FROM photos WHERE id = $1 AND hidden = false`, id).
Scan(&photo.ID, &photo.FolderID, &photo.Filename, &photo.Path, &photo.URLPath,
&photo.Title, &photo.Description, &photo.Note,
&photo.Width, &photo.Height, &photo.SizeBytes, &photo.Blurhash,
&photo.ExifData, &photo.Hidden, &photo.CreatedAt, &photo.TakenAt)
return &photo, err
}
func (h *Handlers) getPhotoByURLPath(ctx context.Context, urlPath string) (*models.Photo, error) {
var photo models.Photo
err := h.db.Pool().QueryRow(ctx,
`SELECT id, folder_id, filename, path, url_path, title, description, note,
width, height, size_bytes, blurhash, exif_data, hidden, created_at, taken_at
FROM photos WHERE url_path = $1 AND hidden = false`, urlPath).
Scan(&photo.ID, &photo.FolderID, &photo.Filename, &photo.Path, &photo.URLPath,
&photo.Title, &photo.Description, &photo.Note,
&photo.Width, &photo.Height, &photo.SizeBytes, &photo.Blurhash,
&photo.ExifData, &photo.Hidden, &photo.CreatedAt, &photo.TakenAt)
return &photo, err
}
func (h *Handlers) getAdjacentPhotoInfo(ctx context.Context, photo *models.Photo) (prevURL, nextURL string, prevID, nextID int) {
var prev, next struct {
ID int
URLPath string
}
sortTime := photo.CreatedAt
if photo.TakenAt.Valid {
sortTime = photo.TakenAt.Time
}
if photo.FolderID.Valid {
_ = h.db.Pool().QueryRow(ctx,
`SELECT id, COALESCE(url_path, '') FROM photos
WHERE folder_id = $1 AND hidden = false
AND (COALESCE(taken_at, created_at) > $2 OR (COALESCE(taken_at, created_at) = $2 AND id > $3))
ORDER BY COALESCE(taken_at, created_at) ASC, id ASC LIMIT 1`,
photo.FolderID.Int64, sortTime, photo.ID).Scan(&prev.ID, &prev.URLPath)
_ = h.db.Pool().QueryRow(ctx,
`SELECT id, COALESCE(url_path, '') FROM photos
WHERE folder_id = $1 AND hidden = false
AND (COALESCE(taken_at, created_at) < $2 OR (COALESCE(taken_at, created_at) = $2 AND id < $3))
ORDER BY COALESCE(taken_at, created_at) DESC, id DESC LIMIT 1`,
photo.FolderID.Int64, sortTime, photo.ID).Scan(&next.ID, &next.URLPath)
} else {
_ = h.db.Pool().QueryRow(ctx,
`SELECT id, COALESCE(url_path, '') FROM photos
WHERE folder_id IS NULL AND hidden = false
AND (COALESCE(taken_at, created_at) > $1 OR (COALESCE(taken_at, created_at) = $1 AND id > $2))
ORDER BY COALESCE(taken_at, created_at) ASC, id ASC LIMIT 1`,
sortTime, photo.ID).Scan(&prev.ID, &prev.URLPath)
_ = h.db.Pool().QueryRow(ctx,
`SELECT id, COALESCE(url_path, '') FROM photos
WHERE folder_id IS NULL AND hidden = false
AND (COALESCE(taken_at, created_at) < $1 OR (COALESCE(taken_at, created_at) = $1 AND id < $2))
ORDER BY COALESCE(taken_at, created_at) DESC, id DESC LIMIT 1`,
sortTime, photo.ID).Scan(&next.ID, &next.URLPath)
}
if prev.ID > 0 {
prevID = prev.ID
if prev.URLPath != "" {
prevURL = "/p/" + prev.URLPath
} else {
prevURL = fmt.Sprintf("/photo/%d", prev.ID)
}
}
if next.ID > 0 {
nextID = next.ID
if next.URLPath != "" {
nextURL = "/p/" + next.URLPath
} else {
nextURL = fmt.Sprintf("/photo/%d", next.ID)
}
}
return
}
func (h *Handlers) getPhotoPosition(ctx context.Context, photo *models.Photo) (position, total int) {
_ = h.db.Pool().QueryRow(ctx,
`SELECT COUNT(*) FROM photos WHERE folder_id IS NOT DISTINCT FROM $1 AND hidden = false`,
photo.FolderID).Scan(&total)
_ = h.db.Pool().QueryRow(ctx,
`SELECT COUNT(*) + 1 FROM photos
WHERE folder_id IS NOT DISTINCT FROM $1 AND hidden = false
AND (COALESCE(taken_at, created_at), id) > (COALESCE($2, $3), $4)`,
photo.FolderID, photo.TakenAt, photo.CreatedAt, photo.ID).Scan(&position)
return
}
func (h *Handlers) getPhotoBreadcrumbs(ctx context.Context, photo *models.Photo) []models.Folder {
if !photo.FolderID.Valid {
return nil
}
var folder models.Folder
if err := h.db.Pool().QueryRow(ctx, "SELECT id, parent_id, name, path FROM folders WHERE id = $1",
photo.FolderID.Int64).Scan(&folder.ID, &folder.ParentID, &folder.Name, &folder.Path); err != nil {
return nil
}
return h.getBreadcrumbs(ctx, &folder)
}
func (h *Handlers) getRootFolders(ctx context.Context) ([]models.Folder, error) {
return h.getFoldersWithCounts(ctx, "parent_id IS NULL")
}
func (h *Handlers) getSubfolders(ctx context.Context, parentID int) ([]models.Folder, error) {
return h.getFoldersWithCounts(ctx, fmt.Sprintf("parent_id = %d", parentID))
}
func (h *Handlers) getFoldersWithCounts(ctx context.Context, where string) ([]models.Folder, error) {
query := fmt.Sprintf(`
SELECT f.id, f.parent_id, f.name, f.path, f.cover_photo_id, f.created_at,
(SELECT COUNT(*) FROM photos WHERE folder_id = f.id AND hidden = false) as photo_count,
(SELECT COUNT(*) FROM folders WHERE parent_id = f.id) as subfolder_count,
(SELECT COALESCE(SUM(size_bytes), 0) FROM photos WHERE folder_id = f.id AND hidden = false) as total_size,
(SELECT ARRAY(
SELECT p.id FROM photos p WHERE p.folder_id = f.id AND p.hidden = false
ORDER BY COALESCE(p.taken_at, p.created_at) DESC, p.id DESC LIMIT 4
)) as preview_ids
FROM folders f WHERE %s ORDER BY f.created_at DESC`, where)
rows, err := h.db.Pool().Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var folders []models.Folder
for rows.Next() {
var f models.Folder
var previewIDs []int64
if err := rows.Scan(&f.ID, &f.ParentID, &f.Name, &f.Path, &f.CoverPhotoID, &f.CreatedAt,
&f.PhotoCount, &f.SubfolderCount, &f.TotalSize, &previewIDs); err != nil {
continue
}
for _, pid := range previewIDs {
f.PreviewURLs = append(f.PreviewURLs, fmt.Sprintf("/thumb/small/%d", pid))
}
if len(f.PreviewURLs) > 0 {
f.CoverURL = f.PreviewURLs[0]
}
folders = append(folders, f)
}
return folders, nil
}
func (h *Handlers) getRootPhotos(ctx context.Context) ([]models.Photo, error) {
return h.getPhotos(ctx, "folder_id IS NULL AND hidden = false")
}
func (h *Handlers) getFolderPhotos(ctx context.Context, folderID int) ([]models.Photo, error) {
return h.getPhotos(ctx, fmt.Sprintf("folder_id = %d AND hidden = false", folderID))
}
func (h *Handlers) getPhotos(ctx context.Context, where string) ([]models.Photo, error) {
query := fmt.Sprintf(`
SELECT id, folder_id, filename, path, COALESCE(url_path, ''), title, width, height, blurhash, size_bytes, taken_at, created_at
FROM photos WHERE %s ORDER BY COALESCE(taken_at, created_at) DESC, id DESC`, where)
rows, err := h.db.Pool().Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var photos []models.Photo
for rows.Next() {
var p models.Photo
if err := rows.Scan(&p.ID, &p.FolderID, &p.Filename, &p.Path, &p.URLPath, &p.Title, &p.Width, &p.Height, &p.Blurhash, &p.SizeBytes, &p.TakenAt, &p.CreatedAt); err != nil {
continue
}
photos = append(photos, p)
}
return photos, nil
}
func (h *Handlers) getAllFolders(ctx context.Context) ([]models.Folder, error) {
rows, err := h.db.Pool().Query(ctx, "SELECT id, parent_id, name, path FROM folders ORDER BY path")
if err != nil {
return nil, err
}
defer rows.Close()
var folders []models.Folder
for rows.Next() {
var f models.Folder
if err := rows.Scan(&f.ID, &f.ParentID, &f.Name, &f.Path); err != nil {
continue
}
folders = append(folders, f)
}
return folders, nil
}
func (h *Handlers) getFolderTree(ctx context.Context) ([]models.Folder, error) {
query := `
WITH RECURSIVE folder_tree AS (
SELECT id, parent_id, name, path, cover_photo_id, created_at, 0 as depth
FROM folders WHERE parent_id IS NULL
UNION ALL
SELECT f.id, f.parent_id, f.name, f.path, f.cover_photo_id, f.created_at, ft.depth + 1
FROM folders f INNER JOIN folder_tree ft ON f.parent_id = ft.id
)
SELECT ft.id, ft.parent_id, ft.name, ft.path, ft.cover_photo_id, ft.created_at, ft.depth,
(SELECT COUNT(*) FROM photos WHERE folder_id = ft.id AND hidden = false),
(SELECT COUNT(*) FROM folders WHERE parent_id = ft.id),
(SELECT COALESCE(SUM(size_bytes), 0) FROM photos WHERE folder_id = ft.id AND hidden = false),
COALESCE(ft.cover_photo_id, (SELECT p.id FROM photos p WHERE p.folder_id = ft.id AND p.hidden = false
ORDER BY COALESCE(p.taken_at, p.created_at) DESC, p.id DESC LIMIT 1))
FROM folder_tree ft ORDER BY ft.path`
rows, err := h.db.Pool().Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var folders []models.Folder
for rows.Next() {
var f models.Folder
var firstPhotoID sql.NullInt64
if err := rows.Scan(&f.ID, &f.ParentID, &f.Name, &f.Path, &f.CoverPhotoID, &f.CreatedAt, &f.Depth,
&f.PhotoCount, &f.SubfolderCount, &f.TotalSize, &firstPhotoID); err != nil {
continue
}
if firstPhotoID.Valid {
f.CoverURL = fmt.Sprintf("/thumb/small/%d", firstPhotoID.Int64)
}
f.HasChildren = f.SubfolderCount > 0
folders = append(folders, f)
}
return folders, nil
}
func (h *Handlers) getBreadcrumbs(ctx context.Context, folder *models.Folder) []models.Folder {
var breadcrumbs []models.Folder
current := folder
for current != nil {
breadcrumbs = append([]models.Folder{*current}, breadcrumbs...)
if !current.ParentID.Valid {
break
}
var parent models.Folder
if err := h.db.Pool().QueryRow(ctx, "SELECT id, parent_id, name, path FROM folders WHERE id = $1",
current.ParentID.Int64).Scan(&parent.ID, &parent.ParentID, &parent.Name, &parent.Path); err != nil {
break
}
current = &parent
}
return breadcrumbs
}
func (h *Handlers) isPathSafe(path string) bool {
cleaned := filepath.Clean(path)
if strings.Contains(cleaned, "..") {
return false
}
return strings.HasPrefix(filepath.Join(h.cfg.MediaRoot, cleaned), h.cfg.MediaRoot)
}
func (h *Handlers) resolveConflict(path string) string {
if _, err := os.Stat(path); os.IsNotExist(err) {
return path
}
ext := filepath.Ext(path)
base := strings.TrimSuffix(path, ext)
for i := 1; i < 10000; i++ {
newPath := fmt.Sprintf("%s_%d%s", base, i, ext)
if _, err := os.Stat(newPath); os.IsNotExist(err) {
return newPath
}
}
return fmt.Sprintf("%s_%d%s", base, time.Now().UnixNano(), ext)
}
func formatSize(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
}
func sanitizeFilename(name string) string {
name = filepath.Base(name)
name = strings.ReplaceAll(name, "..", "")
name = strings.Map(func(r rune) rune {
if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' {
return '_'
}
return r
}, name)
if name == "" || name == "." {
name = "unnamed"
}
return name
}
func randString(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)[:n]
}
func isImageFile(name string) bool {
ext := strings.ToLower(filepath.Ext(name))
return ext == ".jpg" || ext == ".jpeg" || ext == ".png"
}
func (h *Handlers) apiListFolders(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
parentIDStr := r.URL.Query().Get("parent_id")
var where string
var args []interface{}
if parentIDStr == "" {
where = "parent_id IS NULL"
} else if parentIDStr == "root" {
where = "parent_id IS NULL"
} else {
pid, err := strconv.Atoi(parentIDStr)
if err != nil {
http.Error(w, "invalid parent_id", 400)
return
}
where = "parent_id = $1"
args = append(args, pid)
}
query := fmt.Sprintf(`
SELECT f.id, f.parent_id, f.name, f.path, f.cover_photo_id, f.created_at,
(SELECT COUNT(*) FROM photos WHERE folder_id = f.id AND hidden = false) as photo_count,
(SELECT COUNT(*) FROM folders WHERE parent_id = f.id) as subfolder_count,
(SELECT COALESCE(SUM(size_bytes), 0) FROM photos WHERE folder_id = f.id AND hidden = false) as total_size
FROM folders f WHERE %s ORDER BY f.name`, where)
rows, err := h.db.Pool().Query(ctx, query, args...)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer rows.Close()
type folderJSON struct {
ID int `json:"id"`
ParentID *int `json:"parent_id"`
Name string `json:"name"`
Path string `json:"path"`
CoverPhotoID *int `json:"cover_photo_id"`
CreatedAt string `json:"created_at"`
PhotoCount int `json:"photo_count"`
SubfolderCount int `json:"subfolder_count"`
TotalSize int64 `json:"total_size"`
}
var folders []folderJSON
for rows.Next() {
var f folderJSON
var parentID sql.NullInt64
var coverPhotoID sql.NullInt64
var createdAt time.Time
if err := rows.Scan(&f.ID, &parentID, &f.Name, &f.Path, &coverPhotoID, &createdAt,
&f.PhotoCount, &f.SubfolderCount, &f.TotalSize); err != nil {
continue
}
if parentID.Valid {
pid := int(parentID.Int64)
f.ParentID = &pid
}
if coverPhotoID.Valid {
cid := int(coverPhotoID.Int64)
f.CoverPhotoID = &cid
}
f.CreatedAt = createdAt.Format(time.RFC3339)
folders = append(folders, f)
}
if folders == nil {
folders = []folderJSON{}
}
h.jsonResponse(w, map[string]interface{}{
"folders": folders,
})
}
func (h *Handlers) apiGetFolder(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.Error(w, "invalid id", 400)
return
}
ctx := r.Context()
var parentID sql.NullInt64
var coverPhotoID sql.NullInt64
var name, path string
var createdAt time.Time
var photoCount, subfolderCount int
var totalSize int64
err = h.db.Pool().QueryRow(ctx, `
SELECT f.id, f.parent_id, f.name, f.path, f.cover_photo_id, f.created_at,
(SELECT COUNT(*) FROM photos WHERE folder_id = f.id AND hidden = false),
(SELECT COUNT(*) FROM folders WHERE parent_id = f.id),
(SELECT COALESCE(SUM(size_bytes), 0) FROM photos WHERE folder_id = f.id AND hidden = false)
FROM folders f WHERE f.id = $1`, id).
Scan(&id, &parentID, &name, &path, &coverPhotoID, &createdAt,
&photoCount, &subfolderCount, &totalSize)
if err != nil {
http.NotFound(w, r)
return
}
folder := map[string]interface{}{
"id": id,
"parent_id": nil,
"name": name,
"path": path,
"cover_photo_id": nil,
"created_at": createdAt.Format(time.RFC3339),
"photo_count": photoCount,
"subfolder_count": subfolderCount,
"total_size": totalSize,
}
if parentID.Valid {
folder["parent_id"] = int(parentID.Int64)
}
if coverPhotoID.Valid {
folder["cover_photo_id"] = int(coverPhotoID.Int64)
}
h.jsonResponse(w, folder)
}
func (h *Handlers) apiListPhotos(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
perPage, _ := strconv.Atoi(r.URL.Query().Get("per_page"))
if perPage < 1 || perPage > 100 {
perPage = 50
}
offset := (page - 1) * perPage
folderFilter := r.URL.Query().Get("folder_id")
query := `SELECT id, folder_id, filename, path, COALESCE(url_path, ''), title, description,
width, height, size_bytes, blurhash, hidden, created_at, taken_at
FROM photos WHERE hidden = false`
countQuery := "SELECT COUNT(*) FROM photos WHERE hidden = false"
var args []interface{}
argIdx := 1
if folderFilter == "root" {
query += " AND folder_id IS NULL"
countQuery += " AND folder_id IS NULL"
} else if folderFilter != "" {
fid, err := strconv.Atoi(folderFilter)
if err != nil {
http.Error(w, "invalid folder_id", 400)
return
}
query += fmt.Sprintf(" AND folder_id = $%d", argIdx)
countQuery += fmt.Sprintf(" AND folder_id = $%d", argIdx)
args = append(args, fid)
argIdx++
}
var totalCount int
_ = h.db.Pool().QueryRow(ctx, countQuery, args...).Scan(&totalCount)
query += fmt.Sprintf(" ORDER BY COALESCE(taken_at, created_at) DESC, id DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
args = append(args, perPage, offset)
rows, err := h.db.Pool().Query(ctx, query, args...)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer rows.Close()
type photoJSON struct {
ID int `json:"id"`
FolderID *int `json:"folder_id"`
Filename string `json:"filename"`
Path string `json:"path"`
URL string `json:"url"`
Title *string `json:"title"`
Description *string `json:"description"`
Width int `json:"width"`
Height int `json:"height"`
SizeBytes int64 `json:"size_bytes"`
Blurhash *string `json:"blurhash"`
CreatedAt string `json:"created_at"`
TakenAt *string `json:"taken_at"`
Thumbnails struct {
Small string `json:"small"`
Medium string `json:"medium"`
Large string `json:"large"`
} `json:"thumbnails"`
}
var photos []photoJSON
for rows.Next() {
var p photoJSON
var folderID sql.NullInt64
var urlPath string
var title, description, blurhash sql.NullString
var createdAt time.Time
var takenAt sql.NullTime
var hidden bool
if err := rows.Scan(&p.ID, &folderID, &p.Filename, &p.Path, &urlPath, &title, &description,
&p.Width, &p.Height, &p.SizeBytes, &blurhash, &hidden, &createdAt, &takenAt); err != nil {
continue
}
if folderID.Valid {
fid := int(folderID.Int64)
p.FolderID = &fid
}
if urlPath != "" {
p.URL = "/p/" + urlPath
} else {
p.URL = fmt.Sprintf("/photo/%d", p.ID)
}
if title.Valid {
p.Title = &title.String
}
if description.Valid {
p.Description = &description.String
}
if blurhash.Valid {
p.Blurhash = &blurhash.String
}
p.CreatedAt = createdAt.Format(time.RFC3339)
if takenAt.Valid {
t := takenAt.Time.Format(time.RFC3339)
p.TakenAt = &t
}
p.Thumbnails.Small = fmt.Sprintf("/thumb/small/%d", p.ID)
p.Thumbnails.Medium = fmt.Sprintf("/thumb/medium/%d", p.ID)
p.Thumbnails.Large = fmt.Sprintf("/thumb/large/%d", p.ID)
photos = append(photos, p)
}
if photos == nil {
photos = []photoJSON{}
}
h.jsonResponse(w, map[string]interface{}{
"photos": photos,
"page": page,
"per_page": perPage,
"total": totalCount,
"pages": (totalCount + perPage - 1) / perPage,
})
}
func (h *Handlers) apiGetPhoto(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.Error(w, "invalid id", 400)
return
}
ctx := r.Context()
var folderID sql.NullInt64
var filename, path, urlPath string
var title, description, note, blurhash sql.NullString
var width, height int
var sizeBytes int64
var exifData json.RawMessage
var hidden bool
var createdAt time.Time
var takenAt sql.NullTime
err = h.db.Pool().QueryRow(ctx, `
SELECT id, folder_id, filename, path, COALESCE(url_path, ''), title, description, note,
width, height, size_bytes, blurhash, exif_data, hidden, created_at, taken_at
FROM photos WHERE id = $1 AND hidden = false`, id).
Scan(&id, &folderID, &filename, &path, &urlPath, &title, &description, &note,
&width, &height, &sizeBytes, &blurhash, &exifData, &hidden, &createdAt, &takenAt)
if err != nil {
http.NotFound(w, r)
return
}
photo := map[string]interface{}{
"id": id,
"folder_id": nil,
"filename": filename,
"path": path,
"url": fmt.Sprintf("/photo/%d", id),
"title": nil,
"description": nil,
"note": nil,
"width": width,
"height": height,
"size_bytes": sizeBytes,
"blurhash": nil,
"created_at": createdAt.Format(time.RFC3339),
"taken_at": nil,
"thumbnails": map[string]string{
"small": fmt.Sprintf("/thumb/small/%d", id),
"medium": fmt.Sprintf("/thumb/medium/%d", id),
"large": fmt.Sprintf("/thumb/large/%d", id),
},
"original": fmt.Sprintf("/original/%d", id),
}
if folderID.Valid {
photo["folder_id"] = int(folderID.Int64)
}
if urlPath != "" {
photo["url"] = "/p/" + urlPath
}
if title.Valid {
photo["title"] = title.String
}
if description.Valid {
photo["description"] = description.String
}
if note.Valid {
photo["note"] = note.String
}
if blurhash.Valid {
photo["blurhash"] = blurhash.String
}
if takenAt.Valid {
photo["taken_at"] = takenAt.Time.Format(time.RFC3339)
}
if exifData != nil {
var exif map[string]interface{}
if json.Unmarshal(exifData, &exif) == nil {
photo["exif"] = exif
}
}
h.jsonResponse(w, photo)
}
func (h *Handlers) apiRandomPhoto(w http.ResponseWriter, r *http.Request) {
var id int
var urlPath string
err := h.db.Pool().QueryRow(r.Context(),
`SELECT id, COALESCE(url_path, '') FROM photos
WHERE hidden = false ORDER BY RANDOM() LIMIT 1`).Scan(&id, &urlPath)
if err != nil {
http.Error(w, "no photos", 404)
return
}
u := fmt.Sprintf("/photo/%d", id)
if urlPath != "" {
u = "/p/" + urlPath
}
h.jsonResponse(w, map[string]interface{}{"id": id, "url": u})
}
func (h *Handlers) publicRandomPhoto(w http.ResponseWriter, r *http.Request) {
var count int
_ = h.db.Pool().QueryRow(r.Context(), "SELECT COUNT(*) FROM photos WHERE hidden = false").Scan(&count)
if count == 0 {
http.Redirect(w, r, "/", http.StatusFound)
return
}
var id int
var urlPath string
_ = h.db.Pool().QueryRow(r.Context(),
`SELECT id, COALESCE(url_path, '') FROM photos WHERE hidden = false
OFFSET floor(random() * $1) LIMIT 1`, count).Scan(&id, &urlPath)
if urlPath != "" {
http.Redirect(w, r, "/p/"+urlPath, http.StatusFound)
} else {
http.Redirect(w, r, fmt.Sprintf("/photo/%d", id), http.StatusFound)
}
}
func (h *Handlers) adminStats(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
stats := h.collectStats(ctx)
h.render(w, "admin/stats.html", map[string]interface{}{
"Stats": stats,
"Title": "Statistics",
})
}
func (h *Handlers) apiStats(w http.ResponseWriter, r *http.Request) {
h.jsonResponse(w, h.collectStats(r.Context()))
}
func (h *Handlers) collectStats(ctx context.Context) map[string]interface{} {
stats := make(map[string]interface{})
var photoCount, folderCount, hiddenCount int
var totalSize int64
var avgWidth, avgHeight float64
_ = h.db.Pool().QueryRow(ctx, "SELECT COUNT(*) FROM photos WHERE hidden = false").Scan(&photoCount)
_ = h.db.Pool().QueryRow(ctx, "SELECT COUNT(*) FROM folders").Scan(&folderCount)
_ = h.db.Pool().QueryRow(ctx, "SELECT COUNT(*) FROM photos WHERE hidden = true").Scan(&hiddenCount)
_ = h.db.Pool().QueryRow(ctx, "SELECT COALESCE(SUM(size_bytes), 0) FROM photos").Scan(&totalSize)
_ = h.db.Pool().QueryRow(ctx, "SELECT COALESCE(AVG(width), 0), COALESCE(AVG(height), 0) FROM photos WHERE hidden = false").Scan(&avgWidth, &avgHeight)
stats["overview"] = map[string]interface{}{
"photo_count": photoCount,
"folder_count": folderCount,
"hidden_count": hiddenCount,
"total_size": totalSize,
"avg_width": int(avgWidth),
"avg_height": int(avgHeight),
}
type kv struct {
Key string `json:"key"`
Count int `json:"count"`
}
cameras := []kv{}
rows, _ := h.db.Pool().Query(ctx, `
SELECT exif_data->>'camera_model' as camera, COUNT(*) as cnt
FROM photos WHERE exif_data->>'camera_model' != '' AND hidden = false
GROUP BY camera ORDER BY cnt DESC LIMIT 20`)
if rows != nil {
for rows.Next() {
var item kv
_ = rows.Scan(&item.Key, &item.Count)
cameras = append(cameras, item)
}
rows.Close()
}
stats["cameras"] = cameras
lenses := []kv{}
rows, _ = h.db.Pool().Query(ctx, `
SELECT exif_data->>'lens_model' as lens, COUNT(*) as cnt
FROM photos WHERE exif_data->>'lens_model' != '' AND hidden = false
GROUP BY lens ORDER BY cnt DESC LIMIT 20`)
if rows != nil {
for rows.Next() {
var item kv
_ = rows.Scan(&item.Key, &item.Count)
lenses = append(lenses, item)
}
rows.Close()
}
stats["lenses"] = lenses
focalLengths := []kv{}
rows, _ = h.db.Pool().Query(ctx, `
SELECT exif_data->>'focal_length' as fl, COUNT(*) as cnt
FROM photos WHERE exif_data->>'focal_length' != '' AND hidden = false
GROUP BY fl ORDER BY cnt DESC LIMIT 20`)
if rows != nil {
for rows.Next() {
var item kv
_ = rows.Scan(&item.Key, &item.Count)
focalLengths = append(focalLengths, item)
}
rows.Close()
}
stats["focal_lengths"] = focalLengths
apertures := []kv{}
rows, _ = h.db.Pool().Query(ctx, `
SELECT exif_data->>'aperture' as ap, COUNT(*) as cnt
FROM photos WHERE exif_data->>'aperture' != '' AND hidden = false
GROUP BY ap ORDER BY cnt DESC LIMIT 20`)
if rows != nil {
for rows.Next() {
var item kv
_ = rows.Scan(&item.Key, &item.Count)
apertures = append(apertures, item)
}
rows.Close()
}
stats["apertures"] = apertures
isoRanges := []kv{}
rows, _ = h.db.Pool().Query(ctx, `
SELECT CASE
WHEN (exif_data->>'iso')::int <= 100 THEN '≤100'
WHEN (exif_data->>'iso')::int <= 400 THEN '101-400'
WHEN (exif_data->>'iso')::int <= 1600 THEN '401-1600'
WHEN (exif_data->>'iso')::int <= 6400 THEN '1601-6400'
ELSE '>6400'
END as iso_range, COUNT(*) as cnt
FROM photos WHERE exif_data->>'iso' != '' AND exif_data->>'iso' != '0' AND hidden = false
GROUP BY iso_range ORDER BY cnt DESC`)
if rows != nil {
for rows.Next() {
var item kv
_ = rows.Scan(&item.Key, &item.Count)
isoRanges = append(isoRanges, item)
}
rows.Close()
}
stats["iso_ranges"] = isoRanges
type monthCount struct {
Month string `json:"month"`
Count int `json:"count"`
}
timeline := []monthCount{}
rows, _ = h.db.Pool().Query(ctx, `
SELECT TO_CHAR(COALESCE(taken_at, created_at), 'YYYY-MM') as month, COUNT(*) as cnt
FROM photos WHERE hidden = false
GROUP BY month ORDER BY month DESC LIMIT 36`)
if rows != nil {
for rows.Next() {
var item monthCount
_ = rows.Scan(&item.Month, &item.Count)
timeline = append(timeline, item)
}
rows.Close()
}
stats["timeline"] = timeline
exposureModes := []kv{}
rows, _ = h.db.Pool().Query(ctx, `
SELECT exif_data->>'exposure_mode' as mode, COUNT(*) as cnt
FROM photos WHERE exif_data->>'exposure_mode' != '' AND hidden = false
GROUP BY mode ORDER BY cnt DESC`)
if rows != nil {
for rows.Next() {
var item kv
_ = rows.Scan(&item.Key, &item.Count)
exposureModes = append(exposureModes, item)
}
rows.Close()
}
stats["exposure_modes"] = exposureModes
type cameraSize struct {
Camera string `json:"camera"`
AvgSize float64 `json:"avg_size"`
Count int `json:"count"`
}
var cameraSizes []cameraSize
rows, _ = h.db.Pool().Query(ctx, `
SELECT exif_data->>'camera_model', AVG(size_bytes), COUNT(*)
FROM photos WHERE exif_data->>'camera_model' != '' AND hidden = false
GROUP BY exif_data->>'camera_model' ORDER BY COUNT(*) DESC LIMIT 10`)
if rows != nil {
for rows.Next() {
var item cameraSize
_ = rows.Scan(&item.Camera, &item.AvgSize, &item.Count)
cameraSizes = append(cameraSizes, item)
}
rows.Close()
}
stats["camera_sizes"] = cameraSizes
return stats
}
func (h *Handlers) adminReprocess(w http.ResponseWriter, r *http.Request) {
go func() {
if err := h.scanSvc.ReprocessAllMetadata(context.Background()); err != nil {
log.Printf("reprocess error: %v", err)
}
}()
h.jsonResponse(w, map[string]string{"status": "started"})
}