Compare commits
	
		
			No commits in common. "db776d34ddafb8388ead270a2214b28879baecef" and "9c78413bb944e01771bb6c466cc48fa8166d9e5c" have entirely different histories.
		
	
	
		
			db776d34dd
			...
			9c78413bb9
		
	
		
							
								
								
									
										19
									
								
								.env.example
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								.env.example
									
									
									
									
									
								
							| @ -1,22 +1,3 @@ | |||||||
| # Do not use quotes, make will scream at you | # Do not use quotes, make will scream at you | ||||||
| 
 |  | ||||||
| # Database Configuration |  | ||||||
| DATABASE_URL=postgres://user:password@localhost/termcloud | DATABASE_URL=postgres://user:password@localhost/termcloud | ||||||
| 
 |  | ||||||
| # Server Configuration |  | ||||||
| PORT=8080 | PORT=8080 | ||||||
| STORAGE_DIR=storage |  | ||||||
| MAX_FILE_SIZE_MB=100 |  | ||||||
| RATE_LIMIT=20.0 |  | ||||||
| CORS_ORIGINS=* |  | ||||||
| GZIP_LEVEL=5 |  | ||||||
| 
 |  | ||||||
| # Database Pool Configuration |  | ||||||
| DB_MAX_CONNECTIONS=100 |  | ||||||
| DB_MIN_CONNECTIONS=10 |  | ||||||
| DB_CONN_LIFETIME=1h |  | ||||||
| DB_CONN_IDLE_TIME=1m |  | ||||||
| DB_HEALTH_CHECK_PERIOD=5s |  | ||||||
| 
 |  | ||||||
| # User Defaults |  | ||||||
| DEFAULT_STORAGE_LIMIT_GB=1 |  | ||||||
|  | |||||||
							
								
								
									
										27
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										27
									
								
								README.md
									
									
									
									
									
								
							| @ -5,34 +5,19 @@ A simple file storage service with user buckets and usage limits. | |||||||
| ## Setup | ## Setup | ||||||
| 
 | 
 | ||||||
| 1. Set up PostgreSQL database and run the schema: | 1. Set up PostgreSQL database and run the schema: | ||||||
|  | 
 | ||||||
| ```bash | ```bash | ||||||
| psql -d termcloud -f internal/db/schema.sql | psql -d termcloud -f internal/db/schema.sql | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
| 2. Configure environment variables (copy `.env.example` to `.env` and customize): | 2. Set environment variables: | ||||||
|  | 
 | ||||||
| ```bash | ```bash | ||||||
| cp .env.example .env | # .env.example | ||||||
| # Edit .env with your settings | DATABASE_URL="postgres://user:password@localhost/termcloud" | ||||||
|  | PORT="8080" | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
| ### Configuration Options |  | ||||||
| 
 |  | ||||||
| | Variable | Default | Description | |  | ||||||
| |----------|---------|-------------| |  | ||||||
| | `DATABASE_URL` | - | PostgreSQL connection string | |  | ||||||
| | `PORT` | 8080 | Server port | |  | ||||||
| | `STORAGE_DIR` | storage | Directory for file storage | |  | ||||||
| | `MAX_FILE_SIZE_MB` | 100 | Maximum file size in MB | |  | ||||||
| | `RATE_LIMIT` | 20.0 | Requests per second limit | |  | ||||||
| | `CORS_ORIGINS` | * | Allowed CORS origins | |  | ||||||
| | `GZIP_LEVEL` | 5 | Gzip compression level (1-9) | |  | ||||||
| | `DB_MAX_CONNECTIONS` | 100 | Maximum database connections | |  | ||||||
| | `DB_MIN_CONNECTIONS` | 10 | Minimum database connections | |  | ||||||
| | `DB_CONN_LIFETIME` | 1h | Connection lifetime | |  | ||||||
| | `DB_CONN_IDLE_TIME` | 1m | Connection idle timeout | |  | ||||||
| | `DB_HEALTH_CHECK_PERIOD` | 5s | Health check interval | |  | ||||||
| | `DEFAULT_STORAGE_LIMIT_GB` | 1 | Default user storage limit | |  | ||||||
| 
 |  | ||||||
| 3. Build and run: | 3. Build and run: | ||||||
| 
 | 
 | ||||||
