/manage lists every photo; /manage/edit/{slug} rewrites the photo's
.toml sidecar (caption, date), /manage/delete/{slug} asks for
confirmation and 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.
The pages are off by default: the routes are only registered with
-manage, so an install that does not opt in has no such code path and
answers 404. NixOS gets services.photogallery.manage.enable, which
also adds the nginx location only when on. Like /upload, the pages
carry no auth of their own and are not linked from the public site.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
170 lines
4.6 KiB
Go
170 lines
4.6 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
|
|
manage bool
|
|
generator *Generator
|
|
}
|
|
|
|
func NewServer(port, contentDir, outputDir string, serve, manage bool, g *Generator) *Server {
|
|
return &Server{
|
|
port: port,
|
|
contentDir: contentDir,
|
|
outputDir: outputDir,
|
|
serve: serve,
|
|
manage: manage,
|
|
generator: g,
|
|
}
|
|
}
|
|
|
|
// routes builds the handler. The /manage pages are only registered with
|
|
// -manage, so a service without the flag has no edit or delete code path at
|
|
// all and answers 404 there.
|
|
func (s *Server) routes() *http.ServeMux {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /upload", s.handleUploadPage)
|
|
mux.HandleFunc("POST /upload", s.handleUpload)
|
|
if s.manage {
|
|
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")
|
|
})
|
|
return mux
|
|
}
|
|
|
|
func (s *Server) Listen() error {
|
|
mux := s.routes()
|
|
|
|
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()
|
|
}
|