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 // .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. Non-browser // clients (the Android app) use this instead of the static /thumbs/.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. // It is registered UNGUARDED (see server.go): thumbnails are the same public // data as the static /thumbs/, so there is nothing to protect, and staying off // the auth path means it can never 401 and so can never trip an auth-fail jail // that would ban the app over a per-row thumbnail load. 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) } // handleTagsJSON serves the generated tags.json under /api/ so the Android app // can offer tag autocomplete. It is registered UNGUARDED (see server.go): the // tag list is the same public data as the static /tags.json, so there is // nothing to protect, and staying off the auth path means it can never return // 401 and so can never trip an nginx auth-fail jail that would ban the app // over an autocomplete poll. It lives under /api/ only so nginx proxies it to // the app, reaching non-browser clients past a front-door botwall on // `location /` (the same botwall reason /api/thumb/ exists). func (s *Server) handleTagsJSON(w http.ResponseWriter, r *http.Request) { f, err := os.Open(filepath.Join(s.outputDir, "tags.json")) 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", "application/json; charset=utf-8") http.ServeContent(w, r, "tags.json", 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 .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) }