Compare commits

..
3 Commits
Author SHA1 Message Date
foxandClaude Fable 5.1 ab0bd5b65a feat(manage): delete photos from /manage, prune leftover pages
/manage/delete/{slug} asks for confirmation, then removes the image,
its sidecar and everything generated from them. Builds now drop day
pages of days without photos and pager pages past the last one, so a
delete or a date change leaves nothing behind.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 05:53:12 +00:00
foxandClaude Fable 5.1 52ff47217a feat(generator): add -default-view to choose timeline or grid as site root
The chosen view is published at /, the other under /timeline/ or
/grid/. Page paths, pager links and the cross-links between the views
are derived from the setting. The directory named after the default
view is removed on build, since it can only be a leftover from before
a switch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 05:53:12 +00:00
foxandClaude Fable 5.1 4b85dba8e1 feat(manage): add /manage pages to edit caption and date
/manage lists every photo; /manage/edit/{slug} rewrites the photo's
.toml sidecar, which triggers a rebuild. The pages sit behind the same
htpasswd as /upload and are not linked from the public site.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 05:53:12 +00:00
7 changed files with 22 additions and 80 deletions
+2 -2
View File
@@ -19,13 +19,13 @@ 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`, and with `-manage`: `GET /manage` (list of photos), `GET`/`POST /manage/edit/{slug}` (edit caption and date), `GET`/`POST /manage/delete/{slug}` (confirm, then delete).
Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart), `GET /manage` (list of photos), `GET`/`POST /manage/edit/{slug}` (edit caption and date), `GET`/`POST /manage/delete/{slug}` (confirm, then delete), `GET /health`.
In production nginx sits in front: serves `public/` directly, proxies `/upload` and `/manage` to localhost, htpasswd on both. Note `/health` is **not** proxied — nginx only forwards `location /upload` and `location /manage`, so anything outside the box can only reach those two.
### Managing photos
`manage.go` holds the `/manage` handlers, kept apart from the upload code in `server.go`. They are only registered when the binary runs with `-manage` (NixOS `services.photogallery.manage.enable`, which also adds the nginx location); without it the paths 404 and there is no edit or delete code path at all. The pages are rendered on request from the sidecars (not generated into `public/`), and nothing on the public site links to them. Constraints worth knowing before touching it:
`manage.go` holds the `/manage` handlers, kept apart from the upload code in `server.go`. The pages are rendered on request from the sidecars (not generated into `public/`), and nothing on the public site links to them. Constraints worth knowing before touching it:
- An edit only rewrites the `.toml` sidecar. Any key other than `caption` and `date` in a hand-written sidecar is dropped, same as on upload.
- Slugs from the URL are matched against the photos actually in `content/`, never joined into a path.
+1 -7
View File
@@ -69,9 +69,6 @@ services.photogallery = {
serifFamily = "EB Garamond";
mlFamily = "Manjari";
# Optional: edit and delete published photos at /manage (off by default)
manage.enable = true;
# Optional: built-in nginx vhost
nginx = {
enable = true;
@@ -106,7 +103,6 @@ nix-shell -p apacheHttpd --run \
| `mlFamily` | `Manjari` | CSS font-family for the alt-script bio |
| `pageSize` | `60` | Photos per page on the timeline and grid views |
| `defaultView` | `timeline` | View at the site root, `timeline` or `grid`; the other is served under `/timeline/` or `/grid/` |
| `manage.enable` | `false` | Serve the `/manage` pages (edit, delete) and, with `nginx.enable`, protect them with the same htpasswd |
| `nginx.enable` | `false` | Configure an nginx vhost |
| `nginx.domain` | — | Domain for the vhost |
| `nginx.htpasswdFile` | — | htpasswd file for `/upload` and `/manage` |
@@ -147,9 +143,7 @@ Or open `https://photos.yourdomain.tld/upload` in a browser.
## Editing and deleting photos
The pages are off by default: start the binary with `-manage` (NixOS:
`services.photogallery.manage.enable = true`). Then open
`https://photos.yourdomain.tld/manage` in a browser. It lists every
Open `https://photos.yourdomain.tld/manage` in a browser. It lists every
photo; *Edit* opens a form for the caption and date, and saving rewrites the
photo's `.toml` sidecar, which triggers a rebuild.
-7
View File
@@ -37,13 +37,6 @@ func buildSite(t *testing.T, output, defaultView string) {
}
}
func writeFile(t *testing.T, path string, data []byte) {
t.Helper()
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
}
func readPage(t *testing.T, output, rel string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(output, filepath.FromSlash(rel), "index.html"))
+1 -2
View File
@@ -20,7 +20,6 @@ func main() {
serifFamily := flag.String("serif-family", "Crimson Pro", "CSS font-family for the primary serif text")
mlFamily := flag.String("ml-family", "Manjari", "CSS font-family for the alt-script (Malayalam) bio line")
pageSize := flag.Int("page-size", 60, "photos per page on the timeline and grid views (timeline packs whole days, so pages vary slightly around this)")
manage := flag.Bool("manage", false, "serve the /manage pages for editing and deleting published photos (off by default; put them behind auth in nginx)")
defaultView := flag.String("default-view", viewTimeline, "view published at the site root: \"timeline\" or \"grid\"; the other one is published under /<name>/")
flag.Parse()
@@ -47,6 +46,6 @@ func main() {
defer w.Close()
go w.Watch()
s := NewServer(*port, *contentDir, *outputDir, *serve, *manage, g)
s := NewServer(*port, *contentDir, *outputDir, *serve, g)
log.Fatal(s.Listen())
}
+8 -38
View File
@@ -47,7 +47,14 @@ func newTestServer(t *testing.T) *Server {
g := NewGenerator(content, output, "https://photos.example.com",
"Ada", "", "", "", "", "Crimson Pro", "Manjari", 60, viewTimeline)
return NewServer("0", content, output, false, true, g)
return NewServer("0", content, output, false, g)
}
func writeFile(t *testing.T, path string, data []byte) {
t.Helper()
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
}
func readMeta(t *testing.T, s *Server, slug string) PhotoMeta {
@@ -316,40 +323,3 @@ func TestDeleteRejectsBadInput(t *testing.T) {
})
}
}
func TestManageRoutesNeedTheFlag(t *testing.T) {
for _, manage := range []bool{false, true} {
s := newTestServer(t)
s.manage = manage
mux := s.routes()
want := http.StatusNotFound
if manage {
want = http.StatusOK
}
for _, path := range []string{"/manage", "/manage/edit/sunrise", "/manage/delete/sunrise"} {
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
if w.Code != want {
t.Errorf("manage=%v: GET %s = %d, want %d", manage, path, w.Code, want)
}
}
// The delete POST only does something with the flag: without it the
// photo must survive, with it (no Origin header, as from curl) it goes.
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("POST", "/manage/delete/sunrise", nil))
_, err := os.Stat(filepath.Join(s.contentDir, "sunrise.jpg"))
present := err == nil
if present == manage {
t.Errorf("manage=%v: POST delete = %d, photo present afterwards = %v", manage, w.Code, present)
}
// /upload and /health are there regardless.
for _, path := range []string{"/upload", "/health"} {
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
if w.Code != http.StatusOK {
t.Errorf("manage=%v: GET %s = %d, want 200", manage, path, w.Code)
}
}
}
}
+3 -5
View File
@@ -88,8 +88,6 @@ in {
description = "View published at the site root. The other one is published under /timeline/ or /grid/.";
};
manage.enable = lib.mkEnableOption "the /manage pages for editing and deleting published photos (served behind the same htpasswd as /upload)";
nginx = {
enable = lib.mkEnableOption "nginx virtual host for photogallery";
@@ -142,7 +140,7 @@ in {
User = "photogallery";
Group = "photogallery";
ExecStart = lib.escapeShellArgs ([
ExecStart = lib.escapeShellArgs [
"${pkg}/bin/photogallery"
"--content" cfg.contentDir
"--output" cfg.outputDir
@@ -157,7 +155,7 @@ in {
"--ml-family" cfg.mlFamily
"--page-size" (toString cfg.pageSize)
"--default-view" cfg.defaultView
] ++ lib.optional cfg.manage.enable "--manage");
];
Restart = "on-failure";
RestartSec = "5s";
@@ -194,7 +192,7 @@ in {
extraConfig = "client_max_body_size 20M;";
};
locations."/manage" = lib.mkIf cfg.manage.enable {
locations."/manage" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}";
basicAuth = "gallery";
basicAuthFile = cfg.nginx.htpasswdFile;
+7 -19
View File
@@ -18,43 +18,31 @@ type Server struct {
contentDir string
outputDir string
serve bool
manage bool
generator *Generator
}
func NewServer(port, contentDir, outputDir string, serve, manage bool, g *Generator) *Server {
func NewServer(port, contentDir, outputDir string, serve bool, g *Generator) *Server {
return &Server{
port: port,
contentDir: contentDir,
outputDir: outputDir,
serve: serve,
manage: manage,
generator: g,
}
}
// routes builds the handler. The /manage pages are only registered with
// -manage, so a service without the flag has no edit or delete code path at
// all and answers 404 there.
func (s *Server) routes() *http.ServeMux {
func (s *Server) Listen() error {
mux := http.NewServeMux()
mux.HandleFunc("GET /upload", s.handleUploadPage)
mux.HandleFunc("POST /upload", s.handleUpload)
if s.manage {
mux.HandleFunc("GET /manage", s.handleManagePage)
mux.HandleFunc("GET /manage/edit/{slug}", s.handleEditPage)
mux.HandleFunc("POST /manage/edit/{slug}", s.handleEdit)
mux.HandleFunc("GET /manage/delete/{slug}", s.handleDeletePage)
mux.HandleFunc("POST /manage/delete/{slug}", s.handleDelete)
}
mux.HandleFunc("GET /manage", s.handleManagePage)
mux.HandleFunc("GET /manage/edit/{slug}", s.handleEditPage)
mux.HandleFunc("POST /manage/edit/{slug}", s.handleEdit)
mux.HandleFunc("GET /manage/delete/{slug}", s.handleDeletePage)
mux.HandleFunc("POST /manage/delete/{slug}", s.handleDelete)
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
return mux
}
func (s *Server) Listen() error {
mux := s.routes()
if s.serve {
// Serve the generated site on the same port — for local development only.