feat: implement unified S3-compatible storage system

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>
This commit is contained in:
2025-07-01 23:07:44 +12:00
parent e3152d9f40
commit f99a866e13
14 changed files with 1650 additions and 2 deletions

View File

@@ -5,6 +5,7 @@ import (
"log"
"net/http"
"strconv"
"strings"
"github.com/openaccounting/oa-server/core/api"
"github.com/openaccounting/oa-server/core/auth"
@@ -31,6 +32,22 @@ func main() {
viper.AutomaticEnv()
viper.SetEnvPrefix("OA") // will look for OA_DATABASE_PASSWORD, etc.
// Configure Viper to handle nested config with environment variables
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
// Bind specific storage environment variables for better support
// Using mapstructure field names (snake_case)
viper.BindEnv("Storage.backend", "OA_STORAGE_BACKEND")
viper.BindEnv("Storage.local.root_dir", "OA_STORAGE_LOCAL_ROOTDIR")
viper.BindEnv("Storage.local.base_url", "OA_STORAGE_LOCAL_BASEURL")
viper.BindEnv("Storage.s3.region", "OA_STORAGE_S3_REGION")
viper.BindEnv("Storage.s3.bucket", "OA_STORAGE_S3_BUCKET")
viper.BindEnv("Storage.s3.prefix", "OA_STORAGE_S3_PREFIX")
viper.BindEnv("Storage.s3.access_key_id", "OA_STORAGE_S3_ACCESSKEYID")
viper.BindEnv("Storage.s3.secret_access_key", "OA_STORAGE_S3_SECRETACCESSKEY")
viper.BindEnv("Storage.s3.endpoint", "OA_STORAGE_S3_ENDPOINT")
viper.BindEnv("Storage.s3.path_style", "OA_STORAGE_S3_PATHSTYLE")
// Set default values
viper.SetDefault("Address", "localhost")
viper.SetDefault("Port", 8080)
@@ -38,6 +55,11 @@ func main() {
viper.SetDefault("DatabaseFile", "./openaccounting.db")
viper.SetDefault("ApiPrefix", "/api/v1")
// Set storage defaults (using mapstructure field names)
viper.SetDefault("Storage.backend", "local")
viper.SetDefault("Storage.local.root_dir", "./uploads")
viper.SetDefault("Storage.local.base_url", "")
// Read configuration
err := viper.ReadInConfig()
if err != nil {
@@ -50,6 +72,14 @@ func main() {
if err != nil {
log.Fatal(fmt.Errorf("failed to unmarshal config: %s", err.Error()))
}
// Set storage defaults if not configured (Viper doesn't handle nested defaults well)
if config.Storage.Backend == "" {
config.Storage.Backend = "local"
}
if config.Storage.Local.RootDir == "" {
config.Storage.Local.RootDir = "./uploads"
}
// Parse database address (assuming format host:port for MySQL)
host := config.DatabaseAddress
@@ -105,6 +135,12 @@ func main() {
// Set the global model instance
model.Instance = gormModel
// Initialize storage backend for attachments
err = api.InitializeAttachmentHandler(config.Storage)
if err != nil {
log.Fatal(fmt.Errorf("failed to initialize storage backend: %s", err.Error()))
}
app, err := api.Init(config.ApiPrefix)
if err != nil {
log.Fatal(fmt.Errorf("failed to create api instance with: %s", err.Error()))