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

+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})
}
// 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")