corpus

self-hosted listenbrainz and last.fm proxy
Log | Files | Refs | README | LICENSE

Main.purs (16384B)


      1 module Main where
      2 
      3 import Prelude
      4 
      5 import Config (AppConfig, UserConfig, UserEntry, loadConfig, s3ConfigFromUser)
      6 import Cover (serveCover)
      7 import Cosine (serveSimilar)
      8 import Data.Argonaut (decodeJson, encodeJson, parseJson, stringify)
      9 import Data.Array (find, length)
     10 import Data.Array as Data.Array
     11 import Data.Either (Either(..), fromRight')
     12 import Data.Foldable (for_, traverse_)
     13 import Data.Int as Int
     14 import Data.Maybe (Maybe(..), fromMaybe)
     15 import Data.String (Pattern(..), stripPrefix)
     16 import Data.String.Regex (regex, replace, parseFlags)
     17 import Partial.Unsafe (unsafeCrashWith)
     18 import Data.Traversable (traverse)
     19 import Db (Connection, backupDb, connect, fromString, getOrCreateToken, getScrobbles, getStats, getTokenUser, initDb, initReleaseMetadata, ping, upsertScrobble, withTransaction)
     20 import Effect (Effect)
     21 import Effect.Aff (Aff, Fiber, forkAff, joinFiber, killFiber, launchAff_, try)
     22 import Effect.Aff.AVar (AVar)
     23 import Effect.Aff.AVar as Avar
     24 import Effect.Class (liftEffect)
     25 import Effect.Exception as Exception
     26 import Node.Encoding (Encoding(UTF8))
     27 import Log as Log
     28 import Node.EventEmitter (on_)
     29 import Metadata (enrichMetadata)
     30 import Metrics as Metrics
     31 import Node.FS.Aff as FSA
     32 import Node.HTTP (createServer)
     33 import Node.HTTP.IncomingMessage as IM
     34 import Node.HTTP.OutgoingMessage (setHeader, toWriteable)
     35 import Node.HTTP.ServerResponse (setStatusCode, toOutgoingMessage)
     36 import Node.HTTP.Server as Server
     37 import Node.Net.Server (listenTcp, listeningH)
     38 import Node.Process (argv, lookupEnv)
     39 import Node.Stream (end, write, writeString)
     40 import Node.Stream.Aff (readableToStringUtf8)
     41 import Sync (lbSync, lbSyncLoop, lfSync, lfSyncLoop)
     42 import Web.URL (URL)
     43 import Web.URL as URL
     44 import Web.URL.URLSearchParams as URLSearchParams
     45 import Command as Command
     46 import Handler (Request, Response, serveBadRequest, serveError, serveInternalError, serveNotFound, serveUnauthorized)
     47 import Templates (indexHtml)
     48 import Types (Listen(..), ListenBrainzAdditionalInfo(..), ListenBrainzSubmitListen(..), ListenBrainzSubmitPayload(..), ListenBrainzSubmitTrackMetadata(..), MbidMapping(..), TrackMetadata(..))
     49 import Foreign.Object as Object
     50 
     51 type UserContext =
     52   { conn :: Connection
     53   , writeLock :: AVar Unit
     54   , config :: UserConfig
     55   , slug :: String
     56   , displayName :: String
     57   , enrichMetadataFiber :: Maybe (Fiber Unit)
     58   , backupFiber :: Maybe (Fiber Unit)
     59   }
     60 
     61 normalizePath :: String -> String
     62 normalizePath path = case stripPrefix (Pattern "/u/") path of
     63   Just _ -> "/u/:slug"
     64   Nothing -> path
     65 
     66 -- Request handler
     67 -- API endpoints (/proxy, /stats, /cover, /healthz) select the user via ?user=<slug>.
     68 -- Index pages are served at / (root user) and /u/<slug> (named users).
     69 handleRequest :: Boolean -> String -> Array UserContext -> Request -> Response -> Effect Unit
     70 handleRequest metricsEnabled corsOrigin contexts req res = do
     71   let method = IM.method req
     72   let rawUrl = IM.url req
     73   let allUsers = map (\ctx -> { slug: ctx.slug, name: ctx.displayName }) contexts
     74   case URL.fromRelative rawUrl "http://localhost" of
     75     Nothing ->
     76       serveNotFound res
     77     Just url -> do
     78       let path = URL.pathname url
     79       Metrics.wrapRequest method (normalizePath path) Log.info req res do
     80         launchAff_ $ do
     81           result <- try $ routeRequest metricsEnabled corsOrigin contexts req url path allUsers res
     82           case result of
     83             Left err -> do
     84               Log.error $ "Internal server error: " <> Exception.message err
     85               liftEffect $ serveInternalError res
     86             Right _ ->
     87               pure unit
     88 
     89 routeRequest :: Boolean -> String -> Array UserContext -> Request -> URL -> String -> Array { slug :: String, name :: String } -> Response -> Aff Unit
     90 routeRequest metricsEnabled corsOrigin contexts req url path allUsers res = liftEffect $ case path of
     91   "/client.js" ->
     92     serveClientJs res
     93   "/favicon.png" ->
     94     serveAsset "image/png" "assets/favicon.png" res
     95   "/cover.webp" ->
     96     serveAsset "image/webp" "assets/cover.webp" res
     97   "/" ->
     98     serveIndex allUsers "" res
     99   "/metrics" ->
    100     if metricsEnabled then serveMetrics res
    101     else do
    102       Log.warn "Path not found: /metrics"
    103       serveNotFound res
    104   "/proxy" ->
    105     withUser url \ctx -> serveProxy corsOrigin ctx.conn url res
    106   "/stats" ->
    107     withUser url \ctx -> serveStats ctx.conn url res
    108   "/cover" ->
    109     withUser url \ctx -> launchAff_ $ serveCover serveNotFound ctx.config ctx.slug url res
    110   "/similar" ->
    111     withUser url \ctx -> serveSimilar serveBadRequest serveError ctx.slug ctx.config url res
    112   "/healthz" ->
    113     withUser url \ctx -> serveHealthz ctx.conn res
    114   "/1/submit-listens" ->
    115     if IM.method req == "POST" then
    116       launchAff_ $ withUserFromToken contexts req res \ctx ->
    117         serveSubmitListens ctx.slug ctx.conn ctx.writeLock req res
    118     else
    119       liftEffect $ serveBadRequest res "Method not allowed"
    120   _ ->
    121     case stripPrefix (Pattern "/u/") path of
    122       Just slug ->
    123         serveIndex allUsers slug res
    124       Nothing -> do
    125         Log.warn $ "Path not found: " <> path
    126         serveNotFound res
    127   where
    128   withUser urlParam f =
    129     let
    130       slug = fromMaybe "" (getQueryParam "user" urlParam)
    131     in
    132       case find (\c -> c.slug == slug) contexts of
    133         Nothing -> do
    134           Log.warn $ "Unknown user: " <> show slug
    135           serveNotFound res
    136         Just ctx ->
    137           f ctx
    138 
    139 withUserFromToken :: Array UserContext -> Request -> Response -> (UserContext -> Aff Unit) -> Aff Unit
    140 withUserFromToken contextsParam reqParam resParam f = do
    141   let
    142     headers = IM.headers reqParam
    143     mAuth = Object.lookup "authorization" headers
    144     mToken = mAuth >>= stripPrefix (Pattern "Token ")
    145   case mToken of
    146     Nothing -> liftEffect $ do
    147       Log.warn "Missing or invalid Authorization header"
    148       serveUnauthorized resParam
    149     Just tokenValue -> do
    150       -- We need to check which connection to use to verify the token.
    151       -- Since initDb creates the table in every user's database, but usually tokens are user-specific.
    152       -- The current structure assumes each user has their own DB.
    153       -- We'll try to find the user by checking each context's DB for the token.
    154       mCtx <- findUserByToken contextsParam tokenValue
    155       case mCtx of
    156         Nothing -> liftEffect $ do
    157           Log.warn $ "Unknown API token provided"
    158           serveUnauthorized resParam
    159         Just ctx ->
    160           f ctx
    161 
    162 findUserByToken :: Array UserContext -> String -> Aff (Maybe UserContext)
    163 findUserByToken contexts tokenValue = case Data.Array.uncons contexts of
    164   Nothing ->
    165     pure Nothing
    166   Just { head: ctx, tail: rest } -> do
    167     mSlug <- getTokenUser ctx.conn tokenValue
    168     case mSlug of
    169       Just slug | slug == ctx.slug ->
    170         pure (Just ctx)
    171       _ ->
    172         findUserByToken rest tokenValue
    173 
    174 serveIndex :: Array { slug :: String, name :: String } -> String -> Response -> Effect Unit
    175 serveIndex allUsers slug res = do
    176   setHeader "Content-Type" "text/html" (toOutgoingMessage res)
    177   setStatusCode 200 res
    178   let w = toWriteable (toOutgoingMessage res)
    179   void $ writeString w UTF8 (indexHtml slug allUsers)
    180   end w
    181 
    182 serveMetrics :: Response -> Effect Unit
    183 serveMetrics res = do
    184   launchAff_ do
    185     contentType <- liftEffect Metrics.getContentType
    186     metricsText <- Metrics.getMetrics
    187     liftEffect $ do
    188       setHeader "Content-Type" contentType (toOutgoingMessage res)
    189       setStatusCode 200 res
    190       let w = toWriteable (toOutgoingMessage res)
    191       void $ writeString w UTF8 metricsText
    192       end w
    193 
    194 serveClientJs :: Response -> Effect Unit
    195 serveClientJs res = do
    196   setHeader "Content-Type" "application/javascript" (toOutgoingMessage res)
    197   launchAff_ do
    198     result <- try $ FSA.readTextFile UTF8 "client.js"
    199     liftEffect $ case result of
    200       Right content -> do
    201         setStatusCode 200 res
    202         let w = toWriteable (toOutgoingMessage res)
    203         void $ writeString w UTF8 content
    204         end w
    205       Left err -> do
    206         Log.error $ "Failed to read client.js: " <> Exception.message err
    207         serveNotFound res
    208 
    209 getQueryParam :: String -> URL -> Maybe String
    210 getQueryParam name url = URLSearchParams.get name (URL.searchParams url)
    211 
    212 serveProxy :: String -> Connection -> URL -> Response -> Effect Unit
    213 serveProxy corsOrigin db url res = do
    214   setHeader "Content-Type" "application/json" (toOutgoingMessage res)
    215   setHeader "Access-Control-Allow-Origin" corsOrigin (toOutgoingMessage res)
    216 
    217   launchAff_ do
    218     let limit = fromMaybe 25 (getQueryParam "limit" url >>= Int.fromString)
    219     let offset = fromMaybe 0 (getQueryParam "offset" url >>= Int.fromString)
    220     let
    221       mFilter = do
    222         field <- getQueryParam "filterField" url >>= fromString
    223         value <- getQueryParam "filterValue" url
    224         pure { field, value }
    225 
    226     let mSearch = getQueryParam "search" url
    227     listens <- getScrobbles db limit offset mFilter mSearch
    228     let responseBody = stringify $ encodeJson { payload: { listens: listens } }
    229 
    230     liftEffect $ do
    231       setStatusCode 200 res
    232       let w = toWriteable (toOutgoingMessage res)
    233       void $ writeString w UTF8 responseBody
    234       end w
    235 
    236 sanitizeDate :: String -> String
    237 sanitizeDate = replace (fromRight' (\_ -> unsafeCrashWith "invalid regex") $ regex "[^0-9\\-]" (parseFlags "g")) ""
    238 
    239 serveStats :: Connection -> URL -> Response -> Effect Unit
    240 serveStats db url res = do
    241   setHeader "Content-Type" "application/json" (toOutgoingMessage res)
    242   launchAff_ do
    243     let period = getQueryParam "period" url
    244     let section = getQueryParam "section" url
    245     let safeDate = map sanitizeDate
    246     let mFrom = safeDate (getQueryParam "from" url)
    247     let mTo = safeDate (getQueryParam "to" url)
    248     stats <- getStats db period mFrom mTo section
    249     let responseBody = stringify $ encodeJson stats
    250     liftEffect $ do
    251       setStatusCode 200 res
    252       let w = toWriteable (toOutgoingMessage res)
    253       void $ writeString w UTF8 responseBody
    254       end w
    255 
    256 serveAsset :: String -> String -> Response -> Effect Unit
    257 serveAsset contentType path res = do
    258   setHeader "Content-Type" contentType (toOutgoingMessage res)
    259   setHeader "Cache-Control" "public, max-age=86400" (toOutgoingMessage res)
    260   launchAff_ do
    261     result <- try $ FSA.readFile path
    262     liftEffect $ case result of
    263       Right buf -> do
    264         setStatusCode 200 res
    265         let w = toWriteable (toOutgoingMessage res)
    266         void $ write w buf
    267         end w
    268       Left _ ->
    269         serveNotFound res
    270 
    271 serveHealthz :: Connection -> Response -> Effect Unit
    272 serveHealthz db res = do
    273   setHeader "Content-Type" "application/json" (toOutgoingMessage res)
    274   launchAff_ do
    275     result <- try $ ping db
    276     liftEffect $ do
    277       let w = toWriteable (toOutgoingMessage res)
    278       case result of
    279         Right _ -> do
    280           setStatusCode 200 res
    281           void $ writeString w UTF8 """{"status":"ok"}"""
    282         Left err -> do
    283           setStatusCode 503 res
    284           void $ writeString w UTF8 $ """{"status":"error","message":""" <> show (Exception.message err) <> "}"
    285       end w
    286 
    287 serveSubmitListens :: String -> Connection -> AVar Unit -> Request -> Response -> Aff Unit
    288 serveSubmitListens slug db lock req res = do
    289   body <- readableToStringUtf8 (IM.toReadable req)
    290   case parseJson body >>= decodeJson of
    291     Left err ->
    292       liftEffect $ serveBadRequest res $ "Invalid JSON: " <> show err
    293     Right (ListenBrainzSubmitPayload { listenType, payload }) -> do
    294       let listens = Data.Array.mapMaybe (submitListenToListen listenType) payload
    295       withTransaction db lock $ traverse_ (upsertScrobble db) listens
    296       liftEffect $ do
    297         Metrics.incSyncScrobbles slug "api" (length listens)
    298         setHeader "Content-Type" "application/json" (toOutgoingMessage res)
    299         setStatusCode 200 res
    300         let w = toWriteable (toOutgoingMessage res)
    301         void $ writeString w UTF8 """{"status":"ok"}"""
    302         end w
    303 
    304 submitListenToListen :: String -> ListenBrainzSubmitListen -> Maybe Listen
    305 submitListenToListen "single" (ListenBrainzSubmitListen { listenedAt, trackMetadata }) =
    306   Just $ Listen
    307     { listenedAt
    308     , trackMetadata: submitTrackMetadataToTrackMetadata trackMetadata
    309     }
    310 submitListenToListen "import" (ListenBrainzSubmitListen { listenedAt, trackMetadata }) =
    311   Just $ Listen
    312     { listenedAt
    313     , trackMetadata: submitTrackMetadataToTrackMetadata trackMetadata
    314     }
    315 submitListenToListen "playing_now" _ = Nothing
    316 submitListenToListen _ _ = Nothing
    317 
    318 submitTrackMetadataToTrackMetadata :: ListenBrainzSubmitTrackMetadata -> TrackMetadata
    319 submitTrackMetadataToTrackMetadata (ListenBrainzSubmitTrackMetadata { trackName, artistName, releaseName, additionalInfo }) =
    320   TrackMetadata
    321     { trackName: Just trackName
    322     , artistName: Just artistName
    323     , releaseName
    324     , genre: Nothing
    325     , label: Nothing
    326     , mbidMapping: do
    327         info <- additionalInfo
    328         let ListenBrainzAdditionalInfo i = info
    329         pure $ MbidMapping
    330           { releaseMbid: i.releaseMbid
    331           , caaReleaseMbid: i.releaseMbid
    332           }
    333     }
    334 
    335 startUser :: UserEntry -> Aff UserContext
    336 startUser { slug, name, config } = do
    337   Log.info $ "Starting user: " <> if slug == "" then "(root)" else slug
    338   conn <- connect config.databaseFile
    339   initDb conn
    340   initReleaseMetadata conn
    341   writeLock <- Avar.new unit
    342 
    343   mToken <- getOrCreateToken conn slug
    344   for_ mToken \token ->
    345     Log.info $ "User '" <> slug <> "' token: " <> token
    346 
    347   case config.lastfmUser, config.lastfmApiKey of
    348     Just _, Nothing -> Log.warn $ "User '" <> slug <> "': Last.fm sync disabled (missing API key)"
    349     _, _ -> pure unit
    350 
    351   lbFiber <- case config.listenbrainzUser of
    352     Just username -> Just <$> forkAff (lbSync conn username slug writeLock)
    353     Nothing -> pure Nothing
    354   lfFiber <- case config.lastfmUser, config.lastfmApiKey of
    355     Just lfmUser, Just apiKey -> Just <$> forkAff (lfSync conn apiKey lfmUser slug writeLock)
    356     _, _ -> pure Nothing
    357 
    358   void $ forkAff do
    359     for_ lbFiber joinFiber
    360     for_ lfFiber joinFiber
    361     for_ config.listenbrainzUser \username ->
    362       void $ forkAff $ lbSyncLoop conn username slug writeLock
    363     case config.lastfmUser, config.lastfmApiKey of
    364       Just lfmUser, Just apiKey -> void $ forkAff $ lfSyncLoop conn apiKey lfmUser slug writeLock
    365       _, _ -> pure unit
    366 
    367   enrichMetadataFiber <- forkAff $ enrichMetadata conn config slug
    368   backupFiber <-
    369     if config.backupEnabled then Just <$> forkAff (backupDb conn config.databaseFile (s3ConfigFromUser config) (Int.toNumber config.backupIntervalHours * 3600000.0) slug)
    370     else pure Nothing
    371 
    372   let displayName = fromMaybe (if slug == "" then "root" else slug) name
    373   pure { conn, writeLock, config, slug, displayName, enrichMetadataFiber: Just enrichMetadataFiber, backupFiber }
    374 
    375 cleanupUser :: UserContext -> Aff Unit
    376 cleanupUser ctx = do
    377   let label = if ctx.slug == "" then "(root)" else ctx.slug
    378   Log.info $ "Shutting down user: " <> label
    379   for_ ctx.enrichMetadataFiber \fiber ->
    380     void $ try $ killFiber (Exception.error "Server shutting down") fiber
    381   for_ ctx.backupFiber \fiber ->
    382     void $ try $ killFiber (Exception.error "Server shutting down") fiber
    383   Log.info $ "Closed user: " <> label
    384 
    385 foreign import dotenvConfig :: Effect Unit
    386 
    387 main :: Effect Unit
    388 main = do
    389   dotenvConfig
    390   launchAff_ do
    391     configFile <- liftEffect $ map (fromMaybe "users.json") $ lookupEnv "CORPUS_USERS_FILE"
    392     args <- liftEffect argv
    393     let commandArgs = Data.Array.drop 2 args
    394     case Data.Array.uncons commandArgs of
    395       Just _ ->
    396         Command.run configFile commandArgs
    397       Nothing -> do
    398         result <- try $ loadConfig configFile
    399         case result of
    400           Left err -> do
    401             Log.error $ "Failed to load " <> configFile <> ": " <> Exception.message err
    402             liftEffect $ Exception.throwException err
    403           Right (appConfig :: AppConfig) -> do
    404             Log.info $ "Loaded " <> show (length appConfig.users) <> " user(s) from " <> configFile
    405             contexts <- traverse startUser appConfig.users
    406             liftEffect $ do
    407               server <- createServer
    408               server # on_ Server.requestH (handleRequest appConfig.metricsEnabled appConfig.corsOrigin contexts)
    409               let netServer = Server.toNetServer server
    410 
    411               netServer # on_ listeningH do
    412                 Log.info $ "Server is running on " <> appConfig.host <> ":" <> show appConfig.port
    413 
    414               listenTcp netServer { host: appConfig.host, port: appConfig.port, backlog: 128 }