packages feed

wikimusic-api-1.1.0.1: src/WikiMusic/PostgreSQL/UserCommand.hs

{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedLabels #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE UndecidableInstances #-}
{-# LANGUAGE NoFieldSelectors #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}

module WikiMusic.PostgreSQL.UserCommand () where

import Data.ByteString.Base64 qualified
import Data.Password.Bcrypt
import Data.Text (pack)
import Data.UUID.V4
import Hasql.Decoders as D
import Hasql.Encoders as E
import Hasql.Pool qualified
import Hasql.Session qualified as Session
import Hasql.Statement (Statement (..))
import Relude
import WikiMusic.Free.UserCommand
import WikiMusic.Model.Auth
import WikiMusic.Model.Env
import WikiMusic.PostgreSQL.ReadAbstraction
import WikiMusic.PostgreSQL.WriteAbstraction
import WikiMusic.Protolude

instance Exec UserCommand where
  execAlgebra (MakeResetPasswordLink env email next) = doIfUserFoundByEmail env email (makeToken env) >>= next
  execAlgebra (ChangePasswordByEmail env email password next) = do
    next =<< doIfUserFoundByEmail env email (changePassword env password)
  execAlgebra (InvalidateResetTokenByEmail env email next) = next =<< doIfUserFoundByEmail env email (invalidateToken env)
  execAlgebra (InviteUser env _ email name' role desc next) = next =<< addUser env email name' role desc
  execAlgebra (DeleteUser env _ email next) = next =<< doIfUserFoundByEmail env email (deleteU env)

makeToken :: (MonadIO m) => Env -> UUID -> m (Either UserCommandError Text)
makeToken env identifier = do
  now <- liftIO getCurrentTime
  maybeToken <- maybeGenToken
  first (PersistenceError . pack . Relude.show) maybeToken & either (pure . Left) (doWithToken now)
  where
    doWithToken now token = do
      maybeRowsAff <- hasqlTransaction (env ^. #pool) stmt (identifier, token, now)
      pure $ first fromHasqlUsageError $ token <$ maybeRowsAff
    encoder =
      contrazip3
        (E.param . E.nonNullable $ E.uuid)
        (E.param . E.nonNullable $ E.text)
        (E.param . E.nonNullable $ E.timestamptz)
    stmt = Statement query encoder D.noResult True
    query =
      encodeUtf8
        [trimming|
           UPDATE users SET password_reset_token = $$2, last_edited_at = $$3
           WHERE identifier = $$1
      |]

maybeGenToken :: (MonadIO m) => m (Either UnicodeException Text)
maybeGenToken = do
  l <- pack . Relude.show <$> liftIO nextRandom
  r <- pack . Relude.show <$> liftIO nextRandom
  pure $ decodeUtf8' . Data.ByteString.Base64.encode . encodeUtf8 $ tokenConcat l r
  where
    tokenConcat l r = l <> "$" <> r

invalidateToken :: (MonadIO m) => Env -> UUID -> m (Either UserCommandError ())
invalidateToken env identifier = do
  now <- liftIO getCurrentTime
  maybeRowsAff <- liftIO $ hasqlTransaction (env ^. #pool) stmt (identifier, now)
  pure $ bimap fromHasqlUsageError (const ()) maybeRowsAff
  where
    stmt = Statement query encoder D.noResult True
    encoder =
      contrazip2
        (E.param . E.nonNullable $ E.uuid)
        (E.param . E.nonNullable $ E.timestamptz)
    query =
      encodeUtf8
        [trimming|
        UPDATE users SET password_reset_token = NULL,
        last_edited_at = $$2 WHERE identifier = $$1  
      |]

fromHasqlUsageError :: Hasql.Pool.UsageError -> UserCommandError
fromHasqlUsageError = PersistenceError . pack . Relude.show

changePassword :: (MonadIO m) => Env -> UserPassword -> UUID -> m (Either UserCommandError ())
changePassword env password identifier = do
  hashed <- hashPassword (mkPassword (password ^. #value))
  now <- liftIO getCurrentTime
  new <- liftIO nextRandom

  maybeRowsAff <- liftIO $ hasqlTransaction (env ^. #pool) stmt (unPasswordHash hashed, identifier, now, newToken now new)
  pure $ bimap fromHasqlUsageError (const ()) maybeRowsAff
  where
    stmt = Statement query encoder D.noResult True
    encoder =
      contrazip4
        (E.param . E.nonNullable $ E.text)
        (E.param . E.nonNullable $ E.uuid)
        (E.param . E.nonNullable $ E.timestamptz)
        (E.param . E.nonNullable $ E.text)
    query =
      encodeUtf8
        [trimming|
        UPDATE users SET password_hash = $$1, last_edited_at = $$3,
        auth_token = $$4 WHERE identifier = $$2
      |]

newToken :: UTCTime -> UUID -> Text
newToken now new =
  decodeUtf8
    . Data.ByteString.Base64.encode
    . encodeUtf8
    $ (pack . WikiMusic.Protolude.show $ now)
      <> "--"
      <> (pack . WikiMusic.Protolude.show $ new)

addUser :: (MonadIO m) => Env -> UserEmail -> UserName -> UserRole -> Maybe Text -> m (Either UserCommandError Text)
addUser env email name' role desc = do
  now <- liftIO getCurrentTime
  identifier <- liftIO nextRandom
  new <- liftIO nextRandom
  maybeRowsAff <-
    liftIO $
      hasqlTransaction (env ^. #pool) stmt (identifier, name' ^. #value, email ^. #value, Nothing, now, Just (newToken now new), desc)
  let creation = bimap fromHasqlUsageError (const ()) maybeRowsAff
  case creation of
    Left e -> pure . Left $ e
    Right _ -> do
      someUUID <- liftIO nextRandom
      maybeRowsAff' <-
        liftIO $
          hasqlTransaction (env ^. #pool) roleStmt (someUUID, identifier, pack . Relude.show $ role, now)
      let rol = bimap fromHasqlUsageError (const ()) maybeRowsAff'
      case rol of
        Left e -> pure . Left $ e
        Right _ -> doIfUserFoundByEmail env email (makeToken env)
  where
    stmt = Statement query encoder D.noResult True
    roleStmt = Statement roleQuery roleEncoder D.noResult True
    encoder =
      contrazip7
        (E.param . E.nonNullable $ E.uuid)
        (E.param . E.nonNullable $ E.text)
        (E.param . E.nonNullable $ E.text)
        (E.param . E.nullable $ E.text)
        (E.param . E.nonNullable $ E.timestamptz)
        (E.param . E.nullable $ E.text)
        (E.param . E.nullable $ E.text)
    roleEncoder =
      contrazip4
        (E.param . E.nonNullable $ E.uuid)
        (E.param . E.nonNullable $ E.uuid)
        (E.param . E.nonNullable $ E.text)
        (E.param . E.nonNullable $ E.timestamptz)
    bindParams' = bindParams 7
    roleBindParams' = bindParams 4
    query =
      encodeUtf8
        [trimming|
                 INSERT INTO users (identifier, display_name, email_address, password_hash, created_at, auth_token, description)
                 VALUES ( $bindParams' )
                 |]
    roleQuery =
      encodeUtf8
        [trimming|
               INSERT INTO user_roles (identifier, user_identifier, role_id, created_at)
               VALUES( $roleBindParams' )
      |]

doIfUserFoundByEmail :: (MonadIO m) => Env -> UserEmail -> (UUID -> m (Either UserCommandError a)) -> m (Either UserCommandError a)
doIfUserFoundByEmail env email eff = do
  maybeFoundByEmail <- liftIO $ Hasql.Pool.use (env ^. #pool) (Session.statement (email ^. #value) stmt)
  first fromHasqlUsageError maybeFoundByEmail & either (pure . Left) doWithMaybeIdentifier
  where
    doWithMaybeIdentifier = maybe (pure . Left . LogicError $ "User could not be found!") eff
    encoder = E.param . E.nonNullable $ E.text
    stmt = Statement query encoder (D.rowMaybe . D.column . D.nonNullable $ D.uuid) True
    query =
      encodeUtf8
        [trimming|
        SELECT identifier FROM users WHERE email_address = $$1 LIMIT 1
      |]

deleteU :: (MonadIO m) => Env -> UUID -> m (Either UserCommandError ())
deleteU env identifier = do
  newUid <- liftIO nextRandom
  maybeRowsAff <- liftIO $ hasqlTransaction (env ^. #pool) stmt (identifier, Relude.show newUid)
  pure $ bimap fromHasqlUsageError (const ()) maybeRowsAff
  where
    encoder =
      contrazip2
        (E.param . E.nonNullable $ E.uuid)
        (E.param . E.nonNullable $ E.text)
    stmt = Statement query encoder D.noResult True
    query =
      encodeUtf8
        [trimming|
     UPDATE users SET password_hash = '', auth_token = '', email_address = $$2
     WHERE identifier = $$1
     |]