From 226d721ea3bf3750bbc8dd13e1b82b1fb40c0fca Mon Sep 17 00:00:00 2001 From: RXWatcher <14085001+RXWatcher@users.noreply.github.com> Date: Sun, 24 May 2026 15:14:12 +0200 Subject: [PATCH] feat(audiobooks): port ABS handler scaffolding (mount/handler/filter) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1 of the audiobookshelf-compatible REST + Socket.io port from silo-plugin-audiobooks. Lands the package layout, Handler struct with interface stubs for silo-side dependencies (MediaStore, TokenStore, ProfileCredentialValidator, ConfigProvider, EventPublisher, Recommender), and an empty Mount() method. Real route handlers arrive in subsequent stages (auth, file serving, progress, browse). Plugin imports of store/backend/streaming/mediatoken/bookref/runtimehost are replaced with minimal interfaces; real silo implementations come when those interfaces are needed by specific route handlers. Also ports filter.go (ABS filter query parsing/matching), access_log.go (request logging via log/slog), minified.go (minified=1 response shaping), login_ratelimit.go (per-IP token bucket), jwt.go (JWT mint/parse), and types.go (shared ABS wire-format types and constants) — all clean of plugin-specific imports. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/audiobooks/abs/access_log.go | 71 +++++ internal/audiobooks/abs/filter.go | 135 +++++++++ internal/audiobooks/abs/handler.go | 322 +++++++++++++++++++++ internal/audiobooks/abs/jwt.go | 91 ++++++ internal/audiobooks/abs/login_ratelimit.go | 115 ++++++++ internal/audiobooks/abs/minified.go | 100 +++++++ internal/audiobooks/abs/types.go | 158 ++++++++++ 7 files changed, 992 insertions(+) create mode 100644 internal/audiobooks/abs/access_log.go create mode 100644 internal/audiobooks/abs/filter.go create mode 100644 internal/audiobooks/abs/handler.go create mode 100644 internal/audiobooks/abs/jwt.go create mode 100644 internal/audiobooks/abs/login_ratelimit.go create mode 100644 internal/audiobooks/abs/minified.go create mode 100644 internal/audiobooks/abs/types.go diff --git a/internal/audiobooks/abs/access_log.go b/internal/audiobooks/abs/access_log.go new file mode 100644 index 00000000..916c4a43 --- /dev/null +++ b/internal/audiobooks/abs/access_log.go @@ -0,0 +1,71 @@ +package abs + +import ( + "log/slog" + "net/http" + "strings" + "time" +) + +// accessLog is a minimal chi middleware that emits one structured line +// per request. The 2xx/3xx path logs at Debug so a default-Info runtime +// stays quiet during normal playback; non-2xx escalates to Warn so +// failures still surface without an explicit log-level flip. Path is +// captured query-less so ?token= and refresh tokens never land in logs. +func (h *Handler) accessLog(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + sw := &statusRecorder{ResponseWriter: w, status: 200} + next.ServeHTTP(sw, r) + + auth := r.Header.Get("Authorization") + authKind := "none" + switch { + case strings.HasPrefix(auth, "Bearer "): + authKind = "bearer" + case auth != "": + authKind = "other" + case r.URL.Query().Get("token") != "": + authKind = "qtok" + } + + // Short-circuit asset requests the mobile app never hits to keep + // the signal-to-noise high. + path := r.URL.Path + if strings.HasPrefix(path, "/assets/") { + return + } + + args := []any{ + "method", r.Method, + "path", path, + "auth", authKind, + "status", sw.status, + "dur_ms", time.Since(start).Milliseconds(), + } + if sw.status >= 400 { + slog.Warn("abs req failed", args...) + return + } + slog.Debug("abs req", append(args, "bytes", sw.bytes)...) + }) +} + +// statusRecorder lets the access log read the status code + bytes +// written without re-implementing http.ResponseWriter. +type statusRecorder struct { + http.ResponseWriter + status int + bytes int +} + +func (s *statusRecorder) WriteHeader(code int) { + s.status = code + s.ResponseWriter.WriteHeader(code) +} + +func (s *statusRecorder) Write(b []byte) (int, error) { + n, err := s.ResponseWriter.Write(b) + s.bytes += n + return n, err +} diff --git a/internal/audiobooks/abs/filter.go b/internal/audiobooks/abs/filter.go new file mode 100644 index 00000000..231666eb --- /dev/null +++ b/internal/audiobooks/abs/filter.go @@ -0,0 +1,135 @@ +package abs + +import ( + "encoding/base64" + "strings" +) + +// FilterKind is the leading segment of an ABS `filter=` query value. +type FilterKind string + +const ( + FilterAuthors FilterKind = "authors" + FilterSeries FilterKind = "series" + FilterNarrators FilterKind = "narrators" + FilterGenres FilterKind = "genres" + FilterProgress FilterKind = "progress" + FilterTags FilterKind = "tags" + FilterLanguages FilterKind = "languages" +) + +// SentinelNoSeries is the literal value real ABS clients send for "books +// without a series" — it is NOT base64-encoded, in contrast to ordinary +// series IDs which are. +const SentinelNoSeries = "no-series" + +// Filter describes a parsed ABS `filter=.` query parameter. +// Value is the post-decode value (base64-decoded for most kinds; sentinel +// values such as "no-series" are passed through). Raw preserves the +// original `.` for echoing back in pagination envelopes. +type Filter struct { + Kind FilterKind + Value string + Raw string +} + +// ParseFilter pulls apart an ABS `filter=` query value. Real ABS encodes the +// value as base64-then-URL-encoded — chi/http already URL-decodes the query, +// so the input we see is `.`. Two non-encoded special +// cases: the literal `no-series` sentinel and the `progress.*` family +// (in-progress / finished / not-finished). When the value isn't valid +// base64, we treat it as a sentinel and pass it through unchanged. +// +// Returns (Filter{}, false) when raw is empty or has no kind prefix. +func ParseFilter(raw string) (Filter, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return Filter{}, false + } + dot := strings.IndexByte(raw, '.') + if dot <= 0 || dot >= len(raw)-1 { + return Filter{}, false + } + kind := FilterKind(raw[:dot]) + rest := raw[dot+1:] + out := Filter{Kind: kind, Raw: raw} + + // progress.* and the no-series sentinel are never base64-encoded by + // real ABS clients. + if kind == FilterProgress || rest == SentinelNoSeries { + out.Value = rest + return out, true + } + + if b, err := base64.RawURLEncoding.DecodeString(rest); err == nil && len(b) > 0 { + out.Value = string(b) + return out, true + } + if b, err := base64.RawStdEncoding.DecodeString(rest); err == nil && len(b) > 0 { + out.Value = string(b) + return out, true + } + if b, err := base64.StdEncoding.DecodeString(rest); err == nil && len(b) > 0 { + out.Value = string(b) + return out, true + } + // Fall through — accept the raw string as a sentinel. Future ABS + // versions may add more sentinels (mirroring how no-series escaped the + // base64 encoding); this keeps us forward-compatible. + out.Value = rest + return out, true +} + +// Matches reports whether the given LibraryItem satisfies this filter. genres +// is best-effort — backend summaries don't carry genres, so a genres filter +// matches nothing unless the caller pre-populated the field via detail +// lookups (we currently don't). tags / languages behave the same way and +// are accepted for forward-compat but always return false. +// +// Progress filters require the caller to supply an optional `inProgress` +// and `finished` flag for the book; without them the progress branch +// returns false. The caller is responsible for joining progress state from +// the store. +func (f Filter) Matches(item LibraryItem, inProgress, finished bool, hasProgress bool) bool { + switch f.Kind { + case FilterAuthors: + for _, a := range item.Media.Metadata.Authors { + if a.ID == f.Value || a.Name == f.Value { + return true + } + } + return false + case FilterSeries: + if f.Value == SentinelNoSeries { + return len(item.Media.Metadata.Series) == 0 + } + for _, s := range item.Media.Metadata.Series { + if s.ID == f.Value || s.Name == f.Value { + return true + } + } + return false + case FilterNarrators: + for _, n := range item.Media.Metadata.Narrators { + if n == f.Value { + return true + } + } + return false + case FilterProgress: + switch f.Value { + case "in-progress": + return hasProgress && inProgress && !finished + case "finished": + return hasProgress && finished + case "not-finished": + return !finished + case "not-started": + return !hasProgress + } + return false + default: + // genres / tags / languages — not derivable from a summary today. + return false + } +} diff --git a/internal/audiobooks/abs/handler.go b/internal/audiobooks/abs/handler.go new file mode 100644 index 00000000..d0ee2512 --- /dev/null +++ b/internal/audiobooks/abs/handler.go @@ -0,0 +1,322 @@ +// Package abs implements the Audiobookshelf-mobile-app compatibility surface. +// It mints self-contained JWTs signed with a per-deployment secret and serves +// the /abs/api/* and /abs/public/* routes, as well as the canonical +// root-level paths real ABS clients build against (e.g. /login, /api/items). +// +// Stage 1 lands the package skeleton: Handler struct, interface stubs for +// silo-side dependencies, and an empty Mount() method. Real route handlers +// are added in subsequent stages (auth, file serving, progress, browse). +package abs + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/models" +) + +// --------------------------------------------------------------------------- +// Dependency interfaces +// --------------------------------------------------------------------------- + +// MediaStore is the slice of silo's catalog the ABS handler reads. +// Real impl: catalog.ItemRepository + scanner.FileRepository wrapped in +// a small adapter struct added in a later stage. +type MediaStore interface { + GetAudiobookByID(ctx context.Context, contentID string) (*models.MediaItem, error) + ListAudiobooks(ctx context.Context, limit, offset int) ([]*models.MediaItem, int, error) + GetMediaFiles(ctx context.Context, contentID string) ([]*models.MediaFile, error) +} + +// TokenStore persists and validates the ABS JWT JTIs that back the +// revocable-token surface (login, refresh, logout, bearerAuth). +// Real impl: a thin repo over the abs_tokens table added in Stage 2. +type TokenStore interface { + // InsertToken persists a newly minted JTI. + InsertToken(ctx context.Context, tok ABSToken) error + // GetTokenByJTI looks up a token by its JTI; returns ErrNotFound if absent. + GetTokenByJTI(ctx context.Context, jti string) (ABSToken, error) + // RevokeTokenByJTI marks a JTI as revoked (sets revoked_at). + RevokeTokenByJTI(ctx context.Context, jti string) error + // TouchToken extends last_seen_at for active-session bookkeeping. + TouchToken(ctx context.Context, jti string) error +} + +// ABSToken is the in-memory representation of a persisted JTI row. +type ABSToken struct { + ID string + UserID string + ProfileID string + JTI string + ExpiresAt time.Time + RevokedAt *time.Time +} + +// ProfileCredentialValidator validates a (username, password) pair against +// silo's auth backend. Implemented by an adapter over internal/auth in a +// later stage. +type ProfileCredentialValidator interface { + Validate(ctx context.Context, username, password string) (userID string, profileID string, displayName string, err error) +} + +// EventPublisher delivers a realtime event to Socket.io clients. May be nil; +// handlers guard with publish/broadcast nil-safe wrappers. +type EventPublisher interface { + Publish(userID, event string, payload any) + Broadcast(event string, payload any) +} + +// Recommender powers /items/{id}/similar. nil → route returns an empty list. +type Recommender interface { + Similar(ctx context.Context, contentID string, limit int) ([]string, error) +} + +// --------------------------------------------------------------------------- +// Config provider +// --------------------------------------------------------------------------- + +// ConfigProvider supplies runtime config values the ABS handler needs. +// Keeps the handler decoupled from any particular settings-store shape. +type ConfigProvider interface { + // JWTSecret returns the HMAC-SHA256 signing secret for ABS JWTs. + JWTSecret(ctx context.Context) ([]byte, error) + // AccessTTL / RefreshTTL are the default token lifetimes; zero means + // "use built-in default (24 h / 30 d)". + AccessTTL(ctx context.Context) (time.Duration, error) + RefreshTTL(ctx context.Context) (time.Duration, error) + // StandaloneLoginEnabled reports whether body-creds login is permitted + // (i.e., operator has not disabled it in settings). + StandaloneLoginEnabled(ctx context.Context) (bool, error) +} + +// --------------------------------------------------------------------------- +// Dependencies + Handler +// --------------------------------------------------------------------------- + +// Dependencies bundles everything the Handler needs at construction time. +type Dependencies struct { + MediaStore MediaStore + TokenStore TokenStore + CredValidator ProfileCredentialValidator + Config ConfigProvider + Publisher EventPublisher // may be nil + Recommender Recommender // may be nil + LoginLimiter *LoginLimiter // may be nil — one is created if absent + // InstallID returns the current plugin install ID for building + // host-proxy-routable URLs. Defaults to "silo.audiobooks" when nil. + InstallID func() string +} + +// Handler wires the /abs/api/* and canonical ABS-client paths. +type Handler struct { + deps Dependencies +} + +// New constructs an ABS Handler. Sensible defaults are applied for optional +// fields (LoginLimiter, InstallID). +func New(deps Dependencies) *Handler { + if deps.LoginLimiter == nil { + deps.LoginLimiter = NewLoginLimiter() + } + if deps.InstallID == nil { + deps.InstallID = func() string { return "silo.audiobooks" } + } + return &Handler{deps: deps} +} + +// --------------------------------------------------------------------------- +// Mount +// --------------------------------------------------------------------------- + +// Mount registers the ABS-compatible routes on r. Stage 1 registers an empty +// /abs group with the access-log middleware attached; subsequent stages add +// real route handlers. +// +// The dual-mount design (routes at both /abs/api/* and /* roots) is preserved +// here so stage-by-stage handlers land in the right places without needing to +// revisit Mount later. +func (h *Handler) Mount(parent chi.Router) { + parent.Group(func(r chi.Router) { + r.Use(h.accessLog) + h.mountRoutes(r) + }) +} + +func (h *Handler) mountRoutes(_ chi.Router) { + // TODO Stage 2: auth routes (login, logout, refresh, authorize, me, ping, status, init) + // TODO Stage 3: library browse routes (libraries, items, item detail, cover, authors, series, search, personalized) + // TODO Stage 4: playback session + file routes (play, file/download, public/track) + // TODO Stage 5: progress routes (me/progress/*, me/items-in-progress, me/listening-stats, me/stats/year/*) + // TODO Stage 6: social / collection routes (bookmarks, smart-collections, collections, playlists, RSS feeds, similar) +} + +// --------------------------------------------------------------------------- +// Auth context helpers (used by bearerAuth middleware + handlers) +// --------------------------------------------------------------------------- + +// ctxKey is the unexported ABS auth context key. +type ctxKey struct{} + +// ctxAuth carries the decoded ABS JWT claims for the lifetime of a request. +type ctxAuth struct { + UserID string + ProfileID string + JTI string + Token string // raw bearer token +} + +// absAuthFrom extracts ABS auth from the request context. Returns (zero, false) +// when bearerAuth middleware hasn't run (unauthenticated routes). +func absAuthFrom(r *http.Request) (ctxAuth, bool) { + a, ok := r.Context().Value(ctxKey{}).(ctxAuth) + return a, ok +} + +// bearerAuth is the authentication middleware for protected ABS routes. +// It reads the bearer token from the Authorization header or ?token= query +// param, validates the JWT, checks the JTI isn't revoked, and injects +// ctxAuth into the request context. +// +// Placeholder implementation — full validation logic lands in Stage 2 when +// TokenStore and ConfigProvider are wired to real backing stores. +func (h *Handler) bearerAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if raw == "" { + raw = r.URL.Query().Get("token") + } + if raw == "" { + http.Error(w, "unauthenticated", http.StatusUnauthorized) + return + } + if h.deps.Config == nil || h.deps.TokenStore == nil { + // Dependencies not yet wired — reject to avoid a security hole. + http.Error(w, "auth not configured", http.StatusServiceUnavailable) + return + } + secret, err := h.deps.Config.JWTSecret(r.Context()) + if err != nil { + http.Error(w, "config unavailable", http.StatusInternalServerError) + return + } + claims, err := ParseToken(secret, raw) + if err != nil || claims.Type != "access" { + http.Error(w, "invalid token", http.StatusUnauthorized) + return + } + row, err := h.deps.TokenStore.GetTokenByJTI(r.Context(), claims.JTI) + if err != nil || row.RevokedAt != nil { + http.Error(w, "token revoked", http.StatusUnauthorized) + return + } + _ = h.deps.TokenStore.TouchToken(r.Context(), claims.JTI) + ctx := context.WithValue(r.Context(), ctxKey{}, ctxAuth{ + UserID: claims.UserID, + ProfileID: claims.ProfileID, + JTI: claims.JTI, + Token: raw, + }) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// --------------------------------------------------------------------------- +// Publisher nil-safe wrappers +// --------------------------------------------------------------------------- + +func (h *Handler) publish(userID, event string, payload any) { + if h.deps.Publisher == nil { + return + } + h.deps.Publisher.Publish(userID, event, payload) +} + +func (h *Handler) broadcast(event string, payload any) { + if h.deps.Publisher == nil { + return + } + h.deps.Publisher.Broadcast(event, payload) +} + +// --------------------------------------------------------------------------- +// URL helpers +// --------------------------------------------------------------------------- + +// absBaseURL returns the server address prefix ABS clients should use to +// resolve response-embedded URLs. +// +// - Host-proxied (X-Silo-User-Id header present): returns the plugin-proxy +// path ":///api/v1/plugins/". +// - Standalone listener: returns "://" — origin only. +// +// Honors X-Forwarded-Proto / X-Forwarded-Host for TLS-terminating proxies. +func (h *Handler) absBaseURL(r *http.Request) string { + scheme := r.Header.Get("X-Forwarded-Proto") + if scheme == "" { + if r.TLS != nil { + scheme = "https" + } else { + scheme = "http" + } + } + host := r.Header.Get("X-Forwarded-Host") + if host == "" { + host = r.Host + } + if r.Header.Get("X-Silo-User-Id") != "" { + return scheme + "://" + host + "/api/v1/plugins/" + h.deps.InstallID() + } + return scheme + "://" + host +} + +// --------------------------------------------------------------------------- +// Shared response helpers (used by handlers across multiple stages) +// --------------------------------------------------------------------------- + +// writeJSON serialises v as JSON and writes it with the given HTTP status. +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// readPagedQuery extracts `limit` and `page` from query params. Real ABS +// treats limit=0 as "return all" (not "return zero rows") — we surface that +// intent and let callers short-circuit pagination. +func readPagedQuery(r *http.Request, defaultLimit int) (limit, page int) { + limit = defaultLimit + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + limit = n + } + } + if v := r.URL.Query().Get("page"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + page = n + } + } + return limit, page +} + +// pagedEnvelope builds the standard ABS pagination response shape. All eight +// fields are always emitted (no omitempty) because ABS clients branch on +// their presence (sortBy, filterBy, minified). +func pagedEnvelope(results any, total, limit, page int, sortBy string, sortDesc bool, filterBy string, minified bool, include string) map[string]any { + return map[string]any{ + "results": results, + "total": total, + "limit": limit, + "page": page, + "sortBy": sortBy, + "sortDesc": sortDesc, + "filterBy": filterBy, + "minified": minified, + "include": include, + } +} diff --git a/internal/audiobooks/abs/jwt.go b/internal/audiobooks/abs/jwt.go new file mode 100644 index 00000000..77c6d554 --- /dev/null +++ b/internal/audiobooks/abs/jwt.go @@ -0,0 +1,91 @@ +// Package abs — JWT minting and validation for the ABS-compat layer. +package abs + +import ( + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// Claims are the unified ABS JWT claim set. Different `Type` values denote +// access, refresh, or session tokens. +type Claims struct { + Type string `json:"type"` // access | refresh | session + UserID string `json:"sub"` // user id + ProfileID string `json:"pid,omitempty"` // empty = primary profile + JTI string `json:"jti"` // token id (revocable) + DeviceID string `json:"device_id,omitempty"` + SessionID string `json:"sid,omitempty"` + BookID string `json:"bid,omitempty"` + FileIdx int `json:"fidx,omitempty"` + jwt.RegisteredClaims +} + +// IssueAccessToken mints a stateless access JWT. +func IssueAccessToken(secret []byte, userID, profileID, jti string, ttl time.Duration) (string, error) { + return issueJWT(secret, Claims{ + Type: "access", + UserID: userID, + ProfileID: profileID, + JTI: jti, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + }) +} + +// IssueRefreshToken mints a refresh JWT. +func IssueRefreshToken(secret []byte, userID, profileID, jti string, ttl time.Duration) (string, error) { + return issueJWT(secret, Claims{ + Type: "refresh", + UserID: userID, + ProfileID: profileID, + JTI: jti, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + }) +} + +// IssueSessionToken mints a streaming-capability JWT used in the public route. +func IssueSessionToken(secret []byte, userID, sessionID, bookID string, fileIdx int, ttl time.Duration) (string, error) { + return issueJWT(secret, Claims{ + Type: "session", + UserID: userID, + SessionID: sessionID, + BookID: bookID, + FileIdx: fileIdx, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + }) +} + +// ParseToken validates and decodes a JWT. Returns an error on signature +// mismatch or expiry. +func ParseToken(secret []byte, raw string) (*Claims, error) { + claims := &Claims{} + tok, err := jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) { + if t.Method.Alg() != jwt.SigningMethodHS256.Alg() { + return nil, fmt.Errorf("unexpected signing method %v", t.Header["alg"]) + } + return secret, nil + }) + if err != nil { + return nil, err + } + if !tok.Valid { + return nil, errors.New("token invalid") + } + return claims, nil +} + +func issueJWT(secret []byte, c Claims) (string, error) { + t := jwt.NewWithClaims(jwt.SigningMethodHS256, c) + return t.SignedString(secret) +} diff --git a/internal/audiobooks/abs/login_ratelimit.go b/internal/audiobooks/abs/login_ratelimit.go new file mode 100644 index 00000000..6f378d6d --- /dev/null +++ b/internal/audiobooks/abs/login_ratelimit.go @@ -0,0 +1,115 @@ +package abs + +import ( + "net" + "net/http" + "strings" + "sync" + "time" + + "golang.org/x/time/rate" +) + +// loginLimitBurst caps the number of body-creds /login attempts a single +// source IP can make in quick succession. The token bucket refills at +// loginLimitPerToken (10/min ≈ one every 6s), so a bursty client can spend +// the burst and then must wait. Tuned to be invisible to legitimate listeners +// (who attempt login once and succeed) while making credential-stuffing +// expensive. +const ( + loginLimitBurst = 10 + loginLimitPerToken = 6 * time.Second + loginLimitIdle = 10 * time.Minute + loginLimitGCEvery = 5 * time.Minute +) + +type loginLimiterEntry struct { + limiter *rate.Limiter + last time.Time +} + +// LoginLimiter is a process-local per-IP rate limiter for the standalone-port +// body-creds login path. The header-authenticated path is never gated here — +// that traffic comes from the trusted silo host proxy. +// +// Construct one per process (in server wiring) and inject via +// Dependencies.LoginLimiter. Constructing one per Handler would leak a +// janitor goroutine on every reconfigure. +type LoginLimiter struct { + mu sync.Mutex + buckets map[string]*loginLimiterEntry + stopCh chan struct{} +} + +// NewLoginLimiter builds a limiter and starts its background janitor. The +// janitor exits when Stop() is called. +func NewLoginLimiter() *LoginLimiter { + l := &LoginLimiter{ + buckets: make(map[string]*loginLimiterEntry), + stopCh: make(chan struct{}), + } + go l.janitor() + return l +} + +// Stop terminates the janitor goroutine. Safe to call once. +func (l *LoginLimiter) Stop() { close(l.stopCh) } + +func (l *LoginLimiter) allow(key string) bool { + if key == "" { + return true + } + l.mu.Lock() + e, ok := l.buckets[key] + if !ok { + e = &loginLimiterEntry{ + limiter: rate.NewLimiter(rate.Every(loginLimitPerToken), loginLimitBurst), + } + l.buckets[key] = e + } + e.last = time.Now() + lim := e.limiter + l.mu.Unlock() + return lim.Allow() +} + +func (l *LoginLimiter) janitor() { + ticker := time.NewTicker(loginLimitGCEvery) + defer ticker.Stop() + for { + select { + case <-l.stopCh: + return + case <-ticker.C: + cutoff := time.Now().Add(-loginLimitIdle) + l.mu.Lock() + for k, e := range l.buckets { + if e.last.Before(cutoff) { + delete(l.buckets, k) + } + } + l.mu.Unlock() + } + } +} + +// clientIP returns the rate-limit key for a request. The standalone listener +// terminates TCP directly with the client, so r.RemoteAddr is the real client +// IP. Honors X-Forwarded-For's first hop as a defensive fallback for operators +// who put their own reverse proxy in front of the standalone listener; only +// the first hop is trusted (the rest is client-supplied). +func clientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if i := strings.IndexByte(xff, ','); i >= 0 { + xff = xff[:i] + } + if v := strings.TrimSpace(xff); v != "" { + return v + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} diff --git a/internal/audiobooks/abs/minified.go b/internal/audiobooks/abs/minified.go new file mode 100644 index 00000000..133d1f37 --- /dev/null +++ b/internal/audiobooks/abs/minified.go @@ -0,0 +1,100 @@ +package abs + +import "strings" + +// MinifiedLibraryItem mirrors real-ABS's `minified=1` response shape. The +// big-ticket changes from the full LibraryItem: media.metadata.authors and +// media.metadata.series arrays are omitted; the metadata block grows flat +// authorName / authorNameLF / seriesName / seriesSequence fields built from +// the same source data; chapters and audioFiles are dropped. +// +// Real ABS clients sniff for these fields by name. Emitting the full shape +// for a minified request works (clients ignore extra fields) but is wasteful +// on the wire when a client is paging through hundreds of items. Emitting +// the minified shape for a full request silently breaks detail pages. +type MinifiedLibraryItem struct { + ID string `json:"id"` + LibraryID string `json:"libraryId"` + FolderID string `json:"folderId"` + MediaType string `json:"mediaType"` + Media minifiedMedia `json:"media"` + NumTracks int `json:"numTracks,omitempty"` + AddedAt int64 `json:"addedAt"` + UpdatedAt int64 `json:"updatedAt"` +} + +type minifiedMedia struct { + Metadata minifiedMetadata `json:"metadata"` + Duration float64 `json:"duration"` + CoverPath string `json:"coverPath"` +} + +type minifiedMetadata struct { + Title string `json:"title"` + AuthorName string `json:"authorName"` + AuthorNameLF string `json:"authorNameLF"` + SeriesName string `json:"seriesName,omitempty"` + SeriesSequence string `json:"seriesSequence,omitempty"` + Narrators []string `json:"narrators,omitempty"` + PublishedYear string `json:"publishedYear,omitempty"` +} + +// Minify projects a LibraryItem onto the minified shape. The original item +// is not mutated. authorName joins authors with ", "; authorNameLF inverts +// the standard "First Last" → "Last, First" form when there's a space, and +// joins multiple authors with " & " (matching the convention real ABS uses +// for shelf sort labels). +func Minify(item LibraryItem) MinifiedLibraryItem { + m := item.Media.Metadata + names := make([]string, 0, len(m.Authors)) + lfNames := make([]string, 0, len(m.Authors)) + for _, a := range m.Authors { + if a.Name == "" { + continue + } + names = append(names, a.Name) + lfNames = append(lfNames, lastFirst(a.Name)) + } + seriesName, seriesSeq := "", "" + if len(m.Series) > 0 { + s := m.Series[0] + seriesName = s.Name + seriesSeq = s.Sequence + } + return MinifiedLibraryItem{ + ID: item.ID, + LibraryID: item.LibraryID, + FolderID: item.FolderID, + MediaType: item.MediaType, + Media: minifiedMedia{ + Metadata: minifiedMetadata{ + Title: m.Title, + AuthorName: strings.Join(names, ", "), + AuthorNameLF: strings.Join(lfNames, " & "), + SeriesName: seriesName, + SeriesSequence: seriesSeq, + Narrators: m.Narrators, + PublishedYear: m.PublishedYear, + }, + Duration: item.Media.Duration, + CoverPath: item.Media.CoverPath, + }, + NumTracks: item.NumTracks, + AddedAt: item.AddedAt, + UpdatedAt: item.UpdatedAt, + } +} + +// lastFirst flips a "First Middle Last" name into "Last, First Middle". For +// single-token names it returns them unchanged. Empty inputs return empty. +func lastFirst(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "" + } + last := strings.LastIndexByte(name, ' ') + if last <= 0 || last == len(name)-1 { + return name + } + return name[last+1:] + ", " + name[:last] +} diff --git a/internal/audiobooks/abs/types.go b/internal/audiobooks/abs/types.go new file mode 100644 index 00000000..3f85a5f9 --- /dev/null +++ b/internal/audiobooks/abs/types.go @@ -0,0 +1,158 @@ +package abs + +// ABS wire-format constants. ServerVersion must be ≥ 2.26.0 for the official +// ABS mobile app to take its JWT path; below that it falls into "old token" +// mode and rejects modern refresh-token semantics. +// Ref: /opt/audiobookshelf-app/components/connect/ServerConnectForm.vue:731 +const ( + VirtualLibraryID = "silo-audiobooks" + VirtualLibraryName = "Audiobooks" + VirtualFolderID = "main" + LibraryMediaType = "book" + ServerVersion = "2.35.0" + ServerSourceTag = "silo" +) + +// AuthorObj is the ABS-shaped author reference. ABS clients filter by id; +// some screens render only name. +type AuthorObj struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// SeriesObj is the ABS-shaped series reference; Sequence is the per-book +// position string (e.g. "1", "1.5"). +type SeriesObj struct { + ID string `json:"id"` + Name string `json:"name"` + Sequence string `json:"sequence,omitempty"` +} + +// ChapterABS is the ABS chapter shape (start/end in seconds, float). +type ChapterABS struct { + ID int `json:"id"` + Start float64 `json:"start"` + End float64 `json:"end"` + Title string `json:"title"` +} + +// AudioTrackMetadata is the file-level metadata block nested inside each +// AudioTrack. The mobile downloader reads filename/ext to name the local +// copy, size to budget storage, and the mtime fields for cache invalidation. +type AudioTrackMetadata struct { + Filename string `json:"filename"` + Ext string `json:"ext"` + Path string `json:"path"` + RelPath string `json:"relPath"` + Size int64 `json:"size"` + MtimeMs int64 `json:"mtimeMs"` + CtimeMs int64 `json:"ctimeMs"` + BirthtimeMs int64 `json:"birthtimeMs"` +} + +// AudioTrack is a single playable file as the ABS mobile client expects to +// see it. The shape is rich because the official audiobookshelf-app's Vue +// layer reads many fields off each track — ino + metadata for download URL +// construction and offline-cache decisions, bitRate / channels / codec / +// format for the "Now Playing" detail UI, embeddedCoverArt for whether to +// fall back to the item-level cover, metaTags for ID3-style display. A +// missing key on any of those code paths makes the player silently abort +// the audio load (the "spinner forever" we kept chasing before). +type AudioTrack struct { + Index int `json:"index"` + Ino string `json:"ino"` + Metadata *AudioTrackMetadata `json:"metadata,omitempty"` + AddedAt int64 `json:"addedAt,omitempty"` + UpdatedAt int64 `json:"updatedAt,omitempty"` + TrackNumFromMeta *int `json:"trackNumFromMeta"` + DiscNumFromMeta *int `json:"discNumFromMeta"` + TrackNumFromFilename *int `json:"trackNumFromFilename"` + DiscNumFromFilename *int `json:"discNumFromFilename"` + ManuallyVerified bool `json:"manuallyVerified"` + Exclude bool `json:"exclude"` + Error *string `json:"error"` + Format string `json:"format,omitempty"` + Duration float64 `json:"duration"` + BitRate int `json:"bitRate,omitempty"` + Language *string `json:"language"` + Codec string `json:"codec,omitempty"` + TimeBase string `json:"timeBase,omitempty"` + Channels int `json:"channels,omitempty"` + ChannelLayout string `json:"channelLayout,omitempty"` + Chapters []ChapterABS `json:"chapters,omitempty"` + EmbeddedCoverArt any `json:"embeddedCoverArt"` + MetaTags map[string]string `json:"metaTags,omitempty"` + MimeType string `json:"mimeType"` + Title string `json:"title,omitempty"` + StartOffset float64 `json:"startOffset"` + ContentURL string `json:"contentUrl"` +} + +// Metadata is the book-level metadata block. Authors / Narrators / Series +// match the ABS spec: arrays of references (or strings for Narrators). +type Metadata struct { + Title string `json:"title"` + Authors []AuthorObj `json:"authors"` + Narrators []string `json:"narrators"` + Series []SeriesObj `json:"series"` + Description string `json:"description,omitempty"` + PublishedYear string `json:"publishedYear,omitempty"` + ISBN string `json:"isbn,omitempty"` + Publisher string `json:"publisher,omitempty"` + Genres []string `json:"genres,omitempty"` +} + +// LibraryItemMedia carries the bulk of the audiobook metadata. +// +// ABS distinguishes between audioFiles (file-level metadata) and tracks +// (the playback ordering the player iterates). For most audiobooks they're +// the same slice; we emit both because the item-detail page reads +// media.tracks.length to decide whether to render the play button, while +// card/list views read media.numTracks. +type LibraryItemMedia struct { + Metadata Metadata `json:"metadata"` + Duration float64 `json:"duration"` + CoverPath string `json:"coverPath"` + AudioFiles []AudioTrack `json:"audioFiles"` + Tracks []AudioTrack `json:"tracks"` + Chapters []ChapterABS `json:"chapters"` + NumTracks int `json:"numTracks"` +} + +// CollapsedSeriesV1 is the per-item annotation real ABS attaches when +// collapseseries=1. The shape is "name + count + per-book books[]"; we emit +// a stable subset since clients differ on which fields they read. +type CollapsedSeriesV1 struct { + ID string `json:"id"` + Name string `json:"name"` + NameIgnorePrefix string `json:"nameIgnorePrefix,omitempty"` + NumBooks int `json:"numBooks"` + LibraryItemIDs []string `json:"libraryItemIds"` +} + +// LibraryItem is the ABS-shaped audiobook summary. AddedAt / UpdatedAt are +// Unix milliseconds; some shelves on the home screen sort by these and +// clients also expect them as ints (not strings). +// +// CollapsedSeries is non-nil only on items returned with collapseseries=1. +// It folds every book in a series into a single representative entry. ABS +// clients pattern-match on the presence of this field to switch from "list +// of books" to "list of series" UI. +type LibraryItem struct { + ID string `json:"id"` + LibraryID string `json:"libraryId"` + FolderID string `json:"folderId"` + MediaType string `json:"mediaType"` + // IsMissing / IsInvalid are gating fields the ABS mobile client checks + // before rendering the play affordance. We always emit them (no omitempty) + // so the client never sees them as undefined; the catalog we serve is by + // definition present and valid. + // Ref: /opt/audiobookshelf-app/pages/item/_id/index.vue:445 + IsMissing bool `json:"isMissing"` + IsInvalid bool `json:"isInvalid"` + Media LibraryItemMedia `json:"media"` + NumTracks int `json:"numTracks,omitempty"` + AddedAt int64 `json:"addedAt"` + UpdatedAt int64 `json:"updatedAt"` + CollapsedSeries *CollapsedSeriesV1 `json:"collapsedSeries,omitempty"` +}