github.com/keybase/client/go@v0.0.0-20241007131713-f10651d043c8/chat/attachments/preprocess.go (about)

     1  package attachments
     2  
     3  import (
     4  	"encoding/json"
     5  	"fmt"
     6  	"io"
     7  	"net/http"
     8  	"os"
     9  	"path/filepath"
    10  	"strings"
    11  
    12  	"github.com/keybase/client/go/chat/globals"
    13  	"github.com/keybase/client/go/libkb"
    14  
    15  	"github.com/keybase/client/go/chat/types"
    16  	"github.com/keybase/client/go/chat/utils"
    17  
    18  	"github.com/keybase/client/go/protocol/chat1"
    19  	"golang.org/x/net/context"
    20  )
    21  
    22  type Dimension struct {
    23  	Width  int `json:"width"`
    24  	Height int `json:"height"`
    25  }
    26  
    27  func (d *Dimension) Empty() bool {
    28  	return d.Width == 0 && d.Height == 0
    29  }
    30  
    31  func (d *Dimension) Encode() string {
    32  	if d.Width == 0 && d.Height == 0 {
    33  		return ""
    34  	}
    35  	enc, err := json.Marshal(d)
    36  	if err != nil {
    37  		return ""
    38  	}
    39  	return string(enc)
    40  }
    41  
    42  type Preprocess struct {
    43  	// Detected content type of the input
    44  	ContentType string
    45  	// May differ from the caller's view if we convert the input
    46  	Filename string
    47  	// Only set if a conversion of the input bytes happens
    48  	SrcDat []byte
    49  
    50  	Preview            []byte
    51  	PreviewContentType string
    52  	BaseDim            *Dimension
    53  	BaseDurationMs     int
    54  	BaseIsAudio        bool
    55  	PreviewDim         *Dimension
    56  	PreviewAudioAmps   []float64
    57  	PreviewDurationMs  int
    58  }
    59  
    60  func (p *Preprocess) BaseMetadata() chat1.AssetMetadata {
    61  	if p.BaseDim == nil || p.BaseDim.Empty() {
    62  		return chat1.AssetMetadata{}
    63  	}
    64  	if p.BaseDurationMs > 0 {
    65  		return chat1.NewAssetMetadataWithVideo(chat1.AssetMetadataVideo{
    66  			Width:      p.BaseDim.Width,
    67  			Height:     p.BaseDim.Height,
    68  			DurationMs: p.BaseDurationMs,
    69  			IsAudio:    p.BaseIsAudio,
    70  		})
    71  	}
    72  	return chat1.NewAssetMetadataWithImage(chat1.AssetMetadataImage{
    73  		Width:  p.BaseDim.Width,
    74  		Height: p.BaseDim.Height,
    75  	})
    76  }
    77  
    78  func (p *Preprocess) PreviewMetadata() chat1.AssetMetadata {
    79  	if p.PreviewDim == nil || p.PreviewDim.Empty() {
    80  		return chat1.AssetMetadata{}
    81  	}
    82  	if p.PreviewDurationMs > 0 {
    83  		return chat1.NewAssetMetadataWithVideo(chat1.AssetMetadataVideo{
    84  			Width:      p.PreviewDim.Width,
    85  			Height:     p.PreviewDim.Height,
    86  			DurationMs: p.PreviewDurationMs,
    87  		})
    88  	}
    89  	return chat1.NewAssetMetadataWithImage(chat1.AssetMetadataImage{
    90  		Width:     p.PreviewDim.Width,
    91  		Height:    p.PreviewDim.Height,
    92  		AudioAmps: p.PreviewAudioAmps,
    93  	})
    94  }
    95  
    96  func (p *Preprocess) Export(getLocation func() *chat1.PreviewLocation) (res chat1.MakePreviewRes, err error) {
    97  	res = chat1.MakePreviewRes{
    98  		MimeType: p.ContentType,
    99  		Location: getLocation(),
   100  	}
   101  	if p.PreviewContentType != "" {
   102  		res.PreviewMimeType = &p.PreviewContentType
   103  	}
   104  	md := p.PreviewMetadata()
   105  	var empty chat1.AssetMetadata
   106  	if md != empty {
   107  		res.Metadata = &md
   108  	}
   109  	baseMd := p.BaseMetadata()
   110  	if baseMd != empty {
   111  		res.BaseMetadata = &baseMd
   112  	}
   113  	return res, nil
   114  }
   115  
   116  func processCallerPreview(ctx context.Context, g *globals.Context, callerPreview chat1.MakePreviewRes) (p Preprocess, err error) {
   117  	ltyp, err := callerPreview.Location.Ltyp()
   118  	if err != nil {
   119  		return p, err
   120  	}
   121  	switch ltyp {
   122  	case chat1.PreviewLocationTyp_BYTES:
   123  		source := callerPreview.Location.Bytes()
   124  		p.Preview = make([]byte, len(source))
   125  		copy(p.Preview, source)
   126  	case chat1.PreviewLocationTyp_FILE:
   127  		f, err := os.Open(callerPreview.Location.File())
   128  		if err != nil {
   129  			return p, err
   130  		}
   131  		defer f.Close()
   132  		if p.Preview, err = io.ReadAll(f); err != nil {
   133  			return p, err
   134  		}
   135  	case chat1.PreviewLocationTyp_URL:
   136  		resp, err := libkb.ProxyHTTPGet(g.ExternalG(), g.Env, callerPreview.Location.Url(), "PreviewLocation")
   137  		if err != nil {
   138  			return p, err
   139  		}
   140  		defer resp.Body.Close()
   141  		if p.Preview, err = io.ReadAll(resp.Body); err != nil {
   142  			return p, err
   143  		}
   144  	default:
   145  		return p, fmt.Errorf("unknown preview location: %v", ltyp)
   146  	}
   147  	p.ContentType = callerPreview.MimeType
   148  	if callerPreview.PreviewMimeType != nil {
   149  		p.PreviewContentType = *callerPreview.PreviewMimeType
   150  	}
   151  	if callerPreview.Metadata != nil {
   152  		typ, err := callerPreview.Metadata.AssetType()
   153  		if err != nil {
   154  			return p, err
   155  		}
   156  		switch typ {
   157  		case chat1.AssetMetadataType_IMAGE:
   158  			p.PreviewDim = &Dimension{
   159  				Width:  callerPreview.Metadata.Image().Width,
   160  				Height: callerPreview.Metadata.Image().Height,
   161  			}
   162  			p.PreviewAudioAmps = callerPreview.Metadata.Image().AudioAmps
   163  		case chat1.AssetMetadataType_VIDEO:
   164  			p.PreviewDurationMs = callerPreview.Metadata.Video().DurationMs
   165  			p.PreviewDim = &Dimension{
   166  				Width:  callerPreview.Metadata.Video().Width,
   167  				Height: callerPreview.Metadata.Video().Height,
   168  			}
   169  		}
   170  	}
   171  	if callerPreview.BaseMetadata != nil {
   172  		typ, err := callerPreview.BaseMetadata.AssetType()
   173  		if err != nil {
   174  			return p, err
   175  		}
   176  		switch typ {
   177  		case chat1.AssetMetadataType_IMAGE:
   178  			p.BaseDim = &Dimension{
   179  				Width:  callerPreview.BaseMetadata.Image().Width,
   180  				Height: callerPreview.BaseMetadata.Image().Height,
   181  			}
   182  		case chat1.AssetMetadataType_VIDEO:
   183  			p.BaseDurationMs = callerPreview.BaseMetadata.Video().DurationMs
   184  			p.BaseIsAudio = callerPreview.BaseMetadata.Video().IsAudio
   185  			p.BaseDim = &Dimension{
   186  				Width:  callerPreview.BaseMetadata.Video().Width,
   187  				Height: callerPreview.BaseMetadata.Video().Height,
   188  			}
   189  		}
   190  	}
   191  	return p, nil
   192  }
   193  
   194  func DetectMIMEType(ctx context.Context, src ReadResetter, filename string) (res string, err error) {
   195  	head := make([]byte, 512)
   196  	_, err = io.ReadFull(src, head)
   197  	switch err {
   198  	case nil:
   199  	case io.EOF, io.ErrUnexpectedEOF:
   200  		return "", nil
   201  	default:
   202  		return res, err
   203  	}
   204  
   205  	res = http.DetectContentType(head)
   206  	if err = src.Reset(); err != nil {
   207  		return res, err
   208  	}
   209  	// MIME type detection failed us, try using an extension map
   210  	if res == "application/octet-stream" {
   211  		ext := strings.ToLower(filepath.Ext(filename))
   212  		if typ, ok := mimeTypes[ext]; ok {
   213  			res = typ
   214  		}
   215  	}
   216  	return res, nil
   217  }
   218  
   219  func PreprocessAsset(ctx context.Context, g *globals.Context, log utils.DebugLabeler, src ReadResetter, filename string,
   220  	nvh types.NativeVideoHelper, callerPreview *chat1.MakePreviewRes) (p Preprocess, err error) {
   221  	if callerPreview != nil && callerPreview.Location != nil {
   222  		log.Debug(ctx, "preprocessAsset: caller provided preview, using that")
   223  		if p, err = processCallerPreview(ctx, g, *callerPreview); err != nil {
   224  			log.Debug(ctx, "preprocessAsset: failed to process caller preview, making fresh one: %s", err)
   225  		} else {
   226  			return p, nil
   227  		}
   228  	}
   229  	defer func() {
   230  		err := src.Reset()
   231  		if err != nil {
   232  			log.Debug(ctx, "preprocessAsset: reset failed: %+v", err)
   233  		}
   234  	}()
   235  
   236  	p.Filename = filename
   237  	p.ContentType, err = DetectMIMEType(ctx, src, filename)
   238  	if err != nil {
   239  		return p, err
   240  	}
   241  
   242  	// Convert heif to jpeg when possible
   243  	if p.ContentType == "image/heif" {
   244  		shouldConvertHEIC, err := utils.GetGregorBool(ctx, g, utils.ConvertHEICGregorKey, true)
   245  		if err != nil {
   246  			return p, err
   247  		}
   248  		if shouldConvertHEIC {
   249  			dat, err := HEICToJPEG(ctx, log, filename)
   250  			if err != nil {
   251  				return p, err
   252  			}
   253  			if dat != nil {
   254  				p.Filename = strings.TrimSuffix(filename, filepath.Ext(filename)) + ".jpeg"
   255  				p.ContentType = "image/jpeg"
   256  				p.SrcDat = dat
   257  
   258  				src = NewBufReadResetter(dat)
   259  			}
   260  		}
   261  	}
   262  	log.Debug(ctx, "preprocessAsset: detected attachment content type %s", p.ContentType)
   263  	previewRes, err := Preview(ctx, log, src, p.ContentType, p.Filename, nvh)
   264  	if err != nil {
   265  		log.Debug(ctx, "preprocessAsset: error making preview: %s", err)
   266  		return p, err
   267  	}
   268  	if previewRes != nil {
   269  		log.Debug(ctx, "preprocessAsset: made preview for attachment asset: content type: %s",
   270  			previewRes.ContentType)
   271  		p.Preview = previewRes.Source
   272  		p.PreviewContentType = previewRes.ContentType
   273  		if previewRes.BaseWidth > 0 || previewRes.BaseHeight > 0 {
   274  			p.BaseDim = &Dimension{Width: previewRes.BaseWidth, Height: previewRes.BaseHeight}
   275  		}
   276  		if previewRes.PreviewWidth > 0 || previewRes.PreviewHeight > 0 {
   277  			p.PreviewDim = &Dimension{Width: previewRes.PreviewWidth, Height: previewRes.PreviewHeight}
   278  		}
   279  		p.BaseDurationMs = previewRes.BaseDurationMs
   280  		p.PreviewDurationMs = previewRes.PreviewDurationMs
   281  	}
   282  
   283  	return p, nil
   284  }