implement most of the auth stuff, just need to finish billing
This commit is contained in:
parent
b765dc6c88
commit
3be0ede054
@ -18,5 +18,6 @@ DB_CONN_LIFETIME=1h
|
||||
DB_CONN_IDLE_TIME=1m
|
||||
DB_HEALTH_CHECK_PERIOD=5s
|
||||
|
||||
# User Defaults
|
||||
# User Defaults and Billing
|
||||
DEFAULT_STORAGE_LIMIT_GB=1
|
||||
PRICE_PER_GB_USD=0.50
|
||||
|
||||
56
README.md
56
README.md
@ -1,6 +1,22 @@
|
||||
# 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
|
||||
|
||||
@ -19,22 +35,12 @@ cp .env.example .env
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PRICE_PER_GB_USD` | 0.50 | Monthly charge per GB of peak storage |
|
||||
| `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:
|
||||
|
||||
```bash
|
||||
make build
|
||||
make run
|
||||
@ -42,11 +48,31 @@ make run
|
||||
|
||||
## Usage
|
||||
|
||||
### Create a user and get API key:
|
||||
|
||||
### 1. Create Account
|
||||
```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
|
||||
|
||||
|
||||
@ -24,7 +24,8 @@ func main() {
|
||||
defer db.ClosePool(pool)
|
||||
|
||||
bucketService := db.NewBucketService(pool, cfg)
|
||||
h := handlers.NewHandlers(bucketService)
|
||||
accountService := db.NewAccountService(pool, cfg)
|
||||
h := handlers.NewHandlers(bucketService, accountService)
|
||||
|
||||
e := echo.New()
|
||||
|
||||
@ -43,10 +44,15 @@ func main() {
|
||||
|
||||
e.GET("/", h.RootHandler)
|
||||
|
||||
e.POST("/api/v1/accounts", h.CreateAccountHandler)
|
||||
|
||||
api := e.Group("/api/v1")
|
||||
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.POST("/buckets", h.CreateBucketHandler)
|
||||
api.DELETE("/buckets/:bucket", h.DeleteBucketHandler)
|
||||
@ -55,12 +61,10 @@ func main() {
|
||||
api.GET("/buckets/:bucket/policy", h.GetBucketPolicyHandler)
|
||||
api.DELETE("/buckets/:bucket/policy", h.DeleteBucketPolicyHandler)
|
||||
|
||||
bucketRoutes := api.Group("/buckets/:bucket")
|
||||
bucketRoutes.Use(h.PolicyEnforcementMiddleware)
|
||||
bucketRoutes.GET("/objects", h.ListObjectsHandler)
|
||||
bucketRoutes.PUT("/objects/*", h.UploadObjectHandler)
|
||||
bucketRoutes.GET("/objects/*", h.GetObjectHandler)
|
||||
bucketRoutes.DELETE("/objects/*", h.DeleteObjectHandler)
|
||||
api.GET("/buckets/:bucket/objects", h.ListObjectsHandler)
|
||||
api.PUT("/buckets/:bucket/objects/*", h.UploadObjectHandler)
|
||||
api.GET("/buckets/:bucket/objects/*", h.GetObjectHandler)
|
||||
api.DELETE("/buckets/:bucket/objects/*", h.DeleteObjectHandler)
|
||||
|
||||
e.Logger.Fatal(e.Start(":" + cfg.Port))
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ type Config struct {
|
||||
ConnIdleTime time.Duration
|
||||
HealthCheckPeriod time.Duration
|
||||
DefaultStorageLimit int64
|
||||
PricePerGBUSD float64
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
@ -37,6 +38,7 @@ func Load() *Config {
|
||||
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,
|
||||
PricePerGBUSD: getEnvFloat64("PRICE_PER_GB_USD", 0.50),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
293
internal/db/account.go
Normal file
293
internal/db/account.go
Normal 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)
|
||||
}
|
||||
@ -45,12 +45,14 @@ type Object struct {
|
||||
type BucketService struct {
|
||||
pool *pgxpool.Pool
|
||||
storageDir string
|
||||
accountService *AccountService
|
||||
}
|
||||
|
||||
func NewBucketService(pool *pgxpool.Pool, cfg *config.Config) *BucketService {
|
||||
return &BucketService{
|
||||
pool: pool,
|
||||
storageDir: cfg.StorageDir,
|
||||
accountService: NewAccountService(pool, cfg),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,16 +1,44 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
api_key TEXT UNIQUE,
|
||||
storage_limit_bytes BIGINT DEFAULT 1073741824,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
account_number TEXT NOT NULL UNIQUE,
|
||||
access_token TEXT UNIQUE,
|
||||
balance_usd DECIMAL(10,2) DEFAULT 0.00,
|
||||
is_active BOOLEAN DEFAULT FALSE,
|
||||
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 (
|
||||
id SERIAL PRIMARY KEY,
|
||||
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,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
@ -35,8 +63,11 @@ CREATE TABLE IF NOT EXISTS bucket_policies (
|
||||
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_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_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);
|
||||
|
||||
@ -13,10 +13,14 @@ import (
|
||||
|
||||
type Handlers struct {
|
||||
bucketService *db.BucketService
|
||||
accountService *db.AccountService
|
||||
}
|
||||
|
||||
func NewHandlers(bucketService *db.BucketService) *Handlers {
|
||||
return &Handlers{bucketService: bucketService}
|
||||
func NewHandlers(bucketService *db.BucketService, accountService *db.AccountService) *Handlers {
|
||||
return &Handlers{
|
||||
bucketService: bucketService,
|
||||
accountService: accountService,
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return func(c echo.Context) error {
|
||||
apiKey := c.Request().Header.Get("X-API-Key")
|
||||
if apiKey == "" {
|
||||
return c.JSON(401, map[string]string{"error": "API key required"})
|
||||
accessToken := c.Request().Header.Get("X-Access-Token")
|
||||
if accessToken == "" {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) CreateBucketHandler(c echo.Context) error {
|
||||
user := c.Get("user").(*db.User)
|
||||
account := c.Get("account").(*db.Account)
|
||||
|
||||
var req struct {
|
||||
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"})
|
||||
}
|
||||
|
||||
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 strings.Contains(err.Error(), "duplicate key") {
|
||||
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)
|
||||
}
|
||||
|
||||
func (h *Handlers) ListBucketsHandler(c echo.Context) error {
|
||||
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"})
|
||||
}
|
||||
|
||||
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 {
|
||||
return func(c echo.Context) error {
|
||||
user := c.Get("user").(*db.User)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user