b0bf6fe770
Guild membership is now the whole gate: discordCallback checks membership
(and DISCORD_REQUIRED_ROLE when set), then Store.EnsureReader creates the
Reader on first sight and returns the same row on every later login. The
refusal returns before EnsureReader, so nothing is created as a side
effect of being turned away. OWNER_DISCORD_ID keeps seeding the owner, but
only as the administrator — it no longer gates sign-in.
The cutover grace path is gone with it: API_TOKEN, API_TOKEN_GRACE_UNTIL
and the legacy branch in httpmw.ResolveReader are deleted, so a credential
authenticates exactly one Reader or nothing. That also lets
userscript.Handler drop the re-derivation — the resolved path segment is
already the credential to substitute.
New surfaces: an empty library offers both install links instead of
describing a filter (listView.Fresh, which also hides the action key it has
nothing to name), and the owner alone gets a Readers panel with
POST /readers/{id}/revoke (404 for anyone else) to sign a Reader out
everywhere.
Isolation is asserted from both directions rather than by counting one
Reader's rows, and the shared-series invariant is pinned: two Readers on
one series produce one series row, two independent progresses, one poll
per due cycle, and one Reader's delete leaves the other's bookmark and the
poll intact.
647 lines
24 KiB
Go
647 lines
24 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
|
|
}
|
|
|
|
// 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
|
|
// Fresh means this Reader has no bookmarks at all, in either library — a
|
|
// brand-new registration rather than an empty bucket. The empty state then
|
|
// explains how a library gets filled instead of describing a filter.
|
|
Fresh 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.
|
|
Readers []store.ReaderSummary
|
|
}
|
|
|
|
// 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) (*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},
|
|
}, 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: 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 = true
|
|
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
|
|
}
|
|
// Fresh is about the Reader, not the library, so it is taken before the
|
|
// filter narrows the slice: it decides whether an empty list reads as
|
|
// "install the scripts" or "this bucket is empty".
|
|
fresh := 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), Fresh: fresh}, 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
|
|
}
|
|
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})
|
|
}
|