package services import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" ) // StorageClient interacts with Supabase Storage via REST API. type StorageClient struct { baseURL string serviceKey string httpClient *http.Client } func NewStorageClient(supabaseURL, serviceKey string) *StorageClient { return &StorageClient{ baseURL: supabaseURL, serviceKey: serviceKey, httpClient: &http.Client{}, } } // Upload stores a file in the given bucket at the specified path. func (s *StorageClient) Upload(ctx context.Context, bucket, path, contentType string, data io.Reader) error { url := fmt.Sprintf("%s/storage/v1/object/%s/%s", s.baseURL, bucket, path) req, err := http.NewRequestWithContext(ctx, "POST", url, data) if err != nil { return fmt.Errorf("creating upload request: %w", err) } req.Header.Set("Authorization", "Bearer "+s.serviceKey) req.Header.Set("Content-Type", contentType) req.Header.Set("x-upsert", "true") resp, err := s.httpClient.Do(req) if err != nil { return fmt.Errorf("uploading to storage: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("storage upload failed (status %d): %s", resp.StatusCode, string(body)) } return nil } // Download retrieves a file from storage. Caller must close the returned ReadCloser. func (s *StorageClient) Download(ctx context.Context, bucket, path string) (io.ReadCloser, string, error) { url := fmt.Sprintf("%s/storage/v1/object/%s/%s", s.baseURL, bucket, path) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, "", fmt.Errorf("creating download request: %w", err) } req.Header.Set("Authorization", "Bearer "+s.serviceKey) resp, err := s.httpClient.Do(req) if err != nil { return nil, "", fmt.Errorf("downloading from storage: %w", err) } if resp.StatusCode != http.StatusOK { resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return nil, "", fmt.Errorf("file not found in storage") } body, _ := io.ReadAll(resp.Body) return nil, "", fmt.Errorf("storage download failed (status %d): %s", resp.StatusCode, string(body)) } ct := resp.Header.Get("Content-Type") return resp.Body, ct, nil } // Delete removes files from storage by their paths. func (s *StorageClient) Delete(ctx context.Context, bucket string, paths []string) error { url := fmt.Sprintf("%s/storage/v1/object/%s", s.baseURL, bucket) body, err := json.Marshal(map[string][]string{"prefixes": paths}) if err != nil { return fmt.Errorf("marshaling delete request: %w", err) } req, err := http.NewRequestWithContext(ctx, "DELETE", url, bytes.NewReader(body)) if err != nil { return fmt.Errorf("creating delete request: %w", err) } req.Header.Set("Authorization", "Bearer "+s.serviceKey) req.Header.Set("Content-Type", "application/json") resp, err := s.httpClient.Do(req) if err != nil { return fmt.Errorf("deleting from storage: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("storage delete failed (status %d): %s", resp.StatusCode, string(respBody)) } return nil }