-
Notifications
You must be signed in to change notification settings - Fork 0
/
Main.hs
459 lines (385 loc) · 13.6 KB
/
Main.hs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
{-# LANGUAGE OverloadedStrings #-}
module Main (main) where
import Control.Applicative
import Control.Concurrent.MVar (MVar, modifyMVar, newMVar, readMVar)
import Control.Monad (guard, join, void, when)
import Control.Exception (bracket, bracketOnError)
import Safe
import qualified Network.Wai.Handler.Warp as Warp
import Network.Wai
import Network.Wai.Logger (ApacheLogger, withStdoutLogger)
import Network.HTTP.Types
import qualified Crypto.Hash.MD5 as MD5
import System.Entropy (getEntropy)
import Data.Monoid ((<>))
import qualified Data.Traversable as Tr
import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe)
import Data.Int (Int64)
import Data.List (find)
import Data.String (IsString, fromString)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as T
import qualified Data.Text.Encoding as T
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy.Char8 as LB
import qualified Data.ByteString.Base16 as B16
import Data.Time.Clock.POSIX
import qualified Database.SQLite.Simple as SQL
import qualified Database.SQLite3 as RawSQL
import Options (Options (..), getOptions)
main :: IO ()
main = do
Options
{ optionsHost = host
, optionsPort = port
, optionsAccountsPath = accountsPath
, optionsDatabasePath = dbPath
} <- getOptions
accounts <- parseAccounts <$> T.readFile accountsPath
sessions <- newMVar []
withDB dbPath setupDB
let formatHost host = if ':' `elem` host then "[" <> host <> "]" else host
baseURL = "http://" <> LB.pack (formatHost host) <> ":" <> LB.pack (show port)
settings =
Warp.setPort port
$ Warp.setHost (fromString host)
$ Warp.defaultSettings
withStdoutLogger $ \logger ->
Warp.runSettings settings (app dbPath baseURL logger accounts sessions)
app :: FilePath -> LB.ByteString -> ApacheLogger -> Accounts -> MVar Sessions -> Application
app dbPath baseURL logger accounts sessions req respond = do
let path = rawPathInfo req
body <- strictRequestBody req
let urlQuery = queryString req
wantsHandshake = ("hs", Just "true") `elem` urlQuery
isGET = requestMethod req == methodGet
isPOST = requestMethod req == methodPost
q = parseQuery $ LB.toStrict body
mSessionID = queryGet q "s"
ok = (status200, "OK\n")
missingParameters = (status400, "FAILED Missing Parameters")
failAuth reason = (status403, LB.pack (show reason) <> "\n")
mAuthenticatedUser <- case mSessionID of
Just sessionID | isPOST && not wantsHandshake ->
findSessionUser sessions sessionID
Nothing -> pure Nothing
(st, msg) <- case mAuthenticatedUser of
Just username | isPOST && path == submissionsPath -> do
withDB dbPath $ \db -> withTransaction db $
mapM_ (insertScrobble db username) . catMaybes . takeWhile isJust $
map (\idx -> lookupScrobble (<> "[" <> B.pack (show idx) <> "]") q)
[(0 :: Int)..]
pure ok
Just username | isPOST && path == nowPlayingPath -> do
let t = fromJustNote "read track" $ lookupTrack id q
withDB dbPath $ \db -> withTransaction db $
insertNowPlaying db username t
pure ok
Nothing | isPOST -> pure $ failAuth BADAUTH
Nothing | isGET && wantsHandshake -> do
let user = fromJustNote "username" $ queryGetText urlQuery "u"
case queryHandshake urlQuery of
Nothing -> pure missingParameters
Just handshake -> do
mSessionID <- login accounts sessions handshake
pure $ case mSessionID of
Right sessionID -> (status200, handshakeResponse baseURL sessionID)
Left err -> failAuth err
_ ->
pure (status404, "Not Found\n")
logger req st (Just . fromIntegral $ LB.length msg)
respond $ responseLBS st [] msg
-- Authentication
type Accounts = [(Username, B.ByteString)]
parseAccounts :: Text -> Accounts
parseAccounts = map parseLine . T.lines
where parseLine line = let (u, p) = T.breakOn ":" line
in (u, T.encodeUtf8 (T.tail p))
type SessionID = B.ByteString
type Sessions = [(SessionID, Username)]
getSession :: MVar Sessions -> Username -> IO SessionID
getSession sv user = do
modifyMVar sv $ \sessions ->
case find ((== user) . snd) sessions of
Just (sessionID, _) -> pure (sessions, sessionID)
Nothing -> do
sessionID <- B16.encode <$> getEntropy 16
pure ((sessionID, user) : sessions, sessionID)
findSessionUser :: MVar Sessions -> SessionID -> IO (Maybe Username)
findSessionUser sv sessionID =
fmap snd . find ((== sessionID) . fst) <$> readMVar sv
data LoginError = BADAUTH | BADTIME
deriving (Show)
data Handshake = Handshake
{ hsUser :: Text
, hsToken :: B.ByteString
, hsTimestamp :: Int64
, hsTimestampString :: B.ByteString
}
queryHandshake :: Query -> Maybe Handshake
queryHandshake q = do
guard $ ("hs", Just "true") `elem` q
u <- queryGetText q "u"
a <- queryGet q "a"
ts <- queryGet q "t"
ti <- readMay $ B.unpack ts
pure Handshake
{ hsUser = u
, hsToken = a
, hsTimestampString = ts
, hsTimestamp = ti
}
checkTime :: Handshake -> IO Bool
checkTime hs = do
currentTime <- getPOSIXTime
let maxClockDifference = 10
timestamp = hsTimestamp hs
currentSeconds :: Int64
currentSeconds = round currentTime
pure $ abs (timestamp - currentSeconds) < maxClockDifference
checkToken :: Accounts -> Handshake -> Maybe Username
checkToken accounts hs = do
let user = hsUser hs
password <- lookup user accounts
let expectedToken = md5 (md5 password <> hsTimestampString hs)
if expectedToken == hsToken hs then Just user else Nothing
-- | MD5 with output in lowercase hexadecimal as specified in the
-- scrobbling submission protocol.
md5 :: B.ByteString -> B.ByteString
md5 = B16.encode . MD5.hash
note :: a -> Maybe b -> Either a b
note x Nothing = Left x
note _ (Just x) = Right x
login :: Accounts -> MVar Sessions -> Handshake -> IO (Either LoginError SessionID)
login accounts sessions hs = do
timeOk <- checkTime hs
if timeOk then
sequence . note BADAUTH $ getSession sessions <$> checkToken accounts hs
else
pure $ Left BADTIME
-- Scrobble type
lookupTrack :: (B.ByteString -> B.ByteString) -> Query -> Maybe Track
lookupTrack accessor query = do
let getRead = queryGetRead query . accessor
getText = queryGetText query . accessor
a <- getText "a"
t <- getText "t"
let l = getRead "l"
b = getText "b"
n = getText "n"
m = getText "m"
return Track
{ trackTitle = t
, trackArtist = a
, trackAlbum = b
, trackLength = l
, trackNumber = n
, trackMusicBrainzID = m
}
lookupScrobble :: (B.ByteString -> B.ByteString) -> Query -> Maybe Scrobble
lookupScrobble accessor query = do
let get = queryGet query . accessor
getRead = queryGetRead query . accessor
track <- lookupTrack accessor query
i <- getRead "i"
o <- parseScrobbleSource . B.unpack =<< get "o"
let r = parseScrobbleRating . B.unpack =<< get "r"
return Scrobble
{ scrobbleTrack = track
, scrobbleSource = o
, scrobbleRating = r
, scrobbleTime = i
}
queryGet :: Query -> B.ByteString -> Maybe B.ByteString
queryGet q x = nonempty =<< join (lookup x q)
queryGetRead :: Read a => Query -> B.ByteString -> Maybe a
queryGetRead q x = readMay . B.unpack =<< queryGet q x
queryGetText :: Query -> B.ByteString -> Maybe Text
queryGetText q x = T.decodeUtf8 <$> queryGet q x
data Track = Track
{ trackTitle :: Text
, trackArtist :: Text
, trackAlbum :: Maybe Text
, trackLength :: Maybe Int
, trackNumber :: Maybe Text
, trackMusicBrainzID :: Maybe Text
}
deriving (Eq, Show)
type EpochTime = Int64
data Scrobble = Scrobble
{ scrobbleTrack :: Track
, scrobbleSource :: ScrobbleSource
, scrobbleRating :: Maybe ScrobbleRating
, scrobbleTime :: EpochTime
}
deriving (Eq, Show)
data ScrobbleSource
= SourceP
| SourceR
| SourceE
| SourceL
deriving (Eq, Show)
data ScrobbleRating
= RatingLove
| RatingBan
| RatingSkip
deriving (Eq, Show)
parseScrobbleSource :: String -> Maybe ScrobbleSource
parseScrobbleSource s = case s of
"P" -> Just SourceP
"R" -> Just SourceR
"E" -> Just SourceE
'L' : _ -> Just SourceL
_ -> Nothing
scrobbleSourceLetter :: IsString s => ScrobbleSource -> s
scrobbleSourceLetter s = case s of
SourceP -> "P"
SourceR -> "R"
SourceE -> "E"
SourceL -> "L"
parseScrobbleRating :: String -> Maybe ScrobbleRating
parseScrobbleRating s = case s of
"L" -> Just RatingLove
"B" -> Just RatingBan
"S" -> Just RatingSkip
_ -> Nothing
scrobbleRatingLetter :: IsString s => ScrobbleRating -> s
scrobbleRatingLetter s = case s of
RatingLove -> "L"
RatingBan -> "B"
RatingSkip -> "S"
nonempty :: (IsString s, Eq s) => s -> Maybe s
nonempty "" = Nothing
nonempty x = Just x
-- Routing
nowPlayingPath :: IsString s => s
nowPlayingPath = "/nowplaying/"
submissionsPath :: IsString s => s
submissionsPath = "/submissions/"
handshakeResponse :: LB.ByteString -> SessionID -> LB.ByteString
handshakeResponse baseURL sessionID =
"OK\n"
<> LB.fromStrict sessionID
<> "\n"
<> baseURL <> nowPlayingPath <> "\n"
<> baseURL <> submissionsPath <> "\n"
-- Database
type RowID = Int64
type Username = Text
insertNowPlaying :: SQL.Connection -> Username -> Track -> IO RowID
insertNowPlaying db user tr = do
ui <- insertName db "user" user
tri <- insertTrack db tr
now <- realToFrac <$> getPOSIXTime :: IO Double
let nowInt = round now :: Int64
SQL.execute db "DELETE FROM now_playing WHERE user_id = ?" (SQL.Only ui)
insertValues db "now_playing" ["user_id", "track_id", "timestamp"] (ui, tri, nowInt)
insertScrobble :: SQL.Connection -> Username -> Scrobble -> IO RowID
insertScrobble db user s = do
let so = scrobbleSourceLetter (scrobbleSource s) :: Text
ra = scrobbleRatingLetter <$> scrobbleRating s :: Maybe Text
ts = scrobbleTime s
ui <- insertName db "user" user
tri <- insertTrack db $ scrobbleTrack s
insertValues db "scrobble" ["user_id", "track_id", "source", "rating", "timestamp"] (ui, tri, so, ra, ts)
insertTrack :: SQL.Connection -> Track -> IO RowID
insertTrack db t = do
ti <- insertName db "title" $ trackTitle t
ai <- insertName db "artist" $ trackArtist t
bi <- Tr.sequence $ insertName db "album" <$> trackAlbum t
mi <- Tr.sequence $ insertName db "musicbrainz" <$> trackMusicBrainzID t
selectOrInsertValues db "track" ["title_id", "artist_id", "album_id", "musicbrainz_id", "length", "number"] (ti, ai, bi, mi, trackLength t, trackNumber t)
newtype TableName = TableName { unTableName :: Text }
deriving (Show)
type ColumnName = TableName
instance IsString TableName where
fromString = TableName . fromString
insertName :: SQL.Connection -> TableName -> Text -> IO RowID
insertName db t n = selectOrInsertValues db t ["name"] (SQL.Only n)
insertValues :: SQL.ToRow a => SQL.Connection -> TableName -> [ColumnName] -> a -> IO RowID
insertValues db (TableName table) cols' x = do
SQL.execute db insertQuery x
SQL.lastInsertRowId db
where
cols = map unTableName cols'
substTuple = tuple $ map (const "?") cols
insertQuery = SQL.Query $
"INSERT INTO " <> table
<> tuple cols <> " values " <> substTuple
selectValues :: SQL.ToRow a => SQL.Connection -> TableName -> [ColumnName] -> a -> IO (Maybe RowID)
selectValues db (TableName table) cols' x =
fmap SQL.fromOnly . listToMaybe <$> SQL.query db selectQuery x
where
cols = map unTableName cols'
ands = T.intercalate " AND " $ map (<> " IS ?") cols
selectQuery = SQL.Query $
"SELECT id FROM " <> table <> " WHERE " <> ands
selectOrInsertValues :: SQL.ToRow a => SQL.Connection -> TableName -> [ColumnName] -> a -> IO RowID
selectOrInsertValues db t cols' x = do
mi <- selectValues db t cols' x
case mi of
Nothing -> insertValues db t cols' x
Just i -> return i
openDB :: FilePath -> IO SQL.Connection
openDB path = do
db <- SQL.open path
when False $
SQL.setTrace db . Just $ \s -> T.putStrLn $ "SQL: " <> s
SQL.execute_ db "PRAGMA foreign_keys = ON"
return db
withDB :: FilePath -> (SQL.Connection -> IO a) -> IO a
withDB dbPath = bracket (openDB dbPath) SQL.close
setupDB :: SQL.Connection -> IO ()
setupDB db = RawSQL.exec (SQL.connectionHandle db) schema
withTransaction :: SQL.Connection -> IO a -> IO a
withTransaction db f =
bracketOnError
(void $ SQL.execute_ db "BEGIN TRANSACTION")
(const $ SQL.execute_ db "ROLLBACK")
(const $ f <* SQL.execute_ db "COMMIT TRANSACTION")
commas :: [Text] -> Text
commas = T.intercalate ","
tuple :: [Text] -> Text
tuple xs = "(" <> commas xs <> ")"
schema :: Text
schema = T.unlines
[ nameTable "title"
, nameTable "artist"
, nameTable "album"
, nameTable "musicbrainz"
, nameTable "user"
, "CREATE TABLE IF NOT EXISTS track"
, "( id INTEGER PRIMARY KEY"
, ", " <> foreignKey "title" True
, ", " <> foreignKey "artist" True
, ", " <> foreignKey "album" False
, ", " <> foreignKey "musicbrainz" False
, ", length INTEGER"
, ", number TEXT"
, ");"
, "CREATE TABLE IF NOT EXISTS scrobble"
, "( id INTEGER PRIMARY KEY"
, ", " <> foreignKey "user" True
, ", " <> foreignKey "track" True
, ", source TEXT NOT NULL"
, ", rating TEXT"
, ", timestamp INTEGER NOT NULL"
, ");"
, "CREATE TABLE IF NOT EXISTS now_playing"
, "( id INTEGER PRIMARY KEY"
, ", " <> foreignKey "user" True
, ", " <> foreignKey "track" True
, ", timestamp INTEGER NOT NULL"
, ");"
]
where nameTable name = T.unlines
[ "CREATE TABLE IF NOT EXISTS " <> name
, "( id INTEGER PRIMARY KEY"
, ", name TEXT NOT NULL UNIQUE"
, ");"
]
foreignKey name required =
name <> "_id INTEGER"
<> (if required then " NOT NULL" else "")
<> " REFERENCES " <> name