Files
silo-server/internal/api/handlers/ebook_reader.go
T
CoffeeKnyte e5bf0155ad fix(playback): make revocation state converge and cutoffs credential-accurate
Five defects in revocation state and credential semantics. Lands after the
tracker-lifecycle batch on purpose: raising the over-cap TTL is only safe once
the count feeding it is trustworthy.

#13 -- a longer old revocation suppressed a newer cutoff. applyLocal kept or
replaced the WHOLE record by expiry, so when the existing revocation expired
later the new one was dropped entirely, including its newer RevokedAt. The
durable upsert did the same, with a comment documenting it as intentional.
RevokedAt is the user-kill CUTOFF, so this left a credential issued between the
two cutoffs valid -- a second admin kill after a user re-authenticates silently
failed to cut them. The two fields now merge independently: ExpiresAt stays
monotonic, RevokedAt advances to the later value, and reason follows the newer
cutoff. Both superseded comments are replaced rather than left contradicting the
code. Session-kind revocation still ignores RevokedAt, so the enforcer's
re-revoke cannot weaken a session kill.

Also fixed while here: Redis received the merged record but pub/sub published the
raw input one, so under pub/sub-only delivery (Redis down) an edge got the newer
short record without the older long expiry and lost monotonicity. Both now carry
the merged record.

#7 + M1 -- Postgres could indefinitely block the urgent Redis kill.
RevokeWithWarnings held the global opMu across all propagation, stripped the
caller's deadline with WithoutCancel, and did the durable Postgres upsert BEFORE
Redis, on a pool with no statement timeout. The local kill still applied, so
playback on that process was fine -- but edge propagation, pub/sub, the admin
response and every later revoke/unrevoke stalled behind the lock. Redis and
pub/sub now go first, and the detached context is bounded. WithoutCancel is kept
deliberately: propagation must outlive an aborted admin request.

opMu scope is deliberately NOT narrowed. mirrorToRedis is an unconditional SET
with no atomic merge, so same-process serialization is what stops an older value
overwriting a newer one; narrowing the lock would also let an unrevoke interleave
with a revoke's propagation. Bounding the context caps how long the lock can be
held, which is the actual reported harm. The remaining cross-replica race -- two
central replicas racing the same SET -- is documented, not half-fixed; it needs
A6's shared picture.

A2 / #6 -- a missed unrevoke got resurrected. In-memory tombstones already
existed, but being process-local they did not survive a restart or reach a
replica that missed the pub/sub event, so maintain's durable self-heal
re-Upserted the surviving entry and the ban returned. Tombstones are now durable,
via two nullable columns on stream_revocations rather than a second table: a
tombstone is a state of the same key, and it needs its own expiry horizon
separate from the revocation's. The upsert rejects a stale replica's write while
a tombstone is live but lets a genuinely newer revocation clear it, and warm
paths apply tombstones BEFORE revocations so an un-banned key cannot be restored
as a live kill. Tombstones are pruned on the same sweep, so the table cannot grow
without bound.

A1 / #3 -- over-cap kills reopened after 5 minutes while the token stayed
reconstructable for 24h. The TTL now derives from playback.MaxTokenTTL rather
than duplicating 24h, behind a validated setting.

Critically, the enforcer uses a revoke-if-absent path rather than re-revoking.
Expiry is monotonic and the enforcer re-evaluates every 30s, so a plain long TTL
would slide expiry forward by another full lifetime on every pass -- making a
wrong kill effectively permanent for as long as any stale record persisted, with
only an explicit unrevoke to recover it. Admin Revoke keeps its monotonic
behaviour; only the enforcer's own repeat kill is non-extending. The setting is
documented as affecting future revocations only, since monotonic expiry means it
cannot shorten one already issued.

A3 / #5 -- the user cutoff compared against a fresh time.Now() taken at request
entry, so a request from a pre-cutoff login could look post-cutoff and escape the
kill. The credential time is now the access token's iat.

Two deliberate choices worth stating. API-key credentials carry no issue time, so
they pass the zero time and, per IsRevoked's documented contract, are never
matched by a user cutoff: a user kill provably cannot cut an API-key-owned pour.
That is an accepted, logged, documented hole -- and strictly better than
time.Now(), which actively defeats the cutoff. And jellycompat uses the compat
session's CreatedAt rather than the bridged Silo token's iat, because that token
refreshes without a new Jellyfin login, so its iat would advance on refresh and
let a refreshed credential slip past a cutoff.

Stream tokens are now bound to their route: a token whose SessionID does not match
the URL's session_id is rejected with 403 instead of being silently ignored,
matching the reconstruction helper that already refused a different session.

Per-login logout cuts remain out of scope -- they need per-login identity in the
stream credential. S5 (the (sessionID, userID, startedAt) clump) is rejected as
ceremony now that A3 is the iat option rather than the generation model.

#12 (closing an RSS feed does not cut its current pour) is deferred: it needs a
namespaced revocation id that cannot collide with real session ids, that id
threaded onto public feed requests, and protection against a new feed inheriting
an old tombstone.

