packages feed

hercules-ci-cli-0.2.2: src/Hercules/CLI/Credentials.hs

{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE DeriveAnyClass #-}

-- | Manages the ~/.config/hercules-ci/credentials.json
module Hercules.CLI.Credentials where

import Control.Lens ((^?))
import Control.Monad.Trans.Maybe (MaybeT (MaybeT, runMaybeT))
import Data.Aeson (FromJSON, ToJSON, eitherDecode)
import qualified Data.Aeson as A
import Data.Aeson.Lens (AsPrimitive (_String), key)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as BL
import qualified Data.Map as M
import qualified Data.Text as T
import Hercules.CLI.Client (determineDefaultApiBaseUrl)
import Hercules.CLI.JSON (writeJsonFile)
import Hercules.Error
import qualified Network.URI as URI
import Protolude
import System.Directory (XdgDirectory (XdgConfig), createDirectoryIfMissing, doesFileExist, getXdgDirectory)
import qualified System.Environment
import System.FilePath (takeDirectory, (</>))

data Credentials = Credentials
  { domains :: Map Text DomainCredentials
  }
  deriving (Eq, Generic, FromJSON, ToJSON)

data DomainCredentials = DomainCredentials
  { personalToken :: Text
  }
  deriving (Eq, Generic, FromJSON, ToJSON)

data CredentialsParsingException = CredentialsParsingException
  { filePath :: FilePath,
    message :: Text
  }
  deriving (Show, Eq)

instance Exception CredentialsParsingException where
  displayException e = "Could not parse credentials file " <> filePath e <> ": " <> toS (message e)

data NoCredentialException = NoCredentialException
  { noCredentialDomain :: Text
  }
  deriving (Show, Eq)

instance Exception NoCredentialException where
  displayException e = "Could not find credentials for domain " <> toS (noCredentialDomain e) <> ". Please run hci login."

data ApiBaseUrlParsingException = ApiBaseUrlParsingException
  { apiBaseUrlParsingMessage :: Text
  }
  deriving (Show, Eq)

instance Exception ApiBaseUrlParsingException where
  displayException e = "Could not parse the api domain: " <> toS (apiBaseUrlParsingMessage e) <> ". Please correct the HERCULES_CI_API_BASE_URL environment variable."

getCredentialsFilePath :: IO FilePath
getCredentialsFilePath = do
  dir <- getXdgDirectory XdgConfig "hercules-ci"
  pure $ dir </> "credentials.json"

readCredentials :: IO Credentials
readCredentials = do
  filePath_ <- getCredentialsFilePath
  doesFileExist filePath_ >>= \case
    False -> pure (Credentials mempty)
    True -> do
      bs <- BS.readFile filePath_
      escalate $ parseCredentials filePath_ bs

parseCredentials :: FilePath -> ByteString -> Either CredentialsParsingException Credentials
parseCredentials filePath_ bs =
  case eitherDecode (BL.fromStrict bs) of
    Right a -> Right a
    Left e -> Left (CredentialsParsingException {filePath = filePath_, message = toS e})

writeCredentials :: Credentials -> IO ()
writeCredentials credentials = do
  filePath_ <- getCredentialsFilePath
  createDirectoryIfMissing True (takeDirectory filePath_)
  writeJsonFile filePath_ credentials

urlDomain :: Text -> Either Text Text
urlDomain urlText = do
  uri <- maybeToEither "could not parse HERCULES_CI_API_BASE_URL" $ URI.parseAbsoluteURI (toS urlText)
  authority <- maybeToEither "HERCULES_CI_API_BASE_URL has no domain/authority part" $ URI.uriAuthority uri
  let name = URI.uriRegName authority
  maybeToEither "HERCULES_CI_API_BASE_URL domain name must not be empty" $ guard (name /= "")
  pure (toS name)

determineDomain :: IO Text
determineDomain = do
  baseUrl <- determineDefaultApiBaseUrl
  escalateAs ApiBaseUrlParsingException (urlDomain baseUrl)

writePersonalToken :: Text -> Text -> IO ()
writePersonalToken domain token = do
  creds <- readCredentials
  let creds' = creds {domains = domains creds & M.insert domain (DomainCredentials token)}
  writeCredentials creds'

readPersonalToken :: Text -> IO Text
readPersonalToken domain = do
  creds <- readCredentials
  case M.lookup domain (domains creds) of
    Nothing -> throwIO NoCredentialException {noCredentialDomain = domain}
    Just cred -> pure (personalToken cred)

tryReadEffectToken :: IO (Maybe Text)
tryReadEffectToken = runMaybeT do
  inEffect <- MaybeT $ System.Environment.lookupEnv "IN_HERCULES_CI_EFFECT"
  guard $ inEffect == "true"
  secretsJsonPath <- MaybeT $ System.Environment.lookupEnv "HERCULES_CI_SECRETS_JSON"
  liftIO do
    bs <- BS.readFile secretsJsonPath
    json <- case eitherDecode (BL.fromStrict bs) of
      Right x -> pure (x :: A.Value)
      Left e -> throwIO $ FatalError $ "HERCULES_CI_SECRETS_JSON, " <> T.pack secretsJsonPath <> " has invalid JSON: " <> T.pack e
    case json ^? key "hercules-ci" . key "data" . key "token" . _String of
      Just x -> pure x
      Nothing -> throwIO $ FatalError $ "HERCULES_CI_SECRETS_JSON, " <> T.pack secretsJsonPath <> " doesn't have key hercules-ci.data.token"

readToken :: Text -> IO Text
readToken domain = do
  tryReadEffectToken >>= \case
    Just x -> pure x
    Nothing -> readPersonalToken domain