// Package gcs provides an image uploader for GCS. package gcs import ( "context" "fmt" "io" "os" "path" "path/filepath" "time" "cloud.google.com/go/storage" "github.com/grafana/grafana/pkg/ifaces/gcsifaces" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/util" "golang.org/x/oauth2/google" "golang.org/x/oauth2/jwt" "google.golang.org/api/option" ) // NewUploader returns a new Uploader. func NewUploader(keyFile, bucket, path string, enableSignedURLs bool, signedURLExpiration time.Duration) (*Uploader, error) { if signedURLExpiration <= 0 { return nil, fmt.Errorf("invalid signed URL expiration: %q", signedURLExpiration) } uploader := &Uploader{ KeyFile: keyFile, Bucket: bucket, path: path, log: log.New("gcsuploader"), enableSignedURLs: enableSignedURLs, signedURLExpiration: signedURLExpiration, } uploader.log.Debug("Created uploader", "key", keyFile, "bucket", bucket, "path", path, "enableSignedUrls", enableSignedURLs, "signedUrlExpiration", signedURLExpiration.String()) return uploader, nil } // newClient returns a new GCS client. // Stubbable by tests. var newClient = func(ctx context.Context, opts ...option.ClientOption) (gcsifaces.StorageClient, error) { client, err := storage.NewClient(ctx, opts...) return clientWrapper{client}, err } // Uploader supports uploading images to GCS. type Uploader struct { KeyFile string Bucket string path string log log.Logger enableSignedURLs bool signedURLExpiration time.Duration } // Upload uploads an image to GCS. func (u *Uploader) Upload(ctx context.Context, imageDiskPath string) (string, error) { fileName, err := util.GetRandomString(20) if err != nil { return "", err } ext := filepath.Ext(imageDiskPath) if ext == "" { ext = ".png" } fileName += ext key := path.Join(u.path, fileName) var keyData []byte if u.KeyFile != "" { u.log.Debug("Opening key file ", u.KeyFile) keyData, err = os.ReadFile(u.KeyFile) if err != nil { return "", err } } const scope = storage.ScopeReadWrite var client gcsifaces.StorageClient if u.KeyFile != "" { u.log.Debug("Creating Google credentials from JSON") creds, err := google.CredentialsFromJSON(ctx, keyData, scope) if err != nil { return "", err } u.log.Debug("Creating GCS client") client, err = newClient(ctx, option.WithCredentials(creds)) if err != nil { return "", err } } else { u.log.Debug("Creating GCS client with default application credentials") client, err = newClient(ctx, option.WithScopes(scope)) if err != nil { return "", err } } if err := u.uploadFile(ctx, client, imageDiskPath, key); err != nil { return "", err } if !u.enableSignedURLs { return fmt.Sprintf("https://storage.googleapis.com/%s/%s", u.Bucket, key), nil } u.log.Debug("Signing GCS URL") var jwtData []byte if u.KeyFile != "" { jwtData = keyData } else { creds, err := client.FindDefaultCredentials(ctx, scope) if err != nil { return "", fmt.Errorf("failed to find default Google credentials: %s", err) } jwtData = creds.JSON } conf, err := client.JWTConfigFromJSON(jwtData) if err != nil { return "", err } opts := &storage.SignedURLOptions{ Scheme: storage.SigningSchemeV4, Method: "GET", GoogleAccessID: conf.Email, PrivateKey: conf.PrivateKey, Expires: time.Now().Add(u.signedURLExpiration), } signedURL, err := client.SignedURL(u.Bucket, key, opts) if err != nil { return "", err } return signedURL, nil } func (u *Uploader) uploadFile( ctx context.Context, client gcsifaces.StorageClient, imageDiskPath, key string, ) error { u.log.Debug("Opening image file", "path", imageDiskPath) // We can ignore the gosec G304 warning on this one because `imageDiskPath` comes // from alert notifiers and is only used to upload images generated by alerting. // nolint:gosec fileReader, err := os.Open(imageDiskPath) if err != nil { return err } defer func() { if err := fileReader.Close(); err != nil { u.log.Warn("Failed to close file", "err", err, "path", imageDiskPath) } }() // Set public access if not generating a signed URL pubAcc := !u.enableSignedURLs u.log.Debug("Uploading to GCS bucket using SDK", "bucket", u.Bucket, "key", key, "public", pubAcc) uri := fmt.Sprintf("gs://%s/%s", u.Bucket, key) wc := client.Bucket(u.Bucket).Object(key).NewWriter(ctx) if pubAcc { wc.SetACL("publicRead") } if _, err := io.Copy(wc, fileReader); err != nil { _ = wc.Close() return fmt.Errorf("failed to upload to %s: %s", uri, err) } if err := wc.Close(); err != nil { return fmt.Errorf("failed to upload to %s: %s", uri, err) } return nil } type clientWrapper struct { client *storage.Client } func (c clientWrapper) Bucket(key string) gcsifaces.StorageBucket { return bucketWrapper{c.client.Bucket(key)} } func (c clientWrapper) FindDefaultCredentials(ctx context.Context, scope string) (*google.Credentials, error) { return google.FindDefaultCredentials(ctx, scope) } func (c clientWrapper) JWTConfigFromJSON(keyJSON []byte) (*jwt.Config, error) { return google.JWTConfigFromJSON(keyJSON) } func (c clientWrapper) SignedURL(bucket, name string, opts *storage.SignedURLOptions) (string, error) { return storage.SignedURL(bucket, name, opts) } type bucketWrapper struct { bucket *storage.BucketHandle } func (b bucketWrapper) Object(key string) gcsifaces.StorageObject { return objectWrapper{b.bucket.Object(key)} } type objectWrapper struct { object *storage.ObjectHandle } func (o objectWrapper) NewWriter(ctx context.Context) gcsifaces.StorageWriter { return writerWrapper{o.object.NewWriter(ctx)} } type writerWrapper struct { *storage.Writer } func (w writerWrapper) SetACL(acl string) { w.ObjectAttrs.PredefinedACL = acl }