Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43075f00d4 | ||
|
|
3f719359de | ||
|
|
f1abd45f24 |
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user