fix(audiobooks): enforce the stream kill switch on ABS byte-serving routes

The Audiobookshelf-compat surface sits outside the monitoring and kill design
entirely — neither architecture matrix mentioned it. Three routes pour full
media and none consulted the kill switch:

- /(abs/)api/items/{id}/file/{ino} and /download — bearer auth, no revocation
  check, no in-flight cut.
- /(abs/)public/session/{sid}/track/{idx} — mounted outside bearerAuth (the
  session id is the capability); it held a transport marker but was unkillable.
- /feed/{slug}/file/{ino} — no auth at all, the slug is the capability;
  invisible and unkillable, and closing a feed only blocked the next request
  rather than cutting a pour already in flight.

Each surface now passes its real credential-issue time, because a user
revocation is a cutoff, not a ban: it matches only streams whose credential
predates it. ABS bearer tokens are stateless JWTs that OnUserSessionsRevoked
does not delete, so passing request-entry time (as the jellycompat login path
safely does) would have meant a user kill never refused a later ABS request.

- bearerAuth carries the JWT's iat into ctxAuth; the authenticated file route
  uses it.
- The public track uses the persisted playback session's StartedAt and passes
  the native session id so session-level kills land too, plus the shared
  metered writer for byte accounting.
- The feed file uses the feed's CreatedAt for an owner cutoff — a feed opened
  before the revocation dies, one opened after re-authenticating serves — and
  arms the in-flight cut.

The authenticated file route stays download-class: like the native and
jellycompat download routes it is exempt from the live-stream cap and from
streammonitor, and is covered by no download quota. It is now killable and
explicitly documented rather than quietly invisible. Bringing all three
download-class routes under one quota and one monitor record is tracked as a
follow-up rather than adding a fourth per-route model here.

