diff --git a/charts/nginz/values.yaml b/charts/nginz/values.yaml index bbeb433b3e6..68fa31e8196 100644 --- a/charts/nginz/values.yaml +++ b/charts/nginz/values.yaml @@ -200,6 +200,10 @@ nginx_conf: - all disable_zauth: true unlimited_requests_endpoint: true + - path: /users/public/([^/]*) + envs: + - all + disable_zauth: true - path: /users envs: - all diff --git a/integration/test/API/Galley.hs b/integration/test/API/Galley.hs index 852c3fdd1f0..cf1377b322e 100644 --- a/integration/test/API/Galley.hs +++ b/integration/test/API/Galley.hs @@ -649,6 +649,35 @@ updateMessageTimer user qcnv update = do req <- baseRequest user Galley Versioned path submit "PUT" (addJSONObject ["message_timer" .= updateReq] req) +getConversationDescription :: + (HasCallStack, MakesValue user, MakesValue conv) => + user -> + conv -> + App Response +getConversationDescription user qcnv = do + (cnvDomain, cnvId) <- objQid qcnv + req <- baseRequest user Galley Versioned (joinHttpPath ["conversations", cnvDomain, cnvId, "description"]) + submit "GET" req + +updateConversationDescription :: + (HasCallStack, MakesValue user, MakesValue conv) => + user -> + conv -> + Int64 -> + ByteString -> + App Response +updateConversationDescription user qcnv baseVersion ciphertext = do + (cnvDomain, cnvId) <- objQid qcnv + req <- baseRequest user Galley Versioned (joinHttpPath ["conversations", cnvDomain, cnvId, "description"]) + submit + "PUT" + ( req + & addJSONObject + [ "base_version" .= baseVersion, + "ciphertext" .= BS.unpack (B64.encode ciphertext) + ] + ) + updateHistory :: ( HasCallStack, MakesValue user, diff --git a/integration/test/Notifications.hs b/integration/test/Notifications.hs index 12f070697e1..db59f7951db 100644 --- a/integration/test/Notifications.hs +++ b/integration/test/Notifications.hs @@ -185,6 +185,10 @@ isConvMsgTimerUpdateNotif :: (HasCallStack, MakesValue n) => n -> App Bool isConvMsgTimerUpdateNotif n = fieldEquals n "payload.0.type" "conversation.message-timer-update" +isConvDescriptionUpdateNotif :: (HasCallStack, MakesValue n) => n -> App Bool +isConvDescriptionUpdateNotif n = + fieldEquals n "payload.0.type" "conversation.description-update" + isConvAccessUpdateNotif :: (HasCallStack, MakesValue n) => n -> App Bool isConvAccessUpdateNotif n = fieldEquals n "payload.0.type" "conversation.access-update" diff --git a/integration/test/Test/Conversation.hs b/integration/test/Test/Conversation.hs index 5d16f9b4948..57182430714 100644 --- a/integration/test/Test/Conversation.hs +++ b/integration/test/Test/Conversation.hs @@ -29,6 +29,7 @@ import Control.Concurrent (threadDelay) import Control.Monad.Codensity import Control.Monad.Reader import qualified Data.Aeson as Aeson +import qualified Data.ByteString.Char8 as BS import qualified Data.Text as T import GHC.Stack import MLS.Util @@ -758,6 +759,70 @@ testConversationReceiptModeUpdate proto = do resp.status `shouldMatchInt` 200 resp.json %. "receipt_mode" `shouldMatchInt` receiptMode +testConversationDescriptionUpdate :: (HasCallStack) => App () +testConversationDescriptionUpdate = do + (owner, tid, [convMember, outsider]) <- createTeam OwnDomain 3 + conv <- + postConversation + owner + (defProteus {team = Just tid, qualifiedUsers = [convMember], newUsersRole = "wire_member"}) + >>= getJSON 201 + + let assertDescription :: (HasCallStack) => Response -> Int -> ByteString -> App () + assertDescription resp version ciphertext = do + resp.status `shouldMatchInt` 200 + resp.json %. "version" `shouldMatchInt` version + resp.json %. "ciphertext" `shouldMatchBase64` ciphertext + + firstCiphertext = BS.pack "group description v1" + secondCiphertext = BS.pack "group description v2" + + withWebSockets [owner, convMember] $ \[ownerWs, memberWs] -> do + bindResponse (updateConversationDescription owner conv (0 :: Int64) firstCiphertext) $ \resp -> + assertDescription resp 1 firstCiphertext + + for_ [ownerWs, memberWs] $ \ws -> do + notif <- awaitMatch isConvDescriptionUpdateNotif ws + assertBool "notification should target the updated conversation" =<< isNotifConv conv notif + assertBool "notification should be emitted by the updating user" =<< isNotifFromUser owner notif + notif %. "payload.0.qualified_conversation" `shouldMatch` objQidObject conv + notif %. "payload.0.qualified_from" `shouldMatch` objQidObject owner + notif %. "payload.0.data.version" `shouldMatchInt` 1 + notif %. "payload.0.data.ciphertext" `shouldMatchBase64` firstCiphertext + + bindResponse (getConversationDescription owner conv) $ \resp -> + assertDescription resp 1 firstCiphertext + + bindResponse (getConversationDescription convMember conv) $ \resp -> + assertDescription resp 1 firstCiphertext + + bindResponse (updateConversationDescription owner conv (1 :: Int64) secondCiphertext) $ \resp -> + assertDescription resp 2 secondCiphertext + + for_ [ownerWs, memberWs] $ \ws -> do + notif <- awaitMatch isConvDescriptionUpdateNotif ws + assertBool "notification should target the updated conversation" =<< isNotifConv conv notif + assertBool "notification should be emitted by the updating user" =<< isNotifFromUser owner notif + notif %. "payload.0.qualified_conversation" `shouldMatch` objQidObject conv + notif %. "payload.0.qualified_from" `shouldMatch` objQidObject owner + notif %. "payload.0.data.version" `shouldMatchInt` 2 + notif %. "payload.0.data.ciphertext" `shouldMatchBase64` secondCiphertext + + bindResponse (getConversationDescription owner conv) $ \resp -> + assertDescription resp 2 secondCiphertext + + bindResponse (updateConversationDescription owner conv (0 :: Int64) (BS.pack "stale update")) $ \resp -> do + resp.status `shouldMatchInt` 403 + resp.json %. "label" `shouldMatch` "invalid-op" + + bindResponse (updateConversationDescription convMember conv (2 :: Int64) (BS.pack "non-admin update")) $ \resp -> do + resp.status `shouldMatchInt` 403 + resp.json %. "label" `shouldMatch` "access-denied" + + bindResponse (getConversationDescription outsider conv) $ \resp -> do + resp.status `shouldMatchInt` 403 + resp.json %. "label" `shouldMatch` "access-denied" + testReceiptModeWithRemotesOk :: (HasCallStack) => App () testReceiptModeWithRemotesOk = do [alice, bob] <- createAndConnectUsers [OwnDomain, OtherDomain] diff --git a/libs/wire-api/src/Wire/API/Conversation.hs b/libs/wire-api/src/Wire/API/Conversation.hs index ac954d93d98..23fac53a519 100644 --- a/libs/wire-api/src/Wire/API/Conversation.hs +++ b/libs/wire-api/src/Wire/API/Conversation.hs @@ -90,6 +90,10 @@ module Wire.API.Conversation ExtraConversationData (..), ConversationHistoryUpdate (..), + -- * conversation description + ConversationDescription (..), + ConversationDescriptionUpdate (..), + -- * re-exports module Wire.API.Conversation.Member, fromOwnConversation, @@ -105,6 +109,7 @@ import Data.ByteString.Lazy qualified as LBS import Data.Default import Data.Domain import Data.Id +import Data.Json.Util (Base64ByteString (..)) import Data.List.Extra (disjointOrd) import Data.List.NonEmpty (NonEmpty) import Data.Map qualified as Map @@ -1338,6 +1343,44 @@ instance ToSchema ConversationHistoryUpdate where ConversationHistoryUpdate <$> (.history) .= field "history" schema +data ConversationDescription = ConversationDescription + { descriptionVersion :: Int64, + descriptionCiphertext :: ByteString + } + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform ConversationDescription) + deriving (FromJSON, ToJSON, S.ToSchema) via (Schema ConversationDescription) + +instance ToSchema ConversationDescription where + schema = + object $ + ConversationDescription + <$> descriptionVersion .= field "version" schema + <*> descriptionCiphertext + .= fieldWithDocModifier + "ciphertext" + (DS.description ?~ "Encrypted description payload, Base64 in JSON") + (Base64ByteString .= fmap fromBase64ByteString (unnamed schema)) + +data ConversationDescriptionUpdate = ConversationDescriptionUpdate + { descriptionUpdateBaseVersion :: Int64, + descriptionUpdateCiphertext :: ByteString + } + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform ConversationDescriptionUpdate) + deriving (FromJSON, ToJSON, S.ToSchema) via (Schema ConversationDescriptionUpdate) + +instance ToSchema ConversationDescriptionUpdate where + schema = + object $ + ConversationDescriptionUpdate + <$> descriptionUpdateBaseVersion .= field "base_version" schema + <*> descriptionUpdateCiphertext + .= fieldWithDocModifier + "ciphertext" + (DS.description ?~ "Encrypted description payload, Base64 in JSON") + (Base64ByteString .= fmap fromBase64ByteString (unnamed schema)) + -------------------------------------------------------------------------------- -- MultiVerb instances diff --git a/libs/wire-api/src/Wire/API/Event/Conversation.hs b/libs/wire-api/src/Wire/API/Event/Conversation.hs index 64195a8c8bf..3d74deaad58 100644 --- a/libs/wire-api/src/Wire/API/Event/Conversation.hs +++ b/libs/wire-api/src/Wire/API/Event/Conversation.hs @@ -44,6 +44,7 @@ module Wire.API.Event.Conversation _EdConnect, _EdConvReceiptModeUpdate, _EdConvRename, + _EdConvDescriptionUpdate, _EdConvDelete, _EdConvAccessUpdate, _EdConvMessageTimerUpdate, @@ -175,6 +176,7 @@ data EventType | MemberLeave | MemberStateUpdate | ConvRename + | ConvDescriptionUpdate | ConvAccessUpdate | ConvMessageTimerUpdate | ConvCodeUpdate @@ -203,6 +205,7 @@ instance ToSchema EventType where element "conversation.member-leave" MemberLeave, element "conversation.member-update" MemberStateUpdate, element "conversation.rename" ConvRename, + element "conversation.description-update" ConvDescriptionUpdate, element "conversation.access-update" ConvAccessUpdate, element "conversation.receipt-mode-update" ConvReceiptModeUpdate, element "conversation.message-timer-update" ConvMessageTimerUpdate, @@ -227,6 +230,7 @@ data EventData | EdConnect Connect | EdConvReceiptModeUpdate ConversationReceiptModeUpdate | EdConvRename ConversationRename + | EdConvDescriptionUpdate ConversationDescription | EdConvDelete | EdConvReset ConversationReset | EdConvAccessUpdate ConversationAccessData @@ -250,6 +254,7 @@ genEventData = \case MemberLeave -> EdMembersLeave <$> arbitrary <*> arbitrary MemberStateUpdate -> EdMemberUpdate <$> arbitrary ConvRename -> EdConvRename <$> arbitrary + ConvDescriptionUpdate -> EdConvDescriptionUpdate <$> arbitrary ConvAccessUpdate -> EdConvAccessUpdate <$> arbitrary ConvMessageTimerUpdate -> EdConvMessageTimerUpdate <$> arbitrary ConvCodeUpdate -> EdConvCodeUpdate <$> arbitrary @@ -272,6 +277,7 @@ eventDataType (EdMembersJoin _) = MemberJoin eventDataType (EdMembersLeave _ _) = MemberLeave eventDataType (EdMemberUpdate _) = MemberStateUpdate eventDataType (EdConvRename _) = ConvRename +eventDataType (EdConvDescriptionUpdate _) = ConvDescriptionUpdate eventDataType (EdConvAccessUpdate _) = ConvAccessUpdate eventDataType (EdConvMessageTimerUpdate _) = ConvMessageTimerUpdate eventDataType (EdConvCodeUpdate _) = ConvCodeUpdate @@ -312,6 +318,7 @@ isCellsConversationEvent eventType = ProtocolUpdate -> False AddPermissionUpdate -> False ConvHistoryUpdate -> False + ConvDescriptionUpdate -> False -------------------------------------------------------------------------------- -- Event data helpers @@ -492,6 +499,7 @@ taggedEventDataSchema = MemberLeave -> tag _EdMembersLeave (unnamed memberLeaveSchema) MemberStateUpdate -> tag _EdMemberUpdate (unnamed schema) ConvRename -> tag _EdConvRename (unnamed schema) + ConvDescriptionUpdate -> tag _EdConvDescriptionUpdate (unnamed schema) -- FUTUREWORK: when V2 is dropped, it is fine to change this schema to -- V3, since V3 clients are guaranteed to know how to parse V2 and V3 -- conversation access update events. diff --git a/libs/wire-api/src/Wire/API/PostgresMarshall.hs b/libs/wire-api/src/Wire/API/PostgresMarshall.hs index e1a6f55f18d..f6c5608f974 100644 --- a/libs/wire-api/src/Wire/API/PostgresMarshall.hs +++ b/libs/wire-api/src/Wire/API/PostgresMarshall.hs @@ -37,6 +37,7 @@ import Data.Id import Data.Json.Util (UTCTimeMillis (fromUTCTimeMillis), toUTCTimeMillis) import Data.Misc import Data.Profunctor +import Data.Range import Data.Set qualified as Set import Data.Text qualified as Text import Data.Text.Ascii qualified as Ascii @@ -45,6 +46,7 @@ import Data.Time (UTCTime) import Data.UUID import Data.Vector (Vector) import Data.Vector qualified as V +import GHC.TypeLits import Hasql.Statement import Imports import SAML2.WebSSO qualified as SAML @@ -590,6 +592,12 @@ instance PostgresMarshall Int32 TeamInviteTag where instance PostgresMarshall UUID SAML.IdPId where postgresMarshall = SAML.fromIdPId +instance PostgresMarshall Text HttpsUrl where + postgresMarshall = httpsUrlToText + +instance PostgresMarshall a (Range n m a) where + postgresMarshall = fromRange + --- class PostgresUnmarshall db domain where @@ -1062,6 +1070,12 @@ instance PostgresUnmarshall Text Handle where instance PostgresUnmarshall UTCTime UTCTimeMillis where postgresUnmarshall = Right . toUTCTimeMillis +instance PostgresUnmarshall Text HttpsUrl where + postgresUnmarshall = mapLeft Text.pack . httpsUrlFromText + +instance (KnownNat n, KnownNat m, Within a n m) => PostgresUnmarshall a (Range n m a) where + postgresUnmarshall = mapLeft Text.pack . checkedEither + --- lmapPG :: (PostgresMarshall db domain, Profunctor p) => p db x -> p domain x diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs index 760b7da289a..36411f5f35d 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs @@ -177,6 +177,14 @@ type UserAPI = :> QualifiedCaptureUserId "uid" :> GetUserVerb ) + :<|> Named + "get-public-profile" + ( Summary "Get a user's public profile" + :> "users" + :> "public" + :> Capture' '[Description "The user handle"] "handle" Handle + :> Get '[JSON] (Maybe PublicProfile) + ) :<|> Named "update-user-email" ( Summary "Resend email address validation email." diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs index fa030bf6c8c..10d528a64ee 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs @@ -1203,6 +1203,33 @@ type ConversationAPI = (UpdateResponses "Name updated" "Name unchanged" Event) (UpdateResult Event) ) + :<|> Named + "get-conversation-description" + ( Summary "Get conversation description" + :> From 'V17 + :> CanThrow 'ConvNotFound + :> CanThrow 'ConvAccessDenied + :> ZLocalUser + :> "conversations" + :> QualifiedCapture' '[Description "Conversation ID"] "cnv" ConvId + :> "description" + :> Get '[JSON] ConversationDescription + ) + :<|> Named + "update-conversation-description" + ( Summary "Update conversation description" + :> From 'V17 + :> CanThrow 'ConvNotFound + :> CanThrow 'ConvAccessDenied + :> CanThrow 'InvalidOperation + :> ZLocalUser + :> ZConn + :> "conversations" + :> QualifiedCapture' '[Description "Conversation ID"] "cnv" ConvId + :> "description" + :> ReqBody '[JSON] ConversationDescriptionUpdate + :> Put '[JSON] ConversationDescription + ) -- This endpoint can lead to the following events being sent: -- - ConvMessageTimerUpdate event to members :<|> Named diff --git a/libs/wire-api/src/Wire/API/User.hs b/libs/wire-api/src/Wire/API/User.hs index 1154b6cb86c..b95646ab836 100644 --- a/libs/wire-api/src/Wire/API/User.hs +++ b/libs/wire-api/src/Wire/API/User.hs @@ -32,6 +32,12 @@ module Wire.API.User -- Profiles UserProfile (..), SelfProfile (..), + PublicProfile (..), + ProfileLink (..), + VerifiedLink, + UnverifiedLink, + Bio, + LinkName, -- User (should not be here) User (..), UserType (..), @@ -231,6 +237,65 @@ import Wire.API.User.Profile import Wire.API.User.RichInfo import Wire.Arbitrary as Arbitrary +-------------------------------------------------------------------------------- +-- PublicProfile + +type Bio = Range 0 140 Text + +data PublicProfile = PublicProfile + { publicId :: Qualified UserId, + publicBio :: Maybe Bio, + publicHandle :: Maybe Handle, + publicLinks :: [VerifiedLink] + } + deriving stock (Eq, Show, Generic) + deriving (ToJSON, FromJSON, S.ToSchema) via (Schema PublicProfile) + +instance ToSchema PublicProfile where + schema = + object $ + PublicProfile + <$> publicId .= field "qualified_id" schema + <*> publicBio .= maybe_ (optField "bio" schema) + <*> publicHandle .= maybe_ (optField "handle" schema) + <*> publicLinks .= field "links" (array schema) + +data ProfileLink t = ProfileLink + { name :: LinkName, + url :: HttpsUrl, + verified :: t + } + deriving stock (Eq, Show, Ord, Generic) + deriving (Arbitrary) via GenericUniform (ProfileLink t) + +type VerifiedLink = ProfileLink Bool + +deriving via (Schema VerifiedLink) instance ToJSON VerifiedLink + +deriving via (Schema VerifiedLink) instance FromJSON VerifiedLink + +deriving via (Schema VerifiedLink) instance S.ToSchema VerifiedLink + +instance ToSchema VerifiedLink where + schema = + namedObject "ProfileLink" $ + ProfileLink + <$> (.name) .= field "name" schema + <*> (.url) .= field "url" schema + <*> verified .= field "verified" schema + +type UnverifiedLink = ProfileLink () + +instance ToSchema UnverifiedLink where + schema = + namedObject "UnverifiedLink" $ + ProfileLink + <$> (.name) .= field "name" schema + <*> (.url) .= field "url" schema + <*> verified .= pure () + +type LinkName = Range 1 20 Text + -------------------------------------------------------------------------------- -- UserIdList @@ -1449,7 +1514,9 @@ data UserUpdate = UserUpdate -- | DEPRECATED uupPict :: Maybe Pict, uupAssets :: Maybe [Asset], - uupAccentId :: Maybe ColourId + uupAccentId :: Maybe ColourId, + uupBio :: Maybe Bio, + uupLinks :: Maybe [UnverifiedLink] } deriving stock (Eq, Show, Generic) deriving (ToJSON, FromJSON, S.ToSchema) via (Schema UserUpdate) @@ -1469,6 +1536,10 @@ instance ToSchema UserUpdate where .= maybe_ (optField "assets" (array schema)) <*> uupAccentId .= maybe_ (optField "accent_id" schema) + <*> uupBio + .= maybe_ (optField "bio" schema) + <*> uupLinks + .= maybe_ (optField "links" (array schema)) data UpdateProfileError = DisplayNameManagedByScim diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/UserUpdate_user.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/UserUpdate_user.hs index 36d9be217a1..2f371cbf393 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/UserUpdate_user.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/UserUpdate_user.hs @@ -1,4 +1,5 @@ {-# LANGUAGE OverloadedLists #-} +{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-} -- This file is part of the Wire Server implementation. -- @@ -20,6 +21,8 @@ module Test.Wire.API.Golden.Generated.UserUpdate_user where import Data.Id +import Data.Misc +import Data.Range import Data.UUID qualified as UUID import Imports import Wire.API.Asset @@ -32,15 +35,20 @@ testObject_UserUpdate_user_1 = uupTextStatus = Nothing, uupPict = Nothing, uupAssets = Nothing, - uupAccentId = Nothing + uupAccentId = Nothing, + uupBio = Nothing, + uupLinks = Nothing } testObject_UserUpdate_user_2 :: UserUpdate testObject_UserUpdate_user_2 = - UserUpdate - { uupName = Just (Name {fromName = "~\RSK\1033973w\EMd\156648\59199g"}), - uupTextStatus = rightToMaybe $ mkTextStatus "text status", - uupPict = Just (Pict {fromPict = []}), - uupAssets = Just [ImageAsset (AssetKeyV3 (Id (fromJust (UUID.fromString "5cd81cc4-c643-4e9c-849c-c596a88c27fd"))) AssetExpiring) (Just AssetComplete)], - uupAccentId = Just (ColourId {fromColourId = 3}) - } + let Right u = httpsUrlFromText "https://website.example/test" + in UserUpdate + { uupName = Just (Name {fromName = "~\RSK\1033973w\EMd\156648\59199g"}), + uupTextStatus = rightToMaybe $ mkTextStatus "text status", + uupPict = Just (Pict {fromPict = []}), + uupAssets = Just [ImageAsset (AssetKeyV3 (Id (fromJust (UUID.fromString "5cd81cc4-c643-4e9c-849c-c596a88c27fd"))) AssetExpiring) (Just AssetComplete)], + uupAccentId = Just (ColourId {fromColourId = 3}), + uupBio = Just (unsafeRange "Its me, the test user"), + uupLinks = Just [ProfileLink {name = unsafeRange "github", url = u, verified = ()}] + } diff --git a/libs/wire-api/test/golden/testObject_UserUpdate_user_2.json b/libs/wire-api/test/golden/testObject_UserUpdate_user_2.json index 3785f90aad5..4bdd7086d25 100644 --- a/libs/wire-api/test/golden/testObject_UserUpdate_user_2.json +++ b/libs/wire-api/test/golden/testObject_UserUpdate_user_2.json @@ -7,6 +7,13 @@ "type": "image" } ], + "bio": "Its me, the test user", + "links": [ + { + "name": "github", + "url": "https://website.example/test" + } + ], "name": "~\u001eK󼛵w\u0019d𦏨g", "picture": [], "text_status": "text status" diff --git a/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs b/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs index 6c01d6cadfd..fdc5b7dc30e 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs @@ -79,3 +79,4 @@ testIsCellsConversationEvent = OtrMessageAdd -> isCellsConversationEvent e === False ProtocolUpdate -> isCellsConversationEvent e === False Typing -> isCellsConversationEvent e === False + ConvDescriptionUpdate -> isCellsConversationEvent e === False diff --git a/libs/wire-subsystems/default.nix b/libs/wire-subsystems/default.nix index 292b9a59947..726b723d4a3 100644 --- a/libs/wire-subsystems/default.nix +++ b/libs/wire-subsystems/default.nix @@ -100,6 +100,7 @@ , resourcet , retry , saml2-web-sso +, scalpel , schema-profunctor , scientific , servant @@ -235,6 +236,7 @@ mkDerivation { resourcet retry saml2-web-sso + scalpel schema-profunctor servant servant-client-core @@ -364,6 +366,7 @@ mkDerivation { resourcet retry saml2-web-sso + scalpel schema-profunctor scientific servant diff --git a/libs/wire-subsystems/postgres-migrations/20260617092843-create-profile-links.sql b/libs/wire-subsystems/postgres-migrations/20260617092843-create-profile-links.sql new file mode 100644 index 00000000000..0e1e1de42e6 --- /dev/null +++ b/libs/wire-subsystems/postgres-migrations/20260617092843-create-profile-links.sql @@ -0,0 +1,7 @@ +create table profile_links ( + user_id uuid NOT NULL, + link_name text NOT NULL, + url text NOT NULL, + verified_at timestamptz, + PRIMARY KEY (user_id, link_name) + ); diff --git a/libs/wire-subsystems/postgres-migrations/20260617093827-add-user-bio.sql b/libs/wire-subsystems/postgres-migrations/20260617093827-add-user-bio.sql new file mode 100644 index 00000000000..3d861b34664 --- /dev/null +++ b/libs/wire-subsystems/postgres-migrations/20260617093827-add-user-bio.sql @@ -0,0 +1,2 @@ +SET LOCAL lock_timeout = '2s'; +ALTER TABLE wire_user ADD COLUMN bio text; diff --git a/libs/wire-subsystems/postgres-migrations/20260617123443-group-description.sql b/libs/wire-subsystems/postgres-migrations/20260617123443-group-description.sql new file mode 100644 index 00000000000..3cc91bd934d --- /dev/null +++ b/libs/wire-subsystems/postgres-migrations/20260617123443-group-description.sql @@ -0,0 +1,32 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- Migration: Add encrypted conversation description table +-- Description: Stores one encrypted description blob per conversation or channel + +CREATE TABLE IF NOT EXISTS conversation_description ( + conv_id uuid PRIMARY KEY REFERENCES conversation (id) ON DELETE CASCADE, + version bigint NOT NULL, + ciphertext bytea NOT NULL, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + updated_at timestamptz NOT NULL DEFAULT current_timestamp +); + +CREATE TRIGGER update_conversation_description_updated_at +BEFORE UPDATE ON conversation_description +FOR EACH ROW +EXECUTE PROCEDURE update_updated_at(); diff --git a/libs/wire-subsystems/src/Wire/ConversationStore.hs b/libs/wire-subsystems/src/Wire/ConversationStore.hs index 763b83d1153..e9d25a56832 100644 --- a/libs/wire-subsystems/src/Wire/ConversationStore.hs +++ b/libs/wire-subsystems/src/Wire/ConversationStore.hs @@ -92,6 +92,9 @@ data ConversationStore m a where SetConversationHistory :: ConvId -> History -> ConversationStore m () SetConversationEpoch :: ConvId -> Epoch -> ConversationStore m () SetConversationCipherSuite :: ConvId -> CipherSuiteTag -> ConversationStore m () + InsertConversationDescription :: ConvId -> ConversationDescription -> ConversationStore m (Maybe ConversationDescription) + GetConversationDescription :: ConvId -> ConversationStore m (Maybe ConversationDescription) + UpdateConversationDescription :: ConvId -> ConversationDescriptionUpdate -> ConversationStore m (Maybe ConversationDescription) SetConversationCellsState :: ConvId -> CellsState -> ConversationStore m () ResetConversation :: ConvId -> GroupId -> ConversationStore m () SetGroupInfo :: ConvId -> Maybe GroupInfoData -> ConversationStore m () diff --git a/libs/wire-subsystems/src/Wire/ConversationStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/ConversationStore/Cassandra.hs index 18dfd61690e..d828459233c 100644 --- a/libs/wire-subsystems/src/Wire/ConversationStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/ConversationStore/Cassandra.hs @@ -955,6 +955,21 @@ interpretConversationStoreToCassandra client = interpret $ \case SetConversationCipherSuite cid cs -> do logEffect "ConversationStore.SetConversationCipherSuite" embedClient client $ updateConvCipherSuite cid cs + InsertConversationDescription cid description -> do + logEffect "ConversationStore.InsertConversationDescription" + isConvInPostgres cid >>= \case + False -> pure Nothing + True -> interpretConversationStoreToPostgres $ ConvStore.insertConversationDescription cid description + GetConversationDescription cid -> do + logEffect "ConversationStore.GetConversationDescription" + isConvInPostgres cid >>= \case + False -> pure Nothing + True -> interpretConversationStoreToPostgres $ ConvStore.getConversationDescription cid + UpdateConversationDescription cid description -> do + logEffect "ConversationStore.UpdateConversationDescription" + isConvInPostgres cid >>= \case + False -> pure Nothing + True -> interpretConversationStoreToPostgres $ ConvStore.updateConversationDescription cid description SetConversationCellsState cid ps -> do logEffect "ConversationStore.SetConversationCellsState" embedClient client $ updateConvCellsState cid ps @@ -1276,6 +1291,21 @@ interpretConversationStoreToCassandraAndPostgres client = interpret $ \case isConvInPostgres cid >>= \case False -> embedClient client $ updateConvCipherSuite cid cs True -> interpretConversationStoreToPostgres (ConvStore.setConversationCipherSuite cid cs) + InsertConversationDescription cid description -> do + logEffect "ConversationStore.InsertConversationDescription" + isConvInPostgres cid >>= \case + False -> pure Nothing + True -> interpretConversationStoreToPostgres $ ConvStore.insertConversationDescription cid description + GetConversationDescription cid -> do + logEffect "ConversationStore.GetConversationDescription" + isConvInPostgres cid >>= \case + False -> pure Nothing + True -> interpretConversationStoreToPostgres $ ConvStore.getConversationDescription cid + UpdateConversationDescription cid description -> do + logEffect "ConversationStore.UpdateConversationDescription" + isConvInPostgres cid >>= \case + False -> pure Nothing + True -> interpretConversationStoreToPostgres $ ConvStore.updateConversationDescription cid description SetConversationCellsState cid ps -> do logEffect "ConversationStore.SetConversationCellsState" withMigrationLockAndCleanup client LockShared (Left cid) $ diff --git a/libs/wire-subsystems/src/Wire/ConversationStore/Postgres.hs b/libs/wire-subsystems/src/Wire/ConversationStore/Postgres.hs index ea5f3c82a3e..07fd5a638ef 100644 --- a/libs/wire-subsystems/src/Wire/ConversationStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/ConversationStore/Postgres.hs @@ -20,6 +20,7 @@ module Wire.ConversationStore.Postgres (interpretConversationStoreToPostgres) where import Control.Monad.Trans.Maybe +import Data.ByteString qualified as BS import Data.Domain import Data.Id import Data.Map qualified as Map @@ -87,6 +88,9 @@ interpretConversationStoreToPostgres = interpret $ \case SetConversationHistory cid value -> setConversationHistoryImpl cid value SetConversationEpoch cid epoch -> setConversationEpochImpl cid epoch SetConversationCipherSuite cid cs -> setConversationCipherSuiteImpl cid cs + InsertConversationDescription cid description -> insertConversationDescriptionImpl cid description + GetConversationDescription cid -> getConversationDescriptionImpl cid + UpdateConversationDescription cid description -> updateConversationDescriptionImpl cid description SetConversationCellsState cid ps -> setConversationCellsStateImpl cid ps ResetConversation cid groupId -> resetConversationImpl cid groupId DeleteConversation cid -> deleteConversationImpl cid @@ -519,6 +523,68 @@ setConversationCipherSuiteImpl convId cs = SET cipher_suite = ($2 :: integer) WHERE id = ($1 :: uuid)|] +insertConversationDescriptionImpl :: (PGConstraints r) => ConvId -> ConversationDescription -> Sem r (Maybe ConversationDescription) +insertConversationDescriptionImpl convId ConversationDescription {..} = + fmap toDescription <$> runStatement (convId, descriptionVersion, descriptionCiphertext) insert + where + insert :: Hasql.Statement (ConvId, Int64, BS.ByteString) (Maybe (Int64, BS.ByteString)) + insert = + lmapPG + [maybeStatement|INSERT INTO conversation_description + (conv_id, version, ciphertext) + VALUES + ($1 :: uuid, $2 :: bigint, $3 :: bytea) + ON CONFLICT (conv_id) DO NOTHING + RETURNING (version :: bigint), (ciphertext :: bytea) + |] + toDescription (version', ciphertext') = + ConversationDescription + { descriptionVersion = version', + descriptionCiphertext = ciphertext' + } + +getConversationDescriptionImpl :: (PGConstraints r) => ConvId -> Sem r (Maybe ConversationDescription) +getConversationDescriptionImpl convId = + fmap (\(descriptionVersion, descriptionCiphertext) -> ConversationDescription {..}) <$> runStatement convId select + where + select :: Hasql.Statement ConvId (Maybe (Int64, BS.ByteString)) + select = + dimapPG + [maybeStatement|SELECT (version :: bigint), (ciphertext :: bytea) + FROM conversation_description + WHERE conv_id = ($1 :: uuid) + |] + +updateConversationDescriptionImpl :: + (PGConstraints r) => + ConvId -> + ConversationDescriptionUpdate -> + Sem r (Maybe ConversationDescription) +updateConversationDescriptionImpl convId ConversationDescriptionUpdate {..} = + fmap (fmap toDescription) $ + runTransactionWithRetry ReadCommitted Write $ + Transaction.statement (convId, descriptionUpdateBaseVersion, descriptionUpdateCiphertext) stmt + where + stmt :: Hasql.Statement (ConvId, Int64, BS.ByteString) (Maybe (Int64, BS.ByteString)) + stmt = + lmapPG + [maybeStatement| + WITH updated AS ( + UPDATE conversation_description + SET version = (($2 :: bigint) + 1), + ciphertext = ($3 :: bytea) + WHERE conv_id = ($1 :: uuid) + AND version = ($2 :: bigint) + RETURNING version, ciphertext + ) + SELECT (version :: bigint), (ciphertext :: bytea) FROM updated + |] + toDescription (version', ciphertext') = + ConversationDescription + { descriptionVersion = version', + descriptionCiphertext = ciphertext' + } + setConversationCellsStateImpl :: (PGConstraints r) => ConvId -> CellsState -> Sem r () setConversationCellsStateImpl convId cells = runStatement (convId, cells) update diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs index a10c9469814..f55c16c7660 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs @@ -376,6 +376,10 @@ data ConversationSubsystem m a where Local UserId -> Qualified ConvId -> ConversationSubsystem m Public.Conversation + GetConversationDescription :: + Local UserId -> + Qualified ConvId -> + ConversationSubsystem m ConversationDescription InternalGetConversation :: ConvId -> ConversationSubsystem m (Maybe StoredConversation) @@ -531,6 +535,12 @@ data ConversationSubsystem m a where Qualified ConvId -> ConversationRename -> ConversationSubsystem m (UpdateResult Event) + UpdateConversationDescription :: + Local UserId -> + ConnId -> + Qualified ConvId -> + ConversationDescriptionUpdate -> + ConversationSubsystem m ConversationDescription UpdateConversationMessageTimer :: Local UserId -> ConnId -> diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs index 972ec7573b8..c29dae92a4a 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs @@ -225,6 +225,8 @@ interpretConversationSubsystem = interpret $ \case mapErrors $ Query.getOwnConversation lusr qcnv GetConversation lusr qcnv -> mapErrors $ Query.getConversation lusr qcnv + GetConversationDescription lusr qcnv -> + mapErrors $ Query.getConversationDescription lusr qcnv InternalGetConversation cnv -> mapErrors $ ConvStore.getConversation cnv GetConversationRoles lusr cnv -> @@ -283,6 +285,8 @@ interpretConversationSubsystem = interpret $ \case mapErrors $ Update.updateOtherMember lusr con qcnv quid update UpdateConversationName lusr zcon qcnv rename -> mapErrors $ Update.updateConversationName lusr zcon qcnv rename + UpdateConversationDescription lusr zcon qcnv description -> + mapErrors $ Update.updateConversationDescription lusr zcon qcnv description UpdateConversationMessageTimer lusr zcon qcnv update -> mapErrors $ Update.updateConversationMessageTimer lusr zcon qcnv update UpdateConversationReceiptMode lusr zcon qcnv update -> diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Query.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Query.hs index ce8009d45bd..e3c0f5a1b62 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Query.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Query.hs @@ -23,6 +23,7 @@ module Wire.ConversationSubsystem.Query getUnqualifiedOwnConversation, getOwnConversation, getConversation, + getConversationDescription, getRemoteConversation, getLocalConversationInternal, getConversationRoles, @@ -196,6 +197,39 @@ getConversation lusr cnv = (fmap fromOwnConversation . getRemoteConversation lusr) cnv +getConversationDescription :: + forall r. + ( Member ConversationStore.ConversationStore r, + Member (ErrorS 'ConvNotFound) r, + Member (ErrorS 'ConvAccessDenied) r, + Member (Error FederationError) r, + Member TeamSubsystem r + ) => + Local UserId -> + Qualified ConvId -> + Sem r ConversationDescription +getConversationDescription lusr cnv = + foldQualified + lusr + (getLocalConversationDescription lusr . tUnqualified) + (\_ -> throw FederationNotImplemented) + cnv + +getLocalConversationDescription :: + ( Member ConversationStore.ConversationStore r, + Member (ErrorS 'ConvNotFound) r, + Member (ErrorS 'ConvAccessDenied) r, + Member TeamSubsystem r + ) => + Local UserId -> + ConvId -> + Sem r ConversationDescription +getLocalConversationDescription lusr cnv = do + convView <- getConversationAsViewer (tUntagged lusr) (qualifyAs lusr cnv) + unless convView.viewingAsMember $ throwS @'ConvNotFound + fromMaybe (ConversationDescription 0 mempty) + <$> ConversationStore.getConversationDescription cnv + getOwnConversation :: forall r. ( Member ConversationStore.ConversationStore r, diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs index 42f907bd939..fb49f3493da 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs @@ -32,6 +32,7 @@ module Wire.ConversationSubsystem.Update rmCodeUnqualified, getCode, updateConversationName, + updateConversationDescription, updateConversationReceiptMode, updateConversationMessageTimer, updateConversationAccess, @@ -1634,6 +1635,88 @@ updateConversationName lusr zcon qcnv convRename = do qcnv convRename +updateConversationDescription :: + ( Member ConversationStore r, + Member (Error FederationError) r, + Member (ErrorS 'ConvNotFound) r, + Member (ErrorS 'ConvAccessDenied) r, + Member (ErrorS 'InvalidOperation) r, + Member NotificationSubsystem r, + Member Now r, + Member E.ExternalAccess r, + Member TeamSubsystem r + ) => + Local UserId -> + ConnId -> + Qualified ConvId -> + ConversationDescriptionUpdate -> + Sem r ConversationDescription +updateConversationDescription lusr zcon qcnv descriptionUpdate = + foldQualified + lusr + (updateLocalConversationDescription lusr zcon) + (\_ _ -> throw FederationNotImplemented) + qcnv + descriptionUpdate + +updateLocalConversationDescription :: + ( Member ConversationStore r, + Member (ErrorS 'ConvNotFound) r, + Member (ErrorS 'ConvAccessDenied) r, + Member (ErrorS 'InvalidOperation) r, + Member NotificationSubsystem r, + Member Now r, + Member E.ExternalAccess r, + Member TeamSubsystem r + ) => + Local UserId -> + ConnId -> + Local ConvId -> + ConversationDescriptionUpdate -> + Sem r ConversationDescription +updateLocalConversationDescription lusr zcon lcnv descriptionUpdate = do + storedConv <- E.getConversation (tUnqualified lcnv) >>= noteS @'ConvNotFound + mTeamMember <- maybe (pure Nothing) (TeamSubsystem.internalGetTeamMember (tUnqualified lusr)) storedConv.metadata.cnvmTeam + Query.ensureConvAdmin storedConv (tUnqualified lusr) mTeamMember + current <- E.getConversationDescription (tUnqualified lcnv) + let convDescription = + ConversationDescription + { descriptionVersion = descriptionUpdate.descriptionUpdateBaseVersion + 1, + descriptionCiphertext = descriptionUpdate.descriptionUpdateCiphertext + } + description <- + case current of + Nothing -> do + when (descriptionUpdate.descriptionUpdateBaseVersion /= 0) $ + throwS @'InvalidOperation + E.insertConversationDescription (tUnqualified lcnv) convDescription >>= \case + Just created -> pure created + Nothing -> + E.updateConversationDescription (tUnqualified lcnv) descriptionUpdate >>= noteS @'InvalidOperation + Just ConversationDescription {descriptionVersion} -> do + unless (descriptionUpdate.descriptionUpdateBaseVersion == descriptionVersion) $ + throwS @'InvalidOperation + E.updateConversationDescription (tUnqualified lcnv) descriptionUpdate >>= noteS @'InvalidOperation + notifyConversationDescriptionUpdated lusr zcon storedConv lcnv description + +notifyConversationDescriptionUpdated :: + ( Member NotificationSubsystem r, + Member Now r, + Member E.ExternalAccess r + ) => + Local UserId -> + ConnId -> + StoredConversation -> + Local ConvId -> + ConversationDescription -> + Sem r ConversationDescription +notifyConversationDescriptionUpdated lusr zcon conv lcnv description = do + now <- Now.get + let event = Event (tUntagged lcnv) Nothing (EventFromUser (tUntagged lusr)) now Nothing (EdConvDescriptionUpdate description) + (bots, users) = localBotsAndUsers $ conv.localMembers + pushConversationEvent (Just zcon) conv event (qualifyAs lusr (map (.id_) users)) bots + pure description + updateLocalConversationName :: ( Member ConversationStore r, Member (Error FederationError) r, diff --git a/libs/wire-subsystems/src/Wire/ProfileLinkStore.hs b/libs/wire-subsystems/src/Wire/ProfileLinkStore.hs new file mode 100644 index 00000000000..0c9ad749744 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/ProfileLinkStore.hs @@ -0,0 +1,160 @@ +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE TemplateHaskell #-} +{-# OPTIONS_GHC -fprint-potential-instances #-} + +module Wire.ProfileLinkStore where + +import Control.Exception qualified as Exception +import Data.ByteString qualified as BS +import Data.ByteString.Builder qualified as BS +import Data.Handle +import Data.Id +import Data.Misc +import Data.Set qualified as Set +import Data.Text qualified as Text +import Data.Text.Encoding qualified as Text +import Data.Time +import Data.Vector (Vector) +import Debug.Trace +import Hasql.Statement (Statement) +import Hasql.TH +import Hasql.Transaction qualified as Transaction +import Hasql.Transaction.Sessions +import Imports +import Polysemy +import Text.HTML.Scalpel +import URI.ByteString (serializeURIRef) +import Wire.API.PostgresMarshall +import Wire.API.User +import Wire.Postgres +import Wire.Sem.Now (Now) +import Wire.Sem.Now qualified as Now + +-- | FUTUREWORK: Merge this into UserStore when Cassadnra is out of picture +data ProfileLinkStore m a where + UpsertProfileLinks :: UserId -> [UnverifiedLink] -> ProfileLinkStore m () + UpdateVerified :: UserId -> ProfileLink x -> Bool -> ProfileLinkStore m () + GetProfileLinks :: UserId -> ProfileLinkStore m [ProfileLink (Maybe UTCTime)] + +makeSem ''ProfileLinkStore + +-- TODO: Move this to its own module +data ProfileLinkSubsystem m a where + VerifyLink :: UserId -> Handle -> ProfileLink (Maybe UTCTime) -> ProfileLinkSubsystem m VerifiedLink + +makeSem ''ProfileLinkSubsystem + +interpretProfileLinkStorePostgres :: (PGConstraints r, Member Now r) => InterpreterFor ProfileLinkStore r +interpretProfileLinkStorePostgres = interpret $ \case + UpsertProfileLinks uid links -> upsertProfileLinksImpl uid links + UpdateVerified uid link verified -> updateVerifiedImpl uid link verified + GetProfileLinks uid -> getProfileLinksImpl uid + +interpretProfileLinkSubsystem :: (Member (Embed IO) r, Member ProfileLinkStore r, Member Now r) => InterpreterFor ProfileLinkSubsystem r +interpretProfileLinkSubsystem = interpret $ \case + VerifyLink uid handle link -> verifyLinkImpl uid handle link + +-- | FUTUREWORK: Use global manager +verifyLinkImpl :: (Member (Embed IO) r, Member ProfileLinkStore r, Member Now r) => UserId -> Handle -> ProfileLink (Maybe UTCTime) -> Sem r (ProfileLink Bool) +verifyLinkImpl uid handle link = do + now <- Now.get + -- A link is considered verified if it was verified less than 24h ago + let isAlreadyVerified = maybe False (< 3600 * 24) $ diffUTCTime now <$> link.verified + traceM $ "isAlreadyVerified: " <> show isAlreadyVerified + if isAlreadyVerified + then pure link {verified = True} + else do + verificationResult <- liftIO $ verify `Exception.catch` (\(_ :: SomeException) -> pure False) + traceM $ "verificationResult: " <> show verificationResult + updateVerified uid link verificationResult + pure link {verified = verificationResult} + where + verify :: IO Bool + verify = do + let linkStr = Text.unpack . Text.decodeUtf8 . BS.toStrict . BS.toLazyByteString $ serializeURIRef link.url.httpsUrl + traceM $ "link: " <> linkStr + fromMaybe False <$> scrapeURL linkStr scraper + + scraper :: Scraper Text Bool + scraper = do + let backlink = ("https://account.bella.wire.link" <> "/@" <> Text.unpack (fromHandle handle)) + linkSelector = "link" @: ["href" @= backlink] + anchorSelector = "a" @: ["href" @= backlink] + rels <- (<>) <$> attrs "rel" linkSelector <*> attrs "rel" anchorSelector + traceM $ "rels: " <> show rels + pure $ any (\rel -> any (== "me") $ Text.words rel) rels + +getProfileLinksImpl :: (PGConstraints r) => UserId -> Sem r [ProfileLink (Maybe UTCTime)] +getProfileLinksImpl uid = + map (\(name, url, verified) -> ProfileLink {..}) <$> runStatement uid select + where + select :: Statement UserId [(LinkName, HttpsUrl, Maybe UTCTime)] + select = + dimapPG + [vectorStatement| + SELECT link_name :: text, url :: text, verified_at :: timestamptz? + FROM profile_links + WHERE user_id = $1 :: uuid + |] + +upsertProfileLinksImpl :: (PGConstraints r) => UserId -> [UnverifiedLink] -> Sem r () +upsertProfileLinksImpl uid links = + runTransaction Serializable Write $ do + existingLinks <- Set.fromList <$> Transaction.statement uid selectProfileLinks + let flattenedLinks = Set.fromList $ map (\l -> (l.name, l.url)) links + deletedLinks = Set.map fst $ Set.difference existingLinks flattenedLinks + Transaction.statement (uid, deletedLinks) deleteLinks + Transaction.statement (mkRow flattenedLinks) upsertLinks + where + selectProfileLinks :: Statement UserId [(LinkName, HttpsUrl)] + selectProfileLinks = + dimapPG + [vectorStatement| + SELECT link_name :: text, url :: text + FROM profile_links + WHERE user_id = $1 :: uuid + |] + + deleteLinks :: Statement (UserId, Set LinkName) () + deleteLinks = + lmapPG @(_, Vector _) + [resultlessStatement| + DELETE FROM profile_links + WHERE user_id = $1 :: uuid + AND link_name = ANY($2 :: text[]) + |] + + upsertLinks :: Statement ([UserId], [LinkName], [HttpsUrl]) () + upsertLinks = + lmapPG @(Vector _, Vector _, Vector _) + [resultlessStatement| + INSERT INTO profile_links (user_id, link_name, url) + SELECT * FROM UNNEST($1 :: uuid[], $2 :: text[], $3 :: text[]) + ON CONFLICT (user_id, link_name) DO UPDATE + SET url = EXCLUDED.url, + verified_at = NULL + |] + + mkRow :: Set (LinkName, HttpsUrl) -> ([UserId], [LinkName], [HttpsUrl]) + mkRow flattenedLinks = + let flattenedLinksList = Set.toList flattenedLinks + in ( (replicate (Set.size flattenedLinks) uid), + (map fst flattenedLinksList), + (map snd flattenedLinksList) + ) + +updateVerifiedImpl :: (Member Now r, PGConstraints r) => UserId -> ProfileLink x -> Bool -> Sem r () +updateVerifiedImpl uid link isVerified = do + verifiedTime <- if isVerified then Just <$> Now.get else pure Nothing + runStatement (uid, link.name, link.url, verifiedTime) markVerified + where + markVerified :: Statement (UserId, LinkName, HttpsUrl, Maybe UTCTime) () + markVerified = + lmapPG + [resultlessStatement| + UPDATE profile_links + SET verified_at = $4 :: timestamptz? + WHERE user_id = $1 :: uuid + AND link_name = $2 :: text + AND url = $3 :: text + |] diff --git a/libs/wire-subsystems/src/Wire/UserStore.hs b/libs/wire-subsystems/src/Wire/UserStore.hs index 8545d4c563d..e7e4c2a800e 100644 --- a/libs/wire-subsystems/src/Wire/UserStore.hs +++ b/libs/wire-subsystems/src/Wire/UserStore.hs @@ -46,13 +46,14 @@ data StoredUserUpdate = MkStoredUserUpdate assets :: Maybe [Asset], accentId :: Maybe ColourId, locale :: Maybe Locale, - supportedProtocols :: Maybe (Set BaseProtocolTag) + supportedProtocols :: Maybe (Set BaseProtocolTag), + bio :: Maybe Bio } deriving stock (Eq, Ord, Show, Generic) deriving (Arbitrary) via GenericUniform StoredUserUpdate instance Default StoredUserUpdate where - def = MkStoredUserUpdate Nothing Nothing Nothing Nothing Nothing Nothing Nothing + def = MkStoredUserUpdate Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing -- | Update user handle (this involves several http requests for locking the required handle). -- The old/previous handle (for deciding idempotency). @@ -116,6 +117,7 @@ data UserStore m a where DeleteServiceUser :: ProviderId -> ServiceId -> BotId -> UserStore m () LookupServiceUsers :: ProviderId -> ServiceId -> Maybe (GeneralPaginationState BotId) -> UserStore m (PageWithState BotId (BotId, ConvId, Maybe TeamId)) LookupServiceUsersForTeam :: ProviderId -> ServiceId -> TeamId -> Maybe (GeneralPaginationState BotId) -> UserStore m (PageWithState BotId (BotId, ConvId)) + GetBio :: UserId -> UserStore m (Maybe Bio) makeSem ''UserStore diff --git a/libs/wire-subsystems/src/Wire/UserStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/UserStore/Cassandra.hs index bc919cd854d..d44cd60ad04 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/Cassandra.hs @@ -22,6 +22,7 @@ import Cassandra.Exec (prepared) import Control.Lens ((^.)) import Data.Handle import Data.Id +import Data.Range import Database.CQL.Protocol import Imports import Polysemy @@ -79,6 +80,17 @@ interpretUserStoreCassandra casClient = DeleteServiceUser pid sid bid -> deleteServiceUserImpl pid sid bid LookupServiceUsers pid sid mPagingState -> lookupServiceUsersImpl pid sid (paginationStateCassandra =<< mPagingState) LookupServiceUsersForTeam pid sid tid mPagingState -> lookupServiceUsersForTeamImpl pid sid tid (paginationStateCassandra =<< mPagingState) + GetBio uid -> getBioImpl uid + +getBioImpl :: UserId -> Client (Maybe Bio) +getBioImpl u = do + storedBio <- + (runIdentity =<<) + <$> (query1 selectBio (params LocalQuorum (Identity u))) + pure $ checked =<< storedBio + where + selectBio :: PrepQuery R (Identity UserId) (Identity (Maybe Text)) + selectBio = "SELECT bio FROM user WHERE id = ?" createUserImpl :: NewStoredUser -> Maybe (ConvId, Maybe TeamId) -> Client () createUserImpl new mbConv = retry x5 . batch $ do @@ -182,6 +194,7 @@ updateUserImpl uid update = for_ update.locale \a -> addPrepQuery userLocaleUpdate (a.lLanguage, a.lCountry, uid) for_ update.accentId \c -> addPrepQuery userAccentIdUpdate (c, uid) for_ update.supportedProtocols \a -> addPrepQuery userSupportedProtocolsUpdate (a, uid) + for_ update.bio $ \b -> addPrepQuery userBioUpdate (fromRange b, uid) updateEmailImpl :: UserId -> EmailAddress -> Client () updateEmailImpl u e = retry x5 $ write userEmailUpdate (params LocalQuorum (e, u)) @@ -496,6 +509,9 @@ userLocaleUpdate = "UPDATE user SET language = ?, country = ? WHERE id = ?" userSupportedProtocolsUpdate :: PrepQuery W (Imports.Set BaseProtocolTag, UserId) () userSupportedProtocolsUpdate = "UPDATE user SET supported_protocols = ? WHERE id = ?" +userBioUpdate :: PrepQuery W (Text, UserId) () +userBioUpdate = "UPDATE user SET bio = ? WHERE id = ?" + handleInsert :: PrepQuery W (Handle, UserId) () handleInsert = "INSERT INTO user_handle (handle, user) VALUES (?, ?)" diff --git a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs index 9192317b407..634dd5cb1ae 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs @@ -76,6 +76,11 @@ interpretUserStorePostgres = DeleteServiceUser pid sid bid -> deleteServiceUserImpl pid sid bid LookupServiceUsers pid sid mPagingState -> lookupServiceUsersImpl pid sid (paginationStatePostgres =<< mPagingState) LookupServiceUsersForTeam pid sid tid mPagingState -> lookupServiceUsersForTeamImpl pid sid tid (paginationStatePostgres =<< mPagingState) + GetBio uid -> getBioImpl uid + +-- TODO: Implement +getBioImpl :: UserId -> Sem r (Maybe Bio) +getBioImpl _ = pure Nothing {- ORMOLU_DISABLE -} type InsertUserRow = diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem.hs b/libs/wire-subsystems/src/Wire/UserSubsystem.hs index 3980bbf6b8f..6edac18e5ec 100644 --- a/libs/wire-subsystems/src/Wire/UserSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/UserSubsystem.hs @@ -93,7 +93,9 @@ data UserProfileUpdate = MkUserProfileUpdate assets :: Maybe [Asset], accentId :: Maybe ColourId, locale :: Maybe Locale, - supportedProtocols :: Maybe (Set BaseProtocolTag) + supportedProtocols :: Maybe (Set BaseProtocolTag), + bio :: Maybe Bio, + links :: Maybe [UnverifiedLink] } deriving stock (Eq, Ord, Show, Generic) deriving (Arbitrary) via GenericUniform UserProfileUpdate @@ -107,7 +109,9 @@ instance Default UserProfileUpdate where assets = Nothing, accentId = Nothing, locale = Nothing, - supportedProtocols = Nothing + supportedProtocols = Nothing, + bio = Nothing, + links = Nothing } -- | Outcome of email change invariant checks. @@ -121,6 +125,7 @@ data ChangeEmailResult data UserSubsystem m a where -- | First arg is for authorization only. GetUserProfiles :: Local UserId -> [Qualified UserId] -> UserSubsystem m [UserProfile] + GetPublicProfile :: Handle -> UserSubsystem m (Maybe PublicProfile) -- | These give us partial success and hide concurrency in the interpreter. -- (Nit-pick: a better return type for this might be `([Qualified ([UserId], -- FederationError)], [UserProfile])`, and then we'd probably need a function of type diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs index 42ec197d844..e83a992473c 100644 --- a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs @@ -38,7 +38,7 @@ import Data.Json.Util import Data.LegalHold import Data.List.Extra (nubOrd) import Data.Map.Strict qualified as Map -import Data.Misc (HttpsUrl, PlainTextPassword6, mkHttpsUrl) +import Data.Misc import Data.Qualified import Data.Range import Data.Set qualified as Set @@ -90,6 +90,8 @@ import Wire.IndexedUserStore (IndexedUserStore) import Wire.IndexedUserStore qualified as IndexedUserStore import Wire.IndexedUserStore.Bulk.ElasticSearch (teamSearchVisibilityInbound) import Wire.InvitationStore +import Wire.ProfileLinkStore (ProfileLinkStore, ProfileLinkSubsystem) +import Wire.ProfileLinkStore qualified as ProfileLinkStore import Wire.Sem.Concurrency import Wire.Sem.Metrics import Wire.Sem.Metrics qualified as Metrics @@ -107,6 +109,7 @@ import Wire.UserSubsystem as UserSubsystem import Wire.UserSubsystem.Error import Wire.UserSubsystem.HandleBlacklist import Wire.UserSubsystem.UserSubsystemConfig +import Wire.Util import Witherable (wither) runUserSubsystem :: @@ -134,7 +137,9 @@ runUserSubsystem :: Member (Input UserSubsystemConfig) r, Member TeamSubsystem r, Member UserGroupStore r, - Member (Input (Local any)) r + Member (Input (Local any)) r, + Member ProfileLinkStore r, + Member ProfileLinkSubsystem r ) => InterpreterFor AuthenticationSubsystem (AppSubsystem ': ClientSubsystem ': r) -> InterpreterFor AppSubsystem (ClientSubsystem ': r) -> @@ -146,6 +151,8 @@ runUserSubsystem authInterpreter appInterpreter clientInterpreter = clientInterpreter . appInterpreter . authInterpreter . \case GetUserProfiles self others -> getUserProfilesImpl self others + GetPublicProfile hdl -> + getPublicProfileImpl hdl GetLocalUserProfiles others -> getLocalUserProfilesImpl others GetLocalAppProfiles ltid -> @@ -196,6 +203,28 @@ runUserSubsystem authInterpreter appInterpreter clientInterpreter = CheckUserIsAdmin uid -> checkUserIsAdminImpl uid UserSubsystem.SetUserSearchable luid uid searchability -> setUserSearchableImpl luid uid searchability +getPublicProfileImpl :: + ( Member UserStore r, + Member ProfileLinkStore r, + Member (Input (Local x)) r, + Member ProfileLinkSubsystem r + ) => + Handle -> Sem r (Maybe PublicProfile) +getPublicProfileImpl hdl = runMaybeT do + uid <- MaybeT $ UserStore.lookupHandle hdl + bio <- lift $ UserStore.getBio uid + -- TODO: Don't use the store directly? + links <- lift $ ProfileLinkStore.getProfileLinks uid + quid <- lift $ qualifyLocal uid + verifiedLinks <- lift $ mapM (ProfileLinkStore.verifyLink uid hdl) links + pure + PublicProfile + { publicHandle = Just hdl, + publicBio = bio, + publicId = tUntagged quid, + publicLinks = verifiedLinks + } + scimExtId :: StoredUser -> Maybe Text scimExtId su = do m <- su.managedBy @@ -646,7 +675,8 @@ updateUserProfileImpl :: Member Events r, Member GalleyAPIAccess r, Member IndexedUserStore r, - Member Metrics r + Member Metrics r, + Member ProfileLinkStore r ) => Local UserId -> Maybe ConnId -> @@ -659,6 +689,7 @@ updateUserProfileImpl (tUnqualified -> uid) mconn updateOrigin update = do guardLockedFields user updateOrigin update mapError (\StoredUserUpdateHandleExists -> UserSubsystemHandleExists) $ updateUser uid (storedUserUpdate update) + for_ update.links $ ProfileLinkStore.upsertProfileLinks uid let interestingToUpdateIndex = isJust update.name || isJust update.accentId when interestingToUpdateIndex $ syncUserIndex uid generateUserEvent uid mconn (mkProfileUpdateEvent uid update) @@ -678,7 +709,8 @@ storedUserUpdate update = assets = update.assets, accentId = update.accentId, locale = update.locale, - supportedProtocols = update.supportedProtocols + supportedProtocols = update.supportedProtocols, + bio = update.bio } mkProfileUpdateEvent :: UserId -> UserProfileUpdate -> UserEvent diff --git a/libs/wire-subsystems/src/Wire/Util.hs b/libs/wire-subsystems/src/Wire/Util.hs index 0e9c476cace..bd5c4ce491b 100644 --- a/libs/wire-subsystems/src/Wire/Util.hs +++ b/libs/wire-subsystems/src/Wire/Util.hs @@ -38,7 +38,7 @@ embedClientInput a = do client <- input embedClient client a -qualifyLocal :: (Member (Input (Local ())) r) => a -> Sem r (Local a) +qualifyLocal :: (Member (Input (Local x)) r) => a -> Sem r (Local a) qualifyLocal a = do l <- input pure $ qualifyAs l a diff --git a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs index 88624c7019f..59cec720263 100644 --- a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs +++ b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs @@ -124,6 +124,7 @@ import Wire.MockInterpreters import Wire.NotificationSubsystem import Wire.PasswordResetCodeStore import Wire.PasswordStore +import Wire.ProfileLinkStore (ProfileLinkStore, ProfileLinkSubsystem) import Wire.RateLimit import Wire.Sem.Concurrency import Wire.Sem.Concurrency.Sequential @@ -290,6 +291,8 @@ type MiniBackendLowerEffects = VerificationCodeStore, SessionStore, UserGroupStore, + ProfileLinkSubsystem, + ProfileLinkStore, RateLimit, HashPassword, DeleteQueue, @@ -333,6 +336,8 @@ miniBackendLowerEffectsInterpreters mb@(MiniBackendParams {..}) = . inMemoryDeleteQueueInterpreter . staticHashPasswordInterpreter . noRateLimit + . runInMemoryProfileLinkStore + . noopProfileLinkSubsystem . userGroupStoreTestInterpreter . runInMemorySessionStore . runInMemoryVerificationCodeStore diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs index c1bdcdb2628..a01b6db9b94 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs @@ -45,6 +45,7 @@ import Wire.MockInterpreters.NotificationSubsystem as MockInterpreters import Wire.MockInterpreters.Now as MockInterpreters import Wire.MockInterpreters.PasswordResetCodeStore as MockInterpreters import Wire.MockInterpreters.PasswordStore as MockInterpreters +import Wire.MockInterpreters.ProfileLinkStore as MockInterpreters import Wire.MockInterpreters.PropertyStore as MockInterpreters import Wire.MockInterpreters.Random as MockInterpreters import Wire.MockInterpreters.RateLimit as MockInterpreters diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ProfileLinkStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ProfileLinkStore.hs new file mode 100644 index 00000000000..49169576bf9 --- /dev/null +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ProfileLinkStore.hs @@ -0,0 +1,54 @@ +{-# LANGUAGE RecordWildCards #-} + +module Wire.MockInterpreters.ProfileLinkStore where + +import Data.Id +import Data.Map qualified as Map +import Data.Misc +import Data.Set qualified as Set +import Data.Time +import Imports +import Polysemy +import Polysemy.State +import Wire.API.User +import Wire.ProfileLinkStore +import Wire.Sem.Now as Now + +type ProfileLinks = Map (UserId, LinkName) (HttpsUrl, Maybe UTCTime) + +runInMemoryProfileLinkStore :: (Member Now r) => InterpreterFor ProfileLinkStore r +runInMemoryProfileLinkStore = + evalState mempty + . inMemoryProfileLinkStoreInterpreter + . raiseUnder + +noopProfileLinkSubsystem :: InterpreterFor ProfileLinkSubsystem r +noopProfileLinkSubsystem = interpret $ \case + VerifyLink _ _ link -> pure $ link {verified = False} + +inMemoryProfileLinkStoreInterpreter :: (Member Now r, Member (State ProfileLinks) r) => InterpreterFor ProfileLinkStore r +inMemoryProfileLinkStoreInterpreter = interpret $ \case + UpsertProfileLinks uid links -> + modify $ \old -> + let existingLinks = Set.fromList $ Map.foldMapWithKey (\(_, n) (u, _) -> [(n, u)]) $ Map.filterWithKey (\(u, _) _ -> u == uid) old + flattenedLinks = Set.fromList $ map (\l -> (l.name, l.url)) links + deletedLinks = Set.map ((uid,) . fst) $ Set.difference existingLinks flattenedLinks + in Set.foldr + ( \(n, u) -> + Map.insertWith + ( \(uNew, _) (uOld, ts) -> + if uNew == uOld + then (uNew, ts) + else (uNew, Nothing) + ) + (uid, n) + (u, Nothing) + ) + (Map.withoutKeys old deletedLinks) + flattenedLinks + UpdateVerified uid link verified -> do + timestamp <- Now.get + modify $ Map.insert (uid, link.name) (link.url, if verified then Just timestamp else Nothing) + GetProfileLinks uid -> do + relevant <- gets (Map.filterWithKey (\(u, _) _ -> u == uid)) + pure $ Map.foldMapWithKey (\(_, name) (url, verified) -> [ProfileLink {..}]) relevant diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserStore.hs index 9f6d24d9ef1..30bc385da69 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserStore.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserStore.hs @@ -166,6 +166,7 @@ inMemoryUserStoreInterpreter = interpret $ \case DeleteServiceUser {} -> error "DeleteServiceUser: Not implemented" LookupServiceUsers {} -> error "lookupServiceUsers: Not implemented" LookupServiceUsersForTeam {} -> error "lookupServiceUsersForteam: Not implemented" + GetBio {} -> error "getBio: Not implemented" storedUserToIndexUser :: StoredUser -> IndexUser storedUserToIndexUser storedUser = diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs index c5feaeae032..f92b301fbc6 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs @@ -94,6 +94,7 @@ inMemoryUserSubsystemInterpreter = BrowseTeam {} -> error "BrowseTeam: implement on demand (userSubsystemInterpreter)" CheckUserIsAdmin {} -> error "CheckUserIsAdmin: implement on demand (userSubsystemInterpreter)" SetUserSearchable {} -> error "SetUserSearchable: implement on demand (userSubsystemInterpreter)" + GetPublicProfile {} -> error "GetPublicProfile: implement on deman (userSubsystemInterpreter)" toProfile :: User -> UserProfile toProfile u = mkUserProfileWithEmail (userEmail u) u Nothing UserLegalHoldDisabled diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index 881b3965068..decc8a99b48 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -169,6 +169,7 @@ common common-all , resourcet , retry , saml2-web-sso + , scalpel , schema-profunctor , servant , servant-client-core @@ -389,6 +390,7 @@ library Wire.Postgres Wire.PostgresMigrationOpts Wire.PostgresMigrations + Wire.ProfileLinkStore Wire.PropertyStore Wire.PropertyStore.Cassandra Wire.PropertySubsystem @@ -618,6 +620,7 @@ test-suite wire-subsystems-tests Wire.MockInterpreters.Now Wire.MockInterpreters.PasswordResetCodeStore Wire.MockInterpreters.PasswordStore + Wire.MockInterpreters.ProfileLinkStore Wire.MockInterpreters.PropertyStore Wire.MockInterpreters.Random Wire.MockInterpreters.RateLimit diff --git a/postgres-schema.sql b/postgres-schema.sql index 2f9f0effcea..2547da1ea33 100644 --- a/postgres-schema.sql +++ b/postgres-schema.sql @@ -54,6 +54,22 @@ CREATE TYPE public.recurrence_frequency AS ENUM ( ALTER TYPE public.recurrence_frequency OWNER TO "wire-server"; +-- +-- Name: update_updated_at(); Type: FUNCTION; Schema: public; Owner: wire-server +-- + +CREATE FUNCTION public.update_updated_at() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$; + + +ALTER FUNCTION public.update_updated_at() OWNER TO "wire-server"; + SET default_tablespace = ''; SET default_table_access_method = heap; @@ -74,6 +90,33 @@ CREATE TABLE public.apps ( ALTER TABLE public.apps OWNER TO "wire-server"; +-- +-- Name: asset; Type: TABLE; Schema: public; Owner: wire-server +-- + +CREATE TABLE public.asset ( + user_id uuid NOT NULL, + typ integer NOT NULL, + key text NOT NULL, + size integer +); + + +ALTER TABLE public.asset OWNER TO "wire-server"; + +-- +-- Name: bot_conv; Type: TABLE; Schema: public; Owner: wire-server +-- + +CREATE TABLE public.bot_conv ( + id uuid NOT NULL, + conv uuid NOT NULL, + conv_team uuid +); + + +ALTER TABLE public.bot_conv OWNER TO "wire-server"; + -- -- Name: collaborators; Type: TABLE; Schema: public; Owner: wire-server -- @@ -132,6 +175,21 @@ CREATE TABLE public.conversation_codes ( ALTER TABLE public.conversation_codes OWNER TO "wire-server"; +-- +-- Name: conversation_description; Type: TABLE; Schema: public; Owner: wire-server +-- + +CREATE TABLE public.conversation_description ( + conv_id uuid NOT NULL, + version bigint NOT NULL, + ciphertext bytea NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +ALTER TABLE public.conversation_description OWNER TO "wire-server"; + -- -- Name: conversation_member; Type: TABLE; Schema: public; Owner: wire-server -- @@ -178,6 +236,20 @@ CREATE TABLE public.conversation_out_of_sync ( ALTER TABLE public.conversation_out_of_sync OWNER TO "wire-server"; +-- +-- Name: deleted_user; Type: TABLE; Schema: public; Owner: wire-server +-- + +CREATE TABLE public.deleted_user ( + id uuid NOT NULL, + team uuid, + created_at timestamp with time zone NOT NULL, + deleted_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +ALTER TABLE public.deleted_user OWNER TO "wire-server"; + -- -- Name: domain_registration; Type: TABLE; Schema: public; Owner: wire-server -- @@ -386,6 +458,42 @@ CREATE TABLE public.user_group_member ( ALTER TABLE public.user_group_member OWNER TO "wire-server"; +-- +-- Name: wire_user; Type: TABLE; Schema: public; Owner: wire-server +-- + +CREATE TABLE public.wire_user ( + id uuid NOT NULL, + user_type integer NOT NULL, + accent_id integer NOT NULL, + activated boolean NOT NULL, + country text, + email text, + email_unvalidated text, + expires timestamp with time zone, + feature_conference_calling integer, + handle text, + language text, + managed_by integer, + name text NOT NULL, + password text, + picture jsonb, + provider uuid, + service uuid, + searchable boolean, + sso_id jsonb, + account_status integer, + supported_protocols integer, + team uuid, + text_status text, + rich_info jsonb, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +ALTER TABLE public.wire_user OWNER TO "wire-server"; + -- -- Name: apps apps_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server -- @@ -394,6 +502,14 @@ ALTER TABLE ONLY public.apps ADD CONSTRAINT apps_pkey PRIMARY KEY (user_id); +-- +-- Name: bot_conv bot_conv_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server +-- + +ALTER TABLE ONLY public.bot_conv + ADD CONSTRAINT bot_conv_pkey PRIMARY KEY (id); + + -- -- Name: collaborators collaborators_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server -- @@ -410,6 +526,14 @@ ALTER TABLE ONLY public.conversation_codes ADD CONSTRAINT conversation_codes_pkey PRIMARY KEY (key); +-- +-- Name: conversation_description conversation_description_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server +-- + +ALTER TABLE ONLY public.conversation_description + ADD CONSTRAINT conversation_description_pkey PRIMARY KEY (conv_id); + + -- -- Name: conversation_member conversation_member_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server -- @@ -442,6 +566,14 @@ ALTER TABLE ONLY public.conversation ADD CONSTRAINT conversation_pkey PRIMARY KEY (id); +-- +-- Name: deleted_user deleted_user_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server +-- + +ALTER TABLE ONLY public.deleted_user + ADD CONSTRAINT deleted_user_pkey PRIMARY KEY (id); + + -- -- Name: domain_registration_challenge domain_registration_challenge_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server -- @@ -546,6 +678,43 @@ ALTER TABLE ONLY public.user_group ADD CONSTRAINT user_group_pkey PRIMARY KEY (team_id, id); +-- +-- Name: wire_user wire_user_handle_key; Type: CONSTRAINT; Schema: public; Owner: wire-server +-- + +ALTER TABLE ONLY public.wire_user + ADD CONSTRAINT wire_user_handle_key UNIQUE (handle); + + +-- +-- Name: wire_user wire_user_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server +-- + +ALTER TABLE ONLY public.wire_user + ADD CONSTRAINT wire_user_pkey PRIMARY KEY (id); + + +-- +-- Name: asset_user_id_idx; Type: INDEX; Schema: public; Owner: wire-server +-- + +CREATE INDEX asset_user_id_idx ON public.asset USING btree (user_id); + + +-- +-- Name: bot_conv_conv_idx; Type: INDEX; Schema: public; Owner: wire-server +-- + +CREATE INDEX bot_conv_conv_idx ON public.bot_conv USING btree (conv); + + +-- +-- Name: bot_conv_team_idx; Type: INDEX; Schema: public; Owner: wire-server +-- + +CREATE INDEX bot_conv_team_idx ON public.bot_conv USING btree (conv_team); + + -- -- Name: collaborators_team_id_idx; Type: INDEX; Schema: public; Owner: wire-server -- @@ -651,6 +820,43 @@ CREATE INDEX idx_meetings_start_time ON public.meetings USING btree (start_time) CREATE INDEX user_group_member_user_id_idx ON public.user_group_member USING btree (user_id); +-- +-- Name: wire_user_service_idx; Type: INDEX; Schema: public; Owner: wire-server +-- + +CREATE INDEX wire_user_service_idx ON public.wire_user USING btree (provider, service); + + +-- +-- Name: conversation_description update_conversation_description_updated_at; Type: TRIGGER; Schema: public; Owner: wire-server +-- + +CREATE TRIGGER update_conversation_description_updated_at BEFORE UPDATE ON public.conversation_description FOR EACH ROW EXECUTE FUNCTION public.update_updated_at(); + + +-- +-- Name: wire_user update_user_updated_at; Type: TRIGGER; Schema: public; Owner: wire-server +-- + +CREATE TRIGGER update_user_updated_at BEFORE UPDATE ON public.wire_user FOR EACH ROW EXECUTE FUNCTION public.update_updated_at(); + + +-- +-- Name: bot_conv bot_conv_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: wire-server +-- + +ALTER TABLE ONLY public.bot_conv + ADD CONSTRAINT bot_conv_id_fkey FOREIGN KEY (id) REFERENCES public.wire_user(id) ON DELETE CASCADE; + + +-- +-- Name: conversation_description conversation_description_conv_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: wire-server +-- + +ALTER TABLE ONLY public.conversation_description + ADD CONSTRAINT conversation_description_conv_id_fkey FOREIGN KEY (conv_id) REFERENCES public.conversation(id) ON DELETE CASCADE; + + -- -- Name: conversation_member conversation_member_conv_fkey; Type: FK CONSTRAINT; Schema: public; Owner: wire-server -- diff --git a/services/brig/brig.cabal b/services/brig/brig.cabal index 70e95dcd393..b7c1f013450 100644 --- a/services/brig/brig.cabal +++ b/services/brig/brig.cabal @@ -189,6 +189,7 @@ library Brig.Schema.V90_DomainRegistrationTeamIndex Brig.Schema.V91_UpdateDomainRegistrationSchema_AddWebappUrl Brig.Schema.V92_AddUserType + Brig.Schema.V93_AddBio Brig.Team.API Brig.Team.Template Brig.Template diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs index 729843ca924..bbaa965e4b6 100644 --- a/services/brig/src/Brig/API/Public.hs +++ b/services/brig/src/Brig/API/Public.hs @@ -456,6 +456,7 @@ servantSitemap = userAPI = Named @"get-user-unqualified" getUserUnqualifiedH :<|> Named @"get-user-qualified" getUserProfileH + :<|> Named @"get-public-profile" getPublicProfileH :<|> Named @"update-user-email" updateUserEmail :<|> Named @"get-handle-info-unqualified" getHandleInfoUnqualifiedH :<|> Named @"get-user-by-handle-qualified" Handle.getHandleInfo @@ -645,6 +646,9 @@ servantSitemap = :<|> Named @"put-app" putApp :<|> Named @"refresh-app-cookie" refreshAppCookie +getPublicProfileH :: (Member UserSubsystem r) => Handle -> Handler r (Maybe Public.PublicProfile) +getPublicProfileH = lift . liftSem . User.getPublicProfile + --------------------------------------------------------------------------- -- Handlers @@ -1118,12 +1122,16 @@ updateUser :: Handler r () updateUser uid conn uu = do let update = - def + MkUserProfileUpdate { name = uu.uupName, pict = uu.uupPict, textStatus = uu.uupTextStatus, assets = uu.uupAssets, - accentId = uu.uupAccentId + accentId = uu.uupAccentId, + bio = uu.uupBio, + links = uu.uupLinks, + locale = Nothing, + supportedProtocols = Nothing } lift . liftSem $ updateUserProfile uid (Just conn) UpdateOriginWireClient update diff --git a/services/brig/src/Brig/CanonicalInterpreter.hs b/services/brig/src/Brig/CanonicalInterpreter.hs index afa2ecc20e3..1f9f2fbae0a 100644 --- a/services/brig/src/Brig/CanonicalInterpreter.hs +++ b/services/brig/src/Brig/CanonicalInterpreter.hs @@ -121,6 +121,7 @@ import Wire.PasswordResetCodeStore.Cassandra (interpretClientToIO, passwordReset import Wire.PasswordStore (PasswordStore) import Wire.PasswordStore.Cassandra (interpretPasswordStore) import Wire.PostgresMigrationOpts +import Wire.ProfileLinkStore (ProfileLinkStore, ProfileLinkSubsystem, interpretProfileLinkStorePostgres, interpretProfileLinkSubsystem) import Wire.PropertyStore import Wire.PropertyStore.Cassandra import Wire.PropertySubsystem @@ -210,6 +211,8 @@ type BrigLowerLevelEffects = UserKeyStore, UserStore, UserGroupStore, + ProfileLinkSubsystem, + ProfileLinkStore, DomainRegistrationStore, DomainVerificationChallengeStore, Error AppSubsystemError, @@ -485,6 +488,8 @@ runBrigToIO e (AppT ma) = do . mapError appSubsystemErrorToHttpError . domainVerificationChallengeStore . domainRegistrationStore + . interpretProfileLinkStorePostgres + . interpretProfileLinkSubsystem . interpretUserGroupStoreToPostgres . userStoreInterpreter . interpretUserKeyStoreCassandra e.casClient diff --git a/services/brig/src/Brig/Schema/Run.hs b/services/brig/src/Brig/Schema/Run.hs index bef0e82ce37..ee34a1b83cc 100644 --- a/services/brig/src/Brig/Schema/Run.hs +++ b/services/brig/src/Brig/Schema/Run.hs @@ -67,6 +67,7 @@ import Brig.Schema.V89_UpdateDomainRegistrationSchema qualified as V89_UpdateDom import Brig.Schema.V90_DomainRegistrationTeamIndex qualified as V90_DomainRegistrationTeamIndex import Brig.Schema.V91_UpdateDomainRegistrationSchema_AddWebappUrl qualified as V91_UpdateDomainRegistrationSchema_AddWebappUrl import Brig.Schema.V92_AddUserType qualified as V92_AddUserType +import Brig.Schema.V93_AddBio qualified as V93_AddBio import Cassandra.MigrateSchema (migrateSchema) import Cassandra.Schema import Control.Exception (finally) @@ -140,7 +141,8 @@ migrations = V89_UpdateDomainRegistrationSchema.migration, V90_DomainRegistrationTeamIndex.migration, V91_UpdateDomainRegistrationSchema_AddWebappUrl.migration, - V92_AddUserType.migration + V92_AddUserType.migration, + V93_AddBio.migration -- FUTUREWORK: undo V41 (searchable flag); we stopped using it in -- https://github.com/wireapp/wire-server/pull/964 ] diff --git a/services/brig/src/Brig/Schema/V93_AddBio.hs b/services/brig/src/Brig/Schema/V93_AddBio.hs new file mode 100644 index 00000000000..0fe25c4bd70 --- /dev/null +++ b/services/brig/src/Brig/Schema/V93_AddBio.hs @@ -0,0 +1,33 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . +module Brig.Schema.V93_AddBio + ( migration, + ) +where + +import Cassandra.Schema +import Imports +import Text.RawString.QQ + +migration :: Migration +migration = + Migration 93 "add bio column" $ do + schema' + [r| + ALTER TABLE user ADD + ( bio text ) + |] diff --git a/services/brig/test/integration/API/Team.hs b/services/brig/test/integration/API/Team.hs index 66e1ead9e28..f5ac0f10161 100644 --- a/services/brig/test/integration/API/Team.hs +++ b/services/brig/test/integration/API/Team.hs @@ -185,7 +185,7 @@ testUpdateEvents brig cannon = do newAssets = Just [ImageAsset (AssetKeyV3 (Id (fromJust (UUID.fromString "5cd81cc4-c643-4e9c-849c-c596a88c27fd"))) AssetExpiring) (Just AssetComplete)] newName = Just $ Name "Alice in Wonderland" newPic = Nothing -- Legacy - userUpdate = UserUpdate newName Nothing newPic newAssets newColId + userUpdate = UserUpdate newName Nothing newPic newAssets newColId Nothing Nothing update = RequestBodyLBS . encode $ userUpdate -- Update profile & receive notification WS.bracketRN cannon [alice, bob] $ \[aliceWS, bobWS] -> do diff --git a/services/brig/test/integration/API/User/Account.hs b/services/brig/test/integration/API/User/Account.hs index 796edb99a9e..cfc7a26c62c 100644 --- a/services/brig/test/integration/API/User/Account.hs +++ b/services/brig/test/integration/API/User/Account.hs @@ -922,7 +922,7 @@ testUserUpdate brig cannon userJournalWatcher = do mNewName = Just $ aliceNewName mNewTextStatus = rightToMaybe $ mkTextStatus "fun status" newPic = Nothing -- Legacy - userUpdate = UserUpdate mNewName mNewTextStatus newPic newAssets newColId + userUpdate = UserUpdate mNewName mNewTextStatus newPic newAssets newColId Nothing Nothing update = RequestBodyLBS . encode $ userUpdate -- Update profile & receive notification WS.bracketRN cannon [alice, bob] $ \[aliceWS, bobWS] -> do @@ -1265,7 +1265,7 @@ testDeleteWithProfilePic brig cargohold = do (qUnqualified $ ast ^. Asset.assetKey) (Just AssetComplete) ] - userUpdate = UserUpdate Nothing Nothing Nothing newAssets Nothing + userUpdate = UserUpdate Nothing Nothing Nothing newAssets Nothing Nothing Nothing update = RequestBodyLBS . encode $ userUpdate -- Update profile with the uploaded asset put (brig . path "/self" . contentJson . zUser uid . zConn "c" . body update) diff --git a/services/galley/src/Galley/API/Public/Conversation.hs b/services/galley/src/Galley/API/Public/Conversation.hs index b69d74adb0e..6be492be987 100644 --- a/services/galley/src/Galley/API/Public/Conversation.hs +++ b/services/galley/src/Galley/API/Public/Conversation.hs @@ -87,6 +87,8 @@ conversationAPI = <@> mkNamedAPI @"update-conversation-name-deprecated" (\lusr con cnv rename -> updateConversationName lusr con (tUntagged (qualifyAs lusr cnv)) rename) <@> mkNamedAPI @"update-conversation-name-unqualified" (\lusr con cnv rename -> updateConversationName lusr con (tUntagged (qualifyAs lusr cnv)) rename) <@> mkNamedAPI @"update-conversation-name" updateConversationName + <@> mkNamedAPI @"get-conversation-description" getConversationDescription + <@> mkNamedAPI @"update-conversation-description" updateConversationDescription <@> mkNamedAPI @"update-conversation-message-timer-unqualified" (\lusr con cnv update -> updateConversationMessageTimer lusr con (tUntagged (qualifyAs lusr cnv)) update) <@> mkNamedAPI @"update-conversation-message-timer" updateConversationMessageTimer <@> mkNamedAPI @"update-conversation-receipt-mode-unqualified" (\lusr con cnv update -> updateConversationReceiptMode lusr con (tUntagged (qualifyAs lusr cnv)) update) diff --git a/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs b/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs index 97b7c036430..57f25a6cedd 100644 --- a/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs +++ b/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs @@ -2504,7 +2504,7 @@ specSCIMManaged = do do displayName <- Name <$> randomAlphaNum - let uupd = UserUpdate (Just displayName) Nothing Nothing Nothing Nothing + let uupd = UserUpdate (Just displayName) Nothing Nothing Nothing Nothing Nothing Nothing call $ updateProfileBrig brig uid uupd !!! do (fmap Wai.label . responseJsonEither @Wai.Error) === const (Right "managed-by-scim")