499 lines
15 KiB
Go
499 lines
15 KiB
Go
package common
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
|
|
gethcrypto "github.com/ethereum/go-ethereum/crypto"
|
|
"github.com/status-im/status-go/eth-node/crypto"
|
|
"github.com/status-im/status-go/eth-node/types"
|
|
"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 is: "0x" + hex-encoded decompressed public key.
|
|
// We keep it a string here for correct json marshalling.
|
|
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
|
|
}
|
|
|
|
communityID, err := types.DecodeHex(preview.CommunityID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode community id: %w", err)
|
|
}
|
|
|
|
community := &protobuf.UnfurledStatusCommunityLink{
|
|
CommunityId: 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 = types.EncodeHex(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 {
|
|
decompressedPublicKey, err := types.DecodeHex(preview.Contact.PublicKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode contact public key: %w", err)
|
|
}
|
|
|
|
publicKey, err := crypto.UnmarshalPubkey(decompressedPublicKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal decompressed public key: %w", err)
|
|
}
|
|
|
|
compressedPublicKey := crypto.CompressPubkey(publicKey)
|
|
|
|
icon, err := preview.Contact.Icon.convertToProto()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ul.Payload = &protobuf.UnfurledStatusLink_Contact{
|
|
Contact: &protobuf.UnfurledStatusContactLink{
|
|
PublicKey: compressedPublicKey,
|
|
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: 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 {
|
|
publicKey, err := crypto.DecompressPubkey(c.PublicKey)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
lp.Contact = &StatusContactLinkPreview{
|
|
PublicKey: types.EncodeHex(gethcrypto.FromECDSAPub(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: 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
|
|
}
|