feat(community)_: Move images from community data to MediaServer (#5336)

* feat(community)_: Move images from community data to MediaServer

* test_: fix lint issue

* test_: add more test statements

* feat_: deprecate old API

* test_: addressed review feedback from Icaro

* fix_: addressed review feedback from Jonathan

* chore_:wrap image url in an object
This commit is contained in:
frank 2024-06-24 17:37:44 +08:00 committed by GitHub
parent 39dffd8b70
commit e0673ad1ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 530 additions and 26 deletions

View File

@ -27,6 +27,7 @@ import (
"github.com/status-im/status-go/protocol/protobuf"
"github.com/status-im/status-go/protocol/requests"
"github.com/status-im/status-go/protocol/v1"
"github.com/status-im/status-go/server"
)
const signatureLength = 65
@ -255,6 +256,162 @@ func (o *Community) MarshalPublicAPIJSON() ([]byte, error) {
return json.Marshal(communityItem)
}
func (o *Community) MarshalJSONWithMediaServer(mediaServer *server.MediaServer) ([]byte, error) {
if o.config.MemberIdentity == nil {
return nil, errors.New("member identity not set")
}
type Image struct {
Uri string `json:"uri"`
}
communityItem := struct {
ID types.HexBytes `json:"id"`
MemberRole protobuf.CommunityMember_Roles `json:"memberRole"`
IsControlNode bool `json:"isControlNode"`
Verified bool `json:"verified"`
Joined bool `json:"joined"`
JoinedAt int64 `json:"joinedAt"`
Spectated bool `json:"spectated"`
RequestedAccessAt int `json:"requestedAccessAt"`
Name string `json:"name"`
Description string `json:"description"`
IntroMessage string `json:"introMessage"`
OutroMessage string `json:"outroMessage"`
Tags []CommunityTag `json:"tags"`
Chats map[string]CommunityChat `json:"chats"`
Categories map[string]CommunityCategory `json:"categories"`
Images map[string]Image `json:"images"`
Permissions *protobuf.CommunityPermissions `json:"permissions"`
Members map[string]*protobuf.CommunityMember `json:"members"`
CanRequestAccess bool `json:"canRequestAccess"`
CanManageUsers bool `json:"canManageUsers"` //TODO: we can remove this
CanDeleteMessageForEveryone bool `json:"canDeleteMessageForEveryone"` //TODO: we can remove this
CanJoin bool `json:"canJoin"`
Color string `json:"color"`
RequestedToJoinAt uint64 `json:"requestedToJoinAt,omitempty"`
IsMember bool `json:"isMember"`
Muted bool `json:"muted"`
MuteTill time.Time `json:"muteTill,omitempty"`
CommunityAdminSettings CommunityAdminSettings `json:"adminSettings"`
Encrypted bool `json:"encrypted"`
PendingAndBannedMembers map[string]CommunityMemberState `json:"pendingAndBannedMembers"`
TokenPermissions map[string]*CommunityTokenPermission `json:"tokenPermissions"`
CommunityTokensMetadata []*protobuf.CommunityTokenMetadata `json:"communityTokensMetadata"`
ActiveMembersCount uint64 `json:"activeMembersCount"`
PubsubTopic string `json:"pubsubTopic"`
PubsubTopicKey string `json:"pubsubTopicKey"`
Shard *shard.Shard `json:"shard"`
LastOpenedAt int64 `json:"lastOpenedAt"`
Clock uint64 `json:"clock"`
}{
ID: o.ID(),
Clock: o.Clock(),
MemberRole: o.MemberRole(o.MemberIdentity()),
IsControlNode: o.IsControlNode(),
Verified: o.config.Verified,
Chats: make(map[string]CommunityChat),
Categories: make(map[string]CommunityCategory),
Joined: o.config.Joined,
JoinedAt: o.config.JoinedAt,
Spectated: o.config.Spectated,
CanRequestAccess: o.CanRequestAccess(o.config.MemberIdentity),
CanJoin: o.canJoin(),
CanManageUsers: o.CanManageUsers(o.config.MemberIdentity),
CanDeleteMessageForEveryone: o.CanDeleteMessageForEveryone(o.config.MemberIdentity),
RequestedToJoinAt: o.RequestedToJoinAt(),
IsMember: o.isMember(),
Muted: o.config.Muted,
MuteTill: o.config.MuteTill,
Tags: o.Tags(),
Encrypted: o.Encrypted(),
PubsubTopic: o.PubsubTopic(),
PubsubTopicKey: o.PubsubTopicKey(),
Shard: o.Shard(),
LastOpenedAt: o.config.LastOpenedAt,
}
if o.config.CommunityDescription != nil {
for id, c := range o.config.CommunityDescription.Categories {
category := CommunityCategory{
ID: id,
Name: c.Name,
Position: int(c.Position),
}
communityItem.Encrypted = o.Encrypted()
communityItem.Categories[id] = category
}
for id, c := range o.config.CommunityDescription.Chats {
// NOTE: Here `CanPost` is only set for ChatMessage. But it can be different for reactions/pin/etc.
// Consider adding more properties to `CommunityChat` to reflect that.
canPost, err := o.CanPost(o.config.MemberIdentity, id, protobuf.ApplicationMetadataMessage_CHAT_MESSAGE)
if err != nil {
return nil, err
}
canPostReactions, err := o.CanPost(o.config.MemberIdentity, id, protobuf.ApplicationMetadataMessage_EMOJI_REACTION)
if err != nil {
return nil, err
}
canView := o.CanView(o.config.MemberIdentity, id)
chat := CommunityChat{
ID: id,
Name: c.Identity.DisplayName,
Emoji: c.Identity.Emoji,
Color: c.Identity.Color,
Description: c.Identity.Description,
Permissions: c.Permissions,
Members: c.Members,
CanPost: canPost,
CanView: canView,
CanPostReactions: canPostReactions,
ViewersCanPostReactions: c.ViewersCanPostReactions,
TokenGated: o.channelEncrypted(id),
CategoryID: c.CategoryId,
HideIfPermissionsNotMet: c.HideIfPermissionsNotMet,
Position: int(c.Position),
}
communityItem.Chats[id] = chat
}
communityItem.TokenPermissions = o.tokenPermissions()
communityItem.PendingAndBannedMembers = o.PendingAndBannedMembers()
communityItem.Members = o.config.CommunityDescription.Members
communityItem.Permissions = o.config.CommunityDescription.Permissions
communityItem.IntroMessage = o.config.CommunityDescription.IntroMessage
communityItem.OutroMessage = o.config.CommunityDescription.OutroMessage
// update token meta image to url rather than base64 image
var tokenMetadata []*protobuf.CommunityTokenMetadata
for _, m := range o.config.CommunityDescription.CommunityTokensMetadata {
copyM := proto.Clone(m).(*protobuf.CommunityTokenMetadata)
copyM.Image = mediaServer.MakeCommunityDescriptionTokenImageURL(o.IDString(), copyM.GetSymbol())
tokenMetadata = append(tokenMetadata, copyM)
}
communityItem.CommunityTokensMetadata = tokenMetadata
communityItem.ActiveMembersCount = o.config.CommunityDescription.ActiveMembersCount
if o.config.CommunityDescription.Identity != nil {
communityItem.Name = o.Name()
communityItem.Color = o.config.CommunityDescription.Identity.Color
communityItem.Description = o.config.CommunityDescription.Identity.Description
for t := range o.config.CommunityDescription.Identity.Images {
if communityItem.Images == nil {
communityItem.Images = make(map[string]Image)
}
communityItem.Images[t] = Image{Uri: mediaServer.MakeCommunityImageURL(o.IDString(), t)}
}
}
communityItem.CommunityAdminSettings = CommunityAdminSettings{
PinMessageAllMembersEnabled: false,
}
if o.config.CommunityDescription.AdminSettings != nil {
communityItem.CommunityAdminSettings.PinMessageAllMembersEnabled = o.config.CommunityDescription.AdminSettings.PinMessageAllMembersEnabled
}
}
return json.Marshal(communityItem)
}
func (o *Community) MarshalJSON() ([]byte, error) {
if o.config.MemberIdentity == nil {
return nil, errors.New("member identity not set")

View File

@ -4,11 +4,14 @@ import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"os"
"reflect"
"strings"
@ -4348,6 +4351,169 @@ func (s *MessengerCommunitiesSuite) sendImageToCommunity(sender *Messenger, chat
return sentMessage
}
func (s *MessengerCommunitiesSuite) TestSerializedCommunities() {
community, _ := s.createCommunity()
addMediaServer := func(messenger *Messenger) {
mediaServer, err := server.NewMediaServer(messenger.database, nil, nil, nil)
s.Require().NoError(err)
s.Require().NoError(mediaServer.Start())
messenger.httpServer = mediaServer
}
addMediaServer(s.owner)
// update community description
description := community.Description()
identImageName := "small"
identImagePayload := []byte("123")
description.Identity = &protobuf.ChatIdentity{
Images: map[string]*protobuf.IdentityImage{
identImageName: {
Payload: identImagePayload,
},
},
}
// #nosec G101
tokenImageInBase64 := "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/wcAAwAB/tdgBUgAAAAASUVORK5CYII="
description.CommunityTokensMetadata = []*protobuf.CommunityTokenMetadata{
{
Image: tokenImageInBase64,
Symbol: "STT",
},
}
description.Clock = description.Clock + 1
community.Edit(description)
s.Require().NoError(s.owner.communitiesManager.SaveCommunity(community))
// check edit was successful
b, err := s.owner.communitiesManager.GetByID(community.ID())
s.Require().NoError(err)
s.Require().NotNil(b)
s.Len(b.Description().CommunityTokensMetadata, 1)
s.Equal(tokenImageInBase64, b.Description().CommunityTokensMetadata[0].Image)
s.Len(b.Description().Identity.Images, 1)
s.Equal(identImagePayload, b.Description().Identity.Images[identImageName].Payload)
c, err := s.owner.SerializedCommunities()
s.Require().NoError(err)
s.Require().Len(c, 1)
d, err := json.Marshal(c)
s.Require().NoError(err)
type Image struct {
Uri string `json:"uri"`
}
var communityData []struct {
Images map[string]Image `json:"images"`
CommunityTokensMetadata []struct {
Image string `json:"image"`
Symbol string `json:"symbol"`
} `json:"communityTokensMetadata"`
}
err = json.Unmarshal(d, &communityData)
s.Require().NoError(err)
// Check community description image
s.Require().NotEmpty(communityData[0].Images[identImageName])
image := communityData[0].Images[identImageName]
s.T().Log(fmt.Sprintf("Image URL (%s):", identImageName), image)
e, err := s.fetchImage(image.Uri)
s.Require().NoError(err)
s.Require().Equal(identImagePayload, e)
imageUrlWithoutCommunityID, err := s.removeUrlParam(image.Uri, "communityID")
s.Require().NoError(err)
e, err = s.fetchImage(imageUrlWithoutCommunityID)
s.Require().NoError(err)
s.Require().Len(e, 0)
imageUrlWithWrongCommunityID, err := s.updateUrlParam(image.Uri, "communityID", "0x0")
s.Require().NoError(err)
e, err = s.fetchImage(imageUrlWithWrongCommunityID)
s.Require().NoError(err)
s.Require().Len(e, 0)
// Check communityTokensMetadata image
s.Require().NotEmpty(communityData[0].CommunityTokensMetadata)
tokenImageUrl := communityData[0].CommunityTokensMetadata[0].Image
s.T().Log("Community Token Metadata Image:", tokenImageUrl)
s.T().Log("Community Token Metadata Symbol:", communityData[0].CommunityTokensMetadata[0].Symbol)
f, err := s.fetchImage(tokenImageUrl)
s.Require().NoError(err)
tokenImagePayload, err := images.GetPayloadFromURI(tokenImageInBase64)
s.Require().NoError(err)
s.Require().Equal(tokenImagePayload, f)
tokenImageUrlWithoutCommunityID, err := s.removeUrlParam(tokenImageUrl, "communityID")
s.Require().NoError(err)
f, err = s.fetchImage(tokenImageUrlWithoutCommunityID)
s.Require().NoError(err)
s.Require().Len(f, 0)
tokenImageUrlWithWrongCommunityID, err := s.updateUrlParam(tokenImageUrl, "communityID", "0x0")
s.Require().NoError(err)
f, err = s.fetchImage(tokenImageUrlWithWrongCommunityID)
s.Require().NoError(err)
s.Require().Len(f, 0)
tokenImageUrlWithoutSymbol, err := s.removeUrlParam(tokenImageUrl, "symbol")
s.Require().NoError(err)
f, err = s.fetchImage(tokenImageUrlWithoutSymbol)
s.Require().NoError(err)
s.Require().Len(f, 0)
tokenImageUrlWithWrongSymbol, err := s.updateUrlParam(tokenImageUrl, "symbol", "WRONG")
s.Require().NoError(err)
f, err = s.fetchImage(tokenImageUrlWithWrongSymbol)
s.Require().NoError(err)
s.Require().Len(f, 0)
}
func (s *MessengerCommunitiesSuite) updateUrlParam(rawURL string, name, val string) (string, error) {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return "", err
}
queryParams := parsedURL.Query()
queryParams.Set(name, val)
parsedURL.RawQuery = queryParams.Encode()
return parsedURL.String(), nil
}
func (s *MessengerCommunitiesSuite) removeUrlParam(rawURL, name string) (string, error) {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return "", err
}
queryParams := parsedURL.Query()
queryParams.Del(name)
parsedURL.RawQuery = queryParams.Encode()
return parsedURL.String(), nil
}
func (s *MessengerCommunitiesSuite) fetchImage(fullURL string) ([]byte, error) {
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
return nil, err
}
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: true, // nolint: gosec
},
},
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return ioutil.ReadAll(resp.Body)
}
func (s *MessengerCommunitiesSuite) TestMemberMessagesHasImageLink() {
// GIVEN
community, communityChat := s.createCommunity()

View File

@ -4843,6 +4843,22 @@ func (m *Messenger) CreateResponseWithACNotification(communityID string, acType
return response, nil
}
func (m *Messenger) SerializedCommunities() ([]json.RawMessage, error) {
cs, err := m.Communities()
if err != nil {
return nil, err
}
res := make([]json.RawMessage, 0, len(cs))
for _, c := range cs {
b, err := c.MarshalJSONWithMediaServer(m.httpServer)
if err != nil {
return nil, err
}
res = append(res, b)
}
return res, nil
}
// SendMessageToControlNode sends a message to the control node of the community.
// use pointer to rawMessage to get the message ID and other updated properties.
func (m *Messenger) SendMessageToControlNode(community *communities.Community, rawMessage *common.RawMessage) ([]byte, error) {

View File

@ -14,6 +14,11 @@ import (
"strconv"
"time"
"github.com/golang/protobuf/proto"
"github.com/status-im/status-go/eth-node/types"
"github.com/status-im/status-go/protocol/protobuf"
"go.uber.org/zap"
eth_common "github.com/ethereum/go-ethereum/common"
@ -37,6 +42,8 @@ const (
LinkPreviewFaviconPath = "/link-preview/favicon"
StatusLinkPreviewThumbnailPath = "/status-link-preview/thumbnail"
communityTokenImagesPath = "/communityTokenImages"
communityDescriptionImagesPath = "/communityDescriptionImages"
communityDescriptionTokenImagesPath = "/communityDescriptionTokenImages"
walletBasePath = "/wallet"
walletCommunityImagesPath = walletBasePath + "/communityImages"
@ -993,6 +1000,131 @@ func handleCommunityTokenImages(db *sql.DB, logger *zap.Logger) http.HandlerFunc
}
}
func handleCommunityDescriptionImagesPath(db *sql.DB, logger *zap.Logger) http.HandlerFunc {
if db == nil {
return handleRequestDBMissing(logger)
}
return func(w http.ResponseWriter, r *http.Request) {
params := r.URL.Query()
if len(params["communityID"]) == 0 {
logger.Error("[handleCommunityDescriptionImagesPath] no communityID")
return
}
communityID := params["communityID"][0]
name := ""
if len(params["name"]) > 0 {
name = params["name"][0]
}
err, communityDescription := getCommunityDescription(db, communityID, logger)
if err != nil {
return
}
if communityDescription.Identity == nil {
logger.Error("no identity in community description", zap.String("community id", communityID))
return
}
var imagePayload []byte
for t, i := range communityDescription.Identity.Images {
if t == name {
imagePayload = i.Payload
}
}
if imagePayload == nil {
logger.Error("can't find community description image", zap.String("community id", communityID), zap.String("name", name))
return
}
mime, err := images.GetProtobufImageMime(imagePayload)
if err != nil {
logger.Error("failed to get community image mime", zap.String("community id", communityID), zap.Error(err))
}
w.Header().Set("Content-Type", mime)
w.Header().Set("Cache-Control", "no-store")
_, err = w.Write(imagePayload)
if err != nil {
logger.Error("failed to write community image", zap.String("community id", communityID), zap.Error(err))
}
}
}
func handleCommunityDescriptionTokenImagesPath(db *sql.DB, logger *zap.Logger) http.HandlerFunc {
if db == nil {
return handleRequestDBMissing(logger)
}
return func(w http.ResponseWriter, r *http.Request) {
params := r.URL.Query()
if len(params["communityID"]) == 0 {
logger.Error("[handleCommunityDescriptionTokenImagesPath] no communityID")
return
}
communityID := params["communityID"][0]
if len(params["symbol"]) == 0 {
logger.Error("[handleCommunityDescriptionTokenImagesPath] no symbol")
return
}
symbol := params["symbol"][0]
err, communityDescription := getCommunityDescription(db, communityID, logger)
if err != nil {
return
}
var foundToken *protobuf.CommunityTokenMetadata
for _, m := range communityDescription.CommunityTokensMetadata {
if m.GetSymbol() == symbol {
foundToken = m
}
}
if foundToken == nil {
logger.Error("can't find community description token image", zap.String("community id", communityID), zap.String("symbol", symbol))
return
}
imagePayload, err := images.GetPayloadFromURI(foundToken.Image)
if err != nil {
logger.Error("failed to get community description token image payload", zap.Error(err))
return
}
mime, err := images.GetProtobufImageMime(imagePayload)
if err != nil {
logger.Error("failed to get community description token image mime", zap.String("community id", communityID), zap.String("symbol", symbol), zap.Error(err))
}
w.Header().Set("Content-Type", mime)
w.Header().Set("Cache-Control", "no-store")
_, err = w.Write(imagePayload)
if err != nil {
logger.Error("failed to write community description token image", zap.String("community id", communityID), zap.String("symbol", symbol), zap.Error(err))
}
}
}
// getCommunityDescription returns the latest community description from the cache.
// NOTE: you should ensure preprocessDescription is called before this function.
func getCommunityDescription(db *sql.DB, communityID string, logger *zap.Logger) (error, *protobuf.CommunityDescription) {
var descriptionBytes []byte
err := db.QueryRow(`SELECT description FROM encrypted_community_description_cache WHERE community_id = ? ORDER BY clock DESC LIMIT 1`, types.Hex2Bytes(communityID)).Scan(&descriptionBytes)
if err != nil {
logger.Error("failed to find community description", zap.String("community id", communityID), zap.Error(err))
return err, nil
}
communityDescription := new(protobuf.CommunityDescription)
err = proto.Unmarshal(descriptionBytes, communityDescription)
if err != nil {
logger.Error("failed to unmarshal community description", zap.String("community id", communityID), zap.Error(err))
}
return err, communityDescription
}
func handleWalletCommunityImages(db *sql.DB, logger *zap.Logger) http.HandlerFunc {
if db == nil {
return handleRequestDBMissing(logger)

View File

@ -55,6 +55,8 @@ func NewMediaServer(db *sql.DB, downloader *ipfs.Downloader, multiaccountsDB *mu
LinkPreviewFaviconPath: handleLinkPreviewFavicon(s.db, s.logger),
StatusLinkPreviewThumbnailPath: handleStatusLinkPreviewThumbnail(s.db, s.logger),
communityTokenImagesPath: handleCommunityTokenImages(s.db, s.logger),
communityDescriptionImagesPath: handleCommunityDescriptionImagesPath(s.db, s.logger),
communityDescriptionTokenImagesPath: handleCommunityDescriptionTokenImagesPath(s.db, s.logger),
walletCommunityImagesPath: handleWalletCommunityImages(s.walletDB, s.logger),
walletCollectionImagesPath: handleWalletCollectionImages(s.walletDB, s.logger),
walletCollectibleImagesPath: handleWalletCollectibleImages(s.walletDB, s.logger),
@ -168,6 +170,28 @@ func (s *MediaServer) MakeCommunityTokenImagesURL(communityID string, chainID ui
return u.String()
}
func (s *MediaServer) MakeCommunityImageURL(communityID, name string) string {
u := s.MakeBaseURL()
u.Path = communityDescriptionImagesPath
u.RawQuery = url.Values{
"communityID": {communityID},
"name": {name},
}.Encode()
return u.String()
}
func (s *MediaServer) MakeCommunityDescriptionTokenImageURL(communityID, symbol string) string {
u := s.MakeBaseURL()
u.Path = communityDescriptionTokenImagesPath
u.RawQuery = url.Values{
"communityID": {communityID},
"symbol": {symbol},
}.Encode()
return u.String()
}
func (s *MediaServer) MakeWalletCommunityImagesURL(communityID string) string {
u := s.MakeBaseURL()
u.Path = walletCommunityImagesPath

View File

@ -4,6 +4,7 @@ import (
"context"
"crypto/ecdsa"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math/big"
@ -394,10 +395,18 @@ func (api *PublicAPI) SetInstallationName(installationID string, name string) er
}
// Communities returns a list of communities that are stored
// Deprecated: Use SerializedCommunities instead
func (api *PublicAPI) Communities(parent context.Context) ([]*communities.Community, error) {
return api.service.messenger.Communities()
}
// SerializedCommunities returns a list of serialized communities.
// The key difference from the Communities function is that it uses MediaServer
// to construct image URLs for all the images rather than using base64 encoding.
func (api *PublicAPI) SerializedCommunities(parent context.Context) ([]json.RawMessage, error) {
return api.service.messenger.SerializedCommunities()
}
// JoinedCommunities returns a list of communities that the user has joined
func (api *PublicAPI) JoinedCommunities(parent context.Context) ([]*communities.Community, error) {
return api.service.messenger.JoinedCommunities()