core: renew badges monthly and alert when support ends (#7448)

This commit is contained in:
spaced4ndy
2026-09-09 14:31:05 +00:00
committed by GitHub
parent c623f4284c
commit f9bab17643
30 changed files with 2901 additions and 220 deletions
+6
View File
@@ -69,6 +69,8 @@ defaultChatConfig =
chatVRange = supportedChatVRange,
badgePublicKeys = M.mapKeys fromIntegral entitlementIssuerKeys,
badgeServiceAddress = Nothing,
badgeCurrentTime = getCurrentTime,
badgeRetryInterval = RetryInterval {initialInterval = 30_000000, increaseAfter = 0, maxInterval = 3600_000000},
confirmMigrations = MCConsole,
-- this property should NOT use operator = Nothing
-- non-operator servers can be passed via options
@@ -189,6 +191,8 @@ newChatController
deliveryTaskWorkers <- TM.emptyIO
deliveryJobWorkers <- TM.emptyIO
relayRequestWorkers <- TM.emptyIO
badgeWorkers <- TM.emptyIO
badgeSeq <- newTVarIO 0
relayGroupLinkChecksAsync <- newTVarIO Nothing
webPreviewState <- forM webPreviewConfig $ \_ -> newWebPreviewState
chatRelayTests <- TM.emptyIO
@@ -235,6 +239,8 @@ newChatController
deliveryTaskWorkers,
deliveryJobWorkers,
relayRequestWorkers,
badgeWorkers,
badgeSeq,
relayGroupLinkChecksAsync,
webPreviewState,
chatRelayTests,
+2 -2
View File
@@ -3,11 +3,11 @@
-- | Badge redemption codes, shared by the client, the badge service and the checkout site.
--
-- A code is @SXB-@ and 20 Crockford base32 characters in four groups of five:
-- A code is "SXB-" and 20 Crockford base32 characters in four groups of five:
-- 19 payload characters and a final check character.
--
-- Reading folds the characters the alphabet omits so that a code copied by hand still
-- verifies: it is case-insensitive and maps @I@ and @L@ to @1@ and @O@ to @0@.
-- verifies: it is case-insensitive and maps 'I' and 'L' to '1' and 'O' to '0'.
--
-- The check character is Luhn mod N with N = 32 over the payload values, which keeps it
-- inside the same 32-character alphabet. It detects every single-character substitution
+176
View File
@@ -0,0 +1,176 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module Simplex.Chat.Badges.Ledger
( emptyEntry,
lapseEntry,
grantEntry,
issueEntry,
paidThrough,
balanceChecked,
addMonths,
endOfMondayAfter,
entryTypeColumns,
entryTypeFromColumns,
creditTypeTag,
debitTypeTag,
)
where
import Data.Text (Text)
import Data.Time.Calendar (addDays, addGregorianMonthsClip, toGregorian)
import Data.Time.Calendar.WeekDate (toWeekDate)
import Data.Time.Clock (UTCTime (..))
import Simplex.Chat.Badges (BadgeType)
import Simplex.Chat.Badges.Service (StatementCreditType (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..))
-- | balanceStartTs is always a whole number of months from the anchor; this is that number.
-- The calendar difference overshoots by at most one month, so one comparison settles it.
monthsFromAnchor :: StatementEntry -> Integer
monthsFromAnchor StatementEntry {balanceStartTs, balanceAnchorTs}
| addMonths months balanceAnchorTs <= balanceStartTs = max 0 months
| otherwise = max 0 (months - 1)
where
(ay, am, _) = toGregorian (utctDay balanceAnchorTs)
(sy, sm, _) = toGregorian (utctDay balanceStartTs)
months = (sy - ay) * 12 + toInteger (sm - am)
-- | The start of the month that follows n more months of this run.
monthAfter :: StatementEntry -> Int -> UTCTime
monthAfter e n = addMonths (monthsFromAnchor e + toInteger n) (balanceAnchorTs e)
paidThrough :: StatementEntry -> UTCTime
paidThrough e = monthAfter e (balanceMonths e)
elapsedMonths :: UTCTime -> StatementEntry -> Int
elapsedMonths t e = length $ takeWhile (\m -> monthAfter e m <= t) [1 .. balanceMonths e]
-- | The seed for a purchase with no ledger yet: no months, and a run starting now.
emptyEntry :: UTCTime -> BadgeType -> StatementEntry
emptyEntry t badgeType =
StatementEntry
{ -- this entry is never stored, and every operation puts its own id on the entry it returns
entryId = "",
changeMonths = 0,
balanceMonths = 0,
balanceStartTs = t,
balanceAnchorTs = t,
balanceBadgeType = badgeType,
wasPausedSince = Nothing,
createdAt = t,
entryType = SECredit SCOpening
}
-- | Writes off the months that have passed.
lapseEntry :: UTCTime -> Text -> StatementEntry -> Maybe StatementEntry
lapseEntry t entryId e@StatementEntry {balanceMonths}
| k == 0 = Nothing
| otherwise =
Just
e
{ entryId,
createdAt = t,
changeMonths = negate k,
balanceMonths = balanceMonths - k,
balanceStartTs = monthAfter e k,
entryType = SEDebit SDLapse
}
where
k = elapsedMonths t e
-- | New months start where the current coverage ends, or at t if it has already lapsed - so they
-- are neither spent on the month still running nor backdated over a gap.
grantEntry :: UTCTime -> Text -> Int -> StatementCreditType -> StatementEntry -> StatementEntry
grantEntry t entryId n credit e@StatementEntry {balanceMonths, balanceStartTs}
-- only a lapsed run restarts; topping up before coverage ends continues the run on its anchor,
-- so buying a month at a time keeps the same day of month as buying a year at once
| lapsed = credited {balanceMonths = n, balanceStartTs = t, balanceAnchorTs = t}
| otherwise = credited {balanceMonths = balanceMonths + n}
where
lapsed = balanceMonths == 0 && t > balanceStartTs
credited = e {entryId, createdAt = t, changeMonths = n, entryType = SECredit credit}
-- | The period issued runs from the previous entry's balanceStartTs to this one's.
issueEntry :: UTCTime -> Text -> StatementEntry -> Maybe StatementEntry
issueEntry t entryId e@StatementEntry {balanceMonths, balanceStartTs}
| balanceMonths <= 0 || balanceStartTs > t = Nothing
| otherwise =
Just
e
{ entryId,
createdAt = t,
changeMonths = -1,
balanceMonths = balanceMonths - 1,
balanceStartTs = monthAfter e 1,
entryType = SEDebit SDBadge
}
-- | Pairs each arriving entry with whether its balance follows from the one before it, the stored
-- tip standing in for the first one's predecessor. 'Nothing' is "not checked".
-- TODO [badges] do the arithmetic.
balanceChecked :: Maybe StatementEntry -> [StatementEntry] -> [(StatementEntry, Maybe Bool)]
balanceChecked _tip = map (\e -> (e, Nothing))
-- | The tag stored is the string the service sent, so a type this version does not know is kept
-- as received and can be read once it does.
entryTypeColumns :: StatementEntryType -> (Text, Maybe Text, Maybe Text)
entryTypeColumns = \case
SECredit c -> ("credit", Just $ creditTypeTag c, Nothing)
SEDebit d -> ("debit", Nothing, Just $ debitTypeTag d)
creditTypeTag :: StatementCreditType -> Text
creditTypeTag = \case
SCPayment _ -> "payment"
SCCode -> "code"
SCCharge _ -> "charge"
SCSupport -> "support"
SCTransferIn _ -> "transferIn"
SCOpening -> "opening"
SCUnknown {tag} -> tag
debitTypeTag :: StatementDebitType -> Text
debitTypeTag = \case
SDRefund -> "refund"
SDUpgrade _ -> "upgrade"
SDTransferOut _ -> "transferOut"
SDSupport -> "support"
SDBadge -> "badge"
SDLapse -> "lapse"
SDUnknown {tag} -> tag
-- | Only the types a tag alone rebuilds, which is those whose constructor has no fields; the rest
-- answer Nothing rather than a type with an invented payload. The client also stores each type's
-- JSON and reads that first, so this is its fallback; the service has no such column.
-- TODO [badges] take the reference columns and rebuild payment, charge, transferIn, upgrade and
-- transferOut, without which the service cannot re-emit a statement carrying one.
entryTypeFromColumns :: Text -> Maybe Text -> Maybe Text -> Maybe StatementEntryType
entryTypeFromColumns entryType credit_ debit_ = case (entryType, credit_, debit_) of
("credit", Just t, _) -> SECredit <$> creditType t
("debit", _, Just t) -> SEDebit <$> debitType t
_ -> Nothing
where
creditType = \case
"code" -> Just SCCode
"support" -> Just SCSupport
"opening" -> Just SCOpening
_ -> Nothing
debitType = \case
"badge" -> Just SDBadge
"lapse" -> Just SDLapse
"refund" -> Just SDRefund
"support" -> Just SDSupport
_ -> 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 Monday is the next Tuesday at 00:00, so this returns a Tuesday and 9 is right.
-- Returning a Monday instead would put the expiry on Sunday evening in the Americas, leaving a
-- renewal that failed there waiting for weekend support.
endOfMondayAfter :: UTCTime -> UTCTime
endOfMondayAfter (UTCTime d _) =
let (_, _, dayOfWeek) = toWeekDate d -- 1 Monday .. 7 Sunday
in UTCTime (addDays (toInteger (9 - dayOfWeek)) d) 0
+6 -3
View File
@@ -98,8 +98,7 @@ data BadgeServiceCommand
balance :: BadgeBalance
}
| BSCIssueBadge
{ badgeRequest :: BadgeRequest,
balance :: BadgeBalance
{ balance :: BadgeBalance -- no badgeRequest: the service holds the key, the tier and the expiry
}
| BSCPauseBadge
@@ -173,6 +172,9 @@ data StatementEntry = StatementEntry
changeMonths :: Int,
balanceMonths :: Int,
balanceStartTs :: UTCTime,
-- the start of the current run of months; every month boundary in it is counted from here,
-- so that the day of month survives a short month
balanceAnchorTs :: UTCTime,
balanceBadgeType :: BadgeType,
wasPausedSince :: Maybe UTCTime,
createdAt :: UTCTime,
@@ -184,7 +186,8 @@ data StatementEntryType = SECredit {credit :: StatementCreditType} | SEDebit {de
deriving (Show)
data StatementCreditType
= SCPayment {invoiceId :: Maybe InvoiceId} -- absent for store and code payments
= SCPayment {invoiceId :: Maybe InvoiceId} -- absent for store payments
| SCCode -- a redeemed code; its own invoice belongs to the buyer, not to the redeemer
| SCCharge {chargeId :: Text}
| SCSupport
| SCTransferIn {fromPurchaseKey :: C.PublicKeyEd25519}
+61 -18
View File
@@ -18,12 +18,13 @@ module Simplex.Chat.Badges.Types
LedgerCreditType (..),
LedgerDebitType (..),
BadgeAlertKind (..),
BadgeFunding (..),
BadgePurchase (..),
BadgeLedgerEntry (..),
BadgeCharge (..),
BadgeIssuance (..),
BadgeAlert (..),
UserBadgeState (..),
BadgeState (..),
) where
import Data.Aeson (FromJSON, ToJSON)
@@ -34,12 +35,12 @@ import Data.Text (Text)
import Data.Time.Clock (UTCTime)
import Data.Word (Word8)
import Simplex.Chat.Badges hiding (BadgePurchase (..))
import Simplex.Chat.PaymentService.Types (InvoiceId, StoredPayment)
import Simplex.Chat.PaymentService.Types (InvoiceId, PaymentId, StoredPayment)
import Simplex.Messaging.Agent.Protocol (UserId)
import Simplex.Messaging.Agent.Store.DB (fromTextField_)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (dropPrefix, enumJSON, taggedObjectJSON)
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, taggedObjectJSON)
#if defined(dbPostgres)
import Database.PostgreSQL.Simple.FromField (FromField (..))
import Database.PostgreSQL.Simple.ToField (ToField (..))
@@ -84,8 +85,9 @@ data LedgerEntryType = LECredit {credit :: LedgerCreditType} | LEDebit {debit ::
-- confirmed
data LedgerCreditType
= CTPayment {invoiceId :: Int64}
| CTCharge {chargeId :: Int64}
= CTPayment {invoiceId :: InvoiceId}
| CTCode
| CTCharge {chargeId :: Text}
| CTSupport
| CTTransferIn {fromPurchaseId :: Maybe Int64}
| CTOpening
@@ -107,6 +109,31 @@ data LedgerDebitType
data BadgeAlertKind = BARenewalApproaching | BAPaymentIssue | BASubscriptionEnded | BAPrepaidEnding | BASupportEnded
deriving (Eq, Show)
instance TextEncoding BadgeAlertKind where
textEncode = \case
BARenewalApproaching -> "renewal_approaching"
BAPaymentIssue -> "payment_issue"
BASubscriptionEnded -> "subscription_ended"
BAPrepaidEnding -> "prepaid_ending"
BASupportEnded -> "support_ended"
textDecode = \case
"renewal_approaching" -> Just BARenewalApproaching
"payment_issue" -> Just BAPaymentIssue
"subscription_ended" -> Just BASubscriptionEnded
"prepaid_ending" -> Just BAPrepaidEnding
"support_ended" -> Just BASupportEnded
_ -> Nothing
instance FromField BadgeAlertKind where fromField = fromTextField_ textDecode
instance ToField BadgeAlertKind where toField = toField . textEncode
-- exactly one of these funds a purchase; the schema cannot say so, both columns being nullable
data BadgeFunding
= BFPayment {paymentId :: PaymentId}
| BFCodeRedemption {redemptionId :: Int64}
deriving (Eq, Show)
-- to review
data BadgePurchase = BadgePurchase
{ badgePurchaseId :: Int64,
@@ -117,7 +144,7 @@ data BadgePurchase = BadgePurchase
badgeType :: BadgeType,
priceId :: Maybe BadgePriceId,
offerId :: Maybe BadgeOfferId,
paymentId :: Int64,
funding :: BadgeFunding,
status :: BadgePurchaseStatus,
credential :: Maybe BadgeCredential,
alertAcked :: Maybe (BadgeAlertKind, Text),
@@ -134,6 +161,7 @@ data BadgeLedgerEntry = BadgeLedgerEntry
changeMonths :: Int,
balanceMonths :: Int,
balanceStartTs :: UTCTime,
balanceAnchorTs :: UTCTime,
balanceBadgeType :: BadgeType,
wasPausedSince :: Maybe UTCTime,
serviceCreatedAt :: UTCTime,
@@ -156,14 +184,16 @@ data BadgeCharge = BadgeCharge
}
deriving (Show)
-- unconfirmed draft
-- every issuance covers one month and is written beside exactly one debit(badge) row
data BadgeIssuance = BadgeIssuance
{ issuanceId :: Int64,
{ issuanceId :: Text,
badgePurchaseId :: Int64,
periodStart :: Maybe UTCTime,
periodEnd :: Maybe UTCTime,
expiry :: Maybe UTCTime,
entryId :: Maybe Int64,
badgeType :: BadgeType,
periodStart :: UTCTime,
periodEnd :: UTCTime,
expiry :: UTCTime,
entryId :: Int64,
credential :: BadgeCredential,
createdAt :: UTCTime
}
deriving (Show)
@@ -177,17 +207,19 @@ data BadgeAlert = BadgeAlert
}
deriving (Show)
-- unconfirmed draft
data UserBadgeState = UserBadgeState
{ badges :: [BadgePurchase],
shownBadgeId :: Maybe Int64,
payments :: [StoredPayment],
-- | The user's badge as the badge surfaces render it. The purchase keys are deliberately absent:
-- this travels to the UI and over remote control, and they are secrets that stay in core.
data BadgeState = BadgeState
{ badgePurchaseId :: Int64,
badgeType :: BadgeType,
monthsLeft :: Int,
paidThrough :: Maybe UTCTime,
paidThrough :: UTCTime,
-- payments returns here with the payment types, which this slice neither writes nor encodes
renewsAt :: Maybe UTCTime,
willRenew :: Bool,
alert :: Maybe BadgeAlert
}
deriving (Show)
instance TextEncoding BadgePurchaseStatus where
textEncode = \case
@@ -224,3 +256,14 @@ instance ToField BadgeCodePaymentStatus where toField = toField . textEncode
$(JQ.deriveJSON (enumJSON $ dropPrefix "BIS") ''BadgeItemStatus)
$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "OD") ''OfferDiscount)
instance ToJSON BadgeAlertKind where
toJSON = textToJSON
toEncoding = textToEncoding
instance FromJSON BadgeAlertKind where
parseJSON = textParseJSON "BadgeAlertKind"
$(JQ.deriveJSON defaultJSON ''BadgeAlert)
$(JQ.deriveJSON defaultJSON ''BadgeState)
+22
View File
@@ -84,6 +84,7 @@ import qualified Simplex.Messaging.Agent.Store.DB as DB
import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyMode (..), SMPWebPortServers (..), SocksMode (..))
import qualified Simplex.Messaging.Crypto as C
import Simplex.Chat.Badges (BadgeCredential, LocalBadge)
import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind, BadgeState (..))
import Simplex.Messaging.Crypto.BBS (BBSPublicKey)
import Simplex.Messaging.Crypto.File (CryptoFile (..))
import qualified Simplex.Messaging.Crypto.File as CF
@@ -92,6 +93,7 @@ import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus)
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON)
import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), MsgId, NMsgMeta (..), NtfServer, ProtocolType (..), QueueId, SMPMsgMeta (..), SubscriptionMode (..), XFTPServer)
import Simplex.Messaging.Session (SessionVar)
import Simplex.Messaging.TMap (TMap)
import Simplex.Messaging.Transport (TLS, TransportPeer (..), simplexMQVersion)
import Simplex.Messaging.Transport.Client (SocksProxyWithAuth, TransportHost)
@@ -145,6 +147,10 @@ data ChatConfig = ChatConfig
badgePublicKeys :: Map Int BBSPublicKey,
-- Nothing until the badge service is deployed
badgeServiceAddress :: Maybe (ConnectTarget 'CMContact),
-- the only clock badge code reads, so tests can shift it; production arithmetic is unchanged
badgeCurrentTime :: IO UTCTime,
-- how long a badge worker waits before repeating a renewal that failed for a passing reason
badgeRetryInterval :: RetryInterval,
confirmMigrations :: MigrationConfirmation,
presetServers :: PresetServers,
shortLinkPresetServers :: NonEmpty SMPServer,
@@ -278,6 +284,12 @@ defaultInlineFilesConfig =
data ChatDatabase = ChatDatabase {chatStore :: DBStore, agentStore :: DBStore}
-- | Signalling badgeWork wakes the worker from its own wait; a full var means it has work to do.
data BadgeWorker = BadgeWorker
{ badgeWorkerAsync :: Async (),
badgeWork :: TMVar ()
}
data ChatController = ChatController
{ currentUser :: TVar (Maybe User),
randomPresetServers :: NonEmpty PresetOperator,
@@ -310,6 +322,9 @@ data ChatController = ChatController
deliveryTaskWorkers :: TMap DeliveryWorkerKey Worker,
deliveryJobWorkers :: TMap DeliveryWorkerKey Worker,
relayRequestWorkers :: TMap Int Worker, -- single global worker with key 1 is used to fit into existing worker management framework
-- one badge worker per user: badge state is per profile, and one profile must not stall another
badgeWorkers :: TMap UserId (SessionVar BadgeWorker),
badgeSeq :: TVar Int,
relayGroupLinkChecksAsync :: TVar (Maybe (Async ())),
webPreviewState :: Maybe WebPreviewState,
chatRelayTests :: TMap ConnId RelayTest,
@@ -641,6 +656,10 @@ data ChatCommand
| UpdateProfileImageFromFile FilePath -- set profile image from a .png/.jpg/.jpeg file
| AddBadge BadgeCredential -- attach an issued badge credential (testing; credential from `simplex-chat badge sign`)
| APIRedeemBadgeCode {userId :: UserId, code :: Text} -- redeem a badge code with the configured badge service
| APIGetBadgeState {userId :: UserId} -- the user's badges, their balances and any current alert
-- episode is last because it is free text: it is the value that makes one occurrence of an
-- alert distinct from the next, and the app returns whatever it was given
| APIAckBadgeAlert {userId :: UserId, badgePurchaseId :: Int64, alertKind :: BadgeAlertKind, snooze :: Bool, episode :: Text}
| ShowProfileImage
| SetUserFeature AChatFeature FeatureAllowed -- UserId (not used in UI)
| SetContactFeature AChatFeature ContactName (Maybe FeatureAllowed)
@@ -846,6 +865,7 @@ data ChatResponse
| CRServiceResponse {user :: User, responseData :: J.Object}
| CRServiceReplyAccepted {user :: User, connectionId :: AgentConnId}
| CRBadgeRedeemed {user :: User, redeemedBadge :: LocalBadge, newBadge :: Bool}
| CRBadgeState {user :: User, badgeState :: Maybe BadgeState}
| CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact}
| CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool}
| CRGroupsList {user :: User, groups :: [GroupInfo]}
@@ -963,6 +983,8 @@ data ChatEvent
| CEvtReceivedContactRequest {user :: User, contactRequest :: UserContactRequest, chat_ :: Maybe AChat}
| CEvtServiceRequest {user :: User, requestId :: AgentInvId, signerKey :: Maybe C.PublicKeyEd25519, requestData :: J.Object}
| CEvtServiceReplySent {connectionId :: AgentConnId}
| CEvtBadgeChanged {user :: User, badgeState :: Maybe BadgeState} -- badge state changed, including a renewal that arrived without a command
| CEvtBadgeAlert {user :: User, badgeAlert :: BadgeAlert}
| CEvtContactRequestRejected {user :: User, contact :: Contact, rejectionReason :: Maybe ContactRejectionReason}
| CEvtAcceptingContactRequest {user :: User, contact :: Contact} -- there is the same command response
| CEvtAcceptingBusinessRequest {user :: User, groupInfo :: GroupInfo}
+400 -33
View File
@@ -51,14 +51,19 @@ import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime)
import Data.Time.Clock (UTCTime, getCurrentTime, nominalDay)
import Data.Word (Word32)
import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDay)
import Data.Type.Equality
import qualified Data.UUID as UUID
import qualified Data.UUID.V4 as V4
import Simplex.Chat.Library.Subscriber
import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), LocalBadge (..), badgeServerCredential, maxXFTPFileSize, mkBadgeStatus, verifyCredential)
import Crypto.Random (ChaChaDRG)
import Simplex.Messaging.Session (SessionVar (..), withGetSessVar')
import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey, LocalBadge (..), badgeServerCredential, maxXFTPFileSize, mkBadgeStatus, verifyCredential)
import qualified Simplex.Chat.Badges.Ledger as L
import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind (..), BadgeState (..))
import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode)
import Simplex.Chat.Badges.Service (BadgeServiceCommand (..), BadgeServiceErrorCode (..), BadgeServiceRequest (..), BadgeServiceResponse (..), currentBadgeServiceVersion)
import Simplex.Chat.Badges.Service (BadgeBalance (..), BadgeServiceCommand (..), BadgeServiceErrorCode (..), BadgeServiceRequest (..), BadgeServiceResponse (..), BadgeStatement (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..), currentBadgeServiceVersion)
import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim)
import Simplex.Chat.Call
import Simplex.Chat.Controller
@@ -100,6 +105,7 @@ import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSizeHar
import Simplex.Messaging.Agent
import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles)
import Simplex.Messaging.Agent.Protocol
import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..), withRetryInterval)
import Simplex.Messaging.Agent.Store.Entity
import Simplex.Messaging.Agent.Store.Interface (execSQL)
import Simplex.Messaging.Agent.Store.Shared (upMigration)
@@ -253,6 +259,7 @@ startChatController mainApp enableSndFiles serviceRequests = do
startDeliveryWorkers
startRelayRequestWorker_
startCleanupManager
mapM_ startBadgeWork users
void $ forkIO $ mapM_ startExpireCIs users
startRelayChecks users
startWebPreview users
@@ -349,7 +356,8 @@ restoreCalls = do
atomically $ writeTVar calls callsMap
stopChatController :: ChatController -> IO ()
stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession} = do
stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession, badgeWorkers} = do
stopBadgeWorkers badgeWorkers
readTVarIO remoteHostSessions >>= mapM_ (cancelRemoteHost False . snd)
atomically (stateTVar remoteCtrlSession (,Nothing)) >>= mapM_ (cancelRemoteCtrl False . snd)
disconnectAgentClient smpAgent
@@ -581,6 +589,7 @@ processChatCommand cxt nm = \case
void . forkIO $ subscribeUsers True users
void . forkIO $ startFilesToReceive users
setAllExpireCIFlags True
mapM_ startBadgeWork users
ok_
APISuspendChat t -> do
chatWriteVar chatActivated False
@@ -3538,6 +3547,17 @@ processChatCommand cxt nm = \case
ShowProfile -> withUser $ \user@User {profile} -> pure $ CRUserProfile user (fromLocalProfile profile)
AddBadge cred -> withUser $ \user -> addUserBadge user cred >> ok user
APIRedeemBadgeCode userId codeText -> withUserId userId $ \user -> redeemBadgeCode nm user codeText
APIGetBadgeState userId -> withUserId userId $ \user -> do
-- the read also signals the worker, whose results follow as CEvtBadgeChanged
lift $ startBadgeWork user
CRBadgeState user <$> getUserBadgeState user
APIAckBadgeAlert userId badgePurchaseId alertKind snooze episode -> withUserId userId $ \user -> do
now <- badgeNow
let snoozeUntil = if snooze then Just (addUTCTime nominalDay now) else Nothing
withStore' $ \db -> setBadgeAlertAcked db badgePurchaseId alertKind episode snoozeUntil
-- after the write, so the pass it signals arms a wake for the snooze rather than raising again
lift $ startBadgeWork user
CRBadgeState user <$> getUserBadgeState user
SetBotCommands commands -> withUser $ \user@User {profile} -> do
let LocalProfile {preferences} = profile
prefs = Just (fromMaybe emptyChatPrefs preferences :: Preferences) {commands = Just commands}
@@ -3640,6 +3660,7 @@ processChatCommand cxt nm = \case
CLUserContact ucId -> "UserContact " <> tshow ucId
CLContactRequest crId -> "ContactRequest " <> tshow crId
CLFile fId -> "File " <> tshow fId
CLBadgeUser uId -> "BadgeUser " <> tshow uId
DebugEvent event -> toView event >> ok_
GetAgentSubsTotal userId -> withUserId userId $ \user -> do
users <- withStore' $ \db -> getUsers db
@@ -5135,12 +5156,16 @@ addUserBadge user cred@(BadgeCredential _ _ _ info) =
Just False -> throwCmdError "badge credential does not verify against configured key"
Just True -> do
now <- liftIO getCurrentTime
user' <- withFastStore' $ \db -> setUserBadge db user (Just (OwnBadge cred (mkBadgeStatus now (Just True) info)))
user' <- withFastStore $ \db -> setUserBadge db user (Just (OwnBadge cred (mkBadgeStatus now (Just True) info)))
presentUserBadgeToContacts user'
presentUserBadgeToContacts :: User -> CM ()
presentUserBadgeToContacts user'@User {profile = LocalProfile {localBadge}} = do
asks currentUser >>= atomically . (`writeTVar` Just user')
presentUserBadgeToContacts user'@User {userId, profile = LocalProfile {localBadge}} = do
-- a badge worker runs for every profile, not only the active one, so this refreshes the active
-- record where it is the same profile and must never switch to another
chatModifyVar currentUser $ \case
Just User {userId = activeId} | activeId == userId -> Just user'
active_ -> active_
lift $ withAgent' $ \a -> setUserEntitlement a (aUserId user') (badgeServerCredential localBadge)
cxt <- asks $ mkStoreCxt . config
contacts <- withFastStore' $ \db -> getUserContacts db cxt user'
@@ -5157,25 +5182,34 @@ presentUserBadgeToContacts user'@User {profile = LocalProfile {localBadge}} = do
-- stashed before the request is sent, so a retry reaches the service as the same signer.
-- A terminal answer drops the stash; a timeout keeps it.
redeemBadgeCode :: NetworkRequestMode -> User -> Text -> CM ChatResponse
redeemBadgeCode nm user codeText = do
redeemBadgeCode nm user@User {userId} codeText = do
code <- maybe (throwCmdError "invalid badge code") pure $ parseBadgeCode codeText
sendTarget <- asks (badgeServiceAddress . config) >>= maybe (throwCmdError "badge service not configured") pure
g <- asks random
now <- liftIO getCurrentTime
let codeSent = badgeCodeText code
redemption@BadgeCodeRedemption {purchaseKey, purchasePrivKey, masterKey} <-
withStore' $ \db ->
getBadgeCodeRedemption db user codeSent
>>= maybe (createBadgeCodeRedemption db g user codeSent now) pure
let req = BadgeServiceRequest {version = currentBadgeServiceVersion, purchaseKey = Just purchaseKey, request = BSCRedeemBadgeCode {masterKey, code = codeSent}}
respData <- sendServiceRequestTo nm user sendTarget Nothing (Just purchasePrivKey) req
case J.fromJSON (J.Object respData) of
J.Error e -> throwCmdError $ "invalid badge service response, " <> show e <> ": " <> respJSON respData
J.Success BSPError {code = errCode} -> do
when (terminalCodeError errCode) $ withStore' $ \db -> deleteBadgeCodeRedemption db (redemptionId redemption)
throwCmdError $ "badge service error: " <> T.unpack (badgeServiceErrorText errCode)
J.Success BSPBadgeCredential {credential = Just cred} -> storeRedeemedBadge user redemption cred
J.Success _ -> throwCmdError $ "unexpected badge service response: " <> respJSON respData
-- the guard, the request and the write are one section: without it two codes redeemed at once
-- both pass the guard and are both spent, for one badge
(present_, redeemed) <- withEntityLock "badgeRedeem" (CLBadgeUser userId) $ do
redemption_ <- withStore' $ \db -> getBadgeCodeRedemption db user codeSent
-- a code already redeemed here is allowed through: re-sending it returns the badge it bought
-- and adds nothing. Refused before its keys are stashed and before the request, so it stays unspent
replaying <- maybe (pure False) (\r -> withStore' $ \db -> isJust <$> getCodeBadgePurchase db r) redemption_
unless replaying $ whenM (withStore' (`userHasBadge` user)) $ throwCmdError "badge already active"
redemption@BadgeCodeRedemption {purchaseKey, purchasePrivKey, masterKey} <-
maybe (withStore' $ \db -> createBadgeCodeRedemption db g user codeSent now) pure redemption_
let req = BadgeServiceRequest {version = currentBadgeServiceVersion, purchaseKey = Just purchaseKey, request = BSCRedeemBadgeCode {masterKey, code = codeSent}}
respData <- sendServiceRequestTo nm user sendTarget Nothing (Just purchasePrivKey) req
case J.fromJSON (J.Object respData) of
J.Error e -> throwCmdError $ "invalid badge service response, " <> show e <> ": " <> respJSON respData
J.Success BSPError {code = errCode} -> do
when (terminalCodeError errCode) $ withStore' $ \db -> deleteBadgeCodeRedemption db (redemptionId redemption)
throwCmdError $ "badge service error: " <> T.unpack (badgeServiceErrorText errCode)
J.Success BSPBadgeCredential {credential = Just cred, statement} -> storeRedeemedBadge user redemption cred statement
J.Success _ -> throwCmdError $ "unexpected badge service response: " <> respJSON respData
-- outside the badge lock: the chat lock must not be taken under it
mapM_ presentUserBadgeToContacts present_
pure redeemed
where
-- re-encoded, not shown as received: JSON escapes the control characters a terminal acts on
respJSON = LB.unpack . J.encode
@@ -5197,10 +5231,317 @@ badgeServiceErrorText = \case
where
errorCodeChar c = isAsciiLower c || isDigit c || c == '_'
-- | Verify the credential before writing anything; the purchase, its issuance and the profile's
-- badge go in one transaction, and contacts are told after it commits.
storeRedeemedBadge :: User -> BadgeCodeRedemption -> BadgeCredential -> CM ChatResponse
storeRedeemedBadge user redemption@BadgeCodeRedemption {masterKey} cred@(BadgeCredential _ credMasterKey _ info@BadgeInfo {badgeExpiry}) =
-- | The only clock badge code reads, so a test can move the client and the service together.
badgeNow :: CM UTCTime
badgeNow = asks (badgeCurrentTime . config) >>= liftIO
-- | A signal carries nothing: each pass derives its work from stored state, so a signal lost or
-- duplicated changes no outcome.
startBadgeWork :: User -> CM' ()
startBadgeWork user = whenM (isJust <$> asks (badgeServiceAddress . config)) $ void $ getBadgeWorker user
-- | Exactly one caller starts the thread and the rest wait for it: the lookup and the create cannot
-- be one transaction, because starting a thread is not STM.
getBadgeWorker :: User -> CM' BadgeWorker
getBadgeWorker User {userId} = do
ws <- asks badgeWorkers
seq' <- asks badgeSeq
now <- liftIO getCurrentTime
withGetSessVar' seq' userId ws now startWorker signalWorker
where
startWorker v = do
badgeWork <- newTMVarIO ()
badgeWorkerAsync <- async $ void $ runExceptT $ runBadgeWorker userId badgeWork
let w = BadgeWorker {badgeWorkerAsync, badgeWork}
w <$ atomically (putTMVar (sessionVar v) w)
signalWorker v = do
w <- atomically $ readTMVar $ sessionVar v
w <$ atomically (void $ tryPutTMVar (badgeWork w) ())
-- | The alert last raised, so it is not repeated on every pass. The snooze is in the key because
-- kind and episode do not change when it lapses: the alert would match and stay silent until a restart.
type BadgeOccurrence = (BadgeAlertKind, Text, Maybe UTCTime)
-- | Nothing ends a pass, so a persistent fault is one attempt per stall interval rather than a hot
-- loop: every error returns a wake, and the wait is outside the retries.
runBadgeWorker :: UserId -> TMVar () -> CM ()
runBadgeWorker userId badgeWork = do
emitted <- newTVarIO Nothing
ri <- asks $ badgeRetryInterval . config
forever $ do
at_ <- withRetryInterval ri $ \_ loop -> do
lift waitChatStartedAndActivated
now <- badgeNow
let stalled = pure $ Just $ badgeStalledInterval `addUTCTime` now
updateUserBadge userId emitted now `catchAllErrors` retryBadgeError loop stalled
now <- badgeNow
liftIO $ waitBadgeWake badgeWork now at_
retryBadgeError :: CM a -> CM a -> ChatError -> CM a
retryBadgeError loop stalled e = eToView e >> if badgeErrorRetry e then loop else stalled
-- | The signal is taken only by the wait that reports it - the take and the timer read are one
-- transaction. now is the badge clock, so the remaining time counts down rather than re-reading it.
waitBadgeWake :: TMVar () -> UTCTime -> Maybe UTCTime -> IO ()
waitBadgeWake badgeWork now = \case
Nothing -> atomically $ takeTMVar badgeWork
Just at -> waitFor $ diffToMicroseconds $ min badgeMaxWake $ diffUTCTime at now
where
waitFor time
| time <= 0 = pure ()
| otherwise = do
let maxWait = min time $ fromIntegral (maxBound :: Int)
timer <- registerDelay $ fromIntegral maxWait
signalled <- atomically $ do
w <- tryTakeTMVar badgeWork
fired <- readTVar timer
unless (isJust w || fired) retry
pure $ isJust w
unless signalled $ waitFor $ time - maxWait
-- | Bounds the wait: a paidThrough far enough out would overflow the microsecond conversion,
-- wrap negative and spin the worker. Longer than any entitlement, so no real wake is early.
badgeMaxWake :: NominalDiffTime
badgeMaxWake = 100 * 365 * nominalDay
-- | Retire what has ended, renew what is due, then report the next wake. Waking early, late or not
-- at all changes only timing: each run reads stored state and works out what to do.
updateUserBadge :: UserId -> TVar (Maybe BadgeOccurrence) -> UTCTime -> CM (Maybe UTCTime)
updateUserBadge userId emitted now = do
user <- withStore $ \db -> getUser db userId
withStore' (`getUserBadgePurchase` user) >>= \case
Nothing -> pure Nothing
Just p@UserBadgePurchase {badgePurchaseId} ->
withStore' (`getBadgeLedgerLastEntry` badgePurchaseId) >>= \case
Nothing -> pure Nothing
Just balance -> do
-- retirement needs no service and an unbounded retry would not return before it
retired <- retireExpiredBadge user p now balance
latest <- withStore' (`getLatestIssuedCredential` badgePurchaseId)
let requestDue = not retired && badgeRequestDue now (shownBadgeCredential user p) latest balance
(balance', serviceAt) <-
if requestDue
then either ((balance,) . Just) (,Nothing) <$> requestBadgeIssue userId p now
else pure (balance, Nothing)
-- presenting broadcasts the record it is handed, and the request above can block for the
-- whole service timeout, so this read belongs after it and not at the top of the pass
user' <- withStore $ \db -> getUser db userId
-- and the purchase, or an alert acked while the request was in flight is raised again
p' <- fromMaybe p <$> withStore' (`getBadgePurchase` badgePurchaseId)
let issued = balanceStartTs balance' /= balanceStartTs balance
-- outside the badge lock: the chat lock must not be taken under it
unless retired $ presentIssuedBadge user' p' now
emitBadgeAlert user' emitted p' now balance'
-- retiring and presenting both replace the badge on the record read above, so it is read again
user'' <- withStore $ \db -> getUser db userId
when (retired || issued) $ toView . CEvtBadgeChanged user'' =<< getUserBadgeState user''
-- a snooze is the one wake that is not in the ledger: nothing else brings the alert back,
-- since support having ended leaves both ledger boundaries in the past
let UserBadgePurchase {alertSnoozeUntil} = p'
snoozeAt = find (> now) alertSnoozeUntil
stalledAt = if requestDue && not issued then Just $ badgeStalledInterval `addUTCTime` now else Nothing
pure $ earliestTime [serviceAt, snoozeAt, stalledAt, badgeBoundary now (shownBadgeCredential user'' p') balance']
-- | Support ended is the only alert raised here: the others need subscriptions, and warning before
-- a prepaid badge ends is not actionable while topping up cannot credit months without issuing.
-- TODO [badges] BAPrepaidEnding belongs here, three days before paidThrough, once that exists.
derivedBadgeAlert :: UTCTime -> StatementEntry -> Maybe BadgeAlert
derivedBadgeAlert now b
| balanceMonths b == 0 && endsAt <= now =
Just BadgeAlert {kind = BASupportEnded, episode = safeDecodeUtf8 $ strEncode endsAt, date = endsAt, price = Nothing}
| otherwise = Nothing
where
endsAt = L.paidThrough b
-- | Derived from state rather than kept pending: raised unless this occurrence is the one already
-- answered, and raised again once a snooze that answered it lapses.
unansweredBadgeAlert :: UTCTime -> UserBadgePurchase -> StatementEntry -> Maybe BadgeAlert
unansweredBadgeAlert now UserBadgePurchase {alertAcked, alertSnoozeUntil} balance =
case derivedBadgeAlert now balance of
Just alert@BadgeAlert {kind, episode}
| alertAcked /= Just (kind, episode) || maybe False (now >=) alertSnoozeUntil -> Just alert
_ -> Nothing
emitBadgeAlert :: User -> TVar (Maybe BadgeOccurrence) -> UserBadgePurchase -> UTCTime -> StatementEntry -> CM ()
emitBadgeAlert user emitted p@UserBadgePurchase {alertSnoozeUntil} now balance =
forM_ (unansweredBadgeAlert now p balance) $ \alert@BadgeAlert {kind, episode} -> do
let occurrence = Just (kind, episode, alertSnoozeUntil)
raised <- atomically $ stateTVar emitted (,occurrence)
when (raised /= occurrence) $ toView $ CEvtBadgeAlert user alert
-- | Read from stored rows alone; the worker's results follow as CEvtBadgeChanged.
getUserBadgeState :: User -> CM (Maybe BadgeState)
getUserBadgeState user = do
now <- badgeNow
withStore' (`getUserBadgePurchase` user) >>= \case
Nothing -> pure Nothing
Just p@UserBadgePurchase {badgePurchaseId} ->
fmap (badgeStateOf now p) <$> withStore' (`getBadgeLedgerLastEntry` badgePurchaseId)
where
badgeStateOf now p@UserBadgePurchase {badgePurchaseId, badgeType} balance =
BadgeState
{ badgePurchaseId,
badgeType,
monthsLeft = balanceMonths balance,
paidThrough = L.paidThrough balance,
renewsAt = Nothing,
willRenew = False,
alert = unansweredBadgeAlert now p balance
}
-- | How long a month that did not issue waits before it is tried again, whatever stopped it. Not
-- derived from the failure, so a misclassified one cannot leave a funded badge to expire.
badgeStalledInterval :: NominalDiffTime
badgeStalledInterval = nominalDay
-- | The wait after a service refusal, floored at initialInterval so answering 0 cannot spin the
-- worker, and uncapped above it.
badgeRetryAfter :: RetryInterval -> Maybe Word32 -> NominalDiffTime
badgeRetryAfter RetryInterval {initialInterval} = maybe badgeStalledInterval (max floorWait . fromIntegral)
where
floorWait = fromIntegral initialInterval / 1000000
-- | How far ahead of the shown credential's expiry the renewal is requested - a day, so a failure
-- has that long to retry. The wake and the due check both derive from it and have to agree.
badgeRequestLead :: NominalDiffTime
badgeRequestLead = nominalDay
-- | The credential the profile is showing for this purchase. Nothing when the purchase is not the
-- one being shown, or when a crash left the issuance written and the profile not.
shownBadgeCredential :: User -> UserBadgePurchase -> Maybe BadgeCredential
shownBadgeCredential User {profile = LocalProfile {localBadge}} UserBadgePurchase {shown}
| not shown = Nothing
| otherwise = case localBadge of
Just (OwnBadge cred _) -> Just cred
_ -> Nothing
credentialExpiry :: BadgeCredential -> UTCTime
credentialExpiry (BadgeCredential _ _ _ BadgeInfo {badgeExpiry}) = badgeExpiry
-- | Timed off the shown credential, not the period end: renewing around its shared expiry is what
-- joins the anonymity set. Latest still equal to shown means this month has not been asked for.
badgeRequestDue :: UTCTime -> Maybe BadgeCredential -> Maybe BadgeCredential -> StatementEntry -> Bool
badgeRequestDue now shownCred latestCred balance =
balanceMonths balance > 0 && latestCred == shownCred && maybe False lapsingSoon shownCred
where
lapsingSoon cred = credentialExpiry cred <= badgeRequestLead `addUTCTime` now
-- | The request and the presentation, a day apart, both read off the credential the profile shows,
-- and the end of what is paid for. The credential's expiry window is what covers renewal, so it
-- says nothing about entitlement: paidThrough is when that ends and the badge has to come off.
-- TODO [badges] every client whose credential shares an expiry requests at the same instant. Only
-- the expiry has to be shared, so the request could fall anywhere in its lead without splitting
-- the anonymity set - spreading the load, and any outage, off a single moment.
badgeBoundary :: UTCTime -> Maybe BadgeCredential -> StatementEntry -> Maybe UTCTime
badgeBoundary now shownCred balance = case filter (> now) moments of
[] -> Nothing
ts -> Just $ minimum ts
where
moments = L.paidThrough balance : maybe [] renewalMoments shownCred
renewalMoments cred =
let expiry = credentialExpiry cred
in [negate badgeRequestLead `addUTCTime` expiry, expiry]
earliestTime :: [Maybe UTCTime] -> Maybe UTCTime
earliestTime ts = case catMaybes ts of
[] -> Nothing
ts' -> Just $ minimum ts'
-- | Only a failure that can clear on its own is repeated; every other throw is terminal, and
-- repeating it would spin. Service errors are classified by retryAfter in requestBadgeIssue.
badgeErrorRetry :: ChatError -> Bool
badgeErrorRetry = \case
ChatErrorAgent {agentError} -> retryable agentError
_ -> False
where
-- an unanswered request is the likeliest renewal failure and temporaryOrHostError does not
-- cover it: that classifies reaching the server, and this timeout is the agent's own
retryable = \case
AGENT (A_SERVICE ASETimeout) -> True
e -> temporaryOrHostError e
-- | Ask the service for the month that is due and apply the response. A timeout writes nothing, so
-- the same request is sent again on the next pass. 'Left' is a service error, already reported, and
-- carries when to try again, since a service error is answered rather than thrown.
requestBadgeIssue :: UserId -> UserBadgePurchase -> UTCTime -> CM (Either UTCTime StatementEntry)
requestBadgeIssue userId UserBadgePurchase {badgePurchaseId, purchaseKey, purchasePrivKey, masterKey} now = do
sendTarget <- asks (badgeServiceAddress . config) >>= maybe (throwCmdError "badge service not configured") pure
withEntityLock "badgeIssue" (CLBadgeUser userId) $ do
user <- withStore $ \db -> getUser db userId
lastEntry <- withStore' (`getBadgeLedgerLastEntry` badgePurchaseId) >>= maybe (throwCmdError "badge ledger has no entry to assert") pure
let req =
BadgeServiceRequest
{ version = currentBadgeServiceVersion,
purchaseKey = Just purchaseKey,
request = BSCIssueBadge {balance = BadgeBalance {lastEntry}}
}
respData <- sendServiceRequestTo NRMBackground user sendTarget Nothing (Just purchasePrivKey) req
case J.fromJSON (J.Object respData) of
J.Success BSPBadgeCredential {credential, statement} -> do
cred_ <- verifyIssuedCredential masterKey credential
-- TODO [badges] the statement is applied either way, so a failed verification spends the
-- month with nothing to show for it; that needs an alert, not only a line in the log
g <- asks random
applied <- withStore' $ \db -> applyBadgeStatement db g badgePurchaseId statement cred_ now
unless applied $ eToView $ ChatError $ CEInternalError "issued badge credential has no ledger row to store it against"
Right <$> (withStore' (`getBadgeLedgerLastEntry` badgePurchaseId) >>= maybe (throwCmdError "badge ledger has no balance") pure)
J.Success BSPError {code, retryAfter} -> do
eToView $ ChatError $ CECommandError $ "badge service error: " <> T.unpack (badgeServiceErrorText code)
ri <- asks $ badgeRetryInterval . config
pure $ Left $ badgeRetryAfter ri retryAfter `addUTCTime` now
_ -> throwCmdError "unexpected badge service response"
-- | The signature covers the master key inside the credential, so it verifies no matter which key
-- that is - the credential is stored only when that key is also this purchase's.
verifyIssuedCredential :: BadgeMasterKey -> Maybe BadgeCredential -> CM (Maybe BadgeCredential)
verifyIssuedCredential _ Nothing = pure Nothing
verifyIssuedCredential masterKey (Just cred@(BadgeCredential _ credMasterKey _ _)) =
verifyOwnBadge cred >>= \case
Just True | credMasterKey == masterKey -> pure $ Just cred
Just True -> Nothing <$ eToView (ChatError $ CEInternalError "issued badge credential is for a different master key")
_ -> Nothing <$ eToView (ChatError $ CEInternalError "issued badge credential does not verify")
-- | Present the newest issued credential once the one on the profile has run out. It is written in
-- a separate transaction from the issuance, so a crash between the two is repaired at the next run.
presentIssuedBadge :: User -> UserBadgePurchase -> UTCTime -> CM ()
presentIssuedBadge user p@UserBadgePurchase {badgePurchaseId, shown} now
| not shown = pure ()
| otherwise = do
cred_ <- withStore' (`getLatestIssuedCredential` badgePurchaseId)
forM_ cred_ $ \cred@(BadgeCredential _ _ _ info) ->
when (presentDue cred) $ do
user' <- withStore $ \db -> setUserBadge db user (Just $ OwnBadge cred (mkBadgeStatus now (Just True) info))
presentUserBadgeToContacts user'
where
shownCred = shownBadgeCredential user p
-- Held back until the shown credential lapses, so the broadcast does not correlate with the
-- request that produced it. Nothing shown at all is the state a lost profile write leaves.
presentDue cred = Just cred /= shownCred && maybe True ((<= now) . credentialExpiry) shownCred
-- | The visible half of "the badge expired".
retireExpiredBadge :: User -> UserBadgePurchase -> UTCTime -> StatementEntry -> CM Bool
retireExpiredBadge user UserBadgePurchase {badgePurchaseId, shown} now balance
| not (shown && L.paidThrough balance <= now) = pure False
| otherwise = do
user' <- withStore $ \db -> do
liftIO $ clearShownBadge db user badgePurchaseId
setUserBadge db user Nothing
True <$ presentUserBadgeToContacts user'
-- | Waiting on the var rather than skipping an empty one is what catches a worker whose creator
-- had not filled it when the map was swapped out.
stopBadgeWorkers :: TM.TMap UserId (SessionVar BadgeWorker) -> IO ()
stopBadgeWorkers workers =
atomically (swapTVar workers M.empty) >>= mapM_ cancelBadgeWorker
where
cancelBadgeWorker v =
void $ forkIO $ atomically (badgeWorkerAsync <$> readTMVar (sessionVar v)) >>= uninterruptibleCancel
-- | Verify the credential before writing anything; the purchase, the statement's rows, the
-- issuance and the profile's badge go in one transaction. Answers the user to tell contacts about,
-- which the caller does once the badge lock is released.
storeRedeemedBadge :: User -> BadgeCodeRedemption -> BadgeCredential -> BadgeStatement -> CM (Maybe User, ChatResponse)
storeRedeemedBadge user@User {userId} redemption@BadgeCodeRedemption {masterKey} cred@(BadgeCredential _ credMasterKey _ info) statement =
verifyOwnBadge cred >>= \case
Nothing -> throwCmdError "redeemed badge credential names an unknown badge key index"
Just False -> throwCmdError "redeemed badge credential does not verify against configured key"
@@ -5209,16 +5550,37 @@ storeRedeemedBadge user redemption@BadgeCodeRedemption {masterKey} cred@(BadgeCr
Just True | credMasterKey /= masterKey -> throwCmdError "redeemed badge credential is for a different master key"
Just True -> do
g <- asks random
now <- liftIO getCurrentTime
now <- badgeNow
let badge = OwnBadge cred (mkBadgeStatus now (Just True) info)
-- TODO [badges] copy the statement's ledger entries, and retire a previously held badge
(user', newBadge) <- withStore' $ \db -> do
newBadge <- createCodeBadgePurchase db g user redemption cred badgeExpiry now
-- TODO [badges] retire a previously held badge
(user', newBadge, applied) <- withStore $ \db -> do
(purchaseId, newBadge) <- liftIO $ createCodeBadgePurchase db user redemption cred now
applied <- liftIO $ applyBadgeStatement db g purchaseId statement (Just cred) now
-- a replay must not put a superseded badge back, or tell every contact again
user' <- if newBadge then setUserBadge db user (Just badge) else pure user
pure (user', newBadge)
when newBadge $ presentUserBadgeToContacts user'
pure $ CRBadgeRedeemed user' badge newBadge
user' <- if newBadge then setUserBadge db user (Just badge) else getUser db userId
pure (user', newBadge, applied)
unless applied $ eToView $ ChatError $ CEInternalError "redeemed badge credential has no ledger row to store it against"
-- nothing is due yet, but a pass is what arms the next wake, and this is the first purchase
lift $ startBadgeWork user'
pure (if newBadge then Just user' else Nothing, CRBadgeRedeemed user' badge newBadge)
-- | Store the statement's rows, then the credential against the badge debit row among them.
-- 'False' when that row cannot be found, which the caller reports rather than drop in silence.
applyBadgeStatement :: DB.Connection -> TVar ChaChaDRG -> Int64 -> BadgeStatement -> Maybe BadgeCredential -> UTCTime -> IO Bool
applyBadgeStatement db g purchaseId BadgeStatement {entries} cred_ now = do
tip <- getBadgeLedgerLastEntry db purchaseId
storeBadgeStatement db purchaseId tip entries now
case (,) <$> cred_ <*> issuedEntryId of
Nothing -> pure True
Just (cred, entryUuid) ->
getBadgeLedgerEntryId db purchaseId entryUuid >>= \case
Nothing -> pure False
Just entryId -> storeBadgeIssuance db g purchaseId entryId cred now
where
-- the credential belongs to the last month the statement issued
issuedEntryId = case [entryId | StatementEntry {entryId, entryType = SEDebit SDBadge} <- entries] of
[] -> Nothing
ids -> Just (last ids)
sendServiceRequestTo :: J.ToJSON a => NetworkRequestMode -> User -> ConnectTarget 'CMContact -> Maybe NominalDiffTime -> Maybe C.PrivateKeyEd25519 -> a -> CM J.Object
sendServiceRequestTo nm user sendTarget requestTimeout signKey request = do
@@ -5625,6 +5987,8 @@ chatCommandP =
"/_reject " *> (APIRejectContact <$> A.decimal <*> (" notify=" *> onOffP <|> pure False)),
"/_service_request " *> (APISendServiceRequest <$> A.decimal <* A.space <*> strP <*> optional (" timeout=" *> (realToFrac <$> A.double)) <*> optional (" sign_key=" *> strP) <* A.space <*> jsonP),
"/_redeem_badge_code " *> (APIRedeemBadgeCode <$> A.decimal <* A.space <*> textP),
"/_badge state " *> (APIGetBadgeState <$> A.decimal),
"/_badge ack " *> (APIAckBadgeAlert <$> A.decimal <* A.space <*> A.decimal <* A.space <*> badgeAlertKindP <* A.space <*> onOffP <* A.space <*> textP),
"/_service_response " *> (APISendServiceResponse <$> A.decimal <* A.space <*> strP <* A.space <*> jsonP),
"/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP),
"/call " *> char_ '@' *> (SendCallInvitation <$> displayNameP <*> pure defaultCallType),
@@ -6044,6 +6408,9 @@ chatCommandP =
descr <- A.takeWhile1 isSpace *> (T.dropWhileEnd isSpace <$> textP) <|> pure ""
pure $ if T.null descr then Nothing else Just $ T.take 160 descr
textP = safeDecodeUtf8 <$> A.takeByteString
badgeAlertKindP = do
t <- A.takeTill (== ' ')
maybe (fail "bad badge alert kind") pure $ textDecode $ safeDecodeUtf8 t
pwdP = jsonP <|> (UserPwd . safeDecodeUtf8 <$> A.takeTill (== ' '))
verifyCodeP = safeDecodeUtf8 <$> A.takeWhile (\c -> isDigit c || c == ' ')
msgTextP = jsonP <|> textP
+229 -24
View File
@@ -7,10 +7,22 @@
module Simplex.Chat.Store.Badges
( BadgeCodeRedemption (..),
UserBadgePurchase (..),
getUserBadgePurchase,
getBadgePurchase,
userHasBadge,
setBadgeAlertAcked,
clearShownBadge,
getBadgeCodeRedemption,
createBadgeCodeRedemption,
deleteBadgeCodeRedemption,
createCodeBadgePurchase,
getCodeBadgePurchase,
storeBadgeIssuance,
getLatestIssuedCredential,
storeBadgeStatement,
getBadgeLedgerLastEntry,
getBadgeLedgerEntryId,
)
where
@@ -19,23 +31,26 @@ import Crypto.Random (ChaChaDRG)
import qualified Data.Aeson as J
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Int (Int64)
import Data.Maybe (isJust)
import Data.Text (Text)
import Data.Time.Clock (UTCTime)
import Simplex.Chat.Badges
import Simplex.Chat.Badges.Types (BadgePurchaseStatus (..))
import Simplex.Chat.Badges.Ledger
import Simplex.Chat.Badges.Service (StatementCreditType (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..))
import Simplex.Chat.Badges.Types (BadgeAlertKind, BadgePurchaseStatus (..))
import Simplex.Chat.Store.Shared (insertedRowId)
import Simplex.Chat.Types
import Simplex.Messaging.Agent.Store.DB (Binary (..))
import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..))
import qualified Simplex.Messaging.Agent.Store.DB as DB
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String (strEncode)
import Simplex.Messaging.Util (maybeFirstRow, safeDecodeUtf8)
import Simplex.Messaging.Util (decodeJSON, maybeFirstRow, maybeFirstRow', safeDecodeUtf8)
#if defined(dbPostgres)
import Database.PostgreSQL.Simple (Only (..))
import Database.PostgreSQL.Simple (Only (..), (:.) (..))
import Database.PostgreSQL.Simple.SqlQQ (sql)
#else
import Database.SQLite.Simple (Only (..))
import Database.SQLite.Simple (Only (..), (:.) (..))
import Database.SQLite.Simple.QQ (sql)
#endif
@@ -90,21 +105,13 @@ deleteBadgeCodeRedemption db redemptionId =
|]
(redemptionId, redemptionId)
-- | The purchase a redeemed code created, its issuance, and the profile's pointer to it.
-- False when the code was already redeemed here: the service replays the credential it issued,
-- and that must add no purchase and leave the shown badge alone.
createCodeBadgePurchase :: DB.Connection -> TVar ChaChaDRG -> User -> BadgeCodeRedemption -> BadgeCredential -> UTCTime -> UTCTime -> IO Bool
createCodeBadgePurchase db g User {userId} redemption credential expiry now =
-- | 'False' when the code was already redeemed here: the service replays the credential it
-- issued, and that must add no purchase and leave the shown badge alone.
createCodeBadgePurchase :: DB.Connection -> User -> BadgeCodeRedemption -> BadgeCredential -> UTCTime -> IO (Int64, Bool)
createCodeBadgePurchase db User {userId} redemption credential now =
getCodeBadgePurchase db redemption >>= \case
Just _ -> pure False
Just purchaseId -> pure (purchaseId, False)
Nothing -> do
purchaseId <- insertPurchase
DB.execute db "UPDATE users SET shown_badge_id = ? WHERE user_id = ?" (purchaseId, userId)
pure True
where
BadgeCodeRedemption {redemptionId, purchaseKey, purchasePrivKey, masterKey = BadgeMasterKey mk} = redemption
BadgeCredential {badgeInfo = BadgeInfo {badgeType}} = credential
insertPurchase = do
DB.execute
db
[sql|
@@ -114,19 +121,217 @@ createCodeBadgePurchase db g User {userId} redemption credential expiry now =
|]
(userId, purchaseKey, purchasePrivKey, Binary mk, badgeType, badgeType, PSIssued, redemptionId, now, now)
purchaseId <- insertedRowId db
DB.execute db "UPDATE users SET shown_badge_id = ? WHERE user_id = ?" (purchaseId, userId)
pure (purchaseId, True)
where
BadgeCodeRedemption {redemptionId, purchaseKey, purchasePrivKey, masterKey = BadgeMasterKey mk} = redemption
BadgeCredential {badgeInfo = BadgeInfo {badgeType}} = credential
-- | The period comes from the ledger, the expiry from the credential, which runs a week longer.
-- 'False' means no issuance row was written, which the caller reports rather than drop in silence.
-- A replayed statement names a month already issued, and one month has one issuance.
storeBadgeIssuance :: DB.Connection -> TVar ChaChaDRG -> Int64 -> Int64 -> BadgeCredential -> UTCTime -> IO Bool
storeBadgeIssuance db g badgePurchaseId entryId credential now =
getIssuedPeriod db badgePurchaseId entryId >>= \case
Nothing -> pure False
Just (periodStart, periodEnd) -> do
issuanceId <- safeDecodeUtf8 . strEncode <$> atomically (C.randomBytes 16 g)
-- TODO [badges] the credential's expiry stands in for the period end, which is up to a
-- week later, until the statement carries the real period
DB.execute
db
[sql|
INSERT INTO badge_issuances (issuance_id, badge_purchase_id, badge_type, period_start, period_end, expiry, credential, created_at)
VALUES (?,?,?,?,?,?,?,?)
INSERT INTO badge_issuances (issuance_id, badge_purchase_id, entry_id, badge_type, period_start, period_end, expiry, credential, created_at)
VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT (badge_purchase_id, entry_id) DO NOTHING
|]
(issuanceId, purchaseId, badgeType, now, expiry, expiry, Binary (LB.toStrict $ J.encode credential), now)
pure purchaseId
((issuanceId, badgePurchaseId, entryId, badgeType) :. (periodStart, periodEnd, badgeExpiry, Binary (LB.toStrict $ J.encode credential), now))
pure True
where
BadgeCredential {badgeInfo = BadgeInfo {badgeType, badgeExpiry}} = credential
getLatestIssuedCredential :: DB.Connection -> Int64 -> IO (Maybe BadgeCredential)
getLatestIssuedCredential db badgePurchaseId = do
rows <-
DB.query
db
[sql|
SELECT credential FROM badge_issuances
WHERE badge_purchase_id = ?
ORDER BY period_end DESC
LIMIT 1
|]
(Only badgePurchaseId)
pure $ case rows of
[Only (Binary bs)] -> J.decodeStrict' bs
_ -> Nothing
-- the start is read from the row before rather than by subtracting a month, which clips
getIssuedPeriod :: DB.Connection -> Int64 -> Int64 -> IO (Maybe (UTCTime, UTCTime))
getIssuedPeriod db badgePurchaseId entryId = do
rows <-
DB.query
db
[sql|
SELECT
(SELECT prev.balance_start_ts FROM badge_ledger prev
WHERE prev.badge_purchase_id = issued.badge_purchase_id AND prev.entry_id < issued.entry_id
ORDER BY prev.entry_id DESC LIMIT 1),
issued.balance_start_ts
FROM badge_ledger issued
WHERE issued.badge_purchase_id = ? AND issued.entry_id = ?
|]
(badgePurchaseId, entryId)
-- no preceding row means no credit was ever stored, so the period this row issued is unknown
pure $ case rows of
[(Just periodStart, periodEnd)] -> Just (periodStart, periodEnd)
_ -> Nothing
getCodeBadgePurchase :: DB.Connection -> BadgeCodeRedemption -> IO (Maybe Int64)
getCodeBadgePurchase db BadgeCodeRedemption {redemptionId} =
maybeFirstRow fromOnly $
DB.query db "SELECT badge_purchase_id FROM badge_purchases WHERE badge_code_redemption_id = ?" (Only redemptionId)
data UserBadgePurchase = UserBadgePurchase
{ badgePurchaseId :: Int64,
purchaseKey :: C.PublicKeyEd25519,
purchasePrivKey :: C.PrivateKeyEd25519,
masterKey :: BadgeMasterKey,
badgeType :: BadgeType,
shown :: Bool,
alertAcked :: Maybe (BadgeAlertKind, Text),
alertSnoozeUntil :: Maybe UTCTime
}
-- | Newest, not the one shown_badge_id points at - retirement clears that, and the support ended
-- alert is recomputed from this purchase after the badge stops being shown.
getUserBadgePurchase :: DB.Connection -> User -> IO (Maybe UserBadgePurchase)
getUserBadgePurchase db User {userId} =
maybeFirstRow fromOnly newestId >>= maybe (pure Nothing) (getBadgePurchase db)
where
newestId =
DB.query
db
[sql|
SELECT badge_purchase_id FROM badge_purchases
WHERE user_id = ? AND purchase_priv_key IS NOT NULL
ORDER BY badge_purchase_id DESC
LIMIT 1
|]
(Only userId)
-- shown is a CASE because in Postgres a comparison is boolean, which BoolInt rejects.
getBadgePurchase :: DB.Connection -> Int64 -> IO (Maybe UserBadgePurchase)
getBadgePurchase db purchaseId =
maybeFirstRow toPurchase $
DB.query
db
[sql|
SELECT p.badge_purchase_id, p.purchase_key, p.purchase_priv_key, p.master_key, p.current_badge_type,
(CASE WHEN u.shown_badge_id = p.badge_purchase_id THEN 1 ELSE 0 END),
p.alert_acked_kind, p.alert_acked_episode, p.alert_snooze_until
FROM badge_purchases p
JOIN users u ON u.user_id = p.user_id
WHERE p.badge_purchase_id = ? AND p.purchase_priv_key IS NOT NULL
|]
(Only purchaseId)
where
toPurchase (badgePurchaseId, purchaseKey, purchasePrivKey, Binary mk, badgeType, shown_, ackedKind_, ackedEpisode_, alertSnoozeUntil) =
UserBadgePurchase
{ badgePurchaseId,
purchaseKey,
purchasePrivKey,
masterKey = BadgeMasterKey mk,
badgeType,
shown = unBI shown_,
alertAcked = (,) <$> ackedKind_ <*> ackedEpisode_,
alertSnoozeUntil
}
-- | Whether a badge is on the profile now: set when a redemption stores one, cleared when it is
-- retired. Read as the id rather than as a comparison, which in Postgres would be a boolean.
userHasBadge :: DB.Connection -> User -> IO Bool
userHasBadge db User {userId} =
maybeFirstRow' False shownBadge $
DB.query db "SELECT shown_badge_id FROM users WHERE user_id = ?" (Only userId)
where
shownBadge :: Only (Maybe Int64) -> Bool
shownBadge = isJust . fromOnly
-- | An ack and a snooze both record the occurrence answered; a snooze also records how long it
-- holds, so that it silences that occurrence and not whichever one is derived next.
setBadgeAlertAcked :: DB.Connection -> Int64 -> BadgeAlertKind -> Text -> Maybe UTCTime -> IO ()
setBadgeAlertAcked db badgePurchaseId kind episode snoozeUntil =
DB.execute
db
"UPDATE badge_purchases SET alert_acked_kind = ?, alert_acked_episode = ?, alert_snooze_until = ? WHERE badge_purchase_id = ?"
(kind, episode, snoozeUntil, badgePurchaseId)
-- | Stop showing a badge that has expired unrenewed; the profile update is broadcast by the caller.
clearShownBadge :: DB.Connection -> User -> Int64 -> IO ()
clearShownBadge db User {userId} badgePurchaseId =
DB.execute db "UPDATE users SET shown_badge_id = NULL WHERE user_id = ? AND shown_badge_id = ?" (userId, badgePurchaseId)
-- | Verbatim, entry_uuid and type included: the client authors no row, or the two sides stop
-- holding the same ledger. DO NOTHING makes a re-applied statement a no-op rather than a throw.
-- An entry whose balance does not follow from the one before it is stored and marked, not refused.
storeBadgeStatement :: DB.Connection -> Int64 -> Maybe StatementEntry -> [StatementEntry] -> UTCTime -> IO ()
storeBadgeStatement db badgePurchaseId tip entries now =
mapM_ storeEntry $ balanceChecked tip entries
where
storeEntry (StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince, createdAt, entryType}, checked) =
DB.execute
db
[sql|
INSERT INTO badge_ledger
(entry_uuid, badge_purchase_id, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type,
was_paused_since, service_created_at, created_at, entry_type, entry_credit_type, entry_debit_type,
entry_type_unknown, entry_type_value, balance_checked)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT (entry_uuid) DO NOTHING
|]
( (entryId, badgePurchaseId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince)
:. (createdAt, now, entryTypeT, creditType, debitType, BI typeUnknown, entryTypeValue, BI <$> checked)
)
where
(entryTypeT, creditType, debitType) = entryTypeColumns entryType
-- kept for every entry, not only for a type this version cannot decode: a tag alone does
-- not rebuild the types that name an invoice, a charge or another purchase
entryTypeValue = safeDecodeUtf8 . LB.toStrict $ case entryType of
SECredit c -> J.encode c
SEDebit d -> J.encode d
typeUnknown = case entryType of
SECredit SCUnknown {} -> True
SEDebit SDUnknown {} -> True
_ -> False
-- | The balance is the last row; nothing derives it by summing the history.
getBadgeLedgerLastEntry :: DB.Connection -> Int64 -> IO (Maybe StatementEntry)
getBadgeLedgerLastEntry db badgePurchaseId =
maybeFirstRow' Nothing toEntry $
DB.query
db
[sql|
SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type,
was_paused_since, service_created_at, entry_type, entry_credit_type, entry_debit_type, entry_type_value
FROM badge_ledger
WHERE badge_purchase_id = ?
ORDER BY entry_id DESC
LIMIT 1
|]
(Only badgePurchaseId)
where
toEntry ((entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType) :. (wasPausedSince, createdAt, entryType_, credit_, debit_, value_)) =
(\entryType -> StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince, createdAt, entryType})
<$> maybe (entryTypeFromColumns entryType_ credit_ debit_) (entryTypeFromValue entryType_) value_
-- | Decodes the stored JSON rather than rebuilding from the tag, so a version that has since
-- learnt the type reads it with its fields, and one that has not still gets it back verbatim.
entryTypeFromValue :: Text -> Text -> Maybe StatementEntryType
entryTypeFromValue entryTypeT value_ = case entryTypeT of
"credit" -> SECredit <$> decodeJSON value_
"debit" -> SEDebit <$> decodeJSON value_
_ -> Nothing
getBadgeLedgerEntryId :: DB.Connection -> Int64 -> Text -> IO (Maybe Int64)
getBadgeLedgerEntryId db badgePurchaseId entryUuid =
maybeFirstRow fromOnly $
DB.query db "SELECT entry_id FROM badge_ledger WHERE badge_purchase_id = ? AND entry_uuid = ?" (badgePurchaseId, entryUuid)
@@ -143,6 +143,7 @@ CREATE TABLE @badge_ledger(
change_months SMALLINT NOT NULL,
balance_months SMALLINT NOT NULL,
balance_start_ts TIMESTAMPTZ NOT NULL,
balance_anchor_ts TIMESTAMPTZ NOT NULL,
balance_badge_type TEXT NOT NULL,
was_paused_since TIMESTAMPTZ,
service_created_at TIMESTAMPTZ NOT NULL,
@@ -183,6 +184,8 @@ CREATE TABLE @badge_issuances(
CREATE INDEX @idx_badge_issuances_purchase ON @badge_issuances(badge_purchase_id, issuance_id);
CREATE INDEX @idx_badge_issuances_entry ON @badge_issuances(entry_id);
CREATE UNIQUE INDEX @idx_badge_issuances_purchase_entry ON @badge_issuances(badge_purchase_id, entry_id);
|]
badgeSchemaTablesDown :: Text
@@ -190,6 +193,7 @@ badgeSchemaTablesDown =
[r|
DROP INDEX @idx_badge_issuances_purchase;
DROP INDEX @idx_badge_issuances_entry;
DROP INDEX @idx_badge_issuances_purchase_entry;
DROP TABLE @badge_issuances;
DROP INDEX @idx_badge_ledger_uuid;
DROP INDEX @idx_badge_ledger_purchase;
@@ -237,6 +241,8 @@ ALTER TABLE badge_ledger ADD COLUMN entry_type_unknown SMALLINT NOT NULL DEFAULT
ALTER TABLE badge_ledger ADD COLUMN entry_type_value TEXT;
ALTER TABLE badge_ledger ADD COLUMN balance_checked SMALLINT;
CREATE INDEX idx_badge_purchases_user ON badge_purchases(user_id);
ALTER TABLE users ADD COLUMN shown_badge_id BIGINT REFERENCES badge_purchases ON DELETE SET NULL;
@@ -223,6 +223,7 @@ CREATE TABLE test_chat_schema.badge_ledger (
change_months smallint NOT NULL,
balance_months smallint NOT NULL,
balance_start_ts timestamp with time zone NOT NULL,
balance_anchor_ts timestamp with time zone NOT NULL,
balance_badge_type text NOT NULL,
was_paused_since timestamp with time zone,
service_created_at timestamp with time zone NOT NULL,
@@ -235,7 +236,8 @@ CREATE TABLE test_chat_schema.badge_ledger (
from_purchase_id bigint,
to_purchase_id bigint,
entry_type_unknown smallint DEFAULT 0 NOT NULL,
entry_type_value text
entry_type_value text,
balance_checked smallint
);
@@ -2214,6 +2216,10 @@ CREATE INDEX idx_badge_issuances_purchase ON test_chat_schema.badge_issuances US
CREATE UNIQUE INDEX idx_badge_issuances_purchase_entry ON test_chat_schema.badge_issuances USING btree (badge_purchase_id, entry_id);
CREATE INDEX idx_badge_ledger_charge ON test_chat_schema.badge_ledger USING btree (charge_id);
+15 -13
View File
@@ -382,19 +382,21 @@ updateUserProfileFields_' db userId profileId Profile {displayName, fullName, sh
-- store the user's own badge credential; touches only the badge columns.
-- bumps user_member_profile_updated_at so groups receive the updated profile (with the badge) on the next message.
setUserBadge :: DB.Connection -> User -> Maybe LocalBadge -> IO User
setUserBadge db user@User {userId, profile = p@LocalProfile {profileId}} localBadge = do
ts <- getCurrentTime
DB.execute
db
[sql|
UPDATE contact_profiles
SET badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, updated_at = ?
WHERE user_id = ? AND contact_profile_id = ?
|]
(localBadgeToRow localBadge :. (ts, userId, profileId))
DB.execute db "UPDATE users SET user_member_profile_updated_at = ? WHERE user_id = ?" (ts, userId)
pure (user :: User) {profile = p {localBadge}, userMemberProfileUpdatedAt = Just ts}
-- answers the row as stored, or a profile edit landing since the caller's read is broadcast back stale.
setUserBadge :: DB.Connection -> User -> Maybe LocalBadge -> ExceptT StoreError IO User
setUserBadge db User {userId, profile = LocalProfile {profileId}} localBadge = do
liftIO $ do
ts <- getCurrentTime
DB.execute
db
[sql|
UPDATE contact_profiles
SET badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, updated_at = ?
WHERE user_id = ? AND contact_profile_id = ?
|]
(localBadgeToRow localBadge :. (ts, userId, profileId))
DB.execute db "UPDATE users SET user_member_profile_updated_at = ? WHERE user_id = ?" (ts, userId)
getUser db userId
setUserSimplexDomain :: DB.Connection -> User -> Maybe SimplexDomain -> IO User
setUserSimplexDomain db user@User {userId, profile = p@LocalProfile {profileId}} domain_ = do
@@ -144,6 +144,7 @@ CREATE TABLE @badge_ledger(
change_months INTEGER NOT NULL,
balance_months INTEGER NOT NULL,
balance_start_ts TEXT NOT NULL,
balance_anchor_ts TEXT NOT NULL,
balance_badge_type TEXT NOT NULL,
was_paused_since TEXT,
service_created_at TEXT NOT NULL,
@@ -184,6 +185,8 @@ CREATE TABLE @badge_issuances(
CREATE INDEX @idx_badge_issuances_purchase ON @badge_issuances(badge_purchase_id, issuance_id);
CREATE INDEX @idx_badge_issuances_entry ON @badge_issuances(entry_id);
CREATE UNIQUE INDEX @idx_badge_issuances_purchase_entry ON @badge_issuances(badge_purchase_id, entry_id);
|]
badgeSchemaTablesDown :: Query
@@ -191,6 +194,7 @@ badgeSchemaTablesDown =
[sql|
DROP INDEX @idx_badge_issuances_purchase;
DROP INDEX @idx_badge_issuances_entry;
DROP INDEX @idx_badge_issuances_purchase_entry;
DROP TABLE @badge_issuances;
DROP INDEX @idx_badge_ledger_uuid;
DROP INDEX @idx_badge_ledger_purchase;
@@ -238,6 +242,8 @@ ALTER TABLE badge_ledger ADD COLUMN entry_type_unknown INTEGER NOT NULL DEFAULT
ALTER TABLE badge_ledger ADD COLUMN entry_type_value TEXT;
ALTER TABLE badge_ledger ADD COLUMN balance_checked INTEGER;
CREATE INDEX idx_badge_purchases_user ON badge_purchases(user_id);
ALTER TABLE users ADD COLUMN shown_badge_id INTEGER REFERENCES badge_purchases ON DELETE SET NULL;
@@ -1204,8 +1204,19 @@ Plan:
SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?)
Query:
INSERT INTO badge_issuances (issuance_id, badge_purchase_id, badge_type, period_start, period_end, expiry, credential, created_at)
VALUES (?,?,?,?,?,?,?,?)
INSERT INTO badge_issuances (issuance_id, badge_purchase_id, entry_id, badge_type, period_start, period_end, expiry, credential, created_at)
VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT (badge_purchase_id, entry_id) DO NOTHING
Plan:
Query:
INSERT INTO badge_ledger
(entry_uuid, badge_purchase_id, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type,
was_paused_since, service_created_at, created_at, entry_type, entry_credit_type, entry_debit_type,
entry_type_unknown, entry_type_value, balance_checked)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT (entry_uuid) DO NOTHING
Plan:
@@ -2017,6 +2028,20 @@ Query:
Plan:
Query:
SELECT
(SELECT prev.balance_start_ts FROM badge_ledger prev
WHERE prev.badge_purchase_id = issued.badge_purchase_id AND prev.entry_id < issued.entry_id
ORDER BY prev.entry_id DESC LIMIT 1),
issued.balance_start_ts
FROM badge_ledger issued
WHERE issued.badge_purchase_id = ? AND issued.entry_id = ?
Plan:
SEARCH issued USING INTEGER PRIMARY KEY (rowid=?)
CORRELATED SCALAR SUBQUERY 1
SEARCH prev USING INDEX idx_badge_ledger_purchase (badge_purchase_id=? AND entry_id<?)
Query:
SELECT
-- Contact
@@ -3729,6 +3754,16 @@ Query:
Plan:
SEARCH cp USING INTEGER PRIMARY KEY (rowid=?)
Query:
SELECT credential FROM badge_issuances
WHERE badge_purchase_id = ?
ORDER BY period_end DESC
LIMIT 1
Plan:
SEARCH badge_issuances USING INDEX idx_badge_issuances_purchase_entry (badge_purchase_id=?)
USE TEMP B-TREE FOR ORDER BY
Query:
SELECT ct.contact_id
FROM contacts ct
@@ -3781,6 +3816,17 @@ Query:
Plan:
SEARCH contact_profiles USING INDEX idx_contact_profiles_user_id (user_id=?)
Query:
SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type,
was_paused_since, service_created_at, entry_type, entry_credit_type, entry_debit_type, entry_type_value
FROM badge_ledger
WHERE badge_purchase_id = ?
ORDER BY entry_id DESC
LIMIT 1
Plan:
SEARCH badge_ledger USING INDEX idx_badge_ledger_purchase (badge_purchase_id=?)
Query:
SELECT f.file_id
FROM files f
@@ -3981,6 +4027,20 @@ Plan:
SEARCH m USING INDEX idx_group_members_user_id (user_id=?)
SEARCH p USING INTEGER PRIMARY KEY (rowid=?)
Query:
SELECT p.badge_purchase_id, p.purchase_key, p.purchase_priv_key, p.master_key, p.current_badge_type,
(CASE WHEN u.shown_badge_id = p.badge_purchase_id THEN 1 ELSE 0 END),
p.alert_acked_kind, p.alert_acked_episode, p.alert_snooze_until
FROM badge_purchases p
JOIN users u ON u.user_id = p.user_id
WHERE p.user_id = ? AND p.purchase_priv_key IS NOT NULL
ORDER BY p.badge_purchase_id DESC
LIMIT 1
Plan:
SEARCH u USING INTEGER PRIMARY KEY (rowid=?)
SEARCH p USING INDEX idx_badge_purchases_user (user_id=?)
Query:
SELECT pgm.message_id, m.shared_msg_id, m.msg_body, m.msg_chat_binding, m.msg_signatures
FROM pending_group_messages pgm
@@ -7018,6 +7078,11 @@ Plan:
Query: INSERT INTO app_settings (app_settings) VALUES (?)
Plan:
Query: INSERT INTO badge_ledger (entry_uuid, badge_purchase_id, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type, service_created_at, created_at, entry_type, entry_credit_type, entry_type_unknown, entry_type_value) SELECT 'unknown-entry', badge_purchase_id, 0, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type, service_created_at, created_at, 'credit', 'grant', 1, '{"type":"grant"}' FROM badge_ledger ORDER BY entry_id DESC LIMIT 1
Plan:
SCAN badge_ledger
SEARCH badge_issuances USING COVERING INDEX idx_badge_issuances_entry (entry_id=?)
Query: INSERT INTO chat_item_mentions (chat_item_id, group_id, member_id, display_name) VALUES (?, ?, ?, ?)
Plan:
@@ -7205,6 +7270,10 @@ Query: SELECT agent_conn_id FROM connections WHERE user_id = ? AND conn_req_inv
Plan:
SEARCH connections USING INDEX idx_connections_conn_req_inv (user_id=? AND conn_req_inv=?)
Query: SELECT alert_acked_kind, alert_acked_episode FROM badge_purchases
Plan:
SCAN badge_purchases
Query: SELECT app_settings FROM app_settings
Plan:
SCAN app_settings
@@ -7217,6 +7286,14 @@ Query: SELECT auth_err_counter FROM connections WHERE user_id = ? AND connection
Plan:
SEARCH connections USING INTEGER PRIMARY KEY (rowid=?)
Query: SELECT badge_expiry, contact_profile_id FROM contact_profiles WHERE badge_proof IS NOT NULL ORDER BY contact_profile_id
Plan:
SCAN contact_profiles
Query: SELECT badge_expiry, contact_profile_id FROM contact_profiles WHERE badge_signature IS NOT NULL ORDER BY contact_profile_id
Plan:
SCAN contact_profiles
Query: SELECT badge_purchase_id FROM badge_purchases WHERE badge_code_redemption_id = ?
Plan:
SEARCH badge_purchases USING COVERING INDEX idx_badge_purchases_code_redemption (badge_code_redemption_id=?)
@@ -7354,6 +7431,24 @@ Query: SELECT count(1) FROM pending_group_messages
Plan:
SCAN pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id
Query: SELECT entry_id FROM badge_ledger WHERE badge_purchase_id = ? AND entry_uuid = ?
Plan:
SEARCH badge_ledger USING INDEX idx_badge_ledger_uuid (entry_uuid=?)
Query: SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_badge_type, COALESCE(entry_credit_type, entry_debit_type) FROM badge_ledger ORDER BY entry_id
Plan:
SCAN badge_ledger
Query: SELECT expiry, badge_purchase_id FROM badge_issuances ORDER BY period_end
Plan:
SCAN badge_issuances
USE TEMP B-TREE FOR ORDER BY
Query: SELECT expiry, badge_purchase_id FROM badge_issuances ORDER BY period_end DESC LIMIT 1
Plan:
SCAN badge_issuances
USE TEMP B-TREE FOR ORDER BY
Query: SELECT file_id FROM files WHERE user_id = ? AND redirect_file_id = ?
Plan:
SEARCH files USING INDEX idx_files_redirect_file_id (redirect_file_id=?)
@@ -7554,6 +7649,14 @@ Query: SELECT should_sync FROM connections_sync WHERE connections_sync_id = 1
Plan:
SEARCH connections_sync USING INTEGER PRIMARY KEY (rowid=?)
Query: SELECT shown_badge_id FROM users WHERE user_id = ?
Plan:
SEARCH users USING INTEGER PRIMARY KEY (rowid=?)
Query: SELECT shown_badge_id, user_id FROM users WHERE user_id = 1
Plan:
SEARCH users USING INTEGER PRIMARY KEY (rowid=?)
Query: SELECT stored_roster_version FROM groups WHERE group_id = ?
Plan:
SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
@@ -7582,6 +7685,10 @@ Query: SELECT xgrplinkmem_received FROM group_members WHERE group_member_id = ?
Plan:
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE badge_purchases SET alert_acked_kind = ?, alert_acked_episode = ?, alert_snooze_until = ? WHERE badge_purchase_id = ?
Plan:
SEARCH badge_purchases USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE chat_items SET item_msg_body = ?, item_chat_binding = ?, item_signatures = ?, item_signed_by_group_member_id = ? WHERE chat_item_id = ? AND include_in_history = 1
Plan:
SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?)
@@ -7646,6 +7753,14 @@ Query: UPDATE connections_sync SET should_sync = 1 WHERE connections_sync_id = 1
Plan:
SEARCH connections_sync USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE contact_profiles SET badge_expiry = ? WHERE badge_proof IS NOT NULL
Plan:
SCAN contact_profiles
Query: UPDATE contact_profiles SET badge_expiry = ? WHERE badge_signature IS NOT NULL
Plan:
SCAN contact_profiles
Query: UPDATE contact_profiles SET contact_domain = ?, updated_at = ? WHERE user_id = ? AND contact_profile_id = ?
Plan:
SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?)
@@ -8034,6 +8149,10 @@ Query: UPDATE users SET shown_badge_id = ? WHERE user_id = ?
Plan:
SEARCH users USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE users SET shown_badge_id = NULL WHERE user_id = ? AND shown_badge_id = ?
Plan:
SEARCH users USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE users SET ui_themes = ?, updated_at = ? WHERE user_id = ?
Plan:
SEARCH users USING INTEGER PRIMARY KEY (rowid=?)
@@ -963,6 +963,7 @@ CREATE TABLE badge_ledger(
change_months INTEGER NOT NULL,
balance_months INTEGER NOT NULL,
balance_start_ts TEXT NOT NULL,
balance_anchor_ts TEXT NOT NULL,
balance_badge_type TEXT NOT NULL,
was_paused_since TEXT,
service_created_at TEXT NOT NULL,
@@ -976,7 +977,8 @@ CREATE TABLE badge_ledger(
to_purchase_id INTEGER REFERENCES badge_purchases
,
entry_type_unknown INTEGER NOT NULL DEFAULT 0,
entry_type_value TEXT
entry_type_value TEXT,
balance_checked INTEGER
) STRICT;
CREATE TABLE badge_issuances(
issuance_id TEXT NOT NULL PRIMARY KEY,
@@ -1556,6 +1558,10 @@ CREATE INDEX idx_badge_issuances_purchase ON badge_issuances(
issuance_id
);
CREATE INDEX idx_badge_issuances_entry ON badge_issuances(entry_id);
CREATE UNIQUE INDEX idx_badge_issuances_purchase_entry ON badge_issuances(
badge_purchase_id,
entry_id
);
CREATE INDEX idx_badge_purchases_user ON badge_purchases(user_id);
CREATE INDEX idx_users_shown_badge ON users(shown_badge_id);
CREATE INDEX idx_badge_code_redemptions_user ON badge_code_redemptions(
+1
View File
@@ -73,6 +73,7 @@ data ChatLockEntity
| CLUserContact Int64
| CLContactRequest Int64
| CLFile Int64
| CLBadgeUser Int64 -- one signed badge request per profile in flight
deriving (Eq, Ord)
-- These error type constructors must be added to mobile apps
+26 -1
View File
@@ -44,6 +44,7 @@ import Simplex.Chat.Help
import Simplex.Chat.Library.Commands (maxImageSize)
import Simplex.Chat.Markdown
import Simplex.Chat.Badges (BadgeInfo (..), BadgeStatus (..), BadgeType (..), LocalBadge, localBadgeInfo, localBadgeStatus)
import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeState (..))
import Simplex.Chat.Messages hiding (NewChatItem (..))
import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Operators
@@ -190,6 +191,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te
CRServiceReplyAccepted u (AgentConnId cId) -> ttyUser u [plain $ "service reply accepted, connection id: " <> safeDecodeUtf8 (strEncode cId)]
-- the badge is only shown when it is the one now on the profile; a replayed code's badge may not be
CRBadgeRedeemed u badge newBadge -> ttyUser u $ if newBadge then "badge redeemed" : viewContactBadge (Just badge) else ["badge already redeemed"]
CRBadgeState u st -> ttyUser u $ viewUserBadgeState st
CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView
CRPublicGroupCreated u g _groupLink _relays -> ttyUser u $ viewGroupCreated g testView
CRPublicGroupCreationFailed u results -> ttyUser u $ viewPublicGroupCreationFailed results
@@ -476,6 +478,8 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView}
<> maybe [] (\k -> [plain $ "signed by " <> safeDecodeUtf8 (strEncode k)]) sigKey_
<> ["request: " <> viewJSON req]
CEvtServiceReplySent (AgentConnId cId) -> [plain $ "service reply sent, connection id: " <> safeDecodeUtf8 (strEncode cId)]
CEvtBadgeChanged u st -> ttyUser u $ viewUserBadgeState st
CEvtBadgeAlert u alert -> ttyUser u $ viewBadgeAlert alert
CEvtContactRequestRejected u Contact {localDisplayName = c} _reason -> ttyUser u [ttyContact c <> ": contact request rejected"]
CEvtRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci
CEvtRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci
@@ -1829,9 +1833,30 @@ viewContactBadge = maybe [] $ \lb ->
BSExpiredOld -> "expired (old)"
BSFailed -> "verification failed"
BSUnknownKey -> "unknown key"
expiry = "expires " <> T.pack (formatTime defaultTimeLocale "%Y-%m-%d" badgeExpiry)
expiry = "expires " <> day badgeExpiry
in [plain (textEncode badgeType <> " badge - " <> st), plain expiry]
viewUserBadgeState :: Maybe BadgeState -> [StyledString]
viewUserBadgeState = maybe [] viewBadge
where
viewBadge BadgeState {badgePurchaseId, badgeType, monthsLeft, paidThrough, alert} =
plain
( tshow badgePurchaseId
<> ": "
<> textEncode badgeType
<> ", "
<> tshow monthsLeft
<> " months left, paid through "
<> day paidThrough
)
: maybe [] viewBadgeAlert alert
viewBadgeAlert :: BadgeAlert -> [StyledString]
viewBadgeAlert BadgeAlert {kind, date} = [plain $ "badge alert: " <> textEncode kind <> " " <> day date]
day :: UTCTime -> Text
day = T.pack . formatTime defaultTimeLocale "%Y-%m-%d"
viewContactInfo :: Contact -> Maybe ConnectionStats -> Maybe Profile -> [StyledString]
viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink, localBadge, contactDomain, contactDomainVerified, description}, activeConn, uiThemes, customData} stats incognitoProfile =
["contact ID: " <> sShow contactId]