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.
391 lines
11 KiB
Go
391 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/BurntSushi/toml"
|
|
)
|
|
|
|
// contentExts are the image extensions a post may use; the sidecar is always
|
|
// <slug>.toml alongside.
|
|
var contentExts = []string{".jpg", ".jpeg", ".png"}
|
|
|
|
// photoJSON is one row of GET /api/photos, consumed by the manage page.
|
|
type photoJSON struct {
|
|
Slug string `json:"slug"`
|
|
Caption string `json:"caption"`
|
|
Date string `json:"date"` // YYYY-MM-DD
|
|
Tags []string `json:"tags"`
|
|
Thumb string `json:"thumb"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
// handleSession reports whether the request carries valid credentials, so the
|
|
// UI can reveal management links and a signed-in indicator. It always returns
|
|
// 200 and never sends WWW-Authenticate, so calling it from a public page does
|
|
// not trigger the browser's Basic-auth dialog.
|
|
//
|
|
// It must not become a password-probing oracle that sidesteps the /upload
|
|
// limiter: a request with no credentials (a logged-out browser) is answered
|
|
// cheaply as "not authenticated", but one carrying wrong credentials is run
|
|
// through the same failed-attempt limiter and gets 429 once that is drained.
|
|
func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
|
|
if s.auth == nil { // auth disabled → everything is open
|
|
writeJSON(w, map[string]bool{"authenticated": true})
|
|
return
|
|
}
|
|
|
|
user, pass, ok := r.BasicAuth()
|
|
if !ok {
|
|
writeJSON(w, map[string]bool{"authenticated": false})
|
|
return
|
|
}
|
|
if s.auth.check(user, pass) {
|
|
writeJSON(w, map[string]bool{"authenticated": true})
|
|
return
|
|
}
|
|
if !s.authLimit.Allow() {
|
|
http.Error(w, "too many attempts", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]bool{"authenticated": false})
|
|
}
|
|
|
|
// handleThumb serves a photo's thumbnail from the generated output, behind the
|
|
// same Basic auth as the rest of /api/. Non-browser clients (the Android app)
|
|
// use this instead of the static /thumbs/<slug>.jpg path, because a front-door
|
|
// botwall on `location /` can block a non-browser client from the static site
|
|
// while `/api/` is proxied straight to this server.
|
|
func (s *Server) handleThumb(w http.ResponseWriter, r *http.Request) {
|
|
slug := r.PathValue("slug")
|
|
if slug == "" || slug != filepath.Base(slug) || strings.HasPrefix(slug, ".") {
|
|
http.Error(w, "bad slug", http.StatusBadRequest)
|
|
return
|
|
}
|
|
f, err := os.Open(filepath.Join(s.outputDir, "thumbs", slug+".jpg"))
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
fi, err := f.Stat()
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "image/jpeg")
|
|
http.ServeContent(w, r, slug+".jpg", fi.ModTime(), f)
|
|
}
|
|
|
|
// handleManagePage serves the (auth-gated) edit/delete UI.
|
|
func (s *Server) handleManagePage(w http.ResponseWriter, r *http.Request) {
|
|
data, err := assets.ReadFile("templates/manage.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)
|
|
}
|
|
|
|
// handleListPhotos returns every post as JSON, newest first, for the manage UI.
|
|
func (s *Server) handleListPhotos(w http.ResponseWriter, r *http.Request) {
|
|
photos, err := s.generator.loadPhotos()
|
|
if err != nil {
|
|
http.Error(w, "could not list photos", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
out := make([]photoJSON, 0, len(photos))
|
|
for _, p := range photos {
|
|
tags := make([]string, 0, len(p.Tags))
|
|
for _, t := range p.Tags {
|
|
tags = append(tags, t.Name)
|
|
}
|
|
out = append(out, photoJSON{
|
|
Slug: p.Slug,
|
|
Caption: p.Caption,
|
|
Date: p.Date.Format("2006-01-02"),
|
|
Tags: tags,
|
|
Thumb: "/" + p.Thumb,
|
|
URL: "/photo/" + p.Slug + "/",
|
|
})
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(out)
|
|
}
|
|
|
|
// handleEdit rewrites one post's sidecar (caption, date, tags). The slug — and
|
|
// therefore the URL — never changes.
|
|
func (s *Server) handleEdit(w http.ResponseWriter, r *http.Request) {
|
|
if s.rejectCrossSite(w, r) {
|
|
return
|
|
}
|
|
if err := parseForm(r); err != nil {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
slug := r.FormValue("slug")
|
|
if _, ok := s.resolveSlug(slug); !ok {
|
|
http.Error(w, "unknown post", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
date := r.FormValue("date")
|
|
if date == "" {
|
|
date = time.Now().Format("2006-01-02")
|
|
}
|
|
if _, err := time.Parse("2006-01-02", date); err != nil {
|
|
http.Error(w, "date must be YYYY-MM-DD", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
caption := r.FormValue("caption")
|
|
if caption == "" {
|
|
caption = slug
|
|
}
|
|
|
|
var tags []string
|
|
for _, t := range parseTags(r.FormValue("tags")) {
|
|
tags = append(tags, t.Name)
|
|
}
|
|
|
|
if err := s.writeSidecar(slug, PhotoMeta{Caption: caption, Date: date, Tags: tags}); err != nil {
|
|
log.Printf("edit %s: %v", slug, err)
|
|
http.Error(w, "could not save", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.rebuild()
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// handleDelete moves one or more posts (image + sidecar) to content/.trash/.
|
|
// Slugs come as repeated `slug` fields or a comma-separated `slugs` field.
|
|
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
|
|
if s.rejectCrossSite(w, r) {
|
|
return
|
|
}
|
|
slugs, err := s.formSlugs(r)
|
|
if err != nil {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
deleted := 0
|
|
for _, slug := range slugs {
|
|
if _, ok := s.resolveSlug(slug); !ok {
|
|
continue
|
|
}
|
|
if err := s.moveToTrash(slug); err != nil {
|
|
log.Printf("delete %s: %v", slug, err)
|
|
continue
|
|
}
|
|
deleted++
|
|
}
|
|
if deleted > 0 {
|
|
s.rebuild()
|
|
}
|
|
writeJSON(w, map[string]int{"deleted": deleted})
|
|
}
|
|
|
|
// handleBulkTags adds and/or removes tags across the given posts, leaving each
|
|
// post's caption and date untouched. `add` and `remove` are comma-separated.
|
|
func (s *Server) handleBulkTags(w http.ResponseWriter, r *http.Request) {
|
|
if s.rejectCrossSite(w, r) {
|
|
return
|
|
}
|
|
slugs, err := s.formSlugs(r)
|
|
if err != nil {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
add := parseTags(r.FormValue("add"))
|
|
remove := make(map[string]bool)
|
|
for _, t := range parseTags(r.FormValue("remove")) {
|
|
remove[t.Name] = true
|
|
}
|
|
if len(add) == 0 && len(remove) == 0 {
|
|
http.Error(w, "nothing to add or remove", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
updated := 0
|
|
for _, slug := range slugs {
|
|
if _, ok := s.resolveSlug(slug); !ok {
|
|
continue
|
|
}
|
|
meta := s.readSidecar(slug)
|
|
|
|
// Start from the current tags, append the additions, drop the removals,
|
|
// then let parseTags normalise and dedupe the result.
|
|
names := append([]string{}, meta.Tags...)
|
|
for _, t := range add {
|
|
names = append(names, t.Name)
|
|
}
|
|
var kept []string
|
|
for _, t := range parseTags(strings.Join(names, ",")) {
|
|
if !remove[t.Name] {
|
|
kept = append(kept, t.Name)
|
|
}
|
|
}
|
|
meta.Tags = kept
|
|
|
|
if err := s.writeSidecar(slug, meta); err != nil {
|
|
log.Printf("bulk tags %s: %v", slug, err)
|
|
continue
|
|
}
|
|
updated++
|
|
}
|
|
if updated > 0 {
|
|
s.rebuild()
|
|
}
|
|
writeJSON(w, map[string]int{"updated": updated})
|
|
}
|
|
|
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
|
|
// parseForm reads form values from either an application/x-www-form-urlencoded
|
|
// or a multipart/form-data body. Browsers posting FormData send multipart,
|
|
// which r.ParseForm alone does not read from the body; ParseMultipartForm
|
|
// handles both — it runs ParseForm internally (so query and urlencoded values
|
|
// are parsed) and only reports ErrNotMultipart for a non-multipart body, by
|
|
// which point those values are already available.
|
|
func parseForm(r *http.Request) error {
|
|
if err := r.ParseMultipartForm(32 << 20); err != nil && err != http.ErrNotMultipart {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resolveSlug reports whether slug names an existing post and returns the image
|
|
// path. It rejects anything that is not a bare, safe filename, closing path
|
|
// traversal and access to dotfiles such as the .trash directory.
|
|
func (s *Server) resolveSlug(slug string) (string, bool) {
|
|
if slug == "" || slug != filepath.Base(slug) || strings.HasPrefix(slug, ".") {
|
|
return "", false
|
|
}
|
|
for _, ext := range contentExts {
|
|
p := filepath.Join(s.contentDir, slug+ext)
|
|
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
|
|
return p, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// formSlugs collects slugs from repeated `slug` fields and/or a comma-separated
|
|
// `slugs` field, de-duplicated.
|
|
func (s *Server) formSlugs(r *http.Request) ([]string, error) {
|
|
if err := parseForm(r); err != nil {
|
|
return nil, err
|
|
}
|
|
seen := make(map[string]bool)
|
|
var out []string
|
|
add := func(v string) {
|
|
v = strings.TrimSpace(v)
|
|
if v != "" && !seen[v] {
|
|
seen[v] = true
|
|
out = append(out, v)
|
|
}
|
|
}
|
|
for _, v := range r.Form["slug"] {
|
|
add(v)
|
|
}
|
|
for _, v := range strings.Split(r.FormValue("slugs"), ",") {
|
|
add(v)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// readSidecar loads a post's existing metadata, falling back to the same
|
|
// defaults loadPhotos uses when the sidecar is missing or unreadable.
|
|
func (s *Server) readSidecar(slug string) PhotoMeta {
|
|
meta := PhotoMeta{Caption: slug, Date: time.Now().Format("2006-01-02")}
|
|
path := filepath.Join(s.contentDir, slug+".toml")
|
|
if _, err := os.Stat(path); err == nil {
|
|
if _, err := toml.DecodeFile(path, &meta); err != nil {
|
|
log.Printf("read sidecar %s: %v", path, err)
|
|
}
|
|
}
|
|
return meta
|
|
}
|
|
|
|
// writeSidecar writes <slug>.toml atomically (temp file + rename) so a
|
|
// concurrent build can never observe a half-written sidecar.
|
|
func (s *Server) writeSidecar(slug string, meta PhotoMeta) error {
|
|
path := filepath.Join(s.contentDir, slug+".toml")
|
|
tmp, err := os.CreateTemp(s.contentDir, ".tmp-sidecar-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpName := tmp.Name()
|
|
defer os.Remove(tmpName) // no-op once the rename below succeeds
|
|
|
|
if err := toml.NewEncoder(tmp).Encode(meta); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmpName, path)
|
|
}
|
|
|
|
// moveToTrash relocates a post's image and sidecar into content/.trash/, so a
|
|
// delete is reversible. A timestamp keeps re-deleted slugs from colliding.
|
|
func (s *Server) moveToTrash(slug string) error {
|
|
trash := filepath.Join(s.contentDir, ".trash")
|
|
if err := os.MkdirAll(trash, 0755); err != nil {
|
|
return err
|
|
}
|
|
stamp := time.Now().Format("20060102-150405")
|
|
|
|
// Move every file the post owns: its image (whichever extension) and its
|
|
// sidecar. Missing pieces are not an error.
|
|
var names []string
|
|
for _, ext := range contentExts {
|
|
names = append(names, slug+ext)
|
|
}
|
|
names = append(names, slug+".toml")
|
|
|
|
for _, name := range names {
|
|
src := filepath.Join(s.contentDir, name)
|
|
if _, err := os.Stat(src); err != nil {
|
|
continue
|
|
}
|
|
ext := filepath.Ext(name)
|
|
base := strings.TrimSuffix(name, ext)
|
|
dst := filepath.Join(trash, base+"."+stamp+ext)
|
|
if err := os.Rename(src, dst); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// rebuild kicks off an asynchronous site rebuild, matching the upload handler.
|
|
// Build is mutex-guarded, so overlapping calls are safe. Tests may install a
|
|
// rebuildHook to make this deterministic.
|
|
func (s *Server) rebuild() {
|
|
if s.rebuildHook != nil {
|
|
s.rebuildHook()
|
|
return
|
|
}
|
|
go func() {
|
|
if err := s.generator.Build(); err != nil {
|
|
log.Printf("rebuild after manage op: %v", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|