xtablo-source/backend/internal/files/store.go
Arthur Belleville e0d72747e0
feat(05-01): add aws-sdk-go-v2 modules, 0005_files migration, sqlc queries, and files.Store
- Add four aws-sdk-go-v2 modules: core, config, credentials, service/s3
- Write 0005_files.sql migration (tablo_files table with ON DELETE CASCADE)
- Write internal/db/queries/files.sql with InsertTabloFile, ListFilesByTablo, GetTabloFileByID, DeleteTabloFile
- Implement internal/files/store.go: FileStorer interface, Store struct, NewStore (UsePathStyle for MinIO), Upload (sniff+stream+bytecount), Delete, PresignDownload
- sqlc generate produces files.sql.go + TabloFile model (gitignored, regeneratable)
2026-05-15 12:18:16 +02:00

122 lines
3.9 KiB
Go

package files
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"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"
)
// FileStorer is the interface satisfied by Store and used for test injection.
type FileStorer interface {
Upload(ctx context.Context, key string, file io.Reader) (contentType string, bytesWritten int64, err error)
Delete(ctx context.Context, key string) error
PresignDownload(ctx context.Context, key string) (string, error)
}
// Store is a thin wrapper around an S3-compatible client.
type Store struct {
client *s3.Client
bucket string
}
// byteCountReader wraps an io.Reader and counts the number of bytes read.
type byteCountReader struct {
r io.Reader
count int64
}
func (b *byteCountReader) Read(p []byte) (int, error) {
n, err := b.r.Read(p)
b.count += int64(n)
return n, err
}
// NewStore constructs a Store pointed at an S3-compatible endpoint.
//
// endpoint: e.g. "http://localhost:9000" (MinIO) or "https://<account>.r2.cloudflarestorage.com" (R2)
// usePathStyle: true for MinIO (required per Pitfall 1), false for R2
func NewStore(ctx context.Context, endpoint, bucket, region, accessKey, secretKey string, usePathStyle bool) (*Store, error) {
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion(region),
config.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(accessKey, secretKey, ""),
),
)
if err != nil {
return nil, err
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(endpoint)
o.UsePathStyle = usePathStyle // true for MinIO; false or omit for R2
})
return &Store{client: client, bucket: bucket}, nil
}
// Upload streams file to S3, sniffing content-type from the first 512 bytes.
// It implements the sniff-and-stream pattern (RESEARCH Pattern 2):
// - Reads first 512 bytes via io.ReadFull (io.ErrUnexpectedEOF is non-fatal for files < 512 bytes)
// - Calls http.DetectContentType on the sniffed bytes (D-05)
// - Reconstructs the full body via io.MultiReader(sniffBuf, file) (Pitfall 8 avoidance)
// - Wraps body in byteCountReader to reliably track bytes written (Pitfall 8 — header.Size unreliable)
func (s *Store) Upload(ctx context.Context, key string, file io.Reader) (contentType string, bytesWritten int64, err error) {
// Sniff content-type from first 512 bytes.
var sniffBuf [512]byte
n, readErr := io.ReadFull(file, sniffBuf[:])
// Accept io.ErrUnexpectedEOF — normal for files < 512 bytes (Pitfall 3).
if readErr != nil && !errors.Is(readErr, io.ErrUnexpectedEOF) {
return "", 0, readErr
}
contentType = http.DetectContentType(sniffBuf[:n])
// Reconstruct full body: sniffed bytes + remaining reader.
body := io.MultiReader(bytes.NewReader(sniffBuf[:n]), file)
// Wrap in a counting reader to track actual bytes written (Pitfall 8).
counter := &byteCountReader{r: body}
_, putErr := s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: counter,
ContentType: aws.String(contentType),
})
if putErr != nil {
return contentType, counter.count, putErr
}
return contentType, counter.count, nil
}
// Delete removes an object from S3.
func (s *Store) Delete(ctx context.Context, key string) error {
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
return err
}
// PresignDownload returns a time-limited presigned GET URL (5-minute TTL).
func (s *Store) PresignDownload(ctx context.Context, key string) (string, error) {
presignClient := s3.NewPresignClient(s.client)
req, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
}, func(o *s3.PresignOptions) {
o.Expires = 5 * time.Minute
})
if err != nil {
return "", err
}
return req.URL, nil
}