ce7e93df53
The design depends on three specific families, and loading them from fonts.googleapis.com means the UI silently loses its character exactly where it is used most: Bromite is a de-googled browser whose users routinely block Google's font domains, and the backend is reachable over a LAN with no internet route. In both cases the page fell back to Georgia and a system sans. Five latin-subset woff2 files, ~120 KB total, in a binary already 27 MB — the cost is noise, and //go:embed static picks them up with no build change (backend/Dockerfile already copies static/). Latin only because the UI is English; woff2 only because every browser that can run this app supports it, so there is no second format to carry. DM Sans ships as one variable file, which covers the whole 400-700 range the design uses for the price of a single request. staticHandler registers the .woff2 MIME type: Go's built-in table has no entry for it and the scratch image has no /etc/mime.types, so the fonts would otherwise be served as application/octet-stream. The two faces above the fold are preloaded, since they are now discovered a stylesheet late rather than from a preconnect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
386 lines
12 KiB
Go
386 lines
12 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 })
|
|
|
|
// The strip reflects overall reading recency, not the active tab.
|
|
recent := reading
|
|
if len(recent) > recentCount {
|
|
recent = recent[:recentCount]
|
|
}
|
|
|
|
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
|
|
}
|
|
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)
|
|
}
|