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))