Files
photog/server.go
T

229 lines
7.2 KiB
Go
Raw Normal View History

2026-07-02 00:53:17 +02:00
package main
import (
"fmt"
"io"
"log"
"net/http"
"net/url"
2026-07-02 00:53:17 +02:00
"os"
"path/filepath"
"strings"
"time"
"github.com/BurntSushi/toml"
2026-10-18 14:45:33 +02:00
"golang.org/x/time/rate"
2026-07-02 00:53:17 +02:00
)
type Server struct {
port string
contentDir string
outputDir string
serve bool
generator *Generator
2026-10-18 14:45:33 +02:00
auth *Authenticator // nil disables auth (dev only)
authLimit *rate.Limiter
// rebuildHook overrides how a mutation triggers a rebuild. nil means the
// default asynchronous Build; tests set it to run synchronously (or not at
// all) so no background goroutine outlives the test.
rebuildHook func()
2026-07-02 00:53:17 +02:00
}
2026-10-18 14:45:33 +02:00
func NewServer(port, contentDir, outputDir string, serve bool, g *Generator, auth *Authenticator) *Server {
2026-07-02 00:53:17 +02:00
return &Server{
port: port,
contentDir: contentDir,
outputDir: outputDir,
serve: serve,
generator: g,
2026-10-18 14:45:33 +02:00
auth: auth,
// Brute-force throttle: ~1 failed auth/sec steady, burst of 10. Only
// failures draw from it, so legitimate bulk uploads are never slowed.
authLimit: rate.NewLimiter(rate.Every(time.Second), 10),
2026-07-02 00:53:17 +02:00
}
}
2026-10-18 14:45:33 +02:00
// guard applies HTTP Basic auth to h when an Authenticator is configured, and
// passes it through untouched otherwise (local --serve development).
func (s *Server) guard(h http.HandlerFunc) http.HandlerFunc {
if s.auth == nil {
return h
}
return s.auth.basicAuth(s.authLimit, h)
}
// sameOrigin reports whether a form/API post came from the gallery's own pages.
// Basic-auth credentials are replayed by the browser on every request to the
// host, so without this check any other website could drive a logged-in user's
// browser into submitting an edit, delete or retag on their behalf (CSRF).
//
// Sec-Fetch-Site is preferred; where it is absent the Origin header is compared
// against -base-url and the request Host (behind nginx the Host reaching this
// server is the proxy upstream, not the public name). A request with neither
// header is a non-browser client such as curl, which cannot be driven
// cross-site, so it is allowed.
func (s *Server) sameOrigin(r *http.Request) bool {
if site := r.Header.Get("Sec-Fetch-Site"); site != "" {
return site == "same-origin" || site == "none"
}
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
if origin == s.generator.baseURL {
return true
}
u, err := url.Parse(origin)
return err == nil && u.Host == r.Host
}
// rejectCrossSite writes a 403 and returns true when r is a cross-site request.
// State-changing handlers call it first: `if s.rejectCrossSite(w, r) { return }`.
func (s *Server) rejectCrossSite(w http.ResponseWriter, r *http.Request) bool {
if s.sameOrigin(r) {
return false
}
http.Error(w, "cross-site request refused", http.StatusForbidden)
return true
}
2026-07-02 00:53:17 +02:00
func (s *Server) Listen() error {
mux := http.NewServeMux()
2026-10-18 14:45:33 +02:00
mux.HandleFunc("GET /upload", s.guard(s.handleUploadPage))
mux.HandleFunc("POST /upload", s.guard(s.handleUpload))
// Edit/delete UI and its API. All auth-gated like /upload; destructive
// endpoints (delete) move posts to content/.trash/ rather than erasing.
mux.HandleFunc("GET /manage", s.guard(s.handleManagePage))
mux.HandleFunc("GET /api/photos", s.guard(s.handleListPhotos))
mux.HandleFunc("POST /api/edit", s.guard(s.handleEdit))
mux.HandleFunc("POST /api/delete", s.guard(s.handleDelete))
mux.HandleFunc("POST /api/tags", s.guard(s.handleBulkTags))
mux.HandleFunc("GET /api/trash", s.guard(s.handleListTrash))
mux.HandleFunc("POST /api/restore", s.guard(s.handleRestore))
mux.HandleFunc("POST /api/trash/empty", s.guard(s.handleEmptyTrash))
2026-07-02 00:53:17 +02:00
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)
2026-10-08 16:14:09 +02:00
// tags — comma-separated album tags (optional; lowercased, deduped)
2026-07-02 00:53:17 +02:00
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)
}
2026-10-08 16:14:09 +02:00
// Normalize the tags once here so the sidecar stores clean lowercase names;
// the generator derives album slugs from them at build time.
var tags []string
for _, t := range parseTags(r.FormValue("tags")) {
tags = append(tags, t.Name)
}
2026-07-02 00:53:17 +02:00
slug := sanitiseSlug(strings.TrimSuffix(header.Filename, ext))
if slug == "" {
slug = fmt.Sprintf("photo-%d", time.Now().UnixMilli())
}
2026-10-08 16:14:09 +02:00
imgPath := filepath.Join(s.contentDir, slug+ext)
2026-07-02 00:53:17 +02:00
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()
2026-10-08 16:14:09 +02:00
if err := toml.NewEncoder(tf).Encode(PhotoMeta{Caption: caption, Date: dateStr, Tags: tags}); err != nil {
2026-07-02 00:53:17 +02:00
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()
}