Gallery admin
+ if errMsg != "" { +{ errMsg }
diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4bdce95 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +# Gallery photos are mounted at runtime, not baked into the image +app/images/** +!app/images/.gitkeep diff --git a/.env.example b/.env.example index 1f5b2fe..40c2d57 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,10 @@ ADDR=:8080 # Homepage hero (path under app/images/) HERO_IMAGE=connectionmachine/20220723_231556.jpg +# Gallery admin at /admin/ (both required to enable) +ADMIN_USER=admin +ADMIN_PASSWORD=changeme + # Host Go caches mounted into the dev container (make sets ~/go/pkg/mod and ~/.cache/go-build if unset) # GOMODCACHE=/home/you/go/pkg/mod # GOCACHE=/home/you/.cache/go-build diff --git a/Dockerfile b/Dockerfile index 84bef0d..bbdceb0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,15 +13,13 @@ RUN templ generate && CGO_ENABLED=0 go build -ldflags="-s -w" -o /server ./cmd/s FROM debian:bookworm-slim RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates \ + && apt-get install -y --no-install-recommends ca-certificates ffmpeg \ && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --from=builder /server ./server COPY app/static ./static -COPY app/images ./images -RUN ./server -thumbs EXPOSE 8080 ENV ADDR=:8080 diff --git a/Dockerfile.dev b/Dockerfile.dev index 4ab3a20..ef6981e 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,6 +1,10 @@ ARG GO_VERSION=1.25 FROM golang:${GO_VERSION}-bookworm +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* + RUN go install github.com/a-h/templ/cmd/templ@latest \ && go install github.com/air-verse/air@latest diff --git a/README.md b/README.md index e40d74a..2187087 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,19 @@ make dev # live reload → http://localhost:7331 - Single-page layout: hero, services, gallery, contact - Gallery loads via HTMX (`hx-get="/gallery"`) -- Year filters without full page reload - Lightbox modal with previous/next navigation - Contact form with SMTP relay (HTMX submit, no page reload) -- Serves photos from `app/images/` +- Serves photos from `app/images/` (not in git — mount or copy locally) +- Password-protected gallery admin at `/admin/` + +## Gallery admin + +Set both `ADMIN_USER` and `ADMIN_PASSWORD` in `.env`, then open [http://localhost:8080/admin/](http://localhost:8080/admin/) (or via your public URL). + +- Sign in with username/password +- Upload JPEGs into an album (or create a new album folder) +- Delete images (removes originals and generated thumbs/hero) +- Changes appear on the public site immediately ## Contact form (SMTP) @@ -57,7 +66,7 @@ If SMTP is not configured, the page shows a mailto fallback instead of the form. Only run **`dev`** or **`website`** at a time if you map both to the same host ports. -Production `website` joins the external `caddy` Docker network for reverse proxy labels. +Production `website` mounts `./app/images` into the container (photos stay on the host). It joins the external `caddy` Docker network for reverse proxy labels. ## Project layout @@ -79,8 +88,8 @@ docker-compose.yaml ## Gallery images -On startup the server builds JPEG thumbnails in `app/images/thumbs/` (max 480px edge) for the grid and hero. The lightbox still loads full-resolution originals. Thumbnails are gitignored and regenerated when source photos change. +On startup the server generates missing derivatives under `app/images/thumbs/` (and `hero/` for JPEGs only). Photos get resized JPEG thumbs; videos (`.mp4`, `.webm`, `.mov`) get a poster frame JPEG via `ffmpeg` when installed. The lightbox plays full videos or shows full-resolution photos. Derivatives are gitignored and rebuilt when source files are newer. ## Deploy notes -First request after deploy may take a moment while thumbnails are generated if they are not baked into the image yet. +First request after deploy may take a moment while thumbnails are generated under the mounted `app/images/` directory. diff --git a/app/images/.gitkeep b/app/images/.gitkeep index e69de29..8b13789 100644 --- a/app/images/.gitkeep +++ b/app/images/.gitkeep @@ -0,0 +1 @@ + diff --git a/app/internal/auth/auth.go b/app/internal/auth/auth.go new file mode 100644 index 0000000..f35c9e5 --- /dev/null +++ b/app/internal/auth/auth.go @@ -0,0 +1,99 @@ +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "os" + "sync" + "time" +) + +const sessionCookie = "tk_admin_session" + +// Config holds admin credentials from the environment. +type Config struct { + Username string + Password string + Enabled bool +} + +// Load reads admin auth settings. Admin is enabled when both user and password are set. +func Load() Config { + user := os.Getenv("ADMIN_USER") + pass := os.Getenv("ADMIN_PASSWORD") + return Config{ + Username: user, + Password: pass, + Enabled: user != "" && pass != "", + } +} + +// Sessions tracks active login tokens in memory. +type Sessions struct { + mu sync.RWMutex + tokens map[string]time.Time + ttl time.Duration +} + +// NewSessions creates a session store. +func NewSessions() *Sessions { + return &Sessions{ + tokens: make(map[string]time.Time), + ttl: 24 * time.Hour, + } +} + +// Create issues a new session token. +func (s *Sessions) Create() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + token := base64.RawURLEncoding.EncodeToString(b) + exp := time.Now().Add(s.ttl) + s.mu.Lock() + s.tokens[token] = exp + s.mu.Unlock() + return token, nil +} + +// Valid reports whether a session token is still active. +func (s *Sessions) Valid(token string) bool { + if token == "" { + return false + } + s.mu.RLock() + exp, ok := s.tokens[token] + s.mu.RUnlock() + if !ok || time.Now().After(exp) { + return false + } + return true +} + +// Delete removes a session token. +func (s *Sessions) Delete(token string) { + s.mu.Lock() + delete(s.tokens, token) + s.mu.Unlock() +} + +// CookieName returns the session cookie name. +func CookieName() string { + return sessionCookie +} + +// CheckCredentials compares username and password to config using constant-time compare. +func CheckCredentials(cfg Config, username, password string) bool { + if !cfg.Enabled { + return false + } + uOK := subtle.ConstantTimeCompare([]byte(username), []byte(cfg.Username)) == 1 + pOK := subtle.ConstantTimeCompare([]byte(password), []byte(cfg.Password)) == 1 + return uOK && pOK +} + +// ErrDisabled is returned when admin auth is not configured. +var ErrDisabled = errors.New("admin auth is not configured") diff --git a/app/internal/gallery/files.go b/app/internal/gallery/files.go new file mode 100644 index 0000000..f6e1f23 --- /dev/null +++ b/app/internal/gallery/files.go @@ -0,0 +1,124 @@ +package gallery + +import ( + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" +) + +var albumNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`) + +// AlbumsOnDisk returns top-level album folder names (excluding thumbs/hero). +func AlbumsOnDisk(imagesDir string) ([]string, error) { + entries, err := os.ReadDir(imagesDir) + if err != nil { + return nil, err + } + var albums []string + for _, e := range entries { + if !e.IsDir() || e.Name() == thumbDirName || e.Name() == heroDirName { + continue + } + if e.Name()[0] == '.' { + continue + } + albums = append(albums, e.Name()) + } + return albums, nil +} + +// SanitizeAlbum normalizes a folder name for new uploads. +func SanitizeAlbum(name string) (string, error) { + name = strings.ToLower(strings.TrimSpace(name)) + name = strings.ReplaceAll(name, " ", "-") + if !albumNameRe.MatchString(name) { + return "", fmt.Errorf("invalid album name") + } + return name, nil +} + +// SanitizeUploadName keeps a safe JPEG filename. +func SanitizeUploadName(name string) (string, error) { + name = filepath.Base(strings.TrimSpace(name)) + name = strings.ReplaceAll(name, " ", "_") + if !isJPEG(name) { + return "", fmt.Errorf("only .jpg and .jpeg files are allowed") + } + if strings.Contains(name, "..") { + return "", fmt.Errorf("invalid filename") + } + return name, nil +} + +// SaveUpload writes an uploaded JPEG into the gallery tree. +func SaveUpload(imagesDir, album, filename string, r io.Reader) (string, error) { + album, err := SanitizeAlbum(album) + if err != nil { + return "", err + } + filename, err = SanitizeUploadName(filename) + if err != nil { + return "", err + } + dir := filepath.Join(imagesDir, album) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + dst := filepath.Join(dir, filename) + f, err := os.Create(dst) + if err != nil { + return "", err + } + defer f.Close() + if _, err := io.Copy(f, r); err != nil { + os.Remove(dst) + return "", err + } + rel := filepath.ToSlash(filepath.Join(album, filename)) + return rel, nil +} + +// DeleteImage removes an image and its derivatives. +func DeleteImage(imagesDir, rel string) error { + if !safeRelPath(rel) { + return fmt.Errorf("invalid path") + } + paths := []string{filepath.Join(imagesDir, filepath.FromSlash(rel))} + if isVideo(filepath.Base(rel)) { + paths = append(paths, filepath.Join(imagesDir, thumbDirName, filepath.FromSlash(videoPosterRel(rel)))) + } else { + paths = append(paths, + filepath.Join(imagesDir, thumbDirName, filepath.FromSlash(rel)), + filepath.Join(imagesDir, heroDirName, filepath.FromSlash(rel)), + ) + } + for _, p := range paths { + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil +} + +// EnsureDerivatives rebuilds thumb and hero for one image. +func EnsureDerivatives(imagesDir, rel string) error { + if !safeRelPath(rel) { + return fmt.Errorf("invalid path") + } + src := filepath.Join(imagesDir, filepath.FromSlash(rel)) + thumbDst := filepath.Join(imagesDir, thumbDirName, filepath.FromSlash(rel)) + heroDst := filepath.Join(imagesDir, heroDirName, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(thumbDst), 0o755); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(heroDst), 0o755); err != nil { + return err + } + if err := writeThumb(src, thumbDst, gridThumbMaxEdge, gridThumbQuality); err != nil { + return err + } + return writeThumb(src, heroDst, heroThumbMaxEdge, heroThumbQuality) +} diff --git a/app/internal/gallery/gallery.go b/app/internal/gallery/gallery.go index 670ce1c..6272ac7 100644 --- a/app/internal/gallery/gallery.go +++ b/app/internal/gallery/gallery.go @@ -19,11 +19,12 @@ type Image struct { URL string ThumbURL string HeroURL string + IsVideo bool Date time.Time Year int } -// List returns JPEG images from dir and subfolders (newest first). +// List returns gallery JPEGs and videos from dir and subfolders (newest first). // Skips generated thumbs/ and hero/ directories. func List(dir string) ([]Image, error) { var images []Image @@ -37,7 +38,7 @@ func List(dir string) ([]Image, error) { } return nil } - if !isJPEG(d.Name()) { + if !isGalleryMedia(d.Name()) { return nil } @@ -53,6 +54,7 @@ func List(dir string) ([]Image, error) { album := albumFromRel(rel) cKey, cLabel := collectionFromRel(rel) date, year := imageDate(path, d.Name()) + video := isVideo(d.Name()) images = append(images, Image{ RelPath: rel, Album: album, @@ -62,6 +64,7 @@ func List(dir string) ([]Image, error) { URL: "/images/" + URLPath(rel), ThumbURL: derivativeURL(dir, thumbDirName, rel), HeroURL: derivativeURL(dir, heroDirName, rel), + IsVideo: video, Date: date, Year: year, }) @@ -158,8 +161,15 @@ func safeRelPath(rel string) bool { } func derivativeURL(imagesDir, subdir, rel string) string { - if _, err := os.Stat(filepath.Join(imagesDir, subdir, filepath.FromSlash(rel))); err == nil { - return "/images/" + subdir + "/" + URLPath(rel) + lookup := rel + if subdir == thumbDirName || subdir == heroDirName { + lookup = thumbLookupRel(rel) + } + if isVideo(filepath.Base(rel)) && subdir == heroDirName { + return "" + } + if _, err := os.Stat(filepath.Join(imagesDir, subdir, filepath.FromSlash(lookup))); err == nil { + return "/images/" + subdir + "/" + URLPath(lookup) } return "" } diff --git a/app/internal/gallery/hero.go b/app/internal/gallery/hero.go index bce2511..6358631 100644 --- a/app/internal/gallery/hero.go +++ b/app/internal/gallery/hero.go @@ -19,12 +19,17 @@ func HeroRelPath() string { func SelectHero(images []Image) (Image, bool) { want := HeroRelPath() for _, img := range images { + if img.IsVideo { + continue + } if img.RelPath == want { return img, true } } - if len(images) > 0 { - return images[0], true + for _, img := range images { + if !img.IsVideo { + return img, true + } } return Image{}, false } diff --git a/app/internal/gallery/media.go b/app/internal/gallery/media.go new file mode 100644 index 0000000..c8f21d3 --- /dev/null +++ b/app/internal/gallery/media.go @@ -0,0 +1,36 @@ +package gallery + +import ( + "path/filepath" + "strings" +) + +func isVideo(name string) bool { + switch strings.ToLower(filepath.Ext(name)) { + case ".mp4", ".webm", ".mov": + return true + default: + return false + } +} + +func isGalleryMedia(name string) bool { + return isJPEG(name) || isVideo(name) +} + +// thumbLookupRel is the path under thumbs/ (and hero/) used to find derivatives. +// Videos use a JPEG poster: clip.mp4 → clip.jpg. +func thumbLookupRel(rel string) string { + if isVideo(filepath.Base(rel)) { + return videoPosterRel(rel) + } + return rel +} + +func videoPosterRel(rel string) string { + ext := filepath.Ext(rel) + if ext == "" { + return rel + ".jpg" + } + return strings.TrimSuffix(rel, ext) + ".jpg" +} diff --git a/app/internal/gallery/media_test.go b/app/internal/gallery/media_test.go new file mode 100644 index 0000000..a39b7f7 --- /dev/null +++ b/app/internal/gallery/media_test.go @@ -0,0 +1,44 @@ +package gallery + +import ( + "os" + "path/filepath" + "testing" +) + +func TestVideoPosterRel(t *testing.T) { + if got := videoPosterRel("portal/clip.mp4"); got != "portal/clip.jpg" { + t.Fatalf("got %q", got) + } +} + +func TestList_includesVideo(t *testing.T) { + dir := t.TempDir() + album := filepath.Join(dir, "portal") + if err := os.MkdirAll(album, 0o755); err != nil { + t.Fatal(err) + } + name := "showreel.mp4" + if err := os.WriteFile(filepath.Join(album, name), []byte("fake"), 0o644); err != nil { + t.Fatal(err) + } + + images, err := List(dir) + if err != nil { + t.Fatal(err) + } + if len(images) != 1 || !images[0].IsVideo || images[0].RelPath != "portal/"+name { + t.Fatalf("got %+v", images) + } +} + +func TestSelectHero_skipsVideo(t *testing.T) { + images := []Image{ + {RelPath: "portal/showreel.mp4", IsVideo: true}, + {RelPath: "portal/photo.jpg", IsVideo: false}, + } + hero, ok := SelectHero(images) + if !ok || hero.RelPath != "portal/photo.jpg" { + t.Fatalf("got %+v ok=%v", hero, ok) + } +} diff --git a/app/internal/gallery/thumbs.go b/app/internal/gallery/thumbs.go index 651cf46..84270b5 100644 --- a/app/internal/gallery/thumbs.go +++ b/app/internal/gallery/thumbs.go @@ -1,10 +1,13 @@ package gallery import ( + "bytes" + "fmt" "image" "image/jpeg" "io/fs" "os" + "os/exec" "path/filepath" "strings" @@ -29,7 +32,8 @@ func EnsureThumbnails(imagesDir string) error { return err } - return filepath.WalkDir(imagesDir, func(path string, d fs.DirEntry, err error) error { + var failures []string + walkErr := filepath.WalkDir(imagesDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } @@ -39,7 +43,7 @@ func EnsureThumbnails(imagesDir string) error { } return nil } - if !isJPEG(d.Name()) { + if !isGalleryMedia(d.Name()) { return nil } @@ -52,19 +56,43 @@ func EnsureThumbnails(imagesDir string) error { return nil } + if isVideo(d.Name()) { + thumbDst := filepath.Join(imagesDir, thumbDirName, filepath.FromSlash(videoPosterRel(rel))) + if err := os.MkdirAll(filepath.Dir(thumbDst), 0o755); err != nil { + failures = append(failures, fmt.Sprintf("%s: %v", rel, err)) + return nil + } + if err := writeVideoPoster(path, thumbDst, gridThumbMaxEdge); err != nil { + failures = append(failures, fmt.Sprintf("%s: %v", rel, err)) + } + return nil + } + thumbDst := filepath.Join(imagesDir, thumbDirName, filepath.FromSlash(rel)) heroDst := filepath.Join(imagesDir, heroDirName, filepath.FromSlash(rel)) if err := os.MkdirAll(filepath.Dir(thumbDst), 0o755); err != nil { - return err + failures = append(failures, fmt.Sprintf("%s: %v", rel, err)) + return nil } if err := os.MkdirAll(filepath.Dir(heroDst), 0o755); err != nil { - return err + failures = append(failures, fmt.Sprintf("%s: %v", rel, err)) + return nil } if err := writeThumb(path, thumbDst, gridThumbMaxEdge, gridThumbQuality); err != nil { - return err + failures = append(failures, fmt.Sprintf("%s thumb: %v", rel, err)) } - return writeThumb(path, heroDst, heroThumbMaxEdge, heroThumbQuality) + if err := writeThumb(path, heroDst, heroThumbMaxEdge, heroThumbQuality); err != nil { + failures = append(failures, fmt.Sprintf("%s hero: %v", rel, err)) + } + return nil }) + if walkErr != nil { + return walkErr + } + if len(failures) > 0 { + return fmt.Errorf("%d file(s): %s", len(failures), strings.Join(failures, "; ")) + } + return nil } // EnsurePriority generates thumbnails for one image before first page load. @@ -87,6 +115,41 @@ func EnsurePriority(imagesDir, relPath string) error { return writeThumb(src, thumbDst, gridThumbMaxEdge, gridThumbQuality) } +func writeVideoPoster(src, dst string, maxEdge int) error { + srcInfo, err := os.Stat(src) + if err != nil { + return err + } + if dstInfo, err := os.Stat(dst); err == nil && !dstInfo.ModTime().Before(srcInfo.ModTime()) { + return nil + } + if _, err := exec.LookPath("ffmpeg"); err != nil { + return nil + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + scale := fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease", maxEdge, maxEdge) + cmd := exec.Command("ffmpeg", + "-hide_banner", "-loglevel", "error", "-y", + "-i", src, + "-vf", scale, + "-frames:v", "1", + "-q:v", "5", + dst, + ) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + return err + } + return fmt.Errorf("%w: %s", err, msg) + } + return nil +} + func writeThumb(src, dst string, maxEdge, quality int) error { srcInfo, err := os.Stat(src) if err != nil { diff --git a/app/internal/handlers/admin.go b/app/internal/handlers/admin.go new file mode 100644 index 0000000..f4c9ef4 --- /dev/null +++ b/app/internal/handlers/admin.go @@ -0,0 +1,201 @@ +package handlers + +import ( + "log" + "net/http" + "time" + + "technical.kiwi/website/internal/auth" + "technical.kiwi/website/internal/gallery" + "technical.kiwi/website/templates" +) + +const maxUploadBytes = 32 << 20 // 32 MiB + +func (s *Server) adminEnabled() bool { + return s.Auth.Enabled +} + +func (s *Server) sessionToken(r *http.Request) string { + c, err := r.Cookie(auth.CookieName()) + if err != nil { + return "" + } + return c.Value +} + +func (s *Server) isAdmin(r *http.Request) bool { + return s.Sessions.Valid(s.sessionToken(r)) +} + +func (s *Server) setSession(w http.ResponseWriter, token string) { + http.SetCookie(w, &http.Cookie{ + Name: auth.CookieName(), + Value: token, + Path: "/admin", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: int((24 * time.Hour).Seconds()), + }) +} + +func (s *Server) clearSession(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: auth.CookieName(), + Value: "", + Path: "/admin", + HttpOnly: true, + MaxAge: -1, + }) +} + +func (s *Server) requireAdmin(fn http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !s.adminEnabled() { + http.Error(w, "admin disabled", http.StatusServiceUnavailable) + return + } + if !s.isAdmin(r) { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + fn(w, r) + } +} + +func (s *Server) reloadGallery() error { + s.galleryMu.Lock() + defer s.galleryMu.Unlock() + + images, err := gallery.List(s.ImagesDir) + if err != nil { + return err + } + hero, hasHero := gallery.SelectHero(images) + s.Images = images + s.Hero = hero + s.HasHero = hasHero + return nil +} + +func (s *Server) adminLogin(w http.ResponseWriter, r *http.Request) { + if !s.adminEnabled() { + http.Error(w, "admin disabled: set ADMIN_USER and ADMIN_PASSWORD", http.StatusServiceUnavailable) + return + } + if s.isAdmin(r) { + http.Redirect(w, r, "/admin/", http.StatusSeeOther) + return + } + templates.AdminLogin("").Render(r.Context(), w) +} + +func (s *Server) adminLoginPost(w http.ResponseWriter, r *http.Request) { + if !s.adminEnabled() { + http.Error(w, "admin disabled", http.StatusServiceUnavailable) + return + } + if err := r.ParseForm(); err != nil { + templates.AdminLogin("Invalid request.").Render(r.Context(), w) + return + } + user := r.FormValue("username") + pass := r.FormValue("password") + if !auth.CheckCredentials(s.Auth, user, pass) { + templates.AdminLogin("Invalid username or password.").Render(r.Context(), w) + return + } + token, err := s.Sessions.Create() + if err != nil { + http.Error(w, "session error", http.StatusInternalServerError) + return + } + s.setSession(w, token) + http.Redirect(w, r, "/admin/", http.StatusSeeOther) +} + +func (s *Server) adminLogout(w http.ResponseWriter, r *http.Request) { + s.Sessions.Delete(s.sessionToken(r)) + s.clearSession(w) + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) +} + +func (s *Server) adminIndex(w http.ResponseWriter, r *http.Request) { + images, _, _ := s.snapshot() + albums, _ := gallery.AlbumsOnDisk(s.ImagesDir) + w.Header().Set("Cache-Control", "no-cache") + templates.AdminDashboard(images, albums, "").Render(r.Context(), w) +} + +func (s *Server) adminUpload(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes) + if err := r.ParseMultipartForm(maxUploadBytes); err != nil { + s.adminIndexError(w, r, "Upload too large or invalid.") + return + } + + album := r.FormValue("album") + if album == "new" { + album = r.FormValue("album_new") + } + + file, header, err := r.FormFile("image") + if err != nil { + s.adminIndexError(w, r, "Choose a JPEG image to upload.") + return + } + defer file.Close() + + rel, err := gallery.SaveUpload(s.ImagesDir, album, header.Filename, file) + if err != nil { + s.adminIndexError(w, r, err.Error()) + return + } + + if err := gallery.EnsureDerivatives(s.ImagesDir, rel); err != nil { + log.Printf("admin upload derivatives: %v", err) + } + if err := s.reloadGallery(); err != nil { + log.Printf("admin reload gallery: %v", err) + } + + if r.Header.Get("HX-Request") == "true" { + images, _, _ := s.snapshot() + templates.AdminImageTable(images).Render(r.Context(), w) + return + } + http.Redirect(w, r, "/admin/", http.StatusSeeOther) +} + +func (s *Server) adminDelete(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + rel := r.FormValue("path") + if err := gallery.DeleteImage(s.ImagesDir, rel); err != nil { + s.adminIndexError(w, r, err.Error()) + return + } + if err := s.reloadGallery(); err != nil { + log.Printf("admin reload gallery: %v", err) + } + + if r.Header.Get("HX-Request") == "true" { + images, _, _ := s.snapshot() + templates.AdminImageTable(images).Render(r.Context(), w) + return + } + http.Redirect(w, r, "/admin/", http.StatusSeeOther) +} + +func (s *Server) adminIndexError(w http.ResponseWriter, r *http.Request, msg string) { + images, _, _ := s.snapshot() + albums, _ := gallery.AlbumsOnDisk(s.ImagesDir) + if r.Header.Get("HX-Request") == "true" { + w.WriteHeader(http.StatusBadRequest) + templates.AdminFlash(msg).Render(r.Context(), w) + return + } + templates.AdminDashboard(images, albums, msg).Render(r.Context(), w) +} diff --git a/app/internal/handlers/handlers.go b/app/internal/handlers/handlers.go index 90219f3..922d89b 100644 --- a/app/internal/handlers/handlers.go +++ b/app/internal/handlers/handlers.go @@ -4,8 +4,10 @@ import ( "log" "net/http" "strings" + "sync" "time" + "technical.kiwi/website/internal/auth" "technical.kiwi/website/internal/contact" "technical.kiwi/website/internal/gallery" "technical.kiwi/website/internal/mail" @@ -22,6 +24,9 @@ type Server struct { HasHero bool Mail *mail.Config ContactEnabled bool + Auth auth.Config + Sessions *auth.Sessions + galleryMu sync.RWMutex } // New builds a Server after loading the image gallery. @@ -43,6 +48,7 @@ func New(imagesDir, staticDir string, mailCfg *mail.Config) (*Server, error) { } log.Printf("hero image: %s", hero.RelPath) } + authCfg := auth.Load() srv := &Server{ ImagesDir: imagesDir, StaticDir: staticDir, @@ -51,20 +57,32 @@ func New(imagesDir, staticDir string, mailCfg *mail.Config) (*Server, error) { HasHero: hasHero, Mail: mailCfg, ContactEnabled: mailCfg != nil, + Auth: authCfg, + Sessions: auth.NewSessions(), } - go func() { - if err := gallery.EnsureThumbnails(imagesDir); err != nil { - log.Printf("gallery thumbnails: %v", err) - return - } + if authCfg.Enabled { + log.Print("admin: enabled at /admin/") + } else { + log.Print("admin: disabled (set ADMIN_USER and ADMIN_PASSWORD)") + } + if err := gallery.EnsureThumbnails(imagesDir); err != nil { + log.Printf("gallery thumbnails: %v", err) + } else { log.Print("gallery thumbnails: ready") - }() + } return srv, nil } +func (s *Server) snapshot() ([]gallery.Image, gallery.Image, bool) { + s.galleryMu.RLock() + defer s.galleryMu.RUnlock() + return s.Images, s.Hero, s.HasHero +} + func (s *Server) index(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-cache") - templates.Home(s.Images, s.Hero, s.HasHero, s.ContactEnabled).Render(r.Context(), w) + images, hero, hasHero := s.snapshot() + templates.Home(images, hero, hasHero, s.ContactEnabled).Render(r.Context(), w) } func (s *Server) contact(w http.ResponseWriter, r *http.Request) { @@ -107,54 +125,31 @@ func (s *Server) contact(w http.ResponseWriter, r *http.Request) { templates.ContactSuccess().Render(r.Context(), w) } -func (s *Server) galleryPartial(w http.ResponseWriter, r *http.Request) { - album := r.URL.Query().Get("album") - collection := r.URL.Query().Get("collection") - filtered := s.filterImages(album, collection) - templates.GalleryGrid(filtered).Render(r.Context(), w) -} - func (s *Server) imageModal(w http.ResponseWriter, r *http.Request) { rel := r.PathValue("path") if !gallery.SafeRelPath(rel) { http.NotFound(w, r) return } - img, ok := s.findImage(rel) + images, _, _ := s.snapshot() + img, ok := findImage(images, rel) if !ok { http.NotFound(w, r) return } - idx := s.indexOf(rel) + idx := indexOf(images, rel) prev, next := "", "" if idx > 0 { - prev = s.Images[idx-1].RelPath + prev = images[idx-1].RelPath } - if idx >= 0 && idx < len(s.Images)-1 { - next = s.Images[idx+1].RelPath + if idx >= 0 && idx < len(images)-1 { + next = images[idx+1].RelPath } templates.ImageModal(img, prev, next).Render(r.Context(), w) } -func (s *Server) filterImages(album, collection string) []gallery.Image { - if album == "" && collection == "" { - return s.Images - } - var out []gallery.Image - for _, img := range s.Images { - if album != "" && img.Album != album { - continue - } - if collection != "" && img.CollectionKey != collection { - continue - } - out = append(out, img) - } - return out -} - -func (s *Server) findImage(rel string) (gallery.Image, bool) { - for _, img := range s.Images { +func findImage(images []gallery.Image, rel string) (gallery.Image, bool) { + for _, img := range images { if img.RelPath == rel { return img, true } @@ -162,8 +157,8 @@ func (s *Server) findImage(rel string) (gallery.Image, bool) { return gallery.Image{}, false } -func (s *Server) indexOf(rel string) int { - for i, img := range s.Images { +func indexOf(images []gallery.Image, rel string) int { + for i, img := range images { if img.RelPath == rel { return i } @@ -175,9 +170,15 @@ func (s *Server) indexOf(rel string) int { func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /{$}", s.index) mux.HandleFunc("POST /contact", s.contact) - mux.HandleFunc("GET /gallery", s.galleryPartial) mux.HandleFunc("GET /gallery/{path...}", s.imageModal) staticHandler := middleware.CacheStatic(s.StaticDir, 7*24*time.Hour) mux.Handle("GET /static/", http.StripPrefix("/static/", staticHandler)) mux.Handle("GET /images/", http.StripPrefix("/images/", middleware.CacheImages(s.ImagesDir))) + + mux.HandleFunc("GET /admin/login", s.adminLogin) + mux.HandleFunc("POST /admin/login", s.adminLoginPost) + mux.HandleFunc("POST /admin/logout", s.adminLogout) + mux.HandleFunc("GET /admin/", s.requireAdmin(s.adminIndex)) + mux.HandleFunc("POST /admin/upload", s.requireAdmin(s.adminUpload)) + mux.HandleFunc("POST /admin/delete", s.requireAdmin(s.adminDelete)) } diff --git a/app/internal/middleware/middleware.go b/app/internal/middleware/middleware.go index e8d6474..1d4593d 100644 --- a/app/internal/middleware/middleware.go +++ b/app/internal/middleware/middleware.go @@ -3,7 +3,9 @@ package middleware import ( "compress/gzip" "io" + "mime" "net/http" + "path/filepath" "strconv" "strings" "time" @@ -21,7 +23,7 @@ func (w *gzipResponseWriter) Write(b []byte) (int, error) { // Gzip compresses responses when the client accepts it. func Gzip(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") || shouldSkipGzip(r) { next.ServeHTTP(w, r) return } @@ -59,3 +61,20 @@ func formatMaxAge(d time.Duration) string { return strconv.Itoa(int(d.Seconds())) } +func shouldSkipGzip(r *http.Request) bool { + // Range requests must stay byte-accurate for media seeking/playback. + if r.Header.Get("Range") != "" { + return true + } + + ext := strings.ToLower(filepath.Ext(r.URL.Path)) + if ext == "" { + return false + } + + ct := mime.TypeByExtension(ext) + return strings.HasPrefix(ct, "image/") || + strings.HasPrefix(ct, "video/") || + strings.HasPrefix(ct, "audio/") +} + diff --git a/app/static/style.css b/app/static/style.css index 63ada44..6b33841 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -281,53 +281,6 @@ main { font-size: 0.95rem; } -.gallery-controls { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; - margin-bottom: 1.25rem; - align-items: center; -} - -.gallery-controls-collections { - padding: 0.75rem 0 0; - margin-bottom: 1.25rem; - border-top: 1px solid var(--border); -} - -.gallery-controls-label { - font-size: 0.8rem; - font-family: var(--mono); - color: var(--text-muted); - margin-right: 0.25rem; - width: 100%; - flex-basis: 100%; -} - -@media (min-width: 600px) { - .gallery-controls-label { - width: auto; - flex-basis: auto; - } -} - -.filter-btn { - font: inherit; - padding: 0.45rem 0.9rem; - border-radius: 999px; - border: 1px solid var(--border); - background: transparent; - color: var(--text-muted); - cursor: pointer; -} - -.filter-btn:hover, -.filter-btn.active { - background: var(--accent-soft); - color: var(--text); - border-color: rgba(196, 123, 58, 0.45); -} - .gallery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); @@ -358,6 +311,21 @@ main { background: var(--surface); } +.gallery-video-badge { + position: absolute; + left: 0.35rem; + bottom: 0.35rem; + padding: 0.15rem 0.4rem; + border-radius: 3px; + background: rgba(0, 0, 0, 0.65); + color: var(--text); + font-family: var(--mono); + font-size: 0.65rem; + letter-spacing: 0.04em; + text-transform: uppercase; + pointer-events: none; +} + .gallery-item:hover img { transform: scale(1.04); } @@ -530,6 +498,7 @@ main { border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; + overflow: hidden; } .modal-close { @@ -550,13 +519,19 @@ main { .modal-figure { margin: 0; + border-radius: 8px; + overflow: hidden; + background: #000; } -.modal-figure img { +.modal-figure img, +.modal-figure video { + display: block; width: 100%; max-height: 75vh; object-fit: contain; border-radius: 8px; + background: #000; } .modal-figure figcaption { @@ -675,11 +650,6 @@ main { gap: 0.5rem; } - .filter-btn { - min-height: 2.5rem; - padding: 0.5rem 1rem; - } - .contact-form { max-width: none; } @@ -739,7 +709,8 @@ main { min-height: 0; } - .modal-figure img { + .modal-figure img, + .modal-figure video { flex: 1; min-height: 0; max-height: none; @@ -775,3 +746,116 @@ main { transform: scale(1.02); } } + +/* --- Admin --- */ +.admin-body { + min-height: 100vh; +} + +.admin-login { + max-width: 24rem; + margin: 4rem auto; + padding: 0 1rem; +} + +.admin-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem clamp(1rem, 4vw, 2rem); + border-bottom: 1px solid var(--border); +} + +.admin-header h1 { + margin: 0; + font-size: 1.25rem; +} + +.admin-main { + max-width: 1100px; + margin: 0 auto; + padding: 1.5rem clamp(1rem, 4vw, 2rem) 3rem; +} + +.admin-panel { + margin-bottom: 2rem; + padding: 1.25rem; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.admin-panel h2 { + margin: 0 0 1rem; + font-size: 1.1rem; +} + +.admin-back { + color: var(--text-muted); + font-size: 0.9rem; +} + +.admin-table-wrap { + overflow-x: auto; +} + +.admin-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.admin-table th, +.admin-table td { + padding: 0.6rem 0.5rem; + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: middle; +} + +.admin-table code { + font-family: var(--mono); + font-size: 0.8rem; + word-break: break-all; +} + +.admin-thumb { + width: 4rem; +} + +.admin-thumb img { + width: 4rem; + height: 4rem; + object-fit: cover; + border-radius: 6px; +} + +.admin-delete { + color: #e8a090; +} + +.admin-delete:hover { + background: rgba(120, 40, 40, 0.25); +} + +#admin-flash:empty { + display: none; +} + +.contact-form select { + display: block; + width: 100%; + box-sizing: border-box; + font: inherit; + padding: 0.65rem 0.85rem; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg-elevated); + color: var(--text); +} + +.contact-form input[type="file"] { + font-size: 0.9rem; + color: var(--text-muted); +} diff --git a/app/templates/admin.templ b/app/templates/admin.templ new file mode 100644 index 0000000..f879427 --- /dev/null +++ b/app/templates/admin.templ @@ -0,0 +1,156 @@ +package templates + +import ( + "fmt" + "technical.kiwi/website/internal/gallery" +) + +templ AdminLogin(errMsg string) { + + +
+ + +{ errMsg }
{ msg }
No images yet.
+ } else { +| Preview | +Path | +Album | ++ |
|---|---|---|---|
|
+ if img.ThumbURL != "" {
+ |
+ { img.RelPath } |
+ { gallery.AlbumLabel(img.Album) } | ++ + | +