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 10:18:16 +00:00
|
|
|
package files
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
2026-05-15 17:57:46 +00:00
|
|
|
// Only compute checksums when the server explicitly requires them.
|
|
|
|
|
// Without this, SDK v2 tries to use trailing checksums over HTTP, which
|
|
|
|
|
// requires a seekable stream — incompatible with io.MultiReader (MinIO local dev).
|
|
|
|
|
o.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired
|
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 10:18:16 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return &Store{client: client, bucket: bucket}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 17:57:46 +00:00
|
|
|
// Upload buffers the file, sniffs its content-type, then PUTs it to S3.
|
|
|
|
|
//
|
|
|
|
|
// Buffering is required because AWS SDK v2 needs a seekable body to compute
|
|
|
|
|
// the SigV4 payload hash over plain HTTP (MinIO dev). The 25 MB cap enforced
|
|
|
|
|
// upstream by http.MaxBytesReader makes this safe.
|
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 10:18:16 +00:00
|
|
|
func (s *Store) Upload(ctx context.Context, key string, file io.Reader) (contentType string, bytesWritten int64, err error) {
|
2026-05-15 17:57:46 +00:00
|
|
|
buf, err := io.ReadAll(file)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", 0, err
|
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 10:18:16 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-15 17:57:46 +00:00
|
|
|
contentType = http.DetectContentType(buf)
|
|
|
|
|
bytesWritten = int64(len(buf))
|
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 10:18:16 +00:00
|
|
|
|
2026-05-15 17:57:46 +00:00
|
|
|
_, err = s.client.PutObject(ctx, &s3.PutObjectInput{
|
|
|
|
|
Bucket: aws.String(s.bucket),
|
|
|
|
|
Key: aws.String(key),
|
|
|
|
|
Body: bytes.NewReader(buf),
|
|
|
|
|
ContentType: aws.String(contentType),
|
|
|
|
|
ContentLength: aws.Int64(bytesWritten),
|
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 10:18:16 +00:00
|
|
|
})
|
2026-05-15 17:57:46 +00:00
|
|
|
return contentType, bytesWritten, err
|
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 10:18:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|