// Package storage provides object storage for Where Woof photos (MinIO, S3-compatible). package storage import ( "context" "fmt" "io" "os" "time" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" ) // Client wraps a MinIO client and bucket for photo objects. type Client struct { mc *minio.Client bucket string } // NewClient builds a storage client from MINIO_* env vars. // Returns (nil, nil) when storage is not configured (MINIO_ENDPOINT unset) — // callers should treat that as "storage unavailable". func NewClient() (*Client, error) { endpoint := os.Getenv("MINIO_ENDPOINT") if endpoint == "" { return nil, nil } access := os.Getenv("MINIO_ACCESS_KEY") secret := os.Getenv("MINIO_SECRET_KEY") bucket := os.Getenv("MINIO_BUCKET") if bucket == "" { bucket = "wherewoof" } mc, err := minio.New(endpoint, &minio.Options{ Creds: credentials.NewStaticV4(access, secret, ""), Secure: false, // internal LAN }) if err != nil { return nil, fmt.Errorf("minio client: %w", err) } return &Client{mc: mc, bucket: bucket}, nil } // Put stores an object under key and returns the size. func (c *Client) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error { if c == nil { return fmt.Errorf("storage not configured") } _, err := c.mc.PutObject(ctx, c.bucket, key, r, size, minio.PutObjectOptions{ ContentType: contentType, }) return err } // Get returns the object reader + content type for a key. func (c *Client) Get(ctx context.Context, key string) (io.ReadCloser, string, error) { if c == nil { return nil, "", fmt.Errorf("storage not configured") } obj, err := c.mc.GetObject(ctx, c.bucket, key, minio.GetObjectOptions{}) if err != nil { return nil, "", err } stat, err := obj.Stat() if err != nil { obj.Close() return nil, "", err } return obj, stat.ContentType, nil } // ListKeys returns object keys with the given prefix (used for migration). func (c *Client) ListKeys(ctx context.Context, prefix string) ([]string, error) { if c == nil { return nil, fmt.Errorf("storage not configured") } var keys []string for obj := range c.mc.ListObjects(ctx, c.bucket, minio.ListObjectsOptions{Prefix: prefix}) { if obj.Err != nil { return nil, obj.Err } keys = append(keys, obj.Key) } return keys, nil } // PresignedURL returns a temporary URL (not used by the app; available for admin previews). func (c *Client) PresignedURL(ctx context.Context, key string, expiry time.Duration) (string, error) { if c == nil { return "", fmt.Errorf("storage not configured") } u, err := c.mc.PresignedGetObject(ctx, c.bucket, key, expiry, nil) if err != nil { return "", err } return u.String(), nil }