diff --git a/CLAUDE.md b/CLAUDE.md index 0512dd1..ee657fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,7 +69,8 @@ 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. +- `GET /api/thumb/{slug}` — serves that post's thumbnail JPEG from `public/thumbs/`. **Unguarded**: thumbnails are the same public data as the static `/thumbs/`, so nothing is protected, and keeping it off the auth path means it can never 401 and so can never trip the fail2ban auth jail over a per-row thumbnail load. Non-browser clients (the Android app) use it 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. +- `GET /api/tags.json` — serves the generated `public/tags.json` for the Android app's tag autocomplete. **Unguarded** (like `/api/session`): the tag list is the same public data as the static `/tags.json`, so nothing is protected, and keeping it off the auth path means it can never return 401 — otherwise an nginx auth-fail jail (fail2ban) would ban the app over a mere autocomplete poll. It lives under `/api/` only so nginx proxies it past a botwall; browsers read the static `/tags.json`. - `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. @@ -192,7 +193,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. 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. +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/` (unguarded public data, app-served) rather than the static `/thumbs/` path, so a botwall on `location /` doesn't blank them. Both tag fields (post and edit) autocomplete from the known tag list, fetched from `/api/tags.json` (unguarded public data, proxied past the botwall); suggestions filter on the token after the last comma and tapping one completes it. 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/README.md b/android/README.md index 19ce205..ec81547 100644 --- a/android/README.md +++ b/android/README.md @@ -4,6 +4,9 @@ Native uploader and manager for the photogallery server. Pick or share one or many photos, add caption/date/tags, and post. A **Manage** screen lists the published posts and edits their caption/date/tags or deletes them (to the server's trash) over the same `/api/*` endpoints the web `/manage` uses. +Both tag fields autocomplete from the existing tags (fetched from +`/api/tags.json`, unguarded public data proxied past the botwall), filtering on +the token after the last comma. Credentials are entered once and sealed with a key held in the Android Keystore. The server is not modified by this app. Everything it needs, it does client 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 9004c0b..566eba2 100644 --- a/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt +++ b/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt @@ -78,6 +78,7 @@ class MainActivity : ComponentActivity() { showManage -> ManageScreen( state = viewModel.manage, + tagSuggestions = viewModel.tagSuggestions, onBack = { showManage = false }, onRefresh = viewModel::loadManage, onSave = viewModel::saveEdit, @@ -87,6 +88,7 @@ class MainActivity : ComponentActivity() { else -> PostScreen( state = viewModel.post, + tagSuggestions = viewModel.tagSuggestions, onPickImages = { picker.launch( PickVisualMediaRequest( diff --git a/android/app/src/main/java/ws/inflo/photogallery/net/UploadClient.kt b/android/app/src/main/java/ws/inflo/photogallery/net/UploadClient.kt index 0957788..700f943 100644 --- a/android/app/src/main/java/ws/inflo/photogallery/net/UploadClient.kt +++ b/android/app/src/main/java/ws/inflo/photogallery/net/UploadClient.kt @@ -234,6 +234,36 @@ class UploadClient( } } + /** + * Fetches the known tag names for autocomplete, alphabetical. Reads + * /api/tags.json (not the static /tags.json) so a front-door botwall on + * `location /` can't blank it for this non-browser client, mirroring how + * thumbnails go through /api/thumb/. That endpoint is unguarded on the + * server (public tag data), so it never 401s and can't get the app banned + * by an auth-fail jail; the Basic header still rides along harmlessly. + * Returns an empty list on any failure — autocomplete is a convenience, + * never a hard dependency. + */ + fun fetchTags(): List { + val request = Request.Builder().url("$base/api/tags.json").get().build() + return try { + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) return emptyList() + val arr = JSONArray(response.body?.string().orEmpty()) + val out = ArrayList(arr.length()) + for (i in 0 until arr.length()) { + val name = arr.getJSONObject(i).optString("tag") + if (name.isNotEmpty()) out.add(name) + } + out + } + } catch (e: IOException) { + emptyList() + } catch (e: JSONException) { + emptyList() + } + } + /** Fetches a thumbnail by its site-relative [path], or null on any failure. */ fun fetchThumbnail(path: String): Bitmap? { val request = Request.Builder().url("$base$path").get().build() 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 77a8b8a..5b4ae82 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 @@ -49,6 +49,7 @@ private val DISPLAY_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM @Composable fun ManageScreen( state: ManageUiState, + tagSuggestions: List, onBack: () -> Unit, onRefresh: () -> Unit, onSave: (slug: String, caption: String, date: LocalDate, tags: String) -> Unit, @@ -120,6 +121,7 @@ fun ManageScreen( editing?.let { post -> EditDialog( post = post, + tagSuggestions = tagSuggestions, onDismiss = { editing = null }, onSave = { caption, date, tags -> onSave(post.slug, caption, date, tags) @@ -209,6 +211,7 @@ private fun PostRow(post: ManagePost, onEdit: () -> Unit, onDelete: () -> Unit) @Composable private fun EditDialog( post: ManagePost, + tagSuggestions: List, onDismiss: () -> Unit, onSave: (caption: String, date: LocalDate, tags: String) -> Unit, ) { @@ -243,12 +246,11 @@ private fun EditDialog( modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(8.dp)) - OutlinedTextField( + TagField( value = tags, onValueChange = { tags = it }, - label = { Text("Tags") }, - placeholder = { Text("comma-separated") }, - singleLine = true, + suggestions = tagSuggestions, + enabled = true, modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(8.dp)) diff --git a/android/app/src/main/java/ws/inflo/photogallery/ui/PostScreen.kt b/android/app/src/main/java/ws/inflo/photogallery/ui/PostScreen.kt index 8c5d589..817723a 100644 --- a/android/app/src/main/java/ws/inflo/photogallery/ui/PostScreen.kt +++ b/android/app/src/main/java/ws/inflo/photogallery/ui/PostScreen.kt @@ -49,6 +49,7 @@ private val DISPLAY_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM @Composable fun PostScreen( state: PostUiState, + tagSuggestions: List, onPickImages: () -> Unit, onCaptionChange: (id: String, value: String) -> Unit, onTagsChange: (id: String, value: String) -> Unit, @@ -111,6 +112,7 @@ fun PostScreen( items(state.items, key = { it.id }) { item -> ItemCard( item = item, + tagSuggestions = tagSuggestions, onCaptionChange = { onCaptionChange(item.id, it) }, onTagsChange = { onTagsChange(item.id, it) }, onEditDate = { dateEditFor = item }, @@ -190,6 +192,7 @@ fun PostScreen( @Composable private fun ItemCard( item: PickedItem, + tagSuggestions: List, onCaptionChange: (String) -> Unit, onTagsChange: (String) -> Unit, onEditDate: () -> Unit, @@ -227,12 +230,10 @@ private fun ItemCard( modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(6.dp)) - OutlinedTextField( + TagField( value = item.tags, onValueChange = onTagsChange, - label = { Text("Tags") }, - placeholder = { Text("comma-separated") }, - singleLine = true, + suggestions = tagSuggestions, enabled = item.status !is ItemStatus.Uploading, modifier = Modifier.fillMaxWidth(), ) diff --git a/android/app/src/main/java/ws/inflo/photogallery/ui/TagField.kt b/android/app/src/main/java/ws/inflo/photogallery/ui/TagField.kt new file mode 100644 index 0000000..af812f2 --- /dev/null +++ b/android/app/src/main/java/ws/inflo/photogallery/ui/TagField.kt @@ -0,0 +1,101 @@ +package ws.inflo.photogallery.ui + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.unit.dp + +private const val MAX_SUGGESTIONS = 8 + +/** + * A comma-separated tags input with inline autocomplete. Suggestions filter on + * the token currently being typed (the text after the last comma) and exclude + * tags already entered; tapping one completes that token and starts the next. + * The raw string is passed straight through — the server normalizes it. + */ +@Composable +fun TagField( + value: String, + onValueChange: (String) -> Unit, + suggestions: List, + enabled: Boolean, + modifier: Modifier = Modifier, +) { + var focused by remember { mutableStateOf(false) } + + val already = remember(value) { + value.split(',').map { it.trim().lowercase() }.filter { it.isNotEmpty() }.toSet() + } + val token = value.substringAfterLast(',').trim() + val matches = remember(token, suggestions, already) { + if (token.isEmpty()) { + emptyList() + } else { + suggestions.asSequence() + .filter { it.lowercase().contains(token.lowercase()) } + .filter { it.lowercase() !in already } + .distinct() + .take(MAX_SUGGESTIONS) + .toList() + } + } + + Column(modifier = modifier) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text("Tags") }, + placeholder = { Text("comma-separated") }, + singleLine = true, + enabled = enabled, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focused = it.isFocused }, + ) + if (enabled && focused && matches.isNotEmpty()) { + Spacer(Modifier.height(6.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + for (tag in matches) { + SuggestionChip( + onClick = { onValueChange(completeToken(value, tag)) }, + label = { Text(tag) }, + colors = SuggestionChipDefaults.suggestionChipColors( + labelColor = GruvAqua, + containerColor = GruvBg1, + ), + ) + } + Spacer(Modifier.width(2.dp)) + } + } + } +} + +/** Replaces the in-progress token (after the last comma) with [chosen] + ", ". */ +private fun completeToken(current: String, chosen: String): String { + val lastComma = current.lastIndexOf(',') + val head = if (lastComma >= 0) current.substring(0, lastComma + 1) + " " else "" + return head + chosen + ", " +} 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 c1e70af..93b81a9 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 @@ -109,11 +109,30 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) { val isConfigured: Boolean get() = settings.isConfigured + // Known tag names for autocomplete, shared by the Post and Manage screens. + // Best-effort: fetched from /api/tags.json, empty until it arrives. + var tagSuggestions by mutableStateOf>(emptyList()) + private set + + init { + refreshTags() + } + + /** Refreshes the autocomplete tag list; silent on any failure. */ + fun refreshTags() { + if (!settings.isConfigured) return + viewModelScope.launch { + val tags = withContext(Dispatchers.IO) { client().fetchTags() } + if (tags.isNotEmpty()) tagSuggestions = tags + } + } + // ── Post screen ───────────────────────────────────────────────────────── /** Replaces the current selection with [uris] and loads each preview. */ fun selectImages(uris: List) { if (uris.isEmpty()) return + refreshTags() val items = uris.map { PickedItem(source = it) } post = post.copy(items = items) @@ -264,6 +283,7 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) { return } manage = manage.copy(loading = true, error = null, message = null) + refreshTags() viewModelScope.launch { val result = withContext(Dispatchers.IO) { client().listPhotos() } when (result) { @@ -283,9 +303,11 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) { private fun loadThumbnails(photos: List) { for (p in photos) { viewModelScope.launch { - // Fetch via the authed /api/thumb/ endpoint, not the static + // Fetch via the /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. + // The endpoint is unguarded (public image data), so it never + // 401s and can't get the app banned by an auth-fail jail. 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 463151f..18aba26 100644 --- a/manage.go +++ b/manage.go @@ -59,11 +59,14 @@ 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. +// handleThumb serves a photo's thumbnail from the generated output. 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. +// It is registered UNGUARDED (see server.go): thumbnails are the same public +// data as the static /thumbs/, so there is nothing to protect, and staying off +// the auth path means it can never 401 and so can never trip an auth-fail jail +// that would ban the app over a per-row thumbnail load. func (s *Server) handleThumb(w http.ResponseWriter, r *http.Request) { slug := r.PathValue("slug") if slug == "" || slug != filepath.Base(slug) || strings.HasPrefix(slug, ".") { @@ -85,6 +88,30 @@ func (s *Server) handleThumb(w http.ResponseWriter, r *http.Request) { http.ServeContent(w, r, slug+".jpg", fi.ModTime(), f) } +// handleTagsJSON serves the generated tags.json under /api/ so the Android app +// can offer tag autocomplete. It is registered UNGUARDED (see server.go): the +// tag list is the same public data as the static /tags.json, so there is +// nothing to protect, and staying off the auth path means it can never return +// 401 and so can never trip an nginx auth-fail jail that would ban the app +// over an autocomplete poll. It lives under /api/ only so nginx proxies it to +// the app, reaching non-browser clients past a front-door botwall on +// `location /` (the same botwall reason /api/thumb/ exists). +func (s *Server) handleTagsJSON(w http.ResponseWriter, r *http.Request) { + f, err := os.Open(filepath.Join(s.outputDir, "tags.json")) + 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", "application/json; charset=utf-8") + http.ServeContent(w, r, "tags.json", 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/manage_test.go b/manage_test.go index 7e7c05c..3f154ff 100644 --- a/manage_test.go +++ b/manage_test.go @@ -330,3 +330,39 @@ func TestHandleListPhotos(t *testing.T) { t.Errorf("foo tags = %v", foo.Tags) } } + +func TestHandleTagsJSON(t *testing.T) { + s, _, output := newTestServer(t) + + rec := httptest.NewRecorder() + s.handleTagsJSON(rec, httptest.NewRequest(http.MethodGet, "/api/tags.json", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("tags.json: code=%d", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Errorf("content-type = %q", ct) + } + var tags []struct { + Tag string `json:"tag"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &tags); err != nil { + t.Fatalf("decode: %v", err) + } + got := map[string]bool{} + for _, tg := range tags { + got[tg.Tag] = true + } + if !got["berlin"] || !got["street"] { + t.Errorf("tags = %v, want berlin+street", tags) + } + + // Missing file → 404. + if err := os.Remove(filepath.Join(output, "tags.json")); err != nil { + t.Fatal(err) + } + rec = httptest.NewRecorder() + s.handleTagsJSON(rec, httptest.NewRequest(http.MethodGet, "/api/tags.json", nil)) + if rec.Code != http.StatusNotFound { + t.Errorf("missing tags.json: code=%d, want 404", rec.Code) + } +} diff --git a/server.go b/server.go index a1d3110..07f9bdb 100644 --- a/server.go +++ b/server.go @@ -100,7 +100,16 @@ 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)) + // Thumbnails and the tag list — deliberately NOT guarded. Both serve the + // same public data as the static site (thumbs under /thumbs/, the tag list + // at /tags.json), so there is nothing to protect. Keeping them off the auth + // path means they can never return 401 and so can never feed an nginx + // auth-fail jail (fail2ban), which would otherwise ban the app over a + // per-row thumbnail load or an autocomplete poll. They stay under /api/ + // only so nginx proxies them to the app, reaching non-browser clients past + // a front-door botwall on `location /`. + mux.HandleFunc("GET /api/thumb/{slug}", s.handleThumb) + mux.HandleFunc("GET /api/tags.json", s.handleTagsJSON) 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))