| ```bash | ```bash | ||||||
|  | |||||||
| @ -8,7 +8,6 @@ import ( | |||||||
| 	"log" | 	"log" | ||||||
| 	"os" | 	"os" | ||||||
| 
 | 
 | ||||||
| 	"git.keircn.com/keiran/termcloud/internal/config" |  | ||||||
| 	"git.keircn.com/keiran/termcloud/internal/db" | 	"git.keircn.com/keiran/termcloud/internal/db" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| @ -30,15 +29,13 @@ func main() { | |||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	ctx := context.Background() | 	ctx := context.Background() | ||||||
| 	cfg := config.Load() | 	pool, err := db.NewPool(ctx) | ||||||
| 
 |  | ||||||
| 	pool, err := db.NewPool(ctx, cfg) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Fatalf("Failed to create database pool: %v", err) | 		log.Fatalf("Failed to create database pool: %v", err) | ||||||
| 	} | 	} | ||||||
| 	defer db.ClosePool(pool) | 	defer db.ClosePool(pool) | ||||||
| 
 | 
 | ||||||
| 	bucketService := db.NewBucketService(pool, cfg) | 	bucketService := db.NewBucketService(pool) | ||||||
| 
 | 
 | ||||||
| 	switch os.Args[1] { | 	switch os.Args[1] { | ||||||
| 	case "create-user": | 	case "create-user": | ||||||
| @ -49,7 +46,7 @@ func main() { | |||||||
| 
 | 
 | ||||||
| 		username := os.Args[2] | 		username := os.Args[2] | ||||||
| 		email := os.Args[3] | 		email := os.Args[3] | ||||||
| 		storageLimit := cfg.DefaultStorageLimit | 		storageLimit := int64(1) * 1024 * 1024 * 1024 | ||||||
| 
 | 
 | ||||||
| 		if len(os.Args) >= 5 { | 		if len(os.Args) >= 5 { | ||||||
| 			var limitGB int64 | 			var limitGB int64 | ||||||
|  | |||||||
| @ -2,10 +2,9 @@ package main | |||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
| 	"fmt" |  | ||||||
| 	"log" | 	"log" | ||||||
|  | 	"os" | ||||||
| 
 | 
 | ||||||
| 	"git.keircn.com/keiran/termcloud/internal/config" |  | ||||||
| 	"git.keircn.com/keiran/termcloud/internal/db" | 	"git.keircn.com/keiran/termcloud/internal/db" | ||||||
| 	"git.keircn.com/keiran/termcloud/internal/handlers" | 	"git.keircn.com/keiran/termcloud/internal/handlers" | ||||||
| 	"github.com/labstack/echo/v4" | 	"github.com/labstack/echo/v4" | ||||||
| @ -15,31 +14,30 @@ import ( | |||||||
| 
 | 
 | ||||||
| func main() { | func main() { | ||||||
| 	ctx := context.Background() | 	ctx := context.Background() | ||||||
| 	cfg := config.Load() |  | ||||||
| 
 | 
 | ||||||
| 	pool, err := db.NewPool(ctx, cfg) | 	pool, err := db.NewPool(ctx) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Fatalf("Failed to create database pool: %v", err) | 		log.Fatalf("Failed to create database pool: %v", err) | ||||||
| 	} | 	} | ||||||
| 	defer db.ClosePool(pool) | 	defer db.ClosePool(pool) | ||||||
| 
 | 
 | ||||||
| 	bucketService := db.NewBucketService(pool, cfg) | 	bucketService := db.NewBucketService(pool) | ||||||
| 	h := handlers.NewHandlers(bucketService) | 	h := handlers.NewHandlers(bucketService) | ||||||
| 
 | 
 | ||||||
| 	e := echo.New() | 	e := echo.New() | ||||||
| 
 | 
 | ||||||
| 	e.Use(middleware.BodyLimit(fmt.Sprintf("%dM", cfg.MaxFileSize/1024/1024))) | 	e.Use(middleware.BodyLimit("100M")) | ||||||
| 	e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ | 	e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ | ||||||
| 		AllowOrigins: cfg.CORSOrigins, | 		AllowOrigins: []string{"*"}, | ||||||
| 		AllowMethods: []string{echo.GET, echo.POST, echo.PUT, echo.DELETE}, | 		AllowMethods: []string{echo.GET, echo.POST, echo.PUT, echo.DELETE}, | ||||||
| 		AllowHeaders: []string{"*"}, | 		AllowHeaders: []string{"*"}, | ||||||
| 	})) | 	})) | ||||||
| 	e.Use(middleware.GzipWithConfig(middleware.GzipConfig{ | 	e.Use(middleware.GzipWithConfig(middleware.GzipConfig{ | ||||||
| 		Level: cfg.GzipLevel, | 		Level: 5, | ||||||
| 	})) | 	})) | ||||||
| 	e.Use(middleware.RateLimiter(middleware.NewRateLimiterMemoryStore(rate.Limit(cfg.RateLimit)))) | 	e.Use(middleware.RateLimiter(middleware.NewRateLimiterMemoryStore(rate.Limit(20)))) | ||||||
| 
 | 
 | ||||||