Part of the stream monitoring & kill-switch epic.
This commit is contained in:
CoffeeKnyte
2026-07-29 15:17:36 +00:00
parent fca9f38a33
commit e35efddac7
7 changed files with 258 additions and 2 deletions
+1
View File
@@ -2285,6 +2285,7 @@ func main() {
Detail: absDetailSvc,
SessionMgr: sessionMgr,
SessionSyncer: deps.SessionSyncer,
Revocation: streamRevocation,
}
absH := audiobooksService.BuildABSHandler(absHDeps)
deps.ABSHandler = absH
+29 -1
View File
@@ -52,12 +52,25 @@ func trackInoFor(contentID string, fileIdx int) string {
// - Serve the bytes directly with Range-request support via playback.ServeDirectPlay.
// - Set Content-Disposition: attachment on /download paths to encourage
// browser save-to-disk / mobile offline-save behaviour.
//
// This is a download-class route: like native and Jellyfin-compatible
// downloads it is exempt from the live-stream cap and streammonitor, and it is
// not yet covered by a download quota. It is still revocable before and during
// the file pour.
func (h *Handler) handleFileStream(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
userID, err := strconv.Atoi(a.UserID)
if err != nil || userID <= 0 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.Revocation != nil && h.deps.Revocation.Refuse(w, "", userID, a.IssuedAt) {
return
}
contentID := chi.URLParam(r, "libraryItemId")
inoStr := chi.URLParam(r, "ino")
@@ -115,6 +128,8 @@ func (h *Handler) handleFileStream(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", ct)
}
stop := h.deps.Revocation.WatchAndCut(w, "", userID, a.IssuedAt)
defer stop()
if err := playback.ServeDirectPlay(w, r, mediaFile.FilePath); err != nil {
// ServeDirectPlay has already written an error response; just log.
return
@@ -172,6 +187,14 @@ func (h *Handler) handlePublicTrack(w http.ResponseWriter, r *http.Request) {
http.Error(w, "session expired", http.StatusGone)
return
}
userID, err := strconv.Atoi(sess.UserID)
if err != nil || userID <= 0 {
http.Error(w, "session owner invalid", http.StatusForbidden)
return
}
if h.deps.Revocation != nil && h.deps.Revocation.Refuse(w, sid, userID, sess.StartedAt) {
return
}
if h.beginNativePlaybackTransport(sid) {
defer h.endNativePlaybackTransport(sid)
}
@@ -196,7 +219,12 @@ func (h *Handler) handlePublicTrack(w http.ResponseWriter, r *http.Request) {
if ct := audioContentType(ext); ct != "" {
w.Header().Set("Content-Type", ct)
}
_ = playback.ServeDirectPlay(w, r, mediaFile.FilePath)
recorder, _ := h.deps.NativeSessions.(playback.ServedBytesRecorder)
metered := playback.NewSessionMeteredWriter(w, recorder, sid)
defer func() { _ = metered.Close() }()
stop := h.deps.Revocation.WatchAndCut(metered, sid, userID, sess.StartedAt)
defer stop()
_ = playback.ServeDirectPlay(metered, r, mediaFile.FilePath)
}
func publicTrackSessionExpired(sess ABSPlaybackSession, now time.Time) bool {
@@ -88,7 +88,7 @@ func newPublicTrackHandler(t *testing.T, sid, contentID string, closed bool) (*H
t.Helper()
audioPath := makeTempAudio(t)
sessStore := &fakePlaybackSessionStore{}
sess := ABSPlaybackSession{ID: sid, UserID: "u1", ContentID: contentID}
sess := ABSPlaybackSession{ID: sid, UserID: "1", ContentID: contentID, StartedAt: time.Now()}
if closed {
now := time.Now()
sess.ClosedAt = &now
+10
View File
@@ -21,6 +21,7 @@ import (
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/streamrevoke"
)
// ---------------------------------------------------------------------------
@@ -277,6 +278,9 @@ type Dependencies struct {
// see Audiobookshelf-compatible clients. May be nil; ABS playback still
// functions, but admin live-session visibility is unavailable.
NativeSessions PlaybackSessionManager
// Revocation makes ABS byte-serving routes obey the shared stream kill
// switch. Nil preserves compatibility for isolated tests.
Revocation *streamrevoke.Store
// NativeSessionSyncer flushes native session-manager state into the shared
// admin live-session table after ABS play/sync/close events.
NativeSessionSyncer PlaybackSessionSyncer
@@ -569,6 +573,7 @@ type ctxAuth struct {
ProfileID string
JTI string
Token string // raw bearer token
IssuedAt time.Time
}
// absAuthFrom extracts ABS auth from the request context. Returns (zero, false)
@@ -682,11 +687,16 @@ func (h *Handler) bearerAuth(next http.Handler) http.Handler {
return
}
_ = h.deps.TokenStore.TouchToken(r.Context(), claims.JTI)
var issuedAt time.Time
if claims.IssuedAt != nil {
issuedAt = claims.IssuedAt.Time
}
ctx := context.WithValue(r.Context(), ctxKey{}, ctxAuth{
UserID: claims.UserID,
ProfileID: claims.ProfileID,
JTI: claims.JTI,
Token: raw,
IssuedAt: issuedAt,
})
next.ServeHTTP(w, r.WithContext(ctx))
})
+204
View File
@@ -0,0 +1,204 @@
package abs
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/streamrevoke"
"github.com/go-chi/chi/v5"
"github.com/golang-jwt/jwt/v5"
)
func TestBearerAuthCarriesJWTTimestampForUserCutoff(t *testing.T) {
const accessTokenType = "access"
secret := []byte("test-secret-32-bytes-aaaaaaaaaaaaa")
tokens := newMemTokenStore()
revocations := streamrevoke.New(streamrevoke.Options{})
if err := revocations.RevokeUser(context.Background(), 7, "cutoff"); err != nil {
t.Fatal(err)
}
cutoff := revocations.List()[0].RevokedAt
h := New(Dependencies{
MediaStore: noopMediaStore{},
TokenStore: tokens,
Config: &staticConfig{secret: secret},
Revocation: revocations,
})
issue := func(jti string, issuedAt time.Time) string {
t.Helper()
raw, err := issueJWT(secret, Claims{
Type: accessTokenType,
UserID: "7",
JTI: jti,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(issuedAt),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
})
if err != nil {
t.Fatal(err)
}
if err := tokens.InsertToken(context.Background(), ABSToken{
UserID: "7", Type: accessTokenType, JTI: jti, ExpiresAt: time.Now().Add(time.Hour),
}); err != nil {
t.Fatal(err)
}
return raw
}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth, ok := absAuthFrom(r)
if !ok {
t.Fatal("missing ctxAuth")
}
if revocations.Refuse(w, "", 7, auth.IssuedAt) {
return
}
w.WriteHeader(http.StatusNoContent)
})
protected := h.bearerAuth(next)
for _, tc := range []struct {
name string
token string
status int
}{
{name: "before cutoff refused", token: issue("old", cutoff.Add(-time.Hour)), status: http.StatusForbidden},
{name: "after cutoff admitted", token: issue("new", cutoff.Add(time.Hour)), status: http.StatusNoContent},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer "+tc.token)
rec := httptest.NewRecorder()
protected.ServeHTTP(rec, req)
if rec.Code != tc.status {
t.Fatalf("status=%d body=%s, want %d", rec.Code, rec.Body.String(), tc.status)
}
})
}
}
func TestPublicTrackUsesSessionStartedAtForCutoff(t *testing.T) {
h, _ := newPublicTrackHandler(t, "sid-cutoff", "book-1", false)
store := streamrevoke.New(streamrevoke.Options{})
h.deps.Revocation = store
if err := store.RevokeUser(context.Background(), 1, "cutoff"); err != nil {
t.Fatal(err)
}
rec := dispatchTrack(h, http.MethodGet, "sid-cutoff", "1")
if rec.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s, want 403", rec.Code, rec.Body.String())
}
sessions, ok := h.deps.PlaybackSessionStore.(*fakePlaybackSessionStore)
if !ok {
t.Fatal("unexpected playback session store type")
}
session := sessions.sessions["sid-cutoff"]
session.StartedAt = time.Now().Add(time.Hour)
sessions.sessions["sid-cutoff"] = session
rec = dispatchTrack(h, http.MethodGet, "sid-cutoff", "1")
if rec.Code != http.StatusOK {
t.Fatalf("post-cutoff session status=%d body=%s, want 200", rec.Code, rec.Body.String())
}
}
type revocationFeedFileMediaStore struct {
noopMediaStore
file *models.MediaFile
onGet func()
}
func (s *revocationFeedFileMediaStore) GetMediaFileByID(_ context.Context, id int) (*models.MediaFile, error) {
if s.onGet != nil {
s.onGet()
}
if s.file != nil && s.file.ID == id {
return s.file, nil
}
return nil, ErrNotFound
}
type deadlineResponseRecorder struct {
*httptest.ResponseRecorder
deadlines int
}
func (w *deadlineResponseRecorder) SetWriteDeadline(time.Time) error {
w.deadlines++
return nil
}
func TestPublicFeedFileUsesFeedCreatedAtForCutoff(t *testing.T) {
const (
feedSlug = "cutoff-feed"
ino = 9
)
ctx := context.Background()
feeds := newMemRSSFeedStore()
feed := RSSFeed{
ID: "feed-1",
UserID: "1",
LibraryItemID: "book-1",
Slug: feedSlug,
CreatedAt: time.Now().Add(-time.Hour),
}
feeds.rows[feed.ID] = feed
revocations := streamrevoke.New(streamrevoke.Options{})
if err := revocations.RevokeUser(ctx, 1, "cutoff"); err != nil {
t.Fatal(err)
}
cutoff := revocations.List()[0].RevokedAt
mediaStore := &revocationFeedFileMediaStore{file: &models.MediaFile{
ID: ino, ContentID: feed.LibraryItemID, FilePath: makeTempAudio(t),
}}
h := New(Dependencies{
MediaStore: mediaStore,
RSSFeedStore: feeds,
Revocation: revocations,
})
rec := dispatchABSWithParams(http.MethodGet, "/feed/"+feedSlug+"/file/9",
map[string]string{"slug": feedSlug, "ino": "9"}, nil, "", "", h.handlePublicFeedFile)
if rec.Code != http.StatusForbidden {
t.Fatalf("pre-cutoff feed status=%d body=%s, want 403", rec.Code, rec.Body.String())
}
feed.CreatedAt = cutoff.Add(time.Nanosecond)
feeds.rows[feed.ID] = feed
for !time.Now().After(feed.CreatedAt) {
time.Sleep(time.Nanosecond)
}
rec = dispatchABSWithParams(http.MethodGet, "/feed/"+feedSlug+"/file/9",
map[string]string{"slug": feedSlug, "ino": "9"}, nil, "", "", h.handlePublicFeedFile)
if rec.Code != http.StatusOK {
t.Fatalf("post-cutoff feed status=%d body=%s, want 200", rec.Code, rec.Body.String())
}
// Revoke again between the handler's initial Refuse check and WatchAndCut.
// The deadline recorder makes the watcher observable without a real socket.
mediaStore.onGet = func() {
mediaStore.onGet = nil
if err := revocations.RevokeUser(ctx, 1, "cut during pour"); err != nil {
t.Fatalf("RevokeUser during pour: %v", err)
}
}
req := httptest.NewRequest(http.MethodGet, "/feed/"+feedSlug+"/file/9", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("slug", feedSlug)
rctx.URLParams.Add("ino", "9")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
deadlineRec := &deadlineResponseRecorder{ResponseRecorder: httptest.NewRecorder()}
h.handlePublicFeedFile(deadlineRec, req)
if deadlineRec.Code != http.StatusOK {
t.Fatalf("watched feed status=%d body=%s, want 200", deadlineRec.Code, deadlineRec.Body.String())
}
if deadlineRec.deadlines != 1 {
t.Fatalf("write deadlines=%d, want 1 from WatchAndCut", deadlineRec.deadlines)
}
}
@@ -224,6 +224,14 @@ func (h *Handler) handlePublicFeedFile(w http.ResponseWriter, r *http.Request) {
http.Error(w, "feed get failed", http.StatusInternalServerError)
return
}
userID, parseUserErr := strconv.Atoi(f.UserID)
if parseUserErr != nil || userID <= 0 {
http.Error(w, "feed not found", http.StatusNotFound)
return
}
if h.deps.Revocation != nil && h.deps.Revocation.Refuse(w, "", userID, f.CreatedAt) {
return
}
inoStr := chi.URLParam(r, "ino")
ino, parseErr := strconv.Atoi(inoStr)
if parseErr != nil {
@@ -235,5 +243,7 @@ func (h *Handler) handlePublicFeedFile(w http.ResponseWriter, r *http.Request) {
http.Error(w, "file not found", http.StatusNotFound)
return
}
stop := h.deps.Revocation.WatchAndCut(w, "", userID, f.CreatedAt)
defer stop()
http.ServeFile(w, r, mf.FilePath)
}
+3
View File
@@ -12,6 +12,7 @@ import (
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/recommendations"
"github.com/Silo-Server/silo-server/internal/scanner"
"github.com/Silo-Server/silo-server/internal/streamrevoke"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -60,6 +61,7 @@ type ABSHandlerDeps struct {
Detail *catalog.DetailService
SessionMgr *playback.SessionManager
SessionSyncer abs.PlaybackSessionSyncer
Revocation *streamrevoke.Store
}
// absAuthAdapter is the narrow slice of internal/auth that BuildABSHandler
@@ -174,6 +176,7 @@ func (s *Service) BuildABSHandler(deps ABSHandlerDeps) *abs.Handler {
SocketIO: socketServer,
NativeSessions: deps.SessionMgr,
NativeSessionSyncer: deps.SessionSyncer,
Revocation: deps.Revocation,
CoverResolver: func(ctx context.Context, path, variant string) string {
if deps.Detail == nil {
return ""