Add tag autocomplete to the Android app

Both tag fields (post and edit) now suggest existing tags as you type,
matching the web upload/manage forms. Suggestions come from a new
GET /api/tags.json that serves the generated public/tags.json. It lives under
/api/ so nginx proxies it to the app, reaching non-browser clients even when a
front-door botwall gates the static site (the same approach as /api/thumb/).

/api/tags.json and /api/thumb/{slug} are both left unguarded: they serve the
same public data as the static site (/tags.json and /thumbs/), so there is
nothing to protect, and keeping them off the auth path means they can never
return 401 — otherwise an nginx auth-fail jail (fail2ban) would ban the app
over an autocomplete poll or a per-row thumbnail load.

A reusable TagField composable filters on the token after the last comma and
completes it on tap; fetchTags() returns an empty list on any failure, so the
field degrades to a plain tags input when the endpoint is absent.
This commit is contained in:
puttaalu committed 2026-10-20 18:26:18 +02:00
1 parent f1e946228a
commit bb8c7109fa
11 files changed
+251 -17

No files matched your search

+3 -2
View File
@@ -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`: 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/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. - `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/<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.
- `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/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/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. - `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 ## 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/<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. 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>` (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: Its own flake, deliberately separate from the root one so NixOS consumers of `nixosModules.default` don't pull the Android SDK into their lock:
+3
View File
@@ -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 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 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. 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. 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 The server is not modified by this app. Everything it needs, it does client
@@ -78,6 +78,7 @@ class MainActivity : ComponentActivity() {
showManage -> ManageScreen( showManage -> ManageScreen(
state = viewModel.manage, state = viewModel.manage,
tagSuggestions = viewModel.tagSuggestions,
onBack = { showManage = false }, onBack = { showManage = false },
onRefresh = viewModel::loadManage, onRefresh = viewModel::loadManage,
onSave = viewModel::saveEdit, onSave = viewModel::saveEdit,
@@ -87,6 +88,7 @@ class MainActivity : ComponentActivity() {
else -> PostScreen( else -> PostScreen(
state = viewModel.post, state = viewModel.post,
tagSuggestions = viewModel.tagSuggestions,
onPickImages = { onPickImages = {
picker.launch( picker.launch(
PickVisualMediaRequest( PickVisualMediaRequest(
@@ -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<String> {
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<String>(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. */ /** Fetches a thumbnail by its site-relative [path], or null on any failure. */
fun fetchThumbnail(path: String): Bitmap? { fun fetchThumbnail(path: String): Bitmap? {
val request = Request.Builder().url("$base$path").get().build() val request = Request.Builder().url("$base$path").get().build()
@@ -49,6 +49,7 @@ private val DISPLAY_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM
@Composable @Composable
fun ManageScreen( fun ManageScreen(
state: ManageUiState, state: ManageUiState,
tagSuggestions: List<String>,
onBack: () -> Unit, onBack: () -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onSave: (slug: String, caption: String, date: LocalDate, tags: String) -> Unit, onSave: (slug: String, caption: String, date: LocalDate, tags: String) -> Unit,
@@ -120,6 +121,7 @@ fun ManageScreen(
editing?.let { post -> editing?.let { post ->
EditDialog( EditDialog(
post = post, post = post,
tagSuggestions = tagSuggestions,
onDismiss = { editing = null }, onDismiss = { editing = null },
onSave = { caption, date, tags -> onSave = { caption, date, tags ->
onSave(post.slug, caption, date, tags) onSave(post.slug, caption, date, tags)
@@ -209,6 +211,7 @@ private fun PostRow(post: ManagePost, onEdit: () -> Unit, onDelete: () -> Unit)
@Composable @Composable
private fun EditDialog( private fun EditDialog(
post: ManagePost, post: ManagePost,
tagSuggestions: List<String>,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSave: (caption: String, date: LocalDate, tags: String) -> Unit, onSave: (caption: String, date: LocalDate, tags: String) -> Unit,
) { ) {
@@ -243,12 +246,11 @@ private fun EditDialog(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
OutlinedTextField( TagField(
value = tags, value = tags,
onValueChange = { tags = it }, onValueChange = { tags = it },
label = { Text("Tags") }, suggestions = tagSuggestions,
placeholder = { Text("comma-separated") }, enabled = true,
singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -49,6 +49,7 @@ private val DISPLAY_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM
@Composable @Composable
fun PostScreen( fun PostScreen(
state: PostUiState, state: PostUiState,
tagSuggestions: List<String>,
onPickImages: () -> Unit, onPickImages: () -> Unit,
onCaptionChange: (id: String, value: String) -> Unit, onCaptionChange: (id: String, value: String) -> Unit,
onTagsChange: (id: String, value: String) -> Unit, onTagsChange: (id: String, value: String) -> Unit,
@@ -111,6 +112,7 @@ fun PostScreen(
items(state.items, key = { it.id }) { item -> items(state.items, key = { it.id }) { item ->
ItemCard( ItemCard(
item = item, item = item,
tagSuggestions = tagSuggestions,
onCaptionChange = { onCaptionChange(item.id, it) }, onCaptionChange = { onCaptionChange(item.id, it) },
onTagsChange = { onTagsChange(item.id, it) }, onTagsChange = { onTagsChange(item.id, it) },
onEditDate = { dateEditFor = item }, onEditDate = { dateEditFor = item },
@@ -190,6 +192,7 @@ fun PostScreen(
@Composable @Composable
private fun ItemCard( private fun ItemCard(
item: PickedItem, item: PickedItem,
tagSuggestions: List<String>,
onCaptionChange: (String) -> Unit, onCaptionChange: (String) -> Unit,
onTagsChange: (String) -> Unit, onTagsChange: (String) -> Unit,
onEditDate: () -> Unit, onEditDate: () -> Unit,
@@ -227,12 +230,10 @@ private fun ItemCard(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
OutlinedTextField( TagField(
value = item.tags, value = item.tags,
onValueChange = onTagsChange, onValueChange = onTagsChange,
label = { Text("Tags") }, suggestions = tagSuggestions,
placeholder = { Text("comma-separated") },
singleLine = true,
enabled = item.status !is ItemStatus.Uploading, enabled = item.status !is ItemStatus.Uploading,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
@@ -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<String>,
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 + ", "
}
@@ -109,11 +109,30 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
val isConfigured: Boolean get() = settings.isConfigured 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<List<String>>(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 ───────────────────────────────────────────────────────── // ── Post screen ─────────────────────────────────────────────────────────
/** Replaces the current selection with [uris] and loads each preview. */ /** Replaces the current selection with [uris] and loads each preview. */
fun selectImages(uris: List<Uri>) { fun selectImages(uris: List<Uri>) {
if (uris.isEmpty()) return if (uris.isEmpty()) return
refreshTags()
val items = uris.map { PickedItem(source = it) } val items = uris.map { PickedItem(source = it) }
post = post.copy(items = items) post = post.copy(items = items)
@@ -264,6 +283,7 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
return return
} }
manage = manage.copy(loading = true, error = null, message = null) manage = manage.copy(loading = true, error = null, message = null)
refreshTags()
viewModelScope.launch { viewModelScope.launch {
val result = withContext(Dispatchers.IO) { client().listPhotos() } val result = withContext(Dispatchers.IO) { client().listPhotos() }
when (result) { when (result) {
@@ -283,9 +303,11 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
private fun loadThumbnails(photos: List<RemotePhoto>) { private fun loadThumbnails(photos: List<RemotePhoto>) {
for (p in photos) { for (p in photos) {
viewModelScope.launch { viewModelScope.launch {
// Fetch via the authed /api/thumb/<slug> endpoint, not the static // Fetch via the /api/thumb/<slug> endpoint, not the static
// /thumbs/ path: a front-door botwall can block this non-browser // /thumbs/ path: a front-door botwall can block this non-browser
// client from the static site while /api/ is proxied to the app. // 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) } val bmp = withContext(Dispatchers.IO) { client().fetchThumbnail("/api/thumb/" + p.slug) }
if (bmp != null) updateManage(p.slug) { it.copy(preview = bmp) } if (bmp != null) updateManage(p.slug) { it.copy(preview = bmp) }
} }
+32 -5
View File
@@ -59,11 +59,14 @@ func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]bool{"authenticated": false}) writeJSON(w, map[string]bool{"authenticated": false})
} }
// handleThumb serves a photo's thumbnail from the generated output, behind the // handleThumb serves a photo's thumbnail from the generated output. Non-browser
// same Basic auth as the rest of /api/. Non-browser clients (the Android app) // clients (the Android app) use this instead of the static /thumbs/<slug>.jpg
// use this instead of the static /thumbs/<slug>.jpg path, because a front-door // path, because a front-door botwall on `location /` can block a non-browser
// botwall on `location /` can block a non-browser client from the static site // client from the static site while `/api/` is proxied straight to this server.
// 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) { func (s *Server) handleThumb(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug") slug := r.PathValue("slug")
if slug == "" || slug != filepath.Base(slug) || strings.HasPrefix(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) 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. // handleManagePage serves the (auth-gated) edit/delete UI.
func (s *Server) handleManagePage(w http.ResponseWriter, r *http.Request) { func (s *Server) handleManagePage(w http.ResponseWriter, r *http.Request) {
data, err := assets.ReadFile("templates/manage.html") data, err := assets.ReadFile("templates/manage.html")
+36
View File
@@ -330,3 +330,39 @@ func TestHandleListPhotos(t *testing.T) {
t.Errorf("foo tags = %v", foo.Tags) 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)
}
}
+10 -1
View File
@@ -100,7 +100,16 @@ func (s *Server) Listen() error {
mux.HandleFunc("POST /api/edit", s.guard(s.handleEdit)) mux.HandleFunc("POST /api/edit", s.guard(s.handleEdit))
mux.HandleFunc("POST /api/delete", s.guard(s.handleDelete)) mux.HandleFunc("POST /api/delete", s.guard(s.handleDelete))
mux.HandleFunc("POST /api/tags", s.guard(s.handleBulkTags)) 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("GET /api/trash", s.guard(s.handleListTrash))
mux.HandleFunc("POST /api/restore", s.guard(s.handleRestore)) mux.HandleFunc("POST /api/restore", s.guard(s.handleRestore))
mux.HandleFunc("POST /api/trash/empty", s.guard(s.handleEmptyTrash)) mux.HandleFunc("POST /api/trash/empty", s.guard(s.handleEmptyTrash))