github.com/keybase/client/go@v0.0.0-20241007131713-f10651d043c8/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, chat1.GetS3ParamsArg{
    98  		ConversationID: convID,
    99  		TempCreds:      true,
   100  	})
   101  	if err != nil {
   102  		return res, err
   103  	}
   104  	outboxID, err := storage.NewOutboxID()
   105  	if err != nil {
   106  		return res, err
   107  	}
   108  	task := attachments.UploadTask{
   109  		S3Params:       s3params,
   110  		Filename:       filename,
   111  		FileSize:       len,
   112  		Plaintext:      src,
   113  		S3Signer:       p.s3signer,
   114  		ConversationID: convID,
   115  		UserID:         uid,
   116  		OutboxID:       outboxID,
   117  	}
   118  	if res, err = p.store.UploadAsset(ctx, &task, io.Discard); err != nil {
   119  		return res, err
   120  	}
   121  	res.MimeType = contentType
   122  	res.Metadata = md
   123  	return res, nil
   124  }
   125  
   126  func (p *Packager) assetFromURLWithBody(ctx context.Context, body io.ReadCloser, contentLength int64,
   127  	url string, uid gregor1.UID, convID chat1.ConversationID, usePreview bool) (res chat1.Asset, err error) {
   128  	defer body.Close()
   129  	if contentLength > 0 && contentLength > p.maxAssetSize {
   130  		return res, fmt.Errorf("asset too large: %d > %d", contentLength, p.maxAssetSize)
   131  	}
   132  	dat, err := io.ReadAll(body)
   133  	if err != nil {
   134  		return res, err
   135  	}
   136  	if int64(len(dat)) > p.maxAssetSize {
   137  		return res, fmt.Errorf("asset too large: %d > %d", len(dat), p.maxAssetSize)
   138  	}
   139  
   140  	filename := p.assetFilename(url)
   141  	src := attachments.NewBufReadResetter(dat)
   142  	pre, err := attachments.PreprocessAsset(ctx, p.G(), p.DebugLabeler, src, filename,
   143  		types.DummyNativeVideoHelper{}, nil)
   144  	if err != nil {
   145  		return res, err
   146  	}
   147  	if err := src.Reset(); err != nil {
   148  		return res, err
   149  	}
   150  
   151  	filename = pre.Filename
   152  	uploadLen := len(dat)
   153  	if pre.SrcDat != nil {
   154  		src = attachments.NewBufReadResetter(pre.SrcDat)
   155  		uploadLen = len(pre.SrcDat)
   156  	}
   157  	uploadPt := src
   158  	uploadMd := pre.BaseMetadata()
   159  	uploadContentType := pre.ContentType
   160  	if usePreview && pre.Preview != nil {
   161  		uploadPt = attachments.NewBufReadResetter(pre.Preview)
   162  		uploadLen = len(pre.Preview)
   163  		uploadMd = pre.PreviewMetadata()
   164  		uploadContentType = pre.PreviewContentType
   165  	} else {
   166  		p.Debug(ctx, "assetFromURL: warning, failed to generate preview for asset, using base")
   167  	}
   168  	return p.uploadAsset(ctx, uid, convID, uploadPt, filename, int64(uploadLen), uploadMd, uploadContentType)
   169  }
   170  
   171  func (p *Packager) uploadVideo(ctx context.Context, uid gregor1.UID, convID chat1.ConversationID,
   172  	video chat1.UnfurlVideo) (res chat1.Asset, err error) {
   173  	body, len, err := p.assetBodyAndLength(ctx, video.Url)
   174  	if err != nil {
   175  		return res, err
   176  	}
   177  	defer body.Close()
   178  	return p.uploadVideoWithBody(ctx, uid, convID, body, len, video)
   179  }
   180  
   181  func (p *Packager) uploadVideoWithBody(ctx context.Context, uid gregor1.UID, convID chat1.ConversationID,
   182  	body io.ReadCloser, len int64, video chat1.UnfurlVideo) (res chat1.Asset, err error) {
   183  	dat, err := io.ReadAll(body)
   184  	if err != nil {
   185  		return res, err
   186  	}
   187  	return p.uploadAsset(ctx, uid, convID, attachments.NewBufReadResetter(dat), "video.mp4",
   188  		len, chat1.NewAssetMetadataWithVideo(chat1.AssetMetadataVideo{
   189  			Width:      video.Width,
   190  			Height:     video.Height,
   191  			DurationMs: 1,
   192  		}), video.MimeType)
   193  }
   194  
   195  func (p *Packager) packageGeneric(ctx context.Context, uid gregor1.UID, convID chat1.ConversationID,
   196  	raw chat1.UnfurlRaw) (res chat1.Unfurl, err error) {
   197  	g := chat1.UnfurlGeneric{
   198  		Title:       raw.Generic().Title,
   199  		Url:         raw.Generic().Url,
   200  		SiteName:    raw.Generic().SiteName,
   201  		PublishTime: raw.Generic().PublishTime,
   202  		Description: raw.Generic().Description,
   203  	}
   204  	if raw.Generic().Video != nil {
   205  		asset, err := p.uploadVideo(ctx, uid, convID, *raw.Generic().Video)
   206  		if err != nil {
   207  			p.Debug(ctx, "packageGeneric: failed to package video asset: %s", err)
   208  		}
   209  		g.Image = &asset
   210  	}
   211  	if g.Image == nil && raw.Generic().ImageUrl != nil {
   212  		asset, err := p.assetFromURL(ctx, *raw.Generic().ImageUrl, uid, convID, true)
   213  		if err != nil {
   214  			p.Debug(ctx, "packageGeneric: failed to get image asset URL: %s", err)
   215  		} else {
   216  			g.Image = &asset
   217  		}
   218  	}
   219  	if raw.Generic().FaviconUrl != nil {
   220  		asset, err := p.assetFromURL(ctx, *raw.Generic().FaviconUrl, uid, convID, true)
   221  		if err != nil {
   222  			p.Debug(ctx, "packageGeneric: failed to get favicon asset URL: %s", err)
   223  		} else {
   224  			g.Favicon = &asset
   225  		}
   226  	}
   227  	return chat1.NewUnfurlWithGeneric(g), nil
   228  }
   229  
   230  func (p *Packager) packageGiphy(ctx context.Context, uid gregor1.UID, convID chat1.ConversationID,
   231  	raw chat1.UnfurlRaw) (res chat1.Unfurl, err error) {
   232  	var g chat1.UnfurlGiphy
   233  	var imgBody io.ReadCloser
   234  	var imgLength int64
   235  	if raw.Giphy().ImageUrl != nil {
   236  		imgBody, imgLength, err = giphy.Asset(libkb.NewMetaContext(ctx, p.G().ExternalG()),
   237  			*raw.Giphy().ImageUrl)
   238  		if err != nil {
   239  			p.Debug(ctx, "Package: failed to get body specs for giphy image: %s", err)
   240  			return res, err
   241  		}
   242  		defer imgBody.Close()
   243  	}
   244  	if raw.Giphy().Video != nil {
   245  		// If we found a video, then let's see if it is smaller than the image, if so we will
   246  		// set it (which means it will get used by the frontend)
   247  		vidBody, vidLength, err := giphy.Asset(libkb.NewMetaContext(ctx, p.G().ExternalG()),
   248  			raw.Giphy().Video.Url)
   249  		if err == nil && (imgLength == 0 || vidLength < imgLength) && vidLength < p.maxAssetSize {
   250  			p.Debug(ctx, "Package: found video: len: %d", vidLength)
   251  			defer vidBody.Close()
   252  			asset, err := p.uploadVideoWithBody(ctx, uid, convID, vidBody, vidLength,
   253  				*raw.Giphy().Video)
   254  			if err != nil {
   255  				p.Debug(ctx, "Package: failed to get video asset URL: %s", err)
   256  			} else {
   257  				g.Video = &asset
   258  			}
   259  		} else if err != nil {
   260  			p.Debug(ctx, "Package: failed to get video specs: %s", err)
   261  		} else {
   262  			defer vidBody.Close()
   263  			p.Debug(ctx, "Package: not selecting video: %d(video) > %d(image)", vidLength, imgLength)
   264  		}
   265  	}
   266  	if g.Video == nil && raw.Giphy().ImageUrl != nil {
   267  		// Only grab the image if we didn't get a video
   268  		asset, err := p.assetFromURLWithBody(ctx, imgBody, imgLength, *raw.Giphy().ImageUrl, uid,
   269  			convID, true)
   270  		if err != nil {
   271  			// if we don't get the image, then just bail out of here
   272  			p.Debug(ctx, "Package: failed to get image asset URL: %s", err)
   273  			return res, errors.New("image not available for giphy unfurl")
   274  		}
   275  		g.Image = &asset
   276  	}
   277  	if raw.Giphy().FaviconUrl != nil {
   278  		if asset, err := p.assetFromURL(ctx, *raw.Giphy().FaviconUrl, uid, convID, true); err != nil {
   279  			p.Debug(ctx, "Package: failed to get favicon asset URL: %s", err)
   280  		} else {
   281  			g.Favicon = &asset
   282  		}
   283  	}
   284  	return chat1.NewUnfurlWithGiphy(g), nil
   285  }
   286  
   287  func (p *Packager) packageMaps(ctx context.Context, uid gregor1.UID, convID chat1.ConversationID,
   288  	raw chat1.UnfurlRaw) (res chat1.Unfurl, err error) {
   289  	mapsRaw := raw.Maps()
   290  	g := chat1.UnfurlGeneric{
   291  		Title:       mapsRaw.Title,
   292  		Url:         mapsRaw.Url,
   293  		SiteName:    mapsRaw.SiteName,
   294  		Description: &mapsRaw.Description,
   295  	}
   296  
   297  	// load user avatar for fancy maps
   298  	username := p.G().ExternalG().GetEnv().GetUsername().String()
   299  	avatarReader, _, err := avatars.GetBorderedCircleAvatar(ctx, p.G(), username, 48, 8, 8)
   300  	if err != nil {
   301  		return res, err
   302  	}
   303  	defer avatarReader.Close()
   304  
   305  	// load map
   306  	var reader io.ReadCloser
   307  	var length int64
   308  	var isDone bool
   309  	mapsURL := mapsRaw.ImageUrl
   310  	locReader, _, err := maps.MapReaderFromURL(ctx, p.G(), mapsURL)
   311  	if err != nil {
   312  		return res, err
   313  	}
   314  	defer locReader.Close()
   315  	if mapsRaw.HistoryImageUrl != nil {
   316  		liveReader, _, err := maps.MapReaderFromURL(ctx, p.G(), *mapsRaw.HistoryImageUrl)
   317  		if err != nil {
   318  			return res, err
   319  		}
   320  		defer liveReader.Close()
   321  		if reader, length, err = maps.DecorateMap(ctx, avatarReader, liveReader); err != nil {
   322  			return res, err
   323  		}
   324  		isDone = mapsRaw.LiveLocationDone
   325  	} else {
   326  		if reader, length, err = maps.DecorateMap(ctx, avatarReader, locReader); err != nil {
   327  			return res, err
   328  		}
   329  		isDone = false
   330  	}
   331  	asset, err := p.assetFromURLWithBody(ctx, reader, length, mapsURL, uid, convID, true)
   332  	if err != nil {
   333  		p.Debug(ctx, "Package: failed to get maps asset URL: %s", err)
   334  		return res, errors.New("image not available for maps unfurl")
   335  	}
   336  	g.Image = &asset
   337  	g.MapInfo = &chat1.UnfurlGenericMapInfo{
   338  		Coord:               mapsRaw.Coord,
   339  		LiveLocationEndTime: mapsRaw.LiveLocationEndTime,
   340  		IsLiveLocationDone:  isDone,
   341  		Time:                mapsRaw.Time,
   342  	}
   343  	return chat1.NewUnfurlWithGeneric(g), nil
   344  }
   345  
   346  func (p *Packager) cacheKey(uid gregor1.UID, convID chat1.ConversationID, raw chat1.UnfurlRaw) string {
   347  	url := raw.GetUrl()
   348  	if url == "" {
   349  		return ""
   350  	}
   351  	typ, err := raw.UnfurlType()
   352  	if err != nil {
   353  		return ""
   354  	}
   355  	return fmt.Sprintf("%s-%s-%s-%s", uid, convID, url, typ)
   356  }
   357  
   358  func (p *Packager) Package(ctx context.Context, uid gregor1.UID, convID chat1.ConversationID,
   359  	raw chat1.UnfurlRaw) (res chat1.Unfurl, err error) {
   360  	defer p.Trace(ctx, &err, "Package")()
   361  
   362  	cacheKey := p.cacheKey(uid, convID, raw)
   363  	if item, valid := p.cache.get(cacheKey); cacheKey != "" && valid {
   364  		p.Debug(ctx, "Package: using cached value")
   365  		return item.data.(chat1.Unfurl), nil
   366  	}
   367  	defer func() {
   368  		if cacheKey != "" && err == nil {
   369  			p.cache.put(cacheKey, res)
   370  		}
   371  	}()
   372  
   373  	typ, err := raw.UnfurlType()
   374  	if err != nil {
   375  		return res, err
   376  	}
   377  	switch typ {
   378  	case chat1.UnfurlType_GENERIC:
   379  		return p.packageGeneric(ctx, uid, convID, raw)
   380  	case chat1.UnfurlType_GIPHY:
   381  		return p.packageGiphy(ctx, uid, convID, raw)
   382  	case chat1.UnfurlType_MAPS:
   383  		return p.packageMaps(ctx, uid, convID, raw)
   384  	default:
   385  		return res, errors.New("not implemented")
   386  	}
   387  }