You've already forked openaccounting-server
forked from cybercinch/openaccounting-server
Consolidates storage backends into a single S3-compatible driver that supports: - AWS S3 (native) - Backblaze B2 (S3-compatible API) - Cloudflare R2 (S3-compatible API) - MinIO and other S3-compatible services - Local filesystem for development This replaces the previous separate B2 driver with a unified approach, reducing dependencies and complexity while adding support for more services. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
236 lines
5.4 KiB
Go
236 lines
5.4 KiB
Go
package storage
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go/aws"
|
|
"github.com/aws/aws-sdk-go/aws/awserr"
|
|
"github.com/aws/aws-sdk-go/aws/credentials"
|
|
"github.com/aws/aws-sdk-go/aws/session"
|
|
"github.com/aws/aws-sdk-go/service/s3"
|
|
"github.com/aws/aws-sdk-go/service/s3/s3manager"
|
|
"github.com/openaccounting/oa-server/core/util/id"
|
|
)
|
|
|
|
// S3Storage implements the Storage interface for Amazon S3
|
|
type S3Storage struct {
|
|
client *s3.S3
|
|
uploader *s3manager.Uploader
|
|
bucket string
|
|
prefix string
|
|
}
|
|
|
|
// NewS3Storage creates a new S3 storage backend
|
|
func NewS3Storage(config S3Config) (*S3Storage, error) {
|
|
if config.Bucket == "" {
|
|
return nil, fmt.Errorf("S3 bucket name is required")
|
|
}
|
|
|
|
// Create AWS config
|
|
awsConfig := &aws.Config{
|
|
Region: aws.String(config.Region),
|
|
}
|
|
|
|
// Set custom endpoint if provided (for S3-compatible services)
|
|
if config.Endpoint != "" {
|
|
awsConfig.Endpoint = aws.String(config.Endpoint)
|
|
awsConfig.S3ForcePathStyle = aws.Bool(config.PathStyle)
|
|
}
|
|
|
|
// Set credentials if provided
|
|
if config.AccessKeyID != "" && config.SecretAccessKey != "" {
|
|
awsConfig.Credentials = credentials.NewStaticCredentials(
|
|
config.AccessKeyID,
|
|
config.SecretAccessKey,
|
|
"",
|
|
)
|
|
}
|
|
|
|
// Create session
|
|
sess, err := session.NewSession(awsConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create AWS session: %w", err)
|
|
}
|
|
|
|
// Create S3 client
|
|
client := s3.New(sess)
|
|
uploader := s3manager.NewUploader(sess)
|
|
|
|
return &S3Storage{
|
|
client: client,
|
|
uploader: uploader,
|
|
bucket: config.Bucket,
|
|
prefix: config.Prefix,
|
|
}, nil
|
|
}
|
|
|
|
// Store saves a file to S3
|
|
func (s *S3Storage) Store(filename string, content io.Reader, contentType string) (string, error) {
|
|
// Generate a unique storage key
|
|
storageKey := s.generateStorageKey(filename)
|
|
|
|
// Prepare upload input
|
|
input := &s3manager.UploadInput{
|
|
Bucket: aws.String(s.bucket),
|
|
Key: aws.String(storageKey),
|
|
Body: content,
|
|
}
|
|
|
|
// Set content type if provided
|
|
if contentType != "" {
|
|
input.ContentType = aws.String(contentType)
|
|
}
|
|
|
|
// Upload the file
|
|
_, err := s.uploader.Upload(input)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to upload file to S3: %w", err)
|
|
}
|
|
|
|
return storageKey, nil
|
|
}
|
|
|
|
// Retrieve gets a file from S3
|
|
func (s *S3Storage) Retrieve(path string) (io.ReadCloser, error) {
|
|
input := &s3.GetObjectInput{
|
|
Bucket: aws.String(s.bucket),
|
|
Key: aws.String(path),
|
|
}
|
|
|
|
result, err := s.client.GetObject(input)
|
|
if err != nil {
|
|
if aerr, ok := err.(awserr.Error); ok {
|
|
switch aerr.Code() {
|
|
case s3.ErrCodeNoSuchKey:
|
|
return nil, &FileNotFoundError{Path: path}
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("failed to retrieve file from S3: %w", err)
|
|
}
|
|
|
|
return result.Body, nil
|
|
}
|
|
|
|
// Delete removes a file from S3
|
|
func (s *S3Storage) Delete(path string) error {
|
|
input := &s3.DeleteObjectInput{
|
|
Bucket: aws.String(s.bucket),
|
|
Key: aws.String(path),
|
|
}
|
|
|
|
_, err := s.client.DeleteObject(input)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete file from S3: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetURL returns a presigned URL for accessing the file
|
|
func (s *S3Storage) GetURL(path string, expiry time.Duration) (string, error) {
|
|
// Check if file exists first
|
|
exists, err := s.Exists(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !exists {
|
|
return "", &FileNotFoundError{Path: path}
|
|
}
|
|
|
|
// Generate presigned URL
|
|
req, _ := s.client.GetObjectRequest(&s3.GetObjectInput{
|
|
Bucket: aws.String(s.bucket),
|
|
Key: aws.String(path),
|
|
})
|
|
|
|
url, err := req.Presign(expiry)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to generate presigned URL: %w", err)
|
|
}
|
|
|
|
return url, nil
|
|
}
|
|
|
|
// Exists checks if a file exists in S3
|
|
func (s *S3Storage) Exists(path string) (bool, error) {
|
|
input := &s3.HeadObjectInput{
|
|
Bucket: aws.String(s.bucket),
|
|
Key: aws.String(path),
|
|
}
|
|
|
|
_, err := s.client.HeadObject(input)
|
|
if err != nil {
|
|
if aerr, ok := err.(awserr.Error); ok {
|
|
switch aerr.Code() {
|
|
case s3.ErrCodeNoSuchKey, "NotFound":
|
|
return false, nil
|
|
}
|
|
}
|
|
return false, fmt.Errorf("failed to check file existence in S3: %w", err)
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
// GetMetadata returns file metadata from S3
|
|
func (s *S3Storage) GetMetadata(path string) (*FileMetadata, error) {
|
|
input := &s3.HeadObjectInput{
|
|
Bucket: aws.String(s.bucket),
|
|
Key: aws.String(path),
|
|
}
|
|
|
|
result, err := s.client.HeadObject(input)
|
|
if err != nil {
|
|
if aerr, ok := err.(awserr.Error); ok {
|
|
switch aerr.Code() {
|
|
case s3.ErrCodeNoSuchKey, "NotFound":
|
|
return nil, &FileNotFoundError{Path: path}
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("failed to get file metadata from S3: %w", err)
|
|
}
|
|
|
|
metadata := &FileMetadata{
|
|
Size: aws.Int64Value(result.ContentLength),
|
|
}
|
|
|
|
if result.LastModified != nil {
|
|
metadata.LastModified = *result.LastModified
|
|
}
|
|
|
|
if result.ContentType != nil {
|
|
metadata.ContentType = *result.ContentType
|
|
}
|
|
|
|
if result.ETag != nil {
|
|
metadata.ETag = strings.Trim(*result.ETag, "\"")
|
|
}
|
|
|
|
return metadata, nil
|
|
}
|
|
|
|
// generateStorageKey creates a unique storage key for a file
|
|
func (s *S3Storage) generateStorageKey(filename string) string {
|
|
// Generate a unique ID for the file
|
|
fileID := id.String(id.New())
|
|
|
|
// Extract file extension
|
|
ext := path.Ext(filename)
|
|
|
|
// Create a key structure: prefix/YYYY/MM/DD/uuid.ext
|
|
now := time.Now()
|
|
datePath := fmt.Sprintf("%04d/%02d/%02d", now.Year(), now.Month(), now.Day())
|
|
|
|
key := path.Join(datePath, fileID+ext)
|
|
|
|
// Add prefix if configured
|
|
if s.prefix != "" {
|
|
key = path.Join(s.prefix, key)
|
|
}
|
|
|
|
return key
|
|
} |