From f70707f154c1088df139ac8098b96646800f202a Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sat, 25 Jul 2026 23:03:00 +0700 Subject: [PATCH] feat(backend): password login, session gate, and list page Co-Authored-By: Claude Opus 5 --- backend/main.go | 11 ++ backend/static/filter.js | 6 ++ backend/static/style.css | 2 + backend/templates/app.html | 53 +++++++++ backend/templates/card.html | 18 ++++ backend/templates/list.html | 9 ++ backend/templates/login.html | 24 +++++ backend/web.go | 201 +++++++++++++++++++++++++++++++++++ backend/web_test.go | 201 +++++++++++++++++++++++++++++++++++ 9 files changed, 525 insertions(+) create mode 100644 backend/static/filter.js create mode 100644 backend/static/style.css create mode 100644 backend/templates/app.html create mode 100644 backend/templates/card.html create mode 100644 backend/templates/list.html create mode 100644 backend/templates/login.html create mode 100644 backend/web.go create mode 100644 backend/web_test.go diff --git a/backend/main.go b/backend/main.go index 3c52e08..117028b 100644 --- a/backend/main.go +++ b/backend/main.go @@ -61,6 +61,17 @@ func newRouter(store *Store, cfg Config) http.Handler { mux.Handle("/bookmarks", auth) mux.Handle("/bookmarks/", auth) + // The browser UI is registered only when a password is configured, so a + // deployment that forgets WEB_PASSWORD exposes nothing rather than + // exposing an unprotected list. + if cfg.WebPassword != "" { + web, err := newWebHandler(store, cfg) + if err != nil { + log.Fatalf("web handler: %v", err) + } + web.register(mux) + } + return withCORS(cfg.AllowedOrigins, mux) } diff --git a/backend/static/filter.js b/backend/static/filter.js new file mode 100644 index 0000000..cc1ffd6 --- /dev/null +++ b/backend/static/filter.js @@ -0,0 +1,6 @@ +// Title search and tab-state handling land in Task 7. +function setActiveTab(el) { + el.parentElement.querySelectorAll("[role=tab]").forEach(function (t) { + t.classList.toggle("active", t === el); + }); +} diff --git a/backend/static/style.css b/backend/static/style.css new file mode 100644 index 0000000..ff1dbdd --- /dev/null +++ b/backend/static/style.css @@ -0,0 +1,2 @@ +/* Styling lands in Task 7. */ +:root { color-scheme: dark light; } diff --git a/backend/templates/app.html b/backend/templates/app.html new file mode 100644 index 0000000..75cacbf --- /dev/null +++ b/backend/templates/app.html @@ -0,0 +1,53 @@ +{{define "app"}} + + + + + + + mangaBookmark + + + + + +
+

mangaBookmark

+
+ +
+
+ + + + + + {{if .Recent}} +
+

Continue reading

+ +
+ {{end}} + +
+ {{template "list" .}} +
+ + +{{end}} diff --git a/backend/templates/card.html b/backend/templates/card.html new file mode 100644 index 0000000..5430666 --- /dev/null +++ b/backend/templates/card.html @@ -0,0 +1,18 @@ +{{define "card"}} + +{{end}} diff --git a/backend/templates/list.html b/backend/templates/list.html new file mode 100644 index 0000000..2a5dcd8 --- /dev/null +++ b/backend/templates/list.html @@ -0,0 +1,9 @@ +{{define "list"}} +{{if .Items}} + {{range .Items}}{{template "card" .}}{{end}} +{{else}} +

+ Nothing here yet. Bookmarks appear once the userscript records a chapter. +

+{{end}} +{{end}} diff --git a/backend/templates/login.html b/backend/templates/login.html new file mode 100644 index 0000000..d3e9e82 --- /dev/null +++ b/backend/templates/login.html @@ -0,0 +1,24 @@ +{{define "login"}} + + + + + + + mangaBookmark + + + +
+

mangaBookmark

+
+ + + {{if .Error}}

{{.Error}}

{{end}} + +
+
+ + +{{end}} diff --git a/backend/web.go b/backend/web.go new file mode 100644 index 0000000..3a8623b --- /dev/null +++ b/backend/web.go @@ -0,0 +1,201 @@ +package main + +import ( + "crypto/subtle" + "embed" + "html/template" + "io/fs" + "log" + "net/http" + "strconv" + "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), + 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)) +} + +// staticHandler serves the embedded assets. The vendored htmx build and the +// stylesheet change only on deploy, so a long max-age is safe; a redeploy +// changes the binary and the browser revalidates on its own schedule. +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) +} diff --git a/backend/web_test.go b/backend/web_test.go new file mode 100644 index 0000000..349ce45 --- /dev/null +++ b/backend/web_test.go @@ -0,0 +1,201 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +const testPassword = "hunter2" + +func webConfig() Config { + cfg := testConfig() + cfg.WebPassword = testPassword + return cfg +} + +// newWebTestServer returns the full router plus the store behind it, so tests +// can seed rows and assert on what the handlers wrote back. +func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *Store) { + t.Helper() + store, err := OpenStore(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("OpenStore: %v", err) + } + t.Cleanup(func() { store.Close() }) + return newRouter(store, cfg), store +} + +// sessionCookie returns a cookie a handler will accept for cfg's API token. +func sessionCookie(t *testing.T, cfg Config) *http.Cookie { + t.Helper() + return &http.Cookie{ + Name: sessionCookieName, + Value: signSession(sessionKey(cfg.Token), time.Now().Add(time.Hour).UnixMilli()), + } +} + +func TestIndexWithoutSessionShowsLogin(t *testing.T) { + srv, _ := newWebTestServer(t, webConfig()) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + + if rr.Code != http.StatusOK { + t.Fatalf("GET / status = %d, want 200", rr.Code) + } + if !strings.Contains(rr.Body.String(), `type="password"`) { + t.Fatal("GET / without a session did not render the password field") + } +} + +func TestIndexWithSessionShowsList(t *testing.T) { + cfg := webConfig() + srv, store := newWebTestServer(t, cfg) + if _, err := store.Upsert(Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", + Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45, + UpdatedAt: time.Now().UnixMilli(), + }); err != nil { + t.Fatalf("Upsert: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(sessionCookie(t, cfg)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("GET / status = %d, want 200", rr.Code) + } + if !strings.Contains(rr.Body.String(), "Solo Leveling") { + t.Fatal("GET / with a session did not render the bookmark title") + } +} + +func TestLoginSuccessSetsCookie(t *testing.T) { + srv, _ := newWebTestServer(t, webConfig()) + req := httptest.NewRequest(http.MethodPost, "/login", + strings.NewReader(url.Values{"password": {testPassword}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusSeeOther { + t.Fatalf("POST /login status = %d, want 303", rr.Code) + } + cookies := rr.Result().Cookies() + if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" { + t.Fatalf("POST /login cookies = %+v, want one non-empty %s", cookies, sessionCookieName) + } +} + +func TestLoginWrongPassword(t *testing.T) { + srv, _ := newWebTestServer(t, webConfig()) + req := httptest.NewRequest(http.MethodPost, "/login", + strings.NewReader(url.Values{"password": {"wrong"}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("POST /login status = %d, want 401", rr.Code) + } + if len(rr.Result().Cookies()) != 0 { + t.Fatal("a failed login set a cookie") + } +} + +func TestLoginRateLimited(t *testing.T) { + srv, _ := newWebTestServer(t, webConfig()) + post := func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/login", + strings.NewReader(url.Values{"password": {"wrong"}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("X-Forwarded-For", "203.0.113.9") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + return rr + } + for i := 0; i < loginMaxFailures; i++ { + if code := post().Code; code != http.StatusUnauthorized { + t.Fatalf("attempt %d status = %d, want 401", i+1, code) + } + } + rr := post() + if rr.Code != http.StatusTooManyRequests { + t.Fatalf("attempt %d status = %d, want 429", loginMaxFailures+1, rr.Code) + } + if after := rr.Header().Get("Retry-After"); after == "" { + t.Fatal("429 response has no Retry-After header") + } else if n, err := strconv.Atoi(after); err != nil || n <= 0 { + t.Fatalf("Retry-After = %q, want a positive integer", after) + } +} + +func TestLogoutClearsCookie(t *testing.T) { + cfg := webConfig() + srv, _ := newWebTestServer(t, cfg) + req := httptest.NewRequest(http.MethodPost, "/logout", nil) + req.AddCookie(sessionCookie(t, cfg)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusSeeOther { + t.Fatalf("POST /logout status = %d, want 303", rr.Code) + } + cookies := rr.Result().Cookies() + if len(cookies) != 1 || cookies[0].MaxAge >= 0 { + t.Fatalf("POST /logout cookies = %+v, want one expiring cookie", cookies) + } +} + +func TestWebDisabledWhenNoPassword(t *testing.T) { + cfg := testConfig() // WebPassword empty + srv, _ := newWebTestServer(t, cfg) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + + if rr.Code != http.StatusNotFound { + t.Fatalf("GET / with WEB_PASSWORD unset = %d, want 404", rr.Code) + } +} + +func TestBookmarksAPIStillBearerOnly(t *testing.T) { + cfg := webConfig() + srv, _ := newWebTestServer(t, cfg) + + // A session cookie must not grant access to the userscript's JSON API. + req := httptest.NewRequest(http.MethodGet, "/bookmarks", nil) + req.AddCookie(sessionCookie(t, cfg)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("GET /bookmarks with only a cookie = %d, want 401", rr.Code) + } + + // And the bearer token must still work. + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil))) + if rr.Code != http.StatusOK { + t.Fatalf("GET /bookmarks with bearer = %d, want 200", rr.Code) + } +} + +func TestStaticAssetsServed(t *testing.T) { + srv, _ := newWebTestServer(t, webConfig()) + for _, path := range []string{"/static/style.css", "/static/htmx.min.js", "/static/filter.js"} { + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil)) + if rr.Code != http.StatusOK { + t.Fatalf("GET %s = %d, want 200", path, rr.Code) + } + if rr.Body.Len() == 0 { + t.Fatalf("GET %s returned an empty body", path) + } + } +}