github.com/status-im/status-go@v1.1.0/protocol/common/message_linkpreview.go (about)

     1  package common
     2  
     3  import (
     4  	"fmt"
     5  	"net/url"
     6  
     7  	gethcrypto "github.com/ethereum/go-ethereum/crypto"
     8  	"github.com/status-im/status-go/eth-node/crypto"
     9  	"github.com/status-im/status-go/eth-node/types"
    10  	"github.com/status-im/status-go/images"
    11  	"github.com/status-im/status-go/protocol/protobuf"
    12  )
    13  
    14  type MakeMediaServerURLType func(msgID string, previewURL string, imageID MediaServerImageID) string
    15  type MakeMediaServerURLMessageWrapperType func(previewURL string, imageID MediaServerImageID) string
    16  
    17  type LinkPreviewThumbnail struct {
    18  	Width  int `json:"width,omitempty"`
    19  	Height int `json:"height,omitempty"`
    20  	// Non-empty when the thumbnail is available via the media server, i.e. after
    21  	// the chat message is sent.
    22  	URL string `json:"url,omitempty"`
    23  	// Non-empty when the thumbnail payload needs to be shared with the client,
    24  	// but before it has been persisted.
    25  	DataURI string `json:"dataUri,omitempty"`
    26  }
    27  
    28  type LinkPreview struct {
    29  	Type        protobuf.UnfurledLink_LinkType `json:"type"`
    30  	URL         string                         `json:"url"`
    31  	Hostname    string                         `json:"hostname"`
    32  	Title       string                         `json:"title,omitempty"`
    33  	Description string                         `json:"description,omitempty"`
    34  	Favicon     LinkPreviewThumbnail           `json:"favicon,omitempty"`
    35  	Thumbnail   LinkPreviewThumbnail           `json:"thumbnail,omitempty"`
    36  }
    37  
    38  type StatusContactLinkPreview struct {
    39  	// PublicKey is: "0x" + hex-encoded decompressed public key.
    40  	// We keep it a string here for correct json marshalling.
    41  	PublicKey   string               `json:"publicKey"`
    42  	DisplayName string               `json:"displayName"`
    43  	Description string               `json:"description"`
    44  	Icon        LinkPreviewThumbnail `json:"icon,omitempty"`
    45  }
    46  
    47  type StatusCommunityLinkPreview struct {
    48  	CommunityID  string               `json:"communityId"`
    49  	DisplayName  string               `json:"displayName"`
    50  	Description  string               `json:"description"`
    51  	MembersCount uint32               `json:"membersCount"`
    52  	Color        string               `json:"color"`
    53  	Icon         LinkPreviewThumbnail `json:"icon,omitempty"`
    54  	Banner       LinkPreviewThumbnail `json:"banner,omitempty"`
    55  }
    56  
    57  type StatusCommunityChannelLinkPreview struct {
    58  	ChannelUUID string                      `json:"channelUuid"`
    59  	Emoji       string                      `json:"emoji"`
    60  	DisplayName string                      `json:"displayName"`
    61  	Description string                      `json:"description"`
    62  	Color       string                      `json:"color"`
    63  	Community   *StatusCommunityLinkPreview `json:"community"`
    64  }
    65  
    66  type StatusLinkPreview struct {
    67  	URL       string                             `json:"url,omitempty"`
    68  	Contact   *StatusContactLinkPreview          `json:"contact,omitempty"`
    69  	Community *StatusCommunityLinkPreview        `json:"community,omitempty"`
    70  	Channel   *StatusCommunityChannelLinkPreview `json:"channel,omitempty"`
    71  }
    72  
    73  func (thumbnail *LinkPreviewThumbnail) IsEmpty() bool {
    74  	return thumbnail.Width == 0 &&
    75  		thumbnail.Height == 0 &&
    76  		thumbnail.URL == "" &&
    77  		thumbnail.DataURI == ""
    78  }
    79  
    80  func (thumbnail *LinkPreviewThumbnail) clear() {
    81  	thumbnail.Width = 0
    82  	thumbnail.Height = 0
    83  	thumbnail.URL = ""
    84  	thumbnail.DataURI = ""
    85  }
    86  
    87  func (thumbnail *LinkPreviewThumbnail) validateForProto() error {
    88  	if thumbnail.DataURI == "" {
    89  		if thumbnail.Width == 0 && thumbnail.Height == 0 {
    90  			return nil
    91  		}
    92  		return fmt.Errorf("dataUri is empty, but width/height are not zero")
    93  	}
    94  
    95  	if thumbnail.Width == 0 || thumbnail.Height == 0 {
    96  		return fmt.Errorf("dataUri is not empty, but width/heigth are zero")
    97  	}
    98  
    99  	return nil
   100  }
   101  
   102  func (thumbnail *LinkPreviewThumbnail) convertToProto() (*protobuf.UnfurledLinkThumbnail, error) {
   103  	var payload []byte
   104  	var err error
   105  	if thumbnail.DataURI != "" {
   106  		payload, err = images.GetPayloadFromURI(thumbnail.DataURI)
   107  		if err != nil {
   108  			return nil, fmt.Errorf("could not get data URI payload, url='%s': %w", thumbnail.URL, err)
   109  		}
   110  	}
   111  
   112  	return &protobuf.UnfurledLinkThumbnail{
   113  		Width:   uint32(thumbnail.Width),
   114  		Height:  uint32(thumbnail.Height),
   115  		Payload: payload,
   116  	}, nil
   117  }
   118  
   119  func (thumbnail *LinkPreviewThumbnail) loadFromProto(
   120  	input *protobuf.UnfurledLinkThumbnail,
   121  	URL string,
   122  	imageID MediaServerImageID,
   123  	makeMediaServerURL MakeMediaServerURLMessageWrapperType) {
   124  
   125  	thumbnail.clear()
   126  	thumbnail.Width = int(input.Width)
   127  	thumbnail.Height = int(input.Height)
   128  
   129  	if len(input.Payload) > 0 {
   130  		thumbnail.URL = makeMediaServerURL(URL, imageID)
   131  	}
   132  }
   133  
   134  func (preview *LinkPreview) validateForProto() error {
   135  	switch preview.Type {
   136  	case protobuf.UnfurledLink_IMAGE:
   137  		if preview.URL == "" {
   138  			return fmt.Errorf("empty url")
   139  		}
   140  		if err := preview.Thumbnail.validateForProto(); err != nil {
   141  			return fmt.Errorf("thumbnail is not valid for proto: %w", err)
   142  		}
   143  		return nil
   144  	default: // Validate as a link type by default.
   145  		if preview.Title == "" {
   146  			return fmt.Errorf("title is empty")
   147  		}
   148  		if preview.URL == "" {
   149  			return fmt.Errorf("url is empty")
   150  		}
   151  		if err := preview.Thumbnail.validateForProto(); err != nil {
   152  			return fmt.Errorf("thumbnail is not valid for proto: %w", err)
   153  		}
   154  		return nil
   155  	}
   156  }
   157  
   158  func (preview *StatusLinkPreview) validateForProto() error {
   159  	if preview.URL == "" {
   160  		return fmt.Errorf("url can't be empty")
   161  	}
   162  
   163  	// At least and only one of Contact/Community/Channel should be present in the preview
   164  	if preview.Contact != nil && preview.Community != nil {
   165  		return fmt.Errorf("both contact and community are set at the same time")
   166  	}
   167  	if preview.Community != nil && preview.Channel != nil {
   168  		return fmt.Errorf("both community and channel are set at the same time")
   169  	}
   170  	if preview.Channel != nil && preview.Contact != nil {
   171  		return fmt.Errorf("both contact and channel are set at the same time")
   172  	}
   173  	if preview.Contact == nil && preview.Community == nil && preview.Channel == nil {
   174  		return fmt.Errorf("none of contact/community/channel are set")
   175  	}
   176  
   177  	if preview.Contact != nil {
   178  		if preview.Contact.PublicKey == "" {
   179  			return fmt.Errorf("contact publicKey is empty")
   180  		}
   181  		if err := preview.Contact.Icon.validateForProto(); err != nil {
   182  			return fmt.Errorf("contact icon invalid: %w", err)
   183  		}
   184  		return nil
   185  	}
   186  
   187  	if preview.Community != nil {
   188  		return preview.Community.validateForProto()
   189  	}
   190  
   191  	if preview.Channel != nil {
   192  		if preview.Channel.ChannelUUID == "" {
   193  			return fmt.Errorf("channelUuid is empty")
   194  		}
   195  		if preview.Channel.Community == nil {
   196  			return fmt.Errorf("channel community is nil")
   197  		}
   198  		if err := preview.Channel.Community.validateForProto(); err != nil {
   199  			return fmt.Errorf("channel community is not valid: %w", err)
   200  		}
   201  		return nil
   202  	}
   203  	return nil
   204  }
   205  
   206  func (preview *StatusCommunityLinkPreview) validateForProto() error {
   207  	if preview == nil {
   208  		return fmt.Errorf("community preview is empty")
   209  	}
   210  	if preview.CommunityID == "" {
   211  		return fmt.Errorf("communityId is empty")
   212  	}
   213  	if err := preview.Icon.validateForProto(); err != nil {
   214  		return fmt.Errorf("community icon is invalid: %w", err)
   215  	}
   216  	if err := preview.Banner.validateForProto(); err != nil {
   217  		return fmt.Errorf("community banner is invalid: %w", err)
   218  	}
   219  	return nil
   220  }
   221  
   222  func (preview *StatusCommunityLinkPreview) convertToProto() (*protobuf.UnfurledStatusCommunityLink, error) {
   223  	if preview == nil {
   224  		return nil, nil
   225  	}
   226  
   227  	icon, err := preview.Icon.convertToProto()
   228  	if err != nil {
   229  		return nil, err
   230  	}
   231  
   232  	banner, err := preview.Banner.convertToProto()
   233  	if err != nil {
   234  		return nil, err
   235  	}
   236  
   237  	communityID, err := types.DecodeHex(preview.CommunityID)
   238  	if err != nil {
   239  		return nil, fmt.Errorf("failed to decode community id: %w", err)
   240  	}
   241  
   242  	community := &protobuf.UnfurledStatusCommunityLink{
   243  		CommunityId:  communityID,
   244  		DisplayName:  preview.DisplayName,
   245  		Description:  preview.Description,
   246  		MembersCount: preview.MembersCount,
   247  		Color:        preview.Color,
   248  		Icon:         icon,
   249  		Banner:       banner,
   250  	}
   251  
   252  	return community, nil
   253  }
   254  
   255  func (preview *StatusCommunityLinkPreview) loadFromProto(c *protobuf.UnfurledStatusCommunityLink,
   256  	URL string, thumbnailPrefix MediaServerImageIDPrefix,
   257  	makeMediaServerURL MakeMediaServerURLMessageWrapperType) {
   258  
   259  	preview.CommunityID = types.EncodeHex(c.CommunityId)
   260  	preview.DisplayName = c.DisplayName
   261  	preview.Description = c.Description
   262  	preview.MembersCount = c.MembersCount
   263  	preview.Color = c.Color
   264  	preview.Icon.clear()
   265  	preview.Banner.clear()
   266  
   267  	if icon := c.GetIcon(); icon != nil {
   268  		preview.Icon.loadFromProto(icon, URL, CreateImageID(thumbnailPrefix, MediaServerIconPostfix), makeMediaServerURL)
   269  	}
   270  	if banner := c.GetBanner(); banner != nil {
   271  		preview.Banner.loadFromProto(banner, URL, CreateImageID(thumbnailPrefix, MediaServerBannerPostfix), makeMediaServerURL)
   272  	}
   273  }
   274  
   275  // ConvertLinkPreviewsToProto expects previews to be correctly sent by the
   276  // client because we can't attempt to re-unfurl URLs at this point (it's
   277  // actually undesirable). We run a basic validation as an additional safety net.
   278  func (m *Message) ConvertLinkPreviewsToProto() ([]*protobuf.UnfurledLink, error) {
   279  	if len(m.LinkPreviews) == 0 {
   280  		return nil, nil
   281  	}
   282  
   283  	unfurledLinks := make([]*protobuf.UnfurledLink, 0, len(m.LinkPreviews))
   284  
   285  	for _, preview := range m.LinkPreviews {
   286  		// Do not process subsequent previews because we do expect all previews to
   287  		// be valid at this stage.
   288  		if err := preview.validateForProto(); err != nil {
   289  			return nil, fmt.Errorf("invalid link preview, url='%s': %w", preview.URL, err)
   290  		}
   291  
   292  		var thumbnailPayload []byte
   293  		var faviconPayload []byte
   294  		var err error
   295  		if preview.Thumbnail.DataURI != "" {
   296  			thumbnailPayload, err = images.GetPayloadFromURI(preview.Thumbnail.DataURI)
   297  			if err != nil {
   298  				return nil, fmt.Errorf("could not get data URI payload for link preview thumbnail, url='%s': %w", preview.URL, err)
   299  			}
   300  		}
   301  		if preview.Favicon.DataURI != "" {
   302  			faviconPayload, err = images.GetPayloadFromURI(preview.Favicon.DataURI)
   303  			if err != nil {
   304  				return nil, fmt.Errorf("could not get data URI payload for link preview favicon, url='%s': %w", preview.URL, err)
   305  			}
   306  		}
   307  
   308  		ul := &protobuf.UnfurledLink{
   309  			Type:             preview.Type,
   310  			Url:              preview.URL,
   311  			Title:            preview.Title,
   312  			Description:      preview.Description,
   313  			ThumbnailWidth:   uint32(preview.Thumbnail.Width),
   314  			ThumbnailHeight:  uint32(preview.Thumbnail.Height),
   315  			ThumbnailPayload: thumbnailPayload,
   316  			FaviconPayload:   faviconPayload,
   317  		}
   318  		unfurledLinks = append(unfurledLinks, ul)
   319  	}
   320  
   321  	return unfurledLinks, nil
   322  }
   323  
   324  func (m *Message) ConvertFromProtoToLinkPreviews(makeThumbnailMediaServerURL func(msgID string, previewURL string) string,
   325  	makeFaviconMediaServerURL func(msgID string, previewURL string) string) []LinkPreview {
   326  	var links []*protobuf.UnfurledLink
   327  
   328  	if links = m.GetUnfurledLinks(); links == nil {
   329  		return nil
   330  	}
   331  
   332  	previews := make([]LinkPreview, 0, len(links))
   333  	for _, link := range links {
   334  		parsedURL, err := url.Parse(link.Url)
   335  		var hostname string
   336  		// URL parsing in Go can fail with URLs that weren't correctly URL encoded.
   337  		// This shouldn't happen in general, but if an error happens we just reuse
   338  		// the full URL.
   339  		if err != nil {
   340  			hostname = link.Url
   341  		} else {
   342  			hostname = parsedURL.Hostname()
   343  		}
   344  		lp := LinkPreview{
   345  			Description: link.Description,
   346  			Hostname:    hostname,
   347  			Title:       link.Title,
   348  			Type:        link.Type,
   349  			URL:         link.Url,
   350  		}
   351  		mediaURL := ""
   352  		if len(link.ThumbnailPayload) > 0 {
   353  			mediaURL = makeThumbnailMediaServerURL(m.ID, link.Url)
   354  		}
   355  		if link.GetThumbnailPayload() != nil {
   356  			lp.Thumbnail.Width = int(link.ThumbnailWidth)
   357  			lp.Thumbnail.Height = int(link.ThumbnailHeight)
   358  			lp.Thumbnail.URL = mediaURL
   359  		}
   360  		faviconMediaURL := ""
   361  		if len(link.FaviconPayload) > 0 {
   362  			faviconMediaURL = makeFaviconMediaServerURL(m.ID, link.Url)
   363  		}
   364  		if link.GetFaviconPayload() != nil {
   365  			lp.Favicon.URL = faviconMediaURL
   366  		}
   367  		previews = append(previews, lp)
   368  	}
   369  
   370  	return previews
   371  }
   372  
   373  func (m *Message) ConvertStatusLinkPreviewsToProto() (*protobuf.UnfurledStatusLinks, error) {
   374  	if len(m.StatusLinkPreviews) == 0 {
   375  		return nil, nil
   376  	}
   377  
   378  	unfurledLinks := make([]*protobuf.UnfurledStatusLink, 0, len(m.StatusLinkPreviews))
   379  
   380  	for _, preview := range m.StatusLinkPreviews {
   381  		// We expect all previews to be valid at this stage
   382  		if err := preview.validateForProto(); err != nil {
   383  			return nil, fmt.Errorf("invalid status link preview, url='%s': %w", preview.URL, err)
   384  		}
   385  
   386  		ul := &protobuf.UnfurledStatusLink{
   387  			Url: preview.URL,
   388  		}
   389  
   390  		if preview.Contact != nil {
   391  			decompressedPublicKey, err := types.DecodeHex(preview.Contact.PublicKey)
   392  			if err != nil {
   393  				return nil, fmt.Errorf("failed to decode contact public key: %w", err)
   394  			}
   395  
   396  			publicKey, err := crypto.UnmarshalPubkey(decompressedPublicKey)
   397  			if err != nil {
   398  				return nil, fmt.Errorf("failed to unmarshal decompressed public key: %w", err)
   399  			}
   400  
   401  			compressedPublicKey := crypto.CompressPubkey(publicKey)
   402  
   403  			icon, err := preview.Contact.Icon.convertToProto()
   404  			if err != nil {
   405  				return nil, err
   406  			}
   407  
   408  			ul.Payload = &protobuf.UnfurledStatusLink_Contact{
   409  				Contact: &protobuf.UnfurledStatusContactLink{
   410  					PublicKey:   compressedPublicKey,
   411  					DisplayName: preview.Contact.DisplayName,
   412  					Description: preview.Contact.Description,
   413  					Icon:        icon,
   414  				},
   415  			}
   416  		}
   417  
   418  		if preview.Community != nil {
   419  			communityPreview, err := preview.Community.convertToProto()
   420  			if err != nil {
   421  				return nil, err
   422  			}
   423  			ul.Payload = &protobuf.UnfurledStatusLink_Community{
   424  				Community: communityPreview,
   425  			}
   426  		}
   427  
   428  		if preview.Channel != nil {
   429  			communityPreview, err := preview.Channel.Community.convertToProto()
   430  			if err != nil {
   431  				return nil, err
   432  			}
   433  
   434  			ul.Payload = &protobuf.UnfurledStatusLink_Channel{
   435  				Channel: &protobuf.UnfurledStatusChannelLink{
   436  					ChannelUuid: preview.Channel.ChannelUUID,
   437  					Emoji:       preview.Channel.Emoji,
   438  					DisplayName: preview.Channel.DisplayName,
   439  					Description: preview.Channel.Description,
   440  					Color:       preview.Channel.Color,
   441  					Community:   communityPreview,
   442  				},
   443  			}
   444  
   445  		}
   446  
   447  		unfurledLinks = append(unfurledLinks, ul)
   448  	}
   449  
   450  	return &protobuf.UnfurledStatusLinks{UnfurledStatusLinks: unfurledLinks}, nil
   451  }
   452  
   453  func (m *Message) ConvertFromProtoToStatusLinkPreviews(makeMediaServerURL func(msgID string, previewURL string, imageID MediaServerImageID) string) []StatusLinkPreview {
   454  	if m.GetUnfurledStatusLinks() == nil {
   455  		return nil
   456  	}
   457  
   458  	links := m.UnfurledStatusLinks.GetUnfurledStatusLinks()
   459  
   460  	if links == nil {
   461  		return nil
   462  	}
   463  
   464  	// This wrapper adds the messageID to the callback
   465  	makeMediaServerURLMessageWrapper := func(previewURL string, imageID MediaServerImageID) string {
   466  		return makeMediaServerURL(m.ID, previewURL, imageID)
   467  	}
   468  
   469  	previews := make([]StatusLinkPreview, 0, len(links))
   470  
   471  	for _, link := range links {
   472  		lp := StatusLinkPreview{
   473  			URL: link.Url,
   474  		}
   475  
   476  		if c := link.GetContact(); c != nil {
   477  			publicKey, err := crypto.DecompressPubkey(c.PublicKey)
   478  			if err != nil {
   479  				continue
   480  			}
   481  
   482  			lp.Contact = &StatusContactLinkPreview{
   483  				PublicKey:   types.EncodeHex(gethcrypto.FromECDSAPub(publicKey)),
   484  				DisplayName: c.DisplayName,
   485  				Description: c.Description,
   486  			}
   487  			if icon := c.GetIcon(); icon != nil {
   488  				lp.Contact.Icon.loadFromProto(icon, link.Url, MediaServerContactIcon, makeMediaServerURLMessageWrapper)
   489  			}
   490  		}
   491  
   492  		if c := link.GetCommunity(); c != nil {
   493  			lp.Community = new(StatusCommunityLinkPreview)
   494  			lp.Community.loadFromProto(c, link.Url, MediaServerCommunityPrefix, makeMediaServerURLMessageWrapper)
   495  		}
   496  
   497  		if c := link.GetChannel(); c != nil {
   498  			lp.Channel = &StatusCommunityChannelLinkPreview{
   499  				ChannelUUID: c.ChannelUuid,
   500  				Emoji:       c.Emoji,
   501  				DisplayName: c.DisplayName,
   502  				Description: c.Description,
   503  				Color:       c.Color,
   504  			}
   505  			if c.Community != nil {
   506  				lp.Channel.Community = new(StatusCommunityLinkPreview)
   507  				lp.Channel.Community.loadFromProto(c.Community, link.Url, MediaServerChannelCommunityPrefix, makeMediaServerURLMessageWrapper)
   508  			}
   509  		}
   510  
   511  		previews = append(previews, lp)
   512  	}
   513  
   514  	return previews
   515  }