package storage import ( "context" "fmt" "log/slog" "time" gcsstorage "cloud.google.com/go/storage" "google.golang.org/api/iterator" "google.golang.org/api/option" ) // GCSStore implements Store using Google Cloud Storage. type GCSStore struct { client *gcsstorage.Client bucket string logger *slog.Logger } // NewGCSStore creates a GCS-backed store. // credentialsJSON may be empty to use Application Default Credentials. func NewGCSStore(ctx context.Context, bucket string, credentialsJSON string, logger *slog.Logger) (*GCSStore, error) { var opts []option.ClientOption if credentialsJSON != "" { opts = append(opts, option.WithCredentialsJSON([]byte(credentialsJSON))) } client, err := gcsstorage.NewClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("storage: failed to create GCS client: %w", err) } return &GCSStore{ client: client, bucket: bucket, logger: logger, }, nil } func (s *GCSStore) Upload(ctx context.Context, path string, data []byte, contentType string) (string, error) { obj := s.client.Bucket(s.bucket).Object(path) w := obj.NewWriter(ctx) w.ContentType = contentType if _, err := w.Write(data); err != nil { _ = w.Close() return "", fmt.Errorf("storage: write failed: %w", err) } if err := w.Close(); err != nil { return "", fmt.Errorf("storage: close failed: %w", err) } // Return a signed URL consistent with GetURL (bucket is private). return s.GetURL(ctx, path) } func (s *GCSStore) UploadPresigned(ctx context.Context, path string, contentType string) (*PresignedUpload, error) { expires := time.Now().Add(15 * time.Minute) url, err := s.client.Bucket(s.bucket).SignedURL(path, &gcsstorage.SignedURLOptions{ Method: "PUT", ContentType: contentType, Expires: expires, }) if err != nil { return nil, fmt.Errorf("storage: presigned URL failed: %w", err) } return &PresignedUpload{ URL: url, Headers: map[string]string{"Content-Type": contentType}, Method: "PUT", Expires: expires, }, nil } func (s *GCSStore) GetURL(ctx context.Context, path string) (string, error) { url, err := s.client.Bucket(s.bucket).SignedURL(path, &gcsstorage.SignedURLOptions{ Method: "GET", Expires: time.Now().Add(1 * time.Hour), }) if err != nil { return "", fmt.Errorf("storage: signed URL failed: %w", err) } return url, nil } func (s *GCSStore) Delete(ctx context.Context, path string) error { if err := s.client.Bucket(s.bucket).Object(path).Delete(ctx); err != nil { return fmt.Errorf("storage: delete failed: %w", err) } return nil } func (s *GCSStore) List(ctx context.Context, prefix string) ([]MediaObject, error) { it := s.client.Bucket(s.bucket).Objects(ctx, &gcsstorage.Query{Prefix: prefix}) var objects []MediaObject for { attrs, err := it.Next() if err == iterator.Done { break } if err != nil { return nil, fmt.Errorf("storage: list failed: %w", err) } signedURL, urlErr := s.GetURL(ctx, attrs.Name) if urlErr != nil { s.logger.Warn("failed to sign URL for listed object", "path", attrs.Name, "error", urlErr) continue } objects = append(objects, MediaObject{ Path: attrs.Name, URL: signedURL, ContentType: attrs.ContentType, Size: attrs.Size, CreatedAt: attrs.Created, }) } return objects, nil } // Close releases GCS client resources. func (s *GCSStore) Close() error { return s.client.Close() }