feat(audiobooks): port ABS handler scaffolding (mount/handler/filter)
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
7ea7609931
commit
226d721ea3
@@ -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
|
||||
}
|
||||
@@ -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=<kind>.<value>` 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 `<kind>.<value>` 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 `<kind>.<base64-value>`. 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
|
||||
}
|
||||
}
|
||||
@@ -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 "<scheme>://<host>/api/v1/plugins/<installID>".
|
||||
// - Standalone listener: returns "<scheme>://<host>" — 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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
Reference in New Issue
Block a user