-
Notifications
You must be signed in to change notification settings - Fork 11
/
Main.hs
225 lines (201 loc) · 8.77 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
module Main (main) where
import ClassyPrelude
import Control.Concurrent.Lifted
import Control.Monad.Logger (runStderrLoggingT)
import Data.CaseInsensitive (original)
import Data.Char (isAlphaNum)
import qualified Data.Configurator as Conf
import Database
import Mailer
import Network.HTTP.Types
import Network.Wai (Application)
import qualified Network.Wai as Wai
import qualified Network.Wai.Handler.Warp as Warp
import Network.Wai.Handler.WebSockets (websocketsOr)
import Network.WebSockets.Connection (defaultConnectionOptions)
import Routes
import qualified Sockets
import System.Random (getStdRandom, randomR)
import Web.Scotty
maybeParam :: Parsable a => LText -> ActionM (Maybe a)
maybeParam name = (Just <$> param name) `rescue` (pure . const Nothing)
defaultParam :: Parsable a => LText -> a -> ActionM a
defaultParam name def = param name `rescue` (pure . const def)
validated :: Parsable a => (a -> Bool) -> LText -> ActionM a -> ActionM a
validated f errorMessage val = do
inner <- val
if f inner then
val
else
raise errorMessage
maybeHeader :: HeaderName -> ActionM (Maybe Text)
maybeHeader name = (listToMaybe . map (decodeUtf8 . snd)
. filter ((== name) . fst) . Wai.requestHeaders) <$> request
getHeader :: HeaderName -> ActionM Text
getHeader name = maybe (raise message) pure =<< maybeHeader name
where message = "missing \"" <> headerName <> "\" header"
headerName = (fromStrict . decodeUtf8 . original) name
route :: Database
-> Chan (EmailAddress, Code)
-> Chan ResolvedPost
-> (ActionM () -> ScottyM ())
-> ActionM (Either Response Request)
-> ScottyM ()
route db emailChan socketChan path makeReq = path $ do
reqOrRes <- makeReq
let dbRes = either pure execute reqOrRes
res <- liftIO (runReaderT dbRes db)
effects <- send res
mapM_ (liftIO . perform) effects
where
perform :: SideEffect -> IO ()
perform (SendEmail emailAddress code) =
writeChan emailChan (emailAddress, code)
perform (SocketUpdate p) =
writeChan socketChan p
simpleRoute :: Database
-> Chan (EmailAddress, Code)
-> Chan ResolvedPost
-> (ActionM () -> ScottyM ())
-> ActionM Request -> ScottyM ()
simpleRoute db emailChan socketChan path makeReq = route db emailChan socketChan path $
(Right <$> makeReq) `rescue` (pure . Left . BadRequest)
execute :: Request -> DatabaseM Response
execute (GetPost idPost) = maybe (PostNotFound idPost) ExistingPost <$> getPost idPost
execute (ListPosts query) = PostList <$> getPosts query
execute (CreatePost idParent token content) =
maybe (pure BadToken) makePost =<< getUserByToken token
where makePost user = do
maybePost <- createPost user content idParent
pure $ case (idParent, maybePost) of
(_, Just post) -> NewPost post
(Just idParent, Nothing) -> PostNotFound idParent
(Nothing, Nothing) -> error "creating top-level posts should never fail"
execute (CreateCode emailAddress) =
maybe UnknownEmail NewCode <$> createCode emailAddress
execute (CreateToken code) =
maybe (pure BadCode) (fmap NewToken . withUser) =<< createToken code
where
noUser = error "we created a token but then couldn't find the user"
withUser token@TokenRecord{tokenUserID} =
maybe noUser (ResolvedToken token) <$> getUser tokenUserID
execute (CreateUser email name) =
maybe (pure ExistingNameOrEmail) withCode =<< createUser email name
where withCode User{userEmail} = maybe noCode NewUser <$> createCode userEmail
noCode = error "failed to create code for new user"
data SideEffect = SendEmail EmailAddress Code
| SocketUpdate ResolvedPost
done :: ActionM [SideEffect]
done = pure []
send :: Response -> ActionM [SideEffect]
send (NewPost p) = json p $> [SocketUpdate p]
send (ExistingPost p) = json p *> done
send (PostList ps) = json ps *> done
send (NewToken t) = json t *> done
send (NewUser resolvedCode@(ResolvedCode _ user)) = json user *> send (NewCode resolvedCode)
send (NewCode (ResolvedCode code user)) = pure [SendEmail (userEmail user) (codeValue code)]
send BadToken = status status401 *> text "invalid token" *> done
send BadCode = status status401 *> text "invalid code" *> done
send UnknownEmail = status status200 *> done
send InvalidUsername = status status400 *> text "invalid username" *> done
send ExistingNameOrEmail = status status409 *> text "username or email address taken" *> done
send (BadRequest message) = status status400 *> text message *> done
send (PostNotFound idPost) = status status404 *> text message *> done
where message = mconcat ["post ", tlshow idPost, " not found"]
isLegalLimit :: Int -> Bool
isLegalLimit x
| x < 1 = False
| x > 500 = False
| otherwise = True
basilica :: Maybe ByteString
-> Database
-> Chan (EmailAddress, Code)
-> Chan ResolvedPost
-> IO Application
basilica origin db emailChan socketChan = scottyApp $ do
case origin of
Nothing -> pure ()
Just o -> do
middleware (addHeaders [("Access-Control-Allow-Origin", o)])
addroute OPTIONS (function $ const $ Just []) $ do
setHeader "Access-Control-Allow-Headers" "X-Token"
setHeader "Access-Control-Allow-Methods" "GET, POST, PUT, PATCH, DELETE, OPTIONS"
status status200
let simple = simpleRoute db emailChan socketChan
let limit = validated isLegalLimit "limit out of range"
(defaultParam "limit" 200)
simple (get "/posts") (ListPosts <$> (PostQuery <$> maybeParam "before"
<*> maybeParam "after"
<*> limit))
simple (get "/posts/:id") (GetPost <$> param "id")
simple (post "/posts") (CreatePost Nothing <$> getHeader "X-Token"
<*> param "content")
simple (post "/posts/:id") (CreatePost <$> (Just <$> param "id")
<*> getHeader "X-Token"
<*> param "content")
simple (post "/codes") (CreateCode <$> param "email")
simple (post "/tokens") (CreateToken <$> param "code")
route db emailChan socketChan (post "/users") $ do
name <- param "name"
if isValidName name then
(fmap Right . CreateUser) <$> param "email" <*> pure name
else
pure (Left InvalidUsername)
isValidName :: Text -> Bool
isValidName name = all isAlphaNum name && (len >= 2) && (len < 20)
where len = length name
addHeaders :: ResponseHeaders -> Wai.Middleware
addHeaders newHeaders app req respond = app req $ \response -> do
let (st, currentHeaders, streamHandle) = Wai.responseToStream response
streamHandle $ \streamBody ->
respond $ Wai.responseStream st (currentHeaders ++ newHeaders) streamBody
randomSubject :: IO Text
randomSubject = (subjects `indexEx`) <$> getStdRandom (randomR (0, length subjects - 1))
where subjects = [ "Hey Beautiful"
, "Hey Baby"
, "Hey Hon"
, "Hey Honey"
, "Hey Girl"
, "Hey Sugarlips"
, "Hey Darling"
, "Hey Buttercup"
, "Hey Honeyfingers"
, "Hey Syruptoes"
]
sendCodeMail :: Mailer -> Text -> (EmailAddress, Code) -> IO ()
sendCodeMail mailer clientUrl (to, code) = do
subject <- randomSubject
sendMail mailer (easyEmail to subject messageBody)
where messageBody = intercalate "\n"
[ "Here's your Basilicode:"
, ""
, code
, ""
, "And a handy login link:"
, ""
, clientUrl <> "/login?code=" <> code
, ""
, "Love,"
, " Basilica"
]
logCode :: (EmailAddress, Code) -> IO ()
logCode (to, code) = putStrLn (intercalate ": " [to, code])
main :: IO ()
main = do
conf <- Conf.load [Conf.Required "conf"]
port <- Conf.require conf "port"
origin <- Conf.lookup conf "client-origin"
mailgunKey <- Conf.lookup conf "mailgun-key"
mailHandler <- case mailgunKey of
Nothing -> pure logCode
Just key -> sendCodeMail (newMailer key) <$> Conf.require conf "client-url"
emailChan <- newChan
socketChan <- newChan
server <- Sockets.newServer socketChan
_ <- fork $ getChanContents emailChan >>= mapM_ mailHandler
dbPath <- Conf.require conf "dbpath"
runStderrLoggingT $ withDatabase dbPath $ \db ->
liftIO $ do
api <- basilica origin db emailChan socketChan
putStrLn $ "Running on port " ++ tshow port
Warp.run port (websocketsOr defaultConnectionOptions server api)