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:
1 parent
f1e946228a
commit
bb8c7109fa
11 files changed
+251
-17
No files matched your search
@@ -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/<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/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/<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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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. */
|
||||
fun fetchThumbnail(path: String): Bitmap? {
|
||||
val request = Request.Builder().url("$base$path").get().build()
|
||||
|
||||
@@ -49,6 +49,7 @@ private val DISPLAY_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM
|
||||
@Composable
|
||||
fun ManageScreen(
|
||||
state: ManageUiState,
|
||||
tagSuggestions: List<String>,
|
||||
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<String>,
|
||||
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))
|
||||
|
||||
@@ -49,6 +49,7 @@ private val DISPLAY_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM
|
||||
@Composable
|
||||
fun PostScreen(
|
||||
state: PostUiState,
|
||||
tagSuggestions: List<String>,
|
||||
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<String>,
|
||||
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(),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
// 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 ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Replaces the current selection with [uris] and loads each preview. */
|
||||
fun selectImages(uris: List<Uri>) {
|
||||
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<RemotePhoto>) {
|
||||
for (p in photos) {
|
||||
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
|
||||
// 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) }
|
||||
}
|
||||
|
||||
@@ -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/<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.
|
||||
// handleThumb serves a photo's thumbnail from the generated output. 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.
|
||||
// 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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user