commit 863e65cb10204e0498a9f13d19c27c684bf21d04 from: mtmn date: Wed Jun 24 18:28:18 2026 UTC feat: add listenbrainz validate-token endpoint commit - 146a6b3417a8d6b5434c1fa82ac7177ed0fa2364 commit + 863e65cb10204e0498a9f13d19c27c684bf21d04 blob - dcb433e04dac789877f618236040b9f0cc9874ec blob + e6e8364d993b9adc086e770fbb1a5d22b8e2d772 --- src/Main.purs +++ src/Main.purs @@ -43,7 +43,7 @@ import Web.URL (URL) import Web.URL as URL import Web.URL.URLSearchParams as URLSearchParams import Command as Command -import Handler (Request, Response, serveBadRequest, serveError, serveInternalError, serveNotFound, serveUnauthorized) +import Handler (Request, Response, respond, serveBadRequest, serveError, serveInternalError, serveNotFound, serveUnauthorized) import Templates (indexHtml) import Types (Listen(..), ListenBrainzAdditionalInfo(..), ListenBrainzSubmitListen(..), ListenBrainzSubmitPayload(..), ListenBrainzSubmitTrackMetadata(..), MbidMapping(..), TrackMetadata(..)) import Foreign.Object as Object @@ -111,6 +111,11 @@ routeRequest metricsEnabled corsOrigin contexts req ur withUser url \ctx -> serveSimilar serveBadRequest serveError ctx.slug ctx.config url res "/healthz" -> withUser url \ctx -> serveHealthz ctx.conn res + "/1/validate-token" -> + if IM.method req == "GET" then + launchAff_ $ serveValidateToken contexts req res + else + liftEffect $ serveBadRequest res "Method not allowed" "/1/submit-listens" -> if IM.method req == "POST" then launchAff_ $ withUserFromToken contexts req res \ctx -> @@ -138,10 +143,7 @@ routeRequest metricsEnabled corsOrigin contexts req ur withUserFromToken :: Array UserContext -> Request -> Response -> (UserContext -> Aff Unit) -> Aff Unit withUserFromToken contextsParam reqParam resParam f = do - let - headers = IM.headers reqParam - mAuth = Object.lookup "authorization" headers - mToken = mAuth >>= stripPrefix (Pattern "Token ") + let mToken = parseAuthToken (Object.lookup "authorization" (IM.headers reqParam)) case mToken of Nothing -> liftEffect $ do Log.warn "Missing or invalid Authorization header" @@ -159,6 +161,42 @@ withUserFromToken contextsParam reqParam resParam f = Just ctx -> f ctx +-- ListenBrainz `validate-token` endpoint. Clients such as Navidrome call this +-- (GET, with `Authorization: Token `) before linking and require a +-- `{"valid": true, "user_name": ...}` response, otherwise they refuse to scrobble. +serveValidateToken :: Array UserContext -> Request -> Response -> Aff Unit +serveValidateToken contextsParam reqParam resParam = do + let mToken = parseAuthToken (Object.lookup "authorization" (IM.headers reqParam)) + case mToken of + Nothing -> liftEffect $ do + Log.warn "validate-token: missing or invalid Authorization header" + respond "application/json" 401 + """{"code":401,"error":"You need to provide an Authorization header."}""" + resParam + Just tokenValue -> do + mCtx <- findUserByToken contextsParam tokenValue + liftEffect $ respond "application/json" 200 + (validateTokenJson (map _.displayName mCtx)) + resParam + +-- Extract a ListenBrainz API token from an `Authorization: Token ` header value. +parseAuthToken :: Maybe String -> Maybe String +parseAuthToken mAuth = mAuth >>= stripPrefix (Pattern "Token ") + +-- Build the `validate-token` response body. `Just displayName` means the token +-- resolved to a user (valid); `Nothing` means the token is unknown (invalid). +-- The shape mirrors the real ListenBrainz API so clients like Navidrome accept it. +validateTokenJson :: Maybe String -> String +validateTokenJson Nothing = + """{"code":200,"message":"Token invalid.","valid":false}""" +validateTokenJson (Just displayName) = + stringify $ encodeJson + { code: 200 + , message: "Token valid." + , valid: true + , user_name: displayName + } + findUserByToken :: Array UserContext -> String -> Aff (Maybe UserContext) findUserByToken contexts tokenValue = case Data.Array.uncons contexts of Nothing -> blob - 76488a669280b230fa926a2889af73764280b51c blob + 784967cb6fcddfccd29f459da314d8089529f650 --- test/Main.purs +++ test/Main.purs @@ -15,9 +15,9 @@ import Test.Spec.Reporter.Console (consoleReporter) import Test.Spec.Runner.Node (runSpecAndExitProcess) import Types (Listen(..), ListenBrainzResponse(..), MbidMapping(..), Payload(..), Stats(..), StatsEntry(..), TrackMetadata(..), ListenBrainzSubmitPayload(..), ListenBrainzSubmitListen(..), ListenBrainzSubmitTrackMetadata(..), ListenBrainzAdditionalInfo(..)) import Db (FilterField(..), connect, initDb, checkExists, upsertScrobble, getScrobbles, initReleaseMetadata, upsertReleaseMetadata, getStats, dbBaseName, getOldestTs, getUnenrichedMbids, getEmptyGenreMbids, getArtistReleasesByMbids, touchGenreCheckedAt, getOrCreateToken, getTokenUser, fromString) -import Data.Argonaut.Core (Json) +import Data.Argonaut.Core (Json, toBoolean, toNumber, toString) import Foreign.Object as Object -import Main (submitListenToListen, findUserByToken, sanitizeDate) +import Main (submitListenToListen, findUserByToken, sanitizeDate, parseAuthToken, validateTokenJson) import Cover (sanitizeKey) import Sync (listenBrainzUrl, lastfmTrackToListen, parseLastfmResponse) import S3 (getS3Url) @@ -152,6 +152,66 @@ main = runSpecAndExitProcess [consoleReporter] do } submitListenToListen "playing_now" submission `shouldEqual` Nothing + it "should convert an import listen the same as a single listen" do + let submission = ListenBrainzSubmitListen + { listenedAt: Just 987654321 + , trackMetadata: ListenBrainzSubmitTrackMetadata + { trackName: "Imported Song" + , artistName: "Imported Artist" + , releaseName: Just "Imported Album" + , additionalInfo: Nothing + } + } + case submitListenToListen "import" submission of + Just (Listen { listenedAt, trackMetadata: TrackMetadata m }) -> do + listenedAt `shouldEqual` Just 987654321 + m.trackName `shouldEqual` Just "Imported Song" + m.artistName `shouldEqual` Just "Imported Artist" + m.releaseName `shouldEqual` Just "Imported Album" + m.mbidMapping `shouldEqual` Nothing + Nothing -> do + fail "Conversion failed" + + it "should ignore unknown listen types" do + let submission = ListenBrainzSubmitListen + { listenedAt: Nothing + , trackMetadata: ListenBrainzSubmitTrackMetadata + { trackName: "Song Name" + , artistName: "Artist Name" + , releaseName: Nothing + , additionalInfo: Nothing + } + } + submitListenToListen "bogus" submission `shouldEqual` Nothing + + describe "ListenBrainz validate-token" do + it "extracts the token from a 'Token ' Authorization header" do + parseAuthToken (Just "Token abc-123") `shouldEqual` Just "abc-123" + + it "rejects a missing or malformed Authorization header" do + parseAuthToken Nothing `shouldEqual` Nothing + parseAuthToken (Just "Bearer abc-123") `shouldEqual` Nothing + parseAuthToken (Just "token abc-123") `shouldEqual` Nothing + + it "builds a valid-token response with the user name" do + let body = validateTokenJson (Just "User One") + let result = parseJson body >>= decodeJson :: _ (Object.Object Json) + case result of + Right obj -> do + (Object.lookup "valid" obj >>= toBoolean) `shouldEqual` Just true + (Object.lookup "user_name" obj >>= toString) `shouldEqual` Just "User One" + (Object.lookup "code" obj >>= toNumber) `shouldEqual` Just 200.0 + Left _ -> fail "validateTokenJson did not produce valid JSON" + + it "builds an invalid-token response for an unknown token" do + let body = validateTokenJson Nothing + let result = parseJson body >>= decodeJson :: _ (Object.Object Json) + case result of + Right obj -> do + (Object.lookup "valid" obj >>= toBoolean) `shouldEqual` Just false + Object.member "user_name" obj `shouldEqual` false + Left _ -> fail "validateTokenJson did not produce valid JSON" + describe "Token Authentication" do it "should create and verify tokens" do conn <- connect ":memory:"