Files
photog/server.go
T
foxandClaude Fable 5.1 ab0bd5b65a feat(manage): delete photos from /manage, prune leftover pages
/manage/delete/{slug} asks for confirmation, then removes the image,
its sidecar and everything generated from them. Builds now drop day
pages of days without photos and pager pages past the last one, so a
delete or a date change leaves nothing behind.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 05:53:12 +00:00

158 lines
4.3 KiB
Go

package main
import (
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/BurntSushi/toml"
)
type Server struct {
port string
contentDir string
outputDir string
serve bool
generator *Generator
}
func NewServer(port, contentDir, outputDir string, serve bool, g *Generator) *Server {
return &Server{
port: port,
contentDir: contentDir,
outputDir: outputDir,
serve: serve,
generator: g,
}
}
func (s *Server) Listen() error {
mux := http.NewServeMux()
mux.HandleFunc("GET /upload", s.handleUploadPage)
mux.HandleFunc("POST /upload", s.handleUpload)
mux.HandleFunc("GET /manage", s.handleManagePage)
mux.HandleFunc("GET /manage/edit/{slug}", s.handleEditPage)
mux.HandleFunc("POST /manage/edit/{slug}", s.handleEdit)
mux.HandleFunc("GET /manage/delete/{slug}", s.handleDeletePage)
mux.HandleFunc("POST /manage/delete/{slug}", s.handleDelete)
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
if s.serve {
// Serve the generated site on the same port — for local development only.
// In production nginx handles this and the server binds to localhost.
mux.Handle("/", http.FileServer(http.Dir(s.outputDir)))
log.Printf("gallery: http://localhost:%s", s.port)
log.Printf("upload: http://localhost:%s/upload", s.port)
return http.ListenAndServe(":"+s.port, mux)
}
log.Printf("upload API on 127.0.0.1:%s", s.port)
return http.ListenAndServe("127.0.0.1:"+s.port, mux)
}
func (s *Server) handleUploadPage(w http.ResponseWriter, r *http.Request) {
data, err := assets.ReadFile("templates/upload.html")
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(data)
}
// handleUpload accepts multipart/form-data with fields:
//
// image — the image file (jpg or png)
// caption — human-readable caption (optional, defaults to filename)
// date — YYYY-MM-DD (optional, defaults to today)
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("image")
if err != nil {
http.Error(w, "missing 'image' field", http.StatusBadRequest)
return
}
defer file.Close()
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" {
http.Error(w, "only jpg and png are accepted", http.StatusBadRequest)
return
}
caption := r.FormValue("caption")
dateStr := r.FormValue("date")
if dateStr == "" {
dateStr = time.Now().Format("2006-01-02")
}
if caption == "" {
caption = strings.TrimSuffix(header.Filename, ext)
}
slug := sanitiseSlug(strings.TrimSuffix(header.Filename, ext))
if slug == "" {
slug = fmt.Sprintf("photo-%d", time.Now().UnixMilli())
}
imgPath := filepath.Join(s.contentDir, slug+ext)
tomlPath := filepath.Join(s.contentDir, slug+".toml")
out, err := os.Create(imgPath)
if err != nil {
log.Printf("create %s: %v", imgPath, err)
http.Error(w, "could not save image", http.StatusInternalServerError)
return
}
defer out.Close()
if _, err := io.Copy(out, file); err != nil {
http.Error(w, "could not write image", http.StatusInternalServerError)
return
}
tf, err := os.Create(tomlPath)
if err != nil {
log.Printf("create %s: %v", tomlPath, err)
http.Error(w, "could not save metadata", http.StatusInternalServerError)
return
}
defer tf.Close()
if err := toml.NewEncoder(tf).Encode(PhotoMeta{Caption: caption, Date: dateStr}); err != nil {
http.Error(w, "could not write metadata", http.StatusInternalServerError)
return
}
// Build() is mutex-guarded so calling it here and from the watcher is safe.
go func() {
if err := s.generator.Build(); err != nil {
log.Printf("rebuild after upload: %v", err)
}
}()
w.WriteHeader(http.StatusCreated)
fmt.Fprintf(w, "uploaded %s\n", slug+ext)
}
// sanitiseSlug returns a filesystem- and URL-safe version of s.
func sanitiseSlug(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
b.WriteRune(r)
case r == ' ', r == '_':
b.WriteRune('-')
}
}
return b.String()
}