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.
This commit is contained in:
puttaalu committed 2026-10-19 15:33:25 +02:00
1 parent 125b6b5241
commit 2143b95657
6 files changed
+50 -3

No files matched your search

+2 -1
View File
@@ -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/<slug>.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/<slug>` (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:
@@ -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(
@@ -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 },
@@ -283,7 +283,10 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
private fun loadThumbnails(photos: List<RemotePhoto>) {
for (p in photos) {
viewModelScope.launch {
val bmp = withContext(Dispatchers.IO) { client().fetchThumbnail(p.thumb) }
// Fetch via the authed /api/thumb/<slug> 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) }
}
}
+26
View File
@@ -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/<slug>.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")
+1
View File
@@ -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))