summaryrefslogtreecommitdiff
path: root/Hero/Host.hs
blob: 468bd0fa023a61af4f64ecd442dfd611a9de8912 (plain)
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
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PolyKinds #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE NoImplicitPrelude #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}

-- | Hero web app
--
-- : out mmc
--
-- : dep acid-state
-- : dep aeson
-- : dep clay
-- : dep containers
-- : dep docopt
-- : dep envy
-- : dep http-types
-- : dep ixset
-- : dep lucid
-- : dep miso
-- : dep mtl
-- : dep network-uri
-- : dep protolude
-- : dep safecopy
-- : dep servant
-- : dep servant-auth
-- : dep servant-auth-server
-- : dep servant-lucid
-- : dep servant-server
-- : dep split
-- : dep split
-- : dep string-quote
-- : dep tasty
-- : dep tasty-hunit
-- : dep text
-- : dep wai
-- : dep wai-app-static
-- : dep wai-extra
-- : dep wai-middleware-metrics
-- : dep warp
-- : dep x509
-- : dep regex-applicative
module Hero.Host
  ( main,
  )
where

import Alpha
import Biz.App (CSS (..), Manifest (..))
import qualified Biz.Cli as Cli
import Biz.Test ((@=?))
import qualified Biz.Test as Test
import qualified Clay
import qualified Crypto.JOSE.JWK as Crypto
import Data.Acid (AcidState)
import qualified Data.Acid.Abstract as Acid
import Data.Text (Text)
import qualified Data.Text.Lazy as Lazy
import Hero.Core
import qualified Hero.Keep as Keep
import qualified Hero.Look as Look
import qualified Hero.Look.Typography as Typography
import qualified Hero.Pack as Pack
import qualified Lucid as L
import Lucid.Base
import Miso
import Miso.String
import Network.HTTP.Types hiding (Header)
import Network.Wai
import Network.Wai.Application.Static
import qualified Network.Wai.Handler.Warp as Warp
import Servant
import qualified Servant.Auth.Server as Auth
import qualified System.Directory as Directory
import qualified System.Envy as Envy
import qualified System.Exit as Exit
import qualified System.IO as IO

main :: IO ()
main = Cli.main <| Cli.Plan help move test

help :: Cli.Docopt
help =
  [Cli.docopt|
mmc

Usage:
  mmc
  mmc test
|]

test :: Test.Tree
test = Test.group "Hero.Host" [Test.unit "id" <| 1 @=? (1 :: Integer)]

