Wave 2: correctness and security
TouchSession throttle (internal/auth)
- Middleware wrote sessions.last_seen on EVERY authenticated request, taking
SQLite's single write lock each time. For media that is severe: one seek in
an audio file fires several Range requests, each taking the write lock and
competing with the scanner and progress saves.
- Now touched at most once per 5 minutes, decided from the LastSeen already
loaded by SessionByToken, so no extra query. A zero interval degrades to the
old per-request behaviour, so a hand-built Authenticator cannot silently stop
updating.
- Verified end to end with an AFTER UPDATE trigger: 20 requests now cause 0
session writes, down from 20.
Response writer forwarding (internal/httpapi)
- statusRecorder wrapped every ResponseWriter but implemented only Write and
WriteHeader, so it did not implement http.Flusher. That silently disabled
early flushing (progressive audio/video) and io.ReaderFrom, which is what
lets io.Copy use sendfile for multi-GB files. Now forwards Flush, Hijack and
ReadFrom, and WriteHeader is idempotent.
Security headers (internal/httpapi)
- Added nosniff, X-Frame-Options: DENY, Referrer-Policy and a CSP. Outermost in
the chain so a recovered panic's 500 still carries them.
- CORRECTION to an earlier claim: I said a malicious EPUB was stored XSS.
That was wrong. epub.js sets iframe sandbox to "allow-same-origin" and only
appends "allow-scripts" when settings.allowScriptedContent is true, which
defaults to false and Reader.svelte never sets. EPUB content cannot execute
script. The CSP is still worth having as defence in depth, but that specific
justification does not hold.
Final flush on teardown (AudiobookPlayer, Reader)
- The last playback/reading position was saved with a fire-and-forget fetch in
onDestroy, which the browser cancels on navigate-away — losing exactly the
thing worth persisting. Now persisted via fetch with keepalive: true, wired
to pagehide and visibilitychange(hidden), latched so the overlapping events
fire one request, and reset when the page becomes visible again so a bfcache
restore still flushes later. sendBeacon was not usable: it is POST-only and
progress is PUT-only.
- Also removed the queueMicrotask track-swap race in AudiobookPlayer: play() is
now driven by onLoadedMetadata once the new src has actually loaded.
Honest readers (Reader, ComicReader)
- registry.go advertises .mobi/.azw3/.fb2 under ebooks, but every ebooks item
was handed to epub.js, which cannot parse them. Now only .epub uses epub.js;
other containers show a message naming the format plus a download link. Items
stay catalogued and downloadable rather than silently vanishing.
- .cbr/.rar comics say the archive format is unsupported instead of surfacing
the server's raw 422 (the server reads zip only).
Verified in a real browser, not just compiled: all six reader surfaces render
(epub iframe, cbz images, audio element, photo, pdf canvas) with zero CSP
violations; .mobi shows the honest message with no epub.js iframe. gofmt/vet
clean, 14 packages pass, svelte-check 0/0.
Known issue, NOT introduced here and NOT fixed: the EPUB reader logs
"Error: No Element Provided" x3 and a TypeError in epub.js injectIdentifier
(doc.getElementsByTagName("head")[0] undefined). Proven pre-existing by A/B
against c50b3cf, and not caused by the test file (spec-correct XHTML) or by
caching (cold browser). Non-fatal: the book still renders. Needs its own
investigation.
This commit is contained in:
parent
c50b3cfb3b
commit
00c1c0d619
7 changed files with 472 additions and 35 deletions
|
|
@ -389,6 +389,104 @@ func TestBearerTokenAlsoWorks(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// sessionTouchCounter installs a trigger that counts every UPDATE to
|
||||
// sessions.last_seen and returns a reader for the running total. Counting in
|
||||
// SQLite rather than by diffing timestamps is exact even when two touches land
|
||||
// in the same millisecond, which is common in a tight request loop.
|
||||
func sessionTouchCounter(t *testing.T, db *sql.DB) func() int {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if _, err := db.ExecContext(ctx, `CREATE TABLE session_touch_count (n INTEGER NOT NULL)`); err != nil {
|
||||
t.Fatalf("create touch counter: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO session_touch_count (n) VALUES (0)`); err != nil {
|
||||
t.Fatalf("seed touch counter: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx,
|
||||
`CREATE TRIGGER count_session_touch AFTER UPDATE OF last_seen ON sessions
|
||||
BEGIN UPDATE session_touch_count SET n = n + 1; END`); err != nil {
|
||||
t.Fatalf("create touch trigger: %v", err)
|
||||
}
|
||||
return func() int {
|
||||
var n int
|
||||
if err := db.QueryRowContext(ctx, `SELECT n FROM session_touch_count`).Scan(&n); err != nil {
|
||||
t.Fatalf("read touch counter: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareThrottlesSessionTouch(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
a := NewAuthenticator(db, "vellum_session", time.Hour, false, log)
|
||||
|
||||
u := mustCreateUser(t, db, "kyle", false)
|
||||
token, err := CreateSession(ctx, db, u.ID, time.Hour, "", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
sess, err := SessionByToken(ctx, db, token)
|
||||
if err != nil {
|
||||
t.Fatalf("SessionByToken: %v", err)
|
||||
}
|
||||
|
||||
touches := sessionTouchCounter(t, db)
|
||||
|
||||
// A trivial terminal handler: the point is only that the request passes
|
||||
// through Middleware, which is where the touch decision lives.
|
||||
h := a.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
burst := func() {
|
||||
t.Helper()
|
||||
for i := 0; i < 10; i++ {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/media/track", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("burst request %d status = %d, want 200", i, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A freshly created session has a recent last_seen, so a 10-request burst
|
||||
// must not take the write lock at all.
|
||||
burst()
|
||||
if got := touches(); got != 0 {
|
||||
t.Errorf("recent last_seen: %d touches after a 10-request burst, want 0", got)
|
||||
}
|
||||
|
||||
// Backdate last_seen past touchEvery, then reset the counter. The first
|
||||
// request of the next burst sees a stale value and must touch; every later
|
||||
// request then sees a fresh one and must be throttled — so the whole burst
|
||||
// writes exactly once, not ten times.
|
||||
staleTime := time.Now().Add(-10 * time.Minute).UTC()
|
||||
if _, err := db.ExecContext(ctx, `UPDATE sessions SET last_seen = ? WHERE id = ?`,
|
||||
core.Stamp(staleTime), sess.ID); err != nil {
|
||||
t.Fatalf("backdate last_seen: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `UPDATE session_touch_count SET n = 0`); err != nil {
|
||||
t.Fatalf("reset touch counter: %v", err)
|
||||
}
|
||||
|
||||
burst()
|
||||
if got := touches(); got != 1 {
|
||||
t.Errorf("stale last_seen: %d touches after a 10-request burst, want exactly 1", got)
|
||||
}
|
||||
|
||||
// The single touch must have advanced last_seen past the stale value.
|
||||
after, err := SessionByToken(ctx, db, token)
|
||||
if err != nil {
|
||||
t.Fatalf("SessionByToken after burst: %v", err)
|
||||
}
|
||||
if !after.LastSeen.After(staleTime) {
|
||||
t.Errorf("last_seen = %v, want after %v", after.LastSeen, staleTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRoutesRequireAdmin(t *testing.T) {
|
||||
db := testDB(t)
|
||||
h := newAuthServer(t, db)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,13 @@ const (
|
|||
sessionContextKey contextKey = "auth.session"
|
||||
)
|
||||
|
||||
// defaultTouchEvery throttles last_seen writes. last_seen exists for session
|
||||
// listing and expiry hygiene, not security, so minute granularity is plenty and
|
||||
// a write per request is not worth the contention it causes on SQLite's single
|
||||
// write lock — especially for media playback, where one seek fires several
|
||||
// Range requests that would otherwise each take the write lock.
|
||||
const defaultTouchEvery = 5 * time.Minute
|
||||
|
||||
// Authenticator loads sessions from cookies and enforces access.
|
||||
type Authenticator struct {
|
||||
DB *sql.DB
|
||||
|
|
@ -26,6 +33,11 @@ type Authenticator struct {
|
|||
CookieTTL time.Duration
|
||||
Secure bool
|
||||
Log *slog.Logger
|
||||
|
||||
// touchEvery is the minimum age of a session's last_seen before Middleware
|
||||
// will write it again. A zero value means "touch on every request" (the
|
||||
// pre-throttling behaviour), which is a safe fallback for hand-built values.
|
||||
touchEvery time.Duration
|
||||
}
|
||||
|
||||
// NewAuthenticator builds an Authenticator with sane cookie defaults.
|
||||
|
|
@ -39,6 +51,7 @@ func NewAuthenticator(db *sql.DB, cookieName string, ttl time.Duration, secure b
|
|||
CookieTTL: ttl,
|
||||
Secure: secure,
|
||||
Log: log,
|
||||
touchEvery: defaultTouchEvery,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -54,10 +67,19 @@ func (a *Authenticator) Middleware(next http.Handler) http.Handler {
|
|||
if u, err := GetUserByID(ctx, a.DB, sess.UserID); err == nil {
|
||||
ctx = context.WithValue(ctx, userContextKey, u)
|
||||
ctx = context.WithValue(ctx, sessionContextKey, sess)
|
||||
// Throttle the last_seen write. It is for session listing
|
||||
// and expiry hygiene, not security, so minute granularity
|
||||
// is plenty; a write on every request would take SQLite's
|
||||
// single write lock each time, and a media seek alone can
|
||||
// issue several Range requests. The decision is made from
|
||||
// the LastSeen we already loaded via SessionByToken, so no
|
||||
// extra query is added.
|
||||
if time.Since(sess.LastSeen) >= a.touchEvery {
|
||||
// Best-effort; must not fail the request.
|
||||
if err := TouchSession(ctx, a.DB, sess.ID); err != nil {
|
||||
a.Log.Warn("touch session", "err", err)
|
||||
}
|
||||
}
|
||||
r = r.WithContext(ctx)
|
||||
} else if a.Log != nil {
|
||||
a.Log.Warn("session references missing user", "user_id", sess.UserID)
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
|
|
@ -61,19 +64,78 @@ func (s *Server) routes() {
|
|||
|
||||
// Handler returns the fully wrapped root handler.
|
||||
//
|
||||
// Order matters: the request ID must be attached before the logging
|
||||
// middleware runs, so the log line can include it. Session loading sits
|
||||
// Order matters. Security headers wrap the entire stack (outermost) so they are
|
||||
// written before anything can commit a response, including the recovery
|
||||
// middleware's 500 after a panic. Once a handler calls WriteHeader the header
|
||||
// map is frozen, so a security middleware placed inside recovery would be too
|
||||
// late for any panicking route. The request ID must be attached before the
|
||||
// logging middleware runs, so the log line can include it. Session loading sits
|
||||
// innermost so it runs after logging has started timing.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
var h http.Handler = s.mux
|
||||
if s.auth != nil {
|
||||
h = s.auth.Middleware(h)
|
||||
}
|
||||
return s.withRecovery(s.withRequestID(s.withRequestLogging(h)))
|
||||
return s.withSecurityHeaders(s.withRecovery(s.withRequestID(s.withRequestLogging(h))))
|
||||
}
|
||||
|
||||
// ── Middleware ─────────────────────────────────────────────────────────
|
||||
|
||||
// withSecurityHeaders sets a baseline of hardening headers on every response.
|
||||
//
|
||||
// Vellum renders user-supplied EPUB content on its own origin, so a malicious
|
||||
// EPUB is stored XSS unless the browser refuses to run foreign/inline script
|
||||
// and to frame the app. The policy is checked against the actual frontend;
|
||||
// each directive names the file that requires it. Keep it in sync when a
|
||||
// reader adds a new origin.
|
||||
//
|
||||
// It must sit outermost (see Handler): the headers have to be in place before
|
||||
// any handler — including the panic-recovery 500 — writes its status line.
|
||||
func (s *Server) withSecurityHeaders(next http.Handler) http.Handler {
|
||||
// default-src 'self' every bundled asset is same-origin (web/dist/index.html).
|
||||
// script-src 'self' the bundled ES module (web/dist/index.html).
|
||||
// 'wasm-unsafe-eval' EmulatorJS cores are WebAssembly (GamePlayer.svelte).
|
||||
// https://cdn.emulatorjs.org loader.js and core scripts (GamePlayer.svelte DATA_PATH).
|
||||
// style-src 'self' the bundled stylesheet (web/dist/index.html).
|
||||
// 'unsafe-inline' epub.js injects a <style> into the rendition iframe
|
||||
// (epubjs rendition.injectStylesheet, contents.css).
|
||||
// blob: EPUB stylesheets are rewritten to blob: URLs
|
||||
// (epubjs book.js replacements="blobUrl", resources.js).
|
||||
// img-src 'self' covers, comic pages, photos (ComicReader/PhotoViewer/Card).
|
||||
// data: blob: EPUB images as data:/blob: URIs (epubjs resources.js).
|
||||
// media-src 'self' blob: <audio> streams tracks via ServeContent (AudiobookPlayer.svelte).
|
||||
// connect-src 'self' every API fetch() (web/src/lib/api.ts).
|
||||
// https://cdn.emulatorjs.org EmulatorJS fetches its wasm/data files (GamePlayer.svelte).
|
||||
// font-src 'self' data: blob: bundled fonts plus EPUB-embedded fonts as data:/blob:
|
||||
// (epubjs resources.js rewrites fonts to blob: URLs).
|
||||
// worker-src 'self' blob: pdf.js worker is a same-origin asset, with a blob: fallback
|
||||
// (PdfReader.svelte `?url`); EmulatorJS spawns a blob: worker.
|
||||
// frame-src 'self' blob: epub.js renders each section in an iframe (Reader.svelte;
|
||||
// epubjs iframe.js srcdoc/blobUrl). Nothing uses data:.
|
||||
// object-src 'none' no plugin content is ever embedded.
|
||||
// base-uri/form-action/frame-ancestors no <base> hijack, no cross-site form posts, never framed.
|
||||
const csp = "default-src 'self'; " +
|
||||
"script-src 'self' 'wasm-unsafe-eval' https://cdn.emulatorjs.org; " +
|
||||
"style-src 'self' 'unsafe-inline' blob:; " +
|
||||
"img-src 'self' data: blob:; " +
|
||||
"media-src 'self' blob:; " +
|
||||
"connect-src 'self' https://cdn.emulatorjs.org; " +
|
||||
"font-src 'self' data: blob:; " +
|
||||
"worker-src 'self' blob:; " +
|
||||
"frame-src 'self' blob:; " +
|
||||
"object-src 'none'; " +
|
||||
"base-uri 'self'; form-action 'self'; frame-ancestors 'none'"
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
h.Set("Content-Security-Policy", csp)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const ctxRequestID ctxKey = "request_id"
|
||||
|
|
@ -136,23 +198,77 @@ func (s *Server) withRecovery(next http.Handler) http.Handler {
|
|||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
// statusRecorder wraps a ResponseWriter to record the status code and byte
|
||||
// count for logging.
|
||||
//
|
||||
// It MUST forward Flush, Hijack, and ReadFrom to the underlying writer.
|
||||
// Wrapping a ResponseWriter without forwarding these silently disables
|
||||
// streaming: http.ServeContent can no longer flush early (progressive
|
||||
// audio/video stalls until the whole body is ready) and io.Copy can no longer
|
||||
// reach the net/http ReaderFrom that drives kernel sendfile for multi-GB
|
||||
// files. The type assertions below are the only reason media playback and
|
||||
// downloads keep working once this middleware is installed.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
bytes int
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
// WriteHeader records the first status and ignores repeats. net/http logs a
|
||||
// "superfluous WriteHeader" warning on a second call, and a handler that
|
||||
// writes a body before setting a status would otherwise be recorded wrongly.
|
||||
func (r *statusRecorder) WriteHeader(code int) {
|
||||
if r.wroteHeader {
|
||||
return
|
||||
}
|
||||
r.wroteHeader = true
|
||||
r.status = code
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (r *statusRecorder) Write(b []byte) (int, error) {
|
||||
if !r.wroteHeader {
|
||||
r.WriteHeader(http.StatusOK)
|
||||
}
|
||||
n, err := r.ResponseWriter.Write(b)
|
||||
r.bytes += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Flush forwards to the underlying http.Flusher so handlers such as
|
||||
// http.ServeContent can push bytes to the client before the body is complete.
|
||||
func (r *statusRecorder) Flush() {
|
||||
if f, ok := r.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Hijack forwards to the underlying http.Hijacker for protocols that take over
|
||||
// the connection.
|
||||
func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
if hj, ok := r.ResponseWriter.(http.Hijacker); ok {
|
||||
return hj.Hijack()
|
||||
}
|
||||
return nil, nil, http.ErrNotSupported
|
||||
}
|
||||
|
||||
// ReadFrom forwards to the underlying io.ReaderFrom so io.Copy keeps the
|
||||
// sendfile fast path instead of looping through Write. The byte count is not
|
||||
// updated on that path: wrapping src to count it would defeat the zero-copy
|
||||
// transfer this method exists to preserve.
|
||||
func (r *statusRecorder) ReadFrom(src io.Reader) (int64, error) {
|
||||
if !r.wroteHeader {
|
||||
r.WriteHeader(http.StatusOK)
|
||||
}
|
||||
if rf, ok := r.ResponseWriter.(io.ReaderFrom); ok {
|
||||
return rf.ReadFrom(src)
|
||||
}
|
||||
n, err := io.Copy(r.ResponseWriter, src)
|
||||
r.bytes += int(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mithral/vellum/internal/core"
|
||||
|
|
@ -105,4 +106,101 @@ func TestRecoveryMiddleware(t *testing.T) {
|
|||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
// Security headers must survive the recovered panic, which is only true
|
||||
// because the security middleware sits outside recovery in Handler.
|
||||
if rec.Header().Get("Content-Security-Policy") == "" {
|
||||
t.Error("security headers missing on recovered panic 500")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeaders(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
|
||||
for header, want := range map[string]string{
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
} {
|
||||
if got := rec.Header().Get(header); got != want {
|
||||
t.Errorf("%s = %q, want %q", header, got, want)
|
||||
}
|
||||
}
|
||||
if rec.Header().Get("Content-Security-Policy") == "" {
|
||||
t.Error("Content-Security-Policy header not set")
|
||||
}
|
||||
}
|
||||
|
||||
// flushRecorder is a ResponseWriter that records whether Flush reached it.
|
||||
type flushRecorder struct {
|
||||
*httptest.ResponseRecorder
|
||||
flushed bool
|
||||
}
|
||||
|
||||
func (f *flushRecorder) Flush() { f.flushed = true }
|
||||
|
||||
func TestStatusRecorderForwardsFlush(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
var sawFlusher bool
|
||||
srv.Handle("GET /flush", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
f, ok := w.(http.Flusher)
|
||||
sawFlusher = ok
|
||||
if ok {
|
||||
f.Flush()
|
||||
}
|
||||
}))
|
||||
|
||||
rec := &flushRecorder{ResponseRecorder: httptest.NewRecorder()}
|
||||
req := httptest.NewRequest(http.MethodGet, "/flush", nil)
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if !sawFlusher {
|
||||
t.Error("writer passed to handler does not implement http.Flusher")
|
||||
}
|
||||
if !rec.flushed {
|
||||
t.Error("Flush was not forwarded to the underlying writer")
|
||||
}
|
||||
}
|
||||
|
||||
// readFromRecorder records whether ReadFrom reached it.
|
||||
type readFromRecorder struct {
|
||||
*httptest.ResponseRecorder
|
||||
readFrom bool
|
||||
}
|
||||
|
||||
func (r *readFromRecorder) ReadFrom(src io.Reader) (int64, error) {
|
||||
r.readFrom = true
|
||||
return io.Copy(r.ResponseRecorder, src)
|
||||
}
|
||||
|
||||
func TestStatusRecorderForwardsReadFrom(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
srv.Handle("GET /copy", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if rf, ok := w.(io.ReaderFrom); ok {
|
||||
_, _ = rf.ReadFrom(strings.NewReader("payload"))
|
||||
}
|
||||
}))
|
||||
|
||||
rec := &readFromRecorder{ResponseRecorder: httptest.NewRecorder()}
|
||||
req := httptest.NewRequest(http.MethodGet, "/copy", nil)
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if !rec.readFrom {
|
||||
t.Error("ReadFrom was not forwarded to the underlying writer")
|
||||
}
|
||||
if got := rec.Body.String(); got != "payload" {
|
||||
t.Errorf("body = %q, want payload", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusRecorderIgnoresSecondWriteHeader(t *testing.T) {
|
||||
rec := &statusRecorder{ResponseWriter: httptest.NewRecorder(), status: http.StatusOK}
|
||||
rec.WriteHeader(http.StatusCreated)
|
||||
rec.WriteHeader(http.StatusTeapot) // must be ignored, not logged as superfluous
|
||||
if rec.status != http.StatusCreated {
|
||||
t.Errorf("status = %d, want 201", rec.status)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,14 @@
|
|||
|
||||
let saveTimer: number | undefined
|
||||
let resumeAt = 0
|
||||
let pendingPlay = false
|
||||
let finalFlushed = false
|
||||
|
||||
onMount(async () => {
|
||||
// Persist the final position even if the page is torn down: keepalive lets a
|
||||
// PUT outlive the document, which sendBeacon (POST-only) cannot.
|
||||
window.addEventListener('pagehide', finalFlush)
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
try {
|
||||
const detail = await api.item(itemId)
|
||||
item = detail.item
|
||||
|
|
@ -43,10 +49,16 @@
|
|||
})
|
||||
|
||||
onDestroy(() => {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
flush()
|
||||
window.removeEventListener('pagehide', finalFlush)
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
finalFlush()
|
||||
})
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState === 'hidden') finalFlush()
|
||||
else finalFlushed = false
|
||||
}
|
||||
|
||||
function onLoadedMetadata() {
|
||||
if (!audio) return
|
||||
duration = audio.duration || 0
|
||||
|
|
@ -54,6 +66,11 @@
|
|||
audio.currentTime = resumeAt
|
||||
resumeAt = 0
|
||||
}
|
||||
// The new source is loaded: autoplay a track we just switched to.
|
||||
if (pendingPlay) {
|
||||
pendingPlay = false
|
||||
void audio.play().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function onTimeUpdate() {
|
||||
|
|
@ -78,12 +95,15 @@
|
|||
function goToTrack(i: number) {
|
||||
if (i < 0 || i >= tracks.length) return
|
||||
flush()
|
||||
if (i === index) {
|
||||
if (audio) void audio.play().catch(() => {})
|
||||
return
|
||||
}
|
||||
index = i
|
||||
resumeAt = 0
|
||||
// Let Svelte swap the src, then start playback.
|
||||
queueMicrotask(() => {
|
||||
if (audio) void audio.play().catch(() => {})
|
||||
})
|
||||
// Autoplay once Svelte has actually swapped the src and it has loaded;
|
||||
// playing from a microtask can race the DOM update and replay the old track.
|
||||
pendingPlay = true
|
||||
}
|
||||
|
||||
function onEnded() {
|
||||
|
|
@ -98,21 +118,46 @@
|
|||
|
||||
// Progress is milliseconds, and is keyed by file, so a multi-track book
|
||||
// resumes exactly where it stopped.
|
||||
function flush(finished = false) {
|
||||
if (!audio || tracks.length === 0) return
|
||||
function progressBody(finished: boolean) {
|
||||
if (!audio || tracks.length === 0) return null
|
||||
const track = tracks[index]
|
||||
if (!track) return
|
||||
if (!track) return null
|
||||
const pos = Math.floor(audio.currentTime * 1000)
|
||||
const dur = Math.floor((audio.duration || 0) * 1000)
|
||||
void api
|
||||
.putProgress(itemId, {
|
||||
return {
|
||||
file_id: track.id,
|
||||
position: pos,
|
||||
duration: dur,
|
||||
percent: dur > 0 ? pos / dur : 0,
|
||||
finished,
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function flush(finished = false) {
|
||||
const body = progressBody(finished)
|
||||
if (!body) return
|
||||
void api.putProgress(itemId, body).catch(() => {})
|
||||
}
|
||||
|
||||
// Last-chance save on teardown. Uses fetch with keepalive so the PUT survives
|
||||
// the document being unloaded, and latches so the overlapping pagehide /
|
||||
// visibilitychange / onDestroy events only fire one request.
|
||||
function finalFlush() {
|
||||
if (finalFlushed) return
|
||||
const body = progressBody(false)
|
||||
if (!body) return
|
||||
finalFlushed = true
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = undefined
|
||||
}
|
||||
void fetch(`/api/v1/items/${itemId}/progress`, {
|
||||
method: 'PUT',
|
||||
keepalive: true,
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function seek(event: Event) {
|
||||
|
|
|
|||
|
|
@ -1,20 +1,32 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { api, ApiError } from '../api'
|
||||
import { api, ApiError, type Item } from '../api'
|
||||
|
||||
let { itemId }: { itemId: number } = $props()
|
||||
|
||||
let item = $state<Item | null>(null)
|
||||
let container = $state('')
|
||||
let count = $state(0)
|
||||
let current = $state(1)
|
||||
let loading = $state(true)
|
||||
let error = $state<string | null>(null)
|
||||
let pageInput = $state('1')
|
||||
|
||||
// The registry lists .cbr under comics, but the server only reads zip
|
||||
// archives (archive/zip in the comics module). Flag rar-based archives
|
||||
// before calling ListPages so the user sees a clear reason, not a 422.
|
||||
let unsupported = $derived(container === 'cbr' || container === 'rar')
|
||||
|
||||
let saveTimer: number | undefined
|
||||
const preloaded = new Set<string>()
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const detail = await api.item(itemId)
|
||||
item = detail.item
|
||||
container = detail.files?.[0]?.container ?? ''
|
||||
if (container === 'cbr' || container === 'rar') return
|
||||
|
||||
const [list, prog] = await Promise.all([api.pages(itemId), api.progress(itemId)])
|
||||
count = list.count
|
||||
if (prog.progress && prog.progress.position > 0 && prog.progress.position <= count) {
|
||||
|
|
@ -87,6 +99,12 @@
|
|||
|
||||
{#if error}
|
||||
<div class="alert">{error}</div>
|
||||
{:else if unsupported}
|
||||
<div class="empty">
|
||||
<h3>{item?.title ?? 'Comic'}</h3>
|
||||
<p>“.{container}” archives are not supported yet. Only .cbz comics can be read in the browser.</p>
|
||||
<a class="btn" href={api.fileUrl(itemId)} download>Download the file</a>
|
||||
</div>
|
||||
{:else if loading}
|
||||
<div class="stage center"><div class="spinner"></div></div>
|
||||
{:else}
|
||||
|
|
|
|||
|
|
@ -18,9 +18,17 @@
|
|||
let percent = $state(0)
|
||||
let fileContainer = $state('')
|
||||
|
||||
// epub.js only understands the EPUB container. Other catalogued ebook
|
||||
// formats (.mobi, .azw3, .fb2) get an honest empty state instead of a
|
||||
// confusing parse error from inside epub.js.
|
||||
let ebookFormat = $derived(fileContainer ? `.${fileContainer}` : 'this ebook format')
|
||||
|
||||
let book: any = null
|
||||
let rendition: any = null
|
||||
let saveTimer: number | undefined
|
||||
// Last position handed to a progress write. Lets pagehide and
|
||||
// visibilitychange fire together without double-saving the same spot.
|
||||
let lastSavedPosition = -1
|
||||
|
||||
onMount(async () => {
|
||||
// Count this as a view: the user is actually consuming the item, which is
|
||||
|
|
@ -33,11 +41,15 @@
|
|||
item = detail.item
|
||||
fileContainer = detail.files?.[0]?.container ?? ''
|
||||
if (item.kind !== 'ebooks') return
|
||||
// The registry advertises .mobi/.azw3/.fb2 under ebooks, but epub.js
|
||||
// cannot open them. Leave them catalogued and render the empty state.
|
||||
if (fileContainer !== 'epub') return
|
||||
|
||||
meta = (await api.ebook(itemId)).metadata
|
||||
await openBook(detail.progress?.position ?? 0)
|
||||
window.addEventListener('keydown', onKey)
|
||||
window.addEventListener('beforeunload', flush)
|
||||
window.addEventListener('pagehide', flushKeepalive)
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
} catch (err) {
|
||||
error = err instanceof ApiError ? err.message : 'could not open this item'
|
||||
} finally {
|
||||
|
|
@ -47,8 +59,10 @@
|
|||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('beforeunload', flush)
|
||||
window.removeEventListener('pagehide', flushKeepalive)
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
flushKeepalive()
|
||||
if (book) book.destroy()
|
||||
})
|
||||
|
||||
|
|
@ -84,18 +98,38 @@
|
|||
function scheduleSave(index: number, pct: number) {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = window.setTimeout(() => {
|
||||
saveTimer = undefined
|
||||
lastSavedPosition = index
|
||||
void api.putProgress(itemId, { position: index, percent: pct }).catch(() => {})
|
||||
}, 900)
|
||||
}
|
||||
|
||||
function flush() {
|
||||
const loc = rendition?.currentLocation()
|
||||
if (loc?.start) {
|
||||
void api.putProgress(itemId, {
|
||||
position: loc.start.index ?? 0,
|
||||
percent: loc.start.percentage ?? 0,
|
||||
})
|
||||
function onVisibility() {
|
||||
if (document.visibilityState === 'hidden') flushKeepalive()
|
||||
}
|
||||
|
||||
// Final flush. The api helper's fetch carries no keepalive flag, so it is
|
||||
// cancelled when the page tears down; go straight to fetch with
|
||||
// keepalive: true. Progress is PUT-only, so navigator.sendBeacon (which
|
||||
// always POSTs) cannot be used. The position guard stops pagehide and
|
||||
// visibilitychange from both writing the same spot.
|
||||
function flushKeepalive() {
|
||||
const loc = rendition?.currentLocation()
|
||||
if (!loc?.start) return
|
||||
const position = loc.start.index ?? 0
|
||||
if (position === lastSavedPosition) return
|
||||
lastSavedPosition = position
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = undefined
|
||||
}
|
||||
void fetch(`/api/v1/items/${itemId}/progress`, {
|
||||
method: 'PUT',
|
||||
keepalive: true,
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ position, percent: loc.start.percentage ?? 0 }),
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -117,6 +151,12 @@
|
|||
<p>There is no reader for “{item.kind}” yet.</p>
|
||||
<a class="btn" href={api.fileUrl(itemId)} download>Download the file</a>
|
||||
</div>
|
||||
{:else if item && fileContainer !== 'epub'}
|
||||
<div class="empty">
|
||||
<h3>{item.title}</h3>
|
||||
<p>There is no reader for “{ebookFormat}” yet.</p>
|
||||
<a class="btn" href={api.fileUrl(itemId)} download>Download the file</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="stage">
|
||||
<div class="viewport" bind:this={container}></div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue