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
| module Lib
( router
) where
import qualified Web.Scotty.Trans as Scotty
import Web.Scotty.Trans as Scotty (middleware, matchAny, request,
formParam, redirect, formParams, captureParam, queryParams)
import Control.Monad.IO.Class (liftIO)
import qualified Web
import qualified Database.SQLite.Simple as SQL (open, Connection)
import Models
import Crypto.BCrypt
import qualified Data.Text.Encoding as T
import qualified Data.Text as T
import Cookies
import qualified Repos.Users as Users
import qualified Repos.Characters as Characters
import qualified Repos.Prompts as Prompts
import qualified Repos.Stories as Stories
import qualified Repos.Subscriptions as Subscriptions
import Utils
import Control.Monad.Reader (runReaderT, lift)
import Control.Monad (when, unless)
import Types
import Colog (logByteStringStdout, LogAction (..))
import Framework
import Network.Wai (Request(..))
import qualified Data.ByteString as BS
import qualified Data.Vault.Lazy as V
import Control.Exception (SomeException)
import Network.HTTP.Types (unauthorized401)
import Openai
import Stripe
import qualified Data.Text.Lazy as TL
import Control.Concurrent (forkIO)
import Data.Aeson (eitherDecode)
import qualified Data.ByteString.Lazy as BL
import qualified Data.ByteString as B
import Data.Maybe (isJust)
import qualified Domain.Auth as Auth
router :: IO ()
router = do
dbConn <- SQL.open "osr.db"
let logger = logByteStringStdout
let env = Env dbConn 3000 logger
reqInfoKey <- V.newKey
let config = Config env reqInfoKey
let runIO (c :: App r) = runReaderT c.runApp config
Scotty.scottyT 3000 runIO (server logger reqInfoKey dbConn)
server :: LogAction IO BS.ByteString -> V.Key ReqInfo -> SQL.Connection -> Server
server logger vaultKeys dbConn = do
middleware limitHttpMethods
middleware $ prepareRequest vaultKeys dbConn logger
middleware safetyHeaders
middleware htmxRedirectMiddleware
-- Scotty.defaultHandler $ Scotty.Handler $ \(e :: SomeException) -> do
-- let err = show e
-- liftIO $ print err
-- if elem (take 2 err) ["AE"]
-- then Scotty.next
-- else do
-- liftIO $ putStrLn $ show e
-- Scotty.text "error"
matchAny (Scotty.regex ".*") $ do
method <- requestMethod <$> request
when (elem method ["GET", "HEAD", "OPTIONS", "TRACE"]) Scotty.next
paths <- pathInfo <$> request
when (toMaybe paths == Just "stripe") Scotty.next
mCookieToken <- getCookie "csrf_token"
case mCookieToken of
Nothing -> Scotty.text "error"
Just cookieToken -> do
-- TODO catch
formToken :: T.Text <- formParam "csrf_token"
if (T.length cookieToken > 0)
&& (T.length formToken > 0)
&& (cookieToken == formToken)
then Scotty.next
else Scotty.text "error"
Scotty.get "/style.css" $ do
Scotty.setHeader "Content-Type" "text/css"
Scotty.file "assets/style.css"
Scotty.get "/tailwind.css" $ do
Scotty.setHeader "Content-Type" "text/css"
Scotty.file "assets/tailwind.css"
-- Scotty.get "/favicon.png" $ do
-- Scotty.setHeader "Content-Type" "image/png"
-- Scotty.file "assets/favicon.png"
Scotty.get "/favicon.svg" $ do
Scotty.setHeader "Content-Type" "image/svg+xml"
Scotty.file "assets/favicon.svg"
Scotty.get "/logo.svg" $ do
Scotty.setHeader "Content-Type" "image/svg+xml"
Scotty.file "assets/logo.svg"
-- tutorial monady lift Stories.hot >>= render . Web.index
getA "/" $ do
render Web.index
getA "/register" $ do
render Web.register
getA "/login" $ do
render $ Web.login
getAuth "/account" $ do
render $ Web.account
getAuth "/characters" $ do
chars <- lift Characters.getAll
render $ Web.characters chars
getAuth "/pricing" $ do
failed <- isJust . lookup "no_payment" <$> queryParams
render $ Web.pricing failed
postAuth "/checkout" $ do
userId <- lift getAuthUserId
checkout <- liftIO $ createCheckout userId
redirect $ TL.fromStrict checkout.url
getAuth "/success" $ do
render Web.success
getAuth "/failure" $ do
render Web.failure
postAuth "/characters" $ do
name :: T.Text <- formParam "name"
desc :: T.Text <- formParam "description"
u <- liftIO uuid
_ <- lift $ Characters.create u name desc
redirect "/characters"
postA "/stripe/webhooks" $ do
stripeHeader <- Scotty.header "Stripe-Signature"
jsonText <- Scotty.body
let valid = validateWebhook (TL.toStrict <$> stripeHeader) (T.decodeUtf8 . B.concat . BL.toChunks $ jsonText)
case valid of
False -> do
Scotty.status unauthorized401
lift $ logXd "not ok"
Scotty.text "not ok"
True -> do
event :: StripeEvent <- Scotty.jsonData
case event.eventType of
"checkout.session.completed" -> do
u <- liftIO uuid
case event.eventData.object.client_reference_id of
Nothing -> do
lift $ logXd $ "subscription with empty user " <> event.id
return ()
Just userId -> do
_ <- lift $ Subscriptions.update True userId
_ <- lift $ Subscriptions.create u userId event.eventData.object.customer
return ()
"customer.subscription.deleted" -> do
_ <- lift $ Subscriptions.update False event.eventData.object.customer
return ()
eventName -> do
lift $ logXd $ "received event " <> eventName
return ()
Scotty.text "ok"
getAuth "/new" $ do
chars <- lift Characters.getAll
render $ Web.newStory chars
getAuth "/stories" $ do
stories <- lift Stories.getAll
render $ Web.stories stories
getAuth "/stories/:id" $ do
storyId :: T.Text <- captureParam "id"
story <- lift $ Stories.get storyId
htmx <- isHtmx
case story of
Nothing -> if htmx
then partialRender $ Web.waitForStory storyId
else render $ Web.waitForStory storyId
Just s -> if htmx
then partialRender $ Web.story s
else render $ Web.story s
postAuth "/stories" $ do
desc :: T.Text <- formParam "description"
params <- formParams
let charsIds = map (TL.toStrict . snd) $ filter (\(x, _) -> x == "characters") params
chars <- lift $ filter (\c -> elem c.id charsIds) <$> Characters.getAll
let charPrompt = map (\c -> c.name <> ": " <> c.description) chars
let prompt = "Create a short story for kids. This is the plot: "
<> desc <> ". It should include those characters:\n" <> T.intercalate "\n" charPrompt
<> "\n Don't repeat characters descriptions from the prompt in a story."
limit <- lift Stories.lastMonth
case limit.count > 327 of
True -> do
lift $ logXd "crossed stories limit"
redirectBack
False -> do
liftIO $ putStr $ T.unpack prompt
lift $ logXd prompt
u <- liftIO uuid
_ <- lift $ Prompts.create u desc
su <- liftIO uuid
userId <- lift getAuthUserId
_ <- liftIO $ forkIO $ do
result <- chatCall prompt
let story = eitherDecode (BL.fromChunks . return . T.encodeUtf8 $ (head result.choices).message.content) :: Either String StoryResp
case story of
Left e -> print e
Right s -> Stories.createIO su u userId s.title s.summary s.story dbConn
return ()
redirect $ TL.fromStrict $ "/stories/" <> su
postA "/register" $ do
username :: T.Text <- formParam "username"
password :: T.Text <- formParam "password"
unless (validParam username && validParam password) $ redirectAndFinish "/register"
hash_pass <- liftIO $ fmap T.decodeUtf8 <$>
hashPasswordUsingPolicy slowerBcryptHashingPolicy (T.encodeUtf8 password)
u <- liftIO uuid
n <- liftIO now
case hash_pass of
Nothing ->
partialRender $ Web.registerHTMX $ Just "Something went wrong, please try again."
Just hp -> do
let user = User u username hp False n
r <- lift $ Users.register user
case r of
Nothing -> do
setSession user.id
lift $ logXd $ "new user registered: " <> username
redirect "/"
Just _ -> do
partialRender $ Web.registerHTMX $ Just "user already registered"
postA "/login" $ do
username :: T.Text <- formParam "username"
password :: T.Text <- formParam "password"
unless (validParam username && validParam password) $ redirectAndFinish "/login"
mUser <- lift $ Users.byUsername username
case Auth.login mUser password of
Nothing -> do
htmx <- isHtmx
if htmx
then partialRender $ Web.loginWithError username password
else redirect "/login"
Just userId -> do
setSession userId
redirect "/"
postA "/logout" $ do
deleteSession
redirect "/"
validParam :: T.Text -> Bool
validParam p = (T.length p >= 4) && (T.length p <= 25)
redirectAndFinish url = do
redirect url
Scotty.finish
|