feat: password-gated web UI on the same backend (#1)
Adds a password-gated browser UI for the bookmark list, served by the same Go
binary and container as the userscript API.
## What
- `GET /` — list page, or the login page when there is no session (200, no redirect).
- `POST /login`, `POST /logout` — stateless HMAC session cookie, 60-day Max-Age.
- `GET /ui/list?tab=all|fav`, `POST /ui/bookmarks/{key}/favorite`,
`POST /ui/bookmarks/{key}/chapter`, `DELETE /ui/bookmarks/{key}` — htmx fragments.
- `GET /static/*` — embedded `style.css`, `htmx.min.js`, `filter.js`.
Mobile-first dark CSS, 2–3 column grid at ≥900px, "Continue reading" strip of the
five most recent series, NEW badge, client-side title search, no build step.
## Stack
Go `html/template` + htmx 2.0.4 (vendored, 50 KB) + plain CSS. No npm, no bundler.
Templates and assets are `go:embed`-ed, so `CGO_ENABLED=0` and the distroless
image still hold.
## Auth
`WEB_PASSWORD` gates the UI; unset means the web routes are never registered and
`/` returns 404. Session cookie is `HttpOnly`, `SameSite=Lax`, `Secure` when the
request is HTTPS. The signing key derives from `API_TOKEN` + `WEB_PASSWORD`, so
rotating either logs every browser out. Login is rate-limited to 10 failures per
20 minutes per client IP, keyed on the **rightmost** `X-Forwarded-For` entry
(Traefik appends the observed peer, so the leftmost is client-spoofable). CGNAT
lockout is a known, accepted limitation — the window self-heals.
## Invariants preserved
- A session cookie never authenticates `/bookmarks*`. That API stays JSON +
bearer token, unchanged, as does the userscript.
- `Store.Upsert` is byte-for-byte unmodified. Every UI write goes
read-modify-write through the new `Store.Get`, so the conditional-`updated_at`
rule (favouriting must not reorder the list, a chapter override must) lives in
exactly one function.
## Deployment
`docker-compose.prod.yml` gains a second Traefik router on `MANGA_WEB_HOST`
pointing at the same service — one container, one certificate resolver, no second
service. Both `MANGA_API_HOST` and `MANGA_WEB_HOST` are required (`:?`), with no
example fallback in `.env.example`: a placeholder there would make Traefik
silently publish the UI on a domain you do not own. Needs a DNS A/AAAA record for
`manga.<domain>`. See `DEPLOY.md` §1b.
## Docs
- Design: `docs/superpowers/specs/2026-07-25-web-ui-design.md`
- Plan: `plans/2026-07-25-web-ui-implementation-plan.md`
## Verification
`gofmt` clean, `go vet`, `go test -race ./...`, `CGO_ENABLED=0 go build`, a real
`docker build` + curl smoke test, and a Playwright pass covering login
reject/accept, favourite-without-reorder, chapter edit, delete-with-confirm,
search, tab switch + back button, 390px with no horizontal overflow, and zero JS
console errors.
Reviewed-on: #1
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
+306
@@ -0,0 +1,306 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed templates
|
||||
var templateFS embed.FS
|
||||
|
||||
//go:embed static
|
||||
var staticFS embed.FS
|
||||
|
||||
// recentCount is how many series the "Continue reading" strip shows.
|
||||
const recentCount = 5
|
||||
|
||||
// webHandler serves the browser UI: full pages at / and htmx fragments at /ui/.
|
||||
// It is a separate handler from bookmarkHandler because the two speak different
|
||||
// representations (HTML versus JSON) to different clients under different auth.
|
||||
type webHandler struct {
|
||||
store *Store
|
||||
tmpl *template.Template
|
||||
key []byte
|
||||
password string
|
||||
limiter *loginLimiter
|
||||
}
|
||||
|
||||
// listView is what every list-rendering template receives.
|
||||
type listView struct {
|
||||
Tab string // "all" or "fav"
|
||||
Recent []Bookmark
|
||||
Items []Bookmark
|
||||
}
|
||||
|
||||
// loginView is what the login template receives.
|
||||
type loginView struct {
|
||||
Error string
|
||||
}
|
||||
|
||||
// newWebHandler parses every template up front so a broken one kills the
|
||||
// process at startup rather than the first request that touches it.
|
||||
func newWebHandler(store *Store, cfg Config) (*webHandler, error) {
|
||||
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &webHandler{
|
||||
store: store,
|
||||
tmpl: tmpl,
|
||||
key: sessionKey(cfg.Token, cfg.WebPassword),
|
||||
password: cfg.WebPassword,
|
||||
limiter: newLoginLimiter(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *webHandler) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /{$}", h.index)
|
||||
mux.HandleFunc("POST /login", h.login)
|
||||
mux.HandleFunc("POST /logout", h.logout)
|
||||
mux.Handle("GET /static/", staticHandler())
|
||||
|
||||
mux.HandleFunc("GET /ui/list", h.requireSession(h.uiList))
|
||||
mux.HandleFunc("POST /ui/bookmarks/{key}/favorite", h.requireSession(h.uiFavorite))
|
||||
mux.HandleFunc("POST /ui/bookmarks/{key}/chapter", h.requireSession(h.uiChapter))
|
||||
mux.HandleFunc("DELETE /ui/bookmarks/{key}", h.requireSession(h.uiDelete))
|
||||
}
|
||||
|
||||
// staticHandler serves the embedded assets. An hour, not longer: assets are
|
||||
// not fingerprinted, and embed.FS reports a zero ModTime, so http.FileServer
|
||||
// emits no Last-Modified or ETag and a client has no way to revalidate a
|
||||
// cached copy after a deploy short of waiting out max-age.
|
||||
func staticHandler() http.Handler {
|
||||
sub, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
panic("embed static: " + err.Error())
|
||||
}
|
||||
files := http.FileServer(http.FS(sub))
|
||||
return http.StripPrefix("/static/", http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
files.ServeHTTP(w, r)
|
||||
}))
|
||||
}
|
||||
|
||||
// authed reports whether the request carries a valid session cookie.
|
||||
func (h *webHandler) authed(r *http.Request) bool {
|
||||
c, err := r.Cookie(sessionCookieName)
|
||||
return err == nil && verifySession(h.key, c.Value, time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
// requireSession guards the fragment endpoints. It answers 401 rather than
|
||||
// redirecting, because htmx swaps whatever body it receives into the page and a
|
||||
// redirected login page would be spliced into the card list.
|
||||
func (h *webHandler) requireSession(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.authed(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *webHandler) render(w http.ResponseWriter, status int, name string, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if err := h.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||
// The status line is already sent, so this can only be logged.
|
||||
log.Printf("render %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// index renders the list, or the login page when there is no session. The login
|
||||
// page is served at / with status 200 rather than as a redirect to a separate
|
||||
// URL: one page, no redirect loop to reason about.
|
||||
func (h *webHandler) index(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.authed(r) {
|
||||
h.render(w, http.StatusOK, "login", loginView{})
|
||||
return
|
||||
}
|
||||
view, err := h.buildListView(r.URL.Query().Get("tab"))
|
||||
if err != nil {
|
||||
log.Printf("index: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.render(w, http.StatusOK, "app", view)
|
||||
}
|
||||
|
||||
// buildListView loads the list once and derives both the tab-filtered items and
|
||||
// the recent strip from it. The strip always reflects overall recency, not the
|
||||
// active tab, so it is built before filtering.
|
||||
func (h *webHandler) buildListView(tab string) (listView, error) {
|
||||
all, err := h.store.List() // already ordered updated_at DESC
|
||||
if err != nil {
|
||||
return listView{}, err
|
||||
}
|
||||
|
||||
recent := all
|
||||
if len(recent) > recentCount {
|
||||
recent = recent[:recentCount]
|
||||
}
|
||||
|
||||
items := all
|
||||
if tab == "fav" {
|
||||
items = []Bookmark{}
|
||||
for _, b := range all {
|
||||
if b.Favorite {
|
||||
items = append(items, b)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tab = "all"
|
||||
}
|
||||
return listView{Tab: tab, Recent: recent, Items: items}, nil
|
||||
}
|
||||
|
||||
func (h *webHandler) uiList(w http.ResponseWriter, r *http.Request) {
|
||||
view, err := h.buildListView(r.URL.Query().Get("tab"))
|
||||
if err != nil {
|
||||
log.Printf("ui list: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.render(w, http.StatusOK, "list", view)
|
||||
}
|
||||
|
||||
func (h *webHandler) login(w http.ResponseWriter, r *http.Request) {
|
||||
ip := clientIP(r)
|
||||
if wait := h.limiter.retryAfter(ip, time.Now()); wait > 0 {
|
||||
secs := int(wait.Seconds()) + 1
|
||||
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
||||
h.render(w, http.StatusTooManyRequests, "login", loginView{
|
||||
Error: "Too many attempts. Try again in " +
|
||||
strconv.Itoa((secs+59)/60) + " min.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
got := r.PostFormValue("password")
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte(h.password)) != 1 {
|
||||
h.limiter.fail(ip, time.Now())
|
||||
h.render(w, http.StatusUnauthorized, "login", loginView{Error: "Wrong password."})
|
||||
return
|
||||
}
|
||||
|
||||
h.limiter.reset(ip)
|
||||
setSessionCookie(w, r, h.key)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *webHandler) logout(w http.ResponseWriter, r *http.Request) {
|
||||
clearSessionCookie(w, r)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// loadForMutation fetches the row a mutation targets, writing the error
|
||||
// response itself when there is nothing to mutate.
|
||||
func (h *webHandler) loadForMutation(w http.ResponseWriter, r *http.Request) (Bookmark, bool) {
|
||||
key := r.PathValue("key")
|
||||
if key == "" {
|
||||
http.Error(w, "missing key", http.StatusBadRequest)
|
||||
return Bookmark{}, false
|
||||
}
|
||||
b, ok, err := h.store.Get(key)
|
||||
if err != nil {
|
||||
log.Printf("ui get %q: %v", key, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return Bookmark{}, false
|
||||
}
|
||||
if !ok {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return Bookmark{}, false
|
||||
}
|
||||
return b, true
|
||||
}
|
||||
|
||||
// saveAndRenderCard upserts and renders the row as stored. Upsert decides
|
||||
// whether updated_at moves, so the argument's timestamp is only a candidate and
|
||||
// the response must come from the return value.
|
||||
func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, b Bookmark) {
|
||||
stored, err := h.store.Upsert(b)
|
||||
if err != nil {
|
||||
log.Printf("ui upsert %q: %v", b.Key, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.render(w, http.StatusOK, "card", stored)
|
||||
}
|
||||
|
||||
// uiFavorite flips the favourite flag. last_chapter_num is untouched, so
|
||||
// Upsert keeps the stored updated_at and the list does not reorder.
|
||||
func (h *webHandler) uiFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
b, ok := h.loadForMutation(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
b.Favorite = !b.Favorite
|
||||
b.UpdatedAt = time.Now().UnixMilli()
|
||||
h.saveAndRenderCard(w, b)
|
||||
}
|
||||
|
||||
// uiChapter forces the read chapter to a value the user typed.
|
||||
//
|
||||
// Writing the number also clears last_chapter_url: that URL points at the
|
||||
// chapter actually read, and once the number is forced elsewhere it would send
|
||||
// the reader backwards. ContinueURL then falls back to the series page, which
|
||||
// is always right.
|
||||
//
|
||||
// A submit that does not change the number touches nothing. The form is
|
||||
// pre-filled, so a bare tap of Save is an easy accidental submit; it must not
|
||||
// destroy last_chapter_url, nor rewrite the last_chapter display string ("45.0"
|
||||
// to "45") behind a frozen updated_at.
|
||||
func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) {
|
||||
b, ok := h.loadForMutation(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
raw := strings.TrimSpace(r.PostFormValue("chapter"))
|
||||
num, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil || num < 0 || math.IsNaN(num) || math.IsInf(num, 0) {
|
||||
http.Error(w, "chapter must be a non-negative number", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if num != b.LastChapterNum {
|
||||
b.LastChapterURL = ""
|
||||
b.LastChapter = raw
|
||||
b.LastChapterNum = num
|
||||
}
|
||||
b.UpdatedAt = time.Now().UnixMilli()
|
||||
h.saveAndRenderCard(w, b)
|
||||
}
|
||||
|
||||
// uiDelete removes the row and answers with an empty body, which htmx swaps in
|
||||
// place of the card — removing it from the page.
|
||||
func (h *webHandler) uiDelete(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.PathValue("key")
|
||||
if key == "" {
|
||||
http.Error(w, "missing key", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.store.Delete(key); err != nil {
|
||||
log.Printf("ui delete %q: %v", key, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
Reference in New Issue
Block a user