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`:
|
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:
|
||||||
|
|
||||||
|
|||||||
@@ -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) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
Reference in new issue
Block a user