implement most of the auth stuff, just need to finish billing

This commit is contained in:
Keiran 2025-08-07 18:57:54 +01:00
parent b765dc6c88
commit 3be0ede054
8 changed files with 487 additions and 48 deletions

View File

@ -18,5 +18,6 @@ DB_CONN_LIFETIME=1h
DB_CONN_IDLE_TIME=1m DB_CONN_IDLE_TIME=1m
DB_HEALTH_CHECK_PERIOD=5s DB_HEALTH_CHECK_PERIOD=5s
# User Defaults # User Defaults and Billing
DEFAULT_STORAGE_LIMIT_GB=1 DEFAULT_STORAGE_LIMIT_GB=1
PRICE_PER_GB_USD=0.50

View File

@ -1,6 +1,22 @@
# Termcloud # Termcloud
A simple file storage service with user buckets and usage limits. A Mullvad-style file storage service with Bitcoin payments and usage-based billing.
## Features
- **Account-based System**: Anonymous account creation with 16-digit account numbers
- **Bitcoin Payments**: Pay with Bitcoin to activate and fund your account
- **Usage-based Billing**: Charged monthly for peak storage usage (pay for what you use)
- **Bucket Policies**: AWS S3-compatible JSON policies for access control
- **RESTful API**: Complete REST API for all operations
## Authentication System
Similar to Mullvad VPN:
1. **Create Account**: Generate anonymous 16-digit account number + access token
2. **Add Funds**: Pay ~$5 worth of Bitcoin to activate account
3. **Usage Billing**: Charged monthly based on peak storage usage ($0.50/GB default)
4. **No Personal Info**: No emails, usernames, or personal information required
## Setup ## Setup
@ -19,22 +35,12 @@ cp .env.example .env
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `PRICE_PER_GB_USD` | 0.50 | Monthly charge per GB of peak storage |
| `DATABASE_URL` | - | PostgreSQL connection string | | `DATABASE_URL` | - | PostgreSQL connection string |
| `PORT` | 8080 | Server port | | `PORT` | 8080 | Server port |
| `STORAGE_DIR` | storage | Directory for file storage | | `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
make build make build
make run make run
@ -42,11 +48,31 @@ make run
## Usage ## Usage
### Create a user and get API key: ### 1. Create Account
```bash ```bash
make admin ARGS="create-user mai sakurajima@waifu.club 5" curl -X POST http://localhost:8080/api/v1/accounts
``` ```
Response:
```json
{
"accountNumber": "1234567890123456",
"accessToken": "abc123...",
"balanceUsd": 0.00,
"isActive": false,
"message": "Account created. Add funds to activate."
}
```
### 2. Add Funds (Bitcoin Payment)
```bash
curl -X POST http://localhost:8080/api/v1/account/payments \
-H "X-Access-Token: your-access-token" \
-H "Content-Type: application/json" \
-d '{"amount": 5.00}'
```
### 3. Use Storage (requires active account)
All storage endpoints require `X-Access-Token` header:
### API Endpoints ### API Endpoints

View File

@ -24,7 +24,8 @@ func main() {
defer db.ClosePool(pool) defer db.ClosePool(pool)
bucketService := db.NewBucketService(pool, cfg) bucketService := db.NewBucketService(pool, cfg)
h := handlers.NewHandlers(bucketService) accountService := db.NewAccountService(pool, cfg)
h := handlers.NewHandlers(bucketService, accountService)
e := echo.New() e := echo.New()
@ -43,10 +44,15 @@ func main() {
e.GET("/", h.RootHandler) e.GET("/", h.RootHandler)
e.POST("/api/v1/accounts", h.CreateAccountHandler)
api := e.Group("/api/v1") api := e.Group("/api/v1")
api.Use(h.AuthMiddleware) api.Use(h.AuthMiddleware)
api.GET("/user", h.GetUserInfoHandler) api.GET("/account", h.GetAccountHandler)
api.POST("/account/payments", h.CreatePaymentHandler)
api.POST("/account/payments/:paymentId/confirm", h.ConfirmPaymentHandler)
api.GET("/buckets", h.ListBucketsHandler) api.GET("/buckets", h.ListBucketsHandler)
api.POST("/buckets", h.CreateBucketHandler) api.POST("/buckets", h.CreateBucketHandler)
api.DELETE("/buckets/:bucket", h.DeleteBucketHandler) api.DELETE("/buckets/:bucket", h.DeleteBucketHandler)
@ -55,12 +61,10 @@ func main() {
api.GET("/buckets/:bucket/policy", h.GetBucketPolicyHandler) api.GET("/buckets/:bucket/policy", h.GetBucketPolicyHandler)
api.DELETE("/buckets/:bucket/policy", h.DeleteBucketPolicyHandler) api.DELETE("/buckets/:bucket/policy", h.DeleteBucketPolicyHandler)
bucketRoutes := api.Group("/buckets/:bucket") api.GET("/buckets/:bucket/objects", h.ListObjectsHandler)
bucketRoutes.Use(h.PolicyEnforcementMiddleware) api.PUT("/buckets/:bucket/objects/*", h.UploadObjectHandler)
bucketRoutes.GET("/objects", h.ListObjectsHandler) api.GET("/buckets/:bucket/objects/*", h.GetObjectHandler)
bucketRoutes.PUT("/objects/*", h.UploadObjectHandler) api.DELETE("/buckets/:bucket/objects/*", h.DeleteObjectHandler)
bucketRoutes.GET("/objects/*", h.GetObjectHandler)
bucketRoutes.DELETE("/objects/*", h.DeleteObjectHandler)
e.Logger.Fatal(e.Start(":" + cfg.Port)) e.Logger.Fatal(e.Start(":" + cfg.Port))
} }

View File

@ -20,6 +20,7 @@ type Config struct {
ConnIdleTime time.Duration ConnIdleTime time.Duration
HealthCheckPeriod time.Duration HealthCheckPeriod time.Duration
DefaultStorageLimit int64 DefaultStorageLimit int64
PricePerGBUSD float64
} }
func Load() *Config { func Load() *Config {
@ -37,6 +38,7 @@ func Load() *Config {
ConnIdleTime: getEnvDuration("DB_CONN_IDLE_TIME", time.Minute), ConnIdleTime: getEnvDuration("DB_CONN_IDLE_TIME", time.Minute),
HealthCheckPeriod: getEnvDuration("DB_HEALTH_CHECK_PERIOD", 5*time.Second), HealthCheckPeriod: getEnvDuration("DB_HEALTH_CHECK_PERIOD", 5*time.Second),
DefaultStorageLimit: getEnvInt64("DEFAULT_STORAGE_LIMIT_GB", 1) * 1024 * 1024 * 1024, DefaultStorageLimit: getEnvInt64("DEFAULT_STORAGE_LIMIT_GB", 1) * 1024 * 1024 * 1024,
PricePerGBUSD: getEnvFloat64("PRICE_PER_GB_USD", 0.50),
} }
} }

293
internal/db/account.go Normal file
View File

@ -0,0 +1,293 @@
package db
import (
"context"
"crypto/rand"
"fmt"
"math/big"
"time"
"git.keircn.com/keiran/termcloud/internal/config"
"github.com/jackc/pgx/v5/pgxpool"
)
type Account struct {
ID int64 `json:"id"`
AccountNumber string `json:"accountNumber"`
AccessToken string `json:"accessToken,omitempty"`
BalanceUSD float64 `json:"balanceUsd"`
IsActive bool `json:"isActive"`
CreatedAt time.Time `json:"createdAt"`
ActivatedAt *time.Time `json:"activatedAt,omitempty"`
LastBillingDate time.Time `json:"lastBillingDate"`
}
type Payment struct {
ID int64 `json:"id"`
AccountID int64 `json:"accountId"`
PaymentType string `json:"paymentType"`
BTCAddress string `json:"btcAddress,omitempty"`
BTCAmount float64 `json:"btcAmount,omitempty"`
USDAmount float64 `json:"usdAmount"`
Confirmations int `json:"confirmations"`
TxHash string `json:"txHash,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
ConfirmedAt *time.Time `json:"confirmedAt,omitempty"`
}
type UsageRecord struct {
ID int64 `json:"id"`
AccountID int64 `json:"accountId"`
BillingPeriodStart time.Time `json:"billingPeriodStart"`
BillingPeriodEnd time.Time `json:"billingPeriodEnd"`
MaxStorageBytes int64 `json:"maxStorageBytes"`
ChargeUSD float64 `json:"chargeUsd"`
ChargedAt *time.Time `json:"chargedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type AccountService struct {
pool *pgxpool.Pool
pricePerGB float64
}
func NewAccountService(pool *pgxpool.Pool, cfg *config.Config) *AccountService {
pricePerGB := cfg.PricePerGBUSD
if pricePerGB == 0 {
pricePerGB = 0.50
}
return &AccountService{
pool: pool,
pricePerGB: pricePerGB,
}
}
func (s *AccountService) GenerateAccountNumber() string {
for {
num, _ := rand.Int(rand.Reader, big.NewInt(9999999999999999))
accountNumber := fmt.Sprintf("%016d", num)
var exists bool
s.pool.QueryRow(context.Background(),
"SELECT EXISTS(SELECT 1 FROM accounts WHERE account_number = $1)",
accountNumber).Scan(&exists)
if !exists {
return accountNumber
}
}
}
func (s *AccountService) GenerateAccessToken() string {
bytes := make([]byte, 32)
rand.Read(bytes)
return fmt.Sprintf("%x", bytes)
}
func (s *AccountService) CreateAccount(ctx context.Context) (*Account, error) {
accountNumber := s.GenerateAccountNumber()
accessToken := s.GenerateAccessToken()
var account Account
err := s.pool.QueryRow(ctx, `
INSERT INTO accounts (account_number, access_token)
VALUES ($1, $2)
RETURNING id, account_number, balance_usd, is_active, created_at, last_billing_date`,
accountNumber, accessToken).Scan(
&account.ID, &account.AccountNumber, &account.BalanceUSD,
&account.IsActive, &account.CreatedAt, &account.LastBillingDate)
if err != nil {
return nil, fmt.Errorf("failed to create account: %w", err)
}
account.AccessToken = accessToken
return &account, nil
}
func (s *AccountService) GetAccountByToken(ctx context.Context, token string) (*Account, error) {
var account Account
var activatedAt *time.Time
err := s.pool.QueryRow(ctx, `
SELECT id, account_number, balance_usd, is_active, created_at, activated_at, last_billing_date
FROM accounts WHERE access_token = $1`, token).Scan(
&account.ID, &account.AccountNumber, &account.BalanceUSD,
&account.IsActive, &account.CreatedAt, &activatedAt, &account.LastBillingDate)
if err != nil {
return nil, fmt.Errorf("account not found: %w", err)
}
account.ActivatedAt = activatedAt
return &account, nil
}
func (s *AccountService) GetAccountByNumber(ctx context.Context, accountNumber string) (*Account, error) {
var account Account
var activatedAt *time.Time
err := s.pool.QueryRow(ctx, `
SELECT id, account_number, access_token, balance_usd, is_active, created_at, activated_at, last_billing_date
FROM accounts WHERE account_number = $1`, accountNumber).Scan(
&account.ID, &account.AccountNumber, &account.AccessToken, &account.BalanceUSD,
&account.IsActive, &account.CreatedAt, &activatedAt, &account.LastBillingDate)
if err != nil {
return nil, fmt.Errorf("account not found: %w", err)
}
account.ActivatedAt = activatedAt
return &account, nil
}
func (s *AccountService) CreatePayment(ctx context.Context, accountID int64, usdAmount float64) (*Payment, error) {
btcPrice, err := s.getBTCPrice()
if err != nil {
return nil, fmt.Errorf("failed to get BTC price: %w", err)
}
btcAmount := usdAmount / btcPrice
btcAddress := s.generateBTCAddress()
var payment Payment
err = s.pool.QueryRow(ctx, `
INSERT INTO payments (account_id, payment_type, btc_address, btc_amount, usd_amount, status)
VALUES ($1, 'bitcoin', $2, $3, $4, 'pending')
RETURNING id, account_id, payment_type, btc_address, btc_amount, usd_amount, confirmations, status, created_at`,
accountID, btcAddress, btcAmount, usdAmount).Scan(
&payment.ID, &payment.AccountID, &payment.PaymentType, &payment.BTCAddress,
&payment.BTCAmount, &payment.USDAmount, &payment.Confirmations, &payment.Status, &payment.CreatedAt)
if err != nil {
return nil, fmt.Errorf("failed to create payment: %w", err)
}
return &payment, nil
}
func (s *AccountService) ConfirmPayment(ctx context.Context, paymentID int64, txHash string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var payment Payment
err = tx.QueryRow(ctx, `
SELECT account_id, usd_amount, status
FROM payments WHERE id = $1`, paymentID).Scan(
&payment.AccountID, &payment.USDAmount, &payment.Status)
if err != nil {
return fmt.Errorf("payment not found: %w", err)
}
if payment.Status != "pending" {
return fmt.Errorf("payment already processed")
}
now := time.Now()
_, err = tx.Exec(ctx, `
UPDATE payments SET status = 'confirmed', tx_hash = $1, confirmations = 6, confirmed_at = $2
WHERE id = $3`, txHash, now, paymentID)
if err != nil {
return err
}
_, err = tx.Exec(ctx, `
UPDATE accounts SET balance_usd = balance_usd + $1, is_active = TRUE, activated_at = COALESCE(activated_at, $2)
WHERE id = $3`, payment.USDAmount, now, payment.AccountID)
if err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *AccountService) RecordUsage(ctx context.Context, accountID int64, storageBytes int64) error {
now := time.Now()
billingStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
billingEnd := billingStart.AddDate(0, 1, 0).Add(-time.Second)
_, err := s.pool.Exec(ctx, `
INSERT INTO usage_records (account_id, billing_period_start, billing_period_end, max_storage_bytes)
VALUES ($1, $2, $3, $4)
ON CONFLICT (account_id, billing_period_start)
DO UPDATE SET max_storage_bytes = GREATEST(usage_records.max_storage_bytes, $4)`,
accountID, billingStart, billingEnd, storageBytes)
return err
}
func (s *AccountService) ProcessMonthlyBilling(ctx context.Context) error {
now := time.Now()
lastMonth := now.AddDate(0, -1, 0)
billingStart := time.Date(lastMonth.Year(), lastMonth.Month(), 1, 0, 0, 0, 0, lastMonth.Location())
billingEnd := billingStart.AddDate(0, 1, 0).Add(-time.Second)
rows, err := s.pool.Query(ctx, `
SELECT account_id, max_storage_bytes
FROM usage_records
WHERE billing_period_start = $1 AND charged_at IS NULL`,
billingStart)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var accountID int64
var maxStorageBytes int64
if err := rows.Scan(&accountID, &maxStorageBytes); err != nil {
continue
}
storageGB := float64(maxStorageBytes) / (1024 * 1024 * 1024)
charge := storageGB * s.pricePerGB
if charge > 0 {
err = s.chargeAccount(ctx, accountID, charge, billingStart)
if err != nil {
continue
}
}
_, err = s.pool.Exec(ctx, `
UPDATE usage_records
SET charge_usd = $1, charged_at = $2
WHERE account_id = $3 AND billing_period_start = $4`,
charge, now, accountID, billingStart)
}
return nil
}
func (s *AccountService) chargeAccount(ctx context.Context, accountID int64, amount float64, billingPeriod time.Time) error {
result, err := s.pool.Exec(ctx, `
UPDATE accounts
SET balance_usd = balance_usd - $1, last_billing_date = $2, is_active = (balance_usd - $1 >= 0)
WHERE id = $3 AND balance_usd >= $1`,
amount, billingPeriod, accountID)
if err != nil {
return err
}
rowsAffected := result.RowsAffected()
if rowsAffected == 0 {
_, err = s.pool.Exec(ctx, `
UPDATE accounts SET is_active = FALSE WHERE id = $1`, accountID)
return fmt.Errorf("insufficient balance for account %d", accountID)
}
return nil
}
func (s *AccountService) getBTCPrice() (float64, error) {
return 45000.0, nil
}
func (s *AccountService) generateBTCAddress() string {
bytes := make([]byte, 20)
rand.Read(bytes)
return fmt.Sprintf("bc1q%x", bytes)
}

View File

@ -45,12 +45,14 @@ type Object struct {
type BucketService struct { type BucketService struct {
pool *pgxpool.Pool pool *pgxpool.Pool
storageDir string storageDir string
accountService *AccountService
} }
func NewBucketService(pool *pgxpool.Pool, cfg *config.Config) *BucketService { func NewBucketService(pool *pgxpool.Pool, cfg *config.Config) *BucketService {
return &BucketService{ return &BucketService{
pool: pool, pool: pool,
storageDir: cfg.StorageDir, storageDir: cfg.StorageDir,
accountService: NewAccountService(pool, cfg),
} }
} }

View File

@ -1,16 +1,44 @@
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS accounts (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE, account_number TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE, access_token TEXT UNIQUE,
api_key TEXT UNIQUE, balance_usd DECIMAL(10,2) DEFAULT 0.00,
storage_limit_bytes BIGINT DEFAULT 1073741824, is_active BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW(),
activated_at TIMESTAMPTZ,
last_billing_date TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS payments (
id SERIAL PRIMARY KEY,
account_id INT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
payment_type VARCHAR(20) NOT NULL DEFAULT 'bitcoin',
btc_address TEXT,
btc_amount DECIMAL(18,8),
usd_amount DECIMAL(10,2),
confirmations INT DEFAULT 0,
tx_hash TEXT,
status VARCHAR(20) DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT NOW(),
confirmed_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS usage_records (
id SERIAL PRIMARY KEY,
account_id INT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
billing_period_start DATE NOT NULL,
billing_period_end DATE NOT NULL,
max_storage_bytes BIGINT DEFAULT 0,
charge_usd DECIMAL(10,2) DEFAULT 0.00,
charged_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(account_id, billing_period_start)
); );
CREATE TABLE IF NOT EXISTS buckets ( CREATE TABLE IF NOT EXISTS buckets (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
owner_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, owner_id INT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
storage_used_bytes BIGINT DEFAULT 0, storage_used_bytes BIGINT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW()
); );
@ -35,8 +63,11 @@ CREATE TABLE IF NOT EXISTS bucket_policies (
updated_at TIMESTAMPTZ DEFAULT NOW() updated_at TIMESTAMPTZ DEFAULT NOW()
); );
CREATE INDEX IF NOT EXISTS idx_accounts_account_number ON accounts (account_number);
CREATE INDEX IF NOT EXISTS idx_accounts_access_token ON accounts (access_token);
CREATE INDEX IF NOT EXISTS idx_payments_account_id ON payments (account_id);
CREATE INDEX IF NOT EXISTS idx_usage_records_account_id ON usage_records (account_id);
CREATE INDEX IF NOT EXISTS idx_buckets_owner_id ON buckets (owner_id); CREATE INDEX IF NOT EXISTS idx_buckets_owner_id ON buckets (owner_id);
CREATE INDEX IF NOT EXISTS idx_users_api_key ON users (api_key);
CREATE INDEX IF NOT EXISTS idx_bucket_policies_bucket_id ON bucket_policies (bucket_id); CREATE INDEX IF NOT EXISTS idx_bucket_policies_bucket_id ON bucket_policies (bucket_id);
CREATE INDEX IF NOT EXISTS idx_objects_bucket_id_key ON objects (bucket_id, key); CREATE INDEX IF NOT EXISTS idx_objects_bucket_id_key ON objects (bucket_id, key);
CREATE INDEX IF NOT EXISTS idx_objects_bucket_id_last_modified ON objects (bucket_id, last_modified DESC); CREATE INDEX IF NOT EXISTS idx_objects_bucket_id_last_modified ON objects (bucket_id, last_modified DESC);

View File

@ -13,10 +13,14 @@ import (
type Handlers struct { type Handlers struct {
bucketService *db.BucketService bucketService *db.BucketService
accountService *db.AccountService
} }
func NewHandlers(bucketService *db.BucketService) *Handlers { func NewHandlers(bucketService *db.BucketService, accountService *db.AccountService) *Handlers {
return &Handlers{bucketService: bucketService} return &Handlers{
bucketService: bucketService,
accountService: accountService,
}
} }
func (h *Handlers) RootHandler(c echo.Context) error { func (h *Handlers) RootHandler(c echo.Context) error {
@ -28,23 +32,27 @@ func (h *Handlers) RootHandler(c echo.Context) error {
func (h *Handlers) AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc { func (h *Handlers) AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
apiKey := c.Request().Header.Get("X-API-Key") accessToken := c.Request().Header.Get("X-Access-Token")
if apiKey == "" { if accessToken == "" {
return c.JSON(401, map[string]string{"error": "API key required"}) return c.JSON(401, map[string]string{"error": "Access token required"})
} }
user, err := h.bucketService.GetUserByAPIKey(context.Background(), apiKey) account, err := h.accountService.GetAccountByToken(context.Background(), accessToken)
if err != nil { if err != nil {
return c.JSON(401, map[string]string{"error": "Invalid API key"}) return c.JSON(401, map[string]string{"error": "Invalid access token"})
} }
c.Set("user", user) if !account.IsActive {
return c.JSON(403, map[string]string{"error": "Account inactive - please add funds"})
}
c.Set("account", account)
return next(c) return next(c)
} }
} }
func (h *Handlers) CreateBucketHandler(c echo.Context) error { func (h *Handlers) CreateBucketHandler(c echo.Context) error {
user := c.Get("user").(*db.User) account := c.Get("account").(*db.Account)
var req struct { var req struct {
Name string `json:"name"` Name string `json:"name"`
@ -57,7 +65,7 @@ func (h *Handlers) CreateBucketHandler(c echo.Context) error {
return c.JSON(400, map[string]string{"error": "Bucket name is required"}) return c.JSON(400, map[string]string{"error": "Bucket name is required"})
} }
bucket, err := h.bucketService.CreateBucket(context.Background(), req.Name, user.ID) bucket, err := h.bucketService.CreateBucket(context.Background(), req.Name, account.ID)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "duplicate key") { if strings.Contains(err.Error(), "duplicate key") {
return c.JSON(409, map[string]string{"error": "Bucket name already exists"}) return c.JSON(409, map[string]string{"error": "Bucket name already exists"})
@ -67,7 +75,6 @@ func (h *Handlers) CreateBucketHandler(c echo.Context) error {
return c.JSON(201, bucket) return c.JSON(201, bucket)
} }
func (h *Handlers) ListBucketsHandler(c echo.Context) error { func (h *Handlers) ListBucketsHandler(c echo.Context) error {
user := c.Get("user").(*db.User) user := c.Get("user").(*db.User)
@ -292,6 +299,79 @@ func (h *Handlers) DeleteBucketPolicyHandler(c echo.Context) error {
return c.JSON(200, map[string]string{"message": "Bucket policy deleted successfully"}) return c.JSON(200, map[string]string{"message": "Bucket policy deleted successfully"})
} }
func (h *Handlers) CreateAccountHandler(c echo.Context) error {
account, err := h.accountService.CreateAccount(context.Background())
if err != nil {
return c.JSON(500, map[string]string{"error": "Failed to create account"})
}
return c.JSON(201, map[string]any{
"accountNumber": account.AccountNumber,
"accessToken": account.AccessToken,
"balanceUsd": account.BalanceUSD,
"isActive": account.IsActive,
"message": "Account created. Add funds to activate.",
})
}
func (h *Handlers) GetAccountHandler(c echo.Context) error {
account := c.Get("account").(*db.Account)
return c.JSON(200, map[string]any{
"accountNumber": account.AccountNumber,
"balanceUsd": account.BalanceUSD,
"isActive": account.IsActive,
"createdAt": account.CreatedAt,
"activatedAt": account.ActivatedAt,
"lastBilling": account.LastBillingDate,
})
}
func (h *Handlers) CreatePaymentHandler(c echo.Context) error {
account := c.Get("account").(*db.Account)
var req struct {
Amount float64 `json:"amount"`
}
if err := c.Bind(&req); err != nil || req.Amount < 5.0 {
return c.JSON(400, map[string]string{"error": "Minimum payment amount is $5.00"})
}
payment, err := h.accountService.CreatePayment(context.Background(), account.ID, req.Amount)
if err != nil {
return c.JSON(500, map[string]string{"error": "Failed to create payment"})
}
return c.JSON(201, map[string]any{
"paymentId": payment.ID,
"btcAddress": payment.BTCAddress,
"btcAmount": payment.BTCAmount,
"usdAmount": payment.USDAmount,
"status": payment.Status,
"message": "Send exact BTC amount to the address above",
})
}
func (h *Handlers) ConfirmPaymentHandler(c echo.Context) error {
paymentID, err := strconv.ParseInt(c.Param("paymentId"), 10, 64)
if err != nil {
return c.JSON(400, map[string]string{"error": "Invalid payment ID"})
}
var req struct {
TxHash string `json:"txHash"`
}
if err := c.Bind(&req); err != nil || req.TxHash == "" {
return c.JSON(400, map[string]string{"error": "Transaction hash required"})
}
if err := h.accountService.ConfirmPayment(context.Background(), paymentID, req.TxHash); err != nil {
return c.JSON(500, map[string]string{"error": "Failed to confirm payment"})
}
return c.JSON(200, map[string]string{"message": "Payment confirmed and account credited"})
}
func (h *Handlers) PolicyEnforcementMiddleware(next echo.HandlerFunc) echo.HandlerFunc { func (h *Handlers) PolicyEnforcementMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
user := c.Get("user").(*db.User) user := c.Get("user").(*db.User)