/
githubmirror
/
client
Обзор
Документация
Войти
/
githubmirror
/
client
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
go/chat/attachment_httpsrv.go
970 строк
30 KB
chrisnojima-zoom
Version 670 - clean2 (#29122)
08 июн 2026, 19:31
Не верифицирован
08 июн 2026, 19:31
1943197
Код
Авторство
О чём код?
package chat import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "hash" "io" "net/http" "net/url" "os" "path/filepath" "strings" "sync" "time" "github.com/keybase/client/go/chat/giphy" "github.com/keybase/client/go/chat/storage" "github.com/keybase/client/go/kbhttp/manager" "github.com/keybase/go-codec/codec" lru "github.com/hashicorp/golang-lru" "github.com/keybase/client/go/chat/attachments" "github.com/keybase/client/go/chat/globals" "github.com/keybase/client/go/chat/s3" "github.com/keybase/client/go/chat/types" "github.com/keybase/client/go/chat/utils" "github.com/keybase/client/go/libkb" disklru "github.com/keybase/client/go/lru" "github.com/keybase/client/go/protocol/chat1" "github.com/keybase/client/go/protocol/gregor1" "github.com/keybase/client/go/protocol/keybase1" ) const keyPrefixLen = 2 var blankProgress = func(_, _ int64) {} type AttachmentHTTPSrv struct { sync.Mutex globals.Contextified utils.DebugLabeler endpoint string attachmentPrefix string pendingPrefix string unfurlPrefix string giphyPrefix string giphyGalleryPrefix string giphySelectPrefix string urlMap *lru.Cache fetcher types.AttachmentFetcher ri func() chat1.RemoteInterface httpSrv *manager.Srv hmacPool sync.Pool } var _ types.AttachmentURLSrv = (*AttachmentHTTPSrv)(nil) func NewAttachmentHTTPSrv(g *globals.Context, httpSrv *manager.Srv, fetcher types.AttachmentFetcher, ri func() chat1.RemoteInterface, ) *AttachmentHTTPSrv { l, err := lru.New(2000) if err != nil { panic(err) } token, err := libkb.RandBytes(32) if err != nil { panic(err) } r := &AttachmentHTTPSrv{ Contextified: globals.NewContextified(g), DebugLabeler: utils.NewDebugLabeler(g.ExternalG(), "AttachmentHTTPSrv", false), endpoint: "at", attachmentPrefix: "at", pendingPrefix: "pe", unfurlPrefix: "uf", giphyPrefix: "gf", giphyGalleryPrefix: "gg", giphySelectPrefix: "gs", ri: ri, urlMap: l, fetcher: fetcher, httpSrv: httpSrv, hmacPool: sync.Pool{ New: func() any { return hmac.New(sha256.New, token) }, }, } r.httpSrv.HandleFunc(r.endpoint, manager.SrvTokenModeUnchecked, r.serve) r.fetcher.OnStart(libkb.NewMetaContextTODO(g.ExternalG())) return r } func (r *AttachmentHTTPSrv) OnDbNuke(mctx libkb.MetaContext) error { return r.fetcher.OnDbNuke(mctx) } func (r *AttachmentHTTPSrv) GetAttachmentFetcher() types.AttachmentFetcher { return r.fetcher } func (r *AttachmentHTTPSrv) genURLKey(prefix string, payload any) (string, error) { h := r.hmacPool.Get().(hash.Hash) defer r.hmacPool.Put(h) h.Reset() _, _ = h.Write([]byte(prefix)) var data []byte mh := codec.MsgpackHandle{WriteExt: true} if err := codec.NewEncoderBytes(&data, &mh).Encode(payload); err != nil { return "", err } _, _ = h.Write(data) return prefix + hex.EncodeToString(h.Sum(nil)), nil } func (r *AttachmentHTTPSrv) getURL(ctx context.Context, prefix string, payload any) string { if !r.httpSrv.Active() { r.Debug(ctx, "getURL: http server failed to start earlier") return "" } addr, err := r.httpSrv.Addr() if err != nil { r.Debug(ctx, "getURL: failed to get HTTP server address: %s", err) return "" } key, err := r.genURLKey(prefix, payload) if err != nil { r.Debug(ctx, "getURL: failed to generate URL key: %s", err) return "" } r.urlMap.Add(key, payload) return fmt.Sprintf("http://%s/%s?key=%s", addr, r.endpoint, key) } func (r *AttachmentHTTPSrv) GetURL(ctx context.Context, convID chat1.ConversationID, msgID chat1.MessageID, preview, noAnim, isEmoji bool, ) string { r.Lock() defer r.Unlock() defer r.Trace(ctx, nil, "GetURL(%s,%d)", convID, msgID)() url := r.getURL(ctx, r.attachmentPrefix, chat1.ConversationIDMessageIDPair{ ConvID: convID, MsgID: msgID, }) url += fmt.Sprintf("&prev=%v&noanim=%v&isemoji=%v", preview, noAnim, isEmoji) r.Debug(ctx, "GetURL: handler URL: convID: %s msgID: %d %s", convID, msgID, url) return url } func (r *AttachmentHTTPSrv) GetPendingPreviewURL(ctx context.Context, outboxID chat1.OutboxID) string { defer r.Trace(ctx, nil, "GetPendingPreviewURL(%s)", outboxID)() url := r.getURL(ctx, r.pendingPrefix, outboxID) r.Debug(ctx, "GetPendingPreviewURL: handler URL: outboxID: %s %s", outboxID, url) return url } type unfurlAsset struct { Asset chat1.Asset ConvID chat1.ConversationID } func (r *AttachmentHTTPSrv) GetUnfurlAssetURL(ctx context.Context, convID chat1.ConversationID, asset chat1.Asset, ) string { defer r.Trace(ctx, nil, "GetUnfurlAssetURL")() url := r.getURL(ctx, r.unfurlPrefix, unfurlAsset{ Asset: asset, ConvID: convID, }) r.Debug(ctx, "GetUnfurlAssetURL: handler URL: %s", url) return url } func (r *AttachmentHTTPSrv) GetGiphyURL(ctx context.Context, giphyURL string) string { defer r.Trace(ctx, nil, "GetGiphyURL")() url := r.getURL(ctx, r.giphyPrefix, giphyURL) r.Debug(ctx, "GetGiphyURL: handler URL: %s", url) return url } func (r *AttachmentHTTPSrv) GetGiphyGalleryURL(ctx context.Context, convID chat1.ConversationID, tlfName string, results []chat1.GiphySearchResult, ) string { defer r.Trace(ctx, nil, "GetGiphyGalleryURL")() url := r.getURL(ctx, r.giphyGalleryPrefix, giphyGalleryInfo{ Results: results, ConvID: convID, TlfName: tlfName, }) r.Debug(ctx, "GetGiphyGalleryURL: handler URL: %s", url) return url } func (r *AttachmentHTTPSrv) servePendingPreview(ctx context.Context, w http.ResponseWriter, req *http.Request) { defer r.Trace(ctx, nil, "servePendingPreview")() key := req.URL.Query().Get("key") intOutboxID, ok := r.urlMap.Get(key) if !ok { r.makeError(ctx, w, http.StatusNotFound, "missing key: %s", key) return } outboxID, ok := intOutboxID.(chat1.OutboxID) if !ok { r.makeError(ctx, w, http.StatusBadRequest, "invalid outboxID") return } pre, err := attachments.NewPendingPreviews(r.G()).Get(ctx, outboxID) if err != nil { r.makeError(ctx, w, http.StatusInternalServerError, "error reading preview: %s", err) return } if _, err := io.Copy(w, bytes.NewReader(pre.Preview)); err != nil { r.makeError(ctx, w, http.StatusInternalServerError, "failed to write resposne: %s", err) return } } func (r *AttachmentHTTPSrv) serveUnfurlAsset(ctx context.Context, w http.ResponseWriter, req *http.Request) { defer r.Trace(ctx, nil, "serveUnfurlAsset")() key := req.URL.Query().Get("key") val, ok := r.urlMap.Get(key) if !ok { r.makeError(ctx, w, http.StatusNotFound, "invalid key: %s", key) return } ua := val.(unfurlAsset) if r.shouldServeContent(ctx, ua.Asset, req) { if r.serveUnfurlVideoHostPage(ctx, w, req) { // if we served the host page, just bail out return } r.Debug(ctx, "serveUnfurlAsset: streaming: req: method: %s range: %s", req.Method, req.Header.Get("Range")) rs, err := r.fetcher.StreamAttachment(ctx, ua.ConvID, ua.Asset, r.ri, r) if err != nil { r.makeError(ctx, w, http.StatusInternalServerError, "failed to get streamer: %s", err) return } http.ServeContent(w, req, ua.Asset.Filename, time.Time{}, rs) } else { if err := r.fetcher.FetchAttachment(ctx, w, ua.ConvID, ua.Asset, r.ri, r, blankProgress); err != nil { r.makeError(ctx, w, http.StatusInternalServerError, "failed to fetch attachment: %s", err) return } } } type giphyGalleryInfo struct { Results []chat1.GiphySearchResult ConvID chat1.ConversationID TlfName string } func (r *AttachmentHTTPSrv) getGiphyGallerySelectURL(ctx context.Context, convID chat1.ConversationID, tlfName string, result chat1.GiphySearchResult, ) string { addr, err := r.httpSrv.Addr() if err != nil { r.Debug(ctx, "getGiphySelectURL: failed to get HTTP server address: %s", err) return "" } key, err := r.genURLKey(r.giphySelectPrefix, result) if err != nil { r.Debug(ctx, "getGiphySelectURL: failed to generate URL key: %s", err) return "" } r.urlMap.Add(key, result) return fmt.Sprintf("http://%s/%s?url=%s&convID=%s&tlfName=%s&key=%s", addr, r.endpoint, url.QueryEscape(result.TargetUrl), convID, tlfName, key) } func (r *AttachmentHTTPSrv) serveGiphyGallerySelect(ctx context.Context, w http.ResponseWriter, req *http.Request, ) { defer r.Trace(ctx, nil, "serveGiphyGallerySelect")() url := req.URL.Query().Get("url") strConvID := req.URL.Query().Get("convID") tlfName := req.URL.Query().Get("tlfName") key := req.URL.Query().Get("key") infoInt, ok := r.urlMap.Get(key) if !ok { r.makeError(ctx, w, http.StatusNotFound, "invalid key: %s", key) return } result := infoInt.(chat1.GiphySearchResult) if result.TargetUrl != url { r.makeError(ctx, w, http.StatusNotFound, "invalid key: %s", key) return } convID, err := chat1.MakeConvID(strConvID) if err != nil { r.makeError(context.TODO(), w, http.StatusInternalServerError, "failed to decode convID: %s", err) return } uid := gregor1.UID(r.G().Env.GetUID().ToBytes()) if err := r.G().InboxSource.Draft(ctx, uid, convID, nil); err != nil { r.Debug(ctx, "serveGiphyGallerySelect: failed to clear draft: %s", err) } if err := r.G().ChatHelper.SendTextByID(ctx, convID, tlfName, url, keybase1.TLFVisibility_PRIVATE); err != nil { r.makeError(context.TODO(), w, http.StatusInternalServerError, "failed to send giphy url: %s", err) } ui, err := r.G().UIRouter.GetChatUI() if err == nil && ui != nil { err := ui.ChatGiphyToggleResultWindow(ctx, convID, false, true) if err != nil { r.Debug(ctx, "serveGiphyGallerySelect: failed to toggle giphy: %s", err) } } else { r.Debug(ctx, "serveGiphyGallerySelect: failed to get chat UI: %s", err) } err = storage.NewGiphyStore(r.G()).Put(ctx, uid, result) if err != nil { r.Debug(ctx, "serveGiphyGallerySelect: failed to track giphy select: %s", err) } } func (r *AttachmentHTTPSrv) serveGiphyGallery(ctx context.Context, w http.ResponseWriter, req *http.Request) { defer r.Trace(ctx, nil, "serveGiphyGallery")() key := req.URL.Query().Get("key") infoInt, ok := r.urlMap.Get(key) if !ok { r.makeError(ctx, w, http.StatusNotFound, "invalid key: %s", key) return } galleryInfo := infoInt.(giphyGalleryInfo) var videoStr strings.Builder for _, res := range galleryInfo.Results { fmt.Fprintf(&videoStr, ` <img style="height: 100%%" src="%s" onclick="sendMessage('%s')" /> `, res.PreviewUrl, r.getGiphyGallerySelectURL(ctx, galleryInfo.ConvID, galleryInfo.TlfName, res)) } res := fmt.Sprintf(` <html> <head> <title>Keybase Giphy Gallery</title> <script> window.sendMessage = function(url) { var req = new XMLHttpRequest(); req.open("GET", url); req.send(); } </script> </head> <body style="margin: 0px;"> <div style="display: flex; flex-direction: row; height: 100%%; overflow-x: auto; overflow-y: hidden; flex-wrap: nowrap; -webkit-overflow-scrolling: touch; border-top: 1px solid rgba(0, 0, 0, 0.20); align-items: flex-end;"> %s </div> </body> </html>`, videoStr.String()) if _, err := io.WriteString(w, res); err != nil { r.makeError(context.TODO(), w, http.StatusInternalServerError, "failed to write giphy gallery: %s", err) } } func (r *AttachmentHTTPSrv) serveGiphyLink(ctx context.Context, w http.ResponseWriter, req *http.Request) { defer r.Trace(ctx, nil, "serveGiphyLink")() key := req.URL.Query().Get("key") val, ok := r.urlMap.Get(key) if !ok { r.makeError(ctx, w, http.StatusNotFound, "invalid key: %s", key) return } // Grab range headers rangeHeader := req.Header.Get("Range") client := giphy.AssetClient(libkb.NewMetaContext(ctx, r.G().GlobalContext)) url, err := giphy.ProxyURL(val.(string)) if err != nil { r.makeError(ctx, w, http.StatusInternalServerError, "url creation: %s", err) return } giphyReq, err := http.NewRequest("GET", url, nil) if err != nil { r.makeError(ctx, w, http.StatusInternalServerError, "request creation: %s", err) return } if len(rangeHeader) > 0 { giphyReq.Header.Add("Range", rangeHeader) } giphyReq.Host = giphy.MediaHost resp, err := client.Do(giphyReq) if err != nil { status := http.StatusInternalServerError if resp != nil { status = resp.StatusCode } r.makeError(ctx, w, status, "failed to get read giphy link: %s", err) return } defer resp.Body.Close() for k := range resp.Header { w.Header().Add(k, resp.Header.Get(k)) } if _, err := io.Copy(w, resp.Body); err != nil { r.makeError(ctx, w, resp.StatusCode, "failed to write giphy data: %s", err) return } } func (r *AttachmentHTTPSrv) makeError(ctx context.Context, w http.ResponseWriter, code int, msg string, args ...any, ) { r.Debug(ctx, "serve: error code: %d msg %s", code, fmt.Sprintf(msg, args...)) w.WriteHeader(code) } func (r *AttachmentHTTPSrv) shouldServeContent(ctx context.Context, asset chat1.Asset, req *http.Request) bool { noStream := req.URL.Query().Get("nostream") == "true" if noStream { // If we just want the bits without streaming return false } return strings.HasPrefix(asset.MimeType, "video") } func (r *AttachmentHTTPSrv) serveUnfurlVideoHostPage(ctx context.Context, w http.ResponseWriter, req *http.Request) bool { contentForce := req.URL.Query().Get("contentforce") == "true" if r.G().IsMobileAppType() && !contentForce { r.Debug(ctx, "serveUnfurlVideoHostPage: mobile client detected, showing the HTML video viewer") w.Header().Set("Content-Type", "text/html") autoplay := "" if req.URL.Query().Get("autoplay") != "true" { autoplay = `onloadeddata="togglePlay('pause')"` } if _, err := fmt.Fprintf(w, ` <html> <head> <meta name="viewport" content="initial-scale=1, viewport-fit=cover"> <title>Keybase Video Viewer</title> <script> window.playVideo = function(data) { var vid = document.getElementById("vid"); vid.play() } window.togglePlay = function(data) { var vid = document.getElementById("vid"); if (data === "play") { vid.play(); } else { vid.pause(); } } </script> </head> <body style="margin: 0px; background-color: rgba(0,0,0,0.05)"> <video id="vid" %s preload="auto" style="width: 100%%; height: 100%%; border-radius: 4px; object-fit:fill" src="%s" playsinline webkit-playsinline loop autoplay muted /> </body> </html> `, autoplay, req.URL.String()+"&contentforce=true"); err != nil { r.Debug(ctx, "serveUnfurlVideoHostPage: failed to write HTML video player: %s", err) } return true } return false } func (r *AttachmentHTTPSrv) serveVideoHostPage(ctx context.Context, w http.ResponseWriter, req *http.Request) bool { contentForce := req.URL.Query().Get("contentforce") == "true" if r.G().IsMobileAppType() && !contentForce { r.Debug(ctx, "serve: mobile client detected, showing the HTML video viewer") w.Header().Set("Content-Type", "text/html") if _, err := fmt.Fprintf(w, ` <html> <head> <meta name="viewport" content="initial-scale=1, viewport-fit=cover"> <title>Keybase Video Viewer</title> <script> window.togglePlay = function(data) { var vid = document.getElementById("vid"); if (data === "play") { vid.play(); vid.setAttribute('controls', 'controls'); } else { vid.pause(); vid.removeAttribute('controls'); } } </script> </head> <body style="margin: 0px;"> <video id="vid" style="width: 100%%; height: 100%%; object-fit:fill; border-radius: 4px" poster="%s" src="%s" preload="none" playsinline webkit-playsinline /> </body> </html> `, req.URL.Query().Get("poster"), req.URL.String()+"&contentforce=true"); err != nil { r.Debug(ctx, "serve: failed to write HTML video player: %s", err) } return true } return false } func (r *AttachmentHTTPSrv) serveAttachment(ctx context.Context, w http.ResponseWriter, req *http.Request) { defer r.Trace(ctx, nil, "serveAttachment")() preview := req.URL.Query().Get("prev") == "true" noAnim := req.URL.Query().Get("noanim") == "true" isEmoji := req.URL.Query().Get("isemoji") == "true" key := req.URL.Query().Get("key") r.Lock() pairInt, ok := r.urlMap.Get(key) r.Unlock() if !ok { r.makeError(ctx, w, http.StatusNotFound, "key not found in URL map") return } pair := pairInt.(chat1.ConversationIDMessageIDPair) uid := gregor1.UID(r.G().Env.GetUID().ToBytes()) r.Debug(ctx, "serveAttachment: convID: %s msgID: %d", pair.ConvID, pair.MsgID) asset, err := attachments.AssetFromMessage(ctx, r.G(), uid, pair.ConvID, pair.MsgID, preview) if err != nil { r.makeError(ctx, w, http.StatusInternalServerError, "failed to get asset: %s", err) return } if len(asset.Path) == 0 { r.makeError(ctx, w, http.StatusNotFound, "attachment not uploaded yet, no path") return } if isEmoji && !r.G().EmojiSource.IsValidSize(asset.Size) { r.makeError(ctx, w, http.StatusBadRequest, "%v", fmt.Errorf("emoji incorrectly sized: %d", asset.Size)) return } r.Debug(ctx, "serveAttachment: setting content-type: %s sz: %d", asset.MimeType, asset.Size) w.Header().Set("Content-Type", asset.MimeType) if r.shouldServeContent(ctx, asset, req) { if r.serveVideoHostPage(ctx, w, req) { // if we served the host page, just bail out return } r.Debug(ctx, "serveAttachment: streaming: req: method: %s range: %s", req.Method, req.Header.Get("Range")) rs, err := r.fetcher.StreamAttachment(ctx, pair.ConvID, asset, r.ri, r) if err != nil { r.makeError(ctx, w, http.StatusInternalServerError, "failed to get streamer: %s", err) return } http.ServeContent(w, req, asset.Filename, time.Time{}, rs) } else { // no animation mode is intended to transform GIF images into single frame versions if noAnim { var buf bytes.Buffer if err := r.fetcher.FetchAttachment(ctx, &buf, pair.ConvID, asset, r.ri, r, blankProgress); err != nil { r.makeError(ctx, w, http.StatusInternalServerError, "failed to fetch attachment: %s", err) return } bufReader := attachments.NewBufReadResetter(buf.Bytes()) if err := attachments.GIFToPNG(ctx, bufReader, w); err != nil { r.Debug(ctx, "serveAttachment: not a gif in no animation mode: %s", err) _ = bufReader.Reset() if _, err := io.Copy(w, bufReader); err != nil { r.makeError(ctx, w, http.StatusInternalServerError, "failed to write attachment: %s", err) return } } } else { if err := r.fetcher.FetchAttachment(ctx, w, pair.ConvID, asset, r.ri, r, blankProgress); err != nil { r.makeError(ctx, w, http.StatusInternalServerError, "failed to fetch attachment: %s", err) return } } } } func (r *AttachmentHTTPSrv) serve(w http.ResponseWriter, req *http.Request) { ctx := globals.ChatCtx(context.Background(), r.G(), keybase1.TLFIdentifyBehavior_CHAT_GUI, nil, NewSimpleIdentifyNotifier(r.G())) defer r.Trace(ctx, nil, "serve")() addr, err := r.httpSrv.Addr() if err != nil { r.Debug(ctx, "serve: failed to get HTTP server address: %s", err) r.makeError(ctx, w, http.StatusInternalServerError, "unable to determine addr") return } if req.Host != addr { r.Debug(ctx, "Host %s didn't match addr %s, failing request to protect against DNS rebinding", req.Host, addr) r.makeError(ctx, w, http.StatusBadRequest, "invalid host") return } key := req.URL.Query().Get("key") if len(key) < keyPrefixLen { r.makeError(ctx, w, http.StatusNotFound, "invalid key") return } if _, ok := r.urlMap.Get(key); !ok { r.makeError(ctx, w, http.StatusNotFound, "invalid key") return } prefix := key[:keyPrefixLen] switch prefix { case r.unfurlPrefix: r.serveUnfurlAsset(ctx, w, req) case r.giphyPrefix: r.serveGiphyLink(ctx, w, req) case r.giphyGalleryPrefix: r.serveGiphyGallery(ctx, w, req) case r.giphySelectPrefix: r.serveGiphyGallerySelect(ctx, w, req) case r.pendingPrefix: r.servePendingPreview(ctx, w, req) case r.attachmentPrefix: r.serveAttachment(ctx, w, req) default: r.makeError(ctx, w, http.StatusBadRequest, "invalid key prefix") } } // Sign implements github.com/keybase/go/chat/s3.Signer interface. func (r *AttachmentHTTPSrv) Sign(payload []byte) ([]byte, error) { arg := chat1.S3SignArg{ Payload: payload, Version: 1, TempCreds: true, } return r.ri().S3Sign(context.Background(), arg) } type RemoteAttachmentFetcher struct { globals.Contextified utils.DebugLabeler store attachments.Store } var _ types.AttachmentFetcher = (*RemoteAttachmentFetcher)(nil) func NewRemoteAttachmentFetcher(g *globals.Context, store attachments.Store) *RemoteAttachmentFetcher { return &RemoteAttachmentFetcher{ Contextified: globals.NewContextified(g), DebugLabeler: utils.NewDebugLabeler(g.ExternalG(), "RemoteAttachmentFetcher", false), store: store, } } func (r *RemoteAttachmentFetcher) StreamAttachment(ctx context.Context, convID chat1.ConversationID, asset chat1.Asset, ri func() chat1.RemoteInterface, signer s3.Signer, ) (res io.ReadSeeker, err error) { defer r.Trace(ctx, &err, "StreamAttachment")() // Grab S3 params for the conversation s3params, err := ri().GetS3Params(ctx, chat1.GetS3ParamsArg{ ConversationID: convID, TempCreds: true, }) if err != nil { return nil, err } return r.store.StreamAsset(ctx, s3params, asset, signer) } func (r *RemoteAttachmentFetcher) FetchAttachment(ctx context.Context, w io.Writer, convID chat1.ConversationID, asset chat1.Asset, ri func() chat1.RemoteInterface, signer s3.Signer, progress types.ProgressReporter, ) (err error) { defer r.Trace(ctx, &err, "FetchAttachment")() // Grab S3 params for the conversation s3params, err := ri().GetS3Params(ctx, chat1.GetS3ParamsArg{ ConversationID: convID, TempCreds: true, }) if err != nil { return err } return r.store.DownloadAsset(ctx, s3params, asset, w, signer, progress) } func (r *RemoteAttachmentFetcher) DeleteAssets(ctx context.Context, convID chat1.ConversationID, assets []chat1.Asset, ri func() chat1.RemoteInterface, signer s3.Signer, ) (err error) { defer r.Trace(ctx, &err, "DeleteAssets")() if len(assets) == 0 { return nil } // get s3 params from server s3params, err := ri().GetS3Params(ctx, chat1.GetS3ParamsArg{ ConversationID: convID, TempCreds: true, }) if err != nil { r.Debug(ctx, "error getting s3params: %s", err) return err } // Try to delete the assets remotely if err := r.store.DeleteAssets(ctx, s3params, signer, assets); err != nil { // there's no way to get asset information after this point. // any assets not deleted will be stranded on s3. r.Debug(ctx, "error deleting assets: %s", err) } r.Debug(ctx, "deleted %d assets", len(assets)) return nil } func (r *RemoteAttachmentFetcher) PutUploadedAsset(ctx context.Context, filename string, asset chat1.Asset) error { return nil } func (r *RemoteAttachmentFetcher) IsAssetLocal(ctx context.Context, asset chat1.Asset) (bool, error) { return false, nil } func (r *RemoteAttachmentFetcher) OnDbNuke(mctx libkb.MetaContext) error { return nil } func (r *RemoteAttachmentFetcher) OnStart(mctx libkb.MetaContext) {} type CachingAttachmentFetcher struct { globals.Contextified utils.DebugLabeler store attachments.Store diskLRU *disklru.DiskLRU // testing tempDir string } var _ types.AttachmentFetcher = (*CachingAttachmentFetcher)(nil) func NewCachingAttachmentFetcher(g *globals.Context, store attachments.Store, size int) *CachingAttachmentFetcher { return &CachingAttachmentFetcher{ Contextified: globals.NewContextified(g), DebugLabeler: utils.NewDebugLabeler(g.ExternalG(), "CachingAttachmentFetcher", false), store: store, diskLRU: disklru.NewDiskLRU("attachments", 2, size), } } func (c *CachingAttachmentFetcher) getBaseDir() string { baseDir := c.G().GetCacheDir() if len(c.tempDir) > 0 { baseDir = c.tempDir } return baseDir } func (c *CachingAttachmentFetcher) getCacheDir() string { return filepath.Join(c.getBaseDir(), "attachments") } func (c *CachingAttachmentFetcher) getFullFilename(name string) string { return name + ".attachment" } func (c *CachingAttachmentFetcher) closeFile(f io.Closer) { if f != nil { f.Close() } } func (c *CachingAttachmentFetcher) cacheKey(asset chat1.Asset) string { return asset.Path } func (c *CachingAttachmentFetcher) createAttachmentFile(ctx context.Context) (*os.File, error) { err := os.MkdirAll(c.getCacheDir(), os.ModePerm) if err != nil { return nil, err } file, err := os.CreateTemp(c.getCacheDir(), "att") file.Close() if err != nil { return nil, err } path := c.getFullFilename(file.Name()) if err := os.Rename(file.Name(), path); err != nil { return nil, err } return os.OpenFile(path, os.O_RDWR, os.ModeAppend) } // normalizeFilenameFromCache substitutes the existing cache dir value into the // file path since it's possible for the path to the cache dir to change, // especially on mobile. func (c *CachingAttachmentFetcher) normalizeFilenameFromCache(file string) string { dir := filepath.Base(filepath.Dir(file)) file = filepath.Base(file) // some attachments may be in the "uploadedpreviews"/"uploadedfulls" dirs, // so we preserve the parent directory here. return filepath.Join(c.getBaseDir(), dir, file) } func (c *CachingAttachmentFetcher) localAssetPath(ctx context.Context, asset chat1.Asset) (found bool, path string, err error) { found, entry, err := c.diskLRU.Get(ctx, c.G(), c.cacheKey(asset)) if err != nil { return found, path, err } if found { path = c.normalizeFilenameFromCache(entry.Value.(string)) } return found, path, nil } func (c *CachingAttachmentFetcher) StreamAttachment(ctx context.Context, convID chat1.ConversationID, asset chat1.Asset, ri func() chat1.RemoteInterface, signer s3.Signer, ) (res io.ReadSeeker, err error) { defer c.Trace(ctx, &err, "StreamAttachment")() return NewRemoteAttachmentFetcher(c.G(), c.store).StreamAttachment(ctx, convID, asset, ri, signer) } func (c *CachingAttachmentFetcher) FetchAttachment(ctx context.Context, w io.Writer, convID chat1.ConversationID, asset chat1.Asset, ri func() chat1.RemoteInterface, signer s3.Signer, progress types.ProgressReporter, ) (err error) { defer c.Trace(ctx, &err, "FetchAttachment")() // Check for a disk cache hit, and decrypt that onto the response stream found, path, err := c.localAssetPath(ctx, asset) if err != nil { return err } if found { c.Debug(ctx, "FetchAttachment: cache hit for: %s filepath: %s", asset.Path, path) fileReader, err := os.Open(path) defer c.closeFile(fileReader) if err != nil { c.Debug(ctx, "FetchAttachment: failed to read cached file, removing: %s", err) os.Remove(path) _ = c.diskLRU.Remove(ctx, c.G(), c.cacheKey(asset)) found = false } if found { return c.store.DecryptAsset(ctx, w, fileReader, asset, progress) } } // Grab S3 params for the conversation s3params, err := ri().GetS3Params(ctx, chat1.GetS3ParamsArg{ ConversationID: convID, TempCreds: true, }) if err != nil { return err } // Create a reader to the remote ciphertext remoteReader, err := c.store.GetAssetReader(ctx, s3params, asset, signer) defer c.closeFile(remoteReader) if err != nil { return err } // Create a file we can write the ciphertext into fileWriter, err := c.createAttachmentFile(ctx) defer c.closeFile(fileWriter) if err != nil { return err } // Read out the ciphertext into the decryption copier, and simultaneously write // into the cached file (the ciphertext) teeReader := io.TeeReader(remoteReader, fileWriter) if err := c.store.DecryptAsset(ctx, w, teeReader, asset, progress); err != nil { c.Debug(ctx, "FetchAttachment: error reading asset: %s", err) c.closeFile(fileWriter) os.Remove(fileWriter.Name()) return err } // commit to the on disk LRU return c.putFileInLRU(ctx, fileWriter.Name(), asset) } func (c *CachingAttachmentFetcher) putFileInLRU(ctx context.Context, filename string, asset chat1.Asset) error { // Add an entry to the disk LRU mapping the asset path to the local path, and remove // the remnants of any evicted attachments. evicted, err := c.diskLRU.Put(ctx, c.G(), c.cacheKey(asset), filename) if err != nil { return err } if evicted != nil { path := c.normalizeFilenameFromCache(evicted.Value.(string)) os.Remove(path) } return nil } func (c *CachingAttachmentFetcher) IsAssetLocal(ctx context.Context, asset chat1.Asset) (found bool, err error) { defer c.Trace(ctx, &err, "IsAssetLocal")() found, path, err := c.localAssetPath(ctx, asset) if err != nil { return false, err } if !found { return false, nil } fileReader, err := os.Open(path) defer c.closeFile(fileReader) if err != nil { return false, nil } return true, nil } func (c *CachingAttachmentFetcher) PutUploadedAsset(ctx context.Context, filename string, asset chat1.Asset) (err error) { defer c.Trace(ctx, &err, "PutUploadedAsset")() return c.putFileInLRU(ctx, filename, asset) } func (c *CachingAttachmentFetcher) DeleteAssets(ctx context.Context, convID chat1.ConversationID, assets []chat1.Asset, ri func() chat1.RemoteInterface, signer s3.Signer, ) (err error) { defer c.Trace(ctx, &err, "DeleteAssets")() if len(assets) == 0 { return nil } // Delete the assets locally for _, asset := range assets { found, path, err := c.localAssetPath(ctx, asset) if err != nil { c.Debug(ctx, "error getting asset: %s", err) continue } if found { os.Remove(path) _ = c.diskLRU.Remove(ctx, c.G(), c.cacheKey(asset)) } } // get s3 params from server s3params, err := ri().GetS3Params(ctx, chat1.GetS3ParamsArg{ ConversationID: convID, TempCreds: true, }) if err != nil { c.Debug(ctx, "error getting s3params: %s", err) return err } // Try to delete the assets remotely if err := c.store.DeleteAssets(ctx, s3params, signer, assets); err != nil { // there's no way to get asset information after this point. // any assets not deleted will be stranded on s3. c.Debug(ctx, "error deleting assets: %s", err) } c.Debug(ctx, "deleted %d assets", len(assets)) return nil } func (c *CachingAttachmentFetcher) OnStart(mctx libkb.MetaContext) { mctx, cancel := mctx.WithContextCancel() mctx.G().PushShutdownHook(func(libkb.MetaContext) error { cancel() return nil }) go disklru.CleanOutOfSyncWithDelay(mctx, c.diskLRU, c.getCacheDir(), 10*time.Second) } func (c *CachingAttachmentFetcher) OnDbNuke(mctx libkb.MetaContext) error { if c.diskLRU != nil { if err := c.diskLRU.CleanOutOfSync(mctx, c.getCacheDir()); err != nil { c.Debug(mctx.Ctx(), "unable to run clean: %v", err) } } return nil }