package common import ( "fmt" "net/url" "github.com/status-im/status-go/images" "github.com/status-im/status-go/protocol/protobuf" ) type MakeMediaServerURLType func(msgID string, previewURL string, imageID MediaServerImageID) string type MakeMediaServerURLMessageWrapperType func(previewURL string, imageID MediaServerImageID) string type LinkPreviewThumbnail struct { Width int `json:"width"` Height int `json:"height"` // Non-empty when the thumbnail is available via the media server, i.e. after // the chat message is sent. URL string `json:"url,omitempty"` // Non-empty when the thumbnail payload needs to be shared with the client, // but before it has been persisted. DataURI string `json:"dataUri,omitempty"` } type LinkPreview struct { Type protobuf.UnfurledLink_LinkType `json:"type"` URL string `json:"url"` Hostname string `json:"hostname"` Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` Thumbnail LinkPreviewThumbnail `json:"thumbnail,omitempty"` } type StatusContactLinkPreview struct { PublicKey string `json:"publicKey"` DisplayName string `json:"displayName"` Description string `json:"description"` Icon LinkPreviewThumbnail `json:"icon,omitempty"` } type StatusCommunityLinkPreview struct { CommunityID string `json:"communityId"` DisplayName string `json:"displayName"` Description string `json:"description"` MembersCount uint32 `json:"membersCount"` Color string `json:"color"` Icon LinkPreviewThumbnail `json:"icon,omitempty"` Banner LinkPreviewThumbnail `json:"banner,omitempty"` } type StatusCommunityChannelLinkPreview struct { ChannelUUID string `json:"channelUuid"` Emoji string `json:"emoji"` DisplayName string `json:"displayName"` Description string `json:"description"` Color string `json:"color"` Community *StatusCommunityLinkPreview `json:"community"` } type StatusLinkPreview struct { URL string `json:"url,omitempty"` Contact *StatusContactLinkPreview `json:"contact,omitempty"` Community *StatusCommunityLinkPreview `json:"community,omitempty"` Channel *StatusCommunityChannelLinkPreview `json:"channel,omitempty"` } func (thumbnail *LinkPreviewThumbnail) IsEmpty() bool { return thumbnail.Width == 0 && thumbnail.Height == 0 && thumbnail.URL == "" && thumbnail.DataURI == "" } func (thumbnail *LinkPreviewThumbnail) clear() { thumbnail.Width = 0 thumbnail.Height = 0 thumbnail.URL = "" thumbnail.DataURI = "" } func (thumbnail *LinkPreviewThumbnail) validateForProto() error { if thumbnail.DataURI == "" { if thumbnail.Width == 0 && thumbnail.Height == 0 { return nil } return fmt.Errorf("dataUri is empty, but width/height are not zero") } if thumbnail.Width == 0 || thumbnail.Height == 0 { return fmt.Errorf("dataUri is not empty, but width/heigth are zero") } return nil } func (thumbnail *LinkPreviewThumbnail) convertToProto() (*protobuf.UnfurledLinkThumbnail, error) { var payload []byte var err error if thumbnail.DataURI != "" { payload, err = images.GetPayloadFromURI(thumbnail.DataURI) if err != nil { return nil, fmt.Errorf("could not get data URI payload, url='%s': %w", thumbnail.URL, err) } } return &protobuf.UnfurledLinkThumbnail{ Width: uint32(thumbnail.Width), Height: uint32(thumbnail.Height), Payload: payload, }, nil } func (thumbnail *LinkPreviewThumbnail) loadFromProto( input *protobuf.UnfurledLinkThumbnail, URL string, imageID MediaServerImageID, makeMediaServerURL MakeMediaServerURLMessageWrapperType) { thumbnail.clear() thumbnail.Width = int(input.Width) thumbnail.Height = int(input.Height) if len(input.Payload) > 0 { thumbnail.URL = makeMediaServerURL(URL, imageID) } } func (preview *LinkPreview) validateForProto() error { switch preview.Type { case protobuf.UnfurledLink_IMAGE: if preview.URL == "" { return fmt.Errorf("empty url") } if err := preview.Thumbnail.validateForProto(); err != nil { return fmt.Errorf("thumbnail is not valid for proto: %w", err) } return nil default: // Validate as a link type by default. if preview.Title == "" { return fmt.Errorf("title is empty") } if preview.URL == "" { return fmt.Errorf("url is empty") } if err := preview.Thumbnail.validateForProto(); err != nil { return fmt.Errorf("thumbnail is not valid for proto: %w", err) } return nil } } func (preview *StatusLinkPreview) validateForProto() error { if preview.URL == "" { return fmt.Errorf("url can't be empty") } // At least and only one of Contact/Community/Channel should be present in the preview if preview.Contact != nil && preview.Community != nil { return fmt.Errorf("both contact and community are set at the same time") } if preview.Community != nil && preview.Channel != nil { return fmt.Errorf("both community and channel are set at the same time") } if preview.Channel != nil && preview.Contact != nil { return fmt.Errorf("both contact and channel are set at the same time") } if preview.Contact == nil && preview.Community == nil && preview.Channel == nil { return fmt.Errorf("none of contact/community/channel are set") } if preview.Contact != nil { if preview.Contact.PublicKey == "" { return fmt.Errorf("contact publicKey is empty") } if err := preview.Contact.Icon.validateForProto(); err != nil { return fmt.Errorf("contact icon invalid: %w", err) } return nil } if preview.Community != nil { return preview.Community.validateForProto() } if preview.Channel != nil { if preview.Channel.ChannelUUID == "" { return fmt.Errorf("channelUuid is empty") } if preview.Channel.Community == nil { return fmt.Errorf("channel community is nil") } if err := preview.Channel.Community.validateForProto(); err != nil { return fmt.Errorf("channel community is not valid: %w", err) } return nil } return nil } func (preview *StatusCommunityLinkPreview) validateForProto() error { if preview == nil { return fmt.Errorf("community preview is empty") } if preview.CommunityID == "" { return fmt.Errorf("communityId is empty") } if err := preview.Icon.validateForProto(); err != nil { return fmt.Errorf("community icon is invalid: %w", err) } if err := preview.Banner.validateForProto(); err != nil { return fmt.Errorf("community banner is invalid: %w", err) } return nil } func (preview *StatusCommunityLinkPreview) convertToProto() (*protobuf.UnfurledStatusCommunityLink, error) { if preview == nil { return nil, nil } icon, err := preview.Icon.convertToProto() if err != nil { return nil, err } banner, err := preview.Banner.convertToProto() if err != nil { return nil, err } community := &protobuf.UnfurledStatusCommunityLink{ CommunityId: []byte(preview.CommunityID), DisplayName: preview.DisplayName, Description: preview.Description, MembersCount: preview.MembersCount, Color: preview.Color, Icon: icon, Banner: banner, } return community, nil } func (preview *StatusCommunityLinkPreview) loadFromProto(c *protobuf.UnfurledStatusCommunityLink, URL string, thumbnailPrefix MediaServerImageIDPrefix, makeMediaServerURL MakeMediaServerURLMessageWrapperType) { preview.CommunityID = string(c.CommunityId) preview.DisplayName = c.DisplayName preview.Description = c.Description preview.MembersCount = c.MembersCount preview.Color = c.Color preview.Icon.clear() preview.Banner.clear() if icon := c.GetIcon(); icon != nil { preview.Icon.loadFromProto(icon, URL, CreateImageID(thumbnailPrefix, MediaServerIconPostfix), makeMediaServerURL) } if banner := c.GetBanner(); banner != nil { preview.Banner.loadFromProto(banner, URL, CreateImageID(thumbnailPrefix, MediaServerBannerPostfix), makeMediaServerURL) } } // ConvertLinkPreviewsToProto expects previews to be correctly sent by the // client because we can't attempt to re-unfurl URLs at this point (it's // actually undesirable). We run a basic validation as an additional safety net. func (m *Message) ConvertLinkPreviewsToProto() ([]*protobuf.UnfurledLink, error) { if len(m.LinkPreviews) == 0 { return nil, nil } unfurledLinks := make([]*protobuf.UnfurledLink, 0, len(m.LinkPreviews)) for _, preview := range m.LinkPreviews { // Do not process subsequent previews because we do expect all previews to // be valid at this stage. if err := preview.validateForProto(); err != nil { return nil, fmt.Errorf("invalid link preview, url='%s': %w", preview.URL, err) } var payload []byte var err error if preview.Thumbnail.DataURI != "" { payload, err = images.GetPayloadFromURI(preview.Thumbnail.DataURI) if err != nil { return nil, fmt.Errorf("could not get data URI payload, url='%s': %w", preview.URL, err) } } ul := &protobuf.UnfurledLink{ Type: preview.Type, Url: preview.URL, Title: preview.Title, Description: preview.Description, ThumbnailWidth: uint32(preview.Thumbnail.Width), ThumbnailHeight: uint32(preview.Thumbnail.Height), ThumbnailPayload: payload, } unfurledLinks = append(unfurledLinks, ul) } return unfurledLinks, nil } func (m *Message) ConvertFromProtoToLinkPreviews(makeMediaServerURL func(msgID string, previewURL string) string) []LinkPreview { var links []*protobuf.UnfurledLink if links = m.GetUnfurledLinks(); links == nil { return nil } previews := make([]LinkPreview, 0, len(links)) for _, link := range links { parsedURL, err := url.Parse(link.Url) var hostname string // URL parsing in Go can fail with URLs that weren't correctly URL encoded. // This shouldn't happen in general, but if an error happens we just reuse // the full URL. if err != nil { hostname = link.Url } else { hostname = parsedURL.Hostname() } lp := LinkPreview{ Description: link.Description, Hostname: hostname, Title: link.Title, Type: link.Type, URL: link.Url, } mediaURL := "" if len(link.ThumbnailPayload) > 0 { mediaURL = makeMediaServerURL(m.ID, link.Url) } if link.GetThumbnailPayload() != nil { lp.Thumbnail.Width = int(link.ThumbnailWidth) lp.Thumbnail.Height = int(link.ThumbnailHeight) lp.Thumbnail.URL = mediaURL } previews = append(previews, lp) } return previews } func (m *Message) ConvertStatusLinkPreviewsToProto() (*protobuf.UnfurledStatusLinks, error) { if len(m.StatusLinkPreviews) == 0 { return nil, nil } unfurledLinks := make([]*protobuf.UnfurledStatusLink, 0, len(m.StatusLinkPreviews)) for _, preview := range m.StatusLinkPreviews { // We expect all previews to be valid at this stage if err := preview.validateForProto(); err != nil { return nil, fmt.Errorf("invalid status link preview, url='%s': %w", preview.URL, err) } ul := &protobuf.UnfurledStatusLink{ Url: preview.URL, } if preview.Contact != nil { icon, err := preview.Contact.Icon.convertToProto() if err != nil { return nil, err } ul.Payload = &protobuf.UnfurledStatusLink_Contact{ Contact: &protobuf.UnfurledStatusContactLink{ PublicKey: []byte(preview.Contact.PublicKey), DisplayName: preview.Contact.DisplayName, Description: preview.Contact.Description, Icon: icon, }, } } if preview.Community != nil { communityPreview, err := preview.Community.convertToProto() if err != nil { return nil, err } ul.Payload = &protobuf.UnfurledStatusLink_Community{ Community: communityPreview, } } if preview.Channel != nil { communityPreview, err := preview.Channel.Community.convertToProto() if err != nil { return nil, err } ul.Payload = &protobuf.UnfurledStatusLink_Channel{ Channel: &protobuf.UnfurledStatusChannelLink{ ChannelUuid: []byte(preview.Channel.ChannelUUID), Emoji: preview.Channel.Emoji, DisplayName: preview.Channel.DisplayName, Description: preview.Channel.Description, Color: preview.Channel.Color, Community: communityPreview, }, } } unfurledLinks = append(unfurledLinks, ul) } return &protobuf.UnfurledStatusLinks{UnfurledStatusLinks: unfurledLinks}, nil } func (m *Message) ConvertFromProtoToStatusLinkPreviews(makeMediaServerURL func(msgID string, previewURL string, imageID MediaServerImageID) string) []StatusLinkPreview { if m.GetUnfurledStatusLinks() == nil { return nil } links := m.UnfurledStatusLinks.GetUnfurledStatusLinks() if links == nil { return nil } // This wrapper adds the messageID to the callback makeMediaServerURLMessageWrapper := func(previewURL string, imageID MediaServerImageID) string { return makeMediaServerURL(m.ID, previewURL, imageID) } previews := make([]StatusLinkPreview, 0, len(links)) for _, link := range links { lp := StatusLinkPreview{ URL: link.Url, } if c := link.GetContact(); c != nil { lp.Contact = &StatusContactLinkPreview{ PublicKey: string(c.PublicKey), DisplayName: c.DisplayName, Description: c.Description, } if icon := c.GetIcon(); icon != nil { lp.Contact.Icon.loadFromProto(icon, link.Url, MediaServerContactIcon, makeMediaServerURLMessageWrapper) } } if c := link.GetCommunity(); c != nil { lp.Community = new(StatusCommunityLinkPreview) lp.Community.loadFromProto(c, link.Url, MediaServerCommunityPrefix, makeMediaServerURLMessageWrapper) } if c := link.GetChannel(); c != nil { lp.Channel = &StatusCommunityChannelLinkPreview{ ChannelUUID: string(c.ChannelUuid), Emoji: c.Emoji, DisplayName: c.DisplayName, Description: c.Description, Color: c.Color, } if c.Community != nil { lp.Channel.Community = new(StatusCommunityLinkPreview) lp.Channel.Community.loadFromProto(c.Community, link.Url, MediaServerChannelCommunityPrefix, makeMediaServerURLMessageWrapper) } } previews = append(previews, lp) } return previews }