package storage import ( "context" "io" "time" "spin/internal/config" awscfg "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" ) // ObjectInfo describes a single S3 object as listed by the file-admin tool. type ObjectInfo struct { Key string `json:"key"` Size int64 `json:"size"` LastModified time.Time `json:"lastModified"` } // Storage wraps two S3 clients: one for in-cluster operations (S3_ENDPOINT) and // one used only to mint presigned URLs that the browser can reach // (S3_PUBLIC_ENDPOINT). Mirrors the sister eQMS service. type Storage struct { bucket string prefix string // S3_PREFIX — app namespace within a shared bucket client *s3.Client presign *s3.PresignClient } // k applies the configured key prefix. DB stores logical keys; the physical // object location is prefix + key (e.g. "spin-backend/contracts/..."). func (s *Storage) k(key string) string { return s.prefix + key } // New constructs the storage clients. MinIO requires path-style addressing. func New(ctx context.Context, cfg config.Config) (*Storage, error) { creds := credentials.NewStaticCredentialsProvider(cfg.S3AccessKey, cfg.S3SecretKey, "") base, err := awscfg.LoadDefaultConfig(ctx, awscfg.WithRegion(cfg.S3Region), awscfg.WithCredentialsProvider(creds), ) if err != nil { return nil, err } client := s3.NewFromConfig(base, func(o *s3.Options) { o.BaseEndpoint = strPtr(cfg.S3Endpoint) o.UsePathStyle = true }) // Separate client whose endpoint is the browser-reachable host. publicClient := s3.NewFromConfig(base, func(o *s3.Options) { o.BaseEndpoint = strPtr(cfg.S3PublicEndpoint) o.UsePathStyle = true }) return &Storage{ bucket: cfg.S3Bucket, prefix: cfg.S3Prefix, client: client, presign: s3.NewPresignClient(publicClient), }, nil } func strPtr(s string) *string { return &s } // EnsureBucket creates the bucket if it does not exist. Best-effort. func (s *Storage) EnsureBucket(ctx context.Context) error { _, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: &s.bucket}) if err == nil { return nil } _, err = s.client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: &s.bucket}) return err } // Upload stores an object. func (s *Storage) Upload(ctx context.Context, key, contentType string, body io.Reader, size int64) error { full := s.k(key) _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ Bucket: &s.bucket, Key: &full, Body: body, ContentType: &contentType, ContentLength: &size, }) return err } // Get streams an object's bytes from S3. The caller must close the returned // reader. The second return value is the content length (-1 if unknown). func (s *Storage) Get(ctx context.Context, key string) (io.ReadCloser, int64, error) { full := s.k(key) out, err := s.client.GetObject(ctx, &s3.GetObjectInput{ Bucket: &s.bucket, Key: &full, }) if err != nil { return nil, 0, err } var size int64 = -1 if out.ContentLength != nil { size = *out.ContentLength } return out.Body, size, nil } // Delete removes a single logical object (prefix applied). func (s *Storage) Delete(ctx context.Context, key string) error { full := s.k(key) _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ Bucket: &s.bucket, Key: &full, }) return err } // Prefix returns the configured S3 key prefix (app namespace). Used by the // file-admin tool as the default listing prefix. func (s *Storage) Prefix() string { return s.prefix } // List enumerates RAW S3 keys (no app-prefix rewrite) under the given prefix, // following pagination. The admin file tool operates on physical keys directly. func (s *Storage) List(ctx context.Context, prefix string) ([]ObjectInfo, error) { var out []ObjectInfo var token *string for { page, err := s.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ Bucket: &s.bucket, Prefix: &prefix, ContinuationToken: token, }) if err != nil { return nil, err } for _, obj := range page.Contents { info := ObjectInfo{} if obj.Key != nil { info.Key = *obj.Key } if obj.Size != nil { info.Size = *obj.Size } if obj.LastModified != nil { info.LastModified = *obj.LastModified } out = append(out, info) } if page.IsTruncated == nil || !*page.IsTruncated { break } token = page.NextContinuationToken } return out, nil } // DeleteKeys bulk-deletes RAW S3 keys (no app-prefix rewrite) and returns the // number of objects deleted. Batches in groups of 1000 (S3 DeleteObjects limit). func (s *Storage) DeleteKeys(ctx context.Context, keys []string) (int, error) { deleted := 0 for i := 0; i < len(keys); i += 1000 { end := i + 1000 if end > len(keys) { end = len(keys) } ids := make([]s3types.ObjectIdentifier, 0, end-i) for _, k := range keys[i:end] { kk := k ids = append(ids, s3types.ObjectIdentifier{Key: &kk}) } out, err := s.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{ Bucket: &s.bucket, Delete: &s3types.Delete{Objects: ids}, }) if err != nil { return deleted, err } deleted += len(out.Deleted) } return deleted, nil } // PresignGet returns a browser-usable presigned GET URL valid for 10 minutes. func (s *Storage) PresignGet(ctx context.Context, key string) (string, error) { full := s.k(key) out, err := s.presign.PresignGetObject(ctx, &s3.GetObjectInput{ Bucket: &s.bucket, Key: &full, }, s3.WithPresignExpires(10*time.Minute)) if err != nil { return "", err } return out.URL, nil }