Files
mangaBookmark/backend/web.go
T
sulthan 264cf21960 Show the recent strip only where it is not a duplicate
The strip is the head of the same updated_at DESC reading list the All tab
renders, and app.html put it on every tab: on a small library it repeated the
list immediately below it while eating ~230px of the first phone screen, and on
Archived and Finished it showed reading series above the opposite bucket.

buildListView now fills Recent only for tab=all with more reading series than
the strip holds. Because the strip lives outside the htmx-swapped #list, an
in-page tab switch cannot re-render it, so applyFilter also decides its
visibility from the active tab; setActiveTab pokes it after a swap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:10:42 +07:00

389 lines
13 KiB
Go

package main
import (
"crypto/subtle"
"embed"
"html/template"
"io/fs"
"log"
"math"
"mime"
"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", "fav", or "new"
Recent []Bookmark
Items []Bookmark
// NewCount is the badge on the Updated tab: how many series being read
// have a chapter out that has not been read. It is counted over the whole
// reading set, not the active tab, so the badge does not change meaning as
// the user moves between tabs.
NewCount int
}
// Initial is the monogram the templates show in place of a cover when the
// source site never gave us an og:image. First rune, uppercased; "?" when even
// the title is missing, so the slot is never empty.
func (b Bookmark) Initial() string {
for _, r := range b.Title {
return strings.ToUpper(string(r))
}
return "?"
}
// 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}/status", h.requireSession(h.uiStatus))
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())
}
// Go's built-in table has no .woff2 and the scratch image has no
// /etc/mime.types, so without this the fonts go out as
// application/octet-stream.
if err := mime.AddExtensionType(".woff2", "font/woff2"); err != nil {
panic("woff2 mime: " + 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)
}
// filterBookmarks returns the subset keep reports true for, preserving order.
// It always returns a non-nil slice so an empty tab renders its empty state.
func filterBookmarks(all []Bookmark, keep func(Bookmark) bool) []Bookmark {
out := []Bookmark{}
for _, b := range all {
if keep(b) {
out = append(out, b)
}
}
return out
}
// buildListView loads the list once and derives both the tab-filtered items and
// the recent strip from it.
//
// Archived and finished series appear in their own tab and nowhere else — not
// in All, not in Updated, not in Favourites, and not in the recent strip. An
// archived favourite therefore shows only under Archived: Favourites means
// "favourites I am currently reading".
func (h *webHandler) buildListView(tab string) (listView, error) {
all, err := h.store.List() // already ordered updated_at DESC
if err != nil {
return listView{}, err
}
reading := filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusReading })
withNew := filterBookmarks(reading, func(b Bookmark) bool { return b.HasNewChapter() })
var items []Bookmark
switch tab {
case "fav":
items = filterBookmarks(reading, func(b Bookmark) bool { return b.Favorite })
case "new":
items = withNew
case "archived":
items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusArchived })
case "finished":
items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusFinished })
default:
tab = "all"
items = reading
}
// The strip is the head of the same updated_at DESC reading list the All tab
// renders, so it only earns its ~230px of the first phone screen where it is
// not a pure duplicate of what sits right below it: on All, with more series
// than it shows. On the other tabs it either repeats the list or, worse,
// puts reading series above the archived and finished buckets.
var recent []Bookmark
if tab == "all" && len(reading) > recentCount {
recent = reading[:recentCount]
}
return listView{Tab: tab, Recent: recent, Items: items, NewCount: len(withNew)}, 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.
//
// ponytail: the swapped card stays put even when its new status no longer
// matches the active tab, add an hx-swap-oob list refresh if that reads as a
// bug rather than as feedback. Archiving from the All tab leaves the card on
// screen until the next list load. The alternative costs a full list round
// trip on every toggle, and the card visibly showing its new state is the
// feedback the user needs.
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)
}
// uiStatus moves a bookmark between lifecycle buckets. This is the only place
// a series can be marked finished — the JSON API refuses that value, so the
// userscript cannot set it even by accident.
//
// last_chapter_num is untouched, so Upsert keeps the stored updated_at and the
// list does not reorder.
func (h *webHandler) uiStatus(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
}
switch s := r.PostFormValue("status"); s {
case statusReading, statusArchived, statusFinished:
b.Status = s
default:
http.Error(w, "invalid status", http.StatusBadRequest)
return
}
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)
}