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 }