`transfers.Registry` caps at 10,000 entries. Past that `Begin` returned `ErrRegistryFull`, and every call site logged at Debug and served the file anyway with its byte updates discarded. With no connection cap anywhere, one actor could pin the registry and blind download-class monitoring for everyone — which is the monitoring the abuse controls depend on. Per decision A7, saturation now fails closed and is unreachable by a single actor in the first place: - A per-user concurrent-transfer cap (`playback.max_user_concurrent_transfers`, default 24) checked before the global limit, so one actor exhausts its own budget rather than the shared registry. 24 leaves headroom for multi-connection downloaders, which open 4-8 sockets per file and take a transfer id per concurrent Range request. `MaxPerUser` is a *pointer* option because a plain int cannot distinguish "unset, use the default" from "explicitly 0, unlimited". - All five download-class call sites refuse rather than serve unmonitored: 429 `transfer_limit_exceeded` for the per-user cap, 503 `monitoring_unavailable` for a full registry, both with `Retry-After`. The handoff listed four sites; `internal/api/handlers/ebook_reader.go` was the missing fifth. - The ABS file handler now admits the transfer *before* setting Content-Disposition and the audio Content-Type, so a rejection is not mislabeled as an audio attachment. Two correctness fixes fall out of doing this properly: - `End` now decrements the per-user count only when it actually removed an entry, and drops the map entry at zero so neither counts nor warning timestamps grow unbounded. - The two download handlers registered `defer transfers.End(transfer.ID)` *before* the service called `Begin`, so a duplicate id would have removed another request's live record. `Begin` moves into the handler beside its `End`, and the service keeps its late DownloadID/MediaFileID enrichment through a new `Registry.Annotate`. Saturation is logged at Warn inside the registry, rate-limited per user and carrying the route and user id, rather than once per rejected request at each call site. A7 asked for Warn at the call sites, but an actor parked at its cap would then generate unbounded warning traffic — a log-amplification vector of its own. No information is lost: route and user id are already on the Transfer. 429/503 are new failure statuses on existing endpoints, not repurposed ones. silo-android and silo-apple need follow-up to retry with backoff on `Retry-After`; the cap and the fail-closed behavior are advertised on `GET /downloads/capability` so clients can feature-detect instead of probing. The jellycompat call site is verified by reading only: that test package does not compile on origin/main (pre-existing, unrelated). Part 3 of 3 for the Batch 4 liveness/replica work. Part of #305
1141 lines
38 KiB
Go
1141 lines
38 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 rejected",
|
|
"component", "ebooks",
|
|
"transfer_id", transfer.ID,
|
|
"error", err,
|
|
)
|
|
switch {
|
|
case errors.Is(err, transfers.ErrUserTransferLimit):
|
|
w.Header().Set("Retry-After", "5")
|
|
writeError(w, http.StatusTooManyRequests, "transfer_limit_exceeded", "Concurrent transfer limit exceeded")
|
|
case errors.Is(err, transfers.ErrRegistryFull):
|
|
w.Header().Set("Retry-After", "5")
|
|
writeError(w, http.StatusServiceUnavailable, "monitoring_unavailable", "Transfer monitoring unavailable")
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to monitor transfer")
|
|
}
|
|
return
|
|
}
|
|
}
|
|
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
|
|
}
|