Initial commit
This commit is contained in:
commit
cbf619c370
|
@ -0,0 +1,30 @@
|
|||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
{-# LANGUAGE TemplateHaskell #-}
|
||||
{-# LANGUAGE TypeFamilies #-}
|
||||
{-# LANGUAGE MultiParamTypeClasses #-}
|
||||
{-# LANGUAGE RankNTypes #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE DeriveGeneric #-}
|
||||
{-# LANGUAGE ViewPatterns #-}
|
||||
|
||||
{-# LANGUAGE NoImplicitPrelude #-}
|
||||
|
||||
import Relude hiding (get)
|
||||
|
||||
import Yesod
|
||||
import Yesod.Auth
|
||||
import Client.Types
|
||||
import Client.Handlers
|
||||
import Client.Auth ()
|
||||
import Yesod.Static (static, Static)
|
||||
import System.Directory (createDirectoryIfMissing)
|
||||
|
||||
mkYesodDispatch "DataIdClient" resourcesDataIdClient
|
||||
|
||||
main :: IO ()
|
||||
main = getStaticDir "/tmp/data-id" >>= warp 3000 . DataIdClient
|
||||
|
||||
getStaticDir :: FilePath -> IO Static
|
||||
getStaticDir dir = createDirectoryIfMissing True dir >> static dir
|
|
@ -0,0 +1,56 @@
|
|||
{-# LANGUAGE ConstraintKinds #-}
|
||||
{-# LANGUAGE DeriveGeneric #-}
|
||||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
{-# LANGUAGE TemplateHaskell #-}
|
||||
{-# LANGUAGE TypeFamilies #-}
|
||||
|
||||
{-# LANGUAGE NoImplicitPrelude #-}
|
||||
|
||||
module Client.ApiRequests where
|
||||
|
||||
import Relude
|
||||
|
||||
import Data.Morpheus.Client
|
||||
import Yesod hiding (Header)
|
||||
import Yesod.Auth
|
||||
|
||||
type ClientTypeConstraint (a :: Type) = (RequestType a, ToJSON (RequestArgs a), FromJSON a)
|
||||
-- From Data.Morpheus.Client.Fetch.RequestType
|
||||
type Header = (Text, Text)
|
||||
-- From Data.Morpheus.Client.Fetch.GQLClient
|
||||
-- No clue why these are not exported
|
||||
|
||||
type RequestConstraint a site = (ClientTypeConstraint a, ApiRequest site)
|
||||
|
||||
type Authorization = Text
|
||||
|
||||
class YesodAuth site => ApiRequest site where
|
||||
getApiUrl :: HandlerFor site Text
|
||||
authIdToAuthorization :: site -> AuthId site -> Authorization
|
||||
|
||||
apiRequest' :: RequestConstraint a site => [Header] -> Bool -> Args a -> HandlerFor site a
|
||||
apiRequest' extraHeaders authRequired args = do
|
||||
yesod <- liftHandler getYesod
|
||||
auth <- fmap (authIdToAuthorization yesod) <$> if authRequired then Just <$> requireAuthId else maybeAuthId
|
||||
apiRequestAuth extraHeaders (("Bearer " <>) <$> auth) args >>= handleErrors
|
||||
|
||||
apiRequestAuth :: RequestConstraint a site =>
|
||||
[Header] -> Maybe Authorization -> Args a -> HandlerFor site (GQLClientResult a)
|
||||
apiRequestAuth extraHeaders auth args = do
|
||||
apiUrl <- getApiUrl
|
||||
stream <- liftIO $ request (fromString (toString apiUrl) `withHeaders` headers) args
|
||||
single stream
|
||||
where headers = maybe [] (\x -> [("Authorization", x)]) auth <> extraHeaders
|
||||
|
||||
apiRequest :: RequestConstraint a site => Bool -> Args a -> HandlerFor site a
|
||||
apiRequest authRequired = apiRequest' [] authRequired
|
||||
|
||||
handleErrors :: RequestConstraint a site => GQLClientResult a -> HandlerFor site a
|
||||
handleErrors (Right a) = pure a
|
||||
handleErrors (Left (FetchErrorParseFailure s)) = error $ "GQL parse failure: " <> toText s
|
||||
handleErrors (Left (FetchErrorProducedErrors errs Nothing)) = error $ "GQL errors: " <> show errs
|
||||
handleErrors (Left (FetchErrorProducedErrors _ (Just a))) = pure a -- TODO log the errors?
|
||||
handleErrors (Left (FetchErrorNoResult)) = error $ "GQL no results"
|
|
@ -0,0 +1,65 @@
|
|||
{-# LANGUAGE TemplateHaskell #-}
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE TypeFamilies #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
{-# LANGUAGE DeriveGeneric #-}
|
||||
{-# LANGUAGE RankNTypes #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
|
||||
{-# LANGUAGE NoImplicitPrelude #-}
|
||||
|
||||
module Client.Auth where
|
||||
|
||||
import Relude
|
||||
|
||||
import Server.Types
|
||||
import Data.Morpheus.Client
|
||||
import Yesod
|
||||
import Yesod.Auth
|
||||
import qualified Data.ByteString.Base64 as B64 (encode)
|
||||
import qualified Yesod.Auth.Message as Msg
|
||||
|
||||
pluginName = "externalBasic"
|
||||
|
||||
loginR = PluginR pluginName ["login"]
|
||||
|
||||
type Authorization = Text
|
||||
type CredsIdent = Text
|
||||
|
||||
postLoginR :: YesodAuth master =>
|
||||
(Authorization -> HandlerFor master (Maybe CredsIdent)) -> AuthHandler master TypedContent
|
||||
postLoginR authReq = do
|
||||
res <- runInputPostResult $ (\x y -> x <> ":" <> y)
|
||||
<$> ireq textField "email" <*> ireq textField "password"
|
||||
case res of
|
||||
FormSuccess auth -> do
|
||||
maybeAuth <- liftHandler $ authReq $ ("Basic " <> ) $ decodeUtf8 $ B64.encode $ encodeUtf8 auth
|
||||
case maybeAuth of
|
||||
Nothing -> loginErrorMessageI LoginR Msg.NoIdentifierProvided -- invalid creds
|
||||
Just txt -> do
|
||||
setCredsRedirect Creds {credsPlugin = pluginName, credsIdent = txt, credsExtra = []}
|
||||
_ -> loginErrorMessageI LoginR Msg.NoIdentifierProvided
|
||||
|
||||
authExternalBasic :: forall site. YesodAuth site =>
|
||||
(Authorization -> HandlerFor site (Maybe CredsIdent)) -> AuthPlugin site
|
||||
authExternalBasic authReq = AuthPlugin pluginName dispatch loginForm
|
||||
where dispatch :: YesodAuth site => Text -> [Text] -> AuthHandler site TypedContent
|
||||
dispatch "POST" ["login"] = postLoginR authReq >>= sendResponse
|
||||
dispatch _ _ = notFound
|
||||
|
||||
loginForm :: YesodAuth site => (Route Auth -> Route site) -> WidgetFor site ()
|
||||
loginForm toParent = do
|
||||
emailId <- newIdent
|
||||
passwordId <- newIdent
|
||||
[whamlet|
|
||||
<h1>Kirjaudu
|
||||
<form action=@{toParent loginR} method=post>
|
||||
<label for="#{emailId}">
|
||||
Sähköposti
|
||||
<input id="#{emailId}" name="email" type="text" autofocus>
|
||||
<label for="#{passwordId}">
|
||||
Salasana
|
||||
<input id="#{passwordId}" name="password" type="password">
|
||||
<input type="submit" value="Kirjaudu">
|
||||
|]
|
|
@ -0,0 +1,46 @@
|
|||
{-# LANGUAGE QuasiQuotes #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
{-# LANGUAGE NoImplicitPrelude #-}
|
||||
|
||||
module Client.FormFields where
|
||||
|
||||
import Relude
|
||||
|
||||
import Yesod
|
||||
import Client.Types
|
||||
import Server.Types
|
||||
|
||||
emailField :: Field Handler Email
|
||||
emailField = Field
|
||||
{ fieldParse = \rawValues _ ->
|
||||
case rawValues of
|
||||
[] -> pure $ Right Nothing
|
||||
[x] -> pure $ maybe (Left "could not parse as an email address") (Right . Just) $ toEmail x
|
||||
_ -> pure $ Left $ "Expected one value"
|
||||
, fieldView = \id name otherAttributes result isRequired ->
|
||||
let result' = either (\x -> x) renderEmail result
|
||||
in [whamlet|
|
||||
<input type="email" id="#{id}" name="#{name}" value="#{result'}" *{otherAttributes} :isRequired:required="">
|
||||
|]
|
||||
, fieldEnctype = UrlEncoded
|
||||
}
|
||||
|
||||
verifiedPasswordField :: Field Handler Text
|
||||
verifiedPasswordField = Field
|
||||
{ fieldParse = \rawValues _ ->
|
||||
case rawValues of
|
||||
[] -> pure $ Right Nothing
|
||||
[x,y]
|
||||
| x == y -> pure $ Right $ Just x
|
||||
| otherwise -> pure $ Left "Salasanat eivät täsmää"
|
||||
_ -> pure $ Left "Expected two values"
|
||||
, fieldView = \id name otherAttributes _ isRequired ->
|
||||
[whamlet|
|
||||
<input type="password" id="#{id}" name="#{name}" :isRequired:required="true" *{otherAttributes}>
|
||||
<label for="#{id}-confirm">
|
||||
Toista salasana
|
||||
<input type="password" id="#{id}-confirm" name="#{name}" :isRequired:required="true" *{otherAttributes}>
|
||||
|]
|
||||
, fieldEnctype = UrlEncoded
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE TypeFamilies #-}
|
||||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
{-# LANGUAGE DisambiguateRecordFields #-}
|
||||
{-# LANGUAGE RecordWildCards #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
|
||||
{-# LANGUAGE NoImplicitPrelude #-}
|
||||
|
||||
module Client.Handlers
|
||||
( module Client.Handlers.Profile
|
||||
, module Client.Handlers.Apply
|
||||
, module Client.Handlers.Applications
|
||||
, module Client.Handlers.VerifyEmail
|
||||
, getHomeR
|
||||
, postApiR
|
||||
) where
|
||||
|
||||
import Relude
|
||||
|
||||
import Client.Handlers.Profile
|
||||
import Client.Handlers.Apply
|
||||
import Client.Handlers.Applications
|
||||
import Client.Handlers.VerifyEmail
|
||||
import Client.Types
|
||||
import Yesod
|
||||
import Yesod.Auth
|
||||
import Server (serverApp)
|
||||
|
||||
getHomeR :: Handler Html
|
||||
getHomeR = ifM (isJust <$> maybeAuthId) (redirect OwnProfileR) (redirect $ AuthR LoginR)
|
||||
|
||||
postApiR :: Handler ()
|
||||
postApiR = sendWaiApplication =<< liftIO serverApp
|
|
@ -0,0 +1,117 @@
|
|||
{-# LANGUAGE DeriveGeneric #-}
|
||||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
{-# LANGUAGE TemplateHaskell #-}
|
||||
{-# LANGUAGE TypeFamilies #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
{-# LANGUAGE RecordWildCards #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
|
||||
{-# LANGUAGE NoImplicitPrelude #-}
|
||||
|
||||
module Client.Handlers.Applications where
|
||||
|
||||
import Relude hiding (id)
|
||||
|
||||
import Client.ApiRequests
|
||||
import Client.Types
|
||||
import Data.Morpheus.Client
|
||||
import Server.Types hiding (Applications)
|
||||
import Yesod hiding (emailField)
|
||||
import Yesod.Auth
|
||||
import Client.FormFields
|
||||
import Data.Time (Day)
|
||||
import Data.Maybe (fromJust)
|
||||
|
||||
declareLocalTypesInline "schema.gql" [raw|
|
||||
query Applications {
|
||||
applications {
|
||||
id
|
||||
name
|
||||
nickname
|
||||
email
|
||||
homeplace
|
||||
birthdate
|
||||
application
|
||||
}
|
||||
}
|
||||
|]
|
||||
|
||||
declareLocalTypesInline "schema.gql" [raw|
|
||||
mutation Reject($user: UserID!) {
|
||||
reject(user: $user)
|
||||
}
|
||||
|]
|
||||
|
||||
declareLocalTypesInline "schema.gql" [raw|
|
||||
mutation Accept($user: UserID!) {
|
||||
accept(user: $user)
|
||||
}
|
||||
|]
|
||||
|
||||
resolveApplicationForm :: Maybe UserID -> Form UserID
|
||||
resolveApplicationForm user = renderDivs $ areq hiddenField "" user
|
||||
|
||||
applicationsW :: [ApplicationsApplications] -> Widget
|
||||
applicationsW applications = do
|
||||
setTitle "Jäsenhakemukset"
|
||||
[whamlet|<h1>Käsiteltävät jäsenhakemukset|]
|
||||
when (null applications) $ [whamlet|<p>Ei jäsenhakemuksia.|]
|
||||
rejectRoute <- handlerToWidget $ maybeAuthorized RejectR True
|
||||
acceptRoute <- handlerToWidget $ maybeAuthorized AcceptR True
|
||||
forM_ applications $ \ApplicationsApplications {..} -> do
|
||||
(widget, enctype) <- handlerToWidget $ generateFormPost $ resolveApplicationForm $ Just id
|
||||
[whamlet|
|
||||
<details>
|
||||
<summary>
|
||||
<h2>#{name}
|
||||
<article>
|
||||
<h3>Hakemus
|
||||
<p>#{Textarea application}
|
||||
<table>
|
||||
<tr>
|
||||
<th scope="row">Nimi
|
||||
<td>#{name}
|
||||
<tr>
|
||||
<th scope="row">Kutsumanimi
|
||||
<td>#{nickname}
|
||||
<tr>
|
||||
<th scope="row">Kotipaikka
|
||||
<td>#{homeplace}
|
||||
<tr>
|
||||
<th scope="row">Syntymäaika
|
||||
<td>#{renderDate $ birthdate}
|
||||
<tr>
|
||||
<th scope="row">Sähköposti
|
||||
<td>#{renderEmail $ fromJust $ email}
|
||||
$maybe route <- acceptRoute
|
||||
<form action="@{route}" method="post" enctype="#{enctype}">
|
||||
^{widget}
|
||||
<input type="submit" value="Hyväksy">
|
||||
$maybe route <- rejectRoute
|
||||
<form action="@{route}" method="post" enctype="#{enctype}">
|
||||
^{widget}
|
||||
<input type="submit" value="Hylkää" class="reject-button">
|
||||
|]
|
||||
|
||||
getApplicationsR :: (YesodAuth DataIdClient, AuthId DataIdClient ~ Text) => Handler Html
|
||||
getApplicationsR = do
|
||||
Applications applications <- apiRequest @Applications True ()
|
||||
defaultLayout $ applicationsW applications
|
||||
|
||||
postAcceptR :: (YesodAuth DataIdClient, AuthId DataIdClient ~ Text) => Handler Html
|
||||
postAcceptR = do
|
||||
((result, _), _) <- runFormPost $ resolveApplicationForm Nothing
|
||||
case result of
|
||||
FormSuccess user -> void $ apiRequest @Accept True $ AcceptArgs user
|
||||
_ -> pure ()
|
||||
redirect ApplicationsR
|
||||
|
||||
postRejectR :: (YesodAuth DataIdClient, AuthId DataIdClient ~ Text) => Handler Html
|
||||
postRejectR = do
|
||||
((result, _), _) <- runFormPost $ resolveApplicationForm Nothing
|
||||
case result of
|
||||
FormSuccess user -> void $ apiRequest @Reject True $ RejectArgs user
|
||||
_ -> pure ()
|
||||
redirect ApplicationsR
|
|
@ -0,0 +1,89 @@
|
|||
{-# LANGUAGE DeriveGeneric #-}
|
||||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
{-# LANGUAGE TemplateHaskell #-}
|
||||
{-# LANGUAGE TypeFamilies #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
{-# LANGUAGE RecordWildCards #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
|
||||
{-# LANGUAGE NoImplicitPrelude #-}
|
||||
|
||||
module Client.Handlers.Apply where
|
||||
|
||||
import Relude hiding (id)
|
||||
|
||||
import Client.ApiRequests
|
||||
import Client.Types
|
||||
import Data.Morpheus.Client
|
||||
import Server.Types
|
||||
import Yesod hiding (emailField)
|
||||
import Yesod.Auth
|
||||
import Client.FormFields
|
||||
import Data.Time (Day)
|
||||
|
||||
declareLocalTypesInline "schema.gql" [raw|
|
||||
mutation Apply($name: String!, $nickname: String, $homeplace: String!,
|
||||
$birthdate: Date!, $email: Email!, $password: String!, $application: String!) {
|
||||
apply(email: $email, password: $password, name: $name, nickname: $nickname,
|
||||
birthdate: $birthdate, homeplace: $homeplace, application: $application) {
|
||||
id
|
||||
name
|
||||
nickname
|
||||
email
|
||||
pendingEmail
|
||||
homeplace
|
||||
birthdate
|
||||
}
|
||||
}
|
||||
|]
|
||||
|
||||
applyForm :: Html -> MForm Handler (FormResult ApplyArgs, Widget)
|
||||
applyForm = renderDivs $ ApplyArgs
|
||||
<$> areq textField nameSettings Nothing
|
||||
<*> aopt textField nicknameSettings Nothing
|
||||
<*> areq textField homeplaceSettings Nothing
|
||||
<*> areq dayField' birthdateSettings Nothing
|
||||
<*> areq emailField emailSettings Nothing
|
||||
<*> areq verifiedPasswordField "Salasana" Nothing
|
||||
<*> areq textareaField' applicationSettings Nothing
|
||||
where dayField' :: Field Handler Date
|
||||
dayField' = checkMMap (pure . Right . Date :: Day -> Handler (Either Text Date))
|
||||
(\(Date x) -> x) dayField
|
||||
textareaField' = checkMMap (pure . Right . unTextarea :: Textarea -> Handler (Either Text Text))
|
||||
Textarea textareaField
|
||||
nameSettings = "Nimi" {fsAttrs = [("placeholder","Erkki Juhani Esimerkki")]}
|
||||
nicknameSettings = "Kutsumanimi" {fsAttrs = [("placeholder","Juhani")]}
|
||||
homeplaceSettings = "Kotipaikka" {fsAttrs = [("placeholder","Espoo")]}
|
||||
birthdateSettings = "Syntymäaika" {fsAttrs = [("placeholder","2000-01-01")]}
|
||||
emailSettings = "Sähköposti" {fsAttrs = [("placeholder","erkki.juhani@esimerkki.fi")]}
|
||||
applicationSettings = "Hakemus (eli miksi olet data)"
|
||||
{fsAttrs = [("placeholder","Aloitin opiskelun Otaniemen datalla vuonna 2020.")]}
|
||||
|
||||
applyW :: (Widget, Enctype) -> Widget
|
||||
applyW (applyWidget, applyEnctype) = do
|
||||
setTitle "Jäsenhakemus"
|
||||
[whamlet|
|
||||
<h1>
|
||||
Jäsenhakemus
|
||||
<form action="@{ApplyR}" method="post" enctype="#{applyEnctype}">
|
||||
^{applyWidget}
|
||||
<p>
|
||||
Lähettämällä jäsenhakemuksen vakuutat antamiesi tietojen oikeellisuuden ja puuttettomuuden
|
||||
sekä sitoudut pitämään ne ajan tasalla.
|
||||
<input type="submit" value="Hae jäseneksi">
|
||||
|]
|
||||
|
||||
getApplyR :: (YesodAuth DataIdClient, AuthId DataIdClient ~ Text) => Handler Html
|
||||
getApplyR = do
|
||||
applyForm <- liftHandler $ generateFormPost applyForm
|
||||
defaultLayout $ applyW applyForm
|
||||
|
||||
postApplyR :: (YesodAuth DataIdClient, AuthId DataIdClient ~ Text) => Handler Html
|
||||
postApplyR = do
|
||||
((result, widget), enctype) <- runFormPost applyForm
|
||||
case result of
|
||||
FormSuccess application -> apiRequest @Apply False application >>= \x -> redirect $ ProfileR $ id (apply x :: ApplyApply)
|
||||
_ -> do
|
||||
defaultLayout $ applyW (widget, enctype)
|
|
@ -0,0 +1,134 @@
|
|||
{-# LANGUAGE DeriveGeneric #-}
|
||||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
{-# LANGUAGE TemplateHaskell #-}
|
||||
{-# LANGUAGE TypeFamilies #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
{-# LANGUAGE RecordWildCards #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
|
||||
{-# LANGUAGE NoImplicitPrelude #-}
|
||||
|
||||
module Client.Handlers.Profile where
|
||||
|
||||
import Relude hiding (id)
|
||||
|
||||
import Client.ApiRequests
|
||||
import Client.Types
|
||||
import Client.FormFields
|
||||
import Data.Morpheus.Client
|
||||
import Server.Types
|
||||
import Yesod hiding (emailField)
|
||||
import Yesod.Auth
|
||||
|
||||
declareLocalTypesInline "schema.gql" [raw|
|
||||
query ProfilePage($id: UserID) {
|
||||
user(id: $id) {
|
||||
id
|
||||
name
|
||||
nickname
|
||||
email
|
||||
pendingEmail
|
||||
homeplace
|
||||
birthdate
|
||||
}
|
||||
permissions
|
||||
}
|
||||
|]
|
||||
|
||||
declareLocalTypesInline "schema.gql" [raw|
|
||||
mutation UpdateProfile($user: UserID, $name: String, $homeplace: String, $nickname: String, $email: Email) {
|
||||
update(user: $user, name: $name, homeplace: $homeplace, nickname: $nickname, email: $email) {
|
||||
id
|
||||
}
|
||||
}
|
||||
|]
|
||||
|
||||
declareLocalTypesInline "schema.gql" [raw|
|
||||
mutation UpdatePassword($user: UserID, $password: String!) {
|
||||
update(user: $user, password: $password) {
|
||||
id
|
||||
}
|
||||
}
|
||||
|]
|
||||
|
||||
passwordForm :: Form Text
|
||||
passwordForm = renderDivs $ areq verifiedPasswordField "Uusi salasana" Nothing
|
||||
|
||||
profileForm :: (Maybe UserID) -> (Maybe ProfilePageUser) -> Form UpdateProfileArgs
|
||||
profileForm userID user extraHtml = do
|
||||
(nameRes, nameView) <- mopt textField "Nimi" (Just $ maybe Nothing (\x -> Just $ name (x ::ProfilePageUser)) user)
|
||||
(homeRes, homeView) <- mopt textField "Kotipaikka" (Just $ maybe Nothing (\x -> Just $ homeplace (x :: ProfilePageUser)) user)
|
||||
(nicknameRes, nicknameView) <- mopt textField "Kutsumanimi" (Just $ maybe Nothing (\x -> Just $ nickname (x :: ProfilePageUser)) user)
|
||||
(emailRes, emailView) <- mopt emailField "Sähköposti" (maybe Nothing (\x -> Just $ email (x :: ProfilePageUser)) user)
|
||||
let profileUpdateRes = UpdateProfileArgs userID <$> nameRes <*> homeRes <*> nicknameRes <*> emailRes
|
||||
maybePendingEmail = user >>= \x -> pendingEmail (x :: ProfilePageUser)
|
||||
inputField FieldView {..} = [whamlet|
|
||||
<label for="#{fvId}">
|
||||
^{fvLabel}
|
||||
^{fvInput}
|
||||
|]
|
||||
widget = [whamlet|
|
||||
#{extraHtml}
|
||||
^{inputField nameView}
|
||||
^{inputField homeView}
|
||||
^{inputField nicknameView}
|
||||
^{inputField emailView}
|
||||
$maybe pendingEmail <- maybePendingEmail
|
||||
<p>Päivitys osoitteeseen #
|
||||
<a href="mailto:#{renderEmail pendingEmail}">#{renderEmail pendingEmail}
|
||||
odottaa vahvistusta. #
|
||||
<a href="@{VerifyEmailR}">Siirry vahvistamaan
|
||||
|]
|
||||
return (profileUpdateRes, widget)
|
||||
|
||||
profile :: UserID -> (Widget, Enctype) -> (Widget, Enctype) -> Widget
|
||||
profile user (profileWidget, profileEnctype) (passwordWidget, passwordEnctype) = do
|
||||
setTitle "Muokkaa profiilia"
|
||||
passwordRoute <- handlerToWidget $ maybeAuthorized (UpdatePasswordR user) True
|
||||
[whamlet|
|
||||
<h1>
|
||||
Omat tiedot
|
||||
<form action="@{ProfileR user}" method="post" enctype="#{profileEnctype}">
|
||||
^{profileWidget}
|
||||
<input type="submit" value="Päivitä tiedot">
|
||||
$maybe route <- passwordRoute
|
||||
<form action="@{route}" method="post" enctype="#{passwordEnctype}">
|
||||
^{passwordWidget}
|
||||
<input type="submit" value="Vaihda salasana">
|
||||
|]
|
||||
|
||||
getProfile :: (YesodAuth DataIdClient, AuthId DataIdClient ~ Text) => (Maybe UserID) -> Handler Html
|
||||
getProfile userID = do
|
||||
ProfilePage {..} <- apiRequest True (ProfilePageArgs {id = userID})
|
||||
passwordForm <- liftHandler $ generateFormPost passwordForm
|
||||
profileForm <- liftHandler $ generateFormPost $
|
||||
profileForm ((\x -> id (x :: ProfilePageUser)) <$> user) user
|
||||
defaultLayout $ profile ((\x -> id (x :: ProfilePageUser)) $ fromJust user) profileForm passwordForm
|
||||
where fromJust = fromMaybe $ error "Tried to access the profile of an inexistent user"
|
||||
|
||||
getOwnProfileR :: (YesodAuth DataIdClient, AuthId DataIdClient ~ Text) => Handler Html
|
||||
getOwnProfileR = getProfile Nothing
|
||||
|
||||
getProfileR :: (YesodAuth DataIdClient, AuthId DataIdClient ~ Text) => UserID -> Handler Html
|
||||
getProfileR = getProfile . Just
|
||||
|
||||
postProfileR :: (YesodAuth DataIdClient, AuthId DataIdClient ~ Text) => UserID -> Handler Html
|
||||
postProfileR userID = do
|
||||
((result, widget), enctype) <- runFormPost $ profileForm (Just userID) Nothing
|
||||
case result of
|
||||
FormSuccess update -> apiRequest @UpdateProfile True update >> redirect (ProfileR userID)
|
||||
_ -> do
|
||||
passwordForm <- liftHandler $ generateFormPost passwordForm
|
||||
defaultLayout $ profile userID (widget, enctype) passwordForm
|
||||
|
||||
postUpdatePasswordR :: (YesodAuth DataIdClient, AuthId DataIdClient ~ Text) => UserID -> Handler Html
|
||||
postUpdatePasswordR userID = do
|
||||
((result, widget), enctype) <- runFormPost passwordForm
|
||||
case result of
|
||||
FormSuccess new ->
|
||||
apiRequest @UpdatePassword True (UpdatePasswordArgs {password = new, user = Just userID}) >> redirect (ProfileR userID)
|
||||
_ -> do
|
||||
profileForm <- liftHandler $ generateFormPost $ profileForm (Just userID) Nothing
|
||||
defaultLayout $ profile userID profileForm (widget, enctype)
|
|
@ -0,0 +1,50 @@
|
|||
{-# LANGUAGE DeriveGeneric #-}
|
||||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
{-# LANGUAGE TemplateHaskell #-}
|
||||
{-# LANGUAGE TypeFamilies #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
|
||||
{-# LANGUAGE NoImplicitPrelude #-}
|
||||
|
||||
module Client.Handlers.VerifyEmail where
|
||||
|
||||
import Relude
|
||||
|
||||
import Client.ApiRequests
|
||||
import Client.Types
|
||||
import Server.Types
|
||||
import Data.Morpheus.Client
|
||||
import Yesod
|
||||
import Yesod.Auth
|
||||
|
||||
declareLocalTypesInline "schema.gql" [raw|
|
||||
mutation VerifyEmail($secret: String!) {
|
||||
verifyEmail(secret: $secret)
|
||||
}
|
||||
|]
|
||||
|
||||
getVerifyEmailR = do
|
||||
codeForm <- generateFormPost verifyForm
|
||||
defaultLayout $ verifyEmailW codeForm
|
||||
|
||||
postVerifyEmailR :: (YesodAuth DataIdClient, AuthId DataIdClient ~ Text) => Handler Html
|
||||
postVerifyEmailR = do
|
||||
((result, widget), enctype) <- runFormPost verifyForm
|
||||
case result of
|
||||
FormSuccess verify -> apiRequest @VerifyEmail False verify >> setMessage "Sähköpostiosoite vahvistettu" >> redirect OwnProfileR
|
||||
_ -> defaultLayout $ verifyEmailW (widget, enctype)
|
||||
|
||||
verifyEmailW (codeWidget, codeEnctype) = do
|
||||
setTitle "Vahvista sähköpostiosoite"
|
||||
[whamlet|
|
||||
<h1>
|
||||
Vahvista sähköpostiosoite
|
||||
<form action="@{VerifyEmailR}" method="post" enctype="#{codeEnctype}">
|
||||
^{codeWidget}
|
||||
<input type="submit" value="Vahvista">
|
||||
|]
|
||||
|
||||
verifyForm = renderDivs $ VerifyEmailArgs <$> areq textField "Vahvistuskoodi" Nothing
|
|
@ -0,0 +1,330 @@
|
|||
{-# LANGUAGE DeriveAnyClass #-}
|
||||
{-# LANGUAGE DeriveGeneric #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE MultiParamTypeClasses #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
{-# LANGUAGE RankNTypes #-}
|
||||
{-# LANGUAGE RecordWildCards #-}
|
||||
{-# LANGUAGE TemplateHaskell #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
{-# LANGUAGE TypeFamilies #-}
|
||||
{-# LANGUAGE ViewPatterns #-}
|
||||
|
||||
{-# LANGUAGE NoImplicitPrelude #-}
|
||||
|
||||
module Client.Types where
|
||||
|
||||
import Relude hiding (id)
|
||||
import Relude.Extra.Foldable1 (maximum1)
|
||||
|
||||
import Yesod
|
||||
import Yesod.Core.Handler (getCurrentRoute)
|
||||
import Yesod.Default.Util (addStaticContentExternal)
|
||||
import Yesod.Auth
|
||||
import Yesod.Static
|
||||
import Server.Types (UserID(..), Scope(..), Permission(..))
|
||||
import Data.Time (getCurrentTime)
|
||||
import Data.Time.Format.ISO8601 (iso8601Show)
|
||||
import Client.Auth
|
||||
import Client.ApiRequests
|
||||
import Data.Morpheus.Client
|
||||
import Server (readPermission)
|
||||
import Data.Map (findWithDefault)
|
||||
|
||||
data DataIdClient = DataIdClient { getStatic :: Static }
|
||||
|
||||
mkYesodData "DataIdClient" [parseRoutes|
|
||||
/ HomeR GET
|
||||
|
||||
/profile OwnProfileR GET
|
||||
/profile/#UserID ProfileR GET POST
|
||||
/update-password/#UserID UpdatePasswordR POST
|
||||
/verify-email VerifyEmailR GET POST
|
||||
|
||||
/applications ApplicationsR GET
|
||||
/accept AcceptR POST
|
||||
/reject RejectR POST
|
||||
|
||||
/apply ApplyR GET POST
|
||||
|
||||
/api ApiR POST
|
||||
/auth AuthR Auth getAuth
|
||||
/static StaticR Static getStatic
|
||||
|]
|
||||
|
||||
declareLocalTypesInline "schema.gql" [raw|
|
||||
query GetPermissions {
|
||||
permissions
|
||||
user {
|
||||
id
|
||||
}
|
||||
}
|
||||
|]
|
||||
|
||||
withAuthenticated :: (AuthId DataIdClient -> Handler AuthResult) -> Handler AuthResult
|
||||
withAuthenticated m = maybeAuthId >>= maybe (pure AuthenticationRequired) m
|
||||
|
||||
hasPermission :: Scope -> Permission -> Handler Bool
|
||||
hasPermission scope permission = do
|
||||
GetPermissions {..} <- apiRequest @GetPermissions False ()
|
||||
let permissionMap = fromMaybe mempty $ readPermission permissions
|
||||
findPermission scope = findWithDefault None scope permissionMap
|
||||
userID = (\x -> id (x :: GetPermissionsUser)) <$> user
|
||||
scopes = scope :| case scope of
|
||||
Tokens uid | Just uid == userID -> [OwnTokens]
|
||||
Profile uid | Just uid == userID -> [OwnProfile]
|
||||
_ -> []
|
||||
-- TODO add Members and Applications to Profile Scopes if profile owner is member/applicant
|
||||
pure $ (>= permission) $ maximum1 $ findPermission <$> scopes
|
||||
|
||||
requirePermission :: Scope -> Permission -> Handler AuthResult
|
||||
requirePermission scope permission = ifM (hasPermission scope permission)
|
||||
(pure Authorized)
|
||||
(pure $ Unauthorized $ "Toiminto vaatii " <> renderPermission permission <> " " <> renderScope scope)
|
||||
where renderPermission ReadWrite = "kirjoitusoikeuden"
|
||||
renderPermission ReadOnly = "lukuoikeuden"
|
||||
renderPermission None = "nollaoikeuden"
|
||||
renderScope OwnProfile = "omaan profiliin"
|
||||
renderScope (Profile user) = "käyttäjän '" <> show user <> "' profiliin"
|
||||
renderScope OwnTokens = "omiin tokeneihin" -- TODO kunnon suomennos
|
||||
renderScope (Tokens user) = "käyttäjän '" <> show user <> "' tokeneihin" -- TODO kunnon suomennos
|
||||
renderScope Members = "kaikkiin jäseniin"
|
||||
renderScope Applications = "jäsenhakemuksiin"
|
||||
|
||||
authorizedHelper :: Scope -> Bool -> Handler AuthResult
|
||||
authorizedHelper scope True = requirePermission scope ReadWrite
|
||||
authorizedHelper scope False = requirePermission scope ReadOnly
|
||||
|
||||
instance Yesod DataIdClient where
|
||||
authRoute = const $ Just $ AuthR LoginR
|
||||
approot = ApprootStatic "http://localhost:3000"
|
||||
makeSessionBackend = const $ sslOnlySessions $ laxSameSiteSessions $
|
||||
Just <$> defaultClientSessionBackend (60*24*7) "client_session_key.aes"
|
||||
isAuthorized (AuthR _) _ = pure Authorized
|
||||
isAuthorized (StaticR _) _ = pure Authorized
|
||||
isAuthorized HomeR _ = pure Authorized
|
||||
isAuthorized ApiR _ = pure Authorized
|
||||
isAuthorized ApplyR _ = pure Authorized
|
||||
isAuthorized VerifyEmailR _ = pure Authorized
|
||||
isAuthorized OwnProfileR isWrite = withAuthenticated $ const $ authorizedHelper OwnProfile isWrite
|
||||
isAuthorized (ProfileR user) isWrite = withAuthenticated $ const $ authorizedHelper (Profile user) isWrite
|
||||
isAuthorized (UpdatePasswordR user) isWrite =
|
||||
withAuthenticated $ const $ authorizedHelper (Profile user) isWrite
|
||||
isAuthorized ApplicationsR isWrite = withAuthenticated $ const $ authorizedHelper Applications isWrite
|
||||
isAuthorized AcceptR isWrite = withAuthenticated $ const $ authorizedHelper Applications isWrite
|
||||
isAuthorized RejectR isWrite = withAuthenticated $ const $ authorizedHelper Applications isWrite
|
||||
errorHandler NotFound = fmap toTypedContent $ defaultLayout $ defaultMessageWidget "Sivua ei löytynyt" $
|
||||
[hamlet|<p>Pyytämästäsi osoitteesta ei löytynyt sivua|]
|
||||
errorHandler (InternalError e) = do
|
||||
$logErrorS "yesod-core" e
|
||||
selectRep $ do
|
||||
provideRep $ defaultLayout $ do
|
||||
currentTime <- liftIO getCurrentTime
|
||||
defaultMessageWidget "Palvelinvirhe" [hamlet|
|
||||
<p>
|
||||
Pyynnön käsittelyssä tapahtui virhe. #
|
||||
Ole yhteydessä ylläpitäjiin ja kerro heille palvelimen kellonaika virheen tapahtuessa #
|
||||
#{iso8601Show currentTime}
|
||||
|]
|
||||
errorHandler x = defaultErrorHandler x
|
||||
addStaticContent = addStaticContentExternal Right base64md5 "/tmp/data-id" (StaticR . flip StaticRoute [])
|
||||
defaultLayout widget = do
|
||||
msgs <- getMessages
|
||||
applicationsRoute <- maybeAuthorized ApplicationsR False
|
||||
profileRoute <- maybeAuthorized OwnProfileR False
|
||||
currentRoute <- getCurrentRoute
|
||||
loggedIn <- isJust <$> maybeAuthId
|
||||
p <- widgetToPageContent $ do
|
||||
[whamlet|
|
||||
<header>
|
||||
<nav>
|
||||
<ul>
|
||||
$maybe route <- profileRoute
|
||||
<li :Just route == currentRoute:class="active-nav">
|
||||
<a href="@{route}">Profiili
|
||||
$maybe route <- applicationsRoute
|
||||
<li :Just route == currentRoute:class="active-nav">
|
||||
<a href="@{route}">Hakemukset
|
||||
$if loggedIn
|
||||
<li class="left-nav">
|
||||
<a href="@{AuthR LogoutR}">Kirjaudu ulos
|
||||
$else
|
||||
<li :Just (AuthR LoginR) == currentRoute:class="active-nav" class="left-nav">
|
||||
<a href="@{AuthR LoginR}">Kirjaudu sisään
|
||||
<li :Just ApplyR == currentRoute:class="active-nav" class="left-nav">
|
||||
<a href="@{ApplyR}">Hae jäseneksi
|
||||
<main>
|
||||
$if not $ null msgs
|
||||
<aside class="messages">
|
||||
<ul>
|
||||
$forall (_, msg) <- msgs
|
||||
<li class="message">#{msg}
|
||||
^{widget}
|
||||
|] :: Widget
|
||||
toWidget $ [lucius|
|
||||
:root {
|
||||
--bg-colour: #ffffff;
|
||||
--fg-colour: #181c22;
|
||||
}
|
||||
html {
|
||||
font-family: "Fira Sans", sans-serif;
|
||||
height: 100%;
|
||||
}
|
||||
th {
|
||||
text-align: right;
|
||||
}
|
||||
body {
|
||||
background-color: #e8eaef;
|
||||
color: var(--fg-colour);
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
padding-top: 0.3em;
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.4em;
|
||||
margin-top: 0.3em;
|
||||
margin-bottom: 0.3em;
|
||||
border-radius: 0.25em;
|
||||
}
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.4em;
|
||||
margin-top: 0.3em;
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
form {
|
||||
max-width: 20em;
|
||||
}
|
||||
summary > h2 {
|
||||
display: inline-block;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
details > table th {
|
||||
text-align: left;
|
||||
padding-left: 0;
|
||||
}
|
||||
details {
|
||||
padding: 1.3em;
|
||||
margin: 0.6em;
|
||||
border-color: #00838a;
|
||||
border-width: 0 0 0 0.6em;
|
||||
border-style: solid;
|
||||
border-radius: 0.4em;
|
||||
background-color: #f6f8f8;
|
||||
}
|
||||
details[open] {
|
||||
border-color: #339ca1;
|
||||
}
|
||||
details > article {
|
||||
margin-bottom: 1.5em
|
||||
}
|
||||
input[type="submit"] {
|
||||
background-color: #00838a;
|
||||
color: #ffffff;
|
||||
padding: 0.5em 1em;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
input[type="submit"]:hover {
|
||||
background-color: #339ca1;
|
||||
}
|
||||
input[type="submit"].reject-button {
|
||||
background-color: #8a003a;
|
||||
}
|
||||
input[type="submit"].reject-button:hover {
|
||||
background-color: #aa3968;
|
||||
}
|
||||
nav {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
background-color: var(--fg-colour);
|
||||
}
|
||||
nav ul {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
nav li {
|
||||
display: block;
|
||||
float: left;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
nav li a {
|
||||
color: var(--bg-colour);
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
padding: 0.7em 1em;
|
||||
}
|
||||
nav li a:hover {
|
||||
background-color: #00838a;
|
||||
color: #ffffff;
|
||||
}
|
||||
.active-nav {
|
||||
font-weight: 500;
|
||||
}
|
||||
.left-nav {
|
||||
float: right;
|
||||
}
|
||||
main {
|
||||
margin: 0 auto;
|
||||
max-width: 50em;
|
||||
padding: 5em 1em 3em 1em;
|
||||
background-color: var(--bg-colour);
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|]
|
||||
withUrlRenderer [hamlet|
|
||||
$doctype 5
|
||||
<html>
|
||||
<head>
|
||||
<title>#{pageTitle p}
|
||||
^{pageHead p}
|
||||
<body>
|
||||
^{pageBody p}
|
||||
|]
|
||||
|
||||
declareLocalTypesInline "schema.gql" [raw|
|
||||
mutation GetWebUIToken {
|
||||
newToken(comment: "id.datat.fi webui") {
|
||||
tokenData
|
||||
}
|
||||
}
|
||||
|]
|
||||
|
||||
instance YesodAuth DataIdClient where
|
||||
type AuthId DataIdClient = Text
|
||||
maybeAuthId = lookupSession credsKey
|
||||
loginDest = const HomeR
|
||||
logoutDest = const HomeR
|
||||
authPlugins = const $
|
||||
[ authExternalBasic $
|
||||
fmap (fmap (tokenData . newToken) . rightToMaybe) .
|
||||
flip (apiRequestAuth @GetWebUIToken []) () . Just
|
||||
]
|
||||
authenticate = pure . Authenticated . credsIdent
|
||||
|
||||
instance ApiRequest DataIdClient where
|
||||
getApiUrl = ($ ApiR) <$> getUrlRender
|
||||
authIdToAuthorization = flip const
|
||||
|
||||
instance RenderMessage DataIdClient FormMessage where
|
||||
renderMessage _ _ = defaultFormMessage
|
||||
|
||||
instance PathPiece UserID where
|
||||
toPathPiece (UserID id) = show id
|
||||
fromPathPiece s = UserID <$> readMaybe (toString s)
|
||||
|
||||
type Form a = Html -> MForm Handler (FormResult a, Widget)
|
|
@ -0,0 +1,6 @@
|
|||
import Data.Morpheus.Types (render, App)
|
||||
import Server.API (coreApp)
|
||||
import qualified Data.ByteString.Lazy.Char8 as C8
|
||||
import Server (APIM)
|
||||
|
||||
main = C8.putStrLn $ render (coreApp :: App () APIM)
|
|
@ -0,0 +1,26 @@
|
|||
# Reverse proxy configuration
|
||||
|
||||
A CSP should be defined to block all scripts as the app doesn't need any client
|
||||
side scripting. Content-Type-Options prevent misidentifying files as
|
||||
stylesheets or scripts.
|
||||
|
||||
```
|
||||
Content-Security-Policy: default-src 'self'; scrip-src 'none'; object-src 'none'
|
||||
X-Content-Type-Options: nosniff
|
||||
```
|
||||
|
||||
The reverse proxy must also enforce https, which can be done by redirecting all
|
||||
http requests to https and setting the Strict-Transport-Security header.
|
||||
|
||||
# DOS-attack mitigation
|
||||
|
||||
The app doesn't do anything against DOS-attacks. You should as a bare minimum
|
||||
rate limit the app's endpoint.
|
||||
|
||||
```
|
||||
$ iptables -A INPUT -p tcp -m tcp --syn -m hashlimit \
|
||||
--hashlimit-upto 10/sec --hashlimit-burst 10 --hashlimit-mode srcip,dstport \
|
||||
--hashlimit-name conn-srcip-dport-rate-limit -j ACCEPT
|
||||
```
|
||||
|
||||
This, for example, would rate limit tcp connections.
|
|
@ -0,0 +1,157 @@
|
|||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
|
||||
{-# LANGUAGE PackageImports #-}
|
||||
{-# LANGUAGE RecordWildCards #-}
|
||||
|
||||
{-# LANGUAGE NoImplicitPrelude #-}
|
||||
|
||||
module Server where
|
||||
|
||||
import Relude
|
||||
|
||||
import "cryptonite" Crypto.Random (MonadRandom(..))
|
||||
import Control.Monad.Logger (runStderrLoggingT)
|
||||
import Data.Map (findWithDefault)
|
||||
import Data.Text (toLower, breakOn, stripPrefix)
|
||||
import Database.Persist (entityVal)
|
||||
import Database.Persist.Postgresql (withPostgresqlConn, runSqlConn)
|
||||
import Network.Mail.Mime (renderSendMail, Address(..))
|
||||
import Network.Wai (Application)
|
||||
import Network.Wai.Handler.Warp (run)
|
||||
import Network.Wai.Middleware.Cors
|
||||
import Network.Wai.Middleware.Gzip
|
||||
import Server.API
|
||||
import Server.DB
|
||||
import Server.DB.Queries (getUserByEmail, getPermissions, getToken)
|
||||
import Server.Types
|
||||
import Server.Utils (checkPassword)
|
||||
import Web.Scotty.Trans hiding (readEither)
|
||||
import qualified Data.ByteString.Base64 as B64 (decode)
|
||||
|
||||
main :: IO ()
|
||||
main = run 3100 =<< serverApp
|
||||
|
||||
serverApp :: IO Application
|
||||
serverApp = scottyAppT runAPIM $ do
|
||||
middleware $ gzip def
|
||||
middleware $ cors $ const $ Just CorsResourcePolicy
|
||||
{ corsOrigins = Nothing -- all
|
||||
, corsMethods = ["POST"]
|
||||
, corsRequestHeaders = ["Authorization"]
|
||||
, corsExposedHeaders = Nothing
|
||||
, corsMaxAge = Just (60*60*24)
|
||||
, corsVaryOrigin = False
|
||||
, corsRequireOrigin = False
|
||||
, corsIgnoreFailures = False
|
||||
}
|
||||
post "/api" $ do
|
||||
maybeAuthorization <- fmap toText <$> header "Authorization"
|
||||
let maybeBasic = parseBasic maybeAuthorization
|
||||
maybeBearer = parseBearer maybeAuthorization
|
||||
auth = case maybeBasic of
|
||||
Just _ -> authBasic maybeBasic
|
||||
Nothing -> authBearer maybeBearer
|
||||
auth $ do
|
||||
setHeader "Content-Type" "text/json"
|
||||
body >>= lift . runApp coreApp >>= raw
|
||||
|
||||
data BasicAuth = BasicAuth { emailAddress :: Email, password :: Text } deriving (Show)
|
||||
|
||||
data BearerToken = BearerToken Text deriving (Show)
|
||||
|
||||
parseBearer :: Maybe Text -> Maybe BearerToken
|
||||
parseBearer auth = do
|
||||
[authType, authData] <- words <$> auth
|
||||
guard $ toLower authType == "bearer"
|
||||
pure $ BearerToken authData
|
||||
|
||||
authBearer :: Maybe BearerToken -> ActionT LText APIM a -> ActionT LText APIM a
|
||||
authBearer Nothing m = m
|
||||
authBearer (Just (BearerToken bearer)) m = do
|
||||
token <- lift $ getToken bearer
|
||||
let permissions = fromMaybe mempty $ token >>= readPermission . dBTokenPermissions . entityVal
|
||||
flip local m $ \state -> state
|
||||
{ stateCurrentUser = fromDBKey . dBTokenUid . entityVal <$> token
|
||||
, statePermissions = permissions
|
||||
}
|
||||
|
||||
parseBasic :: Maybe Text -> Maybe BasicAuth
|
||||
parseBasic txt = do
|
||||
[authType, authData] <- words <$> txt
|
||||
guard $ toLower authType == "basic"
|
||||
(email, password) <- rightToMaybe $
|
||||
breakOn' ":" . decodeUtf8 <$> B64.decode (encodeUtf8 authData)
|
||||
emailAddress <- toEmail email
|
||||
pure $ BasicAuth {..}
|
||||
where breakOn' x xs = let (fst, snd) = breakOn x xs
|
||||
in (fst, fromMaybe "" $ stripPrefix x snd)
|
||||
|
||||
|
||||
authBasic :: Maybe BasicAuth -> ActionT LText APIM a -> ActionT LText APIM a
|
||||
authBasic Nothing m = m
|
||||
authBasic (Just basic) m = do
|
||||
user <- verifyBasic basic
|
||||
permissions <- maybe (pure mempty)
|
||||
(fmap (fromMaybe mempty . (>>= readPermission)) . lift . getPermissions) user
|
||||
flip local m $ \state -> state
|
||||
{ stateCurrentUser = user
|
||||
, statePermissions = permissions
|
||||
}
|
||||
|
||||
-- TODO Refact, no need to convert to id and rerequest permissions
|
||||
verifyBasic :: BasicAuth -> ActionT LText APIM (Maybe UserID)
|
||||
verifyBasic BasicAuth {..} = do
|
||||
user <- lift $ getUserByEmail emailAddress
|
||||
if maybe False (checkPassword password . dBUserPasswordCrypt . entityVal) user
|
||||
then pure $ entityToID <$> user
|
||||
else pure Nothing
|
||||
|
||||
newtype APIM a = APIM (ReaderT RequestState IO a)
|
||||
deriving (Functor, Applicative, Monad, MonadIO, MonadReader RequestState)
|
||||
|
||||
data RequestState = RequestState
|
||||
{ stateCurrentUser :: Maybe UserID
|
||||
, statePermissions :: Map Scope Permission
|
||||
}
|
||||
|
||||
|
||||
instance MonadTime APIM where
|
||||
currentTime = liftIO currentTime
|
||||
|
||||
instance MonadDB APIM where
|
||||
runQuery = liftIO . runStderrLoggingT . withPostgresqlConn "postgres:///id.rekisteri" . runSqlConn
|
||||
|
||||
instance MonadEmail APIM where
|
||||
sendEmail = liftIO . renderSendMail
|
||||
fromAddress = pure $ Address Nothing "id@datat.fi"
|
||||
|
||||
instance MonadRequest APIM where
|
||||
currentUser = asks stateCurrentUser
|
||||
|
||||
instance MonadRandom APIM where
|
||||
getRandomBytes = liftIO . getRandomBytes
|
||||
|
||||
instance MonadPermissions APIM where
|
||||
currentPermissions = show <$> asks statePermissions
|
||||
defaultPermissions = pure $ show $ (fromList [(OwnProfile, ReadWrite)] :: Map Scope Permission)
|
||||
toPermissions = pure . fmap show . readPermission
|
||||
hasPermission scope permission = (>= permission) <$> findPermission scope
|
||||
where findPermission :: Scope -> APIM Permission
|
||||
findPermission scope@(Profile user) = selfPermissions scope user OwnProfile
|
||||
findPermission scope@(Tokens user) = selfPermissions scope user OwnTokens
|
||||
findPermission scope = findPermission' scope <$> asks statePermissions
|
||||
findPermission' :: Scope -> Map Scope Permission -> Permission
|
||||
findPermission' = findWithDefault None
|
||||
selfPermissions :: Scope -> UserID -> Scope -> APIM Permission
|
||||
selfPermissions scope user own = do
|
||||
isSelf <- (Just user ==) <$> currentUser
|
||||
let f = if isSelf then max <$> findPermission' own <*> findPermission' scope
|
||||
else findPermission' scope
|
||||
|
||||
f <$> asks statePermissions
|
||||
|
||||
readPermission :: Text -> Maybe (Map Scope Permission)
|
||||
readPermission = rightToMaybe . readEither . toString
|
||||
|
||||
runAPIM :: APIM a -> IO a
|
||||
runAPIM (APIM m) = runReaderT m RequestState { stateCurrentUser = Nothing, statePermissions = fromList [] }
|
|
@ -0,0 +1,333 @@
|
|||
{-# LANGUAGE DataKinds #-}
|
||||
{-# LANGUAGE DeriveAnyClass #-}
|
||||
{-# LANGUAGE DeriveGeneric #-}
|
||||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE FlexibleInstances #-}
|
||||
{-# LANGUAGE MultiParamTypeClasses #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE PackageImports #-}
|
||||
{-# LANGUAGE RecordWildCards #-}
|
||||
{-# LANGUAGE TupleSections #-}
|
||||
{-# LANGUAGE TypeFamilies #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
|
||||
{-# LANGUAGE NoImplicitPrelude #-}
|
||||
|
||||
module Server.API (coreApp, runApp, resolver) where
|
||||
|
||||
import Relude hiding (Undefined, void, when)
|
||||
|
||||
import "cryptonite" Crypto.Random (getRandomBytes, MonadRandom)
|
||||
import Control.Monad.Except (MonadError, throwError)
|
||||
import Data.Aeson (fromJSON, Result(..), toJSON)
|
||||
import Data.Morpheus.Server (deriveApp, runApp)
|
||||
import Data.Morpheus.Server.Types (defaultRootResolver, RootResolver(..), Undefined)
|
||||
import Data.Morpheus.Types (Arg(..), GQLType, GQLError, App)
|
||||
import Data.Time (nominalDay)
|
||||
import Database.Persist (Entity, entityVal, (=.))
|
||||
import Server.DB
|
||||
import Server.DB.Queries
|
||||
import Server.Email (sendVerificationEmail)
|
||||
import Server.Types
|
||||
import Server.Utils
|
||||
import qualified Data.ByteString.Base64 as B64 (encode)
|
||||
import qualified Data.Text as T (null, chunksOf, intercalate)
|
||||
|
||||
-- General functions, maybe migrate to Utils or API.Utils
|
||||
|
||||
targetUser :: (MonadError GQLError m, MonadRequest m) => Maybe UserID -> m UserID
|
||||
targetUser = maybe (fromMaybeFail "No target user specified!" =<< currentUser) pure
|
||||
|
||||
fromMaybeFail :: MonadError GQLError m => GQLError -> Maybe a -> m a
|
||||
fromMaybeFail txt = maybe (throwError txt) pure
|
||||
|
||||
void :: Monad m => m a -> m Unit
|
||||
void m = m >> pure Unit
|
||||
|
||||
when :: Monad m => Bool -> m a -> m Unit
|
||||
when b m = if b then void m else pure Unit
|
||||
|
||||
dbUserToUser :: Monad m => Entity DBUser -> User m
|
||||
dbUserToUser user = let id = entityToID user
|
||||
DBUser {..} = entityVal user
|
||||
Success (MemberData {..}) = fromJSON dBUserMemberData
|
||||
-- Explodes if database doesn't contain needed data
|
||||
in User
|
||||
{ id = pure id
|
||||
, email = pure dBUserEmail
|
||||
, pendingEmail = pure dBUserPendingEmail
|
||||
, name = pure name
|
||||
, nickname = pure $ fromMaybe (error "db contains empty name") $
|
||||
maybe (viaNonEmpty head $ words $ name) Just nickname
|
||||
, birthdate = pure birthdate
|
||||
, homeplace = pure homeplace
|
||||
, registered = pure dBUserRegistered
|
||||
, accepted = pure dBUserAccepted
|
||||
, seceded = pure dBUserSeceded
|
||||
, isMember = pure $ isJust dBUserAccepted && not (isJust dBUserSeceded)
|
||||
, permissions = pure dBUserPermissions
|
||||
, application = pure application
|
||||
}
|
||||
|
||||
dbKeyToPGPKey :: (MonadDB m, MonadError GQLError m) => Entity DBKey -> PGPKey m
|
||||
dbKeyToPGPKey key = let id = entityToID key
|
||||
DBKey {..} = entityVal key
|
||||
in PGPKey
|
||||
{ id = pure id
|
||||
, user = getByID (fromDBKey dBKeyUid :: UserID)
|
||||
>>= fmap dbUserToUser . fromMaybeFail ""
|
||||
, pgpKeyData = pure $ base64Encode dBKeyData
|
||||
, expires = pure dBKeyExpires
|
||||
, uploaded = pure dBKeyUploaded
|
||||
, comment = pure dBKeyComment
|
||||
}
|
||||
|
||||
dbTokenToToken :: (MonadDB m, MonadError GQLError m) => Entity DBToken -> Token m
|
||||
dbTokenToToken token = let id = entityToID token
|
||||
DBToken {..} = entityVal token
|
||||
in Token
|
||||
{ id = pure id
|
||||
, user = getByID (fromDBKey dBTokenUid :: UserID)
|
||||
>>= fmap dbUserToUser . fromMaybeFail ""
|
||||
, name = pure dBTokenName
|
||||
, tokenData = pure dBTokenData
|
||||
, comment = pure dBTokenComment
|
||||
, issued = pure dBTokenIssued
|
||||
, expires = pure dBTokenExpires
|
||||
, permissions = pure dBTokenPermissions
|
||||
}
|
||||
|
||||
newUser :: (MonadEmail m, MonadDB m, MonadRandom m, MonadTime m, MonadError GQLError m, MonadPermissions m) =>
|
||||
ApplicationData -> m UserID
|
||||
newUser (ApplicationData {..}) = do
|
||||
time <- currentTime
|
||||
verificationExpires <- verificationExpireTime
|
||||
secret <- genVerificationSecret
|
||||
passwordHash <- hashPassword password
|
||||
permissions <- defaultPermissions
|
||||
when (T.null name) $ throwError "Name must not be empty"
|
||||
when (T.null password) $ throwError "Password must not be empty"
|
||||
when (T.null homeplace) $ throwError "Homeplace must not be empty"
|
||||
let memberData = MemberData { nickname = nickname >>= \x -> if T.null x then Nothing else Just x, ..}
|
||||
user <- addUser $ DBUser
|
||||
{ dBUserEmail = Nothing
|
||||
, dBUserPendingEmail = Just email
|
||||
, dBUserRegistered = time
|
||||
, dBUserToBeDeleted = Just $ verificationExpires
|
||||
, dBUserPasswordCrypt = passwordHash
|
||||
, dBUserPermissions = permissions
|
||||
, dBUserAccepted = Nothing
|
||||
, dBUserSeceded = Nothing
|
||||
, dBUserRejected = Nothing
|
||||
, dBUserMemberData = toJSON memberData
|
||||
, dBUserEmailVerificationSecret = Just secret
|
||||
}
|
||||
sendVerificationSecret user
|
||||
return user
|
||||
|
||||
verificationExpireTime :: MonadTime m => m Time
|
||||
verificationExpireTime = addTime (7 * nominalDay) <$> currentTime
|
||||
|
||||
genVerificationSecret :: MonadRandom m => m Text
|
||||
genVerificationSecret = T.intercalate "-" . T.chunksOf 4 . base32 <$> getRandomBytes 10
|
||||
|
||||
sendVerificationSecret :: (MonadEmail m, MonadDB m, MonadError GQLError m) => UserID -> m Unit
|
||||
sendVerificationSecret user = void $ do
|
||||
maybeDBUser <- fmap entityVal <$> getByID user
|
||||
let email = dBUserPendingEmail =<< maybeDBUser
|
||||
secret = dBUserEmailVerificationSecret =<< maybeDBUser
|
||||
args = (,) <$> email <*> secret
|
||||
maybe (pure ()) (uncurry sendVerificationEmail) args
|
||||
|
||||
updateUser :: (MonadRandom m, MonadDB m, MonadEmail m, MonadError GQLError m) =>
|
||||
UserID -> UpdateData -> m UserID
|
||||
updateUser user (UpdateData {..}) = do
|
||||
hash <- sequence $ hashPassword <$> password
|
||||
-- assert stuff valid
|
||||
verificationSecretUpdate <- maybe (pure Nothing)
|
||||
(const $ Just . (DBUserEmailVerificationSecret =. ) . Just <$> genVerificationSecret) email
|
||||
user <- updateUserData user
|
||||
(catMaybes [(DBUserPendingEmail =. ) . Just <$> email, verificationSecretUpdate,
|
||||
(DBUserPasswordCrypt =.) <$> hash])
|
||||
(catMaybes [SetUserName <$> name, SetUserNickname . Just <$> nickname, SetUserHomeplace <$> homeplace])
|
||||
when (isJust email) $ sendVerificationSecret user
|
||||
return user
|
||||
|
||||
makeNewToken :: (MonadError GQLError m, MonadDB m, MonadTime m, MonadRandom m, MonadPermissions m) =>
|
||||
NewTokenArgs -> UserID -> m TokenID
|
||||
makeNewToken (NewTokenArgs {..}) user = do
|
||||
tokenData <- decodeUtf8 . B64.encode <$> getRandomBytes 128
|
||||
time <- currentTime
|
||||
permissions <- maybe currentPermissions pure =<< maybe (pure Nothing) toPermissions permissions
|
||||
addToken $ DBToken
|
||||
{ dBTokenUid = toDBKey user
|
||||
, dBTokenName = name
|
||||
, dBTokenData = tokenData
|
||||
, dBTokenComment = fromMaybe "" comment
|
||||
, dBTokenIssued = time
|
||||
, dBTokenExpires = Nothing
|
||||
, dBTokenPermissions = permissions
|
||||
}
|
||||
|
||||
makeNewKey :: (MonadRequest m, MonadDB m, MonadTime m, MonadError GQLError m) =>
|
||||
KeyData -> UserID -> m KeyID
|
||||
makeNewKey (KeyData {..}) user = do
|
||||
time <- currentTime
|
||||
keyData' <- fromMaybeFail "" $ base64Decode keyData
|
||||
addKey $ DBKey
|
||||
{ dBKeyUid = toDBKey user
|
||||
, dBKeyData = keyData'
|
||||
, dBKeyExpires = expires
|
||||
, dBKeyUploaded = time
|
||||
, dBKeyComment = fromMaybe "" comment
|
||||
, dBKeyIsPrimaryEncryptionKey = True
|
||||
}
|
||||
|
||||
acceptApplication :: (MonadDB m, MonadTime m) => UserID -> m Unit
|
||||
acceptApplication user = void $ do
|
||||
time <- currentTime
|
||||
markAsAccepted user time
|
||||
|
||||
rejectApplication :: (MonadDB m, MonadTime m) => UserID -> m Unit
|
||||
rejectApplication user = void $ do
|
||||
time <- currentTime
|
||||
markAsRejected user time
|
||||
|
||||
resolveQuery :: (MonadRequest m, MonadDB m, MonadError GQLError m, MonadPermissions m) => Query m
|
||||
resolveQuery = Query
|
||||
{ users = requirePermission Members ReadOnly >> map (dbUserToUser) <$> getAllUsers
|
||||
, user = \(Arg id) -> targetUser id >>= \user -> requirePermission (Profile user) ReadOnly >>
|
||||
fmap dbUserToUser <$> getByID user
|
||||
, tokens = \(Arg id) -> targetUser id >>= \user ->
|
||||
requirePermission (Tokens user) ReadOnly >> map dbTokenToToken <$> getUserTokens user
|
||||
, applications = requirePermission Applications ReadOnly >> map dbUserToUser <$> applicants
|
||||
, keys = \(Arg id) -> targetUser id >>= \user ->
|
||||
requirePermission (Profile user) ReadOnly >> map dbKeyToPGPKey <$> getKeys user
|
||||
--, key = \(Arg id) -> resolve (pure id)
|
||||
-- TODO is this actually useful
|
||||
, primaryKey = \(Arg id) -> targetUser id >>= \user ->
|
||||
requirePermission (Profile user) ReadOnly >> getPrimaryKey user >>= pure . fmap dbKeyToPGPKey
|
||||
, permissions = currentPermissions
|
||||
}
|
||||
|
||||
resolveMutation :: (MonadRequest m, MonadEmail m, MonadRandom m, MonadTime m,
|
||||
MonadDB m, MonadError GQLError m, MonadPermissions m) => Mutation m
|
||||
resolveMutation = Mutation
|
||||
{ apply = \x -> do
|
||||
userID <- newUser x
|
||||
maybeUser <- getByID userID
|
||||
user <- fromMaybeFail "" maybeUser
|
||||
pure $ dbUserToUser user
|
||||
, verifyEmail = \(Arg secret) -> void $ verifyEmailSecret secret >>= \x -> when (x < 1) $ throwError "Invalid verification secret"
|
||||
, resendVerificationEmail = \(Arg id) -> targetUser id >>= sendVerificationSecret
|
||||
, update = \updateData (Arg id) -> targetUser id >>= \user ->
|
||||
requirePermission (Profile user) ReadWrite >>
|
||||
updateUser user updateData >> getByID user >>= fmap dbUserToUser . fromMaybeFail ""
|
||||
, newToken = \args -> currentUser >>= fromMaybeFail "" >>= \user ->
|
||||
requirePermission (Profile user) ReadWrite >> makeNewToken args user >>=
|
||||
getByID >>= fmap dbTokenToToken . fromMaybeFail ""
|
||||
, newKey = \args -> currentUser >>= fromMaybeFail "" >>= \user ->
|
||||
requirePermission (Profile user) ReadWrite >> makeNewKey args user >>=
|
||||
getByID >>= fmap dbKeyToPGPKey . fromMaybeFail ""
|
||||
, accept = \(Arg id) -> requirePermission Applications ReadWrite >> acceptApplication id
|
||||
, reject = \(Arg id) -> requirePermission Applications ReadWrite >> rejectApplication id
|
||||
}
|
||||
|
||||
-- ScopedTypeVariables requires explicit forall m.
|
||||
coreApp :: forall m. (Typeable m, MonadRequest m, MonadEmail m, MonadRandom m,
|
||||
MonadTime m, MonadDB m, MonadPermissions m) => App () m
|
||||
coreApp = deriveApp resolver
|
||||
|
||||
resolver :: forall m. (Typeable m, MonadRequest m, MonadEmail m, MonadRandom m,
|
||||
MonadTime m, MonadDB m, MonadPermissions m) => RootResolver m () Query Mutation Undefined
|
||||
resolver = defaultRootResolver { queryResolver = resolveQuery, mutationResolver = resolveMutation }
|
||||
|
||||
data User m = User
|
||||
{ id :: m UserID
|
||||
, email :: m (Maybe Email)
|
||||
, pendingEmail :: m (Maybe Email)
|
||||
, name :: m Text
|
||||
, nickname :: m Text
|
||||
, birthdate :: m Date
|
||||
, homeplace :: m Text
|
||||
, registered :: m Time
|
||||
, accepted :: m (Maybe Time)
|
||||
, seceded :: m (Maybe Time)
|
||||
, permissions :: m Text
|
||||
, isMember :: m Bool
|
||||
, application :: m Text
|
||||
} deriving (Generic, GQLType)
|
||||
|
||||
data PGPKey m = PGPKey
|
||||
{ id :: m KeyID
|
||||
, user :: m (User m)
|
||||
, pgpKeyData :: m Base64
|
||||
, expires :: m (Maybe Time)
|
||||
, uploaded :: m Time
|
||||
, comment :: m Text
|
||||
} deriving (Generic, GQLType)
|
||||
|
||||
data Token m = Token
|
||||
{ id :: m TokenID
|
||||
, user :: m (User m)
|
||||
, name :: m (Maybe Text)
|
||||
, tokenData :: m Text
|
||||
, comment :: m Text
|
||||
, issued :: m Time
|
||||
, expires :: m (Maybe Time)
|
||||
, permissions :: m Text
|
||||
} deriving (Generic, GQLType)
|
||||
|
||||
data ApplicationData = ApplicationData
|
||||
{ email :: Email
|
||||
, password :: Text
|
||||
, name :: Text
|
||||
, nickname :: Maybe Text
|
||||
, birthdate :: Date
|
||||
, homeplace :: Text
|
||||
, application :: Text
|
||||
} deriving (Generic, GQLType, Eq, Show)
|
||||
|
||||
data UpdateData = UpdateData
|
||||
{ email :: Maybe Email
|
||||
, password :: Maybe Text
|
||||
, name :: Maybe Text
|
||||
, nickname :: Maybe Text
|
||||
, homeplace :: Maybe Text
|
||||
} deriving (Generic, GQLType, Eq, Show)
|
||||
|
||||
data KeyData = KeyData { comment :: Maybe Text, keyData :: Base64, expires :: Maybe Time }
|
||||
deriving (Generic, GQLType, Eq, Show)
|
||||
|
||||
newtype Cursor = Cursor Text
|
||||
deriving (Generic, GQLType, Eq, Show)
|
||||
|
||||
data Page a m = Page { pageData :: m a, cursor :: m (Maybe Cursor) }
|
||||
deriving (Generic, GQLType)
|
||||
|
||||
data NewTokenArgs = NewTokenArgs
|
||||
{ comment :: Maybe Text, name :: Maybe Text, permissions :: Maybe Text }
|
||||
deriving (Generic, GQLType)
|
||||
|
||||
data Query m = Query
|
||||
{ users :: m [User m]
|
||||
, user :: Arg "id" (Maybe UserID) -> m (Maybe (User m))
|
||||
, applications :: m [User m]
|
||||
, tokens :: Arg "user" (Maybe UserID) -> m [Token m]
|
||||
, keys :: Arg "user" (Maybe UserID) -> m [PGPKey m]
|
||||
, permissions :: m Text
|
||||
--, key :: Arg "id" KeyID -> m (PGPKey m)
|
||||
, primaryKey :: Arg "user" (Maybe UserID) -> m (Maybe (PGPKey m))
|
||||
} deriving (Generic, GQLType)
|
||||
|
||||
data Mutation m = Mutation
|
||||
{ apply :: ApplicationData -> m (User m)
|
||||
, verifyEmail :: Arg "secret" Text -> m Unit
|
||||
, resendVerificationEmail :: Arg "user" (Maybe UserID) -> m Unit
|
||||
, update :: UpdateData -> Arg "user" (Maybe UserID) -> m (User m)
|
||||
, newToken :: NewTokenArgs -> m (Token m)
|
||||
, newKey :: KeyData -> m (PGPKey m)
|
||||
, accept :: Arg "user" UserID -> m Unit
|
||||
, reject :: Arg "user" UserID -> m Unit
|
||||
} deriving (Generic, GQLType)
|
|
@ -0,0 +1,95 @@
|
|||
{-# LANGUAGE DataKinds #-}
|
||||
{-# LANGUAGE DerivingStrategies #-}
|
||||
{-# LANGUAGE FlexibleInstances #-}
|
||||
{-# LANGUAGE GADTs #-}
|
||||
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
|
||||
{-# LANGUAGE MultiParamTypeClasses #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
{-# LANGUAGE StandaloneDeriving #-}
|
||||
{-# LANGUAGE TemplateHaskell #-}
|
||||
{-# LANGUAGE TypeFamilies #-}
|
||||
{-# LANGUAGE UndecidableInstances #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
|
||||
module Server.DB where
|
||||
|
||||
import Data.ByteString (ByteString)
|
||||
import Data.Text (Text)
|
||||
import Database.Persist.TH (persistUpperCase, mkPersist, sqlSettings)
|
||||
import Database.Persist (Entity, Key, entityKey, PersistEntity)
|
||||
import Database.Persist.Sql (fromSqlKey, toSqlKey)
|
||||
import Database.Persist.Postgresql.JSON (Value)
|
||||
import Server.Types
|
||||
|
||||
mkPersist sqlSettings [persistUpperCase|
|
||||
DBUser sql=users
|
||||
email (Maybe Email) sqltype=varchar(255)
|
||||
pendingEmail (Maybe Email) sqltype=varchar(255)
|
||||
emailVerificationSecret (Maybe Text)
|
||||
registered Time
|
||||
passwordCrypt PasswordHash
|
||||
permissions Text
|
||||
accepted (Maybe Time)
|
||||
rejected (Maybe Time)
|
||||
seceded (Maybe Time)
|
||||
toBeDeleted (Maybe Time)
|
||||
memberData Value sqltype=jsonb
|
||||
|
||||
UniqueEmail email
|
||||
UniquePendingEmail pendingEmail
|
||||
UniqueVerification emailVerificationSecret
|
||||
|
||||
deriving (Show)
|
||||
|
||||
DBKey sql=keys
|
||||
uid DBUserId
|
||||
data ByteString
|
||||
expires (Maybe Time)
|
||||
uploaded Time
|
||||
comment Text
|
||||
isPrimaryEncryptionKey Bool
|
||||
|
||||
DBToken sql=tokens
|
||||
uid DBUserId
|
||||
name (Maybe Text)
|
||||
data Text
|
||||
comment Text
|
||||
issued Time
|
||||
expires (Maybe Time)
|
||||
permissions Text
|
||||
|
||||
UniqueNameUid name uid
|
||||
UniqueData data
|
||||
|]
|
||||
|
||||
entityToID :: FromDBKey a => Entity (DB a) -> a
|
||||
entityToID = fromDBKey . entityKey
|
||||
|
||||
class PersistEntity (DB a) => FromDBKey a where
|
||||
type DB a
|
||||
fromDBKey :: Key (DB a) -> a
|
||||
|
||||
instance FromDBKey UserID where
|
||||
type DB UserID = DBUser
|
||||
fromDBKey = UserID . fromSqlKey
|
||||
|
||||
instance FromDBKey TokenID where
|
||||
type DB TokenID = DBToken
|
||||
fromDBKey = TokenID . fromSqlKey
|
||||
|
||||
instance FromDBKey KeyID where
|
||||
type DB KeyID = DBKey
|
||||
fromDBKey = KeyID . fromSqlKey
|
||||
|
||||
class FromDBKey a => ToDBKey a where
|
||||
toDBKey :: a -> Key (DB a)
|
||||
|
||||
instance ToDBKey UserID where
|
||||
toDBKey (UserID x) = toSqlKey x
|
||||
|
||||
instance ToDBKey KeyID where
|
||||
toDBKey (KeyID x) = toSqlKey x
|
||||
|
||||
instance ToDBKey TokenID where
|
||||
toDBKey (TokenID x) = toSqlKey x
|
|
@ -0,0 +1,115 @@
|
|||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE RecordWildCards #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
{-# LANGUAGE TypeFamilies #-}
|
||||
|
||||
module Server.DB.Queries where
|
||||
|
||||
import Server.DB as DB
|
||||
import Server.Types
|
||||
import Data.Text (Text)
|
||||
import Database.Esqueleto.Experimental
|
||||
import qualified Database.Persist as Persist (update, (=.))
|
||||
import qualified Database.Persist.Types as Persist (Update)
|
||||
import Data.Maybe (listToMaybe, isJust)
|
||||
import Data.Aeson (fromJSON, toJSON, Result(..))
|
||||
import GHC.Int (Int64)
|
||||
|
||||
getByID :: (MonadDB m, ToDBKey k, PersistEntityBackend (DB k) ~ SqlBackend) => k -> m (Maybe (Entity (DB k)))
|
||||
getByID id = let key = toDBKey id in runQuery $ fmap (Entity key) <$> get key
|
||||
|
||||
getUserByEmail :: MonadDB m => Email -> m (Maybe (Entity DBUser))
|
||||
getUserByEmail email = runQuery $ getBy (UniqueEmail $ Just email) >>=
|
||||
maybe ((>>= guardUnconfirmed) <$> getBy (UniquePendingEmail $ Just email)) (pure . Just)
|
||||
where guardUnconfirmed user
|
||||
| isJust (dBUserEmail $ entityVal user) = Nothing
|
||||
| otherwise = Just user
|
||||
|
||||
addUser :: MonadDB m => DBUser -> m UserID
|
||||
addUser = fmap fromDBKey . runQuery . insert
|
||||
|
||||
getKeys :: MonadDB m => UserID -> m [Entity DBKey]
|
||||
getKeys user = runQuery $ select $ do
|
||||
keys <- from $ table @DBKey
|
||||
where_ $ keys ^. DBKeyUid ==. val (toDBKey user)
|
||||
pure $ keys
|
||||
|
||||
getPermissions :: MonadDB m => UserID -> m (Maybe Text)
|
||||
getPermissions user = fmap (fmap dBUserPermissions) $ runQuery $ get (toDBKey user)
|
||||
|
||||
setPermissions :: MonadDB m => UserID -> Text -> m ()
|
||||
setPermissions user txt = updateUserData user [DB.DBUserPermissions Persist.=. txt] [] >> return ()
|
||||
|
||||
getPrimaryKey :: MonadDB m => UserID -> m (Maybe (Entity DBKey))
|
||||
getPrimaryKey user = fmap listToMaybe $ runQuery $ select $ do
|
||||
keys <- from $ table @DBKey
|
||||
where_ $ keys ^. DBKeyIsPrimaryEncryptionKey &&. keys ^. DBKeyUid ==. val (toDBKey user)
|
||||
pure $ keys
|
||||
|
||||
getUserTokens :: MonadDB m => UserID -> m [Entity DBToken]
|
||||
getUserTokens user = runQuery $ select $ do
|
||||
tokens <- from $ table @DBToken
|
||||
where_ $ tokens ^. DBTokenUid ==. val (toDBKey user)
|
||||
pure $ tokens
|
||||
|
||||
addToken :: MonadDB m => DBToken -> m TokenID
|
||||
addToken = fmap fromDBKey . runQuery . insert
|
||||
|
||||
getToken :: MonadDB m => Text -> m (Maybe (Entity DBToken))
|
||||
getToken = runQuery . getBy . UniqueData
|
||||
|
||||
addKey :: MonadDB m => DBKey -> m KeyID
|
||||
addKey = fmap fromDBKey . runQuery . insert
|
||||
|
||||
getAllUsers :: MonadDB m => m [Entity DBUser]
|
||||
getAllUsers = runQuery $ select $ do
|
||||
users <- from $ table @DBUser
|
||||
where_ $ isMember users
|
||||
pure $ users
|
||||
|
||||
applicants :: MonadDB m => m [Entity DBUser]
|
||||
applicants = runQuery $ select $ do
|
||||
users <- from $ table @DBUser
|
||||
where_ $ isApplicant users
|
||||
pure $ users
|
||||
|
||||
isApplicant :: SqlExpr (Entity DBUser) -> SqlExpr (Value Bool)
|
||||
isApplicant user = isNothing (user ^. DBUserAccepted) &&. not_ (isNothing (user ^. DBUserEmail)) &&. isNothing (user ^. DBUserRejected)
|
||||
|
||||
isMember :: SqlExpr (Entity DBUser) -> SqlExpr (Value Bool)
|
||||
isMember user = not_ (isNothing (user ^. DBUserAccepted)) &&. isNothing (user ^. DBUserSeceded)
|
||||
|
||||
verifyEmailSecret :: MonadDB m => Text -> m Int64
|
||||
verifyEmailSecret secret = runQuery $ updateCount $ \user -> do
|
||||
set user [ DBUserEmailVerificationSecret =. val Nothing
|
||||
, DBUserEmail =. user ^. DBUserPendingEmail
|
||||
, DBUserPendingEmail =. val Nothing
|
||||
]
|
||||
where_ $ user ^. DBUserEmailVerificationSecret ==. just (val secret)
|
||||
|
||||
markAsAccepted :: MonadDB m => UserID -> Time -> m ()
|
||||
markAsAccepted userID time = runQuery $ update $ \user -> do
|
||||
set user [DBUserAccepted =. just (val time)]
|
||||
where_ $ user ^. DBUserId ==. val (toDBKey userID) &&. isApplicant user
|
||||
|
||||
markAsRejected :: MonadDB m => UserID -> Time -> m ()
|
||||
markAsRejected userID time = runQuery $ update $ \user -> do
|
||||
set user [DBUserRejected =. just (val time)]
|
||||
where_ $ user ^. DBUserId ==. val (toDBKey userID) &&. isApplicant user
|
||||
|
||||
updateUserData :: MonadDB m => UserID -> [Persist.Update DBUser] -> [UserUpdate] -> m UserID
|
||||
updateUserData user updates memberDataUpdates = runQuery $ do
|
||||
let key = toDBKey user
|
||||
Just userData <- get key
|
||||
let (Success memberData) = fromJSON $ dBUserMemberData userData :: Result MemberData
|
||||
userUpdates = [DBUserMemberData Persist.=. (toJSON $ foldr updateData memberData memberDataUpdates)]
|
||||
updateData (SetUserName x) memberData = memberData { name = x }
|
||||
updateData (SetUserNickname x) memberData = memberData { nickname = x }
|
||||
updateData (SetUserHomeplace x) memberData = memberData { homeplace = x }
|
||||
Persist.update key (userUpdates <> updates)
|
||||
pure user
|
||||
|
||||
data UserUpdate = SetUserName Text
|
||||
| SetUserNickname (Maybe Text)
|
||||
| SetUserHomeplace Text
|
|
@ -0,0 +1,17 @@
|
|||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
module Server.Email where
|
||||
|
||||
import Server.Types
|
||||
import Data.Text (Text)
|
||||
import Data.Text.Lazy (fromStrict)
|
||||
import Network.Mail.Mime
|
||||
|
||||
sendVerificationEmail :: MonadEmail m => Email -> Text -> m ()
|
||||
sendVerificationEmail to secret = do
|
||||
from <- fromAddress
|
||||
sendEmail $ simpleMail' (Address Nothing $ renderEmail to) from
|
||||
"Sähköpostin vahvistuskoodi" $ fromStrict $
|
||||
"Vahvista sähköpostisi syöttämällä alla oleva koodi rekisteriin:\n\n"
|
||||
<> secret <> "\n\n"
|
||||
<> "Mikäli et odottanut tätä viestiä, voit jättää sen turvallisesti huomiotta."
|
|
@ -0,0 +1,245 @@
|
|||
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
|
||||
{-# LANGUAGE DeriveGeneric #-}
|
||||
{-# LANGUAGE FlexibleInstances #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE MultiParamTypeClasses #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE TypeFamilies #-}
|
||||
{-# LANGUAGE PackageImports #-}
|
||||
|
||||
{-# LANGUAGE NoImplicitPrelude #-}
|
||||
|
||||
module Server.Types where
|
||||
|
||||
import Relude
|
||||
|
||||
import Control.Monad.Except (throwError)
|
||||
import Control.Monad.Logger (LoggingT)
|
||||
import Data.Aeson (ToJSON(..), FromJSON(..))
|
||||
import Data.ByteArray (ByteArray, ByteArrayAccess)
|
||||
import Data.Morpheus.App.Internal.Resolving (Resolver, LiftOperation)
|
||||
import Data.Morpheus.Server.Types (SCALAR)
|
||||
import Data.Morpheus.Types (GQLType, DecodeScalar(..), KIND, EncodeScalar(..),
|
||||
ScalarValue(..), MonadError, GQLError)
|
||||
import Data.Morpheus.Types.GQLScalar (scalarToJSON, scalarFromJSON)
|
||||
import Data.Time (UTCTime, getCurrentTime, NominalDiffTime, addUTCTime, Day)
|
||||
import Data.Time.Format.ISO8601 (iso8601Show, iso8601ParseM)
|
||||
import Database.Persist.Class (PersistField(..))
|
||||
import Database.Persist.PersistValue (PersistValue(..))
|
||||
import Database.Persist.Sql (PersistFieldSql(..), SqlBackend)
|
||||
import Network.Mail.Mime (Mail, Address(..))
|
||||
import Text.Email.Validate (EmailAddress, toByteString, validate, emailAddress)
|
||||
import qualified Data.ByteString.Base64 as B64 (encode, decode)
|
||||
import "cryptonite" Crypto.Random (MonadRandom(..))
|
||||
|
||||
base64Encode :: ByteString -> Base64
|
||||
base64Encode = Base64 . decodeUtf8 . B64.encode
|
||||
|
||||
base64Decode :: Base64 -> Maybe ByteString
|
||||
base64Decode (Base64 x) = either (const Nothing) Just $ B64.decode $ encodeUtf8 x
|
||||
|
||||
toEmail :: Text -> Maybe Email
|
||||
toEmail = fmap Email . emailAddress . encodeUtf8
|
||||
|
||||
renderEmail :: Email -> Text
|
||||
renderEmail (Email x) = decodeUtf8 $ toByteString x
|
||||
|
||||
renderTime :: Time -> Text
|
||||
renderTime (Time x) = toText $ iso8601Show x
|
||||
|
||||
toTime :: Text -> Maybe Time
|
||||
toTime = fmap Time . iso8601ParseM . toString
|
||||
|
||||
toDate :: Text -> Maybe Date
|
||||
toDate = fmap Date . iso8601ParseM . toString
|
||||
|
||||
renderDate :: Date -> Text
|
||||
renderDate (Date x) = toText $ iso8601Show x
|
||||
|
||||
addTime :: NominalDiffTime -> Time -> Time
|
||||
addTime diff (Time time) = Time $ addUTCTime diff time
|
||||
|
||||
forward :: Monad m => [a] -> m [Maybe a]
|
||||
forward = pure . map Just
|
||||
|
||||
requirePermission :: (MonadPermissions m, MonadError GQLError m) => Scope -> Permission -> m ()
|
||||
requirePermission scope permission = unlessM (hasPermission scope permission) $
|
||||
throwError $ "Insufficient permissions, " <> show permission <> " for "
|
||||
<> show scope <> " required."
|
||||
|
||||
data Scope = OwnProfile
|
||||
| OwnTokens
|
||||
| Profile UserID
|
||||
| Tokens UserID
|
||||
| Members
|
||||
| Applications
|
||||
deriving (Show, Eq, Ord, Read)
|
||||
|
||||
data Permission = None
|
||||
| ReadOnly
|
||||
| ReadWrite
|
||||
deriving (Show, Eq, Ord, Read)
|
||||
|
||||
data MemberData = MemberData
|
||||
{ name :: Text
|
||||
, nickname :: Maybe Text
|
||||
, birthdate :: Date
|
||||
, homeplace :: Text
|
||||
, application :: Text
|
||||
} deriving (Show, Eq, Generic)
|
||||
|
||||
instance FromJSON MemberData
|
||||
instance ToJSON MemberData
|
||||
|
||||
newtype UserID = UserID Int64 deriving (Eq, Show, Generic, Ord, Read)
|
||||
|
||||
instance DecodeScalar UserID where
|
||||
decodeScalar (String s) = first (const $ "invalid UserID: \"" <> s <> "\"") $
|
||||
UserID <$> readEither (toString s)
|
||||
decodeScalar _ = Left "Invalid type for UserID, should be string"
|
||||
|
||||
instance EncodeScalar UserID where
|
||||
encodeScalar (UserID x) = String $ show x
|
||||
|
||||
instance GQLType UserID where type KIND UserID = SCALAR
|
||||
instance ToJSON UserID where toJSON = scalarToJSON
|
||||
instance FromJSON UserID where parseJSON = scalarFromJSON <=< parseJSON
|
||||
|
||||
newtype KeyID = KeyID Int64 deriving (Eq, Show, Generic)
|
||||
|
||||
instance DecodeScalar KeyID where
|
||||
decodeScalar (String s) = first (const $ "invalid KeyID: \"" <> s <> "\"") $
|
||||
KeyID <$> readEither (toString s)
|
||||
decodeScalar _ = Left "Invalid type for KeyID, should be string"
|
||||
|
||||
instance EncodeScalar KeyID where
|
||||
encodeScalar (KeyID s) = String $ show s
|
||||
|
||||
instance GQLType KeyID where type KIND KeyID = SCALAR
|
||||
instance ToJSON KeyID where toJSON = scalarToJSON
|
||||
instance FromJSON KeyID where parseJSON = scalarFromJSON <=< parseJSON
|
||||
|
||||
newtype TokenID = TokenID Int64 deriving (Eq, Show, Generic)
|
||||
|
||||
instance DecodeScalar TokenID where
|
||||
decodeScalar (String s) = first (const $ "invalid TokenID: \"" <> s <> "\"") $
|
||||
TokenID <$> readEither (toString s)
|
||||
decodeScalar _ = Left "Invalid type for TokenID, should be string"
|
||||
|
||||
instance EncodeScalar TokenID where
|
||||
encodeScalar (TokenID s) = String $ show s
|
||||
|
||||
instance GQLType TokenID where type KIND TokenID = SCALAR
|
||||
instance ToJSON TokenID where toJSON = scalarToJSON
|
||||
instance FromJSON TokenID where parseJSON = scalarFromJSON <=< parseJSON
|
||||
|
||||
newtype Base64 = Base64 Text
|
||||
deriving (Eq, Show, ToJSON, FromJSON, Generic, DecodeScalar, EncodeScalar)
|
||||
|
||||
instance GQLType Base64 where type KIND Base64 = SCALAR
|
||||
|
||||
newtype Email = Email EmailAddress deriving (Eq, Show)
|
||||
|
||||
instance DecodeScalar Email where
|
||||
decodeScalar (String s) = maybe (Left $ "Couldn't parse \"" <> s <> "\" as an email address") (Right . Email) $ emailAddress $ encodeUtf8 s
|
||||
decodeScalar _ = Left "Invalid type for Email, should be string"
|
||||
|
||||
instance EncodeScalar Email where
|
||||
encodeScalar = String . renderEmail
|
||||
|
||||
instance GQLType Email where type KIND Email = SCALAR
|
||||
instance ToJSON Email where toJSON = scalarToJSON
|
||||
instance FromJSON Email where parseJSON = scalarFromJSON <=< parseJSON
|
||||
|
||||
instance PersistField Email where
|
||||
toPersistValue = PersistText . renderEmail
|
||||
fromPersistValue (PersistText email) =
|
||||
second Email $ first toText $ validate $ encodeUtf8 email
|
||||
fromPersistValue x = Left $ "Wrong type for Email: " <> show x
|
||||
|
||||
newtype Time = Time UTCTime deriving (Eq, Show, PersistField, PersistFieldSql)
|
||||
|
||||
instance DecodeScalar Time where
|
||||
decodeScalar (String s) = maybe
|
||||
(Left $ "Couldn't parse \"" <> s <> "\" as an ISO8601 timestamp")
|
||||
(Right) $ toTime s
|
||||
decodeScalar _ = Left "Invalid type for Time, should be string"
|
||||
|
||||
instance EncodeScalar Time where
|
||||
encodeScalar = String . renderTime
|
||||
|
||||
instance GQLType Time where type KIND Time = SCALAR
|
||||
instance ToJSON Time where toJSON = scalarToJSON
|
||||
instance FromJSON Time where parseJSON = scalarFromJSON <=< parseJSON
|
||||
|
||||
newtype Date = Date Day deriving (Eq, Show, PersistField, Generic)
|
||||
|
||||
instance DecodeScalar Date where
|
||||
decodeScalar (String s) = maybe
|
||||
(Left $ "Couldn't parse \"" <> s <> "\" as an ISO8601 date")
|
||||
(Right) $ toDate s
|
||||
decodeScalar _ = Left "Invalid type for Date, should be string"
|
||||
|
||||
instance EncodeScalar Date where
|
||||
encodeScalar = String . renderDate
|
||||
|
||||
instance GQLType Date where type KIND Date = SCALAR
|
||||
instance ToJSON Date where toJSON = scalarToJSON
|
||||
instance FromJSON Date where parseJSON = scalarFromJSON <=< parseJSON
|
||||
|
||||
newtype PasswordHash = PasswordHash ByteString
|
||||
deriving (Eq, Show, Ord, Semigroup, Monoid, ByteArrayAccess, ByteArray,
|
||||
PersistField, PersistFieldSql)
|
||||
|
||||
class Monad m => MonadTime m where
|
||||
currentTime :: m Time
|
||||
|
||||
instance MonadTime IO where
|
||||
currentTime = Time <$> getCurrentTime
|
||||
|
||||
class Monad m => MonadDB m where
|
||||
runQuery :: ReaderT SqlBackend (LoggingT IO) a -> m a
|
||||
-- TODO refactor so that it is possible to define a IO-less db, for safety and testability.
|
||||
-- Is there a way to do this that doesn't require moving all db calls to the class?
|
||||
-- Probably not :(
|
||||
|
||||
class Monad m => MonadEmail m where
|
||||
sendEmail :: Mail -> m ()
|
||||
fromAddress :: m Address
|
||||
|
||||
class Monad m => MonadRequest m where
|
||||
currentUser :: m (Maybe UserID)
|
||||
|
||||
class Monad m => MonadPermissions m where
|
||||
hasPermission :: Scope -> Permission -> m Bool
|
||||
currentPermissions :: m Text
|
||||
defaultPermissions :: m Text
|
||||
toPermissions :: Text -> m (Maybe Text)
|
||||
|
||||
instance (MonadDB m, LiftOperation o) => MonadDB (Resolver o () m) where
|
||||
runQuery = lift . runQuery
|
||||
|
||||
instance (MonadRequest m, LiftOperation o) => MonadRequest (Resolver o () m) where
|
||||
currentUser = lift currentUser
|
||||
|
||||
instance (MonadPermissions m, LiftOperation o) => MonadPermissions (Resolver o () m) where
|
||||
hasPermission scope permission = lift $ hasPermission scope permission
|
||||
defaultPermissions = lift defaultPermissions
|
||||
currentPermissions = lift currentPermissions
|
||||
toPermissions = lift . toPermissions
|
||||
|
||||
instance (MonadEmail m, LiftOperation o) => MonadEmail (Resolver o () m) where
|
||||
sendEmail = lift . sendEmail
|
||||
fromAddress = lift fromAddress
|
||||
|
||||
instance (MonadRandom m, LiftOperation o) => MonadRandom (Resolver o () m) where
|
||||
getRandomBytes = lift . getRandomBytes
|
||||
|
||||
instance (MonadTime m, LiftOperation o) => MonadTime (Resolver o () m) where
|
||||
currentTime = lift currentTime
|
||||
|
||||
data Unit = Unit | Unit2 deriving (Eq, Generic, Show)
|
||||
|
||||
instance GQLType Unit
|
||||
instance ToJSON Unit
|
||||
instance FromJSON Unit
|
|
@ -0,0 +1,20 @@
|
|||
{-# LANGUAGE PackageImports #-}
|
||||
|
||||
module Server.Utils where
|
||||
|
||||
import Data.ByteArray.Encoding (convertToBase, Base(..))
|
||||
import Data.ByteString (ByteString)
|
||||
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
|
||||
import Data.Text (Text)
|
||||
import qualified Crypto.KDF.BCrypt as Crypt (hashPassword, validatePassword)
|
||||
import "cryptonite" Crypto.Random (MonadRandom)
|
||||
import Server.Types
|
||||
|
||||
base32 :: ByteString -> Text
|
||||
base32 = decodeUtf8 . convertToBase Base32
|
||||
|
||||
hashPassword :: MonadRandom m => Text -> m PasswordHash
|
||||
hashPassword = Crypt.hashPassword 12 . encodeUtf8
|
||||
|
||||
checkPassword :: Text -> PasswordHash -> Bool
|
||||
checkPassword password = Crypt.validatePassword $ encodeUtf8 password
|
|
@ -0,0 +1,4 @@
|
|||
import Distribution.Simple
|
||||
|
||||
main :: IO ()
|
||||
main = defaultMain
|
|
@ -0,0 +1,14 @@
|
|||
cabal-version: 2.4
|
||||
name: datarekisteri
|
||||
version: 0.0.1
|
||||
author: Saku Laesvuori
|
||||
license: AGPL-3.0-or-later
|
||||
license-file: COPYING.md
|
||||
build-type: Simple
|
||||
stability: alpha
|
||||
|
||||
executable datarekisteri
|
||||
build-depends: aeson, base, base64-bytestring, cryptonite, email-validate, esqueleto, memory, mime-mail, monad-logger, morpheus-graphql, morpheus-graphql-app, morpheus-graphql-client, morpheus-graphql-core, morpheus-graphql-server, mtl, persistent, persistent-postgresql, relude, scotty, smtp-mail, text, time, yesod, yesod-auth
|
||||
main-is: Client.hs
|
||||
hs-source-dirs: .
|
||||
default-language: Haskell2010
|
|
@ -0,0 +1,53 @@
|
|||
-- migrate:up
|
||||
|
||||
create table "users" (
|
||||
"id" serial primary key,
|
||||
"email" varchar(255) unique,
|
||||
"pendingEmail" varchar(255) unique,
|
||||
"emailVerificationSecret" varchar(255) unique,
|
||||
"registered" timestamp not null,
|
||||
"passwordCrypt" bytea not null,
|
||||
"permissions" text not null,
|
||||
"accepted" timestamp,
|
||||
"rejected" timestamp,
|
||||
"seceded" timestamp,
|
||||
"toBeDeleted" timestamp,
|
||||
"memberData" jsonb not null
|
||||
);
|
||||
|
||||
create index "users_memberData_index" on "users" using gin ("memberData");
|
||||
|
||||
create table "keys" (
|
||||
"id" serial primary key,
|
||||
"uid" integer not null references "users" on delete cascade,
|
||||
"data" bytea not null,
|
||||
"expires" timestamp,
|
||||
"uploaded" timestamp not null,
|
||||
"comment" text not null,
|
||||
"isPrimaryEncryptionKey" bool not null
|
||||
);
|
||||
|
||||
create unique index "keys_isPrimaryEncryptionKey_constraint" on "keys" ("uid")
|
||||
where "isPrimaryEncryptionKey";
|
||||
|
||||
create index "keys_uid_index" on "keys" ("uid");
|
||||
|
||||
create table "tokens" (
|
||||
"id" serial primary key,
|
||||
"uid" integer not null references "users" on delete cascade,
|
||||
"name" text,
|
||||
"data" text unique not null,
|
||||
"comment" text not null,
|
||||
"issued" timestamp not null,
|
||||
"expires" timestamp,
|
||||
"permissions" text,
|
||||
unique ("name", "uid")
|
||||
);
|
||||
|
||||
create index "tokens_data_index" on "tokens" ("data");
|
||||
|
||||
-- migrate:down
|
||||
|
||||
drop table "tokens";
|
||||
drop table "keys";
|
||||
drop table "users";
|
|
@ -0,0 +1,283 @@
|
|||
SET statement_timeout = 0;
|
||||
SET lock_timeout = 0;
|
||||
SET idle_in_transaction_session_timeout = 0;
|
||||
SET client_encoding = 'UTF8';
|
||||
SET standard_conforming_strings = on;
|
||||
SELECT pg_catalog.set_config('search_path', '', false);
|
||||
SET check_function_bodies = false;
|
||||
SET xmloption = content;
|
||||
SET client_min_messages = warning;
|
||||
SET row_security = off;
|
||||
|
||||
SET default_tablespace = '';
|
||||
|
||||
SET default_table_access_method = heap;
|
||||
|
||||
--
|
||||
-- Name: keys; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.keys (
|
||||
id integer NOT NULL,
|
||||
uid integer NOT NULL,
|
||||
data bytea NOT NULL,
|
||||
expires timestamp without time zone,
|
||||
uploaded timestamp without time zone NOT NULL,
|
||||
comment text NOT NULL,
|
||||
"isPrimaryEncryptionKey" boolean NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: keys_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.keys_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: keys_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.keys_id_seq OWNED BY public.keys.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: schema_migrations; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.schema_migrations (
|
||||
version character varying(255) NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tokens; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.tokens (
|
||||
id integer NOT NULL,
|
||||
uid integer NOT NULL,
|
||||
name text,
|
||||
data text NOT NULL,
|
||||
comment text NOT NULL,
|
||||
issued timestamp without time zone NOT NULL,
|
||||
expires timestamp without time zone,
|
||||
permissions text
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tokens_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.tokens_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: tokens_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.tokens_id_seq OWNED BY public.tokens.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: users; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.users (
|
||||
id integer NOT NULL,
|
||||
email character varying(255),
|
||||
"pendingEmail" character varying(255),
|
||||
"emailVerificationSecret" character varying(255),
|
||||
registered timestamp without time zone NOT NULL,
|
||||
"passwordCrypt" bytea NOT NULL,
|
||||
permissions text NOT NULL,
|
||||
accepted timestamp without time zone,
|
||||
rejected timestamp without time zone,
|
||||
seceded timestamp without time zone,
|
||||
"toBeDeleted" timestamp without time zone,
|
||||
"memberData" jsonb NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: users_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.users_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.users_id_seq OWNED BY public.users.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: keys id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.keys ALTER COLUMN id SET DEFAULT nextval('public.keys_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tokens id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.tokens ALTER COLUMN id SET DEFAULT nextval('public.tokens_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: users id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: keys keys_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.keys
|
||||
ADD CONSTRAINT keys_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.schema_migrations
|
||||
ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tokens tokens_data_key; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.tokens
|
||||
ADD CONSTRAINT tokens_data_key UNIQUE (data);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tokens tokens_name_uid_key; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.tokens
|
||||
ADD CONSTRAINT tokens_name_uid_key UNIQUE (name, uid);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tokens tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.tokens
|
||||
ADD CONSTRAINT tokens_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: users users_emailVerificationSecret_key; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.users
|
||||
ADD CONSTRAINT "users_emailVerificationSecret_key" UNIQUE ("emailVerificationSecret");
|
||||
|
||||
|
||||
--
|
||||
-- Name: users users_email_key; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.users
|
||||
ADD CONSTRAINT users_email_key UNIQUE (email);
|
||||
|
||||
|
||||
--
|
||||
-- Name: users users_pendingEmail_key; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.users
|
||||
ADD CONSTRAINT "users_pendingEmail_key" UNIQUE ("pendingEmail");
|
||||
|
||||
|
||||
--
|
||||
-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.users
|
||||
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: keys_isPrimaryEncryptionKey_constraint; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE UNIQUE INDEX "keys_isPrimaryEncryptionKey_constraint" ON public.keys USING btree (uid) WHERE "isPrimaryEncryptionKey";
|
||||
|
||||
|
||||
--
|
||||
-- Name: keys_uid_index; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX keys_uid_index ON public.keys USING btree (uid);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tokens_data_index; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX tokens_data_index ON public.tokens USING btree (data);
|
||||
|
||||
|
||||
--
|
||||
-- Name: users_memberData_index; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX "users_memberData_index" ON public.users USING gin ("memberData");
|
||||
|
||||
|
||||
--
|
||||
-- Name: keys keys_uid_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.keys
|
||||
ADD CONSTRAINT keys_uid_fkey FOREIGN KEY (uid) REFERENCES public.users(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
--
|
||||
-- Name: tokens tokens_uid_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.tokens
|
||||
ADD CONSTRAINT tokens_uid_fkey FOREIGN KEY (uid) REFERENCES public.users(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
--
|
||||
-- PostgreSQL database dump complete
|
||||
--
|
||||
|
||||
|
||||
--
|
||||
-- Dbmate schema migrations
|
||||
--
|
||||
|
||||
INSERT INTO public.schema_migrations (version) VALUES
|
||||
('20221208114323');
|
|
@ -0,0 +1,728 @@
|
|||
; also load guix.scm for id-rekisteri
|
||||
(use-modules ((guix licenses) #:prefix license:)
|
||||
(guix packages)
|
||||
(guix)
|
||||
(guix download)
|
||||
(guix build-system haskell)
|
||||
(gnu packages haskell)
|
||||
(gnu packages haskell-xyz)
|
||||
(gnu packages haskell-crypto)
|
||||
(gnu packages haskell-check)
|
||||
(gnu packages haskell-web))
|
||||
|
||||
(define-public datarekisteri
|
||||
(package
|
||||
(name "datarekisteri")
|
||||
(version "0.0.1")
|
||||
(source (origin
|
||||
(method url-fetch)
|
||||
(uri "file:///home/saku/koodi/rekisteri/datarekisteri")
|
||||
(sha256
|
||||
(base32
|
||||
"1lpggpdzgjk23mq7aa64yylds5dbm4ynhcvbarqihjxabvh7xmz1"))))
|
||||
(build-system haskell-build-system)
|
||||
(inputs (list ghc-base64-bytestring
|
||||
ghc-cryptonite
|
||||
ghc-email-validate
|
||||
ghc-esqueleto
|
||||
ghc-mime-mail
|
||||
ghc-morpheus-graphql
|
||||
ghc-morpheus-graphql-client
|
||||
ghc-persistent
|
||||
ghc-persistent-postgresql
|
||||
ghc-scotty
|
||||
ghc-smtp-mail
|
||||
ghc-wai-cors
|
||||
ghc-wai-extra
|
||||
ghc-yesod
|
||||
ghc-yesod-static
|
||||
ghc-yesod-auth))
|
||||
(home-page "")
|
||||
(synopsis "")
|
||||
(description "")
|
||||
(license license:agpl3+)))
|
||||
|
||||
(define-public ghc-authenticate
|
||||
(package
|
||||
(name "ghc-authenticate")
|
||||
(version "1.3.5.1")
|
||||
(source (origin
|
||||
(method url-fetch)
|
||||
(uri (hackage-uri "authenticate" version))
|
||||
(sha256
|
||||
(base32
|
||||
"1rhbvdgwdr68gp13p5piddfdqf3l9lmx4w7k249lc98y23780c3x"))))
|
||||
(build-system haskell-build-system)
|
||||
(inputs (list ghc-aeson
|
||||
ghc-http-conduit
|
||||
ghc-case-insensitive
|
||||
ghc-http-types
|
||||
ghc-xml-conduit
|
||||
ghc-blaze-builder
|
||||
ghc-attoparsec
|
||||
ghc-unordered-containers
|
||||
ghc-conduit
|
||||
ghc-html-conduit
|
||||
ghc-resourcet
|
||||
ghc-network-uri))
|
||||
(home-page "http://github.com/yesodweb/authenticate")
|
||||
(synopsis "Authentication methods for Haskell web applications.")
|
||||
(description "API docs and the README are available at
|
||||
<http://www.stackage.org/package/authenticate>.")
|
||||
(license license:expat)))
|
||||
|
||||
(define-public ghc-yesod-auth
|
||||
(package
|
||||
(name "ghc-yesod-auth")
|
||||
(version "1.6.11")
|
||||
(source (origin
|
||||
(method url-fetch)
|
||||
(uri (hackage-uri "yesod-auth" version))
|
||||
(sha256
|
||||
(base32
|
||||
"0fdahk5mc63g0zsafk8axry01qaxahmclpmmwygp2lhfsjy8mby2"))))
|
||||
(build-system haskell-build-system)
|
||||
(inputs (list ghc-aeson
|
||||
ghc-authenticate
|
||||
ghc-base16-bytestring
|
||||
ghc-base64-bytestring
|
||||
ghc-blaze-builder
|
||||
ghc-blaze-html
|
||||
ghc-blaze-markup
|
||||
ghc-conduit
|
||||
ghc-conduit-extra
|
||||
ghc-cryptonite
|
||||
ghc-data-default
|
||||
ghc-email-validate
|
||||
ghc-file-embed
|
||||
ghc-http-client
|
||||
ghc-http-client-tls
|
||||
ghc-http-conduit
|
||||
ghc-http-types
|
||||
ghc-memory
|
||||
ghc-nonce
|
||||
ghc-persistent
|
||||
ghc-random
|
||||
ghc-safe
|
||||
ghc-shakespeare
|
||||
ghc-unliftio
|
||||
ghc-unliftio-core
|
||||
ghc-unordered-containers
|
||||
ghc-wai
|
||||
ghc-yesod-core
|
||||
ghc-yesod-form
|
||||
ghc-yesod-persistent
|
||||
ghc-network-uri))
|
||||
(home-page "http://www.yesodweb.com/")
|
||||
(synopsis "Authentication for Yesod.")
|
||||
(description "API docs and the README are available at
|
||||
<http://www.stackage.org/package/yesod-auth>")
|
||||
(license license:expat)))
|
||||
|
||||
(define-public ghc-scotty
|
||||
(package
|
||||
(name "ghc-scotty")
|
||||
(version "0.12")
|
||||
(source (origin
|
||||
(method url-fetch)
|
||||
(uri (hackage-uri "scotty" version))
|
||||
(sha256
|
||||
(base32
|
||||
"1lpggpdzgjk23mq7aa64yylds5dbm4ynhcvbarqihjxabvh7xmz1"))))
|
||||
(build-system haskell-build-system)
|
||||
(inputs (list ghc-aeson
|
||||
ghc-base-compat-batteries
|
||||
ghc-blaze-builder
|
||||
ghc-case-insensitive
|
||||
ghc-data-default-class
|
||||
ghc-fail
|
||||
ghc-http-types
|
||||
ghc-monad-control
|
||||
ghc-nats
|
||||
ghc-network
|
||||
ghc-regex-compat
|
||||
ghc-transformers-base
|
||||
ghc-transformers-compat
|
||||
ghc-wai
|
||||
ghc-wai-extra
|
||||
ghc-warp))
|
||||
(native-inputs (list ghc-async ghc-hspec ghc-hspec-wai ghc-lifted-base hspec-discover))
|
||||
(arguments
|
||||
`(#:cabal-revision ("7"
|
||||
"1i8icc612w4dbmqmnf99drqpmjvhjnkmqgk9xr63amj8jkz5lp4m")))
|
||||
(home-page "https://github.com/scotty-web/scotty")
|
||||
(synopsis
|
||||
"Haskell web framework inspired by Ruby's Sinatra, using WAI and Warp")
|
||||
(description
|
||||
"This package provides a Haskell web framework inspired by Ruby's Sinatra, using
|
||||
WAI and Warp. . @ {-# LANGUAGE OverloadedStrings #-} . import
|
||||
Web.Scotty . import Data.Monoid (mconcat) . main = scotty 3000 $   get
|
||||
"/:word" $ do     beam <- param "word"
|
||||
    html $ mconcat ["<h1>Scotty, ", beam, "
|
||||
me up!</h1>"] @ . . Scotty is the cheap and cheerful way to write
|
||||
RESTful, declarative web applications. . * A page is as simple as defining the
|
||||
verb, url pattern, and Text content. . * It is template-language agnostic.
|
||||
Anything that returns a Text value will do. . * Conforms to WAI Application
|
||||
interface. . * Uses very fast Warp webserver by default. . As for the name:
|
||||
Sinatra + Warp = Scotty. . [WAI] <http://hackage.haskell.org/package/wai> .
|
||||
[Warp] <http://hackage.haskell.org/package/warp>")
|
||||
(license license:bsd-3)))
|
||||
|
||||
(define-public ghc-hspec-wai
|
||||
(package
|
||||
(name "ghc-hspec-wai")
|
||||
(version "0.11.1")
|
||||
(source (origin
|
||||
(method url-fetch)
|
||||
(uri (hackage-uri "hspec-wai" version))
|
||||
(sha256
|
||||
(base32
|
||||
"03wiksic5y9a2g6a86nsxrnajdgdvpv17w02h5qla0zp9zs6pa1j"))))
|
||||
(build-system haskell-build-system)
|
||||
(inputs (list ghc-quickcheck
|
||||
ghc-base-compat
|
||||
ghc-case-insensitive
|
||||
ghc-hspec-core
|
||||
ghc-hspec-expectations
|
||||
ghc-http-types
|
||||
ghc-wai
|
||||
ghc-wai-extra))
|
||||
(native-inputs (list ghc-hspec hspec-discover))
|
||||
(home-page "https://github.com/hspec/hspec-wai#readme")
|
||||
(synopsis "Experimental Hspec support for testing WAI applications")
|
||||
(description "Experimental Hspec support for testing WAI applications")
|
||||
(license license:expat)))
|
||||
|
||||
(define-public ghc-morpheus-graphql
|
||||
(package
|
||||
(name "ghc-morpheus-graphql")
|
||||
(version "0.27.0")
|
||||
(source (origin
|
||||
(method url-fetch)
|
||||
(uri (hackage-uri "morpheus-graphql" version))
|
||||
(sha256
|
||||
(base32
|
||||
"1d2wk0zw3qb22skv7g4xagl04las5xnh9f4223c4as9cf39pcrp9"))))
|
||||
(build-system haskell-build-system)
|
||||
(inputs (list ghc-aeson
|
||||
ghc-morpheus-graphql-app
|
||||
ghc-morpheus-graphql-code-gen
|
||||
ghc-morpheus-graphql-core
|
||||
ghc-morpheus-graphql-server
|
||||
ghc-relude
|
||||
ghc-unordered-containers
|
||||
ghc-vector))
|
||||
(native-inputs (list ghc-morpheus-graphql-subscriptions
|
||||
ghc-morpheus-graphql-tests ghc-tasty ghc-tasty-hunit))
|
||||
(home-page "https://morpheusgraphql.com")
|
||||
(synopsis "Morpheus GraphQL")
|
||||
(description "Build GraphQL APIs with your favourite functional language!")
|
||||
(license license:expat)))
|
||||
|
||||
(define-public ghc-morpheus-graphql-code-gen
|
||||
(package
|
||||
(name "ghc-morpheus-graphql-code-gen")
|
||||
(version "0.27.0")
|
||||
(source (origin
|
||||
(method url-fetch)
|
||||
(uri (hackage-uri "morpheus-graphql-code-gen" version))
|
||||
(sha256
|
||||
(base32
|
||||
"0aw9fl0hzl657w2arybyk0zqxvbdz897kiqwsv52r50dnb5x2izf"))))
|
||||
(build-system haskell-build-system)
|
||||
(inputs (list ghc-file-embed
|
||||
ghc-morpheus-graphql-code-gen-utils
|
||||
ghc-morpheus-graphql-core
|
||||
ghc-morpheus-graphql-server
|
||||
ghc-prettyprinter
|
||||
ghc-relude
|
||||
ghc-unordered-containers
|
||||
ghc-glob
|
||||
ghc-morpheus-graphql-client
|
||||
ghc-optparse-applicative
|
||||
ghc-yaml))
|
||||
(home-page "https://morpheusgraphql.com")
|
||||
(synopsis "Morpheus GraphQL CLI")
|
||||
(description "code generator for Morpheus GraphQL")
|
||||
(license license:bsd-3)))
|
||||
|
||||
|
||||
(define-public ghc-morpheus-graphql-core
|
||||
(package
|
||||
(name "ghc-morpheus-graphql-core")
|
||||
(version "0.27.0")
|
||||
(source (origin
|
||||
(method url-fetch)
|
||||
(uri (hackage-uri "morpheus-graphql-core" version))
|
||||
(sha256
|
||||
(base32
|
||||
"0001pq7zj5rpjcg0jasd3vklisan2i8nxyk8d7xa31d4f1grn5ff"))))
|
||||
(build-system haskell-build-system)
|
||||
(inputs (list ghc-aeson
|
||||
ghc-hashable
|
||||
ghc-megaparsec
|
||||
ghc-relude
|
||||
ghc-scientific
|
||||
ghc-th-lift-instances
|
||||
ghc-unordered-containers
|
||||
ghc-vector))
|
||||
(native-inputs (list ghc-morpheus-graphql-tests ghc-tasty ghc-tasty-hunit))
|
||||
(home-page "https://morpheusgraphql.com")
|
||||
(synopsis "Morpheus GraphQL Core")
|
||||
(description "Build GraphQL APIs with your favorite functional language!")
|
||||
(license license:expat)))
|
||||
|
||||
|
||||
(define-public ghc-morpheus-graphql-app
|
||||
(package
|
||||
(name "ghc-morpheus-graphql-app")
|
||||
(version "0.27.0")
|
||||
(source (origin
|
||||
(method url-fetch)
|
||||
(uri (hackage-uri "morpheus-graphql-app" version))
|
||||
(sha256
|
||||
(base32
|
||||
"0mg12a8s2zcxcbm1zm5a4gn6vz8d9d1qdhk930zddxqbll5gq7nq"))))
|
||||
(build-system haskell-build-system)
|
||||
(inputs (list ghc-aeson
|
||||
ghc-hashable
|
||||
ghc-megaparsec
|
||||
ghc-morpheus-graphql-core
|
||||
ghc-relude
|
||||
ghc-scientific
|
||||
ghc-th-lift-instances
|
||||
ghc-unordered-containers
|
||||
ghc-vector))
|
||||
(native-inputs (list ghc-morpheus-graphql-tests ghc-tasty ghc-tasty-hunit))
|
||||
(home-page "https://morpheusgraphql.com")
|
||||
(synopsis "Morpheus GraphQL App")
|
||||
(description "Build GraphQL APIs with your favourite functional language!")
|
||||
(license license:expat)))
|
||||
|
||||
|
||||
(define-public ghc-morpheus-graphql-subscriptions
|
||||
(package
|
||||
(name "ghc-morpheus-graphql-subscriptions")
|
||||
(version "0.27.0")
|
||||
(source (origin
|
||||
(method url-fetch)
|
||||
(uri (hackage-uri "morpheus-graphql-subscriptions" version))
|
||||
(sha256
|
||||
(base32
|
||||
"138fcganlaj4fyq1aygiyy6f4hhw58n26ldrdkxhd66hr1mqv6j9"))))
|
||||
(build-system haskell-build-system)
|
||||
(inputs (list ghc-aeson
|
||||
ghc-morpheus-graphql-app
|
||||
ghc-morpheus-graphql-core
|
||||
ghc-relude
|
||||
ghc-unliftio-core
|
||||
ghc-unordered-containers
|
||||
ghc-uuid
|
||||
ghc-websockets))
|
||||
(home-page "https://morpheusgraphql.com")
|
||||
(synopsis "Morpheus GraphQL Subscriptions")
|
||||
(description "Build GraphQL APIs with your favourite functional language!")
|
||||
(license license:expat)))
|
||||
|
||||
|
||||
(define-public ghc-morpheus-graphql-client
|
||||
(package
|
||||
(name "ghc-morpheus-graphql-client")
|
||||
(version "0.27.0")
|
||||
(source (origin
|
||||
(method url-fetch)
|
||||
(uri (hackage-uri "morpheus-graphql-client" version))
|
||||
(sha256
|
||||
(base32
|
||||
"120414v0rcvzgm9dc2fx8598d88dqnpvhkc8zh3y2gl3b1bl31jb"))))
|
||||
(build-system haskell-build-system)
|
||||
(inputs (list ghc-aeson
|
||||
ghc-file-embed
|
||||
ghc-modern-uri
|
||||
ghc-morpheus-graphql-code-gen-utils
|
||||
ghc-morpheus-graphql-core
|
||||
ghc-morpheus-graphql-subscriptions
|
||||
ghc-prettyprinter
|
||||
ghc-relude
|
||||
ghc-req
|
||||
ghc-unliftio-core
|
||||
ghc-unordered-containers
|
||||
ghc-websockets
|
||||
ghc-wuss))
|
||||
(native-inputs (list ghc-tasty ghc-tasty-hunit))
|
||||
(home-page "https://morpheusgraphql.com")
|
||||
(synopsis "Morpheus GraphQL Client")
|
||||
(description "Build GraphQL APIs with your favorite functional language!")
|
||||
(license license:expat)))
|
||||
|
||||
|
||||
(define-public ghc-morpheus-graphql-tests
|
||||
(package
|
||||
(name "ghc-morpheus-graphql-tests")
|
||||
(version "0.27.0")
|
||||
(source (origin
|
||||
(method url-fetch)
|
||||
(uri (hackage-uri "morpheus-graphql-tests" version))
|
||||
(sha256
|
||||
(base32
|
||||
"1h6nri73s5ibzidhwrkxffldardx6khq9kvhmqklm06cilwr56xi"))))
|
||||
(build-system haskell-build-system)
|
||||
(inputs (list ghc-aeson ghc-relude ghc-tasty ghc-tasty-hunit
|
||||
ghc-unordered-containers))
|
||||
(home-page "https://morpheusgraphql.com")
|
||||
(synopsis "Morpheus GraphQL Test")
|
||||
(description "")
|
||||
(license license:expat)))
|
||||
|
||||
|
||||
(define-public ghc-morpheus-graphql-server
|
||||
(package
|
||||
(name "ghc-morpheus-graphql-server")
|
||||
(version "0.27.0")
|
||||
(source (origin
|
||||
(method url-fetch)
|
||||
(uri (hackage-uri "morpheus-graphql-server" version))
|
||||
(sha256
|
||||
(base32
|
||||
"0cd6qczbb5cm12xv181pzq6d62nv7nf4w1yd3gmhzjfrks62lhwy"))))
|
||||
(build-system haskell-build-system)
|
||||
(inputs (list ghc-aeson
|
||||
ghc-morpheus-graphql-app
|
||||
ghc-morpheus-graphql-core
|
||||
ghc-relude
|
||||
ghc-unordered-containers
|
||||
ghc-vector))
|
||||
(native-inputs (list ghc-file-embed ghc-morpheus-graphql-subscriptions
|
||||
ghc-morpheus-graphql-tests ghc-tasty ghc-tasty-hunit))
|
||||
(home-page "https://morpheusgraphql.com")
|
||||
(synopsis "Morpheus GraphQL")
|
||||
(description "Build GraphQL APIs with your favourite functional language!")
|
||||
(license license:expat)))
|
||||
|
||||
(define-public ghc-relude
|
||||
(package
|
||||
(name "ghc-relude")
|
||||
(version "1.1.0.0")
|
||||
(source (origin
|
||||
(method url-fetch)
|
||||
(uri (hackage-uri "relude" version))
|
||||
(sha256
|
||||
(base32
|
||||
"02dn99v2qmykj0l1qmn15k36hyxccy71b7iqavfk24zgjf5g07dm"))))
|
||||
(build-system haskell-build-system)
|
||||
(inputs (list ghc-hashable ghc-unordered-containers))
|
||||
(native-inputs (list ghc-hedgehog ghc-doctest-20 ghc-glob))
|
||||
(home-page "https://github.com/kowainik/relude")
|
||||
(synopsis
|
||||
"Safe, performant, user-friendly and lightweight Haskell Standard Library")
|
||||
(description
|
||||
"@__relude__@ is an alternative prelude library. If you find the default
|
||||
@Prelude@ unsatisfying, despite its advantages, consider using @relude@ instead.
|
||||
== Relude goals and design principles * __Productivity.__ You can be more
|
||||
productive with a \"non-standard\" standard library, and @relude@ helps you with
|
||||
writing safer and more efficient code faster. * __Total programming__. Usage of
|
||||
[/partial
|
||||
functions/](https://www.reddit.com/r/haskell/comments/5n51u3/why_are_partial_functions_as_in_head_tail_bad/)
|
||||
can lead to unexpected bugs and runtime exceptions in pure code. The types of
|
||||
partial functions lie about their behaviour. And even if it is not always
|
||||
possible to rely only on total functions, @relude@ strives to encourage
|
||||
best-practices and reduce the chances of introducing a bug.
|
||||
+---------------------------------+--------------------------------------------+
|
||||
| __Partial__ | __Total__ |
|
||||
+=================================+============================================+
|
||||
| @head :: [a] -> a@ | @head :: NonEmpty a -> a@ |
|
||||
+---------------------------------+--------------------------------------------+
|
||||
| @tail :: [a] -> [a]@ | @tail :: NonEmpty a -> [a]@ |
|
||||
+---------------------------------+--------------------------------------------+
|
||||
| @read :: Read a => String -> a@ | @readMaybe :: Read a => String -> Maybe a@ |
|
||||
+---------------------------------+--------------------------------------------+
|
||||
| @fromJust :: Maybe a -> a@ | @fromMaybe :: a -> Maybe a -> a@ |
|
||||
+---------------------------------+--------------------------------------------+
|
||||
* __Type-safety__. We use the /\"make invalid states unrepresentable\"/ motto as
|
||||
one of our guiding principles. If it is possible, we express this concept
|
||||
through the types. /Example:/ @ whenNotNull :: Applicative f => [a] -> (NonEmpty
|
||||
a -> f ()) -> f () @ * __Performance.__ We prefer @Text@ over
|
||||
@[String](https://www.reddit.com/r/haskell/comments/29jw0s/whats_wrong_with_string/)@,
|
||||
use space-leaks-free functions (e.g. our custom performant @sum@ and
|
||||
@product@), introduce @\\{\\-\\# INLINE \\#\\-\\}@ and @\\{\\-\\# SPECIALIZE \\#\\-\\}@
|
||||
pragmas where appropriate, and make efficient container types (e.g. @Map@,
|
||||
@HashMap@, @Set@) more accessible. * __Minimalism__ (low number of
|
||||
dependencies). We do not force users of @relude@ to stick to any specific lens
|
||||
or text formatting or logging library. Where possible, @relude@ depends only on
|
||||
boot libraries. The [Dependency
|
||||
graph](https://raw.githubusercontent.com/kowainik/relude/main/relude-dependency-graph.png)
|
||||
of @relude@ can give you a clearer picture. * __Convenience__. Despite
|
||||
minimalism, we want to bring commonly used types and functions into scope, and
|
||||
make available functions easier to use. Some examples of conveniences: 1. No
|
||||
need to add @containers@, @unordered-containers@, @text@ and @bytestring@ to
|
||||
dependencies in your @.cabal@ file to use the main API of these libraries 2. No
|
||||
need to import types like @NonEmpty@, @Text@, @Set@, @Reader[T]@, @MVar@, @STM@
|
||||
3. Functions like @liftIO@, @fromMaybe@, @sortWith@ are available by default as
|
||||
well 4. @IO@ actions are lifted to @MonadIO@ * __Excellent documentation.__ 1.
|
||||
Tutorial 2. Migration guide from @Prelude@ 3. Haddock for every function with
|
||||
examples tested by [doctest](http://hackage.haskell.org/package/doctest). 4.
|
||||
Documentation regarding [internal module
|
||||
structure](http://hackage.haskell.org/package/relude/docs/Relude.html) 5.
|
||||
@relude@-specific [HLint](http://hackage.haskell.org/package/hlint) rules:
|
||||
@[.hlint.yaml](https://github.com/kowainik/relude/blob/main/.hlint.yaml)@ *
|
||||
__User-friendliness.__ Anyone should be able to quickly migrate to @relude@.
|
||||
Only some basic familiarity with the common libraries like @text@ and
|
||||
@containers@ should be enough (but not necessary). * __Exploration.__ We have
|
||||
space to experiment with new ideas and proposals without introducing breaking
|
||||
changes. @relude@ uses the approach with @Extra.*@ modules which are not
|
||||
exported by default. The chosen approach makes it quite easy for us to provide
|
||||
new functionality without breaking anything and let the users decide to use it
|
||||
or not.")
|
||||
(license license:expat)))
|
||||
|
||||
(define-public ghc-doctest-20
|
||||
(package
|
||||
(name "ghc-doctest-20")
|
||||
(version "0.20.0")
|
||||
(source (origin
|
||||
(method url-fetch)
|
||||
(uri (hackage-uri "doctest" version))
|
||||
(sha256
|
||||
(base32
|
||||
"0sk50b8zxq4hvc8qphlmfha1lsv3xha7q7ka081jgswf1qpg34y4"))))
|
||||
(build-system haskell-build-system)
|
||||
(inputs (list ghc-base-compat ghc-code-page ghc-paths ghc-syb))
|
||||
(native-inputs (list ghc-hunit
|
||||
ghc-quickcheck
|
||||
ghc-hspec
|
||||
hspec-discover
|
||||
ghc-hspec-core
|
||||
ghc-mockery
|
||||
ghc-setenv
|
||||
ghc-silently
|
||||
ghc-stringbuilder))
|
||||
(arguments
|
||||
`(#:cabal-revision ("5"
|
||||
"0d7xgi71zdfbg3an6v2ss4lj6lvlmvq36hy788nd94ja2bgfsmpx")))
|
||||
(home-page "https://github.com/sol/doctest#readme")
|
||||
(synopsis "Test interactive Haskell examples")
|
||||
(description
|
||||
"`doctest` is a tool that checks
|
||||
[examples](https://www.haskell.org/haddock/doc/html/ch03s08.html#idm140354810775744)
|
||||
and
|
||||
[properties](https://www.haskell.org/haddock/doc/html/ch03s08.html#idm140354810771856)
|
||||
in Haddock comments. It is similar in spirit to the [popular Python module with
|
||||
the same name](https://docs.python.org/3/library/doctest.html). . Documentation
|
||||
is at <https://github.com/sol/doctest#readme>.")
|
||||
(license license:expat)))
|
||||
|
||||
(define-public ghc-morpheus-graphql-code-gen-utils
|
||||
(package
|
||||
(name "ghc-morpheus-graphql-code-gen-utils")
|
||||
(version "0.27.0")
|
||||
(source (origin
|
||||
(method url-fetch)
|
||||
(uri (hackage-uri "morpheus-graphql-code-gen-utils" version))
|
||||
(sha256
|
||||
(base32
|
||||
"1d5v3b63v9yf47a5hh2am6v09hwm4sfxls38iwvlxva3km0s1qgn"))))
|
||||
(build-system haskell-build-system)
|
||||
(inputs (list ghc-morpheus-graphql-core ghc-prettyprinter ghc-relude
|
||||
ghc-unordered-containers))
|
||||
(home-page "https://morpheusgraphql.com")
|
||||
(synopsis "Morpheus GraphQL CLI")
|
||||
(description "code generator for Morpheus GraphQL")
|
||||
(license license:bsd-3)))
|
||||
|
||||
(define-public ghc-wuss
|
||||
(package
|
||||
(name "ghc-wuss")
|
||||
(version "2.0.0.2")
|
||||
(source (origin
|
||||
(method url-fetch)
|
||||
(uri (hackage-uri "wuss" version))
|
||||
(sha256
|
||||
(base32
|
||||
"04jqq3blzyxqyymhcjsm5z89whk5y7cvnd9dw6nlc40vq4w4v802"))))
|
||||
(build-system haskell-build-system)
|
||||
(inputs (list ghc-connection ghc-network ghc-websockets))
|
||||
(home-page "http://hackage.haskell.org/package/wuss")
|
||||
(synopsis "Secure WebSocket (WSS) clients")
|
||||
(description
|
||||
"Wuss is a library that lets you easily create secure WebSocket clients over the
|
||||
WSS protocol. It is a small addition to
|
||||
<https://hackage.haskell.org/package/websockets the websockets package> and is
|
||||
adapted from existing solutions by <https://gist.github.com/jaspervdj/7198388
|
||||
@@jaspervdj>, <https://gist.github.com/mpickering/f1b7ba3190a4bb5884f3
|
||||
@@mpickering>, and <https://gist.github.com/elfenlaid/7b5c28065e67e4cf0767
|
||||
@@elfenlaid>.")
|
||||
(license license:expat)))
|
||||
|
||||
(define-public ghc-rsa
|
||||
(package
|
||||
(name "ghc-rsa")
|
||||
(version "2.4.1")
|
||||
(source (origin
|
||||
(method url-fetch)
|
||||
(uri (hackage-uri "RSA" version))
|
||||
(sha256
|
||||
(base32
|
||||
"0hchsqrxpfw7mqrqwscfy8ig1w2di6w3nxpzi873w0gibv2diibj"))))
|
||||
(build-system haskell-build-system)
|
||||
(inputs (list ghc-crypto-api ghc-crypto-pubkey-types ghc-sha))
|
||||
(native-inputs (list ghc-quickcheck ghc-tagged ghc-test-framework
|
||||
ghc-test-framework-quickcheck2))
|
||||
(home-page "http://hackage.haskell.org/package/RSA")
|
||||
(synopsis
|
||||
"Implementation of RSA, using the padding schemes of PKCS#1 v2.1.")
|
||||
(description
|
||||
"This library implements the RSA encryption and signature algorithms for
|
||||
arbitrarily-sized ByteStrings. While the implementations work, they are not
|
||||
necessarily the fastest ones on the planet. Particularly key generation. The
|
||||
algorithms included are based of RFC 3447, or the Public-Key Cryptography
|
||||
Standard for RSA, version 2.1 (a.k.a, PKCS#1 v2.1).")
|
||||
(license license:bsd-3)))
|
||||
|
||||
(define-public ghc-crypto-pubkey-types
|
||||
(package
|
||||
(name "ghc-crypto-pubkey-types")
|
||||
(version "0.4.3")
|
||||
(source (origin
|
||||
(method url-fetch)
|
||||
(uri (hackage-uri "crypto-pubkey-types" version))
|
||||
(sha256
|
||||
(base32
|
||||
"0q0wlzjmpx536h1zcdzrpxjkvqw8abj8z0ci38138kpch4igbnby"))))
|
||||
(build-system haskell-build-system)
|
||||
(inputs (list ghc-asn1-types ghc-asn1-encoding))
|
||||
(home-page "http://github.com/vincenthz/hs-crypto-pubkey-types")
|
||||
(synopsis "Generic cryptography Public keys algorithm types")
|
||||
(description "Generic cryptography public keys algorithm types")
|
||||
(license license:bsd-3)))
|
||||
|
||||
(define-public ghc-authenticate-oauth
|
||||
(package
|
||||
(name "ghc-authenticate-oauth")
|
||||
(version "1.7")
|
||||
(source (origin
|
||||
(method url-fetch)
|
||||
(uri (hackage-uri "authenticate-oauth" version))
|
||||
(sha256
|
||||
(base32
|
||||
"0y4v46rn0cvm0sr1v8qq1zgzllrlrr3ji5gij1xprgf1zsazcvvl"))))
|
||||
(build-system haskell-build-system)
|
||||
(inputs (list ghc-http-client
|
||||
ghc-crypto-pubkey-types
|
||||
ghc-rsa
|
||||
ghc-data-default
|
||||
ghc-sha
|
||||
ghc-random
|
||||
ghc-http-types
|
||||
ghc-blaze-builder
|
||||
ghc-transformers-compat))
|
||||
(home-page "http://github.com/yesodweb/authenticate")
|
||||
(synopsis
|
||||
"Library to authenticate with OAuth for Haskell web applications.")
|
||||
(description "API docs and the README are available at
|
||||
<http://www.stackage.org/package/authenticate-oauth>.")
|
||||
(license license:bsd-3)))
|
||||
|
||||
(define-public ghc-req
|
||||
(package
|
||||
(name "ghc-req")
|
||||
(version "3.12.0")
|
||||
(source (origin
|
||||
(method url-fetch)
|
||||
(uri (hackage-uri "req" version))
|
||||
(sha256
|
||||
(base32
|
||||
"1gwdqmqmj3acim5r8c4sjzcvr3hvlbcjwkrpcsvq95ckr1wmzpqp"))))
|
||||
(build-system haskell-build-system)
|
||||
(inputs (list ghc-aeson
|
||||
ghc-authenticate-oauth
|
||||
ghc-blaze-builder
|
||||
ghc-case-insensitive
|
||||
ghc-connection
|
||||
ghc-http-api-data
|
||||
ghc-http-client
|
||||
ghc-http-client-tls
|
||||
ghc-http-types
|
||||
ghc-modern-uri
|
||||
ghc-monad-control
|
||||
ghc-retry
|
||||
ghc-transformers-base
|
||||
ghc-unliftio-core))
|
||||
(native-inputs (list ghc-quickcheck ghc-hspec hspec-discover ghc-hspec-core
|
||||
ghc-quickcheck ghc-hspec))
|
||||
(arguments '(#:tests? #f))
|
||||
(home-page "https://github.com/mrkkrp/req")
|
||||
(synopsis "HTTP client library")
|
||||
(description "HTTP client library.")
|
||||
(license license:bsd-3)))
|
||||
|
||||
(define-public ghc-modern-uri
|
||||
(package
|
||||
(name "ghc-modern-uri")
|
||||
(version "0.3.4.4")
|
||||
(source (origin
|
||||
(method url-fetch)
|
||||
(uri (hackage-uri "modern-uri" version))
|
||||
(sha256
|
||||
(base32
|
||||
"19fffy7kb7ibajagdryjy872x56045zi6c1div8wvr8aisd55qsz"))))
|
||||
(build-system haskell-build-system)
|
||||
(inputs (list ghc-quickcheck
|
||||
ghc-contravariant
|
||||
ghc-hashable
|
||||
ghc-megaparsec
|
||||
ghc-profunctors
|
||||
ghc-reflection
|
||||
ghc-tagged))
|
||||
(native-inputs (list ghc-hspec hspec-discover ghc-hspec-megaparsec))
|
||||
(home-page "https://github.com/mrkkrp/modern-uri")
|
||||
(synopsis "Modern library for working with URIs")
|
||||
(description "Modern library for working with URIs.")
|
||||
(license license:bsd-3)))
|
||||
|
||||
(define-public ghc-mime-mail
|
||||
(package
|
||||
(name "ghc-mime-mail")
|
||||
(version "0.5.1")
|
||||
(source (origin
|
||||
(method url-fetch)
|
||||
(uri (hackage-uri "mime-mail" version))
|
||||
(sha256
|
||||
(base32
|
||||
"1s1wp8v1xlvw3r4qk1lv9zpm99ihka7a785zjl6i3fq1maqq955g"))))
|
||||
(build-system haskell-build-system)
|
||||
(inputs (list ghc-base64-bytestring ghc-random ghc-blaze-builder))
|
||||
(native-inputs (list ghc-hspec hspec-discover))
|
||||
(home-page "http://github.com/snoyberg/mime-mail")
|
||||
(synopsis "Compose MIME email messages.")
|
||||
(description
|
||||
"Hackage documentation generation is not reliable. For up to date documentation,
|
||||
please see: <http://www.stackage.org/package/mime-mail>.")
|
||||
(license license:expat)))
|
||||
|
||||
(define-public ghc-smtp-mail
|
||||
(package
|
||||
(name "ghc-smtp-mail")
|
||||
(version "0.3.0.0")
|
||||
(source (origin
|
||||
(method url-fetch)
|
||||
(uri (hackage-uri "smtp-mail" version))
|
||||
(sha256
|
||||
(base32
|
||||
"0q81m4mi43cd0f1azm6984xw3qw2s6ygszdn86j5z3g5sjj5dax4"))))
|
||||
(build-system haskell-build-system)
|
||||
(inputs (list ghc-base16-bytestring
|
||||
ghc-base64-bytestring
|
||||
ghc-connection
|
||||
ghc-mime-mail
|
||||
ghc-network
|
||||
ghc-network-bsd
|
||||
ghc-cryptonite
|
||||
ghc-memory))
|
||||
(home-page "http://github.com/jhickner/smtp-mail")
|
||||
(synopsis "Simple email sending via SMTP")
|
||||
(description
|
||||
"This packages provides a simple interface for mail over SMTP. PLease see the
|
||||
README for more information.")
|
||||
(license license:bsd-3)))
|
||||
|
||||
datarekisteri
|
|
@ -0,0 +1,76 @@
|
|||
scalar Base64
|
||||
|
||||
scalar Date
|
||||
|
||||
scalar Email
|
||||
|
||||
scalar KeyID
|
||||
|
||||
scalar Time
|
||||
|
||||
scalar TokenID
|
||||
|
||||
scalar UserID
|
||||
|
||||
enum Unit {
|
||||
Unit
|
||||
Unit2
|
||||
}
|
||||
|
||||
type PGPKey {
|
||||
id: KeyID!
|
||||
user: User!
|
||||
pgpKeyData: Base64!
|
||||
expires: Time
|
||||
uploaded: Time!
|
||||
comment: String!
|
||||
}
|
||||
|
||||
type Token {
|
||||
id: TokenID!
|
||||
user: User!
|
||||
name: String
|
||||
tokenData: String!
|
||||
comment: String!
|
||||
issued: Time!
|
||||
expires: Time
|
||||
permissions: String!
|
||||
}
|
||||
|
||||
type User {
|
||||
id: UserID!
|
||||
email: Email
|
||||
pendingEmail: Email
|
||||
name: String!
|
||||
nickname: String!
|
||||
birthdate: Date!
|
||||
homeplace: String!
|
||||
registered: Time!
|
||||
accepted: Time
|
||||
seceded: Time
|
||||
permissions: String!
|
||||
isMember: Boolean!
|
||||
application: String!
|
||||
}
|
||||
|
||||
type Query {
|
||||
users: [User!]!
|
||||
user(id: UserID): User
|
||||
applications: [User!]!
|
||||
tokens(user: UserID): [Token!]!
|
||||
keys(user: UserID): [PGPKey!]!
|
||||
permissions: String!
|
||||
primaryKey(user: UserID): PGPKey
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
apply(email: Email!, password: String!, name: String!, nickname: String, birthdate: Date!, homeplace: String!, application: String!): User!
|
||||
verifyEmail(secret: String!): Unit!
|
||||
resendVerificationEmail(user: UserID): Unit!
|
||||
update(email: Email, password: String, name: String, nickname: String, homeplace: String, user: UserID): User!
|
||||
newToken(comment: String, name: String, permissions: String): Token!
|
||||
newKey(comment: String, keyData: Base64!, expires: Time): PGPKey!
|
||||
accept(user: UserID!): Unit!
|
||||
reject(user: UserID!): Unit!
|
||||
}
|
||||
|
Loading…
Reference in New Issue