Both tag fields (post and edit) now suggest existing tags as you type,
matching the web upload/manage forms. Suggestions come from a new
GET /api/tags.json that serves the generated public/tags.json. It lives under
/api/ so nginx proxies it to the app, reaching non-browser clients even when a
front-door botwall gates the static site (the same approach as /api/thumb/).
/api/tags.json and /api/thumb/{slug} are both left unguarded: they serve the
same public data as the static site (/tags.json and /thumbs/), so there is
nothing to protect, and keeping them off the auth path means they can never
return 401 — otherwise an nginx auth-fail jail (fail2ban) would ban the app
over an autocomplete poll or a per-row thumbnail load.
A reusable TagField composable filters on the token after the last comma and
completes it on tap; fetchTags() returns an empty list on any failure, so the
field degrades to a plain tags input when the endpoint is absent.
369 lines
11 KiB
Go
369 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"image"
|
|
"image/png"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/BurntSushi/toml"
|
|
)
|
|
|
|
// newTestServer builds a Server over fresh temp content/output dirs with a
|
|
// couple of posts, runs one build, and returns it.
|
|
func newTestServer(t *testing.T) (*Server, string, string) {
|
|
t.Helper()
|
|
content := t.TempDir()
|
|
output := t.TempDir()
|
|
|
|
writePNG(t, filepath.Join(content, "foo.png"))
|
|
if err := os.WriteFile(filepath.Join(content, "foo.toml"),
|
|
[]byte("caption = \"Foo\"\ndate = \"2026-01-02\"\ntags = [\"berlin\", \"street\"]\n"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writePNG(t, filepath.Join(content, "bar.png")) // no sidecar
|
|
|
|
g := NewGenerator(content, output, "http://x", "A", "@a", "bio", "", "f", "s", "m", 60, viewTimeline)
|
|
if err := g.Build(); err != nil {
|
|
t.Fatalf("initial build: %v", err)
|
|
}
|
|
s := NewServer("0", content, output, false, g, nil)
|
|
// Make rebuilds a no-op; tests call Build explicitly when they need to
|
|
// assert on generated output, so no background goroutine races teardown.
|
|
s.rebuildHook = func() {}
|
|
return s, content, output
|
|
}
|
|
|
|
func writePNG(t *testing.T, path string) {
|
|
t.Helper()
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
if err := png.Encode(f, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func form(v url.Values) *http.Request {
|
|
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(v.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
return r
|
|
}
|
|
|
|
// multipartForm builds a multipart/form-data request, as a browser's FormData
|
|
// does — the content type the manage handlers must parse, not just urlencoded.
|
|
func multipartForm(fields map[string]string) *http.Request {
|
|
var b bytes.Buffer
|
|
mw := multipart.NewWriter(&b)
|
|
for k, v := range fields {
|
|
mw.WriteField(k, v)
|
|
}
|
|
mw.Close()
|
|
r := httptest.NewRequest(http.MethodPost, "/", &b)
|
|
r.Header.Set("Content-Type", mw.FormDataContentType())
|
|
return r
|
|
}
|
|
|
|
func readMeta(t *testing.T, content, slug string) PhotoMeta {
|
|
t.Helper()
|
|
var m PhotoMeta
|
|
if _, err := toml.DecodeFile(filepath.Join(content, slug+".toml"), &m); err != nil {
|
|
t.Fatalf("decode %s: %v", slug, err)
|
|
}
|
|
return m
|
|
}
|
|
|
|
func TestResolveSlug(t *testing.T) {
|
|
s, _, _ := newTestServer(t)
|
|
cases := map[string]bool{
|
|
"foo": true,
|
|
"bar": true,
|
|
"nope": false,
|
|
"../server": false,
|
|
"foo/../foo": false,
|
|
".trash": false,
|
|
"": false,
|
|
"sub/foo": false,
|
|
}
|
|
for slug, want := range cases {
|
|
if _, ok := s.resolveSlug(slug); ok != want {
|
|
t.Errorf("resolveSlug(%q) = %v, want %v", slug, ok, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleEdit(t *testing.T) {
|
|
s, content, _ := newTestServer(t)
|
|
|
|
rec := httptest.NewRecorder()
|
|
s.handleEdit(rec, form(url.Values{
|
|
"slug": {"foo"},
|
|
"caption": {"New caption"},
|
|
"date": {"2020-05-06"},
|
|
"tags": {"Paris, , night"},
|
|
}))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("edit: code=%d body=%s", rec.Code, rec.Body)
|
|
}
|
|
m := readMeta(t, content, "foo")
|
|
if m.Caption != "New caption" || m.Date != "2020-05-06" {
|
|
t.Errorf("caption/date not saved: %+v", m)
|
|
}
|
|
if strings.Join(m.Tags, ",") != "paris,night" { // lowercased, empty dropped
|
|
t.Errorf("tags = %v, want [paris night]", m.Tags)
|
|
}
|
|
|
|
// Bad date rejected.
|
|
rec = httptest.NewRecorder()
|
|
s.handleEdit(rec, form(url.Values{"slug": {"foo"}, "date": {"06-05-2020"}}))
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("bad date: code=%d, want 400", rec.Code)
|
|
}
|
|
|
|
// Unknown slug rejected (and cannot escape content dir).
|
|
rec = httptest.NewRecorder()
|
|
s.handleEdit(rec, form(url.Values{"slug": {"../evil"}, "date": {"2020-01-01"}}))
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("unknown slug: code=%d, want 404", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleBulkTags(t *testing.T) {
|
|
s, content, _ := newTestServer(t)
|
|
|
|
// Add to bar (no sidecar yet) and foo (already has tags).
|
|
rec := httptest.NewRecorder()
|
|
s.handleBulkTags(rec, form(url.Values{"slugs": {"foo,bar"}, "add": {"aqua, berlin"}}))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("bulk add: code=%d body=%s", rec.Code, rec.Body)
|
|
}
|
|
foo := readMeta(t, content, "foo")
|
|
// berlin already present → not duplicated; aqua appended.
|
|
if got := strings.Join(foo.Tags, ","); got != "berlin,street,aqua" {
|
|
t.Errorf("foo tags = %q, want berlin,street,aqua", got)
|
|
}
|
|
if got := strings.Join(readMeta(t, content, "bar").Tags, ","); got != "aqua,berlin" {
|
|
t.Errorf("bar tags = %q, want aqua,berlin", got)
|
|
}
|
|
|
|
// Remove across both.
|
|
rec = httptest.NewRecorder()
|
|
s.handleBulkTags(rec, form(url.Values{"slugs": {"foo,bar"}, "remove": {"berlin"}}))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("bulk remove: code=%d", rec.Code)
|
|
}
|
|
if got := strings.Join(readMeta(t, content, "foo").Tags, ","); got != "street,aqua" {
|
|
t.Errorf("foo tags after remove = %q, want street,aqua", got)
|
|
}
|
|
|
|
// Empty add+remove is a 400.
|
|
rec = httptest.NewRecorder()
|
|
s.handleBulkTags(rec, form(url.Values{"slugs": {"foo"}}))
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("empty bulk: code=%d, want 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleDeleteMovesToTrashAndPrunes(t *testing.T) {
|
|
s, content, output := newTestServer(t)
|
|
|
|
// Pre-condition: foo's rendition and page exist.
|
|
for _, p := range []string{"thumbs/foo.jpg", "images/foo.png", "photo/foo/index.html"} {
|
|
if _, err := os.Stat(filepath.Join(output, p)); err != nil {
|
|
t.Fatalf("expected %s before delete: %v", p, err)
|
|
}
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
s.handleDelete(rec, form(url.Values{"slug": {"foo"}}))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("delete: code=%d", rec.Code)
|
|
}
|
|
var res map[string]int
|
|
json.Unmarshal(rec.Body.Bytes(), &res)
|
|
if res["deleted"] != 1 {
|
|
t.Errorf("deleted = %d, want 1", res["deleted"])
|
|
}
|
|
|
|
// Source files moved out of content/ ...
|
|
if _, err := os.Stat(filepath.Join(content, "foo.png")); !os.IsNotExist(err) {
|
|
t.Error("foo.png should be gone from content")
|
|
}
|
|
// ... and into .trash/ (timestamp-suffixed).
|
|
trash, _ := os.ReadDir(filepath.Join(content, ".trash"))
|
|
var png, tml bool
|
|
for _, e := range trash {
|
|
if strings.HasPrefix(e.Name(), "foo.") && strings.HasSuffix(e.Name(), ".png") {
|
|
png = true
|
|
}
|
|
if strings.HasPrefix(e.Name(), "foo.") && strings.HasSuffix(e.Name(), ".toml") {
|
|
tml = true
|
|
}
|
|
}
|
|
if !png || !tml {
|
|
t.Errorf("trash missing files: png=%v toml=%v entries=%v", png, tml, trash)
|
|
}
|
|
|
|
// The handler rebuilds asynchronously; run a deterministic build, then
|
|
// confirm the prune removed foo's output and kept bar's.
|
|
if err := s.generator.Build(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, p := range []string{"thumbs/foo.jpg", "images/foo.png", "photo/foo"} {
|
|
if _, err := os.Stat(filepath.Join(output, p)); !os.IsNotExist(err) {
|
|
t.Errorf("%s should have been pruned", p)
|
|
}
|
|
}
|
|
if _, err := os.Stat(filepath.Join(output, "thumbs/bar.jpg")); err != nil {
|
|
t.Errorf("bar's thumb should survive: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestMultipartBodies guards the regression where the manage handlers used
|
|
// r.ParseForm (urlencoded only) and silently ignored the multipart bodies that
|
|
// browsers send via FormData, returning 200 with deleted:0 / no change.
|
|
func TestMultipartBodies(t *testing.T) {
|
|
s, content, _ := newTestServer(t)
|
|
|
|
// Edit via multipart.
|
|
rec := httptest.NewRecorder()
|
|
s.handleEdit(rec, multipartForm(map[string]string{
|
|
"slug": "foo", "caption": "Multipart", "date": "2021-02-03", "tags": "x,y",
|
|
}))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("multipart edit: code=%d", rec.Code)
|
|
}
|
|
if m := readMeta(t, content, "foo"); m.Caption != "Multipart" || strings.Join(m.Tags, ",") != "x,y" {
|
|
t.Errorf("multipart edit not applied: %+v", m)
|
|
}
|
|
|
|
// Delete via multipart actually moves the file.
|
|
rec = httptest.NewRecorder()
|
|
s.handleDelete(rec, multipartForm(map[string]string{"slug": "bar"}))
|
|
var res map[string]int
|
|
json.Unmarshal(rec.Body.Bytes(), &res)
|
|
if res["deleted"] != 1 {
|
|
t.Fatalf("multipart delete: deleted=%d, want 1 (body=%s)", res["deleted"], rec.Body)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(content, "bar.png")); !os.IsNotExist(err) {
|
|
t.Error("bar.png should have moved to trash")
|
|
}
|
|
}
|
|
|
|
func TestCSRFGuard(t *testing.T) {
|
|
s, _, _ := newTestServer(t) // generator.baseURL == "http://x"
|
|
|
|
// A cross-site Origin is refused before any mutation runs.
|
|
r := form(url.Values{"slug": {"foo"}})
|
|
r.Header.Set("Origin", "http://evil.example")
|
|
rec := httptest.NewRecorder()
|
|
s.handleDelete(rec, r)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Errorf("cross-site Origin: code=%d, want 403", rec.Code)
|
|
}
|
|
|
|
// Sec-Fetch-Site: cross-site is likewise refused.
|
|
r = form(url.Values{"slug": {"foo"}})
|
|
r.Header.Set("Sec-Fetch-Site", "cross-site")
|
|
rec = httptest.NewRecorder()
|
|
s.handleDelete(rec, r)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Errorf("Sec-Fetch cross-site: code=%d, want 403", rec.Code)
|
|
}
|
|
|
|
// Same-origin is allowed through (no slugs → deletes nothing, 200).
|
|
r = form(url.Values{})
|
|
r.Header.Set("Sec-Fetch-Site", "same-origin")
|
|
rec = httptest.NewRecorder()
|
|
s.handleDelete(rec, r)
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("same-origin: code=%d, want 200", rec.Code)
|
|
}
|
|
|
|
// A matching Origin is allowed.
|
|
r = form(url.Values{})
|
|
r.Header.Set("Origin", "http://x")
|
|
rec = httptest.NewRecorder()
|
|
s.handleDelete(rec, r)
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("matching Origin: code=%d, want 200", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleListPhotos(t *testing.T) {
|
|
s, _, _ := newTestServer(t)
|
|
rec := httptest.NewRecorder()
|
|
s.handleListPhotos(rec, httptest.NewRequest(http.MethodGet, "/api/photos", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("list: code=%d", rec.Code)
|
|
}
|
|
var photos []photoJSON
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &photos); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(photos) != 2 {
|
|
t.Fatalf("got %d photos, want 2", len(photos))
|
|
}
|
|
var foo *photoJSON
|
|
for i := range photos {
|
|
if photos[i].Slug == "foo" {
|
|
foo = &photos[i]
|
|
}
|
|
}
|
|
if foo == nil {
|
|
t.Fatal("foo not in list")
|
|
}
|
|
if foo.Thumb != "/thumbs/foo.jpg" || foo.URL != "/photo/foo/" {
|
|
t.Errorf("foo paths wrong: %+v", foo)
|
|
}
|
|
if strings.Join(foo.Tags, ",") != "berlin,street" {
|
|
t.Errorf("foo tags = %v", foo.Tags)
|
|
}
|
|
}
|
|
|
|
func TestHandleTagsJSON(t *testing.T) {
|
|
s, _, output := newTestServer(t)
|
|
|
|
rec := httptest.NewRecorder()
|
|
s.handleTagsJSON(rec, httptest.NewRequest(http.MethodGet, "/api/tags.json", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("tags.json: code=%d", rec.Code)
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
|
t.Errorf("content-type = %q", ct)
|
|
}
|
|
var tags []struct {
|
|
Tag string `json:"tag"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &tags); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
got := map[string]bool{}
|
|
for _, tg := range tags {
|
|
got[tg.Tag] = true
|
|
}
|
|
if !got["berlin"] || !got["street"] {
|
|
t.Errorf("tags = %v, want berlin+street", tags)
|
|
}
|
|
|
|
// Missing file → 404.
|
|
if err := os.Remove(filepath.Join(output, "tags.json")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec = httptest.NewRecorder()
|
|
s.handleTagsJSON(rec, httptest.NewRequest(http.MethodGet, "/api/tags.json", nil))
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("missing tags.json: code=%d, want 404", rec.Code)
|
|
}
|
|
}
|