diff --git a/config/bridge.go b/config/bridge.go index c6c96fa9..c61cd7e2 100644 --- a/config/bridge.go +++ b/config/bridge.go @@ -35,6 +35,7 @@ type BridgeConfig struct { DisplaynameTemplate string `yaml:"displayname_template"` PrivateChatPortalMeta string `yaml:"private_chat_portal_meta"` UseContactAvatars bool `yaml:"use_contact_avatars"` + UseOutdatedProfiles bool `yaml:"use_outdated_profiles"` NumberInTopic bool `yaml:"number_in_topic"` NoteToSelfAvatar id.ContentURIString `yaml:"note_to_self_avatar"` diff --git a/config/upgrade.go b/config/upgrade.go index 785dbbeb..ac647dfe 100644 --- a/config/upgrade.go +++ b/config/upgrade.go @@ -84,6 +84,7 @@ func DoUpgrade(helper *up.Helper) { } helper.Copy(up.Str, "bridge", "private_chat_portal_meta") helper.Copy(up.Bool, "bridge", "use_contact_avatars") + helper.Copy(up.Bool, "bridge", "use_outdated_profiles") helper.Copy(up.Bool, "bridge", "number_in_topic") helper.Copy(up.Str, "bridge", "note_to_self_avatar") helper.Copy(up.Int, "bridge", "portal_message_buffer") diff --git a/database/puppet.go b/database/puppet.go index 568b13d5..b897135a 100644 --- a/database/puppet.go +++ b/database/puppet.go @@ -31,7 +31,7 @@ import ( const ( puppetBaseSelect = ` SELECT uuid, number, name, name_quality, avatar_path, avatar_hash, avatar_url, name_set, avatar_set, - contact_info_set, is_registered, custom_mxid, access_token, first_activity_ts, last_activity_ts + contact_info_set, is_registered, profile_fetched_at, custom_mxid, access_token, first_activity_ts, last_activity_ts FROM puppet ` getPuppetBySignalIDQuery = puppetBaseSelect + `WHERE uuid=$1` @@ -41,18 +41,18 @@ const ( updatePuppetQuery = ` UPDATE puppet SET number=$2, name=$3, name_quality=$4, avatar_path=$5, avatar_hash=$6, avatar_url=$7, - name_set=$8, avatar_set=$9, contact_info_set=$10, is_registered=$11, - custom_mxid=$12, access_token=$13 + name_set=$8, avatar_set=$9, contact_info_set=$10, is_registered=$11, profile_fetched_at=$12, + custom_mxid=$13, access_token=$14 WHERE uuid=$1 ` insertPuppetQuery = ` INSERT INTO puppet ( uuid, number, name, name_quality, avatar_path, avatar_hash, avatar_url, - name_set, avatar_set, contact_info_set, is_registered, + name_set, avatar_set, contact_info_set, is_registered, profile_fetched_at, custom_mxid, access_token ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 ) ` oneDayMs = 24 * 60 * 60 * 1000 @@ -75,11 +75,12 @@ type Puppet struct { NameSet bool AvatarSet bool - IsRegistered bool + IsRegistered bool + ContactInfoSet bool + ProfileFetchedAt time.Time - CustomMXID id.UserID - AccessToken string - ContactInfoSet bool + CustomMXID id.UserID + AccessToken string FirstActivityTs int64 LastActivityTs int64 @@ -107,6 +108,7 @@ func (pq *PuppetQuery) GetAllWithCustomMXID(ctx context.Context) ([]*Puppet, err func (p *Puppet) Scan(row dbutil.Scannable) (*Puppet, error) { var number, customMXID sql.NullString + var profileFetchedAt sql.NullInt64 var firstActivityTs, lastActivityTs sql.NullInt64 err := row.Scan( &p.SignalID, @@ -120,16 +122,20 @@ func (p *Puppet) Scan(row dbutil.Scannable) (*Puppet, error) { &p.AvatarSet, &p.ContactInfoSet, &p.IsRegistered, + &profileFetchedAt, &customMXID, &p.AccessToken, &firstActivityTs, &lastActivityTs, ) if err != nil { - return nil, nil + return nil, err } p.Number = number.String p.CustomMXID = id.UserID(customMXID.String) + if profileFetchedAt.Valid { + p.ProfileFetchedAt = time.UnixMilli(profileFetchedAt.Int64) + } p.FirstActivityTs = firstActivityTs.Int64 p.LastActivityTs = lastActivityTs.Int64 return p, nil @@ -148,6 +154,7 @@ func (p *Puppet) sqlVariables() []any { p.AvatarSet, p.ContactInfoSet, p.IsRegistered, + dbutil.UnixMilliPtr(p.ProfileFetchedAt), dbutil.StrPtr(p.CustomMXID), p.AccessToken, } diff --git a/database/upgrades/00-latest.sql b/database/upgrades/00-latest.sql index 83fa6519..7d9e0475 100644 --- a/database/upgrades/00-latest.sql +++ b/database/upgrades/00-latest.sql @@ -1,4 +1,4 @@ --- v0 -> v20 (compatible with v18+): Latest revision +-- v0 -> v21 (compatible with v18+): Latest revision CREATE TABLE portal ( chat_id TEXT NOT NULL, @@ -33,8 +33,9 @@ CREATE TABLE puppet ( name_set BOOLEAN NOT NULL DEFAULT false, avatar_set BOOLEAN NOT NULL DEFAULT false, - is_registered BOOLEAN NOT NULL DEFAULT false, - contact_info_set BOOLEAN NOT NULL DEFAULT false, + is_registered BOOLEAN NOT NULL DEFAULT false, + contact_info_set BOOLEAN NOT NULL DEFAULT false, + profile_fetched_at BIGINT, custom_mxid TEXT, access_token TEXT NOT NULL, diff --git a/database/upgrades/21-puppet-profile-fetch-ts.sql b/database/upgrades/21-puppet-profile-fetch-ts.sql new file mode 100644 index 00000000..a3d3c1b5 --- /dev/null +++ b/database/upgrades/21-puppet-profile-fetch-ts.sql @@ -0,0 +1,2 @@ +-- v21 (compatible with v18+): Add profile fetch timestamp for puppets +ALTER TABLE puppet ADD profile_fetched_at BIGINT; diff --git a/example-config.yaml b/example-config.yaml index f4aa7e40..08d6db12 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -106,6 +106,8 @@ bridge: private_chat_portal_meta: default # Should avatars from the user's contact list be used? This is not safe on multi-user instances. use_contact_avatars: false + # Should the bridge sync ghost user info even if profile fetching fails? This is not safe on multi-user instances. + use_outdated_profiles: false # Should the Signal user's phone number be included in the room topic in private chat portal rooms? number_in_topic: true # Avatar image for the Note to Self room. diff --git a/go.mod b/go.mod index 08ed0c50..b51007f0 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.21 require ( github.com/beeper/libserv v0.0.0-20231231202820-c7303abfc32c - github.com/element-hq/mautrix-go v0.18.0-beta.1-mod-1 + github.com/element-hq/mautrix-go v0.18.0-beta.1-mod-2.0.20240305190819-c6ec91b05c3c github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.0 @@ -16,7 +16,7 @@ require ( github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/stretchr/testify v1.8.4 github.com/tidwall/gjson v1.17.1 - go.mau.fi/util v0.4.0 + go.mau.fi/util v0.4.1-0.20240222202553-953608f657a3 golang.org/x/crypto v0.19.0 golang.org/x/exp v0.0.0-20240213143201-ec583247a57a golang.org/x/net v0.21.0 diff --git a/go.sum b/go.sum index da2c6cc8..236d1737 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,8 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/element-hq/mautrix-go v0.18.0-beta.1-mod-1 h1:9ICQ0yZYIkYibOKmzUG2Vy8nJSkbv/qLQaGghn+fqcY= -github.com/element-hq/mautrix-go v0.18.0-beta.1-mod-1/go.mod h1:eJu6JOtGbObkSyDpiBL58nuDyjLrIBbrrLa9aDbpsaI= +github.com/element-hq/mautrix-go v0.18.0-beta.1-mod-2.0.20240305190819-c6ec91b05c3c h1:xA1shDsjlmCuE1YQnXMCr9++Rm6U8nL6RleuOUmx/80= +github.com/element-hq/mautrix-go v0.18.0-beta.1-mod-2.0.20240305190819-c6ec91b05c3c/go.mod h1:eJu6JOtGbObkSyDpiBL58nuDyjLrIBbrrLa9aDbpsaI= github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 h1:ATgqloALX6cHCranzkLb8/zjivwQ9DWWDCQRnxTPfaA= github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -73,8 +73,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.0 h1:EfOIvIMZIzHdB/R/zVrikYLPPwJlfMcNczJFMs1m6sA= github.com/yuin/goldmark v1.7.0/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -go.mau.fi/util v0.4.0 h1:S2X3qU4pUcb/vxBRfAuZjbrR9xVMAXSjQojNBLPBbhs= -go.mau.fi/util v0.4.0/go.mod h1:leeiHtgVBuN+W9aDii3deAXnfC563iN3WK6BF8/AjNw= +go.mau.fi/util v0.4.1-0.20240222202553-953608f657a3 h1:NcRrdzORHKab5bP1Z8BpH0nxsxsvH0iPPZLpOUN+UIc= +go.mau.fi/util v0.4.1-0.20240222202553-953608f657a3/go.mod h1:leeiHtgVBuN+W9aDii3deAXnfC563iN3WK6BF8/AjNw= go.mau.fi/zeroconfig v0.1.2 h1:DKOydWnhPMn65GbXZOafgkPm11BvFashZWLct0dGFto= go.mau.fi/zeroconfig v0.1.2/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= diff --git a/pkg/libsignalgo/profilekey.go b/pkg/libsignalgo/profilekey.go index 2cd10005..ba43a60a 100644 --- a/pkg/libsignalgo/profilekey.go +++ b/pkg/libsignalgo/profilekey.go @@ -35,19 +35,25 @@ type ProfileKeyCommitment [C.SignalPROFILE_KEY_COMMITMENT_LEN]byte type ProfileKeyVersion [C.SignalPROFILE_KEY_VERSION_ENCODED_LEN]byte type AccessKey [C.SignalACCESS_KEY_LEN]byte +var blankProfileKey ProfileKey + +func (pk *ProfileKey) IsEmpty() bool { + return pk == nil || *pk == blankProfileKey +} + func (ak *AccessKey) String() string { - return string((*ak)[:]) + return string(ak[:]) } func (pv *ProfileKeyVersion) String() string { - return string((*pv)[:]) + return string(pv[:]) } func (pk *ProfileKey) Slice() []byte { if pk == nil { return nil } - return (*pk)[:] + return pk[:] } func (pk *ProfileKey) GetCommitment(u uuid.UUID) (*ProfileKeyCommitment, error) { diff --git a/pkg/signalmeow/contact.go b/pkg/signalmeow/contact.go index 9bad34b3..4ad56c1c 100644 --- a/pkg/signalmeow/contact.go +++ b/pkg/signalmeow/contact.go @@ -22,6 +22,7 @@ import ( "crypto/sha256" "encoding/binary" "encoding/hex" + "errors" "fmt" "net/http" "strings" @@ -46,27 +47,23 @@ func (cli *Client) StoreContactDetailsAsContact(ctx context.Context, contactDeta Logger() existingContact, err := cli.Store.ContactStore.LoadContact(ctx, parsedUUID) if err != nil { - log.Err(err).Msg("error loading contact") + log.Err(err).Msg("Failed to load contact from database") return nil, err } if existingContact == nil { - log.Debug().Msg("creating new contact") existingContact = &types.Contact{ UUID: parsedUUID, } - } else { - log.Debug().Msg("updating existing contact") } existingContact.E164 = contactDetails.GetNumber() existingContact.ContactName = contactDetails.GetName() if profileKeyString := contactDetails.GetProfileKey(); profileKeyString != nil { profileKey := libsignalgo.ProfileKey(profileKeyString) - existingContact.Profile.Key = &profileKey + existingContact.Profile.Key = profileKey err = cli.Store.ProfileKeyStore.StoreProfileKey(ctx, existingContact.UUID, profileKey) if err != nil { - log.Err(err).Msg("storing profile key") - //return *existingContact, nil, err + log.Err(err).Msg("Failed to store profile key from contact") } } @@ -86,26 +83,25 @@ func (cli *Client) StoreContactDetailsAsContact(ctx context.Context, contactDeta } } - log.Debug().Msg("storing contact") storeErr := cli.Store.ContactStore.StoreContact(ctx, *existingContact) if storeErr != nil { - log.Err(storeErr).Msg("error storing contact") + log.Err(storeErr).Msg("Failed to save contact") return existingContact, storeErr } return existingContact, nil } -func (cli *Client) fetchContactThenTryAndUpdateWithProfile(ctx context.Context, profileUUID uuid.UUID) (existingContact *types.Contact, otherSourceUUID uuid.UUID, err error) { +func (cli *Client) fetchContactThenTryAndUpdateWithProfile(ctx context.Context, profileUUID uuid.UUID) (*types.Contact, error) { log := zerolog.Ctx(ctx).With(). Str("action", "fetch contact then try and update with profile"). Stringer("profile_uuid", profileUUID). Logger() contactChanged := false - existingContact, err = cli.Store.ContactStore.LoadContact(ctx, profileUUID) + existingContact, err := cli.Store.ContactStore.LoadContact(ctx, profileUUID) if err != nil { - log.Err(err).Msg("error loading contact") - return + log.Err(err).Msg("Failed to load contact from database") + return nil, err } if existingContact == nil { log.Debug().Msg("creating new contact") @@ -116,49 +112,32 @@ func (cli *Client) fetchContactThenTryAndUpdateWithProfile(ctx context.Context, } else { log.Debug().Msg("updating existing contact") } - profile, lastFetched, fetchErr := cli.RetrieveProfileByID(ctx, profileUUID) - if fetchErr != nil { - log.Err(fetchErr).Msg("error retrieving profile") - // Don't return here, we still want to return what we have - } else if profile != nil { - if existingContact.Profile.Name != profile.Name { - existingContact.Profile.Name = profile.Name - contactChanged = true - } - if existingContact.Profile.About != profile.About { - existingContact.Profile.About = profile.About - contactChanged = true - } - if existingContact.Profile.AboutEmoji != profile.AboutEmoji { - existingContact.Profile.AboutEmoji = profile.AboutEmoji - contactChanged = true - } - if existingContact.Profile.AvatarPath != profile.AvatarPath { - existingContact.Profile.AvatarPath = profile.AvatarPath - contactChanged = true + profile, err := cli.RetrieveProfileByID(ctx, profileUUID) + if err != nil { + logLevel := zerolog.ErrorLevel + if errors.Is(err, errProfileKeyNotFound) { + logLevel = zerolog.DebugLevel } - if existingContact.Profile.Key == nil || *existingContact.Profile.Key != profile.Key { - existingContact.Profile.Key = &profile.Key + log.WithLevel(logLevel).Err(err).Msg("Failed to fetch profile") + // Continue to return contact without profile + } + + if profile != nil { + // Don't bother saving every fetched timestamp to the database, but save if anything else changed + if !existingContact.Profile.Equals(profile) || existingContact.Profile.FetchedAt.IsZero() { contactChanged = true } + existingContact.Profile = *profile } if contactChanged { - existingContact.ProfileFetchTs = lastFetched.UnixMilli() err = cli.Store.ContactStore.StoreContact(ctx, *existingContact) if err != nil { - log.Err(err).Msg("error storing contact") - return - } - } - - if fetchErr != nil { - otherSourceUUID, fetchErr = cli.Store.ContactStore.UpdateContactWithLatestProfile(ctx, existingContact) - if fetchErr != nil { - log.Err(fetchErr).Msg("error retrieving latest profile for contact from other users") + log.Err(err).Msg("Failed to save contact") + return nil, err } } - return + return existingContact, nil } func (cli *Client) UpdateContactE164(ctx context.Context, uuid uuid.UUID, e164 string) error { @@ -169,26 +148,23 @@ func (cli *Client) UpdateContactE164(ctx context.Context, uuid uuid.UUID, e164 s Logger() existingContact, err := cli.Store.ContactStore.LoadContact(ctx, uuid) if err != nil { - log.Err(err).Msg("error loading contact") + log.Err(err).Msg("Failed to load contact from database") return err } if existingContact == nil { - log.Debug().Msg("creating new contact") existingContact = &types.Contact{ UUID: uuid, } - } else { - log.Debug().Msg("found existing contact") } if existingContact.E164 == e164 { return nil } - log.Debug().Msg("e164 changed for contact") + log.Debug().Msg("Contact phone number changed") existingContact.E164 = e164 return cli.Store.ContactStore.StoreContact(ctx, *existingContact) } -func (cli *Client) ContactByID(ctx context.Context, uuid uuid.UUID) (contact *types.Contact, otherSourceUUID uuid.UUID, err error) { +func (cli *Client) ContactByID(ctx context.Context, uuid uuid.UUID) (*types.Contact, error) { return cli.fetchContactThenTryAndUpdateWithProfile(ctx, uuid) } @@ -201,7 +177,7 @@ func (cli *Client) ContactByE164(ctx context.Context, e164 string) (*types.Conta if contact == nil { return nil, nil } - contact, _, err = cli.fetchContactThenTryAndUpdateWithProfile(ctx, contact.UUID) + contact, err = cli.fetchContactThenTryAndUpdateWithProfile(ctx, contact.UUID) return contact, err } diff --git a/pkg/signalmeow/profile.go b/pkg/signalmeow/profile.go index 0e07eb4a..0ce2c439 100644 --- a/pkg/signalmeow/profile.go +++ b/pkg/signalmeow/profile.go @@ -71,13 +71,8 @@ type ProfileResponse struct { //PaymentAddress []byte `json:"paymentAddress"` } -type Profile struct { - types.ProfileFields - Key libsignalgo.ProfileKey -} - type ProfileCache struct { - profiles map[string]*Profile + profiles map[string]*types.Profile errors map[string]*error lastFetched map[string]time.Time } @@ -116,10 +111,10 @@ func (cli *Client) ProfileKeyForSignalID(ctx context.Context, signalACI uuid.UUI var errProfileKeyNotFound = errors.New("profile key not found") -func (cli *Client) RetrieveProfileByID(ctx context.Context, signalID uuid.UUID) (*Profile, time.Time, error) { +func (cli *Client) RetrieveProfileByID(ctx context.Context, signalID uuid.UUID) (*types.Profile, error) { if cli.ProfileCache == nil { cli.ProfileCache = &ProfileCache{ - profiles: make(map[string]*Profile), + profiles: make(map[string]*types.Profile), errors: make(map[string]*error), lastFetched: make(map[string]time.Time), } @@ -131,45 +126,40 @@ func (cli *Client) RetrieveProfileByID(ctx context.Context, signalID uuid.UUID) if ok && time.Since(lastFetched) < 1*time.Hour { profile, ok := cli.ProfileCache.profiles[signalID.String()] if ok { - return profile, lastFetched, nil + return profile, nil } err, ok := cli.ProfileCache.errors[signalID.String()] if ok { - return nil, lastFetched, *err + return nil, *err } } // If we get here, we don't have a cached profile, so fetch it profile, err := cli.fetchProfileByID(ctx, signalID) - lastFetched = time.Now() if err != nil { + // TODO this check is wrong and most likely doesn't work, errors shouldn't use string comparisons // If we get a 401 or 5xx error, we should not retry until the cache expires if strings.HasPrefix(err.Error(), "401") || strings.HasPrefix(err.Error(), "5") { cli.ProfileCache.errors[signalID.String()] = &err - cli.ProfileCache.lastFetched[signalID.String()] = lastFetched + cli.ProfileCache.lastFetched[signalID.String()] = time.Now() } - return nil, lastFetched, err - } - if profile == nil { - return nil, lastFetched, errProfileKeyNotFound + return nil, err } // If we get here, we have a valid profile, so cache it cli.ProfileCache.profiles[signalID.String()] = profile - cli.ProfileCache.lastFetched[signalID.String()] = lastFetched + cli.ProfileCache.lastFetched[signalID.String()] = time.Now() - return profile, lastFetched, nil + return profile, nil } -func (cli *Client) fetchProfileByID(ctx context.Context, signalID uuid.UUID) (*Profile, error) { +func (cli *Client) fetchProfileByID(ctx context.Context, signalID uuid.UUID) (*types.Profile, error) { log := zerolog.Ctx(ctx) profileKey, err := cli.ProfileKeyForSignalID(ctx, signalID) if err != nil { return nil, fmt.Errorf("error getting profile key: %w", err) - } - if profileKey == nil { - log.Warn().Msg("profileKey is nil") - return nil, nil + } else if profileKey == nil { + return nil, errProfileKeyNotFound } profileKeyVersion, err := profileKey.GetProfileKeyVersion(signalID) @@ -210,7 +200,17 @@ func (cli *Client) fetchProfileByID(ctx context.Context, signalID uuid.UUID) (*P if err != nil { return nil, fmt.Errorf("error sending request: %w", err) } - log.Trace().Msg("Got profile response") + var profile types.Profile + profile.FetchedAt = time.Now() + logEvt := log.Trace().Uint32("status_code", resp.GetStatus()) + if logEvt.Enabled() { + if json.Valid(resp.Body) { + logEvt.RawJSON("response_data", resp.Body) + } else { + logEvt.Str("invalid_response_data", base64.StdEncoding.EncodeToString(resp.Body)) + } + } + logEvt.Msg("Got profile response") if *resp.Status < 200 || *resp.Status >= 300 { return nil, fmt.Errorf("error getting profile (unsuccessful status code %d)", *resp.Status) } @@ -219,7 +219,6 @@ func (cli *Client) fetchProfileByID(ctx context.Context, signalID uuid.UUID) (*P if err != nil { return nil, fmt.Errorf("error unmarshalling profile response: %w", err) } - var profile Profile if len(profileResponse.Name) > 0 { profile.Name, err = decryptString(profileKey, profileResponse.Name) if err != nil { @@ -240,16 +239,14 @@ func (cli *Client) fetchProfileByID(ctx context.Context, signalID uuid.UUID) (*P return nil, fmt.Errorf("error decrypting profile aboutEmoji: %w", err) } } + // TODO store other metadata fields? profile.AvatarPath = profileResponse.Avatar profile.Key = *profileKey return &profile, nil } -func (cli *Client) DownloadUserAvatar(ctx context.Context, avatarPath string, profileKey *libsignalgo.ProfileKey) ([]byte, error) { - if profileKey == nil { - return nil, fmt.Errorf("failed to prepare request: profileKey is nil") - } +func (cli *Client) DownloadUserAvatar(ctx context.Context, avatarPath string, profileKey libsignalgo.ProfileKey) ([]byte, error) { username, password := cli.Store.BasicAuthCreds() opts := &web.HTTPReqOpt{ Host: web.CDN1Hostname, diff --git a/pkg/signalmeow/store/contact_store.go b/pkg/signalmeow/store/contact_store.go index 1f509fb9..d0e71dba 100644 --- a/pkg/signalmeow/store/contact_store.go +++ b/pkg/signalmeow/store/contact_store.go @@ -20,6 +20,7 @@ import ( "context" "database/sql" "errors" + "time" "github.com/google/uuid" "go.mau.fi/util/dbutil" @@ -31,7 +32,6 @@ import ( type ContactStore interface { LoadContact(ctx context.Context, theirUUID uuid.UUID) (*types.Contact, error) LoadContactByE164(ctx context.Context, e164 string) (*types.Contact, error) - UpdateContactWithLatestProfile(ctx context.Context, contact *types.Contact) (sourceUUID uuid.UUID, err error) StoreContact(ctx context.Context, contact types.Contact) error AllContacts(ctx context.Context) ([]*types.Contact, error) UpdatePhone(ctx context.Context, theirUUID uuid.UUID, newE164 string) error @@ -51,8 +51,7 @@ const ( profile_about, profile_about_emoji, profile_avatar_path, - profile_avatar_hash, - profile_fetch_ts + profile_fetched_at FROM signalmeow_contacts ` getAllContactsOfUserQuery = getAllContactsQuery + `WHERE our_aci_uuid = $1` @@ -70,10 +69,9 @@ const ( profile_about, profile_about_emoji, profile_avatar_path, - profile_avatar_hash, - profile_fetch_ts + profile_fetched_at ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (our_aci_uuid, aci_uuid) DO UPDATE SET e164_number = excluded.e164_number, contact_name = excluded.contact_name, @@ -83,8 +81,7 @@ const ( profile_about = excluded.profile_about, profile_about_emoji = excluded.profile_about_emoji, profile_avatar_path = excluded.profile_avatar_path, - profile_avatar_hash = excluded.profile_avatar_hash, - profile_fetch_ts = excluded.profile_fetch_ts + profile_fetched_at = excluded.profile_fetched_at ` upsertContactPhoneQuery = ` INSERT INTO signalmeow_contacts ( @@ -98,10 +95,9 @@ const ( profile_about, profile_about_emoji, profile_avatar_path, - profile_avatar_hash, - profile_fetch_ts + profile_fetched_at ) - VALUES ($1, $2, $3, '', '', NULL, '', '', '', '', '', 0) + VALUES ($1, $2, $3, '', '', NULL, '', '', '', '', NULL) ON CONFLICT (our_aci_uuid, aci_uuid) DO UPDATE SET e164_number = excluded.e164_number ` @@ -110,6 +106,7 @@ const ( func scanContact(row dbutil.Scannable) (*types.Contact, error) { var contact types.Contact var profileKey []byte + var profileFetchedAt sql.NullInt64 err := row.Scan( &contact.UUID, &contact.E164, @@ -120,17 +117,18 @@ func scanContact(row dbutil.Scannable) (*types.Contact, error) { &contact.Profile.About, &contact.Profile.AboutEmoji, &contact.Profile.AvatarPath, - &contact.ProfileAvatarHash, - &contact.ProfileFetchTs, + &profileFetchedAt, ) if errors.Is(err, sql.ErrNoRows) { return nil, nil } else if err != nil { return nil, err } + if profileFetchedAt.Valid { + contact.Profile.FetchedAt = time.UnixMilli(profileFetchedAt.Int64) + } if len(profileKey) != 0 { - profileKeyConverted := libsignalgo.ProfileKey(profileKey) - contact.Profile.Key = &profileKeyConverted + contact.Profile.Key = libsignalgo.ProfileKey(profileKey) } return &contact, err } @@ -143,42 +141,6 @@ func (s *SQLStore) LoadContactByE164(ctx context.Context, e164 string) (*types.C return scanContact(s.db.QueryRow(ctx, getContactByPhoneQuery, s.ACI, e164)) } -func (s *SQLStore) UpdateContactWithLatestProfile(ctx context.Context, contact *types.Contact) (sourceUUID uuid.UUID, err error) { - var profileKey []byte - err = s.db.QueryRow( - ctx, - `SELECT - profile_key, - profile_name, - profile_about, - profile_about_emoji, - profile_avatar_path, - our_aci_uuid - FROM signalmeow_contacts - WHERE - our_aci_uuid <> $1 AND - aci_uuid = $2 AND - LENGTH(COALESCE(profile_key, '')) > 0 - ORDER BY profile_fetch_ts DESC LIMIT 1`, - s.ACI, - contact.UUID, - ).Scan( - &profileKey, - &contact.Profile.Name, - &contact.Profile.About, - &contact.Profile.AboutEmoji, - &contact.Profile.AvatarPath, - &sourceUUID, - ) - if errors.Is(err, sql.ErrNoRows) { - err = nil - } else if err == nil { - profileKeyConverted := libsignalgo.ProfileKey(profileKey) - contact.Profile.Key = &profileKeyConverted - } - return -} - func (s *SQLStore) AllContacts(ctx context.Context) ([]*types.Contact, error) { rows, err := s.db.Query(ctx, getAllContactsOfUserQuery, s.ACI) if err != nil { @@ -188,6 +150,10 @@ func (s *SQLStore) AllContacts(ctx context.Context) ([]*types.Contact, error) { } func (s *SQLStore) StoreContact(ctx context.Context, contact types.Contact) error { + var profileKey []byte + if contact.Profile.Key.IsEmpty() { + profileKey = contact.Profile.Key[:] + } _, err := s.db.Exec( ctx, upsertContactQuery, @@ -196,13 +162,12 @@ func (s *SQLStore) StoreContact(ctx context.Context, contact types.Contact) erro contact.E164, contact.ContactName, contact.ContactAvatar.Hash, - contact.Profile.Key.Slice(), + profileKey, contact.Profile.Name, contact.Profile.About, contact.Profile.AboutEmoji, contact.Profile.AvatarPath, - contact.ProfileAvatarHash, - contact.ProfileFetchTs, + dbutil.UnixMilliPtr(contact.Profile.FetchedAt), ) return err } diff --git a/pkg/signalmeow/store/upgrades/00-latest.sql b/pkg/signalmeow/store/upgrades/00-latest.sql index b8161bc4..6c7dc4bb 100644 --- a/pkg/signalmeow/store/upgrades/00-latest.sql +++ b/pkg/signalmeow/store/upgrades/00-latest.sql @@ -1,4 +1,4 @@ --- v0 -> v7: Latest revision +-- v0 -> v8: Latest revision CREATE TABLE signalmeow_device ( aci_uuid TEXT PRIMARY KEY, @@ -76,19 +76,17 @@ CREATE TABLE signalmeow_groups ( ); CREATE TABLE signalmeow_contacts ( - our_aci_uuid TEXT NOT NULL, - aci_uuid TEXT NOT NULL, - -- TODO make all fields not null - e164_number TEXT, - contact_name TEXT, - contact_avatar_hash TEXT, + our_aci_uuid TEXT NOT NULL, + aci_uuid TEXT NOT NULL, + e164_number TEXT NOT NULL, + contact_name TEXT NOT NULL, + contact_avatar_hash TEXT NOT NULL, profile_key bytea, - profile_name TEXT, - profile_about TEXT, - profile_about_emoji TEXT, - profile_avatar_path TEXT NOT NULL DEFAULT '', - profile_avatar_hash TEXT, - profile_fetch_ts BIGINT NOT NULL DEFAULT 0, + profile_name TEXT NOT NULL, + profile_about TEXT NOT NULL, + profile_about_emoji TEXT NOT NULL, + profile_avatar_path TEXT NOT NULL, + profile_fetched_at BIGINT, PRIMARY KEY (our_aci_uuid, aci_uuid), FOREIGN KEY (our_aci_uuid) REFERENCES signalmeow_device (aci_uuid) ON DELETE CASCADE ON UPDATE CASCADE diff --git a/pkg/signalmeow/store/upgrades/08-profile-fetch-time.sql b/pkg/signalmeow/store/upgrades/08-profile-fetch-time.sql new file mode 100644 index 00000000..414245ca --- /dev/null +++ b/pkg/signalmeow/store/upgrades/08-profile-fetch-time.sql @@ -0,0 +1,11 @@ +-- v6 -> v8: Add profile_fetched_at and make other columns not null +ALTER TABLE signalmeow_contacts DROP COLUMN profile_avatar_hash; +ALTER TABLE signalmeow_contacts ADD COLUMN profile_fetched_at BIGINT; +-- only: postgres until "end only" +ALTER TABLE signalmeow_contacts ALTER COLUMN e164_number SET NOT NULL; +ALTER TABLE signalmeow_contacts ALTER COLUMN contact_name SET NOT NULL; +ALTER TABLE signalmeow_contacts ALTER COLUMN contact_avatar_hash SET NOT NULL; +ALTER TABLE signalmeow_contacts ALTER COLUMN profile_name SET NOT NULL; +ALTER TABLE signalmeow_contacts ALTER COLUMN profile_about SET NOT NULL; +ALTER TABLE signalmeow_contacts ALTER COLUMN profile_about_emoji SET NOT NULL; +-- end only postgres diff --git a/pkg/signalmeow/store/upgrades/08-resync-schema-449.sql b/pkg/signalmeow/store/upgrades/08-resync-schema-449.sql new file mode 100644 index 00000000..95af7759 --- /dev/null +++ b/pkg/signalmeow/store/upgrades/08-resync-schema-449.sql @@ -0,0 +1,12 @@ +-- v7 -> v8: Migration from https://github.com/mautrix/signal/pull/449 to match the new v8 upgrade +ALTER TABLE signalmeow_contacts DROP COLUMN profile_avatar_hash; +ALTER TABLE signalmeow_contacts RENAME COLUMN profile_fetch_ts TO profile_fetched_at; +ALTER TABLE signalmeow_contacts ALTER COLUMN profile_fetched_at DROP DEFAULT; +ALTER TABLE signalmeow_contacts ALTER COLUMN profile_fetched_at DROP NOT NULL; +UPDATE signalmeow_contacts SET profile_fetched_at = NULL WHERE profile_fetched_at <= 0; +ALTER TABLE signalmeow_contacts ALTER COLUMN e164_number SET NOT NULL; +ALTER TABLE signalmeow_contacts ALTER COLUMN contact_name SET NOT NULL; +ALTER TABLE signalmeow_contacts ALTER COLUMN contact_avatar_hash SET NOT NULL; +ALTER TABLE signalmeow_contacts ALTER COLUMN profile_name SET NOT NULL; +ALTER TABLE signalmeow_contacts ALTER COLUMN profile_about SET NOT NULL; +ALTER TABLE signalmeow_contacts ALTER COLUMN profile_about_emoji SET NOT NULL; diff --git a/pkg/signalmeow/types/contact.go b/pkg/signalmeow/types/contact.go index 2534e5a9..1d1e5ecd 100644 --- a/pkg/signalmeow/types/contact.go +++ b/pkg/signalmeow/types/contact.go @@ -17,29 +17,41 @@ package types import ( + "time" + "github.com/google/uuid" "github.com/element-hq/mautrix-signal/pkg/libsignalgo" ) +type Profile struct { + Name string + About string + AboutEmoji string + AvatarPath string + Key libsignalgo.ProfileKey + FetchedAt time.Time +} + +func (p *Profile) Equals(other *Profile) bool { + return p.Name == other.Name && + p.About == other.About && + p.AboutEmoji == other.AboutEmoji && + p.AvatarPath == other.AvatarPath && + p.Key == other.Key +} + // The Contact struct combines information from two sources: // - A Signal "contact": contact info harvested from our user's phone's contact list // - A Signal "profile": contact info entered by the target user when registering for Signal // Users of this Contact struct should prioritize "contact" information, but fall back // to "profile" information if the contact information is not available. type Contact struct { - UUID uuid.UUID - E164 string - ContactName string - ContactAvatar ContactAvatar - Profile ContactProfile - ProfileAvatarHash string - ProfileFetchTs int64 -} - -type ContactProfile struct { - ProfileFields - Key *libsignalgo.ProfileKey + UUID uuid.UUID + E164 string + ContactName string + ContactAvatar ContactAvatar + Profile Profile } type ContactAvatar struct { diff --git a/pkg/signalmeow/types/profile.go b/pkg/signalmeow/types/profile.go deleted file mode 100644 index 22d2d0e6..00000000 --- a/pkg/signalmeow/types/profile.go +++ /dev/null @@ -1,24 +0,0 @@ -// mautrix-signal - A Matrix-signal puppeting bridge. -// Copyright (C) 2023 Scott Weber, Tulir Asokan -// -// 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 . - -package types - -type ProfileFields struct { - Name string - About string - AboutEmoji string - AvatarPath string -} diff --git a/puppet.go b/puppet.go index 214aa648..dfe45b37 100644 --- a/puppet.go +++ b/puppet.go @@ -246,27 +246,26 @@ func (puppet *Puppet) UpdateInfo(ctx context.Context, source *User) { ctx = log.WithContext(ctx) var err error log.Debug().Msg("Fetching contact info to update puppet") - info, sourceUUID, err := source.Client.ContactByID(ctx, puppet.SignalID) + info, err := source.Client.ContactByID(ctx, puppet.SignalID) if err != nil { log.Err(err).Msg("Failed to fetch contact info") return } - if sourceUUID != uuid.Nil { - source = puppet.bridge.GetUserBySignalID(sourceUUID) - if source == nil || source.Client == nil { - log.Warn(). - Stringer("source_uuid", sourceUUID). - Msg("No fallback user for profile info update") - return - } + if !puppet.bridge.Config.Bridge.UseOutdatedProfiles && puppet.ProfileFetchedAt.After(info.Profile.FetchedAt) { log.Debug(). - Stringer("source_mxid", source.MXID). - Msg("Using fallback user for profile info update") + Time("contact_profile_fetched_at", info.Profile.FetchedAt). + Time("puppet_profile_fetched_at", puppet.ProfileFetchedAt). + Msg("Ignoring outdated contact info") + return } log.Trace().Msg("Updating puppet info") update := false + if puppet.ProfileFetchedAt.IsZero() && !info.Profile.FetchedAt.IsZero() { + update = true + } + puppet.ProfileFetchedAt = info.Profile.FetchedAt if info.E164 != "" && puppet.Number != info.E164 { puppet.Number = info.E164 update = true @@ -373,7 +372,6 @@ func (puppet *Puppet) updateAvatar(ctx context.Context, source *User, info *type return true } puppet.AvatarHash = newHash - info.ProfileAvatarHash = newHash source.Client.Store.ContactStore.StoreContact(ctx, *info) err = source.Client.Store.ContactStore.StoreContact(ctx, *info) if err != nil {