| 	e.Static("/storage", cfg.StorageDir) | 	e.Static("/storage", "storage") | ||||||
| 
 | 
 | ||||||
| 	e.GET("/", h.RootHandler) | 	e.GET("/", h.RootHandler) | ||||||
| 
 | 
 | ||||||
| @ -55,5 +53,10 @@ func main() { | |||||||
| 	api.GET("/buckets/:bucket/objects/*", h.GetObjectHandler) | 	api.GET("/buckets/:bucket/objects/*", h.GetObjectHandler) | ||||||
| 	api.DELETE("/buckets/:bucket/objects/*", h.DeleteObjectHandler) | 	api.DELETE("/buckets/:bucket/objects/*", h.DeleteObjectHandler) | ||||||
| 
 | 
 | ||||||
| 	e.Logger.Fatal(e.Start(":" + cfg.Port)) | 	port := os.Getenv("PORT") | ||||||
|  | 	if port == "" { | ||||||
|  | 		port = "8080" | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	e.Logger.Fatal(e.Start(":" + port)) | ||||||
| } | } | ||||||
|  | |||||||
| @ -1,100 +0,0 @@ | |||||||
| package config |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"os" |  | ||||||
| 	"strconv" |  | ||||||
| 	"time" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| type Config struct { |  | ||||||
| 	DatabaseURL         string |  | ||||||
| 	Port                string |  | ||||||
| 	StorageDir          string |  | ||||||
| 	MaxFileSize         int64 |  | ||||||
| 	RateLimit           float64 |  | ||||||
| 	CORSOrigins         []string |  | ||||||
| 	GzipLevel           int |  | ||||||
| 	MaxConnections      int32 |  | ||||||
| 	MinConnections      int32 |  | ||||||
| 	ConnLifetime        time.Duration |  | ||||||
| 	ConnIdleTime        time.Duration |  | ||||||
| 	HealthCheckPeriod   time.Duration |  | ||||||
| 	DefaultStorageLimit int64 |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func Load() *Config { |  | ||||||
| 	return &Config{ |  | ||||||
| 		DatabaseURL:         getEnv("DATABASE_URL", ""), |  | ||||||
| 		Port:                getEnv("PORT", "8080"), |  | ||||||
| 		StorageDir:          getEnv("STORAGE_DIR", "storage"), |  | ||||||
| 		MaxFileSize:         getEnvInt64("MAX_FILE_SIZE_MB", 100) * 1024 * 1024, |  | ||||||
| 		RateLimit:           getEnvFloat64("RATE_LIMIT", 20.0), |  | ||||||
| 		CORSOrigins:         getEnvSlice("CORS_ORIGINS", []string{"*"}), |  | ||||||
| 		GzipLevel:           getEnvInt("GZIP_LEVEL", 5), |  | ||||||
| 		MaxConnections:      getEnvInt32("DB_MAX_CONNECTIONS", 100), |  | ||||||
| 		MinConnections:      getEnvInt32("DB_MIN_CONNECTIONS", 10), |  | ||||||
| 		ConnLifetime:        getEnvDuration("DB_CONN_LIFETIME", time.Hour), |  | ||||||
| 		ConnIdleTime:        getEnvDuration("DB_CONN_IDLE_TIME", time.Minute), |  | ||||||
| 		HealthCheckPeriod:   getEnvDuration("DB_HEALTH_CHECK_PERIOD", 5*time.Second), |  | ||||||
| 		DefaultStorageLimit: getEnvInt64("DEFAULT_STORAGE_LIMIT_GB", 1) * 1024 * 1024 * 1024, |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func getEnv(key, defaultValue string) string { |  | ||||||
| 	if value := os.Getenv(key); value != "" { |  | ||||||
| 		return value |  | ||||||
| 	} |  | ||||||
| 	return defaultValue |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func getEnvInt(key string, defaultValue int) int { |  | ||||||
| 	if value := os.Getenv(key); value != "" { |  | ||||||
| 		if intValue, err := strconv.Atoi(value); err == nil { |  | ||||||
| 			return intValue |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	return defaultValue |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func getEnvInt32(key string, defaultValue int32) int32 { |  | ||||||
| 	if value := os.Getenv(key); value != "" { |  | ||||||
| 		if intValue, err := strconv.ParseInt(value, 10, 32); err == nil { |  | ||||||
| 			return int32(intValue) |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	return defaultValue |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func getEnvInt64(key string, defaultValue int64) int64 { |  | ||||||
| 	if value := os.Getenv(key); value != "" { |  | ||||||
| 		if intValue, err := strconv.ParseInt(value, 10, 64); err == nil { |  | ||||||
| 			return intValue |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	return defaultValue |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func getEnvFloat64(key string, defaultValue float64) float64 { |  | ||||||
| 	if value := os.Getenv(key); value != "" { |  | ||||||
| 		if floatValue, err := strconv.ParseFloat(value, 64); err == nil { |  | ||||||
| 			return floatValue |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	return defaultValue |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func getEnvDuration(key string, defaultValue time.Duration) time.Duration { |  | ||||||
| 	if value := os.Getenv(key); value != "" { |  | ||||||
| 		if duration, err := time.ParseDuration(value); err == nil { |  | ||||||
| 			return duration |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	return defaultValue |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func getEnvSlice(key string, defaultValue []string) []string { |  | ||||||
| 	if value := os.Getenv(key); value != "" { |  | ||||||
| 		return []string{value} |  | ||||||
| 	} |  | ||||||
| 	return defaultValue |  | ||||||
| } |  | ||||||
| @ -9,7 +9,6 @@ import ( | |||||||
| 	"path/filepath" | 	"path/filepath" | ||||||
| 	"time" | 	"time" | ||||||
| 
 | 
 | ||||||
| 	"git.keircn.com/keiran/termcloud/internal/config" |  | ||||||
| 	"github.com/jackc/pgx/v5/pgxpool" | 	"github.com/jackc/pgx/v5/pgxpool" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| @ -43,15 +42,11 @@ type Object struct { | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type BucketService struct { | type BucketService struct { | ||||||
| 	pool       *pgxpool.Pool | 	pool *pgxpool.Pool | ||||||
| 	storageDir string |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func NewBucketService(pool *pgxpool.Pool, cfg *config.Config) *BucketService { | func NewBucketService(pool *pgxpool.Pool) *BucketService { | ||||||
| 	return &BucketService{ | 	return &BucketService{pool: pool} | ||||||
| 		pool:       pool, |  | ||||||
| 		storageDir: cfg.StorageDir, |  | ||||||
| 	} |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *BucketService) CreateUser(ctx context.Context, username, email, apiKey string, storageLimit int64) (*User, error) { | func (s *BucketService) CreateUser(ctx context.Context, username, email, apiKey string, storageLimit int64) (*User, error) { | ||||||
| @ -141,12 +136,12 @@ func (s *BucketService) DeleteBucket(ctx context.Context, bucketName string, own | |||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, obj := range objects { | 	for _, obj := range objects { | ||||||
| 		if err := os.Remove(filepath.Join(s.storageDir, bucketName, obj.Key)); err != nil { | 		if err := os.Remove(filepath.Join("storage", bucketName, obj.Key)); err != nil { | ||||||
| 			continue | 			continue | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	os.RemoveAll(filepath.Join(s.storageDir, bucketName)) | 	os.RemoveAll(filepath.Join("storage", bucketName)) | ||||||
| 
 | 
 | ||||||
| 	_, err = s.pool.Exec(ctx, "DELETE FROM buckets WHERE name = $1 AND owner_id = $2", bucketName, ownerID) | 	_, err = s.pool.Exec(ctx, "DELETE FROM buckets WHERE name = $1 AND owner_id = $2", bucketName, ownerID) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| @ -170,7 +165,7 @@ func (s *BucketService) UploadObject(ctx context.Context, bucketID int64, key st | |||||||
| 		return nil, fmt.Errorf("storage limit exceeded") | 		return nil, fmt.Errorf("storage limit exceeded") | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	bucketDir := filepath.Join(s.storageDir, bucket.Name) | 	bucketDir := filepath.Join("storage", bucket.Name) | ||||||
| 	if err := os.MkdirAll(bucketDir, 0755); err != nil { | 	if err := os.MkdirAll(bucketDir, 0755); err != nil { | ||||||
| 		return nil, fmt.Errorf("failed to create bucket directory: %w", err) | 		return nil, fmt.Errorf("failed to create bucket directory: %w", err) | ||||||
| 	} | 	} | ||||||
| @ -254,7 +249,7 @@ func (s *BucketService) DeleteObject(ctx context.Context, bucketID int64, key st | |||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	filePath := filepath.Join(s.storageDir, bucket.Name, key) | 	filePath := filepath.Join("storage", bucket.Name, key) | ||||||
| 	if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { | 	if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { | ||||||
| 		return fmt.Errorf("failed to remove file: %w", err) | 		return fmt.Errorf("failed to remove file: %w", err) | ||||||
| 	} | 	} | ||||||
| @ -277,7 +272,7 @@ func (s *BucketService) GetObjectFile(ctx context.Context, bucketName, key strin | |||||||
| 		return nil, err | 		return nil, err | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	filePath := filepath.Join(s.storageDir, bucketName, key) | 	filePath := filepath.Join("storage", bucketName, key) | ||||||
| 	file, err := os.Open(filePath) | 	file, err := os.Open(filePath) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, fmt.Errorf("failed to open file: %w", err) | 		return nil, fmt.Errorf("failed to open file: %w", err) | ||||||
|  | |||||||
| @ -4,26 +4,28 @@ import ( | |||||||
| 	"context" | 	"context" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"log" | 	"log" | ||||||
|  | 	"os" | ||||||
|  | 	"time" | ||||||
| 
 | 
 | ||||||
| 	"git.keircn.com/keiran/termcloud/internal/config" |  | ||||||
| 	"github.com/jackc/pgx/v5/pgxpool" | 	"github.com/jackc/pgx/v5/pgxpool" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| func NewPool(ctx context.Context, cfg *config.Config) (*pgxpool.Pool, error) { | func NewPool(ctx context.Context) (*pgxpool.Pool, error) { | ||||||
| 	if cfg.DatabaseURL == "" { | 	connStr := os.Getenv("DATABASE_URL") | ||||||
|  | 	if connStr == "" { | ||||||
| 		return nil, fmt.Errorf("DATABASE_URL environment variable is not set") | 		return nil, fmt.Errorf("DATABASE_URL environment variable is not set") | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	config, err := pgxpool.ParseConfig(cfg.DatabaseURL) | 	config, err := pgxpool.ParseConfig(connStr) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, fmt.Errorf("unable to parse DATABASE_URL: %w", err) | 		return nil, fmt.Errorf("unable to parse DATABASE_URL: %w", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	config.MaxConns = cfg.MaxConnections | 	config.MaxConns = 100 | ||||||
| 	config.MinConns = cfg.MinConnections | 	config.MinConns = 10 | ||||||
| 	config.MaxConnLifetime = cfg.ConnLifetime | 	config.MaxConnLifetime = time.Hour | ||||||
| 	config.MaxConnIdleTime = cfg.ConnIdleTime | 	config.MaxConnIdleTime = time.Minute | ||||||
| 	config.HealthCheckPeriod = cfg.HealthCheckPeriod | 	config.HealthCheckPeriod = 5 * time.Second | ||||||
| 
 | 
 | ||||||
| 	pool, err := pgxpool.NewWithConfig(ctx, config) | 	pool, err := pgxpool.NewWithConfig(ctx, config) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user