2c3d687b6d
Adds the three UI mutation endpoints (favourite toggle, manual chapter override, delete) plus the card controls that call them via htmx. Each mutation is a read-modify-write through Store.Get/Upsert so Upsert alone decides whether updated_at moves — favouriting must not reorder the list, only real reading progress should. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
297 lines
8.9 KiB
Go
297 lines
8.9 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"embed"
|
|
"html/template"
|
|
"io/fs"
|
|
"log"
|
|
"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),
|
|
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. 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)
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// It 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.
|
|
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 {
|
|
http.Error(w, "chapter must be a non-negative number", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
b.LastChapter = raw
|
|
b.LastChapterNum = num
|
|
b.LastChapterURL = ""
|
|
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)
|
|
}
|