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) }