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_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
|
||||||
|
|||||||
56
README.md
56
README.md
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
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 {
|
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user