Add a /manage page to edit, delete and restore posts
A new auth-gated web UI at /manage lists every post for inline editing of caption, date and tags, with per-row and bulk delete and bulk tag add/remove. It is backed by a small JSON API (all behind the same htpasswd Basic auth as /upload, and unlinked from the public site): GET /api/photos list posts (newest first) POST /api/edit rewrite one sidecar (caption, date, tags) POST /api/tags bulk add/remove tags across posts POST /api/delete move posts to content/.trash/ (reversible) GET /api/trash list trashed items POST /api/restore move a trashed item back (409 on live-slug clash) POST /api/trash/empty hard delete trashed items (id… or all=1) Design notes: - Editing only rewrites the .toml; the slug (and URL) never changes. - Delete is reversible: image + sidecar move to content/.trash/ with a timestamp suffix, excluded from the site (loadPhotos skips dirs). The only hard delete is emptying the trash. - Build now prunes: it drops the derived HTML trees (photo/ day/ album/ page/ grid/page/) and regenerates them, and pruneRenditions removes orphaned images/thumbs/medium/og. Without this a deleted post would linger at its old URL and on stale day/album pages; this also cleans up after a hand-removed sidecar. - Sidecars are written atomically (temp + rename) so a concurrent build never observes a half-written file. - Slugs and trash ids are validated as bare filenames, closing path traversal and dotfile access. - Every state-changing POST passes a sameOrigin (CSRF) check, since Basic-auth credentials are replayed by the browser on every request to the host. It honours Sec-Fetch-Site, falls back to Origin vs base-url/Host, and allows header-less non-browser clients. Ported from the independent /manage on plex/trunk-ng. module.nix proxies /manage and /api/ to the app (nginx otherwise serves them as missing static files). The apps are unchanged; the API is there for them to adopt edit/delete later.
This commit is contained in:
1 parent
434d2437bf
commit
a7f2afc3ad
9 files changed
+1453
-7
No files matched your search
@@ -19,10 +19,18 @@ The binary does three things at once:
|
||||
- Serves a multipart upload API at `POST /upload`
|
||||
- In dev mode (`--serve`), also serves `public/` as static files
|
||||
|
||||
Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart), `GET /health`.
|
||||
Both `/upload` routes are gated by HTTP Basic auth when `-htpasswd` is set (see Auth); `/health` is always open.
|
||||
Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart),
|
||||
`GET /manage` (edit/delete UI), the manage API under `/api/` (see Manage),
|
||||
`GET /health`. Every route except `/health` is gated by HTTP Basic auth when
|
||||
`-htpasswd` is set (see Auth).
|
||||
|
||||
In production nginx sits in front: serves `public/` directly, proxies `/upload` to localhost, terminates TLS. Auth is **no longer nginx's job** — the app checks credentials itself (`auth.go`), so the nginx vhost just proxies. Note `/health` is **not** proxied — nginx only forwards `location /upload`, so anything outside the box can only reach `/upload`.
|
||||
In production nginx sits in front: serves `public/` directly, proxies the
|
||||
dynamic endpoints (`/upload`, `/manage`, `/api/`) to localhost, terminates TLS.
|
||||
Auth is **no longer nginx's job** — the app checks credentials itself
|
||||
(`auth.go`), so the nginx vhost just proxies. `/health` is **not** proxied, so
|
||||
only `/upload`, `/manage` and `/api/` are reachable from outside the box;
|
||||
anything new the app serves needs a matching `location` in `module.nix` or
|
||||
nginx will 404 it as a missing static file.
|
||||
|
||||
## Auth
|
||||
|
||||
@@ -40,6 +48,44 @@ comparison (against a dummy hash) so timing can't enumerate usernames, and
|
||||
never touch it. Both apps (Android, Qt desktop) already send Basic auth, so no
|
||||
client change was needed. Deps: `golang.org/x/crypto/bcrypt`, `golang.org/x/time/rate`.
|
||||
|
||||
## Manage (edit / delete)
|
||||
|
||||
`GET /manage` is an auth-gated web page (`templates/manage.html`) for editing
|
||||
and deleting posts; it is **not linked from the public site** (reach it by URL).
|
||||
Its API, all behind the same Basic auth and handled in `manage.go`:
|
||||
|
||||
- `GET /api/photos` — every post as JSON (`slug, caption, date, tags, thumb, url`), newest first.
|
||||
- `POST /api/edit` — rewrite one post's sidecar (`slug, caption, date, tags`). The slug (and URL) never changes; editing only touches the `.toml`.
|
||||
- `POST /api/tags` — bulk add/remove tags across posts (`slugs` CSV + `add`/`remove` CSV), leaving each post's caption and date intact.
|
||||
- `POST /api/delete` — move posts (`slug` repeated, or `slugs` CSV) to `content/.trash/` (timestamp-suffixed, reversible), **not** a hard delete.
|
||||
- `GET /api/trash` — list trashed items (`id` = `<slug>.<stamp>`, `slug`, `deletedAt`, `image`, `hasMeta`), newest first.
|
||||
- `POST /api/restore` — move a trashed item (`id`) back to `content/`, stripping the timestamp. Refuses with `409` if a live post already holds that slug.
|
||||
- `POST /api/trash/empty` — **hard delete** trashed items: specific `id`s (repeated), or everything with `all=1`. The only non-reversible delete in the system.
|
||||
|
||||
The manage page has a Trash panel (header toggle) for restore / delete-forever /
|
||||
empty. `trash.go` handles these; filenames parse as `<slug>.<stamp><ext>` (slug
|
||||
is dot-free and the stamp is a fixed `20060102-150405`, so parts peel off
|
||||
unambiguously), and `id`s are validated the same way to block traversal.
|
||||
|
||||
Each non-trash mutation triggers the same async rebuild as upload. Sidecars are written
|
||||
atomically (temp + rename) so a concurrent build never sees a half-written
|
||||
file. Slugs are validated (`resolveSlug`: bare filename only, must name an
|
||||
existing content image) which closes path traversal and dotfile access.
|
||||
|
||||
Every state-changing POST (`edit`, `delete`, `tags`, `restore`, `trash/empty`)
|
||||
first passes `sameOrigin` (`server.go`): because Basic-auth credentials are
|
||||
replayed by the browser on every request to the host, a cross-site page could
|
||||
otherwise drive a logged-in user into a delete (CSRF). The check honours
|
||||
`Sec-Fetch-Site`, falls back to comparing `Origin` against `-base-url`/`Host`,
|
||||
and allows header-less non-browser clients (curl). Cross-site → `403`. (Ported
|
||||
from the independent `/manage` on `plex/trunk-ng`.)
|
||||
|
||||
**Delete relies on `Build`'s prune step** (`generator.go`): a rebuild drops the
|
||||
derived HTML trees (`photo/ day/ album/ page/ grid/page/`) and regenerates them,
|
||||
and `pruneRenditions` removes `images|thumbs|medium|og` files with no backing
|
||||
photo. Without this a deleted post would linger at its old URL and in stale
|
||||
day/album pages. This also cleans up after a sidecar removed by hand.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Go** — stdlib + `BurntSushi/toml`, `fsnotify/fsnotify`, `golang.org/x/image`, `golang.org/x/crypto` (bcrypt), `golang.org/x/time` (rate)
|
||||
@@ -181,6 +227,9 @@ nix build # succeeds
|
||||
| `albums.go` | Tag parsing/normalization, `groupByTag`, album pages + `/albums/` + `/tags.json` |
|
||||
| `server.go` | Upload API + optional static file serving |
|
||||
| `auth.go` | htpasswd (bcrypt) HTTP Basic auth + failed-attempt rate limit for `/upload` |
|
||||
| `manage.go` | Edit/delete API (`/api/photos`, `/api/edit`, `/api/tags`, `/api/delete`) + trash move + atomic sidecar writes |
|
||||
| `trash.go` | Trash API: list (`/api/trash`), `/api/restore`, `/api/trash/empty` (hard delete) |
|
||||
| `templates/manage.html` | Auth-gated edit/delete web UI at `/manage`, with a Trash panel |
|
||||
| `watcher.go` | fsnotify watcher with 500ms debounce |
|
||||
| `assets.go` | `//go:embed` for templates and static |
|
||||
| `templates/timeline.html` | Site root: photos grouped by day, paginated |
|
||||
@@ -201,8 +250,7 @@ nix build # succeeds
|
||||
## What's not done yet
|
||||
|
||||
- ActivityPub federation (planned later — start with RSS/Atom)
|
||||
- Multi-image upload
|
||||
- Delete/edit via UI
|
||||
- Any auth beyond the app's htpasswd Basic auth on `/upload` (no sessions, no per-user scopes)
|
||||
- Edit/delete/trash in the Android and Qt apps (web `/manage` only so far; the `/api/` endpoints are there for the apps to adopt)
|
||||
- Any auth beyond the app's htpasswd Basic auth (no sessions, no per-user scopes)
|
||||
- The Android app has never been run on real hardware
|
||||
- Photo-page swipe has never been tested on a real touchscreen; the iOS Safari edge-gesture interaction is the untested part
|
||||
@@ -123,12 +123,30 @@ func (g *Generator) Build() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Drop fully-derived HTML trees before regenerating them, so a deleted
|
||||
// post, a retag or a changed date leaves no orphaned page behind. These are
|
||||
// cheap to rewrite (no up-to-date skipping applies to HTML), unlike the
|
||||
// image renditions, which are pruned by slug just below.
|
||||
for _, d := range []string{"photo", "day", "album", "page"} {
|
||||
if err := os.RemoveAll(filepath.Join(g.outputDir, d)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(g.outputDir, "grid", "page")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "grid", "day", "album", "albums", "static"} {
|
||||
if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Remove rendition files whose source photo is gone (e.g. after a delete),
|
||||
// keeping the expensive resize outputs of surviving photos in place so
|
||||
// up-to-date skipping still works.
|
||||
g.pruneRenditions(photos)
|
||||
|
||||
for i := range photos {
|
||||
if err := g.processImage(&photos[i]); err != nil {
|
||||
log.Printf("processImage %s: %v", photos[i].Slug, err)
|
||||
@@ -231,6 +249,35 @@ func (g *Generator) loadPhotos() ([]Photo, error) {
|
||||
return photos, nil
|
||||
}
|
||||
|
||||
// pruneRenditions deletes files in the image output directories that no longer
|
||||
// have a backing photo in content/. Called on every build, it is what makes a
|
||||
// delete (and the stale sidecar left by any removed source) actually disappear
|
||||
// from public/ rather than linger as an orphan reachable by direct URL.
|
||||
func (g *Generator) pruneRenditions(photos []Photo) {
|
||||
valid := make(map[string]bool, len(photos))
|
||||
for _, p := range photos {
|
||||
valid[p.Slug] = true
|
||||
}
|
||||
for _, d := range []string{"images", "thumbs", "medium", "og"} {
|
||||
full := filepath.Join(g.outputDir, d)
|
||||
entries, err := os.ReadDir(full)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
slug := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
|
||||
if !valid[slug] {
|
||||
if err := os.Remove(filepath.Join(full, e.Name())); err != nil {
|
||||
log.Printf("prune %s/%s: %v", d, e.Name(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processImage copies the original to public/images/ and writes a square
|
||||
// thumbnail to public/thumbs/ plus an aspect-preserved medium image to
|
||||
// public/medium/. Outputs are skipped when already newer than the source.
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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 := r.ParseForm(); 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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
// 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 := r.ParseForm(); 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)
|
||||
}
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/png"
|
||||
"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)
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -194,11 +194,22 @@ in {
|
||||
};
|
||||
|
||||
# Auth is handled by the app (see services.photogallery.htpasswdFile),
|
||||
# so nginx just proxies the upload endpoint.
|
||||
# so nginx just proxies the dynamic endpoints: the upload form/API,
|
||||
# the manage (edit/delete) page and its JSON API. Everything else is
|
||||
# the static site served from root above.
|
||||
locations."/upload" = {
|
||||
proxyPass = "http://127.0.0.1:${toString cfg.port}";
|
||||
extraConfig = "client_max_body_size 20M;";
|
||||
};
|
||||
|
||||
locations."/manage" = {
|
||||
proxyPass = "http://127.0.0.1:${toString cfg.port}";
|
||||
};
|
||||
|
||||
locations."/api/" = {
|
||||
proxyPass = "http://127.0.0.1:${toString cfg.port}";
|
||||
extraConfig = "client_max_body_size 20M;";
|
||||
};
|
||||
};
|
||||
};
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -22,6 +23,11 @@ type Server struct {
|
||||
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 {
|
||||
@@ -47,10 +53,57 @@ func (s *Server) guard(h http.HandlerFunc) http.HandlerFunc {
|
||||
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/trash", s.guard(s.handleListTrash))
|
||||
mux.HandleFunc("POST /api/restore", s.guard(s.handleRestore))
|
||||
mux.HandleFunc("POST /api/trash/empty", s.guard(s.handleEmptyTrash))
|
||||
|
||||
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintln(w, "ok")
|
||||
})
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Manage — Photo Gallery</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Crimson+Pro:ital,wght@0,400;0,600;0,700;1,400&family=Manjari:wght@100;400;700&display=swap">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="icon" href="/static/favicon.ico" sizes="any">
|
||||
<link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32">
|
||||
<link rel="apple-touch-icon" href="/static/web-192.png">
|
||||
<style>
|
||||
.manage-page { max-width: 760px; margin: 0 auto; padding: 28px 20px 80px; }
|
||||
.manage-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
|
||||
.manage-head h2 { font-family: var(--serif); font-size: 22px; font-weight: 600; color: var(--fg); }
|
||||
.manage-head a { font-family: monospace; font-size: 13px; color: var(--fg4); text-decoration: none; }
|
||||
.manage-head a:hover { color: var(--fg2); }
|
||||
#count { font-family: monospace; font-size: 12px; color: var(--fg4); margin-bottom: 20px; }
|
||||
|
||||
/* Bulk action bar, sticky at the top once posts are selected. */
|
||||
.bulkbar {
|
||||
position: sticky; top: 0; z-index: 5;
|
||||
display: none; flex-wrap: wrap; align-items: center; gap: 10px;
|
||||
background: var(--bg1); border: 1px solid var(--bg2);
|
||||
padding: 12px 14px; margin-bottom: 20px;
|
||||
}
|
||||
.bulkbar.show { display: flex; }
|
||||
.bulkbar .sel { font-family: monospace; font-size: 12px; color: var(--fg2); margin-right: auto; }
|
||||
.bulkbar input[type="text"] {
|
||||
background: var(--bg0); border: 1px solid var(--bg2); color: var(--fg);
|
||||
font-family: var(--serif); font-size: 14px; padding: 7px 9px; width: 150px; outline: none;
|
||||
}
|
||||
.bulkbar input:focus { border-color: var(--fg4); }
|
||||
|
||||
.btn {
|
||||
background: var(--bg1); border: 1px solid var(--bg2); color: var(--fg2);
|
||||
font-family: var(--serif); font-size: 14px; padding: 7px 12px; cursor: pointer;
|
||||
transition: background .2s, color .2s;
|
||||
}
|
||||
.btn:hover:not(:disabled) { background: var(--bg2); color: var(--fg); }
|
||||
.btn:disabled { opacity: .35; cursor: not-allowed; }
|
||||
.btn.danger { color: #fb4934; border-color: #5a2828; }
|
||||
.btn.danger:hover:not(:disabled) { background: #2e1d1d; color: #fb4934; }
|
||||
|
||||
.row {
|
||||
display: grid; grid-template-columns: 22px 72px 1fr auto; gap: 14px;
|
||||
align-items: start; padding: 16px 0; border-top: 1px solid var(--bg1);
|
||||
}
|
||||
.row input[type="checkbox"] { margin-top: 28px; width: 16px; height: 16px; accent-color: var(--aqua, #8ec07c); }
|
||||
.row .thumb { width: 72px; height: 72px; object-fit: cover; background: var(--bg1); display: block; }
|
||||
.fields { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
|
||||
.fields label { font-size: 11px; color: var(--fg4); font-family: monospace; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.fields input {
|
||||
width: 100%; background: var(--bg0); border: 1px solid var(--bg2); color: var(--fg);
|
||||
font-size: 15px; padding: 8px 10px; outline: none; box-sizing: border-box;
|
||||
}
|
||||
.fields input[type="text"] { font-family: var(--serif); }
|
||||
.fields input[type="date"] { font-family: monospace; font-size: 13px; color-scheme: dark; width: auto; }
|
||||
.fields input:focus { border-color: var(--fg4); }
|
||||
.rowactions { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.row { grid-template-columns: 22px 56px 1fr; }
|
||||
.row .rowactions { grid-column: 2 / 4; flex-direction: row; }
|
||||
.row input[type="checkbox"] { margin-top: 20px; }
|
||||
}
|
||||
|
||||
.trashpanel { display: none; border: 1px solid var(--bg2); background: var(--bg0); padding: 14px; margin-bottom: 24px; }
|
||||
.trashpanel.show { display: block; }
|
||||
.trashhead { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
|
||||
.trashhead strong { font-family: var(--serif); color: var(--fg); }
|
||||
.trashhead #trash-count { font-family: monospace; font-size: 12px; color: var(--fg4); margin-right: auto; }
|
||||
.trashrow { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-top: 1px solid var(--bg1); }
|
||||
.trashrow .meta { margin-right: auto; min-width: 0; }
|
||||
.trashrow .slug { font-family: var(--serif); color: var(--fg2); }
|
||||
.trashrow .when { font-family: monospace; font-size: 11px; color: var(--fg4); }
|
||||
#trash-empty-msg { color: var(--fg4); font-family: var(--serif); padding: 8px 0; }
|
||||
|
||||
#toast {
|
||||
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
|
||||
display: none; font-family: monospace; font-size: 13px; padding: 10px 16px; max-width: 90%;
|
||||
}
|
||||
#toast.ok { display: block; background: #1d2e1d; color: #b8bb26; border: 1px solid #344d28; }
|
||||
#toast.err { display: block; background: #2e1d1d; color: #fb4934; border: 1px solid #5a2828; }
|
||||
#empty { color: var(--fg4); font-family: var(--serif); padding: 40px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="manage-page">
|
||||
<div class="manage-head">
|
||||
<h2>Manage posts</h2>
|
||||
<span><a href="/upload">+ Upload</a> <a href="#" id="trash-toggle">Trash</a> <a href="/">← Gallery</a></span>
|
||||
</div>
|
||||
<div id="count">Loading…</div>
|
||||
|
||||
<div class="trashpanel" id="trashpanel">
|
||||
<div class="trashhead">
|
||||
<strong>Trash</strong>
|
||||
<span id="trash-count"></span>
|
||||
<button class="btn danger" id="btn-empty-all">Empty trash</button>
|
||||
</div>
|
||||
<div id="trashlist"></div>
|
||||
<div id="trash-empty-msg" style="display:none">Trash is empty.</div>
|
||||
</div>
|
||||
|
||||
<div class="bulkbar" id="bulkbar">
|
||||
<span class="sel" id="sel">0 selected</span>
|
||||
<input type="text" id="bulk-add" placeholder="add tags" autocomplete="off">
|
||||
<button class="btn" id="btn-add">Add</button>
|
||||
<input type="text" id="bulk-remove" placeholder="remove tags" autocomplete="off">
|
||||
<button class="btn" id="btn-remove">Remove</button>
|
||||
<button class="btn danger" id="btn-del-sel">Delete selected</button>
|
||||
</div>
|
||||
|
||||
<div id="list"></div>
|
||||
<div id="empty" style="display:none">No posts yet.</div>
|
||||
</div>
|
||||
|
||||
<div id="toast"></div>
|
||||
|
||||
<script>
|
||||
const list = document.getElementById('list');
|
||||
const countEl = document.getElementById('count');
|
||||
const emptyEl = document.getElementById('empty');
|
||||
const bulkbar = document.getElementById('bulkbar');
|
||||
const selEl = document.getElementById('sel');
|
||||
const toast = document.getElementById('toast');
|
||||
let toastTimer;
|
||||
|
||||
function flash(msg, ok) {
|
||||
toast.textContent = msg;
|
||||
toast.className = ok ? 'ok' : 'err';
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { toast.className = ''; }, 3500);
|
||||
}
|
||||
|
||||
function selectedSlugs() {
|
||||
return [...list.querySelectorAll('input.pick:checked')].map(c => c.dataset.slug);
|
||||
}
|
||||
|
||||
function refreshBulk() {
|
||||
const n = selectedSlugs().length;
|
||||
selEl.textContent = n + ' selected';
|
||||
bulkbar.classList.toggle('show', n > 0);
|
||||
}
|
||||
|
||||
function row(p) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'row';
|
||||
el.dataset.slug = p.slug;
|
||||
el.innerHTML = `
|
||||
<input type="checkbox" class="pick" data-slug="${p.slug}" aria-label="Select">
|
||||
<a href="${p.url}" target="_blank" rel="noopener"><img class="thumb" src="${p.thumb}" alt=""></a>
|
||||
<div class="fields">
|
||||
<label>Caption</label>
|
||||
<input type="text" class="f-caption" value="">
|
||||
<label>Date</label>
|
||||
<input type="date" class="f-date" value="${p.date}">
|
||||
<label>Tags (comma-separated)</label>
|
||||
<input type="text" class="f-tags" value="">
|
||||
</div>
|
||||
<div class="rowactions">
|
||||
<button class="btn f-save">Save</button>
|
||||
<button class="btn danger f-del">Delete</button>
|
||||
</div>`;
|
||||
// Set values via property to avoid HTML-escaping issues in attributes.
|
||||
el.querySelector('.f-caption').value = p.caption;
|
||||
el.querySelector('.f-tags').value = (p.tags || []).join(', ');
|
||||
|
||||
el.querySelector('.pick').addEventListener('change', refreshBulk);
|
||||
el.querySelector('.f-save').addEventListener('click', () => save(el, p.slug));
|
||||
el.querySelector('.f-del').addEventListener('click', () => delOne(el, p.slug));
|
||||
return el;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch('/api/photos', { headers: { 'Accept': 'application/json' } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const photos = await res.json();
|
||||
list.innerHTML = '';
|
||||
photos.forEach(p => list.appendChild(row(p)));
|
||||
countEl.textContent = photos.length + (photos.length === 1 ? ' post' : ' posts');
|
||||
emptyEl.style.display = photos.length ? 'none' : 'block';
|
||||
refreshBulk();
|
||||
} catch (err) {
|
||||
countEl.textContent = '';
|
||||
flash('Could not load posts: ' + err.message, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function post(url, body) {
|
||||
const res = await fetch(url, { method: 'POST', body });
|
||||
if (!res.ok) throw new Error((await res.text()).trim() || ('HTTP ' + res.status));
|
||||
return res;
|
||||
}
|
||||
|
||||
async function save(el, slug) {
|
||||
const form = new FormData();
|
||||
form.append('slug', slug);
|
||||
form.append('caption', el.querySelector('.f-caption').value);
|
||||
form.append('date', el.querySelector('.f-date').value);
|
||||
form.append('tags', el.querySelector('.f-tags').value);
|
||||
try {
|
||||
await post('/api/edit', form);
|
||||
flash('Saved ' + slug, true);
|
||||
} catch (err) { flash('Save failed: ' + err.message, false); }
|
||||
}
|
||||
|
||||
async function delOne(el, slug) {
|
||||
if (!confirm('Move "' + slug + '" to trash?')) return;
|
||||
const form = new FormData();
|
||||
form.append('slug', slug);
|
||||
try {
|
||||
await post('/api/delete', form);
|
||||
el.remove();
|
||||
flash('Moved ' + slug + ' to trash', true);
|
||||
refreshBulk();
|
||||
updateCount();
|
||||
} catch (err) { flash('Delete failed: ' + err.message, false); }
|
||||
}
|
||||
|
||||
function updateCount() {
|
||||
const n = list.querySelectorAll('.row').length;
|
||||
countEl.textContent = n + (n === 1 ? ' post' : ' posts');
|
||||
emptyEl.style.display = n ? 'none' : 'block';
|
||||
}
|
||||
|
||||
document.getElementById('btn-del-sel').addEventListener('click', async () => {
|
||||
const slugs = selectedSlugs();
|
||||
if (!slugs.length) return;
|
||||
if (!confirm('Move ' + slugs.length + ' post(s) to trash?')) return;
|
||||
const form = new FormData();
|
||||
form.append('slugs', slugs.join(','));
|
||||
try {
|
||||
await post('/api/delete', form);
|
||||
flash('Moved ' + slugs.length + ' to trash', true);
|
||||
await load();
|
||||
} catch (err) { flash('Delete failed: ' + err.message, false); }
|
||||
});
|
||||
|
||||
async function bulkTags(field, label) {
|
||||
const slugs = selectedSlugs();
|
||||
const val = document.getElementById(field).value.trim();
|
||||
if (!slugs.length || !val) return;
|
||||
const form = new FormData();
|
||||
form.append('slugs', slugs.join(','));
|
||||
form.append(label, val);
|
||||
try {
|
||||
await post('/api/tags', form);
|
||||
document.getElementById(field).value = '';
|
||||
flash('Updated tags on ' + slugs.length + ' post(s)', true);
|
||||
await load();
|
||||
} catch (err) { flash('Tag update failed: ' + err.message, false); }
|
||||
}
|
||||
document.getElementById('btn-add').addEventListener('click', () => bulkTags('bulk-add', 'add'));
|
||||
document.getElementById('btn-remove').addEventListener('click', () => bulkTags('bulk-remove', 'remove'));
|
||||
|
||||
// ── Trash ────────────────────────────────────────────────────────────────
|
||||
const trashpanel = document.getElementById('trashpanel');
|
||||
const trashlist = document.getElementById('trashlist');
|
||||
const trashCount = document.getElementById('trash-count');
|
||||
const trashEmpty = document.getElementById('trash-empty-msg');
|
||||
|
||||
function trashRow(it) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'trashrow';
|
||||
const parts = [];
|
||||
if (it.image) parts.push('image');
|
||||
if (it.hasMeta) parts.push('caption/tags');
|
||||
el.innerHTML = `
|
||||
<div class="meta">
|
||||
<div class="slug"></div>
|
||||
<div class="when">deleted ${it.deletedAt} · ${parts.join(' + ') || 'empty'}</div>
|
||||
</div>
|
||||
<button class="btn t-restore">Restore</button>
|
||||
<button class="btn danger t-del">Delete forever</button>`;
|
||||
el.querySelector('.slug').textContent = it.slug;
|
||||
el.querySelector('.t-restore').addEventListener('click', () => restore(it));
|
||||
el.querySelector('.t-del').addEventListener('click', () => deleteForever(it));
|
||||
return el;
|
||||
}
|
||||
|
||||
async function loadTrash() {
|
||||
try {
|
||||
const res = await fetch('/api/trash', { headers: { 'Accept': 'application/json' } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const items = await res.json();
|
||||
trashlist.innerHTML = '';
|
||||
items.forEach(it => trashlist.appendChild(trashRow(it)));
|
||||
trashCount.textContent = items.length + (items.length === 1 ? ' item' : ' items');
|
||||
trashEmpty.style.display = items.length ? 'none' : 'block';
|
||||
} catch (err) {
|
||||
flash('Could not load trash: ' + err.message, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function restore(it) {
|
||||
const form = new FormData();
|
||||
form.append('id', it.id);
|
||||
try {
|
||||
await post('/api/restore', form);
|
||||
flash('Restored ' + it.slug, true);
|
||||
await Promise.all([load(), loadTrash()]);
|
||||
} catch (err) { flash('Restore failed: ' + err.message, false); }
|
||||
}
|
||||
|
||||
async function deleteForever(it) {
|
||||
if (!confirm('Permanently delete "' + it.slug + '"? This cannot be undone.')) return;
|
||||
const form = new FormData();
|
||||
form.append('id', it.id);
|
||||
try {
|
||||
await post('/api/trash/empty', form);
|
||||
flash('Deleted ' + it.slug + ' forever', true);
|
||||
await loadTrash();
|
||||
} catch (err) { flash('Delete failed: ' + err.message, false); }
|
||||
}
|
||||
|
||||
document.getElementById('trash-toggle').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const showing = trashpanel.classList.toggle('show');
|
||||
if (showing) loadTrash();
|
||||
});
|
||||
|
||||
document.getElementById('btn-empty-all').addEventListener('click', async () => {
|
||||
if (!confirm('Permanently delete everything in the trash? This cannot be undone.')) return;
|
||||
const form = new FormData();
|
||||
form.append('all', '1');
|
||||
try {
|
||||
await post('/api/trash/empty', form);
|
||||
flash('Trash emptied', true);
|
||||
await loadTrash();
|
||||
} catch (err) { flash('Empty failed: ' + err.message, false); }
|
||||
});
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,206 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// trashStamp is the timestamp moveToTrash appends: 20060102-150405.
|
||||
var trashStamp = regexp.MustCompile(`^\d{8}-\d{6}$`)
|
||||
|
||||
// trashItem is one recoverable post in content/.trash/, grouping the image and
|
||||
// its sidecar that were trashed together (same slug, same timestamp).
|
||||
type trashItem struct {
|
||||
ID string `json:"id"` // "<slug>.<stamp>", the restore/empty handle
|
||||
Slug string `json:"slug"` // original slug it would restore to
|
||||
DeletedAt string `json:"deletedAt"`
|
||||
Image string `json:"image"` // trash filename of the image, "" if only a sidecar
|
||||
HasMeta bool `json:"hasMeta"` // whether a sidecar is present
|
||||
}
|
||||
|
||||
func (s *Server) trashDir() string { return filepath.Join(s.contentDir, ".trash") }
|
||||
|
||||
// parseTrashName splits a trash filename "<slug>.<stamp><ext>" into its parts.
|
||||
// slug is a bare sanitised name (no dots) and stamp is a fixed timestamp, so
|
||||
// the extension and the stamp peel off unambiguously from the right.
|
||||
func parseTrashName(name string) (slug, stamp, ext string, ok bool) {
|
||||
ext = filepath.Ext(name)
|
||||
rest := strings.TrimSuffix(name, ext) // "<slug>.<stamp>"
|
||||
i := strings.LastIndex(rest, ".")
|
||||
if i < 0 {
|
||||
return "", "", "", false
|
||||
}
|
||||
slug, stamp = rest[:i], rest[i+1:]
|
||||
if slug == "" || slug != filepath.Base(slug) || strings.Contains(slug, ".") {
|
||||
return "", "", "", false
|
||||
}
|
||||
if !trashStamp.MatchString(stamp) {
|
||||
return "", "", "", false
|
||||
}
|
||||
return slug, stamp, ext, true
|
||||
}
|
||||
|
||||
func isImageExt(ext string) bool {
|
||||
for _, e := range contentExts {
|
||||
if ext == e {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleListTrash returns trashed items, newest first.
|
||||
func (s *Server) handleListTrash(w http.ResponseWriter, r *http.Request) {
|
||||
entries, err := os.ReadDir(s.trashDir())
|
||||
if err != nil {
|
||||
// No trash dir yet → empty list, not an error.
|
||||
writeJSON(w, []trashItem{})
|
||||
return
|
||||
}
|
||||
|
||||
byID := make(map[string]*trashItem)
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
slug, stamp, ext, ok := parseTrashName(e.Name())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id := slug + "." + stamp
|
||||
it := byID[id]
|
||||
if it == nil {
|
||||
when := stamp
|
||||
if t, err := time.ParseInLocation("20060102-150405", stamp, time.Local); err == nil {
|
||||
when = t.Format("2006-01-02 15:04")
|
||||
}
|
||||
it = &trashItem{ID: id, Slug: slug, DeletedAt: when}
|
||||
byID[id] = it
|
||||
}
|
||||
if ext == ".toml" {
|
||||
it.HasMeta = true
|
||||
} else if isImageExt(ext) {
|
||||
it.Image = e.Name()
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]trashItem, 0, len(byID))
|
||||
for _, it := range byID {
|
||||
items = append(items, *it)
|
||||
}
|
||||
// Newest first: the stamp sorts lexically in time order, and it is the tail
|
||||
// of the ID.
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].ID > items[j].ID })
|
||||
writeJSON(w, items)
|
||||
}
|
||||
|
||||
// splitTrashID validates a "<slug>.<stamp>" handle and returns its parts.
|
||||
func splitTrashID(id string) (slug, stamp string, ok bool) {
|
||||
i := strings.LastIndex(id, ".")
|
||||
if i < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
slug, stamp = id[:i], id[i+1:]
|
||||
if slug == "" || slug != filepath.Base(slug) || strings.Contains(slug, ".") {
|
||||
return "", "", false
|
||||
}
|
||||
if !trashStamp.MatchString(stamp) {
|
||||
return "", "", false
|
||||
}
|
||||
return slug, stamp, true
|
||||
}
|
||||
|
||||
// trashFiles returns the files in .trash belonging to one id (image + sidecar).
|
||||
func (s *Server) trashFiles(slug, stamp string) []string {
|
||||
var out []string
|
||||
prefix := slug + "." + stamp
|
||||
for _, ext := range append(append([]string{}, contentExts...), ".toml") {
|
||||
p := filepath.Join(s.trashDir(), prefix+ext)
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleRestore moves a trashed post back into content/, stripping the
|
||||
// timestamp. It refuses to overwrite a live post with the same slug.
|
||||
func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
|
||||
if s.rejectCrossSite(w, r) {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
slug, stamp, ok := splitTrashID(r.FormValue("id"))
|
||||
if !ok {
|
||||
http.Error(w, "bad id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, live := s.resolveSlug(slug); live {
|
||||
http.Error(w, "a post with that slug already exists", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
files := s.trashFiles(slug, stamp)
|
||||
if len(files) == 0 {
|
||||
http.Error(w, "not in trash", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
for _, src := range files {
|
||||
name := filepath.Base(src)
|
||||
trashPrefix := slug + "." + stamp // "<slug>.<stamp>"
|
||||
// "<slug>.<stamp>.png" → "<slug>.png"; "<slug>.<stamp>.toml" → "<slug>.toml"
|
||||
dstName := slug + strings.TrimPrefix(name, trashPrefix)
|
||||
if err := os.Rename(src, filepath.Join(s.contentDir, dstName)); err != nil {
|
||||
log.Printf("restore %s: %v", name, err)
|
||||
http.Error(w, "could not restore", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
s.rebuild()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// handleEmptyTrash permanently deletes trashed items — specific ids (repeated
|
||||
// `id` fields), or everything with `all=1`. This is the only hard delete.
|
||||
func (s *Server) handleEmptyTrash(w http.ResponseWriter, r *http.Request) {
|
||||
if s.rejectCrossSite(w, r) {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if r.FormValue("all") == "1" {
|
||||
if err := os.RemoveAll(s.trashDir()); err != nil {
|
||||
log.Printf("empty trash: %v", err)
|
||||
http.Error(w, "could not empty trash", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"emptied": "all"})
|
||||
return
|
||||
}
|
||||
|
||||
removed := 0
|
||||
for _, id := range r.Form["id"] {
|
||||
slug, stamp, ok := splitTrashID(id)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, p := range s.trashFiles(slug, stamp) {
|
||||
if err := os.Remove(p); err == nil {
|
||||
removed++
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, map[string]int{"removed": removed})
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func listTrash(t *testing.T, s *Server) []trashItem {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleListTrash(rec, httptest.NewRequest(http.MethodGet, "/api/trash", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list trash: code=%d", rec.Code)
|
||||
}
|
||||
var items []trashItem
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
|
||||
t.Fatalf("decode trash: %v", err)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func TestTrashListAndRestore(t *testing.T) {
|
||||
s, content, _ := newTestServer(t)
|
||||
|
||||
// Delete foo, then it should show up in the trash.
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleDelete(rec, form(url.Values{"slug": {"foo"}}))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete: %d", rec.Code)
|
||||
}
|
||||
|
||||
items := listTrash(t, s)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("trash has %d items, want 1", len(items))
|
||||
}
|
||||
it := items[0]
|
||||
if it.Slug != "foo" || !it.HasMeta || it.Image == "" {
|
||||
t.Errorf("trash item wrong: %+v", it)
|
||||
}
|
||||
|
||||
// Restore it.
|
||||
rec = httptest.NewRecorder()
|
||||
s.handleRestore(rec, form(url.Values{"id": {it.ID}}))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("restore: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
for _, p := range []string{"foo.png", "foo.toml"} {
|
||||
if _, err := os.Stat(filepath.Join(content, p)); err != nil {
|
||||
t.Errorf("restored %s missing: %v", p, err)
|
||||
}
|
||||
}
|
||||
if items := listTrash(t, s); len(items) != 0 {
|
||||
t.Errorf("trash not empty after restore: %+v", items)
|
||||
}
|
||||
// Caption survived the round trip.
|
||||
if readMeta(t, content, "foo").Caption != "Foo" {
|
||||
t.Error("restored sidecar lost its caption")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreRefusesLiveCollision(t *testing.T) {
|
||||
s, content, _ := newTestServer(t)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleDelete(rec, form(url.Values{"slug": {"foo"}}))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete: %d", rec.Code)
|
||||
}
|
||||
id := listTrash(t, s)[0].ID
|
||||
|
||||
// A new live post now occupies the slug.
|
||||
writePNG(t, filepath.Join(content, "foo.png"))
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
s.handleRestore(rec, form(url.Values{"id": {id}}))
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Errorf("restore over live post: code=%d, want 409", rec.Code)
|
||||
}
|
||||
// The trashed copy is left intact for a manual decision.
|
||||
if len(listTrash(t, s)) != 1 {
|
||||
t.Error("trashed item should remain after refused restore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyTrash(t *testing.T) {
|
||||
s, _, _ := newTestServer(t)
|
||||
|
||||
// Trash both posts.
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleDelete(rec, form(url.Values{"slugs": {"foo,bar"}}))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete: %d", rec.Code)
|
||||
}
|
||||
items := listTrash(t, s)
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("trash has %d, want 2", len(items))
|
||||
}
|
||||
|
||||
// Empty one by id.
|
||||
rec = httptest.NewRecorder()
|
||||
s.handleEmptyTrash(rec, form(url.Values{"id": {items[0].ID}}))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("empty one: %d", rec.Code)
|
||||
}
|
||||
if len(listTrash(t, s)) != 1 {
|
||||
t.Error("expected 1 item left after deleting one")
|
||||
}
|
||||
|
||||
// Empty all.
|
||||
rec = httptest.NewRecorder()
|
||||
s.handleEmptyTrash(rec, form(url.Values{"all": {"1"}}))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("empty all: %d", rec.Code)
|
||||
}
|
||||
if len(listTrash(t, s)) != 0 {
|
||||
t.Error("trash not empty after all")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitTrashIDRejectsJunk(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"foo.20260102-150405": true,
|
||||
"foo.bar.20260102-150405": false, // slug "foo.bar" has a dot → rejected
|
||||
"../x.20260102-150405": false, // traversal
|
||||
"foo.2026": false, // malformed stamp
|
||||
"foo": false, // no stamp
|
||||
"": false,
|
||||
}
|
||||
for id, want := range cases {
|
||||
if _, _, ok := splitTrashID(id); ok != want {
|
||||
t.Errorf("splitTrashID(%q) = %v, want %v", id, ok, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user