diff --git a/internal/audiobooks/abs/handler.go b/internal/audiobooks/abs/handler.go index d99b5ca3..aec54411 100644 --- a/internal/audiobooks/abs/handler.go +++ b/internal/audiobooks/abs/handler.go @@ -11,6 +11,7 @@ package abs import ( "context" "encoding/json" + "log/slog" "net/http" "strconv" "strings" @@ -49,6 +50,39 @@ type MediaStore interface { GetMediaFileByID(ctx context.Context, fileID int) (*models.MediaFile, error) // ListAudiobookLibraries returns media_folder rows with type='audiobooks'. ListAudiobookLibraries(ctx context.Context) ([]AudiobookLibrary, error) + // SearchAudiobooks does a fuzzy title/author/narrator match for the ABS + // /libraries/{id}/search endpoint. Hydrates People so the mapper has + // author/narrator names. + SearchAudiobooks(ctx context.Context, libraryID int64, query string, limit int) ([]*models.MediaItem, error) + // ListContinueListening returns books that the given user has progress + // on but hasn't finished — feeds the Home tab's continue shelf. + ListContinueListening(ctx context.Context, userID, profileID string, libraryID int64, limit int) ([]*models.MediaItem, error) + // ListRecentlyAdded returns the most recently added audiobooks for the + // Home tab's recently-added shelf. + ListRecentlyAdded(ctx context.Context, libraryID int64, limit int) ([]*models.MediaItem, error) + // ListDiscover returns a randomized sampling of audiobooks for the + // Home tab's discover shelf (helps new users browse the library). + ListDiscover(ctx context.Context, libraryID int64, limit int) ([]*models.MediaItem, error) + // ListLibraryAuthors returns distinct authors of audiobooks in the + // library along with each author's book count. + ListLibraryAuthors(ctx context.Context, libraryID int64, limit int) ([]AuthorSummary, error) + // ListLibrarySeries returns distinct series (from audiobook_series) + // represented in the library, ordered by name. + ListLibrarySeries(ctx context.Context, libraryID int64, limit int) ([]SeriesSummary, error) +} + +// AuthorSummary is an aggregated author entry for /libraries/{id}/authors. +type AuthorSummary struct { + ID string + Name string + NumBooks int +} + +// SeriesSummary is an aggregated series entry for /libraries/{id}/series. +type SeriesSummary struct { + ID string + Name string + NumBooks int } // TokenStore persists and validates the ABS JWT JTIs that back the @@ -147,6 +181,11 @@ type Dependencies struct { // SocketIO is the Socket.io server mounted at /abs/socket.io/. May be nil; // the route is only registered when a non-nil value is supplied. SocketIO SocketIOServer + // CoverResolver translates a raw silo poster path (e.g. + // "local/audiobooks/.../original.webp") into a fully-qualified URL + // the ABS client can fetch. Optional; when nil, /api/items/{id}/cover + // 404s rather than redirecting to an unreachable relative path. + CoverResolver func(ctx context.Context, path, variant string) string } // Handler wires the /abs/api/* and canonical ABS-client paths. @@ -185,6 +224,17 @@ func (h *Handler) Mount(parent chi.Router) { } func (h *Handler) mountRoutes(r chi.Router) { + // Discovery + auth endpoints: real ABS exposes these at server ROOT + // (no /api or /abs/api prefix). Mobile clients do `${addr}/ping`, + // `${addr}/login`, etc. Designed to be mounted on a dedicated listener + // so the routes don't collide with silo's SPA catch-all. + for _, prefix := range []string{"", "/abs/api"} { + r.Get(prefix+"/ping", h.handleABSPing) + r.Get(prefix+"/healthcheck", h.handleABSPing) // same body as /ping + r.Get(prefix+"/init", h.handleABSInit) + r.Get(prefix+"/status", h.handleABSStatus) + } + // Stage 2: login (body-creds + host-proxied paths). r.Post("/login", h.handleLogin) r.Post("/abs/api/login", h.handleLogin) @@ -240,6 +290,9 @@ func (h *Handler) mountRoutes(r chi.Router) { for _, prefix := range []string{"/abs/api", "/api"} { // Current user object. r.Get(prefix+"/me", h.handleMe) + // Real-ABS /authorize: validates the bearer and re-mints the + // /me envelope so the client can resume without retyping creds. + r.Post(prefix+"/authorize", h.handleABSAuthorize) // Continue Listening shelf. r.Get(prefix+"/me/items-in-progress", h.handleItemsInProgress) // Library list + detail. @@ -305,26 +358,44 @@ func (h *Handler) bearerAuth(next http.Handler) http.Handler { raw = r.URL.Query().Get("token") } if raw == "" { + slog.Debug("abs bearerAuth: no token", "path", r.URL.Path, "remote", r.RemoteAddr) 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. + slog.Warn("abs bearerAuth: deps not wired", + "have_config", h.deps.Config != nil, + "have_token_store", h.deps.TokenStore != nil, + "path", r.URL.Path) http.Error(w, "auth not configured", http.StatusServiceUnavailable) return } secret, err := h.deps.Config.JWTSecret(r.Context()) if err != nil { + slog.Error("abs bearerAuth: jwt secret fetch failed", "err", err) http.Error(w, "config unavailable", http.StatusInternalServerError) return } claims, err := ParseToken(secret, raw) - if err != nil || claims.Type != "access" { + if err != nil { + slog.Debug("abs bearerAuth: parse failed", "err", err, "path", r.URL.Path) + http.Error(w, "invalid token", http.StatusUnauthorized) + return + } + if claims.Type != "access" { + slog.Debug("abs bearerAuth: wrong token type", "type", claims.Type, "path", r.URL.Path) http.Error(w, "invalid token", http.StatusUnauthorized) return } row, err := h.deps.TokenStore.GetTokenByJTI(r.Context(), claims.JTI) - if err != nil || row.RevokedAt != nil { + if err != nil { + slog.Debug("abs bearerAuth: jti lookup failed", + "jti", claims.JTI, "err", err, "path", r.URL.Path) + http.Error(w, "token revoked", http.StatusUnauthorized) + return + } + if row.RevokedAt != nil { + slog.Debug("abs bearerAuth: jti revoked", "jti", claims.JTI, "path", r.URL.Path) http.Error(w, "token revoked", http.StatusUnauthorized) return } diff --git a/internal/audiobooks/abs/login.go b/internal/audiobooks/abs/login.go index efc5990b..47b749fa 100644 --- a/internal/audiobooks/abs/login.go +++ b/internal/audiobooks/abs/login.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "io" + "log/slog" "net/http" "strings" "time" @@ -87,6 +88,7 @@ func (h *Handler) handleStandaloneLogin(w http.ResponseWriter, r *http.Request) if errors.Is(err, auth.ErrInvalidCredentials) || errors.Is(err, auth.ErrUserDisabled) { http.Error(w, "invalid username or password", http.StatusUnauthorized) } else { + slog.Error("abs login: cred validator failed", "username", body.Username, "error", err) http.Error(w, "login service unavailable", http.StatusServiceUnavailable) } return @@ -101,6 +103,8 @@ func (h *Handler) handleStandaloneLogin(w http.ResponseWriter, r *http.Request) } } + slog.Debug("abs standalone login: validator OK", + "username", body.Username, "user_id", userID, "profile_id", profileID) h.completeLogin(w, r, userID, profileID, displayName) } @@ -171,24 +175,44 @@ func (h *Handler) completeLogin(w http.ResponseWriter, r *http.Request, userID, return } + slog.Debug("abs completeLogin: tokens persisted", + "user_id", userID, "access_jti", accessJTI, "refresh_jti", refreshJTI) + // Build user object. displayName falls back to userID when empty. name := displayName if name == "" { name = userID } + // Resolve the audiobook library list + default ID up front so we can + // emit them in the login envelope. ABS clients require these on the + // initial login response to seed the library picker before /me lands. + libs, _ := h.deps.MediaStore.ListAudiobookLibraries(r.Context()) + libraryMaps := make([]map[string]any, 0, len(libs)) + defaultLibraryID := VirtualLibraryID + for i, lib := range libs { + if i == 0 { + defaultLibraryID = audiobookLibraryID(lib) + } + libraryMaps = append(libraryMaps, audiobookLibraryMap(lib)) + } + user := map[string]any{ - "id": userID, - "username": name, - "type": "user", + "id": userID, + "username": name, + "type": "user", + "defaultLibraryId": defaultLibraryID, + "librariesAccessible": []any{}, // empty = "all libraries accessible" + "mediaProgress": []any{}, + "bookmarks": []any{}, + "isOldToken": false, + "token": access, // legacy field some 2.17- clients still read "permissions": map[string]any{ "update": true, "delete": true, "download": true, "accessExplicitContent": true, }, - // Legacy field for 2.17- clients. - "token": access, } // x-return-tokens opt-in: when set, embed token pair on user object too @@ -199,16 +223,98 @@ func (h *Handler) completeLogin(w http.ResponseWriter, r *http.Request, userID, } writeJSON(w, http.StatusOK, map[string]any{ - "user": user, + "user": user, + "userDefaultLibraryId": defaultLibraryID, "serverSettings": map[string]any{ - "id": "server-settings", - "version": "2.35.0", - "language": "en-us", - "buildNumber": 1, - "authActiveAuthMethods": []string{"local"}, + "version": ServerVersion, + "language": "en-us", }, "ereaderDevices": []any{}, - "accessToken": access, - "refreshToken": refresh, + "libraries": libraryMaps, + // Legacy top-level token fields for clients that read them + // directly (mainline reads from the user object; some third-party + // clients still read top-level). + "accessToken": access, + "refreshToken": refresh, + }) +} + +// handleABSPing — GET /ping (mounted also as /healthcheck). Wire shape +// matches the continuum-plugin-audiobooks implementation exactly: clients +// use this to validate the URL before showing the login form, and any +// other shape causes the official mobile app to reject the server. +func (h *Handler) handleABSPing(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "server": "audiobookshelf", + "version": ServerVersion, + "pong": true, + }) +} + +// handleABSInit — GET /init. Real ABS first-run detection probe. +func (h *Handler) handleABSInit(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"isInit": true}) +} + +// handleABSStatus — GET /status. Mobile clients call this on every +// connection to confirm the server is an ABS install and pull a few +// global flags. Matches the plugin shape exactly (key order intentional). +func (h *Handler) handleABSStatus(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "isInit": true, + "language": "en-us", + "app": "audiobookshelf", + "serverVersion": ServerVersion, + }) +} + +// handleABSAuthorize — POST /api/authorize. Real ABS uses this to +// validate a bearer token and re-mint the login envelope so the client +// can resume without retyping credentials. Mounted inside the bearerAuth +// group so it inherits the same token validation. +// +// The response shape is the FULL login envelope — same fields, same +// ordering — not a `{"user": ...}` wrapper. Mirrors the +// continuum-plugin-audiobooks handleAuthorize verbatim. +func (h *Handler) handleABSAuthorize(w http.ResponseWriter, r *http.Request) { + a, ok := absAuthFrom(r) + if !ok || a.UserID == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + libs, _ := h.deps.MediaStore.ListAudiobookLibraries(r.Context()) + libraryMaps := make([]map[string]any, 0, len(libs)) + defaultLibraryID := VirtualLibraryID + for i, lib := range libs { + if i == 0 { + defaultLibraryID = audiobookLibraryID(lib) + } + libraryMaps = append(libraryMaps, audiobookLibraryMap(lib)) + } + user := map[string]any{ + "id": a.UserID, + "username": a.UserID, + "type": "user", + "defaultLibraryId": defaultLibraryID, + "librariesAccessible": []any{}, + "mediaProgress": []any{}, + "bookmarks": []any{}, + "isOldToken": false, + "permissions": map[string]any{ + "update": true, + "delete": true, + "download": true, + "accessExplicitContent": true, + }, + } + writeJSON(w, http.StatusOK, map[string]any{ + "user": user, + "userDefaultLibraryId": defaultLibraryID, + "serverSettings": map[string]any{ + "version": ServerVersion, + "language": "en-us", + }, + "ereaderDevices": []any{}, + "libraries": libraryMaps, }) }