From 2143b95657b32ee60106704b9370bfea92193ade Mon Sep 17 00:00:00 2001 From: puttaalu Date: Mon, 19 Oct 2026 15:33:25 +0200 Subject: [PATCH] Fix Android manage thumbnails and multi-select picker Thumbnails were blank on the manage screen because the app fetched them from the static /thumbs/ path, which a front-door botwall on `location /` blocks for a non-browser client even though /api/ (proxied to the app) works. Add an authed GET /api/thumb/{slug} that serves the thumbnail JPEG from public/thumbs/, and have the app fetch from there. Also show the thumbnail in the edit dialog, not just the list rows. Construct the photo picker with an explicit maxItems so it reliably opens in multi-select mode; the no-arg PickMultipleVisualMedia default has been seen to present single-select on some devices. --- CLAUDE.md | 3 ++- .../ws/inflo/photogallery/MainActivity.kt | 5 +++- .../ws/inflo/photogallery/ui/ManageScreen.kt | 13 ++++++++++ .../inflo/photogallery/ui/UploadViewModel.kt | 5 +++- manage.go | 26 +++++++++++++++++++ server.go | 1 + 6 files changed, 50 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b3dba64..0512dd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,6 +69,7 @@ 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. +- `GET /api/thumb/{slug}` — serves that post's thumbnail JPEG from `public/thumbs/`, behind auth. Non-browser clients (the Android app) use this instead of the static `/thumbs/.jpg` path, because a front-door botwall on `location /` can block a non-browser client from the static site while `/api/` is proxied straight to the app. - `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. @@ -191,7 +192,7 @@ go run . --serve ## Android app -Kotlin + Jetpack Compose uploader: pick or share one or many photos, add caption/date/tags, post via a durable WorkManager queue. It also has a **Manage screen** (from the Post screen's "Manage" button) that lists published posts with thumbnails and edits caption/date/tags or deletes (to trash) via the same `/api/photos`, `/api/edit`, `/api/delete` endpoints the web `/manage` uses. The manage calls go through `UploadClient` (OkHttp + `org.json`, no extra deps); sending no `Origin`/`Sec-Fetch-Site` header, the app passes the server's same-origin CSRF check as a non-browser client. Credentials (HTTP Basic) are sealed with an Android Keystore AES-GCM key. See `android/README.md` for the detail. +Kotlin + Jetpack Compose uploader: pick or share one or many photos, add caption/date/tags, post via a durable WorkManager queue. It also has a **Manage screen** (from the Post screen's "Manage" button) that lists published posts with thumbnails and edits caption/date/tags or deletes (to trash) via the same `/api/photos`, `/api/edit`, `/api/delete` endpoints the web `/manage` uses. The manage calls go through `UploadClient` (OkHttp + `org.json`, no extra deps); sending no `Origin`/`Sec-Fetch-Site` header, the app passes the server's same-origin CSRF check as a non-browser client. Thumbnails are fetched from `/api/thumb/` (authed, app-served) rather than the static `/thumbs/` path, so a botwall on `location /` doesn't blank them. The multi-photo picker is constructed with an explicit `maxItems` so it reliably opens in multi-select mode. Credentials (HTTP Basic) are sealed with an Android Keystore AES-GCM key. See `android/README.md` for the detail. Its own flake, deliberately separate from the root one so NixOS consumers of `nixosModules.default` don't pull the Android SDK into their lock: diff --git a/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt b/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt index 8af6e36..9004c0b 100644 --- a/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt +++ b/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt @@ -40,8 +40,11 @@ class MainActivity : ComponentActivity() { var showSettings by rememberSaveable { mutableStateOf(!viewModel.isConfigured) } var showManage by rememberSaveable { mutableStateOf(false) } + // Explicit maxItems so the system photo picker reliably opens in + // multi-select mode (the no-arg default has been seen to present + // single-select on some devices). val picker = rememberLauncherForActivityResult( - ActivityResultContracts.PickMultipleVisualMedia(), + ActivityResultContracts.PickMultipleVisualMedia(maxItems = 50), ) { uris -> if (uris.isNotEmpty()) viewModel.selectImages(uris) } val notificationPermission = rememberLauncherForActivityResult( diff --git a/android/app/src/main/java/ws/inflo/photogallery/ui/ManageScreen.kt b/android/app/src/main/java/ws/inflo/photogallery/ui/ManageScreen.kt index c11d30d..77a8b8a 100644 --- a/android/app/src/main/java/ws/inflo/photogallery/ui/ManageScreen.kt +++ b/android/app/src/main/java/ws/inflo/photogallery/ui/ManageScreen.kt @@ -222,6 +222,19 @@ private fun EditDialog( title = { Text("Edit ${post.slug}") }, text = { Column { + val preview = post.preview + if (preview != null) { + Image( + bitmap = preview.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .background(GruvBg1), + ) + Spacer(Modifier.height(10.dp)) + } OutlinedTextField( value = caption, onValueChange = { caption = it }, diff --git a/android/app/src/main/java/ws/inflo/photogallery/ui/UploadViewModel.kt b/android/app/src/main/java/ws/inflo/photogallery/ui/UploadViewModel.kt index 233a42d..c1e70af 100644 --- a/android/app/src/main/java/ws/inflo/photogallery/ui/UploadViewModel.kt +++ b/android/app/src/main/java/ws/inflo/photogallery/ui/UploadViewModel.kt @@ -283,7 +283,10 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) { private fun loadThumbnails(photos: List) { for (p in photos) { viewModelScope.launch { - val bmp = withContext(Dispatchers.IO) { client().fetchThumbnail(p.thumb) } + // Fetch via the authed /api/thumb/ endpoint, not the static + // /thumbs/ path: a front-door botwall can block this non-browser + // client from the static site while /api/ is proxied to the app. + val bmp = withContext(Dispatchers.IO) { client().fetchThumbnail("/api/thumb/" + p.slug) } if (bmp != null) updateManage(p.slug) { it.copy(preview = bmp) } } } diff --git a/manage.go b/manage.go index 0687bd1..463151f 100644 --- a/manage.go +++ b/manage.go @@ -59,6 +59,32 @@ func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]bool{"authenticated": false}) } +// handleThumb serves a photo's thumbnail from the generated output, behind the +// same Basic auth as the rest of /api/. Non-browser clients (the Android app) +// use this instead of the static /thumbs/.jpg path, because a front-door +// botwall on `location /` can block a non-browser client from the static site +// while `/api/` is proxied straight to this server. +func (s *Server) handleThumb(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + if slug == "" || slug != filepath.Base(slug) || strings.HasPrefix(slug, ".") { + http.Error(w, "bad slug", http.StatusBadRequest) + return + } + f, err := os.Open(filepath.Join(s.outputDir, "thumbs", slug+".jpg")) + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() + fi, err := f.Stat() + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "image/jpeg") + http.ServeContent(w, r, slug+".jpg", fi.ModTime(), f) +} + // 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") diff --git a/server.go b/server.go index 555fcc5..a1d3110 100644 --- a/server.go +++ b/server.go @@ -100,6 +100,7 @@ func (s *Server) Listen() error { 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/thumb/{slug}", s.guard(s.handleThumb)) 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))