core: redeem badge codes (#7438)

This commit is contained in:
spaced4ndy
2026-09-01 12:06:51 +00:00
committed by GitHub
parent ed2cf1d98f
commit 3ddf17dce5
28 changed files with 1408 additions and 96 deletions
+1 -1
View File
@@ -11,4 +11,4 @@ main = do
opts@BadgeServiceOpts {runCLI} <- welcomeGetOpts
if runCLI
then badgeServiceCLI opts
else badgeService opts terminalChatConfig
else newServiceState >>= badgeService opts terminalChatConfig
+33 -3
View File
@@ -5,11 +5,12 @@ Scaffolding for the SimpleX supporter-badge RPC service. The wire protocol is sp
At this stage the service:
- creates a double-ratchet contact address on first start (service RPC requires DR, see [`docs/protocol/badges-rpc.md`](../../docs/protocol/badges-rpc.md)),
- listens for service requests (`CEvtServiceRequest`) on that address and responds to every command with `unsupported_version`,
- listens for service requests (`CEvtServiceRequest`) on that address, rejects a request whose `purchaseKey` is not the key the agent verified the signature against, and answers `redeemBadgeCode`,
- issues redemption codes, storing only their `SHA-256` and printing each code once,
- does not accept contact requests — the address is for RPC only,
- exposes a placeholder schema migration (`sx_badge_service_test`) and its own migrations table (`sx_badge_service_migrations`).
- owns the `sx_badge_service_`-prefixed tables and its own migrations table (`sx_badge_service_migrations`).
Business logic — command dispatch, ledger writes, credential signing, provider webhooks — is left for follow-up per the plans.
Every other command still answers `unsupported_version`. Ledger writes, invoices and provider webhooks are left for follow-up per the plans; a redemption issues one credential and reports an empty statement.
## Build
@@ -28,3 +29,32 @@ simplex-badge-service --help
- default (no `--run-cli`): background service mode, no interactive terminal.
- `--run-cli`: interactive CLI that also processes service requests (mirrors `simplex-directory-service --run-cli`).
- `--no-address`: skip address creation on start-up (for operators who provision the address themselves).
The service cannot sign credentials without an issuer key and refuses to start without one:
- `--issuer-key-idx IDX` — the index the apps find the matching public key under (`badgePublicKeys` in `ChatConfig`).
- `--issuer-secret SECRET` — the issuer secret from `simplex-chat badge keygen`.
The service checks the secret against the configured public key at that index and refuses to start
if they disagree: credentials signed with the wrong key cannot be verified by any client, and the
codes redeemed against them would be spent for nothing.
## Issuing codes
Issuing a code is an operator command sent to the running service in `--run-cli` mode, not a way
to start it — so codes are issued without a second process touching the service's database:
```
//issue <badge_type> [months] [paid|unpaid|free]
//issue supporter 12
```
`months` defaults to 1 and must be between 1 and 255; the status defaults to `free` and records
whether the code was sold (`paid`), is awaiting payment (`unpaid`), or was issued by an operator
(`free`) — redemption never reads it.
The code is printed once and only its `SHA-256` is stored, so a code that is not copied when it is
shown cannot be recovered.
Core parses `//...` into `CustomChatCommand` and leaves it to the service's `preCmdHook`, which is
why issuing codes lives in the service rather than in core.
@@ -5,16 +5,20 @@
module BadgeService.Options
( BadgeServiceOpts (..),
BadgeIssuerKey (..),
getBadgeServiceOpts,
badgeServiceOpts,
mkChatOpts,
)
where
import qualified Data.ByteString.Char8 as B
import qualified Data.Text as T
import Options.Applicative
import Simplex.Chat.Controller (updateStr, versionNumber, versionString)
import Simplex.Chat.Options (ChatCmdLog (..), ChatOpts (..), CoreChatOpts, CreateBotOpts (..), coreChatOptsP)
import Simplex.Messaging.Crypto.BBS (BBSSecretKey)
import Simplex.Messaging.Encoding.String (strDecode)
data BadgeServiceOpts = BadgeServiceOpts
{ coreOptions :: CoreChatOpts,
@@ -22,9 +26,21 @@ data BadgeServiceOpts = BadgeServiceOpts
clientService :: Bool,
noAddress :: Bool,
runCLI :: Bool,
-- the service refuses to start without this: it cannot sign a credential
issuerKey :: Maybe BadgeIssuerKey,
testing :: Bool
}
-- | The issuer secret that signs credentials, and the index the apps find its public half under.
data BadgeIssuerKey = BadgeIssuerKey
{ keyIdx :: Int,
secretKey :: BBSSecretKey
}
-- 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
badgeServiceOpts :: FilePath -> FilePath -> Parser BadgeServiceOpts
badgeServiceOpts appDir defaultDbName = do
coreOptions <- coreChatOptsP appDir defaultDbName
@@ -50,6 +66,22 @@ badgeServiceOpts appDir defaultDbName = do
( long "run-cli"
<> help "Run badge service as CLI"
)
issuerKeyIdx <-
optional $
option
auto
( long "issuer-key-idx"
<> metavar "KEY_IDX"
<> help "Index of the issuer key in the app config (required with --issuer-secret)"
)
issuerSecret <-
optional $
option
(eitherReader $ strDecode . B.pack)
( long "issuer-secret"
<> metavar "ISSUER_SECRET"
<> help "Issuer secret from `simplex-chat badge keygen` (base64url)"
)
pure
BadgeServiceOpts
{ coreOptions,
@@ -57,6 +89,7 @@ badgeServiceOpts appDir defaultDbName = do
clientService,
noAddress,
runCLI,
issuerKey = BadgeIssuerKey <$> issuerKeyIdx <*> issuerSecret,
testing = False
}
@@ -4,36 +4,60 @@
{-# LANGUAGE OverloadedStrings #-}
module BadgeService.Service
( welcomeGetOpts,
( ServiceState (..),
newServiceState,
welcomeGetOpts,
checkIssuerKey,
badgeService,
badgeServiceCLI,
IssueCodeOpts (..),
issueBadgeCode,
)
where
import BadgeService.Options
import BadgeService.Store
import BadgeService.Store.Migrate (runBadgeServiceMigrations)
import Control.Applicative (optional)
import Control.Concurrent.STM
import Control.Logger.Simple
import Control.Monad
import Control.Monad.IO.Class (liftIO)
import qualified Data.Aeson as J
import qualified Data.Aeson.KeyMap as KM
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.ByteString.Char8 (ByteString)
import Data.Char (isSpace)
import Data.Functor (($>))
import Data.Maybe (fromMaybe)
import qualified Data.Map.Strict as M
import qualified Data.Text as T
import Simplex.Chat.Badges.Service (BadgeServiceErrorCode (..))
import Data.Time.Calendar (addDays, addGregorianMonthsClip)
import Data.Time.Calendar.WeekDate (toWeekDate)
import Data.Time.Clock (UTCTime (..), getCurrentTime)
import Simplex.Chat.Badges
import Simplex.Chat.Badges.Code
import Simplex.Chat.Badges.Service
import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus (..))
import Simplex.Chat.Bot (initializeBotAddress')
import Simplex.Chat.Bot.Store (withDB, withDB')
import Simplex.Chat.Controller
import Simplex.Chat.Core (sendChatCmd, simplexChatCore)
import Simplex.Chat.Options (printDbOpts)
import Simplex.Chat.Terminal (terminalChatConfig)
import Simplex.Chat.Terminal.Main (simplexChatCLI')
import Simplex.Chat.Types (AgentInvId (..), User (..))
import Simplex.Messaging.Encoding.String (strEncode)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.BBS (bbsPublicKey)
import Simplex.Messaging.Encoding.String (TextEncoding, strEncode, textDecode)
import Simplex.Messaging.Version (isCompatible)
import Simplex.Messaging.Util (raceAny_, safeDecodeUtf8, tshow)
import System.Directory (getAppUserDataDirectory)
import System.Exit (exitFailure)
data ServiceState = ServiceState
{ serviceCC :: TMVar ChatController,
serviceRequestQ :: TQueue (User, AgentInvId, J.Object)
serviceRequestQ :: TQueue (User, AgentInvId, Maybe C.PublicKeyEd25519, J.Object)
}
newServiceState :: IO ServiceState
@@ -52,48 +76,123 @@ welcomeGetOpts = do
putStrLn $ "Service name: " ++ T.unpack serviceName
pure opts
badgeService :: BadgeServiceOpts -> ChatConfig -> IO ()
badgeService opts cfg = do
env <- newServiceState
-- | Check the secret is the key trusted at its index: otherwise every code redeemed is burned.
checkIssuerKey :: BadgeServiceOpts -> ChatConfig -> IO (Either String BadgeIssuerKey)
checkIssuerKey BadgeServiceOpts {issuerKey} ChatConfig {badgePublicKeys} = case issuerKey of
Nothing -> pure $ Left "an issuer key is required - pass both --issuer-key-idx and --issuer-secret (see `simplex-chat badge keygen`)"
Just k@BadgeIssuerKey {keyIdx, secretKey} ->
bbsPublicKey secretKey >>= \case
Left e -> pure $ Left $ "issuer secret is not a valid key: " <> e
Right pk -> pure $ case M.lookup keyIdx badgePublicKeys of
Just pk' | pk' == pk -> Right k
Just _ -> Left $ "issuer secret does not match the configured key at index " <> show keyIdx <> ", its public key is " <> T.unpack (safeDecodeUtf8 $ strEncode pk)
Nothing -> Left $ "no configured badge key at index " <> show keyIdx <> ", clients could not verify what this service signs"
requireIssuerKey :: BadgeServiceOpts -> ChatConfig -> IO BadgeIssuerKey
requireIssuerKey opts cfg =
checkIssuerKey opts cfg >>= either (\e -> putStrLn ("Error: " <> e) >> exitFailure) pure
badgeService :: BadgeServiceOpts -> ChatConfig -> ServiceState -> IO ()
badgeService opts cfg env = do
key <- requireIssuerKey opts cfg
let chatHooks =
defaultChatHooks
{ preStartHook = Just $ badgePreStartHook opts,
postStartHook = Just $ badgePostStartHook opts env
postStartHook = Just $ badgePostStartHook opts env,
preCmdHook = Just badgeCmdHook
}
-- the reader must not block: outputQ carries every chat event
simplexChatCore cfg {chatHooks} (mkChatOpts opts) $ \_ cc ->
forever $ do
(_, event) <- atomically . readTBQueue $ outputQ cc
case event of
-- TODO enforce _sigKey == BadgeServiceRequest.purchaseKey (docs/protocol/badges-rpc.md).
Right (CEvtServiceRequest u reqId _sigKey reqData) -> handleServiceRequest cc u reqId reqData
_ -> pure ()
raceAny_
[ forever $
atomically (readTBQueue $ outputQ cc) >>= \case
(_, Right (CEvtServiceRequest u reqId sigKey reqData)) ->
atomically $ writeTQueue (serviceRequestQ env) (u, reqId, sigKey, reqData)
_ -> pure (),
processQueuedRequests key env
]
badgeServiceCLI :: BadgeServiceOpts -> IO ()
badgeServiceCLI opts = do
key <- requireIssuerKey opts terminalChatConfig
env <- newServiceState
let eventHook _cc ev = do
case ev of
Right (CEvtServiceRequest u reqId _sigKey reqData) ->
atomically $ writeTQueue (serviceRequestQ env) (u, reqId, reqData)
Right (CEvtServiceRequest u reqId sigKey reqData) ->
atomically $ writeTQueue (serviceRequestQ env) (u, reqId, sigKey, reqData)
_ -> pure ()
pure ev
chatHooks =
defaultChatHooks
{ preStartHook = Just $ badgePreStartHook opts,
postStartHook = Just $ badgePostStartHook opts env,
preCmdHook = Just badgeCmdHook,
eventHook = Just eventHook
}
raceAny_
[ simplexChatCLI' terminalChatConfig {chatHooks} (mkChatOpts opts) Nothing,
processQueuedRequests env
processQueuedRequests key env
]
processQueuedRequests :: ServiceState -> IO ()
processQueuedRequests env = do
-- | issuing codes lives here rather than in core: every user's app would otherwise ship it
badgeCmdHook :: ChatController -> ChatCommand -> IO (Either (Either ChatError ChatResponse) ChatCommand)
badgeCmdHook cc = \case
CustomChatCommand cmd -> Left <$> runBadgeCmd cc cmd
cmd -> pure $ Right cmd
runBadgeCmd :: ChatController -> ByteString -> IO (Either ChatError ChatResponse)
runBadgeCmd cc cmd = case A.parseOnly issueCmdP cmd of
Left _ -> pure $ chatCmdError "use: //issue supporter|legend|investor [months 1-255] [paid|unpaid|free]"
Right issueOpts ->
issueBadgeCode cc issueOpts >>= \case
Right code -> pure $ Right CRCustomChatResponse {user_ = Nothing, response = "code " <> formatBadgeCode code}
Left e -> pure $ chatCmdError $ "issuing code: " <> e
issueCmdP :: A.Parser IssueCodeOpts
issueCmdP =
"issue " *> do
badgeType <- badgeTypeP
months_ <- optional (A.space *> A.decimal)
-- outside `optional`, which would otherwise backtrack past a bad count
months <- maybe (pure 1) checkMonths months_
paymentStatus <- fromMaybe CPSFree <$> optional (A.space *> textTokenP)
A.skipSpace
A.endOfInput
pure IssueCodeOpts {badgeType, months, paymentStatus}
where
checkMonths n
| n >= 1 && n <= (255 :: Int) = pure n
| otherwise = fail "months must be between 1 and 255"
-- BadgeType decodes anything to BTUnknown, so a typo would issue an unusable code
badgeTypeP =
textTokenP >>= \case
BTUnknown t -> fail $ "unknown badge type " <> T.unpack t
bt -> pure bt
textTokenP :: TextEncoding a => A.Parser a
textTokenP = do
t <- A.takeWhile1 (not . isSpace)
maybe (fail "invalid value") pure $ textDecode $ safeDecodeUtf8 t
data IssueCodeOpts = IssueCodeOpts
{ badgeType :: BadgeType,
months :: Int,
paymentStatus :: BadgeCodePaymentStatus
}
-- | The caller sees the code once; only its hash is stored, so a lost code cannot be recovered.
issueBadgeCode :: ChatController -> IssueCodeOpts -> IO (Either String BadgeCode)
issueBadgeCode cc IssueCodeOpts {badgeType, months, paymentStatus} = do
code <- randomBadgeCode $ random cc
now <- getCurrentTime
r <- withDB' "issueBadgeCode" cc $ \db -> insertBadgeCode db (badgeCodeHash code) badgeType months paymentStatus now
pure $ code <$ r
processQueuedRequests :: BadgeIssuerKey -> ServiceState -> IO ()
processQueuedRequests key env = do
cc <- atomically $ readTMVar $ serviceCC env
forever $ do
(u, reqId, reqData) <- atomically $ readTQueue $ serviceRequestQ env
handleServiceRequest cc u reqId reqData
(u, reqId, sigKey, reqData) <- atomically $ readTQueue $ serviceRequestQ env
handleServiceRequest key cc u reqId sigKey reqData
badgePreStartHook :: BadgeServiceOpts -> ChatController -> IO ()
badgePreStartHook opts ChatController {config, chatStore} =
@@ -110,11 +209,104 @@ badgePostStartHook BadgeServiceOpts {noAddress, testing} env cc = do
unless noAddress $ initializeBotAddress' (not testing) (Just True) False cc
void $ atomically $ tryPutTMVar (serviceCC env) cc
handleServiceRequest :: ChatController -> User -> AgentInvId -> J.Object -> IO ()
handleServiceRequest cc User {userId} reqId _reqData = do
handleServiceRequest :: BadgeIssuerKey -> ChatController -> User -> AgentInvId -> Maybe C.PublicKeyEd25519 -> J.Object -> IO ()
handleServiceRequest key cc User {userId} reqId sigKey reqData = do
let reqIdT = safeDecodeUtf8 (strEncode reqId)
respObj = KM.fromList [("type", J.String "error"), ("code", J.toJSON BSEUnsupportedVersion)]
logInfo $ "badge service request " <> reqIdT
sendChatCmd cc (APISendServiceResponse userId reqId respObj) >>= \case
resp <- badgeServiceResponse key cc sigKey reqData
sendChatCmd cc (APISendServiceResponse userId reqId (responseObject resp)) >>= \case
Right _ -> pure ()
Left e -> logError $ "badge service response failed for " <> reqIdT <> ": " <> tshow e
responseObject :: BadgeServiceResponse -> J.Object
responseObject r = case J.toJSON r of
J.Object o -> o
_ -> KM.fromList [("type", J.String "error"), ("code", J.toJSON BSEInternal)]
errorResponse :: BadgeServiceErrorCode -> BadgeServiceResponse
errorResponse code = BSPError {code, message = Nothing, retryAfter = Nothing}
-- | The agent verified the signature, so sigKey is a key the sender holds - a purchaseKey that
-- differs would let a client claim a purchase it cannot sign for.
badgeServiceResponse :: BadgeIssuerKey -> ChatController -> Maybe C.PublicKeyEd25519 -> J.Object -> IO BadgeServiceResponse
badgeServiceResponse key cc sigKey reqData = case J.fromJSON (J.Object reqData) of
J.Error _ -> pure $ errorResponse BSEBadRequest
J.Success BadgeServiceRequest {version, purchaseKey, request}
| not (version `isCompatible` supportedBadgeServiceVRange) -> pure $ errorResponse BSEUnsupportedVersion
| purchaseKey /= sigKey -> pure $ errorResponse BSEBadRequest
| otherwise -> case request of
BSCRedeemBadgeCode {masterKey, code} -> case purchaseKey of
Just k -> redeemCode key cc k masterKey code
Nothing -> pure $ errorResponse BSEBadRequest
-- every command but redeemBadgeCode needs a key the service already knows: that one
-- creates the purchase, so its key is unknown on a first redemption
_ -> case purchaseKey of
Nothing -> pure $ errorResponse BSEUnsupportedVersion
Just k ->
withDB' "purchaseKeyExists" cc (`purchaseKeyExists` k) >>= \case
Right True -> pure $ errorResponse BSEUnsupportedVersion
Right False -> pure $ errorResponse BSEUnknownPurchaseKey
Left _ -> pure $ errorResponse BSEInternal
-- | Nothing is written until the credential is signed, so a signing failure leaves the code
-- unspent rather than spent with nothing behind it.
redeemCode :: BadgeIssuerKey -> ChatController -> C.PublicKeyEd25519 -> BadgeMasterKey -> T.Text -> IO BadgeServiceResponse
redeemCode BadgeIssuerKey {keyIdx, secretKey} cc purchaseKey masterKey codeText = case parseBadgeCode codeText of
Nothing -> pure $ errorResponse BSECodeInvalid
Just code ->
withDB' "getBadgeCode" cc (`getBadgeCode` badgeCodeHash code) >>= \case
Left _ -> pure $ errorResponse BSEInternal
Right Nothing -> pure $ errorResponse BSECodeInvalid
Right (Just IssuedCode {badgeCodeId, badgeType, redemption}) -> case redeemedResponse redemption of
Just resp -> pure resp
Nothing -> do
now <- getCurrentTime
-- TODO [badges] the code's months are ignored until the ledger credits them
let periodEnd = addMonths 1 now
badgeInfo = BadgeInfo {badgeType, badgeExpiry = Just (endOfSundayAfter periodEnd), badgeExtra = ""}
issueBadge keyIdx secretKey (VerifiedBadgeRequest BadgeRequest {masterKey, badgeInfo}) >>= \case
Left e -> logError ("badge service signing failed: " <> T.pack e) $> errorResponse BSEInternal
Right credential -> do
issuanceId <- safeDecodeUtf8 . strEncode <$> atomically (C.randomBytes 16 $ random cc)
let newRedemption =
NewBadgeCodeRedemption
{ badgeCodeId,
issuanceId,
purchaseKey,
masterKey,
badgeType,
credential,
periodStart = now,
periodEnd,
expiry = endOfSundayAfter periodEnd
}
-- re-read: a concurrent redemption may have landed while this one was signing
r <- withDB "writeCodeRedemption" cc $ \db ->
liftIO (getBadgeCode db $ badgeCodeHash code) >>= \case
Just IssuedCode {redemption = current} | Just resp <- redeemedResponse current -> pure resp
_ -> liftIO $ credentialResponse credential <$ writeCodeRedemption db newRedemption now
pure $ either (const $ errorResponse BSEInternal) id r
where
-- one definition, used before signing and again inside the write transaction
redeemedResponse = \case
CodeUnredeemed -> Nothing
CodeRedeemedUnreadable -> Just $ errorResponse BSEInternal
CodeRedeemed RedeemedCode {purchaseKey = k, credential}
| k == purchaseKey -> Just $ credentialResponse credential
| otherwise -> Just $ errorResponse BSECodeUsed
-- TODO [badges] the statement is empty until the ledger is written
credentialResponse :: BadgeCredential -> BadgeServiceResponse
credentialResponse credential =
BSPBadgeCredential {credential = Just credential, receipt = Nothing, statement = BadgeStatement {entries = [], previousEntryId = Nothing}}
addMonths :: Integer -> UTCTime -> UTCTime
addMonths n (UTCTime d t) = UTCTime (addGregorianMonthsClip n d) t
-- Every badge in a week expires together, revealing nothing about when it was bought.
-- The end of a Sunday is the next Monday at 00:00, so this returns a Monday and 8 is right.
endOfSundayAfter :: UTCTime -> UTCTime
endOfSundayAfter (UTCTime d _) =
let (_, _, dayOfWeek) = toWeekDate d -- 1 Monday .. 7 Sunday
in UTCTime (addDays (toInteger (8 - dayOfWeek)) d) 0
@@ -0,0 +1,135 @@
{-# LANGUAGE CPP #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE ScopedTypeVariables #-}
module BadgeService.Store
( IssuedCode (..),
CodeRedemption (..),
RedeemedCode (..),
NewBadgeCodeRedemption (..),
getBadgeCode,
purchaseKeyExists,
writeCodeRedemption,
insertBadgeCode,
)
where
import qualified Data.Aeson as J
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Int (Int64)
import Data.Text (Text)
import Data.Time.Clock (UTCTime)
import Simplex.Chat.Badges (BadgeCredential, BadgeMasterKey (..), BadgeType)
import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus, BadgePurchaseStatus (..))
import Simplex.Chat.Store.Shared (insertedRowId)
import Simplex.Messaging.Agent.Store.DB (Binary (..))
import qualified Simplex.Messaging.Agent.Store.DB as DB
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Util (maybeFirstRow, maybeFirstRow')
#if defined(dbPostgres)
import Database.PostgreSQL.Simple (Only (..))
import Database.PostgreSQL.Simple.SqlQQ (sql)
#else
import Database.SQLite.Simple (Only (..))
import Database.SQLite.Simple.QQ (sql)
#endif
data IssuedCode = IssuedCode
{ badgeCodeId :: Int64,
badgeType :: BadgeType,
redemption :: CodeRedemption
}
-- A code that has a purchase is spent, even if its credential cannot be read. Treating that as
-- an unredeemed code would issue a second credential for it.
data CodeRedemption
= CodeUnredeemed
| CodeRedeemed RedeemedCode
| CodeRedeemedUnreadable
data RedeemedCode = RedeemedCode
{ purchaseKey :: C.PublicKeyEd25519,
credential :: BadgeCredential
}
-- Everything writeCodeRedemption inserts, so that the purchase, its issuance and the spent code
-- are written in one transaction. If the code were marked redeemed and one of the other writes
-- failed, it would be spent with no credential behind it, and nothing can reissue it.
data NewBadgeCodeRedemption = NewBadgeCodeRedemption
{ badgeCodeId :: Int64,
issuanceId :: Text,
purchaseKey :: C.PublicKeyEd25519,
masterKey :: BadgeMasterKey,
badgeType :: BadgeType,
credential :: BadgeCredential,
periodStart :: UTCTime,
periodEnd :: UTCTime,
expiry :: UTCTime
}
getBadgeCode :: DB.Connection -> ByteString -> IO (Maybe IssuedCode)
getBadgeCode db codeHash =
maybeFirstRow toCode $
DB.query
db
[sql|
SELECT c.badge_code_id, c.badge_type, p.purchase_key, i.credential
FROM sx_badge_service_badge_codes c
LEFT JOIN sx_badge_service_badge_purchases p ON p.badge_code_id = c.badge_code_id
LEFT JOIN sx_badge_service_badge_issuances i ON i.badge_purchase_id = p.badge_purchase_id
WHERE c.code_hash = ?
ORDER BY i.created_at DESC
LIMIT 1
|]
(Only (Binary codeHash))
where
toCode (badgeCodeId, badgeType, purchaseKey_, credential_) =
IssuedCode {badgeCodeId, badgeType, redemption = codeRedemption purchaseKey_ credential_}
codeRedemption purchaseKey_ credential_ = case purchaseKey_ of
Nothing -> CodeUnredeemed
Just purchaseKey -> case decodeCredential =<< credential_ of
Just credential -> CodeRedeemed RedeemedCode {purchaseKey, credential}
Nothing -> CodeRedeemedUnreadable
decodeCredential (Binary bs) = J.decodeStrict' bs
purchaseKeyExists :: DB.Connection -> C.PublicKeyEd25519 -> IO Bool
purchaseKeyExists db key =
maybeFirstRow' False (\(Only (_ :: Int64)) -> True) $
DB.query db "SELECT badge_purchase_id FROM sx_badge_service_badge_purchases WHERE purchase_key = ?" (Only key)
-- one transaction: the caller has already signed, so no code is left spent without a credential
writeCodeRedemption :: DB.Connection -> NewBadgeCodeRedemption -> UTCTime -> IO ()
writeCodeRedemption db NewBadgeCodeRedemption {badgeCodeId, issuanceId, purchaseKey, masterKey = BadgeMasterKey mk, badgeType, credential, periodStart, periodEnd, expiry} now = do
DB.execute
db
[sql|
INSERT INTO sx_badge_service_badge_purchases
(purchase_key, master_key, initial_badge_type, current_badge_type, status, badge_code_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?)
|]
(purchaseKey, Binary mk, badgeType, badgeType, PSIssued, badgeCodeId, now, now)
purchaseId <- insertedRowId db
DB.execute
db
[sql|
INSERT INTO sx_badge_service_badge_issuances
(issuance_id, badge_purchase_id, badge_type, period_start, period_end, expiry, credential, created_at)
VALUES (?,?,?,?,?,?,?,?)
|]
(issuanceId, purchaseId, badgeType, periodStart, periodEnd, expiry, Binary (LB.toStrict $ J.encode credential), now)
DB.execute db "UPDATE sx_badge_service_badge_codes SET redeemed_at = ? WHERE badge_code_id = ?" (now, badgeCodeId)
insertBadgeCode :: DB.Connection -> ByteString -> BadgeType -> Int -> BadgeCodePaymentStatus -> UTCTime -> IO ()
insertBadgeCode db codeHash badgeType months paymentStatus now =
DB.execute
db
[sql|
INSERT INTO sx_badge_service_badge_codes (code_hash, badge_type, months, code_payment_status, created_at)
VALUES (?,?,?,?,?)
|]
(Binary codeHash, badgeType, months, paymentStatus, now)
@@ -47,9 +47,9 @@ import Directory.Options
import Directory.Search
import Directory.Store
import Directory.Store.Migrate
import Directory.Util
import Simplex.Chat.Bot
import Simplex.Chat.Bot.KnownContacts
import Simplex.Chat.Bot.Store
import Simplex.Chat.Controller
import Simplex.Chat.Core
import Simplex.Chat.Library.Internal (setGroupLinkData)
@@ -68,7 +68,7 @@ import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Data.Time.Clock (UTCTime (..), getCurrentTime)
import Directory.Search
import Directory.Util
import Simplex.Chat.Bot.Store
import Simplex.Chat.Controller
import Simplex.Chat.Names (claimDomain)
import Simplex.Chat.Options.DB (FromField (..), ToField (..))
@@ -1,31 +0,0 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Directory.Util where
import Control.Logger.Simple
import Control.Monad.Except
import Data.Text (Text)
import qualified Data.Text as T
import Simplex.Chat.Controller
import Simplex.Chat.Types
import Simplex.Messaging.Agent.Store.Common (withTransaction)
import qualified Simplex.Messaging.Agent.Store.DB as DB
import Simplex.Messaging.Util (catchAll)
storeCxt :: ChatController -> StoreCxt
storeCxt ChatController {config} = mkStoreCxt config
{-# INLINE storeCxt #-}
withDB' :: Text -> ChatController -> (DB.Connection -> IO a) -> IO (Either String a)
withDB' cxt cc a = withDB cxt cc $ ExceptT . fmap Right . a
withDB :: Text -> ChatController -> (DB.Connection -> ExceptT String IO a) -> IO (Either String a)
withDB cxt ChatController {chatStore} action = do
r_ <- withTransaction chatStore (runExceptT . action) `catchAll` (pure . Left . show)
case r_ of
Left e -> logError $ "Database error: " <> cxt <> " " <> T.pack e
Right _ -> pure ()
pure r_