diff --git a/go.mod b/go.mod index db867e3..a654a33 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.23 require ( github.com/AlecAivazis/survey/v2 v2.3.4 + github.com/GetStream/getstream-go/v4 v4.0.4 github.com/GetStream/stream-chat-go/v8 v8.3.0 github.com/MakeNowJust/heredoc v1.0.0 github.com/cheynewallace/tabby v1.1.1 @@ -14,6 +15,8 @@ require ( github.com/spf13/viper v1.11.0 ) +require github.com/golang-jwt/jwt/v5 v5.2.1 + require ( github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -38,12 +41,12 @@ require ( github.com/spf13/afero v1.8.2 // indirect github.com/spf13/cast v1.5.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect - github.com/stretchr/testify v1.7.1 + github.com/stretchr/testify v1.9.0 github.com/subosito/gotenv v1.2.0 // indirect golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 // indirect golang.org/x/text v0.3.8 // indirect gopkg.in/ini.v1 v1.66.4 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 17c04e8..55239d5 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/AlecAivazis/survey/v2 v2.3.4 h1:pchTU9rsLUSvWEl2Aq9Pv3k0IE2fkqtGxazsk github.com/AlecAivazis/survey/v2 v2.3.4/go.mod h1:hrV6Y/kQCLhIZXGcriDCUBtB3wnN7156gMXJ3+b23xM= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/GetStream/getstream-go/v4 v4.0.4 h1:nkD/s42M+06eOpa4m8n38ukumqxX4LlCAEzrRU27kmw= +github.com/GetStream/getstream-go/v4 v4.0.4/go.mod h1:A5hd7TxT8nSZBWazr4403j05dqP0F8pt7vi8YAJj+9M= github.com/GetStream/stream-chat-go/v8 v8.3.0 h1:mFtQZ0PkcCXMPjCDlnZcex3roOvE+UOaxBcNdq3o62s= github.com/GetStream/stream-chat-go/v8 v8.3.0/go.mod h1:frj3A1yv9mjyWlGNwaZKnXcX9JYYTPWSDqzyOFeHPac= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -81,6 +83,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -134,6 +138,8 @@ github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= @@ -149,6 +155,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= @@ -219,8 +227,9 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -529,8 +538,8 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= -gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/pkg/cmd/feeds/imports/imports.go b/pkg/cmd/feeds/imports/imports.go new file mode 100644 index 0000000..d7d1c5b --- /dev/null +++ b/pkg/cmd/feeds/imports/imports.go @@ -0,0 +1,266 @@ +package imports + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + getstream "github.com/GetStream/getstream-go/v4" + "github.com/MakeNowJust/heredoc" + "github.com/golang-jwt/jwt/v5" + "github.com/spf13/cobra" + + "github.com/GetStream/stream-cli/pkg/config" + "github.com/GetStream/stream-cli/pkg/utils" +) + +func NewCmds() []*cobra.Command { + return []*cobra.Command{ + uploadCmd(), + getCmd(), + listCmd(), + } +} + +// getImportV2Task works around a server-side issue where GET /api/v2/imports/v2/{id} +// requires a JSON body. The SDK sends no body for GET requests, causing a 400 error. +func getImportV2Task(ctx context.Context, app *config.App, id string) (map[string]any, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"server": true}) + authToken, err := token.SignedString([]byte(app.AccessSecretKey)) + if err != nil { + return nil, fmt.Errorf("creating auth token: %w", err) + } + + baseURL := app.ChatURL + if baseURL == "" { + baseURL = config.DefaultChatEdgeURL + } + + reqURL := fmt.Sprintf("%s/api/v2/imports/v2/%s?api_key=%s", + baseURL, url.PathEscape(id), url.QueryEscape(app.AccessKey)) + + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, strings.NewReader("{}")) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", authToken) + req.Header.Set("Stream-Auth-Type", "jwt") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + var result map[string]any + if err := json.Unmarshal(body, &result); err != nil { + return nil, err + } + return result, nil +} + +func uploadToS3(ctx context.Context, filename, url string) error { + data, err := os.Open(filename) + if err != nil { + return err + } + defer data.Close() + + stat, err := data.Stat() + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, "PUT", url, data) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.ContentLength = stat.Size() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + return nil +} + +func uploadCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "upload-import [filename] --output-format [json|tree]", + Short: "Upload an import for Feeds", + Example: heredoc.Doc(` + # Uploads a feeds import and prints it as JSON + $ stream-cli feeds upload-import data.json + + # Uploads a feeds import and prints it as a browsable tree + $ stream-cli feeds upload-import data.json --output-format tree + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := config.GetConfig(cmd).GetFeedsClient(cmd) + if err != nil { + return err + } + + filename := args[0] + + createURLResp, err := client.CreateImportURL(cmd.Context(), &getstream.CreateImportURLRequest{ + Filename: getstream.PtrTo(filepath.Base(filename)), + }) + if err != nil { + return err + } + if err := uploadToS3(cmd.Context(), filename, createURLResp.Data.UploadUrl); err != nil { + return err + } + + bucket, region, err := utils.S3BucketAndRegionFromUploadURL(createURLResp.Data.UploadUrl) + if err != nil { + return err + } + dir := createURLResp.Data.Path + + skipReferencesCheck, err := cmd.Flags().GetBool("skip-references-check") + if err != nil { + return err + } + + resp, err := client.CreateImportV2Task(cmd.Context(), &getstream.CreateImportV2TaskRequest{ + Product: "feeds", + Settings: getstream.ImportV2TaskSettings{ + SkipReferencesCheck: getstream.PtrTo(skipReferencesCheck), + S3: &getstream.ImportV2TaskSettingsS3{ + Bucket: getstream.PtrTo(bucket), + Dir: &dir, + Region: getstream.PtrTo(region), + }, + }, + }) + if err != nil { + return err + } + + return utils.PrintObject(cmd, resp.Data) + }, + } + + fl := cmd.Flags() + fl.StringP("output-format", "o", "json", "[optional] Output format. Can be json or tree") + fl.Bool("skip-references-check", false, "[optional] Skip references validation for the import (default false)") + + return cmd +} + +func getCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "get-import [task-id] --output-format [json|tree] --watch", + Short: "Get a feeds import task", + Example: heredoc.Doc(` + # Returns a feeds import and prints it as JSON + $ stream-cli feeds get-import dcb6e366-93ec-4e52-af6f-b0c030ad5272 + + # Returns a feeds import and watches for completion + $ stream-cli feeds get-import dcb6e366-93ec-4e52-af6f-b0c030ad5272 --watch + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg := config.GetConfig(cmd) + app, err := cfg.GetDefaultAppOrExplicit(cmd) + if err != nil { + return err + } + + id := args[0] + watch, _ := cmd.Flags().GetBool("watch") + + for { + result, err := getImportV2Task(cmd.Context(), app, id) + if err != nil { + return err + } + + err = utils.PrintObject(cmd, result) + if err != nil { + return err + } + + if !watch { + break + } + + time.Sleep(5 * time.Second) + } + + return nil + }, + } + + fl := cmd.Flags() + fl.BoolP("watch", "w", false, "[optional] Keep polling the import to track its status") + fl.StringP("output-format", "o", "json", "[optional] Output format. Can be json or tree") + + return cmd +} + +func listCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list-imports --output-format [json|tree] --state [1-4]", + Short: "List feeds import tasks", + Example: heredoc.Doc(` + # List all feeds imports as json (default) + $ stream-cli feeds list-imports + + # List feeds imports filtered by state + $ stream-cli feeds list-imports --state 2 + + # List all feeds imports as browsable tree + $ stream-cli feeds list-imports --output-format tree + `), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := config.GetConfig(cmd).GetFeedsClient(cmd) + if err != nil { + return err + } + + state, _ := cmd.Flags().GetInt("state") + + req := &getstream.ListImportV2TasksRequest{} + if state > 0 { + req.State = getstream.PtrTo(state) + } + + resp, err := client.ListImportV2Tasks(cmd.Context(), req) + if err != nil { + return err + } + + return utils.PrintObject(cmd, resp.Data) + }, + } + + fl := cmd.Flags() + fl.IntP("state", "s", 0, "[optional] Filter imports by state (1-4)") + fl.StringP("output-format", "o", "json", "[optional] Output format. Can be json or tree") + + return cmd +} diff --git a/pkg/cmd/feeds/root.go b/pkg/cmd/feeds/root.go new file mode 100644 index 0000000..0020d4b --- /dev/null +++ b/pkg/cmd/feeds/root.go @@ -0,0 +1,18 @@ +package feeds + +import ( + "github.com/spf13/cobra" + + "github.com/GetStream/stream-cli/pkg/cmd/feeds/imports" +) + +func NewRootCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "feeds", + Short: "Allows you to interact with your Feeds applications", + } + + cmd.AddCommand(imports.NewCmds()...) + + return cmd +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 4585dcc..2621bf1 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -8,6 +8,7 @@ import ( "github.com/GetStream/stream-cli/pkg/cmd/chat" cfgCmd "github.com/GetStream/stream-cli/pkg/cmd/config" + feedsCmd "github.com/GetStream/stream-cli/pkg/cmd/feeds" "github.com/GetStream/stream-cli/pkg/config" "github.com/GetStream/stream-cli/pkg/version" ) @@ -39,6 +40,7 @@ func NewCmd() *cobra.Command { root.AddCommand( cfgCmd.NewRootCmd(), chat.NewRootCmd(), + feedsCmd.NewRootCmd(), ) cobra.OnInitialize(config.GetInitConfig(root, cfgPath)) diff --git a/pkg/config/config.go b/pkg/config/config.go index 98d6950..b8e07d9 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" + getstream "github.com/GetStream/getstream-go/v4" stream "github.com/GetStream/stream-chat-go/v8" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -80,6 +81,20 @@ func (c *Config) GetClient(cmd *cobra.Command) (*stream.Client, error) { return client, nil } +func (c *Config) GetFeedsClient(cmd *cobra.Command) (*getstream.Stream, error) { + a, err := c.GetDefaultAppOrExplicit(cmd) + if err != nil { + return nil, err + } + + var opts []getstream.ClientOption + if a.ChatURL != "" && a.ChatURL != DefaultChatEdgeURL { + opts = append(opts, getstream.WithBaseUrl(a.ChatURL)) + } + + return getstream.NewClient(a.AccessKey, a.AccessSecretKey, opts...) +} + func (c *Config) Add(newApp App) error { if len(c.Apps) == 0 { c.Default = newApp.Name diff --git a/pkg/utils/s3url.go b/pkg/utils/s3url.go new file mode 100644 index 0000000..5efe304 --- /dev/null +++ b/pkg/utils/s3url.go @@ -0,0 +1,94 @@ +package utils + +import ( + "fmt" + "net/url" + "regexp" + "strings" +) + +// awsRegionPattern matches typical AWS region identifiers (e.g. us-east-1, eu-central-1). +var awsRegionPattern = regexp.MustCompile(`^[a-z]{2}(-gov)?-[a-z]+-\d+$`) + +// regionFromAmzCredential returns the AWS region from the X-Amz-Credential query +// parameter (format: accessKey/date/region/service/aws4_request). +func regionFromAmzCredential(u *url.URL) string { + v := u.Query().Get("X-Amz-Credential") + if v == "" { + return "" + } + decoded, err := url.QueryUnescape(v) + if err != nil { + decoded = v + } + parts := strings.Split(decoded, "/") + if len(parts) < 3 { + return "" + } + r := parts[2] + if !awsRegionPattern.MatchString(r) { + return "" + } + return r +} + +// S3BucketAndRegionFromUploadURL derives the S3 bucket and region from a presigned PUT URL. +// It handles virtual-hosted–style hosts (bucket.s3.region.amazonaws.com, bucket.s3.amazonaws.com) +// and falls back to the region embedded in X-Amz-Credential when the host is non-standard +// (e.g. S3 Transfer Acceleration). +func S3BucketAndRegionFromUploadURL(raw string) (bucket, region string, err error) { + u, err := url.Parse(raw) + if err != nil { + return "", "", fmt.Errorf("parse upload URL: %w", err) + } + host := strings.ToLower(u.Hostname()) + credRegion := regionFromAmzCredential(u) + parts := strings.Split(host, ".") + n := len(parts) + + if n >= 4 && parts[n-2] == "amazonaws" && (parts[n-1] == "com" || parts[n-1] == "cn") { + switch { + case n == 4 && parts[1] == "s3": + // e.g. bucket.s3.amazonaws.com → us-east-1 for the global endpoint + return parts[0], "us-east-1", nil + case n == 5 && parts[1] == "s3": + // e.g. bucket.s3.eu-central-1.amazonaws.com + return parts[0], parts[2], nil + case n == 6 && parts[1] == "s3" && parts[2] == "dualstack": + return parts[0], parts[3], nil + } + } + + // Path-style: s3.region.amazonaws.com/bucket/key + if n == 4 && parts[0] == "s3" && parts[2] == "amazonaws" && awsRegionPattern.MatchString(parts[1]) { + reg := parts[1] + path := strings.Trim(strings.TrimPrefix(u.Path, "/"), "/") + if path == "" { + return "", "", fmt.Errorf("path-style S3 URL missing object key path") + } + slash := strings.IndexByte(path, '/') + if slash <= 0 { + return "", "", fmt.Errorf("path-style S3 URL missing bucket in path") + } + return path[:slash], reg, nil + } + + // Fallback: first label before ".s3" is the bucket (virtual-hosted variants like accelerate) + if i := strings.Index(host, ".s3"); i > 0 { + b := host[:i] + if b != "" { + r := credRegion + if r == "" && n == 4 && parts[1] == "s3" { + r = "us-east-1" + } + if r != "" { + return b, r, nil + } + } + } + + if credRegion != "" { + return "", "", fmt.Errorf("could not derive S3 bucket from upload URL host %q (region from credential: %s)", host, credRegion) + } + return "", "", fmt.Errorf("could not derive S3 bucket and region from upload URL host %q", host) +}