move :: Cli.Arguments -> IO ()
move _ = bracket startup shutdown run
  where
    run (cfg, app, _) = Warp.run (heroPort cfg) app
    prn = IO.hPutStrLn IO.stderr
    startup =
      Envy.decodeEnv >>= \case
        Left e -> Exit.die e
        Right cfg ->
          do
            keep <- Keep.open (heroKeep cfg)
            skey <- upsertKey (heroSkey cfg)
            say "hero"
            prn <| "port: " ++ show (heroPort cfg)
            prn <| "keep: " ++ heroKeep cfg
            prn <| "node: " ++ heroNode cfg
            prn <| "skey: " ++ heroSkey cfg
            let jwts = Auth.defaultJWTSettings skey
                cs =
                  Auth.defaultCookieSettings
                    { -- uncomment this for insecure dev
                      Auth.cookieIsSecure = Auth.NotSecure,
                      Auth.cookieXsrfSetting = Nothing
                    }
                ctx = cs :. jwts :. EmptyContext
                proxy = Proxy @(AllRoutes '[Auth.JWT, Auth.Cookie])
                static = serveDirectoryWith <| defaultWebAppSettings <| heroNode cfg
                server =
                  -- assets, auth, and the homepage is public
                  static
                    :<|> cssHandlers
                    :<|> pure heroManifest
                    :<|> pubHostHandlers
                    :<|> authHandler cs jwts
                    -- app and api are private
                    :<|> wrapAuth (jsonHandlers keep)
                    :<|> wrapAuth appHostHandlers
                    -- fall through to 404
                    :<|> Tagged handle404
            return
              ( cfg,
                serveWithContext
                  proxy
                  ctx
                  server,
                keep
              )
    shutdown :: App -> IO ()
    shutdown (_, _, keep) = do
      Keep.close keep
      return ()

upsertKey :: FilePath -> IO Crypto.JWK
upsertKey fp =
  Directory.doesFileExist fp >>= \exists ->
    if exists
      then Auth.readKey fp
      else Auth.writeKey fp >> Auth.readKey fp

-- This part is a little confusing. I have:
--
--   - 'App' which encapsulates the entire runtime state
--   - 'Config' has stuff I can set at startup
--   - 'HeroKeep' is the database and any other persistance
--   - the above are then put together in the 'startup' private function in
--     `main` above
--
-- I'm sure this can be cleaned up with a monad stack of some sort, but I
-- haven't the brain power to think through that. For now, just try and keep
-- things named clearly so I don't get confused.

-- | This can be generalized I think, put in Biz.App, or something
type App = (Config, Application, AcidState Keep.HeroKeep)

data Config = Config
  { heroPort :: Warp.Port,
    heroNode :: FilePath,
    heroKeep :: FilePath,
    heroSkey :: FilePath
  }
  deriving (Generic, Show)

instance Envy.DefConfig Config where
  defConfig = Config 3000 "_/bild/dev/Hero.Node/static" "_/keep" "/run/hero/skey"

instance Envy.FromEnv Config

-- | Convert client side routes into server-side web handlers
type AppHostRoutes = ToServerRoutes AppRoutes Templated Move

-- | These are the main app handlers, and should require authentication.
appHostHandlers :: User -> Server AppHostRoutes
appHostHandlers _ =
  homeHandler
    :<|> comicCoverHandler
    :<|> comicPageHandler
    :<|> comicPageFullHandler
    :<|> comicVideoHandler
    :<|> discoverHandler
    :<|> chooseExperienceHandler

-- | Marketing pages
type PubHostRoutes = ToServerRoutes PubRoutes Templated Move

pubHostHandlers :: Server PubHostRoutes
pubHostHandlers =
  homeHandler :<|> loginHandler

type JsonApi = "api" :> "comic" :> Get '[JSON] [Comic]

-- TODO: need a "you're not logged in" page
wrapAuth ::
  Auth.ThrowAll route =>
  (user -> route) ->
  Auth.AuthResult user ->
  route
wrapAuth f authResult = case authResult of
  Auth.Authenticated u -> f u
  Auth.BadPassword -> Auth.throwAll err401
  Auth.NoSuchUser -> Auth.throwAll err406
  Auth.Indefinite -> Auth.throwAll err422

jsonHandlers :: AcidState Keep.HeroKeep -> User -> Server JsonApi
jsonHandlers keep _ = Acid.query' keep <| Keep.GetComics 10

type CssRoute = "css" :> "main.css" :> Get '[CSS] Text

cssHandlers :: Server CssRoute
cssHandlers =
  return <. Lazy.toStrict <. Clay.render <| Typography.main <> Look.main

type AuthRoute =
  "auth"
    :> ReqBody '[JSON] LoginForm
    :> Post
         '[JSON]
         ( Headers
             '[ Header "Set-Cookie" Auth.SetCookie,
                Header "Set-Cookie" Auth.SetCookie
              ]
             User
         )

instance Auth.ToJWT User

instance Auth.FromJWT User

-- | Endpoint for performing authentication
--
-- TODO: get creds from keep
-- TODO: load initial library for user
authHandler ::
  Auth.CookieSettings ->
  Auth.JWTSettings ->
  LoginForm ->
  Handler
    ( Headers
        '[ Header "Set-Cookie" Auth.SetCookie,
           Header "Set-Cookie" Auth.SetCookie
         ]
        User
    )
authHandler cookieSettings jwtSettings loginForm =
  case loginForm of
    (LoginForm "ben@bsima.me" "test") ->
      applyCreds <| User "ben@bsima.me" "ben" []
    (LoginForm "mcovino@heroprojects.io" "test") ->
      applyCreds <| User "mcovino@heroprojects.io" "mike" []
    _ -> throwError err401
  where
    applyCreds usr = do
      mApplyCookies <- liftIO <| Auth.acceptLogin cookieSettings jwtSettings usr
      case mApplyCookies of
        Nothing -> throwError err401
        Just applyCookies -> return <| applyCookies usr

