Compare commits

..
3 Commits
Author SHA1 Message Date
puttaalu 43075f00d4 Merge pull request 'Add /manage (edit, delete) and -default-view' (#1) from manage-endpoint into trunk-ng
Reviewed-on: #1
2026-10-03 11:15:40 +00:00
foxandClaude Fable 5.1 3f719359de feat(manage): add /manage pages to edit and delete photos, behind -manage
/manage lists every photo; /manage/edit/{slug} rewrites the photo's
.toml sidecar (caption, date), /manage/delete/{slug} asks for
confirmation and 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.

The pages are off by default: the routes are only registered with
-manage, so an install that does not opt in has no such code path and
answers 404. NixOS gets services.photogallery.manage.enable, which
also adds the nginx location only when on. Like /upload, the pages
carry no auth of their own and are not linked from the public site.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 08:17:17 +02:00
foxandClaude Fable 5.1 f1abd45f24 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 08:16:50 +02:00
7 changed files with 80 additions and 22 deletions
+2 -2
View File
@@ -19,13 +19,13 @@ The binary does three things at once:
- Serves a multipart upload API at `POST /upload` - Serves a multipart upload API at `POST /upload`
- In dev mode (`--serve`), also serves `public/` as static files - In dev mode (`--serve`), also serves `public/` as static files
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`. 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).
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. 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 ### Managing photos
`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: `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:
- 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. - 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. - Slugs from the URL are matched against the photos actually in `content/`, never joined into a path.
+7 -1
View File
@@ -69,6 +69,9 @@ services.photogallery = {
serifFamily = "EB Garamond"; serifFamily = "EB Garamond";
mlFamily = "Manjari"; mlFamily = "Manjari";
# Optional: edit and delete published photos at /manage (off by default)
manage.enable = true;
# Optional: built-in nginx vhost # Optional: built-in nginx vhost
nginx = { nginx = {
enable = true; enable = true;
@@ -103,6 +106,7 @@ nix-shell -p apacheHttpd --run \
| `mlFamily` | `Manjari` | CSS font-family for the alt-script bio | | `mlFamily` | `Manjari` | CSS font-family for the alt-script bio |
| `pageSize` | `60` | Photos per page on the timeline and grid views | | `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/` | | `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.enable` | `false` | Configure an nginx vhost |
| `nginx.domain` | — | Domain for the vhost | | `nginx.domain` | — | Domain for the vhost |
| `nginx.htpasswdFile` | — | htpasswd file for `/upload` and `/manage` | | `nginx.htpasswdFile` | — | htpasswd file for `/upload` and `/manage` |
@@ -143,7 +147,9 @@ Or open `https://photos.yourdomain.tld/upload` in a browser.
## Editing and deleting photos ## Editing and deleting photos
Open `https://photos.yourdomain.tld/manage` in a browser. It lists every 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
photo; *Edit* opens a form for the caption and date, and saving rewrites the photo; *Edit* opens a form for the caption and date, and saving rewrites the
photo's `.toml` sidecar, which triggers a rebuild. photo's `.toml` sidecar, which triggers a rebuild.
+7
View File
@@ -37,6 +37,13 @@ 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 { func readPage(t *testing.T, output, rel string) string {
t.Helper() t.Helper()
data, err := os.ReadFile(filepath.Join(output, filepath.FromSlash(rel), "index.html")) data, err := os.ReadFile(filepath.Join(output, filepath.FromSlash(rel), "index.html"))
+2 -1
View File
@@ -20,6 +20,7 @@ func main() {
serifFamily := flag.String("serif-family", "Crimson Pro", "CSS font-family for the primary serif text") 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") 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)") 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>/") defaultView := flag.String("default-view", viewTimeline, "view published at the site root: \"timeline\" or \"grid\"; the other one is published under /<name>/")
flag.Parse() flag.Parse()
@@ -46,6 +47,6 @@ func main() {
defer w.Close() defer w.Close()
go w.Watch() go w.Watch()
s := NewServer(*port, *contentDir, *outputDir, *serve, g) s := NewServer(*port, *contentDir, *outputDir, *serve, *manage, g)
log.Fatal(s.Listen()) log.Fatal(s.Listen())
} }
+38 -8
View File
@@ -47,14 +47,7 @@ func newTestServer(t *testing.T) *Server {
g := NewGenerator(content, output, "https://photos.example.com", g := NewGenerator(content, output, "https://photos.example.com",
"Ada", "", "", "", "", "Crimson Pro", "Manjari", 60, viewTimeline) "Ada", "", "", "", "", "Crimson Pro", "Manjari", 60, viewTimeline)
return NewServer("0", content, output, false, g) return NewServer("0", content, output, false, true, 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 { func readMeta(t *testing.T, s *Server, slug string) PhotoMeta {
@@ -323,3 +316,40 @@ 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)
}
}
}
}
+5 -3
View File
@@ -88,6 +88,8 @@ in {
description = "View published at the site root. The other one is published under /timeline/ or /grid/."; 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 = { nginx = {
enable = lib.mkEnableOption "nginx virtual host for photogallery"; enable = lib.mkEnableOption "nginx virtual host for photogallery";
@@ -140,7 +142,7 @@ in {
User = "photogallery"; User = "photogallery";
Group = "photogallery"; Group = "photogallery";
ExecStart = lib.escapeShellArgs [ ExecStart = lib.escapeShellArgs ([
"${pkg}/bin/photogallery" "${pkg}/bin/photogallery"
"--content" cfg.contentDir "--content" cfg.contentDir
"--output" cfg.outputDir "--output" cfg.outputDir
@@ -155,7 +157,7 @@ in {
"--ml-family" cfg.mlFamily "--ml-family" cfg.mlFamily
"--page-size" (toString cfg.pageSize) "--page-size" (toString cfg.pageSize)
"--default-view" cfg.defaultView "--default-view" cfg.defaultView
]; ] ++ lib.optional cfg.manage.enable "--manage");
Restart = "on-failure"; Restart = "on-failure";
RestartSec = "5s"; RestartSec = "5s";
@@ -192,7 +194,7 @@ in {
extraConfig = "client_max_body_size 20M;"; extraConfig = "client_max_body_size 20M;";
}; };
locations."/manage" = { locations."/manage" = lib.mkIf cfg.manage.enable {
proxyPass = "http://127.0.0.1:${toString cfg.port}"; proxyPass = "http://127.0.0.1:${toString cfg.port}";
basicAuth = "gallery"; basicAuth = "gallery";
basicAuthFile = cfg.nginx.htpasswdFile; basicAuthFile = cfg.nginx.htpasswdFile;
+14 -2
View File
@@ -18,31 +18,43 @@ type Server struct {
contentDir string contentDir string
outputDir string outputDir string
serve bool serve bool
manage bool
generator *Generator generator *Generator
} }
func NewServer(port, contentDir, outputDir string, serve bool, g *Generator) *Server { func NewServer(port, contentDir, outputDir string, serve, manage bool, g *Generator) *Server {
return &Server{ return &Server{
port: port, port: port,
contentDir: contentDir, contentDir: contentDir,
outputDir: outputDir, outputDir: outputDir,
serve: serve, serve: serve,
manage: manage,
generator: g, generator: g,
} }
} }
func (s *Server) Listen() error { // 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 {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /upload", s.handleUploadPage) mux.HandleFunc("GET /upload", s.handleUploadPage)
mux.HandleFunc("POST /upload", s.handleUpload) mux.HandleFunc("POST /upload", s.handleUpload)
if s.manage {
mux.HandleFunc("GET /manage", s.handleManagePage) mux.HandleFunc("GET /manage", s.handleManagePage)
mux.HandleFunc("GET /manage/edit/{slug}", s.handleEditPage) mux.HandleFunc("GET /manage/edit/{slug}", s.handleEditPage)
mux.HandleFunc("POST /manage/edit/{slug}", s.handleEdit) mux.HandleFunc("POST /manage/edit/{slug}", s.handleEdit)
mux.HandleFunc("GET /manage/delete/{slug}", s.handleDeletePage) mux.HandleFunc("GET /manage/delete/{slug}", s.handleDeletePage)
mux.HandleFunc("POST /manage/delete/{slug}", s.handleDelete) mux.HandleFunc("POST /manage/delete/{slug}", s.handleDelete)
}
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok") fmt.Fprintln(w, "ok")
}) })
return mux
}
func (s *Server) Listen() error {
mux := s.routes()
if s.serve { if s.serve {
// Serve the generated site on the same port — for local development only. // Serve the generated site on the same port — for local development only.