github.com/keybase/client/go@v0.0.0-20240309051027-028f7c731f8b/chat/unfurl/packager.go (about)

     1  package unfurl
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"net/http"
     9  	"strings"
    10  
    11  	"github.com/keybase/client/go/avatars"
    12  	"github.com/keybase/client/go/chat/globals"
    13  	"github.com/keybase/client/go/libkb"
    14  
    15  	"github.com/keybase/client/go/chat/maps"
    16  
    17  	"github.com/keybase/client/go/chat/attachments"
    18  	"github.com/keybase/client/go/chat/giphy"
    19  	"github.com/keybase/client/go/chat/s3"
    20  	"github.com/keybase/client/go/chat/storage"
    21  	"github.com/keybase/client/go/chat/types"
    22  
    23  	"github.com/keybase/client/go/chat/utils"
    24  	"github.com/keybase/client/go/protocol/chat1"
    25  	"github.com/keybase/client/go/protocol/gregor1"
    26  )
    27  
    28  type Packager struct {
    29  	globals.Contextified
    30  	utils.DebugLabeler
    31  
    32  	cache        *unfurlCache
    33  	ri           func() chat1.RemoteInterface
    34  	store        attachments.Store
    35  	s3signer     s3.Signer
    36  	maxAssetSize int64
    37  }
    38  
    39  func NewPackager(g *globals.Context, store attachments.Store, s3signer s3.Signer,
    40  	ri func() chat1.RemoteInterface) *Packager {
    41  	return &Packager{
    42  		Contextified: globals.NewContextified(g),
    43  		DebugLabeler: utils.NewDebugLabeler(g.ExternalG(), "Packager", false),
    44  		cache:        newUnfurlCache(),
    45  		store:        store,
    46  		ri:           ri,
    47  		s3signer:     s3signer,
    48  		maxAssetSize: 10000000,
    49  	}
    50  }
    51  
    52  func (p *Packager) assetFilename(url string) string {
    53  	toks := strings.Split(url, "/")
    54  	if len(toks) > 0 {
    55  		return toks[len(toks)-1]
    56  	}
    57  	return "unknown.jpg"
    58  }
    59  
    60  func (p *Packager) assetBodyAndLength(ctx context.Context, url string) (body io.ReadCloser, size int64, err error) {
    61  	client := libkb.ProxyHTTPClient(p.G().ExternalG(), p.G().Env, "UnfurlPackager")
    62  	req, err := http.NewRequest(http.MethodGet, url, nil)
    63  	if err != nil {
    64  		return nil, 0, err
    65  	}
    66  	req.Header.Add("User-Agent", libkb.UserAgent)
    67  
    68  	resp, err := client.Do(req)
    69  	if err != nil {
    70  		return body, size, err
    71  	}
    72  	if resp.StatusCode != 200 {
    73  		return nil, 0, fmt.Errorf("Status %s", resp.Status)
    74  	}
    75  	return resp.Body, resp.ContentLength, nil
    76  }
    77  
    78  func (p *Packager) assetFromURL(ctx context.Context, url string, uid gregor1.UID,
    79  	convID chat1.ConversationID, usePreview bool) (res chat1.Asset, err error) {
    80  	body, contentLength, err := p.assetBodyAndLength(ctx, url)
    81  	if err != nil {
    82  		return res, err
    83  	}
    84  	return p.assetFromURLWithBody(ctx, body, contentLength, url, uid, convID, usePreview)
    85  }
    86  
    87  func (p *Packager) uploadAsset(ctx context.Context, uid gregor1.UID, convID chat1.ConversationID,
    88  	src *attachments.BufReadResetter, filename string, len int64, md chat1.AssetMetadata, contentType string) (res chat1.Asset, err error) {
    89  	atyp, err := md.AssetType()
    90  	if err != nil {
    91  		return res, err
    92  	}
    93  	if atyp != chat1.AssetMetadataType_IMAGE && atyp != chat1.AssetMetadataType_VIDEO {
    94  		return res, fmt.Errorf("invalid asset for unfurl package: %v mime: %s", atyp, contentType)
    95  	}
    96  
    97  	s3params, err := p.ri().GetS3Params(ctx, convID)
    98  	if err != nil {
    99  		return res, err
   100  	}
   101  	outboxID, err := storage.NewOutboxID()
   102  	if err != nil {
   103  		return res, err
   104  	}
   105  	task := attachments.UploadTask{
   106  		S3Params:       s3params,
   107  		Filename:       filename,
   108  		FileSize:       len,
   109  		Plaintext:      src,
   110  		S3Signer:       p.s3signer,
   111  		ConversationID: convID,
   112  		UserID:         uid,
   113  		OutboxID:       outboxID,
   114  	}
   115  	if res, err = p.store.UploadAsset(ctx, &task, io.Discard); err != nil {
   116  		return res, err
   117  	}
   118  	res.MimeType = contentType
   119  	res.Metadata = md
   120  	return res, nil
   121  }
   122  
   123  func (p *Packager) assetFromURLWithBody(ctx context.Context, body io.ReadCloser, contentLength int64,
   124  	url string, uid gregor1.UID, convID chat1.ConversationID, usePreview bool) (res chat1.Asset, err error) {
   125  	defer body.Close()
   126  	if contentLength > 0 && contentLength > p.maxAssetSize {
   127  		return res, fmt.Errorf("asset too large: %d > %d", contentLength, p.maxAssetSize)
   128  	}
   129  	dat, err := io.ReadAll(body)
   130  	if err != nil {
   131  		return res, err
   132  	}
   133  	if int64(len(dat)) > p.maxAssetSize {
   134  		return res, fmt.Errorf("asset too large: %d > %d", len(dat), p.maxAssetSize)
   135  	}
   136  
   137  	filename := p.assetFilename(url)
   138  	src := attachments.NewBufReadResetter(dat)
   139  	pre, err := attachments.PreprocessAsset(ctx, p.G(), p.DebugLabeler, src, filename,
   140  		types.DummyNativeVideoHelper{}, nil)
   141  	if err != nil {
   142  		return res, err
   143  	}
   144  	if err := src.Reset(); err != nil {
   145  		return res, err
   146  	}
   147  	uploadPt := src
   148  	uploadLen := len(dat)
   149  	uploadMd := pre.BaseMetadata()
   150  	uploadContentType := pre.ContentType
   151  	if usePreview && pre.Preview != nil {
   152  		uploadPt = attachments.NewBufReadResetter(pre.Preview)
   153  		uploadLen = len(pre.Preview)
   154  		uploadMd = pre.PreviewMetadata()
   155  		uploadContentType = pre.PreviewContentType
   156  	} else {
   157  		p.Debug(ctx, "assetFromURL: warning, failed to generate preview for asset, using base")
   158  	}
   159  	return p.uploadAsset(ctx, uid, convID, uploadPt, filename, int64(uploadLen), uploadMd, uploadContentType)
   160  }
   161  
   162  func (p *Packager) uploadVideo(ctx context.Context, uid gregor1.UID, convID chat1.ConversationID,
   163  	video chat1.UnfurlVideo) (res chat1.Asset, err error) {
   164  	body, len, err := p.assetBodyAndLength(ctx, video.Url)
   165  	if err != nil {
   166  		return res, err
   167  	}
   168  	defer body.Close()
   169  	return p.uploadVideoWithBody(ctx, uid, convID, body, len, video)
   170  }
   171  
   172  func (p *Packager) uploadVideoWithBody(ctx context.Context, uid gregor1.UID, convID chat1.ConversationID,
   173  	body io.ReadCloser, len int64, video chat1.UnfurlVideo) (res chat1.Asset, err error) {
   174  	dat, err := io.ReadAll(body)
   175  	if err != nil {
   176  		return res, err
   177  	}
   178  	return p.uploadAsset(ctx, uid, convID, attachments.NewBufReadResetter(dat), "video.mp4",
   179  		len, chat1.NewAssetMetadataWithVideo(chat1.AssetMetadataVideo{
   180  			Width:      video.Width,
   181  			Height:     video.Height,
   182  			DurationMs: 1,
   183  		}), video.MimeType)
   184  }
   185  
   186  func (p *Packager) packageGeneric(ctx context.Context, uid gregor1.UID, convID chat1.ConversationID,
   187  	raw chat1.UnfurlRaw) (res chat1.Unfurl, err error) {
   188  	g := chat1.UnfurlGeneric{
   189  		Title:       raw.Generic().Title,
   190  		Url:         raw.Generic().Url,
   191  		SiteName:    raw.Generic().SiteName,
   192  		PublishTime: raw.Generic().PublishTime,
   193  		Description: raw.Generic().Description,
   194  	}
   195  	if raw.Generic().Video != nil {
   196  		asset, err := p.uploadVideo(ctx, uid, convID, *raw.Generic().Video)
   197  		if err != nil {
   198  			p.Debug(ctx, "packageGeneric: failed to package video asset: %s", err)
   199  		}
   200  		g.Image = &asset
   201  	}
   202  	if g.Image == nil && raw.Generic().ImageUrl != nil {
   203  		asset, err := p.assetFromURL(ctx, *raw.Generic().ImageUrl, uid, convID, true)
   204  		if err != nil {
   205  			p.Debug(ctx, "packageGeneric: failed to get image asset URL: %s", err)
   206  		} else {
   207  			g.Image = &asset
   208  		}
   209  	}
   210  	if raw.Generic().FaviconUrl != nil {
   211  		asset, err := p.assetFromURL(ctx, *raw.Generic().FaviconUrl, uid, convID, true)
   212  		if err != nil {
   213  			p.Debug(ctx, "packageGeneric: failed to get favicon asset URL: %s", err)
   214  		} else {
   215  			g.Favicon = &asset
   216  		}
   217  	}
   218  	return chat1.NewUnfurlWithGeneric(g), nil
   219  }
   220  
   221  func (p *Packager) packageGiphy(ctx context.Context, uid gregor1.UID, convID chat1.ConversationID,
   222  	raw chat1.UnfurlRaw) (res chat1.Unfurl, err error) {
   223  	var g chat1.UnfurlGiphy
   224  	var imgBody io.ReadCloser
   225  	var imgLength int64
   226  	if raw.Giphy().ImageUrl != nil {
   227  		imgBody, imgLength, err = giphy.Asset(libkb.NewMetaContext(ctx, p.G().ExternalG()),
   228  			*raw.Giphy().ImageUrl)
   229  		if err != nil {
   230  			p.Debug(ctx, "Package: failed to get body specs for giphy image: %s", err)
   231  			return res, err
   232  		}
   233  		defer imgBody.Close()
   234  	}
   235  	if raw.Giphy().Video != nil {
   236  		// If we found a video, then let's see if it is smaller than the image, if so we will
   237  		// set it (which means it will get used by the frontend)
   238  		vidBody, vidLength, err := giphy.Asset(libkb.NewMetaContext(ctx, p.G().ExternalG()),
   239  			raw.Giphy().Video.Url)
   240  		if err == nil && (imgLength == 0 || vidLength < imgLength) && vidLength < p.maxAssetSize {
   241  			p.Debug(ctx, "Package: found video: len: %d", vidLength)
   242  			defer vidBody.Close()
   243  			asset, err := p.uploadVideoWithBody(ctx, uid, convID, vidBody, vidLength,
   244  				*raw.Giphy().Video)
   245  			if err != nil {
   246  				p.Debug(ctx, "Package: failed to get video asset URL: %s", err)
   247  			} else {
   248  				g.Video = &asset
   249  			}
   250  		} else if err != nil {
   251  			p.Debug(ctx, "Package: failed to get video specs: %s", err)
   252  		} else {
   253  			defer vidBody.Close()
   254  			p.Debug(ctx, "Package: not selecting video: %d(video) > %d(image)", vidLength, imgLength)
   255  		}
   256  	}
   257  	if g.Video == nil && raw.Giphy().ImageUrl != nil {
   258  		// Only grab the image if we didn't get a video
   259  		asset, err := p.assetFromURLWithBody(ctx, imgBody, imgLength, *raw.Giphy().ImageUrl, uid,
   260  			convID, true)
   261  		if err != nil {
   262  			// if we don't get the image, then just bail out of here
   263  			p.Debug(ctx, "Package: failed to get image asset URL: %s", err)
   264  			return res, errors.New("image not available for giphy unfurl")
   265  		}
   266  		g.Image = &asset
   267  	}
   268  	if raw.Giphy().FaviconUrl != nil {
   269  		if asset, err := p.assetFromURL(ctx, *raw.Giphy().FaviconUrl, uid, convID, true); err != nil {
   270  			p.Debug(ctx, "Package: failed to get favicon asset URL: %s", err)
   271  		} else {
   272  			g.Favicon = &asset
   273  		}
   274  	}
   275  	return chat1.NewUnfurlWithGiphy(g), nil
   276  }
   277  
   278  func (p *Packager) packageMaps(ctx context.Context, uid gregor1.UID, convID chat1.ConversationID,
   279  	raw chat1.UnfurlRaw) (res chat1.Unfurl, err error) {
   280  	mapsRaw := raw.Maps()
   281  	g := chat1.UnfurlGeneric{
   282  		Title:       mapsRaw.Title,
   283  		Url:         mapsRaw.Url,
   284  		SiteName:    mapsRaw.SiteName,
   285  		Description: &mapsRaw.Description,
   286  	}
   287  
   288  	// load user avatar for fancy maps
   289  	username := p.G().ExternalG().GetEnv().GetUsername().String()
   290  	avatarReader, _, err := avatars.GetBorderedCircleAvatar(ctx, p.G(), username, 48, 8, 8)
   291  	if err != nil {
   292  		return res, err
   293  	}
   294  	defer avatarReader.Close()
   295  
   296  	// load map
   297  	var reader io.ReadCloser
   298  	var length int64
   299  	var isDone bool
   300  	mapsURL := mapsRaw.ImageUrl
   301  	locReader, _, err := maps.MapReaderFromURL(ctx, p.G(), mapsURL)
   302  	if err != nil {
   303  		return res, err
   304  	}
   305  	defer locReader.Close()
   306  	if mapsRaw.HistoryImageUrl != nil {
   307  		liveReader, _, err := maps.MapReaderFromURL(ctx, p.G(), *mapsRaw.HistoryImageUrl)
   308  		if err != nil {
   309  			return res, err
   310  		}
   311  		defer liveReader.Close()
   312  		if reader, length, err = maps.DecorateMap(ctx, avatarReader, liveReader); err != nil {
   313  			return res, err
   314  		}
   315  		isDone = mapsRaw.LiveLocationDone
   316  	} else {
   317  		if reader, length, err = maps.DecorateMap(ctx, avatarReader, locReader); err != nil {
   318  			return res, err
   319  		}
   320  		isDone = false
   321  	}
   322  	asset, err := p.assetFromURLWithBody(ctx, reader, length, mapsURL, uid, convID, true)
   323  	if err != nil {
   324  		p.Debug(ctx, "Package: failed to get maps asset URL: %s", err)
   325  		return res, errors.New("image not available for maps unfurl")
   326  	}
   327  	g.Image = &asset
   328  	g.MapInfo = &chat1.UnfurlGenericMapInfo{
   329  		Coord:               mapsRaw.Coord,
   330  		LiveLocationEndTime: mapsRaw.LiveLocationEndTime,
   331  		IsLiveLocationDone:  isDone,
   332  		Time:                mapsRaw.Time,
   333  	}
   334  	return chat1.NewUnfurlWithGeneric(g), nil
   335  }
   336  
   337  func (p *Packager) cacheKey(uid gregor1.UID, convID chat1.ConversationID, raw chat1.UnfurlRaw) string {
   338  	url := raw.GetUrl()
   339  	if url == "" {
   340  		return ""
   341  	}
   342  	typ, err := raw.UnfurlType()
   343  	if err != nil {
   344  		return ""
   345  	}
   346  	return fmt.Sprintf("%s-%s-%s-%s", uid, convID, url, typ)
   347  }
   348  
   349  func (p *Packager) Package(ctx context.Context, uid gregor1.UID, convID chat1.ConversationID,
   350  	raw chat1.UnfurlRaw) (res chat1.Unfurl, err error) {
   351  	defer p.Trace(ctx, &err, "Package")()
   352  
   353  	cacheKey := p.cacheKey(uid, convID, raw)
   354  	if item, valid := p.cache.get(cacheKey); cacheKey != "" && valid {
   355  		p.Debug(ctx, "Package: using cached value")
   356  		return item.data.(chat1.Unfurl), nil
   357  	}
   358  	defer func() {
   359  		if cacheKey != "" && err == nil {
   360  			p.cache.put(cacheKey, res)
   361  		}
   362  	}()
   363  
   364  	typ, err := raw.UnfurlType()
   365  	if err != nil {
   366  		return res, err
   367  	}
   368  	switch typ {
   369  	case chat1.UnfurlType_GENERIC:
   370  		return p.packageGeneric(ctx, uid, convID, raw)
   371  	case chat1.UnfurlType_GIPHY:
   372  		return p.packageGiphy(ctx, uid, convID, raw)
   373  	case chat1.UnfurlType_MAPS:
   374  		return p.packageMaps(ctx, uid, convID, raw)
   375  	default:
   376  		return res, errors.New("not implemented")
   377  	}
   378  }