diff --git a/bridge/config/config.go b/bridge/config/config.go index 37f37c7a2..c5a61b6f4 100644 --- a/bridge/config/config.go +++ b/bridge/config/config.go @@ -216,6 +216,13 @@ type Protocol struct { VerboseJoinPart bool // IRC WebhookBindAddress string // mattermost, slack WebhookURL string // mattermost, slack + + S3Endpoint string // general, mediaserver configuration + S3AccessKey string // general, mediaserver configuration + S3SecretKey string // general, mediaserver configuration + S3ForcePathStyle bool // general, mediaserver configuration + S3Presign bool // general, mediaserver configuration + S3Region string // general, mediaserver configuration } type ChannelOptions struct { diff --git a/docs/advanced/mediaserver.md b/docs/advanced/mediaserver.md index 2f2bbc82b..d626553f3 100644 --- a/docs/advanced/mediaserver.md +++ b/docs/advanced/mediaserver.md @@ -6,10 +6,44 @@ There are 2 options to set this up: * You already have a webserver running * Matterbridge runs on the same server see [local download](#use-local-download) * Matterbridge runs on another server. If the webserver is using caddy, see [caddy](#use-remote-upload) +* You already have a S3-compatible storage, then you could use [S3](#s3-minio--remote-upload-using-s3-compatible-storage) * You don't have a webserver running * See [caddy](#use-remote-upload) # Use remote upload + +# S3 (MinIO) — remote upload using S3-compatible storage + +Matterbridge can upload media to S3-compatible object stores. This is useful when you want a hosted, scalable Mediaserver and you have (or run) an S3 endpoint such as MinIO. + +Key points +* Use an s3:// bucket path for uploads; Matterbridge will put objects into that bucket and return URLs based on MediaServerDownload (or the S3 public endpoint). Additional part in s3:// are treated as a prefix to a file (same behaviour as in `awscli-v2`) +* For MinIO you normally enable path-style requests and provide the endpoint + credentials. + +Sample matterbridge configuration (in [general]) +``` +[general] +# tell matterbridge to upload to the bucket +MediaServerUpload="s3://matterbridge" +# public URL base where objects will be served from (path style, this will be treated as a prefix to URL) +MediaServerDownload="https://minio.example.com/matterbridge" + +# S3 / MinIO connection settings (common names used by many S3 clients) +S3Endpoint="https://minio.example.com:9000" +S3AccessKey="minioadmin" +S3SecretKey="minioadmin" +# MinIO typically requires path style for bucket paths +S3ForcePathStyle=true +# To use presigned URLs instead of public buckets. Presigned URL will be valid for 7 days. +# when using this setting MediaServerDownload is ignored. +# Please note that this produces awful, long links. +S3Presign=false +``` + +Notes and recommendations +* Public buckets are required for links to work, since users need to have permission to read files that were submitted by matterbridge. +* Adjust credentials and endpoints for your environment. For MinIO on a nonstandard port or local testing, use the correct host:port in S3Endpoint and in MediaServerDownload. + ## Caddy In this case we're using caddy for upload/downloading media. Caddy has automatic https support, so I'm going to describe this for https only. diff --git a/gateway/gateway.go b/gateway/gateway.go index a4b8759e9..4f3e352f4 100644 --- a/gateway/gateway.go +++ b/gateway/gateway.go @@ -1,6 +1,7 @@ package gateway import ( + "errors" "fmt" "io/ioutil" "os" @@ -22,6 +23,7 @@ type Gateway struct { config.Config Router *Router + mediaServer mediaServer MyConfig *config.Gateway Bridges map[string]*bridge.Bridge Channels map[string]*config.ChannelInfo @@ -46,15 +48,28 @@ const apiProtocol = "api" func New(rootLogger *logrus.Logger, cfg *config.Gateway, r *Router) *Gateway { logger := rootLogger.WithFields(logrus.Fields{"prefix": "gateway"}) + mediaServerInstance, err := createMediaServer(r.BridgeValues(), rootLogger.WithField("prefix", "mediaserver")) + if err != nil { + if errors.Is(err, ErrMediaConfigurationNotWanted) { + // media server not wanted, ignore + mediaServerInstance = nil + + logger.Info("Media server not configured, skipping media server setup") + } else { + logger.WithError(err).Error("Failed to configure media server for gateway") + } + } + cache, _ := lru.New(5000) gw := &Gateway{ - Channels: make(map[string]*config.ChannelInfo), - Message: r.Message, - Router: r, - Bridges: make(map[string]*bridge.Bridge), - Config: r.Config, - Messages: cache, - logger: logger, + Channels: make(map[string]*config.ChannelInfo), + Message: r.Message, + Router: r, + Bridges: make(map[string]*bridge.Bridge), + Config: r.Config, + Messages: cache, + logger: logger, + mediaServer: mediaServerInstance, } if err := gw.AddConfig(cfg); err != nil { logger.Errorf("Failed to add configuration to gateway: %#v", err) diff --git a/gateway/handlers.go b/gateway/handlers.go index 2f2610384..bbe56a15b 100644 --- a/gateway/handlers.go +++ b/gateway/handlers.go @@ -1,16 +1,11 @@ package gateway import ( - "bytes" "crypto/sha1" //nolint:gosec "fmt" - "io/ioutil" - "net/http" - "os" "path/filepath" "regexp" "strings" - "time" "github.com/matterbridge-org/matterbridge/bridge" "github.com/matterbridge-org/matterbridge/bridge/config" @@ -73,8 +68,7 @@ func (gw *Gateway) handleFiles(msg *config.Message) { // If we don't have a attachfield or we don't have a mediaserver configured return if msg.Extra == nil || - (gw.BridgeValues().General.MediaServerUpload == "" && - gw.BridgeValues().General.MediaDownloadPath == "") { + gw.mediaServer == nil { return } @@ -92,78 +86,23 @@ func (gw *Gateway) handleFiles(msg *config.Message) { sha1sum := fmt.Sprintf("%x", sha1.Sum(*fi.Data))[:8] //nolint:gosec - if gw.BridgeValues().General.MediaServerUpload != "" { - // Use MediaServerUpload. Upload using a PUT HTTP request and basicauth. - if err := gw.handleFilesUpload(&fi); err != nil { - gw.logger.Error(err) - continue - } - } else { - // Use MediaServerPath. Place the file on the current filesystem. - if err := gw.handleFilesLocal(&fi); err != nil { - gw.logger.Error(err) - continue - } + downloadURL, err := gw.mediaServer.handleFilesUpload(&fi) + if err != nil { + gw.logger.Error(err) + continue } // Download URL. - durl := gw.BridgeValues().General.MediaServerDownload + "/" + sha1sum + "/" + fi.Name - - gw.logger.Debugf("mediaserver download URL = %s", durl) + gw.logger.Debugf("mediaserver download URL = %s", downloadURL) // We uploaded/placed the file successfully. Add the SHA and URL. extra := msg.Extra["file"][i].(config.FileInfo) - extra.URL = durl + extra.URL = downloadURL extra.SHA = sha1sum msg.Extra["file"][i] = extra } } -// handleFilesUpload uses MediaServerUpload configuration to upload the file. -// Returns error on failure. -func (gw *Gateway) handleFilesUpload(fi *config.FileInfo) error { - client := &http.Client{ - Timeout: time.Second * 5, - } - // Use MediaServerUpload. Upload using a PUT HTTP request and basicauth. - sha1sum := fmt.Sprintf("%x", sha1.Sum(*fi.Data))[:8] //nolint:gosec - url := gw.BridgeValues().General.MediaServerUpload + "/" + sha1sum + "/" + fi.Name - - req, err := http.NewRequest("PUT", url, bytes.NewReader(*fi.Data)) - if err != nil { - return fmt.Errorf("mediaserver upload failed, could not create request: %#v", err) - } - - gw.logger.Debugf("mediaserver upload url: %s", url) - - req.Header.Set("Content-Type", "binary/octet-stream") - _, err = client.Do(req) - if err != nil { - return fmt.Errorf("mediaserver upload failed, could not Do request: %#v", err) - } - return nil -} - -// handleFilesLocal use MediaServerPath configuration, places the file on the current filesystem. -// Returns error on failure. -func (gw *Gateway) handleFilesLocal(fi *config.FileInfo) error { - sha1sum := fmt.Sprintf("%x", sha1.Sum(*fi.Data))[:8] //nolint:gosec - dir := gw.BridgeValues().General.MediaDownloadPath + "/" + sha1sum - err := os.Mkdir(dir, os.ModePerm) - if err != nil && !os.IsExist(err) { - return fmt.Errorf("mediaserver path failed, could not mkdir: %s %#v", err, err) - } - - path := dir + "/" + fi.Name - gw.logger.Debugf("mediaserver path placing file: %s", path) - - err = ioutil.WriteFile(path, *fi.Data, os.ModePerm) - if err != nil { - return fmt.Errorf("mediaserver path failed, could not writefile: %s %#v", err, err) - } - return nil -} - // ignoreEvent returns true if we need to ignore this event for the specified destination bridge. func (gw *Gateway) ignoreEvent(event string, dest *bridge.Bridge) bool { switch event { diff --git a/gateway/mediaserver.go b/gateway/mediaserver.go new file mode 100644 index 000000000..e930760aa --- /dev/null +++ b/gateway/mediaserver.go @@ -0,0 +1,280 @@ +package gateway + +import ( + "bytes" + "context" + "crypto/sha1" //nolint:gosec + "errors" + "fmt" + "net/http" + "net/url" + "os" + "path" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/smithy-go/logging" + + "github.com/matterbridge-org/matterbridge/bridge/config" + "github.com/sirupsen/logrus" +) + +type mediaServer interface { + handleFilesUpload(fi *config.FileInfo) (string, error) +} + +type commonMediaServer struct { + logger *logrus.Entry +} + +type httpPutMediaServer struct { + commonMediaServer + + httpUploadPath string + httpDownloadPrefix string +} + +type localMediaServer struct { + commonMediaServer + + localPath string + httpDownloadPrefix string +} + +type s3MediaServer struct { + commonMediaServer + + s3Client *s3.Client + presignS3Client *s3.PresignClient + + bucket string + uploadPrefix string + httpDownloadPrefix string +} + +var _ mediaServer = (*httpPutMediaServer)(nil) +var _ mediaServer = (*localMediaServer)(nil) +var _ mediaServer = (*s3MediaServer)(nil) + +const mediaUploadTimeout = 5 * time.Second +const mediaUploadPresignDuration = 7 * 24 * time.Hour // presigned URL valid duration + +var ErrMediaConfiguration = errors.New("media server is not properly configured") +var ErrMediaConfigurationNotWanted = errors.New("media server is not configured and not wanted") + +var ErrMediaServerRuntime = errors.New("media server error") +var errUploadFailed = fmt.Errorf("%w: upload failed", ErrMediaServerRuntime) + +func createS3MediaServer(bg *config.BridgeValues, bucketName string, uploadPrefix string, logger *logrus.Entry) (*s3MediaServer, error) { + if bucketName == "" { + return nil, fmt.Errorf("%w: invalid s3 upload prefix, must be in format s3://bucketname/prefix", ErrMediaConfiguration) + } + + if bg.General.S3Endpoint == "" { + return nil, fmt.Errorf("%w: s3 endpoint is not configured", ErrMediaConfiguration) + } + + if bg.General.S3AccessKey == "" { + return nil, fmt.Errorf("%w: s3 access key is not configured", ErrMediaConfiguration) + } + + if bg.General.S3SecretKey == "" { + return nil, fmt.Errorf("%w: s3 secret key is not configured", ErrMediaConfiguration) + } + + if bg.General.S3Region == "" { + return nil, fmt.Errorf("%w: s3 region is not configured", ErrMediaConfiguration) + } + + uploadPrefix = strings.Trim(uploadPrefix, "/") + + client := s3.NewFromConfig(aws.Config{ + Region: bg.General.S3Region, + Credentials: credentials.NewStaticCredentialsProvider(bg.General.S3AccessKey, bg.General.S3SecretKey, ""), + Logger: logging.Nop{}, + BaseEndpoint: aws.String(bg.General.S3Endpoint), + HTTPClient: &http.Client{Timeout: mediaUploadTimeout}, + }, func(o *s3.Options) { + o.UsePathStyle = bg.General.S3ForcePathStyle + }) + + var presignClient *s3.PresignClient + if bg.General.S3Presign { + presignClient = s3.NewPresignClient(client) + } + + // This will return an error if the bucket does not exist + headBucketResult, err := client.HeadBucket(context.TODO(), &s3.HeadBucketInput{Bucket: aws.String(bucketName)}) + if err != nil { + return nil, fmt.Errorf("%w: failed to check if bucket exists: %w", ErrMediaServerRuntime, err) + } + + logger.WithFields(logrus.Fields{ + "bucket": bucketName, + "uploadPrefix": uploadPrefix, + "baseUrl": bg.General.S3Endpoint, + "pathStyle": bg.General.S3ForcePathStyle, + "headBucketResult": headBucketResult, + }).Debug("checked destination bucket") + + return &s3MediaServer{ + commonMediaServer: commonMediaServer{ + logger: logger, + }, + + s3Client: client, + presignS3Client: presignClient, + + bucket: bucketName, + uploadPrefix: uploadPrefix, + httpDownloadPrefix: bg.General.MediaServerDownload, + }, nil +} + +func createMediaServer(bg *config.BridgeValues, logger *logrus.Entry) (mediaServer, error) { + if bg.General.MediaServerUpload == "" && bg.General.MediaDownloadPath == "" { + return nil, ErrMediaConfigurationNotWanted // we don't have a attachfield or we don't have a mediaserver configured return + } + + if bg.General.MediaServerUpload != "" { + parsed, err := url.Parse(bg.General.MediaServerUpload) + if err != nil { + return nil, fmt.Errorf("%w: invalid media server upload URL: %w", ErrMediaConfiguration, err) + } + + if parsed.Scheme == "http" || parsed.Scheme == "https" { + return &httpPutMediaServer{ + commonMediaServer: commonMediaServer{ + logger: logger.WithField("component", "httpputmediaserver"), + }, + + httpUploadPath: bg.General.MediaServerUpload, + httpDownloadPrefix: bg.General.MediaServerDownload, + }, nil + } + + if parsed.Scheme == "s3" { + s3MediaServer, err := createS3MediaServer(bg, parsed.Host, parsed.Path, logger.WithField("component", "s3mediaserver")) + if err == nil { + return s3MediaServer, nil + } + + return nil, fmt.Errorf("%w: %w", ErrMediaConfiguration, err) + } + + return nil, fmt.Errorf("%w: unknown schema (protocol) for mediaServerUpload: '%s'", ErrMediaConfiguration, parsed.Scheme) + } + + if bg.General.MediaDownloadPath != "" { + return &localMediaServer{ + commonMediaServer: commonMediaServer{ + logger: logger.WithField("component", "localmediaserver"), + }, + + localPath: bg.General.MediaDownloadPath, + httpDownloadPrefix: bg.General.MediaServerDownload, + }, nil + } + + return nil, ErrMediaConfigurationNotWanted // never reached +} + +// handleFilesUpload which uses MediaServerUpload configuration to upload the file via HTTP PUT request. +// Returns error on failure. +func (h *httpPutMediaServer) handleFilesUpload(fi *config.FileInfo) (string, error) { + client := &http.Client{ + Timeout: mediaUploadTimeout, + } + // Use MediaServerUpload. Upload using a PUT HTTP request and basicauth. + sha1sum := fmt.Sprintf("%x", sha1.Sum(*fi.Data))[:8] //nolint:gosec + uploadUrl := h.httpUploadPath + "/" + path.Join(sha1sum, fi.Name) + + req, err := http.NewRequest(http.MethodPut, uploadUrl, bytes.NewReader(*fi.Data)) + if err != nil { + return "", fmt.Errorf("%w: could not create request: %w", errUploadFailed, err) + } + + h.logger.Debugf("mediaserver upload url: %s", uploadUrl) + + req.Header.Set("Content-Type", "binary/octet-stream") + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("%w: could not Do request: %w", errUploadFailed, err) + } + + err = resp.Body.Close() + if err != nil { + h.logger.WithError(err).Error("failed to close response body") + } + + return h.httpDownloadPrefix + "/" + path.Join(sha1sum, fi.Name), nil +} + +// handleFilesUpload which uses MediaServerPath configuration, places the file on the current filesystem. +// Returns error on failure. +func (h *localMediaServer) handleFilesUpload(fi *config.FileInfo) (string, error) { + sha1sum := fmt.Sprintf("%x", sha1.Sum(*fi.Data))[:8] //nolint:gosec + dir := path.Join(h.localPath, sha1sum) + + err := os.Mkdir(dir, 0755) //nolint:gosec // this is for writing media files, so 0755 is fine, we want them to be accesible by webserver + if err != nil && !os.IsExist(err) { + return "", fmt.Errorf("%w: could not mkdir: %w", errUploadFailed, err) + } + + fileWritePath := path.Join(dir, fi.Name) + h.logger.WithField("fileWritePath", fileWritePath).Debug("mediaserver path placing file") + + err = os.WriteFile(fileWritePath, *fi.Data, 0644) //nolint:gosec // this is for writing media files, so 0644 is fine, we want them to be accesible by webserver + if err != nil { + return "", fmt.Errorf("%w: could not writefile: %w", errUploadFailed, err) + } + + return h.httpDownloadPrefix + "/" + path.Join(sha1sum, fi.Name), nil +} + +// handleFilesUpload which uploads media to s3 compatible server. +// Returns error on failure. +func (h *s3MediaServer) handleFilesUpload(fi *config.FileInfo) (string, error) { + sha1sum := fmt.Sprintf("%x", sha1.Sum(*fi.Data))[:8] //nolint:gosec + key := path.Join(h.uploadPrefix, sha1sum, fi.Name) + objectSize := int64(len(*fi.Data)) // TODO: Using this, sine we got this in memory anyway. Would be nicer to use fi.Size, but it is 0 + + // We do not bother with multipart uploads for now, as files are expected to be small (less than 5GB). + // If needed, we can implement that later. + info, err := h.s3Client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(h.bucket), + Key: aws.String(key), + Body: bytes.NewReader(*fi.Data), + ContentLength: aws.Int64(objectSize), + ContentType: aws.String("application/octet-stream"), + }) + if err != nil { + return "", fmt.Errorf("%w: mediaserver s3 PutObject failed: %w", errUploadFailed, err) + } + + downloadURL := h.httpDownloadPrefix + "/" + key + // If presign is enabled, generate a presigned URL, otherwise use the standard download URL. + if h.presignS3Client != nil { + downloadReq, err := h.presignS3Client.PresignGetObject(context.TODO(), &s3.GetObjectInput{ + Bucket: aws.String(h.bucket), + Key: aws.String(key), + }, s3.WithPresignExpires(mediaUploadPresignDuration)) + if err != nil { + return "", fmt.Errorf("%w: mediaserver s3 presign request creation failed: %w", errUploadFailed, err) + } + + downloadURL = downloadReq.URL + } + + h.logger.WithFields(logrus.Fields{ + "key": key, + "etag": info.ETag, + "downloadURL": downloadURL, + }).Debug("successfully uploaded") + + return downloadURL, nil +} diff --git a/go.mod b/go.mod index 6513394e9..a35c9b6f1 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,10 @@ require ( github.com/Benau/tgsconverter v0.0.0-20210809170556-99f4a4f6337f github.com/Rhymen/go-whatsapp v0.1.2-0.20211102134409-31a2e740845c github.com/SevereCloud/vksdk/v2 v2.17.0 + github.com/aws/aws-sdk-go-v2 v1.40.1 + github.com/aws/aws-sdk-go-v2/credentials v1.19.3 + github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0 + github.com/aws/smithy-go v1.24.0 github.com/bwmarrin/discordgo v0.28.1 github.com/d5/tengo/v2 v2.17.0 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc @@ -60,6 +64,14 @@ require ( github.com/Jeffail/gabs v1.4.0 // indirect github.com/apex/log v1.9.0 // indirect github.com/av-elier/go-decimal-to-rational v0.0.0-20191127152832-89e6aad02ecf // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.15 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.15 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.15 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15 // indirect github.com/beeper/argo-go v1.1.2 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/coder/websocket v1.8.14 // indirect diff --git a/go.sum b/go.sum index b7518feda..d89595135 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,30 @@ github.com/aphistic/sweet v0.2.0/go.mod h1:fWDlIh/isSE9n6EPsRmC0det+whmX6dJid3st github.com/av-elier/go-decimal-to-rational v0.0.0-20191127152832-89e6aad02ecf h1:csfEAyvOG4/498Q4SyF48ysFqQC9ESj3o8ppRtg+Rog= github.com/av-elier/go-decimal-to-rational v0.0.0-20191127152832-89e6aad02ecf/go.mod h1:POPnOeaYF7U9o3PjLTb9icRfEOxjBNLRXh9BLximJGM= github.com/aws/aws-sdk-go v1.20.6/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/aws/aws-sdk-go-v2 v1.40.1 h1:difXb4maDZkRH0x//Qkwcfpdg1XQVXEAEs2DdXldFFc= +github.com/aws/aws-sdk-go-v2 v1.40.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= +github.com/aws/aws-sdk-go-v2/credentials v1.19.3 h1:01Ym72hK43hjwDeJUfi1l2oYLXBAOR8gNSZNmXmvuas= +github.com/aws/aws-sdk-go-v2/credentials v1.19.3/go.mod h1:55nWF/Sr9Zvls0bGnWkRxUdhzKqj9uRNlPvgV1vgxKc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.15 h1:Y5YXgygXwDI5P4RkteB5yF7v35neH7LfJKBG+hzIons= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.15/go.mod h1:K+/1EpG42dFSY7CBj+Fruzm8PsCGWTXJ3jdeJ659oGQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.15 h1:AvltKnW9ewxX2hFmQS0FyJH93aSvJVUEFvXfU+HWtSE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.15/go.mod h1:3I4oCdZdmgrREhU74qS1dK9yZ62yumob+58AbFR4cQA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15 h1:NLYTEyZmVZo0Qh183sC8nC+ydJXOOeIL/qI/sS3PdLY= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15/go.mod h1:Z803iB3B0bc8oJV8zH2PERLRfQUJ2n2BXISpsA4+O1M= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6 h1:P1MU/SuhadGvg2jtviDXPEejU3jBNhoeeAlRadHzvHI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6/go.mod h1:5KYaMG6wmVKMFBSfWoyG/zH8pWwzQFnKgpoSRlXHKdQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.15 h1:3/u/4yZOffg5jdNk1sDpOQ4Y+R6Xbh+GzpDrSZjuy3U= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.15/go.mod h1:4Zkjq0FKjE78NKjabuM4tRXKFzUJWXgP0ItEZK8l7JU= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15 h1:wsSQ4SVz5YE1crz0Ap7VBZrV4nNqZt4CIBBT8mnwoNc= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15/go.mod h1:I7sditnFGtYMIqPRU1QoHZAUrXkGp4SczmlLwrNPlD0= +github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0 h1:IrbE3B8O9pm3lsg96AXIN5MXX4pECEuExh/A0Du3AuI= +github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0/go.mod h1:/sJLzHtiiZvs6C1RbxS/anSAFwZD6oC6M/kotQzOiLw= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I= github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=