Files
photog/server.go
T
puttaalu 2143b95657 Fix Android manage thumbnails and multi-select picker
Thumbnails were blank on the manage screen because the app fetched them from
the static /thumbs/ path, which a front-door botwall on `location /` blocks for
a non-browser client even though /api/ (proxied to the app) works. Add an authed
GET /api/thumb/{slug} that serves the thumbnail JPEG from public/thumbs/, and
have the app fetch from there. Also show the thumbnail in the edit dialog, not
just the list rows.

Construct the photo picker with an explicit maxItems so it reliably opens in
multi-select mode; the no-arg PickMultipleVisualMedia default has been seen to
present single-select on some devices.
2026-10-19 15:33:25 +02:00

235 lines
7.4 KiB
Go

package main
import (
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/BurntSushi/toml"
"golang.org/x/time/rate"
)
type Server struct {
port string
contentDir string
outputDir string
serve bool
generator *Generator
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()
}
func NewServer(port, contentDir, outputDir string, serve bool, g *Generator, auth *Authenticator) *Server {
return &Server{
port: port,
contentDir: contentDir,
outputDir: outputDir,
serve: serve,
generator: g,
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),
}
}
// 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
}
func (s *Server) Listen() error {
mux := http.NewServeMux()
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/thumb/{slug}", s.guard(s.handleThumb))
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))
// Login-state probe — deliberately NOT guarded, so public pages can ask
// without triggering a Basic-auth dialog. It does its own silent check.
mux.HandleFunc("GET /api/session", s.handleSession)
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)
// tags — comma-separated album tags (optional; lowercased, deduped)
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)
}
// 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)
}
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, Tags: tags}); 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()
}