Files
simplex-chat/apps/simplex-badge-service/src/BadgeService/Config.hs
T
19e70faeec badges: webapp feature branch (#7548)
* badges: webapp (#7433)

* badges: service migrations, store and catalog

* badges: BTCPay provider and settlement poller

* badges: web listener and /api endpoints

* web: checkout single-page app

* badges: tests and BTCPay fixtures

* badges: README and ini reference

* badges: fix hex16 build on GHC 8.10.7

* badges: Stripe card lane

* badges: fix Stripe card checkout, add theming

* badges: add a discount row to the order summary

* badges: site navbar, embedding, theme, Forget move

* badges: use SB code prefix in web checkout

* badges: rename sxb app namespace to sb

* badges: embed checkout nav via site; keep original app navbar

* badges: post iframe height, apply site background when embedded

* badges: embed dark surfaces, steadier iframe height

* badges: hide app footer when embedded

* badges: size embedded body to content, not viewport

* badges: declare color-scheme to stop reload flash

* badges: fade shell in on load, no reload blank

* badges: prerender app shell into index.html

* badges: pre-paint theme, hide shell on deep reload

* badges: logo returns to landing client-side

* badges: embedded wizard back, buy-a-code, resume

* badges: signal app-managed screens, resume across reload

* badges: rebuild wizard history on deep load so Back walks it

* badges: carry welcome-page height as the iframe floor

* badges: keep selection on Buy a code; rename to Your codes

* badges: read web shell as UTF-8, not locale

* badges: resume the exact paid order after Stripe card redirect

* badges: move docker deploy under scripts

* badges: add serve_webapp toggle and webapp export

* badges: wire split webapp deploy in docker config

* badges: quiet agent logs by default

* badges: resume card redirect in the embedded frame

* badges: migrate Stripe adapter to PaymentIntents

* badges: correct Stripe restricted key scopes in ini example

* badges: card via Payment Element and PaymentIntents

* badges: fix stale Checkout Session wording in Stripe adapter

* badges: fix stale CheckoutActions reference in card comment

* badges: order shell stylesheet before bootstrap script

* badges: remove development card stand-in

* badges: theme the Stripe card form with the site palette

* badges: exclude web from the Haskell build stage

* badges: unify invoice cancel and mark canceled

* badges: default log level to info

* badges: unify closed-invoice buy-again button

* badges: mute agent connection logs at info level

* badges: show purchase time in local timezone in Your codes

* badges: log service events on own channel, quiet agent

* badges: fold service migrations into one baseline

* badges: run compose on postgres over host network

* badges: use high-res hero art

* badges: add web CI to catch stale builds

* badges: rebuild web shell from committed source

* badges: normalize invoice-code link and columns

* badges: drop unused columns, rename index

* badges: note deferred receipt_hash in migrations

* badges: apply code-review fixes

* badges: reduce comments across service and web

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: improve web page (#7546)

* badges: improve web page

* improve layout

* improve layout

* fix

* small changes

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: read one issuer key from the ini

* badges: move and group the service tests

* badges: service fixes (#7567)

* badges: match the redeem error wording in tests

* badges: drop unused imports in the bot tests

* badges: cancel Stripe orders when they expire

* badges: correct the Stripe config and docs

* badges: refuse to revoke a redeemed code

* badges: make the fake Stripe cancel like Stripe

* badges: limit replayed webhook deliveries

---------

Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
Co-authored-by: shum <github.shum@liber.li>
Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2026-09-25 09:01:51 +00:00

285 lines
11 KiB
Haskell

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
module BadgeService.Config
( ListenerConfig (..),
BTCPayConfig (..),
StripeConfig (..),
SpeedPolicy (..),
speedPolicyName,
PollConfig (..),
BadgeIssuerKey (..),
ServiceConfig (..),
defaultExpiryMinutes,
defaultSessionMinutes,
readServiceConfig,
unknownKeys,
)
where
import qualified Control.Exception as E
import BadgeService.Log (logWarn)
import Data.Attoparsec.Text (Parser, endOfInput, isEndOfLine, parseOnly, satisfy, skipMany, skipSpace, skipWhile)
import qualified Data.ByteString.Char8 as B
import Data.Ini (Ini, iniGlobals, iniParser, keys, lookupValue, sections)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import Simplex.Messaging.Crypto.BBS (BBSSecretKey)
import Simplex.Messaging.Encoding.String (strDecode)
import System.IO.Error (ioeGetErrorString)
import Text.Read (readMaybe)
data ListenerConfig = ListenerConfig
{ lHost :: Text,
lPort :: Int,
lStaticDir :: FilePath,
lServeWebapp :: Bool,
lWebappExportDir :: Maybe FilePath,
lTrustForwardedFor :: Bool
}
deriving (Eq, Show)
-- | Sent by name, since BTCPay's numbering is not in speed order.
data SpeedPolicy = HighSpeed | MediumSpeed | LowMediumSpeed | LowSpeed
deriving (Bounded, Enum, Eq, Show)
speedPolicies :: [SpeedPolicy]
speedPolicies = [minBound .. maxBound]
speedPolicyName :: SpeedPolicy -> Text
speedPolicyName = \case
HighSpeed -> "HighSpeed"
MediumSpeed -> "MediumSpeed"
LowMediumSpeed -> "LowMediumSpeed"
LowSpeed -> "LowSpeed"
data BTCPayConfig = BTCPayConfig
{ bHost :: Text,
bApiKey :: Text,
bStoreId :: Text,
bWebhookSecret :: Text,
bExpiryMinutes :: Int,
bSpeedPolicy :: SpeedPolicy,
bPaymentTolerance :: Double
}
deriving (Eq)
-- Hand-written to keep the api key and webhook secret out of logs.
instance Show BTCPayConfig where
show BTCPayConfig {bHost, bStoreId} = "btcpay " <> T.unpack bHost <> " store " <> T.unpack bStoreId
data StripeConfig = StripeConfig
{ sSecretKey :: Text,
sPublishableKey :: Text,
sWebhookSecret :: Text,
sSessionMinutes :: Int,
sHost :: Text
}
deriving (Eq)
-- Hand-written to keep the restricted key and signing secret out of logs.
instance Show StripeConfig where
show StripeConfig {sHost} = "stripe " <> T.unpack sHost
data PollConfig = PollConfig {pWaitingSeconds :: Int, pIdleSeconds :: Int}
deriving (Eq, Show)
data BadgeIssuerKey = BadgeIssuerKey
{ keyIdx :: Int,
secretKey :: BBSSecretKey
}
deriving (Eq)
-- BBSSecretKey derives Show, so this is written out to keep the secret out of logs and errors
instance Show BadgeIssuerKey where
show BadgeIssuerKey {keyIdx} = "issuer key " <> show keyIdx
data ServiceConfig = ServiceConfig
{ listener :: ListenerConfig,
btcpay :: Maybe BTCPayConfig,
stripe :: Maybe StripeConfig,
poll :: PollConfig,
issuer :: Maybe BadgeIssuerKey,
-- Local testing only; signs credentials with a master key this service can link.
devChatRedeem :: Bool
}
deriving (Eq, Show)
defaultExpiryMinutes :: Int
defaultExpiryMinutes = 60
defaultStripeHost :: Text
defaultStripeHost = "https://api.stripe.com"
defaultSessionMinutes :: Int
defaultSessionMinutes = 60
-- | At most one day, so a card order expires long before the poller stops checking it at 72 hours.
minSessionMinutes, maxSessionMinutes :: Int
minSessionMinutes = 1
maxSessionMinutes = 1440
-- | An http host would carry the API key in the clear on every call.
requireHttps :: Text -> Either String Text
requireHttps u
| "https://" `T.isPrefixOf` u = Right u
| otherwise = Left "btcpay.host must be an absolute https URL"
-- | Well below 100, where BTCPay settles an invoice for one satoshi.
maxTolerance :: Double
maxTolerance = 10
readServiceConfig :: FilePath -> IO (Either String ServiceConfig)
readServiceConfig path =
E.try (TIO.readFile path) >>= \case
Left (e :: E.IOException) -> pure (Left (ioeGetErrorString e))
-- Data.Ini stops at the first unparseable line and keeps what it has, so a missing `=` silently drops every section below.
Right text -> case parseOnly (iniParser <* trailingNoise <* endOfInput) text of
Left _ -> pure (Left "could not be read as an ini file: a line is malformed")
Right ini -> do
mapM_ (\k -> logWarn (T.pack path <> ": nothing reads " <> k <> ", so it was ignored")) (unknownKeys ini)
pure (parseConfig ini)
-- | iniParser stops before a trailing comment, so endOfInput alone would reject a valid file.
trailingNoise :: Parser ()
trailingNoise = skipSpace *> skipMany (comment *> skipSpace)
where
comment = satisfy (\c -> c == ';' || c == '#') *> skipWhile (not . isEndOfLine)
knownSettings :: [(Text, [Text])]
knownSettings =
[ ("listener", ["host", "port", "static_dir", "serve_webapp", "webapp_export_dir", "trust_forwarded_for"]),
("btcpay", ["host", "api_key", "store_id", "webhook_secret", "expiry_minutes", "speed_policy", "payment_tolerance"]),
("stripe", ["secret_key", "publishable_key", "webhook_secret", "session_minutes"]),
("poll", ["waiting_seconds", "idle_seconds"]),
("dev", ["chat_redeem"]),
("issuer", ["index", "private_key"])
]
unknownKeys :: Ini -> [Text]
unknownKeys ini = beforeAnySection <> unknownSections <> settings
where
beforeAnySection = [k <> ", written above the first section header" | (k, _) <- iniGlobals ini]
unknownSections = ["[" <> s <> "]" | s <- sections ini, T.strip s `notElem` map fst knownSettings]
settings =
[ section <> "." <> key
| (section, known) <- knownSettings,
key <- either (const []) id (keys section ini),
T.strip key `notElem` known
]
parseConfig :: Ini -> Either String ServiceConfig
parseConfig ini = do
lStaticDir <- T.unpack <$> required "listener" "static_dir"
lHost <- optional "listener" "host" "127.0.0.1"
lPort <- do
p <- num "listener" "port" 8080
if 1 <= p && p <= 65535 then Right p else Left "listener.port must be between 1 and 65535"
lServeWebapp <- bool "listener" "serve_webapp" True
let lWebappExportDir = case fmap T.strip (look "listener" "webapp_export_dir") of
Just v | not (T.null v) -> Just (T.unpack v)
_ -> Nothing
lTrustForwardedFor <- bool "listener" "trust_forwarded_for" False
btc <- btcpaySection
str <- stripeSection
iss <- issuerSection
pWaitingSeconds <- cadence "waiting_seconds" 3
pIdleSeconds <- cadence "idle_seconds" 60
devRedeem <- bool "dev" "chat_redeem" False
pure
ServiceConfig
{ listener = ListenerConfig {lHost, lPort, lStaticDir, lServeWebapp, lWebappExportDir, lTrustForwardedFor},
btcpay = btc,
stripe = str,
poll = PollConfig {pWaitingSeconds, pIdleSeconds},
issuer = iss,
devChatRedeem = devRedeem
}
where
hasSection s = s `elem` sections ini
look s k = either (const Nothing) Just (lookupValue s k ini)
required s k = case look s k of
Just v | not (T.null (T.strip v)) -> Right (T.strip v)
_ -> Left (T.unpack s <> "." <> T.unpack k <> " is required")
optional s k d = case fmap T.strip (look s k) of
Just v | not (T.null v) -> Right v
_ -> Right d
-- Integer, because readMaybe at Int wraps silently, reading 2^64+4 as 4.
num s k d = case look s k of
Nothing -> Right d
Just v -> case readMaybe (T.unpack (T.strip v)) of
Just n | n >= toInteger (minBound :: Int), n <= toInteger (maxBound :: Int) -> Right (fromInteger n)
_ -> Left (T.unpack k <> " must be a whole number")
bool s k d = case fmap (T.toLower . T.strip) (look s k) of
Nothing -> Right d
Just "on" -> Right True
Just "off" -> Right False
Just other -> Left (T.unpack k <> " must be on or off, not " <> T.unpack other)
-- num accepts 0 and negatives, and a zero cadence is a busy loop.
cadence k d = do
v <- num "poll" k d
if v >= 1 then Right v else Left ("poll." <> T.unpack k <> " must be at least 1 second")
issuerSection
| not (hasSection "issuer") = Right Nothing
| otherwise = do
keyIdx <- issuerIndex
secretKey <- issuerSecret
pure (Just BadgeIssuerKey {keyIdx, secretKey})
issuerIndex = do
v <- required "issuer" "index"
case readMaybe (T.unpack v) of
Just n | n >= 1, n <= toInteger (maxBound :: Int) -> Right (fromInteger n)
_ -> Left "issuer.index must be a positive whole number"
issuerSecret = do
v <- required "issuer" "private_key"
either (const (Left "issuer.private_key is not a valid issuer secret; use the value from `simplex-chat badge keygen`")) Right $
strDecode (B.pack (T.unpack v))
btcpaySection
| not (hasSection "btcpay") = Right Nothing
| otherwise = do
bHost <- required "btcpay" "host" >>= requireHttps
bApiKey <- required "btcpay" "api_key"
bStoreId <- required "btcpay" "store_id"
bWebhookSecret <- required "btcpay" "webhook_secret"
bExpiryMinutes <- expiryMinutes
bSpeedPolicy <- speedPolicy
bPaymentTolerance <- tolerance
pure (Just BTCPayConfig {bHost, bApiKey, bStoreId, bWebhookSecret, bExpiryMinutes, bSpeedPolicy, bPaymentTolerance})
-- A negative window puts BTCPay's startDate in the future, so every poll comes back empty.
expiryMinutes = do
v <- num "btcpay" "expiry_minutes" defaultExpiryMinutes
if v >= 1 then Right v else Left "btcpay.expiry_minutes must be at least 1 minute"
speedPolicy = case look "btcpay" "speed_policy" of
Nothing -> Right MediumSpeed
Just v -> case lookup (T.strip v) [(speedPolicyName p, p) | p <- speedPolicies] of
Just p -> Right p
Nothing ->
Left
( "btcpay.speed_policy must be one of "
<> T.unpack (T.intercalate ", " (map speedPolicyName speedPolicies))
<> ", not "
<> T.unpack (T.strip v)
)
tolerance = case look "btcpay" "payment_tolerance" of
Nothing -> Right 0.5
Just v -> case readMaybe (T.unpack (T.strip v)) of
Just d | d >= 0 && d <= maxTolerance -> Right d
_ -> Left ("btcpay.payment_tolerance must be a percentage between 0 and " <> show maxTolerance)
stripeSection
| not (hasSection "stripe") = Right Nothing
| otherwise = do
sSecretKey <- required "stripe" "secret_key"
sPublishableKey <- required "stripe" "publishable_key"
sWebhookSecret <- required "stripe" "webhook_secret"
sSessionMinutes <- sessionMinutes
let sHost = defaultStripeHost
pure (Just StripeConfig {sSecretKey, sPublishableKey, sWebhookSecret, sSessionMinutes, sHost})
sessionMinutes = do
v <- num "stripe" "session_minutes" defaultSessionMinutes
if v >= minSessionMinutes && v <= maxSessionMinutes
then Right v
else Left ("stripe.session_minutes must be between " <> show minSessionMinutes <> " and " <> show maxSessionMinutes <> " minutes")