3a83161b1c
One commit (`af07314`), three strands of browser-UI work against one design system. `docs/design-system.md` was updated to match the CSS, not the reverse. ## Library (Reader-facing) Findings came out of a two-axis design review of the library surface; the fixes are the P1/P2 set plus the cheap P3s. - **`.chrome` sticks at `top: 0`.** Search and the tab row were unreachable three screens into a 300-item library — exactly where they earn their keep. Everything above them (`.topbar`, `.keyrow`, `.recent`) still scrolls away on purpose: another 150px of permanent chrome on an 844px phone costs more than re-scrolling for an icon reminder. - **One `:focus-visible` ring** (`2px solid var(--paper)`, offset 2px) on the nine controls that defined none and fell back to the UA blue — a colour tuned for neither branch of this palette. `.searchbar` keeps its `:focus-within` border recolour as a resting cue but no longer stands in for the ring. - **Mono labels lift 10px → 11px** everywhere (nine rules). PRODUCT.md names night reading and glare as the usage scene; 10px small-caps was the one place taste overrode the brief. 11px is now a documented floor. - **A card in flight past 2s says `Saving…` and carries `aria-busy`.** htmx sets neither, so the wait — up to its own 15s timeout, and this app is used on a phone in dead zones — was silent in both the visual and the assistive channel. Deliberately `--mute`, not `--ember`: ember means "new chapter" and nothing else. - **Titles clamp at 3 lines**; `.is-new .title` takes `width: fit-content`, or `-webkit-box` stretches the ember underline past the text it is supposed to be sized to. - `.libswitch a` reaches a real 44px under `(pointer: coarse)` — padding plus an 11px line landed at 43. ## /admin - Overview routes into Lanes when a lane is unhealthy, prefixes each figure with its column word on the phone layout that drops the `thead`, labels state cells for a screen reader, and has an empty state where the sites table previously assumed rows. - The admin shell picks up the library's chrome: htmx 15s timeout, the shared `#notice` slot, `#sr-announce`, `filter.js`. - `admin_render_test.go` and `card_render_test.go` render the templates directly, so markup regressions in either surface fail without a browser. ## Login `DISCORD_GUILD_NAME` (optional) names the community on the login screen and in the refusal message, so a stranger knows which Discord to ask for an invite. Unset degrades to a generic label. Neither form names the numeric guild id — that was never actionable, and the gate still reveals nothing about whether a given guild exists. ## Handlers `maxChapterNum` (9999) now bounds **both** typed-chapter paths. `uiChapter` and `adminSeriesCorrectLatest` each parsed a `float64` with no ceiling, so a hand-rolled POST stored `1e308` and every later reader of that row — the poller's `HasNewChapter` comparison, the display string — inherited it. Matches the `max` on the card's chapter input. The API PUT path is deliberately untouched: it carries the userscripts' own scraped numbers, not typed input. ## Verification - `cd backend && go test ./...` green (Docker-backed `pgtest`). `TestChapterOverrideRejectsBadInput` gained `"10000"` and `"1e5"` — both parse fine as `float64`, so they only fail if the bound exists. - Visual: 390×844 dark + light, 1000px and 1440px (`zoom: 1.2`) desktop, against the real templates + real CSS. Measured `chromeTop = 0` at `scrollY 950`, `2px solid rgb(242,236,229)` rings, `content: "Saving…"` at `opacity: 1` after 2.4s, `aria-busy` `true` during / cleared after, `libswitchH = 44` in a `hasTouch` context, no horizontal overflow at either width. - `detect.mjs` on `templates/`: `[]`, exit 0. ## Note on shape The three strands landed as one commit because `admin_series.go` and `style.css` each carry hunks from more than one of them; splitting cleanly would have needed hunk-level surgery. Say the word if you want it split before merge. Reviewed-on: #177 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
674 lines
26 KiB
Go
674 lines
26 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"log"
|
|
"math"
|
|
"mime"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"bookmarkmanager/backend/internal/session"
|
|
"bookmarkmanager/backend/internal/store"
|
|
"bookmarkmanager/backend/internal/token"
|
|
"bookmarkmanager/backend/internal/userscript"
|
|
)
|
|
|
|
//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
|
|
|
|
// maxChapterNum bounds a chapter number a Reader or the owner types. The
|
|
// number reaches the store as a float64, so without a ceiling a hand-rolled
|
|
// POST stores 1e308 and every later reader of that row — the poller's
|
|
// HasNewChapter comparison, the display string — inherits it. Matches the
|
|
// `max` on the card's chapter input; no real series is within three orders of
|
|
// magnitude of it.
|
|
const maxChapterNum = 9999
|
|
|
|
// Handler serves the browser UI: full pages at / and htmx fragments at /ui/.
|
|
// It is a separate handler from api.Handler because the two speak different
|
|
// representations (HTML versus JSON) to different clients under different auth.
|
|
type Handler struct {
|
|
store *store.Store
|
|
// tokenKey derives Readers' userscript credentials (internal/token): the
|
|
// install endpoints render the scripts with the credential inside, which
|
|
// is the one place the UI needs the secret.
|
|
tokenKey []byte
|
|
// mangaUserscriptPath / novelUserscriptPath are the bindmounted script
|
|
// files the install endpoints render — the same files the /u/ download
|
|
// paths serve.
|
|
mangaUserscriptPath string
|
|
novelUserscriptPath string
|
|
tmpl *template.Template
|
|
discord DiscordConfig
|
|
states *oauthStates
|
|
limiter *session.LoginLimiter
|
|
// httpClient is the plain stdlib client that talks to Discord. It is not
|
|
// an injected interface: tests point APIBase at a stub server instead.
|
|
httpClient *http.Client
|
|
// pollerEnabled reports whether latest-chapter polling is switched on in
|
|
// this deployment (LATEST_CHAPTER_POLL_ENABLED) and browserConfigured
|
|
// whether a browser sidecar is configured (BROWSER_WS_URL set). Both are
|
|
// deployment facts resolved by the composition root; the Lanes page (issue
|
|
// #145) reports them from config and derives reachability from the pass
|
|
// log rather than from whether a poller goroutine happened to start.
|
|
pollerEnabled bool
|
|
browserConfigured bool
|
|
}
|
|
|
|
// listView is what every list-rendering template receives.
|
|
type listView struct {
|
|
// Lib is the library this view renders: store.KindManga or store.KindNovel.
|
|
// Manga is the default and carries no query parameter, so every pre-novel
|
|
// URL keeps meaning exactly what it did.
|
|
Lib string
|
|
Tab string // "all", "fav", or "new"
|
|
Recent []store.Bookmark
|
|
Items []store.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
|
|
// OOB marks a render of the chrome partials as an out-of-band swap rather
|
|
// than the inline copy app.html lays out.
|
|
OOB bool
|
|
// Rotated marks the setup panel as having just rotated the credential:
|
|
// it swaps the reinstall warning in over the button row.
|
|
Rotated bool
|
|
// EmptyLibrary means this Reader holds no bookmarks in either library, so
|
|
// the empty state can offer the installs instead of reporting on a filter.
|
|
// It is not "newly registered": a Reader who deletes their last bookmark is
|
|
// in the same position and needs the same links.
|
|
EmptyLibrary bool
|
|
// Owner marks the acting Reader as the deployment's owner, which offers
|
|
// the link to the administrative page. Nothing else in the UI differs.
|
|
Owner bool
|
|
}
|
|
|
|
// PageURL and ListURL are the two link shapes every tab needs. Building them
|
|
// here rather than concatenating in the template is what keeps the library
|
|
// parameter from being dropped on one link out of ten.
|
|
func (v listView) PageURL(tab string) string {
|
|
if v.Lib == store.KindNovel {
|
|
return "/?lib=novel&tab=" + tab
|
|
}
|
|
return "/?tab=" + tab
|
|
}
|
|
|
|
func (v listView) ListURL(tab string) string {
|
|
if v.Lib == store.KindNovel {
|
|
return "/ui/list?lib=novel&tab=" + tab
|
|
}
|
|
return "/ui/list?tab=" + tab
|
|
}
|
|
|
|
// loginView is what the login template receives.
|
|
type loginView struct {
|
|
Error string
|
|
GuildName string
|
|
}
|
|
|
|
// New parses every template up front so a broken one kills the process at
|
|
// startup rather than the first request that touches it.
|
|
//
|
|
// pollerEnabled and browserConfigured are deployment facts the composition
|
|
// root resolves from LATEST_CHAPTER_POLL_ENABLED and BROWSER_WS_URL: the Lanes
|
|
// page (issue #145) reports them and derives browser reachability from the
|
|
// pass log, so no running poller is wired through here at all.
|
|
func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string, pollerEnabled, browserConfigured bool) (*Handler, error) {
|
|
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Handler{
|
|
store: s,
|
|
tokenKey: tokenKey,
|
|
mangaUserscriptPath: mangaPath,
|
|
novelUserscriptPath: novelPath,
|
|
tmpl: tmpl,
|
|
discord: discord,
|
|
states: newOAuthStates(),
|
|
limiter: session.NewLoginLimiter(),
|
|
httpClient: &http.Client{Timeout: discordTimeout},
|
|
pollerEnabled: pollerEnabled,
|
|
browserConfigured: browserConfigured,
|
|
}, nil
|
|
}
|
|
|
|
func (h *Handler) Register(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /{$}", h.index)
|
|
mux.HandleFunc("GET /auth/discord", h.discordStart)
|
|
mux.HandleFunc("GET /auth/discord/callback", h.discordCallback)
|
|
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))
|
|
|
|
// Install endpoints render the script directly under the session: the
|
|
// credential travels inside the served bytes, never in the address bar or
|
|
// the page markup. Updates after install use the credential-bearing /u/
|
|
// path the script embeds, which needs no session.
|
|
mux.HandleFunc("GET /install/manga-bookmark.user.js", h.requireSession(h.installUserscript("manga-bookmark.user.js")))
|
|
mux.HandleFunc("GET /install/novel-bookmark.user.js", h.requireSession(h.installUserscript("novel-bookmark.user.js")))
|
|
mux.HandleFunc("POST /rotate-token", h.requireSession(h.rotateToken))
|
|
// Owner-only: every route that reaches past the acting Reader is gated in
|
|
// one place, so a missing gate is visible in the route list.
|
|
for _, rt := range h.adminRoutes() {
|
|
mux.HandleFunc(rt.pattern, h.requireOwner(rt.handler))
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}))
|
|
}
|
|
|
|
type ctxKey int
|
|
|
|
// readerCtxKey is where requireSession stashes the authenticated Reader id.
|
|
const readerCtxKey ctxKey = iota
|
|
|
|
// sessionReader reports whether the request carries a live session, and for
|
|
// whom. The cookie holds only the session id; the row behind it is looked up
|
|
// on every request, so deleting a session takes effect immediately. Expiry is
|
|
// enforced here, in the store, which also removes rows that have lapsed.
|
|
func (h *Handler) sessionReader(r *http.Request) (int64, bool) {
|
|
c, err := r.Cookie(session.CookieName)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
sess, ok, err := h.store.GetSession(c.Value, time.Now())
|
|
if err != nil {
|
|
log.Printf("session lookup: %v", err)
|
|
return 0, false
|
|
}
|
|
return sess.ReaderID, ok
|
|
}
|
|
|
|
// 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 *Handler) requireSession(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
readerID, ok := h.sessionReader(r)
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
next(w, r.WithContext(context.WithValue(r.Context(), readerCtxKey, readerID)))
|
|
}
|
|
}
|
|
|
|
// readerOf returns the authenticated Reader id requireSession stashed.
|
|
func readerOf(r *http.Request) int64 { return r.Context().Value(readerCtxKey).(int64) }
|
|
|
|
func (h *Handler) 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 *Handler) index(w http.ResponseWriter, r *http.Request) {
|
|
readerID, ok := h.sessionReader(r)
|
|
if !ok {
|
|
h.render(w, http.StatusOK, "login", loginView{GuildName: h.discord.GuildName})
|
|
return
|
|
}
|
|
view, err := h.buildListView(readerID, libOf(r.URL.Query().Get("lib")), r.URL.Query().Get("tab"))
|
|
if err != nil {
|
|
log.Printf("index: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// The owner's page differs only by the link to the administrative page:
|
|
// the roster lives there now, so the page read every day is only reading.
|
|
view.Owner = readerID == h.store.OwnerID()
|
|
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 []store.Bookmark, keep func(store.Bookmark) bool) []store.Bookmark {
|
|
out := []store.Bookmark{}
|
|
for _, b := range all {
|
|
if keep(b) {
|
|
out = append(out, b)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// kindOf reads a bookmark's library. A row cached or written before the kind
|
|
// column existed has none; every one of those is manga, which is what the
|
|
// column default says too.
|
|
func kindOf(b store.Bookmark) string {
|
|
if b.Kind == "" {
|
|
return store.KindManga
|
|
}
|
|
return b.Kind
|
|
}
|
|
|
|
// libOf normalises the query parameter. Anything that is not the novel library
|
|
// is the manga one, so a typo lands on the default page rather than an empty
|
|
// list.
|
|
func libOf(q string) string {
|
|
if q == store.KindNovel {
|
|
return store.KindNovel
|
|
}
|
|
return store.KindManga
|
|
}
|
|
|
|
// buildListView loads one reader's list once and derives both the tab-filtered
|
|
// items and the recent strip from it.
|
|
//
|
|
// Archived 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". There is no Finished tab: finished is a fact about
|
|
// the Series, not a bookmark bucket (issue #157).
|
|
func (h *Handler) buildListView(readerID int64, lib, tab string) (listView, error) {
|
|
all, err := h.store.List(readerID) // already ordered updated_at DESC
|
|
if err != nil {
|
|
return listView{}, err
|
|
}
|
|
// Taken before the filter narrows the slice: a Reader with novels but no
|
|
// manga has a working install already, and does not need to be told to go
|
|
// and get one.
|
|
emptyLibrary := len(all) == 0
|
|
// Narrow to one library first: reading, withNew and recent all derive from
|
|
// this slice, so doing it later would let the other library's rows into the
|
|
// strip and the Updated badge.
|
|
all = filterBookmarks(all, func(b store.Bookmark) bool { return kindOf(b) == lib })
|
|
|
|
// Novels do not offer an Updated tab, so a hand-typed one lands on All.
|
|
if lib == store.KindNovel && tab == "new" {
|
|
tab = "all"
|
|
}
|
|
reading := filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusReading })
|
|
|
|
withNew := filterBookmarks(reading, func(b store.Bookmark) bool { return b.HasNewChapter() })
|
|
|
|
var items []store.Bookmark
|
|
switch tab {
|
|
case "fav":
|
|
items = filterBookmarks(reading, func(b store.Bookmark) bool { return b.Favorite })
|
|
case "new":
|
|
items = withNew
|
|
case "archived":
|
|
items = filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusArchived })
|
|
default:
|
|
tab = "all"
|
|
items = reading
|
|
}
|
|
// The strip is scoped to series with a chapter waiting, which is the one
|
|
// question the list below it does not already answer: the list is ordered by
|
|
// reading recency, so the head of it *is* the strip whenever the strip is
|
|
// just "the most recent rows". Only on All — on Updated it would render the
|
|
// same set twice, and on the other tabs it would contradict the bucket.
|
|
//
|
|
// It therefore disappears entirely on a library with nothing new. That is
|
|
// the intended reading: an empty strip has nothing to say, and the ~240px it
|
|
// costs on a phone belongs to the list.
|
|
var recent []store.Bookmark
|
|
if tab == "all" {
|
|
recent = withNew
|
|
if len(recent) > RecentCount {
|
|
recent = recent[:RecentCount]
|
|
}
|
|
}
|
|
return listView{Lib: lib, Tab: tab, Recent: recent, Items: items,
|
|
NewCount: len(withNew), EmptyLibrary: emptyLibrary}, nil
|
|
}
|
|
|
|
func (h *Handler) uiList(w http.ResponseWriter, r *http.Request) {
|
|
view, err := h.buildListView(readerOf(r), libOf(r.URL.Query().Get("lib")), 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)
|
|
// The chrome is outside this response's swap target, so without this a tab
|
|
// switch would leave the strip and badge from whichever tab the page was
|
|
// loaded on — the same URL would render differently depending on how the
|
|
// reader got there.
|
|
h.writeChromeOOB(w, view)
|
|
}
|
|
|
|
// currentTab is the tab the reader is looking at, read from htmx's own header,
|
|
// so out-of-band chrome is rebuilt for that view rather than for a default.
|
|
func currentTab(r *http.Request) string {
|
|
u, err := url.Parse(r.Header.Get("HX-Current-URL"))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return u.Query().Get("tab")
|
|
}
|
|
|
|
// currentLib is the library the reader is looking at, read from htmx's own
|
|
// header for the same reason currentTab is: out-of-band chrome must be rebuilt
|
|
// for that view rather than for the default one.
|
|
func currentLib(r *http.Request) string {
|
|
u, err := url.Parse(r.Header.Get("HX-Current-URL"))
|
|
if err != nil {
|
|
return store.KindManga
|
|
}
|
|
return libOf(u.Query().Get("lib"))
|
|
}
|
|
|
|
// writeChromeOOB appends the regions that live outside #list — the recent
|
|
// strip, the Updated badge and the action key — as out-of-band swaps, so a
|
|
// mutation cannot leave them describing the library as it was before the tap.
|
|
// The key is in here because it is tab-shaped too: the archived bucket swaps
|
|
// Archive for Restore.
|
|
func (h *Handler) writeChromeOOB(w http.ResponseWriter, view listView) {
|
|
view.OOB = true
|
|
names := []string{"recent", "keyrow"}
|
|
if view.Lib == store.KindManga {
|
|
names = append(names, "newcount")
|
|
}
|
|
for _, name := range names {
|
|
if err := h.tmpl.ExecuteTemplate(w, name, view); err != nil {
|
|
// The card is already written; stale chrome beats a torn response.
|
|
log.Printf("render %s oob: %v", name, err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// refreshChrome rebuilds the chrome for the reader's current tab after a
|
|
// mutation and appends it to the response.
|
|
func (h *Handler) refreshChrome(w http.ResponseWriter, r *http.Request) {
|
|
view, err := h.buildListView(readerOf(r), currentLib(r), currentTab(r))
|
|
if err != nil {
|
|
log.Printf("ui chrome: %v", err)
|
|
return
|
|
}
|
|
h.writeChromeOOB(w, view)
|
|
}
|
|
// writeAnnounceOOB appends a visually-hidden live region update out-of-band,
|
|
// so a successful mutation announces itself without moving focus.
|
|
func (h *Handler) writeAnnounceOOB(w http.ResponseWriter, msg string) {
|
|
fmt.Fprintf(w, `<div id="sr-announce" class="sr-only" role="status" aria-live="polite" aria-atomic="true" hx-swap-oob="true">%s</div>`, template.HTMLEscapeString(msg))
|
|
}
|
|
func (h *Handler) writeNoticeOOB(w http.ResponseWriter, msg string) {
|
|
fmt.Fprintf(w, `<p id="notice" class="notice" hx-swap-oob="true">%s</p>`, template.HTMLEscapeString(msg))
|
|
}
|
|
|
|
// renderLogin renders the login page with an error message, for refused or
|
|
// failed sign-ins. Every message is author-written text — nothing Discord
|
|
// supplied is ever interpolated into a page.
|
|
func (h *Handler) renderLogin(w http.ResponseWriter, status int, msg string) {
|
|
h.render(w, status, "login", loginView{Error: msg, GuildName: h.discord.GuildName})
|
|
}
|
|
|
|
// logout revokes the session row and clears the cookie in one step: the next
|
|
// request finds no row and is rejected.
|
|
func (h *Handler) logout(w http.ResponseWriter, r *http.Request) {
|
|
if c, err := r.Cookie(session.CookieName); err == nil {
|
|
if err := h.store.DeleteSession(c.Value); err != nil {
|
|
log.Printf("delete session: %v", err)
|
|
}
|
|
}
|
|
session.ClearCookie(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 *Handler) loadForMutation(w http.ResponseWriter, r *http.Request) (store.Bookmark, bool) {
|
|
key := r.PathValue("key")
|
|
if key == "" {
|
|
http.Error(w, "missing key", http.StatusBadRequest)
|
|
return store.Bookmark{}, false
|
|
}
|
|
b, ok, err := h.store.Get(readerOf(r), key)
|
|
if err != nil {
|
|
log.Printf("ui get %q: %v", key, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return store.Bookmark{}, false
|
|
}
|
|
if !ok {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return store.Bookmark{}, false
|
|
}
|
|
return b, true
|
|
}
|
|
|
|
// saveAndRenderCard upserts and renders the row as stored, then refreshes the
|
|
// chrome. 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. That much is deliberate — the card showing its new
|
|
// state is the feedback for the tap. The strip and the badge are not: they
|
|
// describe the whole library, so they are rebuilt out of band on every
|
|
// mutation, at the cost of one extra list read per toggle.
|
|
func (h *Handler) saveAndRenderCard(w http.ResponseWriter, r *http.Request, b store.Bookmark) {
|
|
stored, err := h.store.Upsert(readerOf(r), 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)
|
|
h.refreshChrome(w, r)
|
|
}
|
|
|
|
// 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 *Handler) 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()
|
|
msg := ""
|
|
if b.Favorite {
|
|
msg = fmt.Sprintf("Added %s to favourites", b.Title)
|
|
} else {
|
|
msg = fmt.Sprintf("Removed %s from favourites", b.Title)
|
|
}
|
|
h.saveAndRenderCard(w, r, b)
|
|
h.writeAnnounceOOB(w, msg)
|
|
}
|
|
|
|
// uiStatus moves a bookmark between the two lifecycle buckets. Finished is not
|
|
// one of them: it is a fact about the Series, decided from the admin surface,
|
|
// so the web UI's per-bookmark control cannot set it (issue #157).
|
|
//
|
|
// last_chapter_num is untouched, so Upsert keeps the stored updated_at and the
|
|
// list does not reorder.
|
|
func (h *Handler) 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 store.StatusReading, store.StatusArchived:
|
|
b.Status = s
|
|
default:
|
|
http.Error(w, "invalid status", http.StatusBadRequest)
|
|
return
|
|
}
|
|
b.UpdatedAt = time.Now().UnixMilli()
|
|
msg := ""
|
|
if b.Status == store.StatusArchived {
|
|
msg = fmt.Sprintf("Archived %s", b.Title)
|
|
} else {
|
|
msg = fmt.Sprintf("Restored %s", b.Title)
|
|
}
|
|
h.saveAndRenderCard(w, r, b)
|
|
h.writeAnnounceOOB(w, msg)
|
|
}
|
|
|
|
// 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 *Handler) 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 || num > maxChapterNum || math.IsNaN(num) || math.IsInf(num, 0) {
|
|
http.Error(w, "chapter must be a non-negative number below 10000", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if num != b.LastChapterNum {
|
|
b.LastChapterURL = ""
|
|
b.LastChapter = raw
|
|
b.LastChapterNum = num
|
|
}
|
|
b.UpdatedAt = time.Now().UnixMilli()
|
|
msg := fmt.Sprintf("Updated %s to chapter %s", b.Title, raw)
|
|
h.saveAndRenderCard(w, r, b)
|
|
h.writeAnnounceOOB(w, msg)
|
|
}
|
|
|
|
// 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 *Handler) uiDelete(w http.ResponseWriter, r *http.Request) {
|
|
b, ok := h.loadForMutation(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := h.store.Delete(readerOf(r), b.Key); err != nil {
|
|
log.Printf("ui delete %q: %v", b.Key, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
msg := fmt.Sprintf("Removed %s", b.Title)
|
|
h.writeAnnounceOOB(w, msg)
|
|
h.writeNoticeOOB(w, msg)
|
|
view, err := h.buildListView(readerOf(r), currentLib(r), currentTab(r))
|
|
if err != nil {
|
|
log.Printf("ui chrome: %v", err)
|
|
return
|
|
}
|
|
h.writeChromeOOB(w, view)
|
|
if len(view.Items) == 0 {
|
|
fmt.Fprint(w, `<main id="list" class="list" tabindex="-1" hx-swap-oob="true">`)
|
|
if err := h.tmpl.ExecuteTemplate(w, "list", view); err != nil {
|
|
log.Printf("render list oob: %v", err)
|
|
return
|
|
}
|
|
fmt.Fprint(w, `</main>`)
|
|
}
|
|
}
|
|
|
|
// installUserscript renders the bindmounted script with the acting Reader's
|
|
// derived credential substituted in. The credential is derived, not stored,
|
|
// so installs work after any restart; the Reader never types or copies it —
|
|
// clicking Install is the whole setup.
|
|
//
|
|
// ?download=1 forces a save instead. Mobile Violentmonkey (Chromium) does not
|
|
// intercept navigation to a .user.js URL, so the Install link only renders the
|
|
// source as text there; the Reader needs the file on disk to add it by hand.
|
|
func (h *Handler) installUserscript(name string) http.HandlerFunc {
|
|
path := h.mangaUserscriptPath
|
|
if name == "novel-bookmark.user.js" {
|
|
path = h.novelUserscriptPath
|
|
}
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
discordID, epoch, err := h.store.ReaderTokenInfo(readerOf(r))
|
|
if err != nil {
|
|
log.Printf("install %s: %v", name, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if r.URL.Query().Has("download") {
|
|
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
|
|
}
|
|
userscript.Render(w, r, path, token.Token(h.tokenKey, discordID, epoch))
|
|
}
|
|
}
|
|
|
|
// rotateToken issues the acting Reader a new credential: the epoch bumps and
|
|
// the stored hash is rewritten, so the old credential stops authenticating
|
|
// the moment the statement commits. Every device must reinstall, or its
|
|
// script keeps failing silently — the setup panel states that warning next
|
|
// to the button, and the response repeats it as confirmation.
|
|
func (h *Handler) rotateToken(w http.ResponseWriter, r *http.Request) {
|
|
readerID := readerOf(r)
|
|
discordID, epoch, err := h.store.ReaderTokenInfo(readerID)
|
|
if err != nil {
|
|
log.Printf("rotate token: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// The hash is computed for epoch+1 and guarded by it in the store, so a
|
|
// concurrent rotation cannot leave the stored hash describing another
|
|
// epoch.
|
|
if err := h.store.RotateToken(readerID, epoch, token.Hash(token.Token(h.tokenKey, discordID, epoch+1))); err != nil {
|
|
log.Printf("rotate token: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
view := listView{Lib: store.KindManga, Rotated: true}
|
|
h.render(w, http.StatusOK, "setup", view)
|
|
}
|