-- | See also 'server' above
type AllRoutes auths =
  ("static" :> Raw)
    :<|> CssRoute
    :<|> ("manifest.json" :> Get '[JSON] Manifest)
    :<|> PubHostRoutes
    :<|> AuthRoute
    :<|> (Auth.Auth auths User :> JsonApi)
    :<|> (Auth.Auth auths User :> AppHostRoutes)
    :<|> Raw

heroManifest :: Manifest
heroManifest =
  Manifest
    { name = "Hero",
      short_name = "Hero",
      start_url = ".",
      display = "standalone",
      theme_color = "#0a0a0a",
      description = "Comics for all"
    }

-- | Type for setting wrapping a view in HTML doctype, header, etc
newtype Templated a = Templated a
  deriving (Show, Eq)

instance L.ToHtml a => L.ToHtml (Templated a) where
  toHtmlRaw = L.toHtml
  toHtml (Templated x) = do
    L.doctype_
    L.html_ [L.lang_ "en"] <| do
      L.head_ <| do
        L.title_ "Hero [alpha]"
        L.link_ [L.rel_ "manifest", L.href_ "/manifest.json"]
        L.link_ [L.rel_ "icon", L.type_ ""]
        -- icons
        L.link_
          [ L.rel_ "apple-touch-icon",
            L.sizes_ "180x180",
            L.href_
              <| Pack.cdnEdge
              <> "/old-assets/images/favicons/apple-touch-icon.png"
          ]
        L.link_
          [ L.rel_ "icon",
            L.type_ "image/png",
            L.sizes_ "32x32",
            L.href_
              <| Pack.cdnEdge
              <> "/old-assets/images/favicons/favicon-32x32.png"
          ]
        L.link_
          [ L.rel_ "icon",
            L.type_ "image/png",
            L.sizes_ "16x16",
            L.href_
              <| Pack.cdnEdge
              <> "/old-assets/images/favicons/favicon-16x16.png"
          ]
        L.link_
          [ L.rel_ "manifest",
            L.href_
              <| Pack.cdnEdge
              <> "/old-assets/images/favicons/manifest.json"
          ]
        L.link_
          [ L.rel_ "mask-icon",
            L.href_
              <| Pack.cdnEdge
              <> "/old-assets/images/favicons/safari-pinned-tab.svg"
          ]
        L.meta_ [L.charset_ "utf-8"]
        L.meta_ [L.name_ "theme-color", L.content_ "#000"]
        L.meta_ [L.httpEquiv_ "X-UA-Compatible", L.content_ "IE=edge"]
        L.meta_
          [L.name_ "viewport", L.content_ "width=device-width, initial-scale=1"]
        cssRef animateRef
        cssRef bulmaRef
        cssRef fontAwesomeRef
        cssRef "/css/main.css" -- TODO: make this a safeLink?
        jsRef "/static/all.js"
        jsRef "/static/usersnap.js"
      L.body_ (L.toHtml x)
    where
      jsRef href =
        L.with
          (L.script_ mempty)
          [ makeAttribute "src" href,
            makeAttribute "async" mempty,
            makeAttribute "defer" mempty
          ]
      cssRef href =
        L.with
          (L.link_ mempty)
          [L.rel_ "stylesheet", L.type_ "text/css", L.href_ href]

handle404 :: Application
handle404 _ respond =
  respond
    <| responseLBS status404 [("Content-Type", "text/html")]
    <| renderBS
    <| toHtml
    <| Templated
    <| the404
    <| initForm homeLink

fontAwesomeRef :: MisoString
fontAwesomeRef = "https://use.fontawesome.com/releases/v5.7.2/css/all.css"

animateRef :: MisoString
animateRef =
  "https://cdnjs.cloudflare.com/ajax/libs/animate.css/3.7.0/animate.min.css"

-- TODO: if I remove this, then the login form (and probably other stuff) gets
-- messed up. When I remove this, I need to also port the necessary CSS styles
-- to make stuff look good.
bulmaRef :: MisoString
bulmaRef =
  "https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.2/css/bulma.min.css"

homeHandler :: Handler (Templated (View Move))
homeHandler = pure <. Templated <. home <| initForm homeLink

comicCoverHandler :: ComicId -> Handler (Templated (View Move))
comicCoverHandler id =
  pure <. Templated <. comicCover id <. initForm <| comicLink id

comicPageHandler :: ComicId -> PageNumber -> Handler (Templated (View Move))
comicPageHandler id n =
  pure <. Templated <. comicReader id n <. initForm <| comicReaderSpreadLink id n

comicPageFullHandler :: ComicId -> PageNumber -> Handler (Templated (View Move))
comicPageFullHandler id n =
  pure <. Templated <. comicReader id n <. initForm <| comicReaderFullLink id n

comicVideoHandler :: ComicId -> PageNumber -> Handler (Templated (View Move))
comicVideoHandler id n =
  pure <. Templated <. comicReader id n <. initForm <| comicVideoLink id n

discoverHandler :: Handler (Templated (View Move))
discoverHandler = pure <. Templated <. discover <| initForm discoverLink

chooseExperienceHandler :: ComicId -> PageNumber -> Handler (Templated (View Move))
chooseExperienceHandler id n =
  pure <. Templated <. comicReader id n <. initForm <| chooseExperienceLink id n

loginHandler :: Handler (Templated (View Move))
loginHandler = pure <. Templated <. login <| initForm loginLink