summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBen Sima <ben@bsima.me>2020-06-27 09:20:59 -0700
committerBen Sima <ben@bsima.me>2020-06-27 09:20:59 -0700
commit14e3c6a61f7727e994c4e1cf2568a3e606f84648 (patch)
tree6322dcfecf06bad2be8f85d560fd81e5206262e2
parent1ad6b3248f788cc178162bac5919c0b0fd6f9d39 (diff)
hero: implement the basics of user logins
There's also a lot of refactoring/renaming in here, so the diff is really messy. The overall problem is that I've only ever added code, I've never gone back and reorganized/rearchitected stuff. So adding even small features is becoming an enormous effort. Anyway, this adds the basics of user auth. Next I need to add the auth checks for every route that needs it, and make sure everything is back to working correctly.
-rw-r--r--.envrc2
-rw-r--r--.gitignore1
-rw-r--r--Hero/App.hs311
-rw-r--r--Hero/Client.hs114
-rw-r--r--Hero/Server.hs156
-rw-r--r--Hero/Service.nix5
-rw-r--r--Miso/Extend.hs26
-rwxr-xr-xbild2
-rw-r--r--nix/haskell-deps.nix3
-rw-r--r--nix/haskell-overlay.nix13
-rw-r--r--nix/sources.json12
11 files changed, 421 insertions, 224 deletions
diff --git a/.envrc b/.envrc
index e1b8d4a..346588d 100644
--- a/.envrc
+++ b/.envrc
@@ -1,10 +1,10 @@
PATH_add $PWD
-export NIX_PATH=$PWD/nix
export BIZ_ROOT=$PWD
export HERO_PORT=3000
export HERO_BEAM=$BIZ_ROOT/_bild/Hero.Client/static
export HERO_KEEP=$BIZ_ROOT/_keep
+export HERO_SKEY=$BIZ_ROOT/_skey
export GUILE_LOAD_PATH=$PWD
export EDITOR=vim
diff --git a/.gitignore b/.gitignore
index 3a189fa..20aa8d7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,5 +8,6 @@ tags
.tex
.pdf
_keep
+_skey
*~
dist*
diff --git a/Hero/App.hs b/Hero/App.hs
index 418993d..9391eac 100644
--- a/Hero/App.hs
+++ b/Hero/App.hs
@@ -35,37 +35,22 @@ import Hero.Look as Look
import Hero.Look.Typography
import Miso
import qualified Miso (for_)
+import Miso.Extend
import Miso.String
import Network.RemoteData
import Servant.API
( (:<|>) (..),
(:>),
- Capture,
- ToHttpApiData,
- FromHttpApiData,
- URI (..),
- safeLink,
)
+import qualified Servant.API as Api
import Servant.Links (linkURI)
-crossorigin_ :: MisoString -> Attribute action
-crossorigin_ = textProp "crossorigin"
-
-- | The css id for controling music in the comic player.
audioId :: MisoString
audioId = "audioSource"
--- | Like 'onClick' but prevents the default action from triggering. Use this to
--- overide 'a_' links, for example.
-onPreventClick :: Action -> Attribute Action
-onPreventClick action =
- onWithOptions
- Miso.defaultOptions {preventDefault = True}
- "click"
- emptyDecoder
- (\() -> action)
-
--- TODO: make ComicId a hashid https://hackage.haskell.org/package/hashids-1.0.2.4/docs/Web-Hashids.html
+-- TODO: make ComicId a hashid
+-- https://hackage.haskell.org/package/hashids-1.0.2.4/docs/Web-Hashids.html
newtype ComicId
= ComicId String
deriving
@@ -77,8 +62,8 @@ newtype ComicId
Generic,
ToMisoString,
IsString,
- ToHttpApiData,
- FromHttpApiData
+ Api.ToHttpApiData,
+ Api.FromHttpApiData
)
instance ToJSON ComicId where
@@ -98,6 +83,8 @@ instance CanSnakeCase Text where
comicSlug :: Comic -> Text
comicSlug Comic {..} = snake comicName <> "-" <> comicIssue
+-- * user
+
data User
= User
{ userEmail :: Text,
@@ -107,10 +94,11 @@ data User
deriving (Show, Eq, Generic, Data, Ord)
instance Semigroup User where
- a <> b = User
- (userEmail a <> userEmail b)
- (userName a <> userName b)
- (userLibrary a <> userLibrary b)
+ a <> b =
+ User
+ (userEmail a <> userEmail b)
+ (userName a <> userName b)
+ (userLibrary a <> userLibrary b)
instance Monoid User where
mempty = User mempty mempty mempty
@@ -121,23 +109,6 @@ instance ToJSON User where
instance FromJSON User where
parseJSON = genericParseJSON Data.Aeson.defaultOptions
-data Comic
- = Comic
- { comicId :: ComicId,
- comicPages :: Integer,
- comicName :: Text,
- -- | Ideally this would be a dynamic number-like type
- comicIssue :: Text,
- comicDescription :: Text
- }
- deriving (Show, Eq, Generic, Data, Ord)
-
-instance ToJSON Comic where
- toJSON = genericToJSON Data.Aeson.defaultOptions
-
-instance FromJSON Comic where
- parseJSON = genericParseJSON Data.Aeson.defaultOptions
-
-- | Class for rendering media objects in different ways.
class IsMediaObject o where
-- | Render a thumbnail for use in a shelf, or otherwise.
@@ -149,74 +120,8 @@ class IsMediaObject o where
-- | Media info view
info :: o -> User -> View Action
-instance IsMediaObject Comic where
- thumbnail c@Comic {..} =
- li_
- []
- [ a_
- [ class_ "comic grow clickable",
- id_ $ "comic-" <> ms comicId,
- onClick $ SetMediaInfo $ Just c
- ]
- [ img_ [src_ $ ms $ Assets.demo <> comicSlug c <> ".png"],
- span_ [] [text $ "Issue #" <> ms comicIssue],
- span_ [] [text $ ms comicName]
- ]
- ]
- feature comic lib =
- div_
- [id_ "featured-comic"]
- [ img_ [id_ "featured-banner", src_ $ ms $ Assets.demo <> "feature-banner.png"],
- div_
- [id_ "featured-content"]
- [ div_
- [class_ "hero-original", css wide]
- [ span_ [css thicc] [text "Herø"],
- span_ [css euro] [text " Original"]
- ],
- div_
- [class_ "comic-logo"]
- [img_ [src_ $ ms $ Assets.demo <> comicSlug comic <> "-logo.png"]],
- div_ [class_ "comic-action-menu"] $ el <$> [Watch comic, Read comic, Save comic lib],
- p_
- [class_ "description"]
- [ text . ms $ comicDescription comic
- ]
- ]
- ]
- info c@Comic {..} lib =
- div_
- [class_ "media-info", css euro]
- [ div_
- [class_ "media-info-meta"]
- [ column [img_ [src_ $ ms $ Assets.demo <> "dmc-widethumb.png"]],
- column
- [ span_ [style_ title] [text $ ms comicName],
- span_ [style_ subtitle] [text $ "Issue #" <> ms comicIssue],
- span_ [] [text "Released: "],
- span_ [] [text $ "Pages: " <> ms (show comicPages :: String)]
- ]
- ],
- div_
- [class_ "media-info-summary"]
- [ p_
- [style_ $ uppercase <> bold <> Look.expanded <> "font-size" =: ".8rem"]
- [text "Summary"],
- p_ [] [text $ ms comicDescription]
- ],
- div_ [class_ "media-info-actions"] $ el <$> [Save c lib, Read c, Watch c]
- -- , row [ text "credits" ]
- ]
- where
- title =
- "color" =: "red" <> "font-size" =: "1.6rem" <> uppercase
- <> "line-height"
- =: "100%"
- <> Look.condensed
- <> bold
- subtitle = "color" =: "#fff" <> "font-size" =: "1.2rem" <> bold <> Look.condensed
-
-type ZoomModel = Int
+-- | How much to Zoom the comic image
+type Magnification = Int
-- | All the buttons.
data Button
@@ -224,7 +129,7 @@ data Button
| Read Comic
| Save Comic User
| SaveIcon Comic User
- | ZoomIcon ZoomModel Comic Page
+ | ZoomIcon Magnification Comic Page
| PlayPause MisoString AudioState
| Arrow Action
@@ -336,17 +241,17 @@ findComic id = List.find (\c -> comicId c == id)
-- discover, 'cp' for comic player.
data Model
= Model
- { uri :: URI,
+ { uri :: Api.URI,
appComics :: RemoteData MisoString [Comic],
user :: User,
dMediaInfo :: Maybe Comic,
cpState :: ComicReaderState,
cpAudioState :: AudioState,
- zoomModel :: ZoomModel
+ magnification :: Magnification
}
deriving (Show, Eq)
-initModel :: URI -> Model
+initModel :: Api.URI -> Model
initModel uri_ =
Model
{ uri = uri_,
@@ -355,12 +260,12 @@ initModel uri_ =
user = mempty,
cpState = detectPlayerState uri_,
cpAudioState = Paused,
- zoomModel = 100
+ magnification = 100
}
--- | Hacky way to initialize the 'ComicReaderState' from the URI.
-detectPlayerState :: URI -> ComicReaderState
-detectPlayerState u = case List.splitOn "/" $ uriPath u of
+-- | Hacky way to initialize the 'ComicReaderState' from the Api.URI.
+detectPlayerState :: Api.URI -> ComicReaderState
+detectPlayerState u = case List.splitOn "/" $ Api.uriPath u of
["", "comic", id, pg, "experience"] -> ChooseExperience (ComicId id) (toPage pg)
["", "comic", id, _, "video"] -> Watching $ ComicId id
["", "comic", id, pg, "full"] -> Reading Full (ComicId id) (toPage pg)
@@ -388,10 +293,12 @@ data Action
| -- discover stuff
SetMediaInfo (Maybe Comic)
| ToggleInLibrary Comic
+ | -- login
+ ValidateUserPassword
| -- app stuff
ScrollIntoView MisoString
- | HandleURI URI
- | ChangeURI URI
+ | HandleURI Api.URI
+ | ChangeURI Api.URI
| DumpModel
deriving (Show, Eq)
@@ -427,7 +334,7 @@ routes = Proxy
-- proxy :: Proxy name
-- proxy = Proxy name
-- view :: Model -> View Action
--- link :: URI
+-- link :: Api.URI
-- * home
@@ -440,19 +347,26 @@ homeProxy = Proxy
home :: Model -> View Action
home = login
-homeLink :: URI
-homeLink = linkURI $ safeLink routes homeProxy
+homeLink :: Api.URI
+homeLink = linkURI $ Api.safeLink routes homeProxy
-- * login
+data LoginForm = LoginForm {loginEmail :: String, loginPass :: String}
+ deriving (Eq, Show, Read, Generic)
+
+instance ToJSON LoginForm
+
+instance FromJSON LoginForm
+
type Login =
"login" :> View Action
loginProxy :: Proxy Login
loginProxy = Proxy
-loginLink :: URI
-loginLink = linkURI $ safeLink routes loginProxy
+loginLink :: Api.URI
+loginLink = linkURI $ Api.safeLink routes loginProxy
login :: Model -> View Action
login _ =
@@ -467,8 +381,8 @@ login _ =
hr_ [class_ fadeIn],
form_
[class_ fadeIn]
- [ ctrl [class_ "input", type_ "email", placeholder_ "Email"],
- ctrl [class_ "input", type_ "password", placeholder_ "Password"],
+ [ ctrl [id_ "user", class_ "input", type_ "email", placeholder_ "Email"],
+ ctrl [id_ "pass", class_ "input", type_ "password", placeholder_ "Password"],
div_
[class_ "action", css euro]
[ div_
@@ -477,7 +391,7 @@ login _ =
label_ [Miso.for_ "checkbox"] [text "Remember Me"]
],
div_
- [class_ "button is-black", onClick $ ChangeURI discoverLink]
+ [class_ "button is-black", onClick ValidateUserPassword]
[text "Login"]
]
],
@@ -502,8 +416,8 @@ login _ =
type Discover = "discover" :> View Action
-discoverLink :: URI
-discoverLink = linkURI $ safeLink routes discoverProxy
+discoverLink :: Api.URI
+discoverLink = linkURI $ Api.safeLink routes discoverProxy
discoverProxy :: Proxy Discover
discoverProxy = Proxy
@@ -566,9 +480,102 @@ discoverFooter =
-- * comic
+data Comic
+ = Comic
+ { comicId :: ComicId,
+ comicPages :: Integer,
+ comicName :: Text,
+ -- | Ideally this would be a dynamic number-like type
+ comicIssue :: Text,
+ comicDescription :: Text
+ }
+ deriving (Show, Eq, Generic, Data, Ord)
+
+instance ToJSON Comic where
+ toJSON = genericToJSON Data.Aeson.defaultOptions
+
+instance FromJSON Comic where
+ parseJSON = genericParseJSON Data.Aeson.defaultOptions
+
+instance IsMediaObject Comic where
+ thumbnail c@Comic {..} =
+ li_
+ []
+ [ a_
+ [ class_ "comic grow clickable",
+ id_ $ "comic-" <> ms comicId,
+ onClick $ SetMediaInfo $ Just c
+ ]
+ [ img_ [src_ $ ms $ Assets.demo <> comicSlug c <> ".png"],
+ span_ [] [text $ "Issue #" <> ms comicIssue],
+ span_ [] [text $ ms comicName]
+ ]
+ ]
+ feature comic lib =
+ div_
+ [id_ "featured-comic"]
+ [ img_
+ [ id_ "featured-banner",
+ src_ $ ms $ Assets.demo <> "feature-banner.png"
+ ],
+ div_
+ [id_ "featured-content"]
+ [ div_
+ [class_ "hero-original", css wide]
+ [ span_ [css thicc] [text "Herø"],
+ span_ [css euro] [text " Original"]
+ ],
+ div_
+ [class_ "comic-logo"]
+ [ img_
+ [ src_
+ $ ms
+ $ Assets.demo <> comicSlug comic <> "-logo.png"
+ ]
+ ],
+ div_ [class_ "comic-action-menu"] $
+ el <$> [Watch comic, Read comic, Save comic lib],
+ p_
+ [class_ "description"]
+ [ text . ms $ comicDescription comic
+ ]
+ ]
+ ]
+ info c@Comic {..} lib =
+ div_
+ [class_ "media-info", css euro]
+ [ div_
+ [class_ "media-info-meta"]
+ [ column [img_ [src_ $ ms $ Assets.demo <> "dmc-widethumb.png"]],
+ column
+ [ span_ [style_ title] [text $ ms comicName],
+ span_ [style_ subtitle] [text $ "Issue #" <> ms comicIssue],
+ span_ [] [text "Released: "],
+ span_ [] [text $ "Pages: " <> ms (show comicPages :: String)]
+ ]
+ ],
+ div_
+ [class_ "media-info-summary"]
+ [ p_
+ [style_ $ uppercase <> bold <> Look.expanded <> "font-size" =: ".8rem"]
+ [text "Summary"],
+ p_ [] [text $ ms comicDescription]
+ ],
+ div_ [class_ "media-info-actions"] $ el <$> [Save c lib, Read c, Watch c]
+ -- , row [ text "credits" ]
+ ]
+ where
+ title =
+ "color" =: "red" <> "font-size" =: "1.6rem" <> uppercase
+ <> "line-height"
+ =: "100%"
+ <> Look.condensed
+ <> bold
+ subtitle = "color" =: "#fff" <> "font-size" =: "1.2rem" <> bold <> Look.condensed
+
type ComicCover =
"comic"
- :> Capture "comicId" ComicId
+ :> Api.Capture "comicId" ComicId
:> View Action
comicProxy :: Proxy ComicCover
@@ -577,24 +584,24 @@ comicProxy = Proxy
comicCover :: ComicId -> Model -> View Action
comicCover comicId_ = comicReader comicId_ 1
-comicLink :: ComicId -> URI
-comicLink comicId_ = linkURI $ safeLink routes comicProxy comicId_
+comicLink :: ComicId -> Api.URI
+comicLink comicId_ = linkURI $ Api.safeLink routes comicProxy comicId_
-- * chooseExperience
type ChooseExperience =
"comic"
- :> Capture "id" ComicId
- :> Capture "page" Page
+ :> Api.Capture "id" ComicId
+ :> Api.Capture "page" Page
:> "experience"
:> View Action
chooseExperienceProxy :: Proxy ChooseExperience
chooseExperienceProxy = Proxy
-chooseExperienceLink :: ComicId -> Page -> URI
+chooseExperienceLink :: ComicId -> Page -> Api.URI
chooseExperienceLink id page =
- linkURI $ safeLink routes chooseExperienceProxy id page
+ linkURI $ Api.safeLink routes chooseExperienceProxy id page
chooseExperiencePage :: Comic -> Page -> Model -> View Action
chooseExperiencePage comic page model =
@@ -641,7 +648,6 @@ dark, energetic or dramatic. Feeling indecisive? Let us navigate your journey
with the original curated music for this piece of visual art.
|]
-
-- * comicReader
data ComicReaderView = Spread | Full
@@ -683,21 +689,20 @@ zoomScreen comic page model =
<> padLeft page
<> ".png"
-
-- * comicReaderSpread
type ComicReaderSpread =
"comic"
- :> Capture "id" ComicId
- :> Capture "page" Page
+ :> Api.Capture "id" ComicId
+ :> Api.Capture "page" Page
:> View Action
comicReaderSpreadProxy :: Proxy ComicReaderSpread
comicReaderSpreadProxy = Proxy
-comicReaderSpreadLink :: ComicId -> Page -> URI
+comicReaderSpreadLink :: ComicId -> Page -> Api.URI
comicReaderSpreadLink id page =
- linkURI $ safeLink routes comicReaderSpreadProxy id page
+ linkURI $ Api.safeLink routes comicReaderSpreadProxy id page
comicSpread :: Comic -> Page -> Model -> View Action
comicSpread comic page model =
@@ -741,33 +746,33 @@ closeButton =
type ComicReaderFull =
"comic"
- :> Capture "id" ComicId
- :> Capture "page" Page
+ :> Api.Capture "id" ComicId
+ :> Api.Capture "page" Page
:> "full"
:> View Action
comicReaderFullProxy :: Proxy ComicReaderFull
comicReaderFullProxy = Proxy
-comicReaderFullLink :: ComicId -> Page -> URI
+comicReaderFullLink :: ComicId -> Page -> Api.URI
comicReaderFullLink id page =
- linkURI $ safeLink routes comicReaderFullProxy id page
+ linkURI $ Api.safeLink routes comicReaderFullProxy id page
-- * comicVideo
type ComicVideo =
"comic"
- :> Capture "id" ComicId
- :> Capture "page" Page
+ :> Api.Capture "id" ComicId
+ :> Api.Capture "page" Page
:> "video"
:> View Action
comicVideoProxy :: Proxy ComicVideo
comicVideoProxy = Proxy
-comicVideoLink :: ComicId -> Page -> URI
+comicVideoLink :: ComicId -> Page -> Api.URI
comicVideoLink id page =
- linkURI $ safeLink routes comicVideoProxy id page
+ linkURI $ Api.safeLink routes comicVideoProxy id page
frameborder_ :: MisoString -> Attribute action
frameborder_ = textProp "frameborder"
@@ -794,7 +799,6 @@ comicVideo _ _ _ =
]
]
-
-- * general page components & utils
-- | If 'View' had a 'Monoid' instance, then '(text "")' could just be 'mempty'
@@ -886,7 +890,7 @@ comicControls comic page model =
div_
[class_ "comic-controls-share"]
[ el $ SaveIcon comic $ user model,
- el $ ZoomIcon (zoomModel model) comic page,
+ el $ ZoomIcon (magnification model) comic page,
button_
[class_ "button icon is-large", onClick ToggleFullscreen]
[i_ [class_ "fa fa-expand"] []]
@@ -928,6 +932,5 @@ column :: [View Action] -> View Action
column = div_ [css $ Clay.display Clay.flex <> Clay.flexDirection Clay.column]
-- | Links
-
the404 :: Model -> View Action
the404 _ = template "404" [p_ [] [text "Not found"]]
diff --git a/Hero/Client.hs b/Hero/Client.hs
index 2b222bd..06a7eab 100644
--- a/Hero/Client.hs
+++ b/Hero/Client.hs
@@ -1,3 +1,4 @@
+{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE NoImplicitPrelude #-}
@@ -18,9 +19,12 @@
-- : dep ghcjs-base
module Hero.Client where
+import Alpha
+import Biz.Auth as Auth
import Data.Aeson (eitherDecodeStrict)
import qualified Data.Set as Set
import qualified GHC.Show as Legacy
+import GHCJS.Types (JSVal)
import Hero.App
( Action (..),
AudioState (..),
@@ -34,18 +38,13 @@ import Hero.App
comicReaderFullLink,
comicReaderSpreadLink,
comicVideoLink,
+ discoverLink,
handlers,
initModel,
routes,
the404,
)
-import JavaScript.Web.XMLHttpRequest
- ( Method (GET),
- Request (..),
- RequestData (NoData),
- contents,
- xhrByteString,
- )
+import JavaScript.Web.XMLHttpRequest as Ajax
import Miso
import Miso.Effect.DOM (scrollIntoView)
import qualified Miso.FFI.Audio as Audio
@@ -66,7 +65,7 @@ main = miso $ \currentURI -> App {model = initModel currentURI, ..}
keyboardSub keynav
]
events = defaultEvents
- initialAction = FetchComics
+ initialAction = NoOp
mountPoint = Nothing
(∈) :: Ord a => a -> Set a -> Bool
@@ -75,8 +74,8 @@ main = miso $ \currentURI -> App {model = initModel currentURI, ..}
-- | Keyboard navigation - maps keys to actions.
keynav :: Set Int -> Action
keynav ks
- | 37 ∈ ks = PrevPage -- left arrow
- | 39 ∈ ks = NextPage -- right arrow
+ | 37 ∈ ks = PrevPage -- ←
+ | 39 ∈ ks = NextPage -- →
| 191 ∈ ks = DumpModel -- ?
| 32 ∈ ks = ToggleAudio audioId -- SPC
| otherwise = NoOp
@@ -89,13 +88,16 @@ see model =
-- | Console-logging
foreign import javascript unsafe "console.log($1);"
- say :: MisoString -> IO ()
+ jslog :: MisoString -> IO ()
+
+foreign import javascript unsafe "$1.value"
+ getValue :: JSVal -> IO MisoString
-- | Updates model, optionally introduces side effects
move :: Action -> Model -> Effect Action Model
move NoOp model = noEff model
move DumpModel model = model <# do
- say $ ms $ Legacy.show model
+ jslog $ ms $ Legacy.show model
pure NoOp
move (SelectExperience comic) model = model {cpState = ChooseExperience (comicId comic) 1}
<# do pure $ ChangeURI $ chooseExperienceLink (comicId comic) 1
@@ -133,7 +135,7 @@ move (ToggleZoom c pg) m = m {cpState = newState} <# pure act
x -> (x, NoOp)
move (ToggleInLibrary c) model = model {user = newUser} <# pure NoOp
where
- newUser = (user model) { userLibrary = newLib }
+ newUser = (user model) {userLibrary = newLib}
newLib
| c `elem` (userLibrary $ user model) =
Protolude.filter (/= c) $ userLibrary $ user model
@@ -171,27 +173,79 @@ move (SetMediaInfo x) model = model {dMediaInfo = x}
Nothing ->
pure NoOp
move (ScrollIntoView id) model = model <# do
- say $ ms $ Legacy.show id
+ jslog $ ms $ Legacy.show id
scrollIntoView id
pure NoOp
+move ValidateUserPassword model =
+ batchEff
+ model
+ [doLogin, (SetComics </ fetchComics)]
+ where
+ doLogin = do
+ user <- getValue =<< Document.getElementById "user"
+ pass <- getValue =<< Document.getElementById "pass"
+ jslog "sending login"
+ sendLogin (ms user) (ms pass) >>= \case
+ Network.Success user -> do
+ jslog "successful login"
+ pure $ ChangeURI discoverLink
+ -- TODO: handle these error cases
+ Network.Loading -> pure NoOp
+ Network.Failure _ -> pure NoOp
+ Network.NotAsked -> pure NoOp
fetchComics :: IO (Network.RemoteData MisoString [Comic])
-fetchComics = do
- mjson <- contents <$> xhrByteString req
- case mjson of
- Nothing ->
- pure $ Network.Failure "Could not fetch comics from server."
- Just json ->
- pure $ Network.fromEither
- $ either (Left . ms) pure
- $ eitherDecodeStrict json
+fetchComics = Ajax.xhrByteString req /> Ajax.contents >>= \case
+ Nothing ->
+ pure $ Network.Failure "Could not fetch comics from server."
+ Just json ->
+ pure $ Network.fromEither
+ $ either (Left . ms) pure
+ $ eitherDecodeStrict json
+ where
+ req =
+ Ajax.Request
+ { Ajax.reqMethod = Ajax.GET,
+ Ajax.reqURI = "/api/comic", -- FIXME: can we replace this hardcoding?
+ Ajax.reqLogin = Nothing,
+ Ajax.reqHeaders = [],
+ Ajax.reqWithCredentials = False,
+ Ajax.reqData = Ajax.NoData
+ }
+
+sendLogin ::
+ Auth.Username ->
+ Auth.Password ->
+ IO
+ ( Network.RemoteData MisoString
+ User
+ )
+sendLogin u p = Ajax.xhrByteString req /> Ajax.contents >>= \case
+ Nothing ->
+ pure $ Network.Failure "Could not send login request."
+ Just json ->
+ pure $ Network.fromEither
+ $ either (Left . ms) pure
+ $ eitherDecodeStrict json
where
req =
- Request
- { reqMethod = GET,
- reqURI = "/api/comic", -- FIXME: can we replace this hardcoding?
- reqLogin = Nothing,
- reqHeaders = [],
- reqWithCredentials = False,
- reqData = NoData
+ Ajax.Request
+ { Ajax.reqMethod = Ajax.POST,
+ Ajax.reqURI = "/login-hook",
+ Ajax.reqLogin = Nothing, -- FIXME
+ Ajax.reqHeaders =
+ [ ("Accept", "application/json"),
+ ("Content-Type", "application/json")
+ ],
+ Ajax.reqWithCredentials = False,
+ -- TODO: make this use Aeson
+ Ajax.reqData =
+ Ajax.StringData $
+ Miso.String.concat
+ [ "{\"loginEmail\": \"",
+ u,
+ "\", \"loginPass\": \"",
+ p,
+ "\"}"
+ ]
}
diff --git a/Hero/Server.hs b/Hero/Server.hs
index 97ce7a2..d179cd2 100644
--- a/Hero/Server.hs
+++ b/Hero/Server.hs
@@ -1,11 +1,17 @@
{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE DeriveDataTypeable #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PolyKinds #-}
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TypeApplications #-}
+{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE NoImplicitPrelude #-}
@@ -27,6 +33,8 @@
-- : dep protolude
-- : dep safecopy
-- : dep servant
+-- : dep servant-auth
+-- : dep servant-auth-server
-- : dep servant-lucid
-- : dep servant-server
-- : dep split
@@ -38,13 +46,16 @@
-- : dep wai-extra
-- : dep wai-middleware-metrics
-- : dep warp
+-- : dep x509
module Hero.Server where
import Alpha
-import Biz.App (CSS(..), Manifest(..))
+import Biz.App (CSS (..), Manifest (..))
import qualified Clay
+import qualified Crypto.JOSE.JWK as Crypto
import Data.Acid (AcidState)
import qualified Data.Acid.Abstract as Acid
+import qualified Data.Aeson as Aeson
import Data.Text (Text)
import qualified Data.Text.Lazy as Lazy
import Hero.App
@@ -61,6 +72,8 @@ 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
@@ -72,26 +85,55 @@ main = bracket startup shutdown run
prn = IO.hPutStrLn IO.stderr
startup = Envy.decodeEnv >>= \case
Left e -> Exit.die e
- Right cfg -> do
- keep <- Keep.open (heroKeep cfg)
- say "hero"
- prn $ "port: " ++ show (heroPort cfg)
- prn $ "beam: " ++ heroBeam cfg
- prn $ "keep: " ++ heroKeep cfg
- let waiapp = mkApp keep cfg
- return (cfg, waiapp, keep)
+ Right cfg ->
+ do
+ keep <- Keep.open (heroKeep cfg)
+ skey <- upsertKey (heroSkey cfg)
+ say "hero"
+ prn $ "port: " ++ show (heroPort cfg)
+ prn $ "beam: " ++ heroBeam cfg
+ prn $ "keep: " ++ heroKeep cfg
+ prn $ "skey: " ++ heroSkey cfg
+ let jwts = Auth.defaultJWTSettings skey
+ cs = Auth.defaultCookieSettings
+ ctx = cs :. jwts :. EmptyContext
+ proxy = Proxy @(AllRoutes '[Auth.JWT])
+ static = serveDirectoryWith $ defaultWebAppSettings $ heroBeam cfg
+ server =
+ static
+ :<|> cssHandlers
+ :<|> (return "hi")
+ :<|> loginHookHandler cs jwts
+ :<|> jsonHandlers keep
+ :<|> publicHandlers
+ :<|> pure heroManifest
+ :<|> 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
--- - 'mkApp' take the second two and makes a 'Wai.Application', should really be
--- called 'serve', and might need to be Servant's 'hoistServer' thing
+-- - 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
@@ -104,29 +146,16 @@ data Config
= Config
{ heroPort :: Warp.Port,
heroBeam :: FilePath,
- heroKeep :: FilePath
+ heroKeep :: FilePath,
+ heroSkey :: FilePath
}
deriving (Generic, Show)
instance Envy.DefConfig Config where
- defConfig = Config 3000 "_bild/Hero.Client/static" "_keep"
+ defConfig = Config 3000 "_bild/Hero.Client/static" "_keep" "/run/hero/skey"
instance Envy.FromEnv Config
-mkApp :: AcidState Keep.HeroKeep -> Config -> Application
-mkApp keep cfg =
- serve
- (Proxy @AllRoutes)
- ( static
- :<|> cssHandlers
- :<|> jsonHandlers keep
- :<|> serverHandlers
- :<|> pure heroManifest
- :<|> Tagged handle404
- )
- where
- static = serveDirectoryWith $ defaultWebAppSettings $ heroBeam cfg
-
-- | Convert client side routes into server-side web handlers
type ServerRoutes = ToServerRoutes ClientRoutes Templated Action
@@ -138,10 +167,39 @@ cssHandlers :: Server CssRoute
cssHandlers =
return . Lazy.toStrict . Clay.render $ Typography.main <> Look.main
-type AllRoutes =
+type Ping = "ping" :> Get '[JSON] Text
+
+type LoginHook =
+ "login-hook"
+ :> ReqBody '[JSON] LoginForm
+ :> Post '[JSON]
+ ( Headers
+ '[ Header "Set-Cookie" Auth.SetCookie,
+ Header "Set-Cookie" Auth.SetCookie
+ ]
+ User
+ )
+
+loginHookHandler ::
+ Auth.CookieSettings ->
+ Auth.JWTSettings ->
+ LoginForm ->
+ Handler
+ ( Headers
+ '[ Header "Set-Cookie" Auth.SetCookie,
+ Header "Set-Cookie" Auth.SetCookie
+ ]
+ User
+ )
+loginHookHandler cs jwts =
+ checkCreds cs jwts
+
+type AllRoutes auths =
("static" :> Raw)
:<|> CssRoute
- :<|> JsonApi
+ :<|> Ping
+ :<|> LoginHook
+ :<|> (Auth.Auth auths User :> JsonApi)
:<|> ServerRoutes
:<|> ("manifest.json" :> Get '[JSON] Manifest)
:<|> Raw
@@ -231,7 +289,6 @@ instance L.ToHtml a => L.ToHtml (Templated a) where
(L.link_ mempty)
[L.rel_ "stylesheet", L.type_ "text/css", L.href_ href]
-
handle404 :: Application
handle404 _ respond =
respond
@@ -249,12 +306,15 @@ 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"
-serverHandlers :: Server ServerRoutes
-serverHandlers =
+publicHandlers :: Server ServerRoutes
+publicHandlers =
homeHandler
:<|> comicCoverHandler
:<|> comicPageHandler
@@ -264,8 +324,35 @@ serverHandlers =
:<|> discoverHandler
:<|> chooseExperienceHandler
-jsonHandlers :: AcidState Keep.HeroKeep -> Server JsonApi
-jsonHandlers keep = Acid.query' keep $ Keep.GetComics 10
+
+instance Auth.ToJWT User
+
+instance Auth.FromJWT User
+
+checkCreds ::
+ Auth.CookieSettings ->
+ Auth.JWTSettings ->
+ LoginForm ->
+ Handler
+ ( Headers
+ '[ Header "Set-Cookie" Auth.SetCookie,
+ Header "Set-Cookie" Auth.SetCookie
+ ]
+ User
+ )
+checkCreds cookieSettings jwtSettings (LoginForm "ben@bsima.me" "test") = do
+ -- TODO: get this from keep
+ liftIO $ say "successful login"
+ let usr = User "ben@bsima.me" "ben" [] -- TODO: load initial library
+ mApplyCookies <- liftIO $ Auth.acceptLogin cookieSettings jwtSettings usr
+ case mApplyCookies of
+ Nothing -> throwError err401
+ Just applyCookies -> return $ applyCookies usr
+checkCreds _ _ _ = throwError err401
+
+jsonHandlers :: AcidState Keep.HeroKeep -> Auth.AuthResult User -> Server JsonApi
+jsonHandlers keep (Auth.Authenticated user) = Acid.query' keep $ Keep.GetComics 10
+jsonHandlers _ _ = Auth.throwAll err401
homeHandler :: Handler (Templated (View Action))
homeHandler = pure . Templated . home $ initModel homeLink
@@ -295,4 +382,3 @@ chooseExperienceHandler id n =
loginHandler :: Handler (Templated (View Action))
loginHandler = pure . Templated . login $ initModel loginLink
-
diff --git a/Hero/Service.nix b/Hero/Service.nix
index a3c6bd5..e5d811b 100644
--- a/Hero/Service.nix
+++ b/Hero/Service.nix
@@ -31,6 +31,11 @@ in
type = lib.types.package;
description = "herocomics-client package to use";
};
+ skey = lib.mkOption {
+ type = lib.types.path;
+ default = "/run/hero/skey";
+ description = "where to store the signing key";
+ };
domain = lib.mkOption {
type = lib.types.str;
default = "herocomics.app";
diff --git a/Miso/Extend.hs b/Miso/Extend.hs
new file mode 100644
index 0000000..e7a9ff6
--- /dev/null
+++ b/Miso/Extend.hs
@@ -0,0 +1,26 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Miso.Extend
+ ( crossorigin_,
+ onPreventClick,
+ )
+where
+
+import Miso
+import Miso.String
+
+-- | HTML crossorigin attribute
+--
+-- https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin
+crossorigin_ :: MisoString -> Attribute action
+crossorigin_ = textProp "crossorigin"
+
+-- | Like 'onClick' but prevents the default action from triggering. Use this to
+-- overide 'a_' links, for example.
+onPreventClick :: action -> Attribute action
+onPreventClick action =
+ onWithOptions
+ Miso.defaultOptions {preventDefault = True}
+ "click"
+ emptyDecoder
+ (\() -> action)
diff --git a/bild b/bild
index fdd3dd7..7ef5009 100755
--- a/bild
+++ b/bild
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
-set -ex
+set -exo pipefail
prefix=$(echo $PWD | sed -e "s|^$BIZ_ROOT/*||g" -e "s|/|.|g")
if [[ "$prefix" == "" ]]
then
diff --git a/nix/haskell-deps.nix b/nix/haskell-deps.nix
index 44c2050..26981dc 100644
--- a/nix/haskell-deps.nix
+++ b/nix/haskell-deps.nix
@@ -33,6 +33,8 @@
"safecopy"
"scotty"
"servant"
+ "servant-auth"
+ "servant-auth-server"
"servant-lucid"
"servant-server"
"split"
@@ -50,4 +52,5 @@
"wai-extra"
"wai-middleware-metrics"
"warp"
+ "x509"
]
diff --git a/nix/haskell-overlay.nix b/nix/haskell-overlay.nix
index 1afea87..f37d602 100644
--- a/nix/haskell-overlay.nix
+++ b/nix/haskell-overlay.nix
@@ -1,20 +1,27 @@
_: pkgs:
let
- cabalBuilder = self: name: self.callCabal2nix name pkgs.sources.${name} {};
+ simpleCabalBuilder = self: name:
+ self.callCabal2nix name pkgs.sources.${name} {};
+ buildCabal = self: name: subdir:
+ if isNull subdir then
+ self.callCabal2nix name pkgs.sources.${name} {}
+ else
+ self.callCabal2nix name (pkgs.sources.${name} + "/${subdir}") {};
in
{
haskell = pkgs.haskell // {
packages = pkgs.haskell.packages // {
ghc865 = pkgs.haskell.packages.ghc865.override (old: {
overrides = with pkgs.pkgs.haskell.lib; self: super:
- pkgs.overridePinnedDeps (cabalBuilder self) // {
+ pkgs.overridePinnedDeps (simpleCabalBuilder self) // {
+ servant-auth = buildCabal self "servant-auth" "servant-auth";
wai-middleware-metrics = dontCheck super.wai-middleware-metrics;
};
});
ghcjs = pkgs.haskell.packages.ghcjs.override (old: {
overrides = with pkgs.haskell.lib; self: super:
- pkgs.overridePinnedDeps (cabalBuilder self) // {
+ pkgs.overridePinnedDeps (simpleCabalBuilder self) // {
QuickCheck = dontCheck super.QuickCheck;
base-compat-batteries = dontCheck super.http-types;
clay = dontCheck super.clay;
diff --git a/nix/sources.json b/nix/sources.json
index 195a218..fdbb4b6 100644
--- a/nix/sources.json
+++ b/nix/sources.json
@@ -62,6 +62,18 @@
"url": "https://github.com/NixOS/nixpkgs/archive/b0c285807d6a9f1b7562ec417c24fa1a30ecc31a.tar.gz",
"url_template": "https://github.com/<owner>/<repo>/archive/<rev>.tar.gz"
},
+ "servant-auth": {
+ "branch": "master",
+ "description": null,
+ "homepage": null,
+ "owner": "haskell-servant",
+ "repo": "servant-auth",
+ "rev": "696fab268e21f3d757b231f0987201b539c52621",
+ "sha256": "1had0xyh511q7ggw2mlfhhk7pfbc30gqm2c9gj1y7pbflmsjgjda",
+ "type": "tarball",
+ "url": "https://github.com/haskell-servant/servant-auth/archive/696fab268e21f3d757b231f0987201b539c52621.tar.gz",
+ "url_template": "https://github.com/<owner>/<repo>/archive/<rev>.tar.gz"
+ },
"wemux": {
"branch": "master",
"description": "Multi-User Tmux Made Easy",