Part of #305.
2026-07-30 12:27:05 +00:00

1130 lines
37 KiB
Go

package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"mime"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/httpstream"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/streamrevoke"
"github.com/Silo-Server/silo-server/internal/transfers"
)
// EbookReaderProgress is the persisted reader position for one profile and ebook.
type EbookReaderProgress struct {
UserID int `json:"-"`
ProfileID string `json:"-"`
ContentID string `json:"content_id"`
FileID int `json:"file_id"`
Location string `json:"location"`
Progress float64 `json:"progress"`
UpdatedAt time.Time `json:"updated_at"`
}
type EbookReaderProgressStore interface {
Get(ctx context.Context, userID int, profileID string, contentID string) (*EbookReaderProgress, error)
Upsert(ctx context.Context, progress EbookReaderProgress) error
}
// EbookReaderConfig is persisted reader configuration for one profile and ebook.
type EbookReaderConfig struct {
UserID int `json:"-"`
ProfileID string `json:"-"`
ContentID string `json:"content_id"`
Config json.RawMessage `json:"config"`
UpdatedAt time.Time `json:"updated_at"`
}
type EbookReaderConfigStore interface {
Get(ctx context.Context, userID int, profileID string, contentID string) (*EbookReaderConfig, error)
Upsert(ctx context.Context, config EbookReaderConfig) error
}
// EbookReaderAnnotation is a persisted reader annotation or bookmark.
type EbookReaderAnnotation struct {
ID string `json:"id"`
UserID int `json:"-"`
ProfileID string `json:"-"`
ContentID string `json:"content_id"`
Kind string `json:"kind"`
CFIRange string `json:"cfi_range,omitempty"`
Location string `json:"location,omitempty"`
SelectedText string `json:"selected_text"`
Note string `json:"note"`
Style string `json:"style"`
Color string `json:"color"`
Metadata json.RawMessage `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type EbookReaderAnnotationStore interface {
List(ctx context.Context, userID int, profileID string, contentID string) ([]EbookReaderAnnotation, error)
Create(ctx context.Context, annotation EbookReaderAnnotation) error
// Update atomically rewrites the scoped annotation: the stored row is
// read under a write lock, merge is invoked with the current value, and
// the merged result is written back in the same transaction so concurrent
// PATCHes cannot lose updates. It returns (nil, nil) when no annotation
// matches the scope and propagates merge errors unchanged.
Update(
ctx context.Context,
userID int,
profileID string,
contentID string,
annotationID string,
merge func(existing EbookReaderAnnotation) (EbookReaderAnnotation, error),
) (*EbookReaderAnnotation, error)
Delete(ctx context.Context, userID int, profileID string, contentID string, annotationID string) error
}
// EbookReaderHandler serves ebook files for the in-app reader.
type EbookReaderHandler struct {
FileAuthorizer *MediaFileAuthorizer
ProgressStore EbookReaderProgressStore
ConfigStore EbookReaderConfigStore
AnnotationStore EbookReaderAnnotationStore
// Conversion, when set and enabled, transparently converts Kindle-family
// files to EPUB on read. Nil means the feature is off.
Conversion *EbookConversion
Transfers *transfers.Registry
Revocation *streamrevoke.Store
}
func NewEbookReaderHandler(authorizer *MediaFileAuthorizer) *EbookReaderHandler {
return &EbookReaderHandler{FileAuthorizer: authorizer}
}
// HandleReadFile serves an ebook file inline with byte-range support.
func (h *EbookReaderHandler) HandleReadFile(w http.ResponseWriter, r *http.Request) {
if apimw.GetUserID(r.Context()) == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
if h == nil || h.FileAuthorizer == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Ebook reader is not configured")
return
}
startedAt := time.Now()
credentialIssuedAt := apimw.CredentialIssuedAt(r.Context())
r = r.WithContext(httpstream.WithCutLatch(r.Context(), &httpstream.CutLatch{}))
contentID := strings.TrimSpace(chi.URLParam(r, "content_id"))
fileID, err := strconv.Atoi(chi.URLParam(r, "file_id"))
if contentID == "" || err != nil || fileID <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "content_id and file_id are required")
return
}
file, err := h.FileAuthorizer.Authorize(r, fileID)
if err != nil {
h.writeReadError(w, err)
return
}
if file == nil || file.ContentID != contentID || !isEbookFile(file) {
writeError(w, http.StatusNotFound, "not_found", "Ebook file not found")
return
}
userID := apimw.GetUserID(r.Context())
if h.Revocation != nil && h.Revocation.Refuse(w, "", userID, credentialIssuedAt) {
return
}
route := "ebook_read"
if h.Conversion.active(r.Context()) && isKindleReaderFile(file) {
route = "ebook_convert"
}
transfer := transfers.Transfer{
ID: uuid.NewString(),
UserID: userID,
ProfileID: apimw.GetProfileID(r.Context()),
MediaFileID: file.ID,
Route: route,
ClientIP: clientip.FromContext(r.Context()),
ClientName: r.UserAgent(),
StartedAt: startedAt,
}
if h.Transfers != nil {
if err := h.Transfers.Begin(transfer); err != nil {
slog.DebugContext(r.Context(), "ebook transfer not monitored",
"component", "ebooks",
"transfer_id", transfer.ID,
"error", err,
)
}
}
defer h.Transfers.End(transfer.ID)
metered := playback.NewSessionMeteredWriter(w, h.Transfers, transfer.ID)
defer func() { _ = metered.Close() }()
if h.Revocation != nil {
stop := h.Revocation.WatchAndCutContext(r.Context(), metered, "", userID, credentialIssuedAt)
defer stop()
}
if err := h.serveEbook(metered, r, file); err != nil {
if errors.Is(err, catalog.ErrItemNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Ebook file not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to serve ebook file")
}
}
// Request body caps for the ebook reader write endpoints. Progress payloads
// are tiny; reader config and annotations can carry user text and metadata
// but should never approach these limits.
const (
ebookReaderProgressMaxBodySize = 16 << 10 // 16 KiB
ebookReaderConfigMaxBodySize = 256 << 10 // 256 KiB
ebookReaderAnnotationMaxBodySize = 256 << 10 // 256 KiB
)
// decodeEbookReaderBody decodes a JSON request body with a hard size cap.
// It writes the error response and returns false when decoding fails.
func decodeEbookReaderBody(w http.ResponseWriter, r *http.Request, maxBodySize int64, dst any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Request body is too large")
return false
}
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return false
}
return true
}
type ebookReaderProgressRequest struct {
FileID int `json:"file_id"`
Location string `json:"location"`
Progress float64 `json:"progress"`
}
type ebookReaderConfigRequest struct {
Config json.RawMessage `json:"config"`
}
type ebookReaderAnnotationRequest struct {
Kind string `json:"kind"`
CFIRange string `json:"cfi_range"`
Location string `json:"location"`
SelectedText string `json:"selected_text"`
Note string `json:"note"`
Style string `json:"style"`
Color string `json:"color"`
Metadata json.RawMessage `json:"metadata"`
}
// ebookReaderAnnotationPatchRequest distinguishes absent fields (nil: keep the
// stored value) from present fields (set, including empty values to clear).
type ebookReaderAnnotationPatchRequest struct {
Kind *string `json:"kind"`
CFIRange *string `json:"cfi_range"`
Location *string `json:"location"`
SelectedText *string `json:"selected_text"`
Note *string `json:"note"`
Style *string `json:"style"`
Color *string `json:"color"`
Metadata json.RawMessage `json:"metadata"`
}
func (h *EbookReaderHandler) HandleGetProgress(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
if h == nil || h.ProgressStore == nil || h.FileAuthorizer == nil || h.FileAuthorizer.ItemAccess == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Ebook reader progress is not configured")
return
}
contentID := strings.TrimSpace(chi.URLParam(r, "content_id"))
if contentID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "content_id is required")
return
}
if err := h.FileAuthorizer.ItemAccess.EnsureAccessible(r.Context(), contentID, requestAccessFilter(r)); err != nil {
h.writeReadError(w, err)
return
}
progress, err := h.ProgressStore.Get(r.Context(), userID, profileID, contentID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load ebook progress")
return
}
if progress == nil {
writeJSON(w, http.StatusOK, map[string]any{})
return
}
writeJSON(w, http.StatusOK, progress)
}
func (h *EbookReaderHandler) HandleSaveProgress(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
if h == nil || h.ProgressStore == nil || h.FileAuthorizer == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Ebook reader progress is not configured")
return
}
contentID := strings.TrimSpace(chi.URLParam(r, "content_id"))
if contentID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "content_id is required")
return
}
var req ebookReaderProgressRequest
if !decodeEbookReaderBody(w, r, ebookReaderProgressMaxBodySize, &req) {
return
}
req.Location = strings.TrimSpace(req.Location)
if req.FileID <= 0 || req.Location == "" || req.Progress < 0 || req.Progress > 1 {
writeError(w, http.StatusBadRequest, "bad_request", "file_id, location, and progress are required")
return
}
file, err := h.FileAuthorizer.Authorize(r, req.FileID)
if err != nil {
h.writeReadError(w, err)
return
}
if file == nil || file.ContentID != contentID || !isEbookFile(file) {
writeError(w, http.StatusNotFound, "not_found", "Ebook file not found")
return
}
progress := EbookReaderProgress{
UserID: userID,
ProfileID: profileID,
ContentID: contentID,
FileID: req.FileID,
Location: req.Location,
Progress: req.Progress,
UpdatedAt: time.Now().UTC(),
}
if err := h.ProgressStore.Upsert(r.Context(), progress); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save ebook progress")
return
}
writeJSON(w, http.StatusOK, progress)
}
func (h *EbookReaderHandler) HandleGetConfig(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
if h == nil || h.ConfigStore == nil || h.FileAuthorizer == nil || h.FileAuthorizer.ItemAccess == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Ebook reader config is not configured")
return
}
contentID := strings.TrimSpace(chi.URLParam(r, "content_id"))
if contentID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "content_id is required")
return
}
if err := h.FileAuthorizer.ItemAccess.EnsureAccessible(r.Context(), contentID, requestAccessFilter(r)); err != nil {
h.writeReadError(w, err)
return
}
config, err := h.ConfigStore.Get(r.Context(), userID, profileID, contentID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load ebook reader config")
return
}
if config == nil {
writeJSON(w, http.StatusOK, map[string]any{"config": map[string]any{}})
return
}
writeJSON(w, http.StatusOK, config)
}
func (h *EbookReaderHandler) HandleSaveConfig(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
if h == nil || h.ConfigStore == nil || h.FileAuthorizer == nil || h.FileAuthorizer.ItemAccess == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Ebook reader config is not configured")
return
}
contentID := strings.TrimSpace(chi.URLParam(r, "content_id"))
if contentID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "content_id is required")
return
}
var req ebookReaderConfigRequest
if !decodeEbookReaderBody(w, r, ebookReaderConfigMaxBodySize, &req) {
return
}
if !jsonObject(req.Config) {
writeError(w, http.StatusBadRequest, "bad_request", "config must be a JSON object")
return
}
if err := h.FileAuthorizer.ItemAccess.EnsureAccessible(r.Context(), contentID, requestAccessFilter(r)); err != nil {
h.writeReadError(w, err)
return
}
config := EbookReaderConfig{
UserID: userID,
ProfileID: profileID,
ContentID: contentID,
Config: req.Config,
UpdatedAt: time.Now().UTC(),
}
if err := h.ConfigStore.Upsert(r.Context(), config); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save ebook reader config")
return
}
writeJSON(w, http.StatusOK, config)
}
func jsonObject(raw json.RawMessage) bool {
if len(raw) == 0 {
return false
}
var value map[string]any
return json.Unmarshal(raw, &value) == nil && value != nil
}
func (h *EbookReaderHandler) HandleListAnnotations(w http.ResponseWriter, r *http.Request) {
userID, profileID, contentID, ok := h.annotationRequestScope(w, r)
if !ok {
return
}
items, err := h.AnnotationStore.List(r.Context(), userID, profileID, contentID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load ebook annotations")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (h *EbookReaderHandler) HandleCreateAnnotation(w http.ResponseWriter, r *http.Request) {
userID, profileID, contentID, ok := h.annotationRequestScope(w, r)
if !ok {
return
}
var req ebookReaderAnnotationRequest
if !decodeEbookReaderBody(w, r, ebookReaderAnnotationMaxBodySize, &req) {
return
}
annotation, err := buildEbookReaderAnnotation(req)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
now := time.Now().UTC()
annotation.ID = uuid.NewString()
annotation.UserID = userID
annotation.ProfileID = profileID
annotation.ContentID = contentID
annotation.CreatedAt = now
annotation.UpdatedAt = now
if err := h.AnnotationStore.Create(r.Context(), annotation); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create ebook annotation")
return
}
writeJSON(w, http.StatusCreated, annotation)
}
func (h *EbookReaderHandler) HandleUpdateAnnotation(w http.ResponseWriter, r *http.Request) {
userID, profileID, contentID, ok := h.annotationRequestScope(w, r)
if !ok {
return
}
annotationID := strings.TrimSpace(chi.URLParam(r, "annotation_id"))
if annotationID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "annotation_id is required")
return
}
var req ebookReaderAnnotationPatchRequest
if !decodeEbookReaderBody(w, r, ebookReaderAnnotationMaxBodySize, &req) {
return
}
// The merge runs inside the store transaction between the locked read and
// the write, so concurrent PATCHes serialize instead of losing updates.
var validationErr error
updated, err := h.AnnotationStore.Update(
r.Context(), userID, profileID, contentID, annotationID,
func(existing EbookReaderAnnotation) (EbookReaderAnnotation, error) {
merged, mergeErr := mergeEbookReaderAnnotationPatch(existing, req)
if mergeErr != nil {
validationErr = mergeErr
return EbookReaderAnnotation{}, mergeErr
}
merged.UpdatedAt = time.Now().UTC()
return merged, nil
},
)
if validationErr != nil {
writeError(w, http.StatusBadRequest, "bad_request", validationErr.Error())
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update ebook annotation")
return
}
if updated == nil {
writeError(w, http.StatusNotFound, "not_found", "Ebook annotation not found")
return
}
writeJSON(w, http.StatusOK, updated)
}
func (h *EbookReaderHandler) HandleDeleteAnnotation(w http.ResponseWriter, r *http.Request) {
userID, profileID, contentID, ok := h.annotationRequestScope(w, r)
if !ok {
return
}
annotationID := strings.TrimSpace(chi.URLParam(r, "annotation_id"))
if annotationID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "annotation_id is required")
return
}
if err := h.AnnotationStore.Delete(r.Context(), userID, profileID, contentID, annotationID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete ebook annotation")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *EbookReaderHandler) annotationRequestScope(w http.ResponseWriter, r *http.Request) (int, string, string, bool) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return 0, "", "", false
}
if h == nil || h.AnnotationStore == nil || h.FileAuthorizer == nil || h.FileAuthorizer.ItemAccess == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Ebook annotations are not configured")
return 0, "", "", false
}
contentID := strings.TrimSpace(chi.URLParam(r, "content_id"))
if contentID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "content_id is required")
return 0, "", "", false
}
if err := h.FileAuthorizer.ItemAccess.EnsureAccessible(r.Context(), contentID, requestAccessFilter(r)); err != nil {
h.writeReadError(w, err)
return 0, "", "", false
}
return userID, profileID, contentID, true
}
// validateEbookReaderAnnotation enforces the annotation invariants shared by
// create and patch: a known kind, a location for bookmarks, a CFI range for
// highlights/notes, and object-shaped metadata.
func validateEbookReaderAnnotation(annotation EbookReaderAnnotation) error {
if annotation.Kind != "highlight" && annotation.Kind != "note" && annotation.Kind != "bookmark" {
return fmt.Errorf("kind must be highlight, note, or bookmark")
}
if annotation.Kind == "bookmark" {
if annotation.Location == "" {
return fmt.Errorf("location is required for bookmarks")
}
} else if annotation.CFIRange == "" {
return fmt.Errorf("cfi_range is required for annotations")
}
if !jsonObject(annotation.Metadata) {
return fmt.Errorf("metadata must be a JSON object")
}
return nil
}
func buildEbookReaderAnnotation(req ebookReaderAnnotationRequest) (EbookReaderAnnotation, error) {
kind := strings.ToLower(strings.TrimSpace(req.Kind))
if kind == "" {
kind = "highlight"
}
metadata := req.Metadata
if len(metadata) == 0 {
metadata = json.RawMessage(`{}`)
}
style := strings.TrimSpace(req.Style)
if style == "" {
style = "highlight"
}
color := strings.TrimSpace(req.Color)
if color == "" {
color = "#facc15"
}
annotation := EbookReaderAnnotation{
Kind: kind,
CFIRange: strings.TrimSpace(req.CFIRange),
Location: strings.TrimSpace(req.Location),
SelectedText: strings.TrimSpace(req.SelectedText),
Note: strings.TrimSpace(req.Note),
Style: style,
Color: color,
Metadata: metadata,
}
if err := validateEbookReaderAnnotation(annotation); err != nil {
return EbookReaderAnnotation{}, err
}
return annotation, nil
}
// mergeEbookReaderAnnotationPatch applies a PATCH request onto the stored
// annotation. Absent fields keep their stored values; present fields are set
// to the provided value (empty values clear). The merged result is validated
// against the same invariants as annotation creation.
func mergeEbookReaderAnnotationPatch(
existing EbookReaderAnnotation,
req ebookReaderAnnotationPatchRequest,
) (EbookReaderAnnotation, error) {
merged := existing
if req.Kind != nil {
merged.Kind = strings.ToLower(strings.TrimSpace(*req.Kind))
}
if req.CFIRange != nil {
merged.CFIRange = strings.TrimSpace(*req.CFIRange)
}
if req.Location != nil {
merged.Location = strings.TrimSpace(*req.Location)
}
if req.SelectedText != nil {
merged.SelectedText = strings.TrimSpace(*req.SelectedText)
}
if req.Note != nil {
merged.Note = strings.TrimSpace(*req.Note)
}
if req.Style != nil {
merged.Style = strings.TrimSpace(*req.Style)
}
if req.Color != nil {
merged.Color = strings.TrimSpace(*req.Color)
}
if req.Metadata != nil {
merged.Metadata = req.Metadata
}
if err := validateEbookReaderAnnotation(merged); err != nil {
return EbookReaderAnnotation{}, err
}
return merged, nil
}
func (h *EbookReaderHandler) writeReadError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, catalog.ErrItemNotFound), errors.Is(err, catalog.ErrEpisodeNotFound):
writeError(w, http.StatusNotFound, "not_found", "Ebook file not found")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize ebook file")
}
}
func serveEbookInline(w http.ResponseWriter, r *http.Request, file *models.MediaFile) error {
f, err := os.Open(file.FilePath)
if err != nil {
if os.IsNotExist(err) {
return catalog.ErrItemNotFound
}
return fmt.Errorf("opening ebook file: %w", err)
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return fmt.Errorf("stat ebook file: %w", err)
}
w.Header().Set("Content-Type", ebookMimeType(file.FilePath, file.Container))
w.Header().Set("Content-Disposition", inlineContentDisposition(filepath.Base(file.FilePath)))
// Ebook files are served inline on the API origin (and reachable via the
// ?token= query fallback), so browsers must never content-sniff them.
w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeContent(httpstream.NewRollingDeadlineWriterCtx(r.Context(), w), r, stat.Name(), stat.ModTime(), f)
return nil
}
// inlineContentDisposition builds an inline Content-Disposition header value.
// mime.FormatMediaType handles quoting, backslash escaping, and RFC 5987
// (filename*) encoding for non-ASCII names.
func inlineContentDisposition(name string) string {
name = strings.TrimSpace(name)
if name == "" || name == "." || name == string(filepath.Separator) {
name = "ebook"
}
if disposition := mime.FormatMediaType("inline", map[string]string{"filename": name}); disposition != "" {
return disposition
}
return `inline; filename="ebook"`
}
func isEbookFile(file *models.MediaFile) bool {
if file == nil {
return false
}
if !strings.EqualFold(file.BaseType, "ebook") {
return false
}
return ebookReaderFormat(file.FilePath, file.Container) != ""
}
// ebookReaderFormat resolves the whitelisted reader format for a file from
// its filename extension, falling back to the catalog container when the
// extension is not a known ebook format. It returns "" when neither identity
// is whitelisted. isEbookFile admission and ebookMimeType both key off this
// resolution, so an admitted file always maps to a concrete ebook MIME type
// and never falls through to application/octet-stream.
func ebookReaderFormat(path, container string) string {
if strings.HasSuffix(strings.ToLower(path), ".fb2.zip") {
return "fbz"
}
if format := normalizeEbookReaderFormat(filepath.Ext(path)); format != "" {
return format
}
return normalizeEbookReaderFormat(container)
}
// normalizeEbookReaderFormat maps an extension or container value onto the
// ebook reader format whitelist, returning "" for unknown values.
func normalizeEbookReaderFormat(value string) string {
format := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(value)), ".")
switch format {
case "epub", "pdf", "mobi", "azw", "azw3", "cbz", "cbr", "fb2", "fbz":
return format
default:
return ""
}
}
func ebookMimeType(path, container string) string {
switch ebookReaderFormat(path, container) {
case "epub":
return "application/epub+zip"
case "pdf":
return "application/pdf"
case "mobi":
return "application/x-mobipocket-ebook"
case "azw":
return "application/vnd.amazon.ebook"
case "azw3":
return "application/vnd.amazon.mobi8-ebook"
case "cbz":
return "application/vnd.comicbook+zip"
case "cbr":
return "application/vnd.comicbook-rar"
case "fb2":
return "application/x-fictionbook+xml"
case "fbz":
return "application/x-zip-compressed-fb2"
default:
return "application/octet-stream"
}
}
type PGEbookReaderProgressStore struct {
pool *pgxpool.Pool
}
func NewPGEbookReaderProgressStore(pool *pgxpool.Pool) *PGEbookReaderProgressStore {
return &PGEbookReaderProgressStore{pool: pool}
}
type PGEbookReaderConfigStore struct {
pool *pgxpool.Pool
}
func NewPGEbookReaderConfigStore(pool *pgxpool.Pool) *PGEbookReaderConfigStore {
return &PGEbookReaderConfigStore{pool: pool}
}
func (s *PGEbookReaderConfigStore) Get(ctx context.Context, userID int, profileID string, contentID string) (*EbookReaderConfig, error) {
if s == nil || s.pool == nil {
return nil, fmt.Errorf("ebook reader config store is not configured")
}
var config EbookReaderConfig
err := s.pool.QueryRow(ctx, `
SELECT user_id, profile_id, content_id, config, updated_at
FROM ebook_reader_config
WHERE user_id = $1 AND profile_id = $2 AND content_id = $3`,
userID, profileID, contentID,
).Scan(
&config.UserID,
&config.ProfileID,
&config.ContentID,
&config.Config,
&config.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get ebook reader config: %w", err)
}
return &config, nil
}
func (s *PGEbookReaderConfigStore) Upsert(ctx context.Context, config EbookReaderConfig) error {
if s == nil || s.pool == nil {
return fmt.Errorf("ebook reader config store is not configured")
}
if _, err := s.pool.Exec(ctx, `
INSERT INTO ebook_reader_config
(user_id, profile_id, content_id, config, updated_at)
VALUES ($1, $2, $3, $4::jsonb, $5)
ON CONFLICT (user_id, profile_id, content_id) DO UPDATE SET
config = EXCLUDED.config,
updated_at = EXCLUDED.updated_at`,
config.UserID,
config.ProfileID,
config.ContentID,
config.Config,
config.UpdatedAt,
); err != nil {
return fmt.Errorf("upsert ebook reader config: %w", err)
}
return nil
}
type PGEbookReaderAnnotationStore struct {
pool *pgxpool.Pool
}
func NewPGEbookReaderAnnotationStore(pool *pgxpool.Pool) *PGEbookReaderAnnotationStore {
return &PGEbookReaderAnnotationStore{pool: pool}
}
func (s *PGEbookReaderAnnotationStore) List(ctx context.Context, userID int, profileID string, contentID string) ([]EbookReaderAnnotation, error) {
if s == nil || s.pool == nil {
return nil, fmt.Errorf("ebook reader annotation store is not configured")
}
rows, err := s.pool.Query(ctx, `
SELECT id, user_id, profile_id, content_id, kind,
COALESCE(cfi_range, ''), COALESCE(location, ''),
selected_text, note, style, color, metadata, created_at, updated_at
FROM ebook_reader_annotations
WHERE user_id = $1 AND profile_id = $2 AND content_id = $3
ORDER BY updated_at DESC`,
userID, profileID, contentID,
)
if err != nil {
return nil, fmt.Errorf("list ebook reader annotations: %w", err)
}
defer rows.Close()
var items []EbookReaderAnnotation
for rows.Next() {
annotation, err := scanEbookReaderAnnotation(rows)
if err != nil {
return nil, err
}
items = append(items, annotation)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate ebook reader annotations: %w", err)
}
return items, nil
}
func (s *PGEbookReaderAnnotationStore) Create(ctx context.Context, annotation EbookReaderAnnotation) error {
if s == nil || s.pool == nil {
return fmt.Errorf("ebook reader annotation store is not configured")
}
_, err := s.pool.Exec(ctx, `
INSERT INTO ebook_reader_annotations
(id, user_id, profile_id, content_id, kind, cfi_range, location,
selected_text, note, style, color, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), NULLIF($7, ''),
$8, $9, $10, $11, $12::jsonb, $13, $14)`,
annotation.ID,
annotation.UserID,
annotation.ProfileID,
annotation.ContentID,
annotation.Kind,
annotation.CFIRange,
annotation.Location,
annotation.SelectedText,
annotation.Note,
annotation.Style,
annotation.Color,
annotation.Metadata,
annotation.CreatedAt,
annotation.UpdatedAt,
)
if err != nil {
return fmt.Errorf("create ebook reader annotation: %w", err)
}
return nil
}
func (s *PGEbookReaderAnnotationStore) Update(
ctx context.Context,
userID int,
profileID string,
contentID string,
annotationID string,
merge func(existing EbookReaderAnnotation) (EbookReaderAnnotation, error),
) (*EbookReaderAnnotation, error) {
if s == nil || s.pool == nil {
return nil, fmt.Errorf("ebook reader annotation store is not configured")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin ebook reader annotation update: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
existing, err := scanEbookReaderAnnotation(tx.QueryRow(ctx, `
SELECT id, user_id, profile_id, content_id, kind,
COALESCE(cfi_range, ''), COALESCE(location, ''),
selected_text, note, style, color, metadata, created_at, updated_at
FROM ebook_reader_annotations
WHERE id = $1 AND user_id = $2 AND profile_id = $3 AND content_id = $4
FOR UPDATE`,
annotationID, userID, profileID, contentID,
))
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get ebook reader annotation for update: %w", err)
}
merged, err := merge(existing)
if err != nil {
return nil, err
}
updated, err := scanEbookReaderAnnotation(tx.QueryRow(ctx, `
UPDATE ebook_reader_annotations
SET kind = $5,
cfi_range = NULLIF($6, ''),
location = NULLIF($7, ''),
selected_text = $8,
note = $9,
style = $10,
color = $11,
metadata = $12::jsonb,
updated_at = $13
WHERE id = $1 AND user_id = $2 AND profile_id = $3 AND content_id = $4
RETURNING id, user_id, profile_id, content_id, kind,
COALESCE(cfi_range, ''), COALESCE(location, ''),
selected_text, note, style, color, metadata, created_at, updated_at`,
annotationID,
userID,
profileID,
contentID,
merged.Kind,
merged.CFIRange,
merged.Location,
merged.SelectedText,
merged.Note,
merged.Style,
merged.Color,
merged.Metadata,
merged.UpdatedAt,
))
if err != nil {
return nil, fmt.Errorf("update ebook reader annotation: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit ebook reader annotation update: %w", err)
}
return &updated, nil
}
func (s *PGEbookReaderAnnotationStore) Delete(ctx context.Context, userID int, profileID string, contentID string, annotationID string) error {
if s == nil || s.pool == nil {
return fmt.Errorf("ebook reader annotation store is not configured")
}
if _, err := s.pool.Exec(ctx, `
DELETE FROM ebook_reader_annotations
WHERE id = $1 AND user_id = $2 AND profile_id = $3 AND content_id = $4`,
annotationID, userID, profileID, contentID,
); err != nil {
return fmt.Errorf("delete ebook reader annotation: %w", err)
}
return nil
}
func scanEbookReaderAnnotation(scanner interface{ Scan(dest ...any) error }) (EbookReaderAnnotation, error) {
var annotation EbookReaderAnnotation
if err := scanner.Scan(
&annotation.ID,
&annotation.UserID,
&annotation.ProfileID,
&annotation.ContentID,
&annotation.Kind,
&annotation.CFIRange,
&annotation.Location,
&annotation.SelectedText,
&annotation.Note,
&annotation.Style,
&annotation.Color,
&annotation.Metadata,
&annotation.CreatedAt,
&annotation.UpdatedAt,
); err != nil {
return EbookReaderAnnotation{}, fmt.Errorf("scan ebook reader annotation: %w", err)
}
return annotation, nil
}
func (s *PGEbookReaderProgressStore) Get(ctx context.Context, userID int, profileID string, contentID string) (*EbookReaderProgress, error) {
if s == nil || s.pool == nil {
return nil, fmt.Errorf("ebook reader progress store is not configured")
}
var progress EbookReaderProgress
err := s.pool.QueryRow(ctx, `
SELECT user_id, profile_id, content_id, file_id, location, progress, updated_at
FROM ebook_reader_progress
WHERE user_id = $1 AND profile_id = $2 AND content_id = $3`,
userID, profileID, contentID,
).Scan(
&progress.UserID,
&progress.ProfileID,
&progress.ContentID,
&progress.FileID,
&progress.Location,
&progress.Progress,
&progress.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get ebook reader progress: %w", err)
}
return &progress, nil
}
// ListByContentIDs lists reading progress for a set of items, excluding rows
// hidden from history. The hidden gating mirrors the video path
// (internal/userstore/pgstore/progress.go): a row is hidden while its
// updated_at <= hidden_before, and counts again once progress is updated
// after the hide.
func (s *PGEbookReaderProgressStore) ListByContentIDs(ctx context.Context, userID int, profileID string, contentIDs []string) (map[string]EbookReaderProgress, error) {
if s == nil || s.pool == nil {
return nil, fmt.Errorf("ebook reader progress store is not configured")
}
if userID <= 0 || profileID == "" || len(contentIDs) == 0 {
return nil, nil
}
rows, err := s.pool.Query(ctx, `
SELECT user_id, profile_id, content_id, file_id, location, progress, updated_at
FROM ebook_reader_progress
WHERE user_id = $1 AND profile_id = $2 AND content_id = ANY($3::text[])
AND NOT EXISTS (
SELECT 1
FROM user_history_hidden_items hhi
WHERE hhi.user_id = ebook_reader_progress.user_id
AND hhi.profile_id = ebook_reader_progress.profile_id
AND hhi.media_item_id = ebook_reader_progress.content_id
AND ebook_reader_progress.updated_at <= hhi.hidden_before
)`,
userID, profileID, contentIDs,
)
if err != nil {
return nil, fmt.Errorf("list ebook reader progress: %w", err)
}
defer rows.Close()
progressByContentID := make(map[string]EbookReaderProgress, len(contentIDs))
for rows.Next() {
var progress EbookReaderProgress
if err := rows.Scan(
&progress.UserID,
&progress.ProfileID,
&progress.ContentID,
&progress.FileID,
&progress.Location,
&progress.Progress,
&progress.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan ebook reader progress: %w", err)
}
progressByContentID[progress.ContentID] = progress
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate ebook reader progress: %w", err)
}
return progressByContentID, nil
}
func (s *PGEbookReaderProgressStore) Delete(ctx context.Context, userID int, profileID string, contentID string) error {
if s == nil || s.pool == nil {
return fmt.Errorf("ebook reader progress store is not configured")
}
if _, err := s.pool.Exec(ctx, `
DELETE FROM ebook_reader_progress
WHERE user_id = $1 AND profile_id = $2 AND content_id = $3`,
userID, profileID, contentID,
); err != nil {
return fmt.Errorf("delete ebook reader progress: %w", err)
}
return nil
}
func (s *PGEbookReaderProgressStore) Upsert(ctx context.Context, progress EbookReaderProgress) error {
if s == nil || s.pool == nil {
return fmt.Errorf("ebook reader progress store is not configured")
}
// A routine autosave (e.g. reopening a finished book) must not silently drop
// a "finished" item below the threshold and un-mark it read; once finished,
// progress only moves on an explicit unread (which deletes the row). Below
// the threshold, progress tracks freely. Manga chapter ✓ marks ride on this
// same row, so the guard protects them too.
query := fmt.Sprintf(`
INSERT INTO ebook_reader_progress
(user_id, profile_id, content_id, file_id, location, progress, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_id, profile_id, content_id) DO UPDATE SET
file_id = EXCLUDED.file_id,
location = EXCLUDED.location,
progress = CASE
WHEN ebook_reader_progress.progress >= %[1]v AND EXCLUDED.progress < %[1]v
THEN ebook_reader_progress.progress
ELSE EXCLUDED.progress
END,
updated_at = EXCLUDED.updated_at`, models.EbookFinishedProgressThreshold)
if _, err := s.pool.Exec(ctx, query,
progress.UserID,
progress.ProfileID,
progress.ContentID,
progress.FileID,
progress.Location,
progress.Progress,
progress.UpdatedAt,
); err != nil {
return fmt.Errorf("upsert ebook reader progress: %w", err)
}
return nil
}