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 // readerID is the owner Reader's id, the only Reader that can exist // while registration is closed (issue #23). Every session row points at // it, so it is also the Reader the UI acts as. readerID int64 // 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 } // 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 } // 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, readerID int64, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string) (*Handler, error) { tmpl, err := template.ParseFS(templateFS, "templates/*.html") if err != nil { return nil, err } return &Handler{ store: s, readerID: readerID, tokenKey: tokenKey, mangaUserscriptPath: mangaPath, novelUserscriptPath: novelPath, tmpl: tmpl, discord: discord, states: newOAuthStates(), limiter: session.NewLoginLimiter(), httpClient: &http.Client{Timeout: discordTimeout}, }, 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)) } // 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 } 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 } // 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)}, 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) }