first commit

This commit is contained in:
Tomas Dvorak
2026-04-10 12:04:09 +02:00
commit 3cb40adb23
203 changed files with 40226 additions and 0 deletions
@@ -0,0 +1,86 @@
package filestorage
import (
"context"
"errors"
"io"
"os"
"path/filepath"
)
type LocalStorage struct {
root string
}
func NewLocal(root string) *LocalStorage {
return &LocalStorage{root: root}
}
func (l *LocalStorage) Provider() string {
return "local"
}
func (l *LocalStorage) Probe(_ context.Context) error {
if err := os.MkdirAll(l.root, 0o755); err != nil {
return err
}
probePath := filepath.Join(l.root, ".healthcheck")
file, err := os.OpenFile(probePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return err
}
if err := file.Close(); err != nil {
return err
}
_ = os.Remove(probePath)
return nil
}
func (l *LocalStorage) Put(_ context.Context, key string, reader io.Reader, _ string, _ int64) error {
path := filepath.Join(l.root, filepath.Clean(key))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
file, err := os.Create(path)
if err != nil {
return err
}
if _, err := io.Copy(file, reader); err != nil {
_ = file.Close()
_ = os.Remove(path)
return err
}
return file.Close()
}
func (l *LocalStorage) Get(_ context.Context, key string) (Object, error) {
path := filepath.Join(l.root, filepath.Clean(key))
file, err := os.Open(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return Object{}, ErrNotFound
}
return Object{}, err
}
info, err := file.Stat()
if err != nil {
_ = file.Close()
return Object{}, err
}
if info.IsDir() {
_ = file.Close()
return Object{}, ErrNotFound
}
return Object{
Body: file,
Size: info.Size(),
}, nil
}
func (l *LocalStorage) Delete(_ context.Context, key string) error {
path := filepath.Join(l.root, filepath.Clean(key))
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
+126
View File
@@ -0,0 +1,126 @@
package filestorage
import (
"context"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
)
type S3Storage struct {
client *s3.Client
bucket string
}
func NewS3FromEnv() (*S3Storage, error) {
bucket := strings.TrimSpace(os.Getenv("S3_BUCKET"))
accessKey := strings.TrimSpace(os.Getenv("S3_ACCESS_KEY"))
secretKey := strings.TrimSpace(os.Getenv("S3_SECRET_KEY"))
if bucket == "" || accessKey == "" || secretKey == "" {
return nil, errors.New("S3_BUCKET, S3_ACCESS_KEY, and S3_SECRET_KEY are required for FILE_STORAGE_PROVIDER=s3")
}
region := strings.TrimSpace(os.Getenv("S3_REGION"))
if region == "" {
region = "us-east-1"
}
endpoint := strings.TrimSpace(os.Getenv("S3_ENDPOINT"))
usePathStyle, _ := strconv.ParseBool(strings.TrimSpace(os.Getenv("S3_USE_PATH_STYLE")))
cfg := aws.Config{
Region: region,
Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
}
client := s3.NewFromConfig(cfg, func(options *s3.Options) {
options.UsePathStyle = usePathStyle
if endpoint != "" {
options.BaseEndpoint = aws.String(endpoint)
}
})
return &S3Storage{client: client, bucket: bucket}, nil
}
func (s *S3Storage) Provider() string {
return "s3"
}
func (s *S3Storage) Probe(ctx context.Context) error {
_, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.bucket)})
return err
}
func (s *S3Storage) Put(ctx context.Context, key string, reader io.Reader, contentType string, size int64) error {
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: reader,
ContentType: aws.String(contentType),
ContentLength: aws.Int64(size),
})
return err
}
func (s *S3Storage) Get(ctx context.Context, key string) (Object, error) {
response, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
if isS3NotFoundError(err) {
return Object{}, ErrNotFound
}
return Object{}, err
}
contentType := ""
if response.ContentType != nil {
contentType = *response.ContentType
}
size := int64(0)
if response.ContentLength != nil {
size = *response.ContentLength
}
return Object{
Body: response.Body,
ContentType: contentType,
Size: size,
}, nil
}
func (s *S3Storage) Delete(ctx context.Context, key string) error {
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
if isS3NotFoundError(err) {
return nil
}
return fmt.Errorf("delete s3 object: %w", err)
}
return nil
}
func isS3NotFoundError(err error) bool {
var noSuchKey *types.NoSuchKey
if errors.As(err, &noSuchKey) {
return true
}
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
switch apiErr.ErrorCode() {
case "NoSuchKey", "NotFound", "NoSuchBucket":
return true
}
}
return false
}
@@ -0,0 +1,40 @@
package filestorage
import (
"context"
"errors"
"io"
"os"
"strings"
)
var ErrNotFound = errors.New("file storage object not found")
type Object struct {
Body io.ReadCloser
ContentType string
Size int64
}
type Storage interface {
Provider() string
Probe(ctx context.Context) error
Put(ctx context.Context, key string, reader io.Reader, contentType string, size int64) error
Get(ctx context.Context, key string) (Object, error)
Delete(ctx context.Context, key string) error
}
func NewFromEnv() (Storage, error) {
provider := strings.ToLower(strings.TrimSpace(os.Getenv("FILE_STORAGE_PROVIDER")))
if provider == "" || provider == "local" {
root := strings.TrimSpace(os.Getenv("FILE_STORAGE_DIR"))
if root == "" {
root = "./data/uploads"
}
return NewLocal(root), nil
}
if provider == "s3" {
return NewS3FromEnv()
}
return nil, errors.New("unsupported file storage provider")
}