741b23322b
Fixes five reported symptoms across comix.to and kagane.to. Diagnosing them turned up two latent bugs underneath, both of which had to be fixed for the kagane cover work to function at all.
## Reported symptoms and their causes
| # | Symptom | Cause |
|---|---------|-------|
| 1 | comix bookmark titled `Comix - Read Comics online for free` | comix is an SPA that rewrites `document.title` on client routing but never touches the server-rendered `og:title`. The adapter read `og:title`, so a cold load stored the homepage's title. |
| 2 | next comix bookmark gets the *previous* series' title | Same cause. After an in-page hop, `og:title` still holds whatever page loaded first. |
| 3 | comix cover shows the placeholder | comix serves no `og:image` at all, so `coverFromPage()` had nothing to read. |
| 4 | kagane chapter never appears in the bookmark list | Reader URLs carry no chapter number, so it is parsed out of `og:title`. Volume-numbered series render `"<Series> - Volume <v> Chapter <n>"`, which the suffix regex did not match, so `chapterNum` came back null and nothing was recorded. |
| 5 | kagane title includes the chapter, e.g. `SP Baby - Volume 1 Chapter 1` | Same unmatched regex — the tail was never stripped. One fix covers 4 and 5. |
| 6 | kagane cover blocked in the web UI | kagane serves covers behind its Cloudflare challenge **and** with `cross-origin-resource-policy: same-origin`. No `<img>` on the UI's origin can load one even from a browser holding the clearance cookie. Hot-linking cannot be made to work. |
## What changed
**Userscript.** comix titles now come from `document.title` with the chapter page's `" - Ch.<n>"` tail stripped, and the cover is the `img` whose `alt` matches the cleaned title. comix fills `document.title` a beat *after* the URL changes — later than the nav watcher's 300 ms snapshot — so the watcher also re-detects when the `detect()` signature changes, not only when the URL does. The kagane suffix regex takes an optional `Volume <v> ` segment. All three page shapes were captured live on 2026-08-08 and pinned as regression tests.
**Cover proxy.** `Bookmark.CoverURL()` rewrites a stored kagane `og:image` to `/img/kagane/{id}`; templates render `.CoverURL` instead of `.Cover`. The endpoint is session-gated like every other UI route and fetches through the shared headless browser, which is same-origin with kagane and so satisfies both the challenge and the CORP header. Results are memoised in-process, so a cover costs one navigation per deployment lifetime. With `BROWSER_WS_URL` unset the endpoint answers 404 rather than reaching for a nil fetcher — the same degrade-to-userscript behaviour the poller already has.
The image id is matched against a UUID regex before it reaches the browser. That gate is load-bearing rather than tidiness: the cover is a stored client-supplied string, so an unvalidated one turns this endpoint into an SSRF primitive aimed at the deployment's own network. `ServeMux` path-cleans a traversal into a redirect before the handler runs, but the handler does not depend on that, and a test pins it.
## Two latent bugs found underneath
**`BrowserFetcher.run` never let a challenge solve.** It navigated, waited for `body`, read once, and closed the tab — roughly half a second end to end. The Cloudflare interstitial has a `body` too, so `WaitReady` was satisfied by the challenge page itself. This made the challenge *unclearable* rather than merely slow: an interstitial needs several seconds of a live page to solve itself and write clearance into the browser's shared cookie jar, so tearing the tab down first means every subsequent call is challenged exactly like the one before it. `run` now holds one tab and re-reads until the caller's predicate reports an answer, bounded by `challengeTimeout` and the caller's own deadline. Exhausting the budget maps back to the 403 the poller already expects, keeping a challenged site distinct from a broken transport.
**`chromedp/headless-shell` cannot clear kagane's challenge at all.** It is a stripped Chrome build and the tells are structural rather than a header: `navigator.webdriver` is true, the plugin list is empty, and the client hints are Chromium- rather than Chrome-branded. Overriding `webdriver` through CDP was tried on its own and changed nothing.
All measured 2026-08-08 from one IP against the same cover, so the comparisons are like for like:
| Browser | Result |
|---------|--------|
| `chromedp/headless-shell:stable` | never cleared (90 s) |
| `zenika/alpine-chrome` | never cleared — ships Chrome 124, old enough that Cloudflare refuses it and old enough to break chromedp's CDP structs |
| `google-chrome`, default UA | never cleared (60 s) — `--headless=new` advertises `HeadlessChrome` |
| `google-chrome`, stock UA, `TZ=UTC` | never cleared (90 s) |
| `google-chrome`, stock UA, any non-UTC `TZ` | **cleared in ~4 s** |
Both remaining tells are load-bearing, and each was tested in isolation. `chrome/` is a Debian image with `google-chrome-stable`, a UA whose version is read back out of the binary at startup (a hardcoded one would drift out of step with the `Sec-CH-UA` hints on the next Chrome update and become a fresh tell), and no `--enable-automation`.
### The timezone tell: UTC, not a country mismatch
The first pass concluded the zone had to match the egress IP's country. Re-measuring against the actual deployment case shows that was wrong, and the correction is in `1552dd1`.
The original inference read the host's `/etc/timezone` (`Asia/Bangkok`) and assumed a Thai egress. It isn't — this host egresses from an Indonesian IP. `Asia/Bangkok` cleared not because it matched a country but because it simply isn't UTC, and the two share +07, which hid the distinction. Same container, same Indonesian IP:
| `TZ` | Result |
|------|--------|
| `UTC` | never cleared (60 s, **twice**) |
| `Asia/Jakarta` | cleared in 4 s |
| `America/New_York` | cleared in 4 s |
`America/New_York` matches neither the country nor the offset nor the hemisphere and clears just as fast. A UTC clock is itself the bot signal — Cloudflare scores it as the datacenter default — and any real zone satisfies the check. `BROWSER_TZ` therefore needs a plausible zone, not a geolocated one, and a deployment that changes region need not keep it in sync.
One sharp edge remains: the usual `-v /etc/localtime:/etc/localtime:ro` does **not** work. Chrome resolves the zone through ICU, which takes the name from that path's symlink target and ignores the file's contents, so glibc reports the host zone while Chrome still reports UTC. `/etc/timezone` carries the name and is mounted instead.
Chrome also binds its DevTools port to loopback and silently ignores `--remote-debugging-address`, which is why headless-shell fronted it with socat. This image does the same, so it stays a drop-in: the compose service keeps the `headless-shell` name and its pinned address, and `BROWSER_WS_URL` is unchanged.
## Verification
```
go test ./... all packages ok
node --test 37 + 12 pass, 0 fail
SMOKE_BROWSER_WS_URL=... go test -run TestSmokeKagane ./internal/latest
TestSmokeKaganeImage PASS (5.29s) fetched 56710 bytes of image/webp
TestSmokeKaganeGet PASS (1.17s) status=200, real chapter-list JSON
```
The smoke test ran against the exact compose configuration — built image, empty `BROWSER_TZ`, `/etc/timezone` mounted, cold profile — hitting real kagane.to. It skips unless `SMOKE_BROWSER_WS_URL` names a sidecar, so `go test ./...` stays hermetic and Docker-only.
A red smoke run means the challenge is not clearing from that IP, which is a live, time-varying fact to re-check rather than necessarily a defect.
## Security invariants
- Auth unchanged. `/img/kagane/{id}` is session-gated by `requireSession`, the same guard as every other UI route.
- Outbound fetch gated: the id is UUID-validated before it reaches the browser, keeping the existing rule that a client-supplied string never selects a fetch target unchecked.
- No new secrets, no new logging of credentials, no change to CORS, sessions, or crypto.
- Templates still escape everything; `.CoverURL` returns a plain string and is not wrapped in `template.HTML`/`URL`.
- One new dependency-free image (`chrome/`) built from Debian plus Google's own apt repo; no new Go modules.
## Deploying
Needs `docker compose build headless-shell`.
**A UTC host must set `BROWSER_TZ`, or kagane silently stops working.** With it unset the sidecar falls back to the host's `/etc/timezone`; on a UTC server that yields UTC, which is the one value that never clears. Any real zone works — `BROWSER_TZ=Asia/Jakarta` for the current deployment. `.env.example` now documents this; it previously did not mention the knob at all.
Only the browser sidecar reads `BROWSER_TZ`. The backend keeps its UTC clock, and stored timestamps are unix ms, so nothing else shifts.
## Deliberately not done
Retry/backoff around the cover proxy, and a panel-side cover fix. The panel renders no covers, and covers cache in-process after the first fetch. Worth adding if kagane starts rate-limiting.
## Correction after review of the deployment case
`1552dd1` was added after the branch was first pushed: the deployment host runs UTC with an Indonesian egress IP, which prompted re-measuring the timezone claim and falsifying it. The earlier commits' reasoning is left intact rather than rebased away, so the diagnostic trail — including the wrong turn and what disproved it — stays readable.
Reviewed-on: #37
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
667 lines
25 KiB
Go
667 lines
25 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"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
|
|
|
|
// 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
|
|
// covers proxies kagane cover images, which no browser can load directly.
|
|
// Nil disables the endpoint — see CoverFetcher.
|
|
covers CoverFetcher
|
|
coverCache coverCache
|
|
}
|
|
|
|
// 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 unlocks
|
|
// the Readers panel. Nothing else in the UI differs.
|
|
Owner bool
|
|
// Readers is the owner's roster, populated only for the owner's own page
|
|
// render and the revocation fragment. OwnerID travels with it so the roster
|
|
// can tell the owner's own row apart from the Readers they may revoke.
|
|
Readers []store.ReaderSummary
|
|
OwnerID int64
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// New parses every template up front so a broken one kills the process at
|
|
// startup rather than the first request that touches it.
|
|
func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string, covers CoverFetcher) (*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},
|
|
covers: covers,
|
|
}, 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))
|
|
|
|
// Session-gated like every other UI route: the deployment proxies kagane's
|
|
// images for its own Readers, not for the internet.
|
|
mux.HandleFunc("GET /img/kagane/{id}", h.requireSession(h.kaganeCover))
|
|
|
|
// 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: the one place the UI crosses the Reader boundary.
|
|
mux.HandleFunc("POST /readers/{id}/revoke", h.requireSession(h.revokeReaderSessions))
|
|
}
|
|
|
|
// 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{})
|
|
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
|
|
}
|
|
if readerID == h.store.OwnerID() {
|
|
view.Owner, view.OwnerID = true, readerID
|
|
if view.Readers, err = h.store.Readers(); err != nil {
|
|
log.Printf("index readers: %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 []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 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 *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 })
|
|
case "finished":
|
|
items = filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusFinished })
|
|
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: archived and finished swap
|
|
// 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)
|
|
}
|
|
|
|
// 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})
|
|
}
|
|
|
|
// 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()
|
|
h.saveAndRenderCard(w, r, 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 *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, store.StatusFinished:
|
|
b.Status = s
|
|
default:
|
|
http.Error(w, "invalid status", http.StatusBadRequest)
|
|
return
|
|
}
|
|
b.UpdatedAt = time.Now().UnixMilli()
|
|
h.saveAndRenderCard(w, r, 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 *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 || 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, r, 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 *Handler) 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(readerOf(r), 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)
|
|
// The empty body is what removes the card; the chrome still has to be told
|
|
// the library got smaller.
|
|
h.refreshChrome(w, r)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// revokeReaderSessions logs one Reader out of every browser they are signed
|
|
// in on. Owner-only: it reaches across the Reader boundary every other handler
|
|
// respects, so the guard is a comparison against the seeded owner rather than
|
|
// a role a Reader could acquire. A non-owner gets 404 — the panel does not
|
|
// exist for them, so neither should the endpoint.
|
|
func (h *Handler) revokeReaderSessions(w http.ResponseWriter, r *http.Request) {
|
|
if readerOf(r) != h.store.OwnerID() {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
target, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "bad reader id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// The owner is not one of the Readers this endpoint reaches: revoking
|
|
// themselves would sign out the browser making the request, which is what
|
|
// logout is for. The roster hides the button; this refuses the hand-rolled
|
|
// POST behind it.
|
|
if target == h.store.OwnerID() {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if err := h.store.DeleteReaderSessions(target); err != nil {
|
|
log.Printf("revoke sessions: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
readers, err := h.store.Readers()
|
|
if err != nil {
|
|
log.Printf("revoke sessions: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
h.render(w, http.StatusOK, "readers", listView{Owner: true, Readers: readers, OwnerID: h.store.OwnerID()})
|
|
}
|