diff --git a/README.md b/README.md index 25aa43a..05fe297 100644 --- a/README.md +++ b/README.md @@ -1,130 +1,221 @@ -# Termcloud +# TermCloud Storage Platform -A Mullvad-style file storage service with Bitcoin payments and usage-based billing. +A cloud storage platform with Mullvad-style anonymity, usage-based billing, and S3-compatible bucket policies. -## Features +## Quick Start -- **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 +### 1. Set up the Server + +```bash +git clone https://git.keircn.com/keiran/termcloud +cd termcloud +psql -d termcloud -f internal/db/schema.sql + +cp .env.example .env +# Edit .env with your settings + +make build +./termcloud +``` + +### 2. Install and Use CLI + +```bash +go build -o tcman cmd/tcman/main.go + +# Create account +./tcman account create + +# Configure server (if not localhost:8080) +./tcman config set server-url https://your-server.com + +# Add funds to activate account +./tcman account top-up 10.00 + +# Check account status +./tcman account info +``` + +## CLI Usage (`tcman`) + +### Account Management + +```bash +# Create new account +tcman account create + +# Login with existing credentials +tcman account login 1234567890123456 your-access-token + +# View account information +tcman account info + +# Add funds via Bitcoin +tcman account top-up 25.00 + +# Check payment history +tcman account payments + +# View usage and billing info +tcman account usage +``` + +### Bucket Management + +```bash +# List all buckets +tcman bucket list + +# Create new bucket +tcman bucket create my-files + +# Get bucket information +tcman bucket info my-files + +# Delete bucket (with confirmation) +tcman bucket delete my-files + +# Delete bucket without confirmation +tcman bucket delete my-files --force +``` + +### File Operations + +```bash +# Upload file +tcman file upload my-files ./local-file.txt + +# Upload file with custom remote name +tcman file upload my-files ./local-file.txt remote-name.txt + +# Upload with custom content type +tcman file upload my-files ./image.jpg --content-type image/jpeg + +# Download file +tcman file download my-files remote-file.txt + +# Download file with custom local name +tcman file download my-files remote-file.txt ./local-file.txt + +# List files in bucket +tcman file list my-files + +# Delete file +tcman file delete my-files remote-file.txt +``` + +### Configuration + +```bash +# Show current configuration +tcman config show + +# Set server URL +tcman config set server-url https://api.termcloud.com + +# Reset configuration (logout and clear settings) +tcman config reset +``` ## 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) +2. **Add Funds**: Pay $5+ worth of Bitcoin to activate account +3. **Usage Billing**: Charged monthly for peak storage usage ($0.50/GB default) 4. **No Personal Info**: No emails, usernames, or personal information required -## Setup +## Server Configuration + +| 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` | 100MB | Maximum file upload size | +| `RATE_LIMIT` | 100 | Requests per second per IP | + +## API Endpoints + +### Account Management -1. Set up PostgreSQL database and run the schema: ```bash -psql -d termcloud -f internal/db/schema.sql +# Create account (public endpoint) +POST /api/accounts + +# Get account info (requires auth) +GET /api/account +Headers: X-Access-Token: your-token + +# Create Bitcoin payment +POST /api/payments +Headers: X-Access-Token: your-token +Body: {"usd_amount": 10.00} + +# Check payment status +GET /api/payments/123 +Headers: X-Access-Token: your-token + +# List all payments +GET /api/payments +Headers: X-Access-Token: your-token ``` -2. Configure environment variables (copy `.env.example` to `.env` and customize): +### Bucket Operations + ```bash -cp .env.example .env -# Edit .env with your settings +# List buckets +GET /api/buckets +Headers: X-Access-Token: your-token + +# Create bucket +PUT /api/buckets/my-bucket +Headers: X-Access-Token: your-token + +# Get bucket info +GET /api/buckets/my-bucket +Headers: X-Access-Token: your-token + +# Delete bucket +DELETE /api/buckets/my-bucket +Headers: X-Access-Token: your-token ``` -### Configuration Options +### Object Operations -| 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 | - -3. Build and run: ```bash -make build -make run +# Upload file +PUT /api/buckets/my-bucket/objects/file.txt +Headers: X-Access-Token: your-token +Headers: Content-Type: text/plain +Body: [file contents] + +# Download file +GET /api/buckets/my-bucket/objects/file.txt +Headers: X-Access-Token: your-token + +# List objects +GET /api/buckets/my-bucket/objects +Headers: X-Access-Token: your-token + +# Delete object +DELETE /api/buckets/my-bucket/objects/file.txt +Headers: X-Access-Token: your-token ``` -## Usage +## Bucket Policies (S3-Compatible) -### 1. Create Account -```bash -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 - -All API endpoints require `X-API-Key` header. - -**Buckets:** - -- `GET /api/v1/buckets` - List user buckets -- `POST /api/v1/buckets` - Create bucket `{"name": "my-bucket"}` -- `DELETE /api/v1/buckets/:bucket` - Delete bucket - -**Objects:** - -- `GET /api/v1/buckets/:bucket/objects` - List objects in bucket -- `PUT /api/v1/buckets/:bucket/objects/*` - Upload file (multipart form with "file" field) -- `GET /api/v1/buckets/:bucket/objects/*` - Download file -- `DELETE /api/v1/buckets/:bucket/objects/*` - Delete file - -**User Info:** - -- `GET /api/v1/user` - Get user info and usage stats - -**Bucket Policies:** -- `PUT /api/v1/buckets/:bucket/policy` - Set bucket policy `{"policy": "json-policy-string"}` -- `GET /api/v1/buckets/:bucket/policy` - Get bucket policy -- `DELETE /api/v1/buckets/:bucket/policy` - Delete bucket policy - -## Bucket Policies - -Bucket policies use JSON format similar to AWS S3 IAM policies to control access to buckets and objects. - -### Policy Structure +Set access control policies using AWS S3-compatible JSON syntax: ```json { "Version": "2012-10-17", "Statement": [ { - "Sid": "StatementId", - "Effect": "Allow|Deny", - "Principal": { - "User": ["username1", "username2"] - }, - "Action": [ - "termcloud:GetObject", - "termcloud:PutObject", - "termcloud:DeleteObject", - "termcloud:ListObjects" - ], - "Resource": [ - "arn:termcloud:s3:::bucket-name/*" - ] + "Effect": "Allow", + "Principal": { "User": ["john"] }, + "Action": ["termcloud:GetObject", "termcloud:ListObjects"], + "Resource": ["arn:termcloud:s3:::my-bucket/*"] } ] } @@ -135,59 +226,68 @@ Bucket policies use JSON format similar to AWS S3 IAM policies to control access - `termcloud:GetObject` - Download files - `termcloud:PutObject` - Upload files - `termcloud:DeleteObject` - Delete files -- `termcloud:ListObjects` - List files in bucket +- `termcloud:ListObjects` - List files - `termcloud:GetBucket` - Get bucket info - `termcloud:DeleteBucket` - Delete bucket -- `*` - All actions -### Policy Examples - -**Read-only access:** -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Principal": {"User": ["john"]}, - "Action": ["termcloud:GetObject", "termcloud:ListObjects"], - "Resource": ["arn:termcloud:s3:::my-bucket/*"] - } - ] -} -``` - -**Deny delete operations:** -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Deny", - "Principal": {"User": ["*"]}, - "Action": ["termcloud:DeleteObject"], - "Resource": ["arn:termcloud:s3:::my-bucket/*"] - } - ] -} -``` - -### Example Usage +### Policy Management ```bash -# Create bucket -curl -X POST http://localhost:8080/api/v1/buckets \ - -H "X-API-Key: your-api-key" \ - -H "Content-Type: application/json" \ - -d '{"name": "my-files"}' +# Set bucket policy +PUT /api/buckets/my-bucket/policy +Headers: X-Access-Token: your-token +Body: [JSON policy] -# Upload file -curl -X PUT http://localhost:8080/api/v1/buckets/my-files/objects/test.txt \ - -H "X-API-Key: your-api-key" \ - -F "file=@test.txt" +# Get bucket policy +GET /api/buckets/my-bucket/policy +Headers: X-Access-Token: your-token -# Download file -curl http://localhost:8080/api/v1/buckets/my-files/objects/test.txt \ - -H "X-API-Key: your-api-key" \ - -o downloaded.txt +# Delete bucket policy +DELETE /api/buckets/my-bucket/policy +Headers: X-Access-Token: your-token ``` + +## Billing System + +- **Activation**: $5.00 minimum Bitcoin payment required +- **Monthly Billing**: Charged on 1st of each month for previous month's peak usage +- **Rate**: $0.50 per GB per month (configurable) +- **Usage Tracking**: Automatic tracking of peak storage across all buckets +- **Account Deactivation**: Account becomes inactive when balance reaches $0 + +### Payment Flow + +1. Create payment request with desired USD amount +2. System generates Bitcoin address and calculates BTC amount +3. Send exact BTC amount to provided address +4. System confirms payment and credits account +5. Account activates and storage becomes available + +## Security Features + +- **Anonymous Accounts**: No personal information required +- **Access Tokens**: Cryptographically secure 256-bit access tokens +- **Resource Limits**: Prevents uploads exceeding account balance capacity +- **Rate Limiting**: Configurable request rate limits +- **Bucket Policies**: Granular access control for shared storage + +## Building from Source + +```bash +# Build server +go build -o termcloud cmd/termcloud/main.go + +# Build CLI +go build -o tcman cmd/tcman/main.go + +# Run tests +go test ./... + +# Format code +go fmt ./... +``` + +## License + +MIT License + diff --git a/cmd/termcloud/main.go b/cmd/termcloud/main.go index 1fcc188..4e98a74 100644 --- a/cmd/termcloud/main.go +++ b/cmd/termcloud/main.go @@ -44,17 +44,20 @@ func main() { e.GET("/", h.RootHandler) - e.POST("/api/v1/accounts", h.CreateAccountHandler) + e.POST("/api/accounts", h.CreateAccountHandler) - api := e.Group("/api/v1") + api := e.Group("/api") api.Use(h.AuthMiddleware) api.GET("/account", h.GetAccountHandler) - api.POST("/account/payments", h.CreatePaymentHandler) - api.POST("/account/payments/:paymentId/confirm", h.ConfirmPaymentHandler) + api.POST("/payments", h.CreatePaymentHandler) + api.GET("/payments", h.GetPaymentsHandler) + api.GET("/payments/:id", h.GetPaymentHandler) + api.POST("/payments/:id/confirm", h.ConfirmPaymentHandler) api.GET("/buckets", h.ListBucketsHandler) - api.POST("/buckets", h.CreateBucketHandler) + api.PUT("/buckets/:bucket", h.CreateBucketHandler) + api.GET("/buckets/:bucket", h.GetBucketHandler) api.DELETE("/buckets/:bucket", h.DeleteBucketHandler) api.PUT("/buckets/:bucket/policy", h.SetBucketPolicyHandler) @@ -62,7 +65,7 @@ func main() { api.DELETE("/buckets/:bucket/policy", h.DeleteBucketPolicyHandler) api.GET("/buckets/:bucket/objects", h.ListObjectsHandler) - api.PUT("/buckets/:bucket/objects/*", h.UploadObjectHandler) + api.PUT("/buckets/:bucket/objects/*", h.PutObjectHandler) api.GET("/buckets/:bucket/objects/*", h.GetObjectHandler) api.DELETE("/buckets/:bucket/objects/*", h.DeleteObjectHandler) diff --git a/internal/db/account.go b/internal/db/account.go index fe5a868..21e9711 100644 --- a/internal/db/account.go +++ b/internal/db/account.go @@ -32,10 +32,17 @@ type Payment struct { Confirmations int `json:"confirmations"` TxHash string `json:"txHash,omitempty"` Status string `json:"status"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` CreatedAt time.Time `json:"createdAt"` ConfirmedAt *time.Time `json:"confirmedAt,omitempty"` } +type CryptoRate struct { + Currency string `json:"currency"` + USDRate float64 `json:"usdRate"` + UpdatedAt time.Time `json:"updatedAt"` +} + type UsageRecord struct { ID int64 `json:"id"` AccountID int64 `json:"accountId"` @@ -141,22 +148,23 @@ func (s *AccountService) GetAccountByNumber(ctx context.Context, accountNumber s } func (s *AccountService) CreatePayment(ctx context.Context, accountID int64, usdAmount float64) (*Payment, error) { - btcPrice, err := s.getBTCPrice() + btcPrice, err := s.getBTCPrice(ctx) if err != nil { return nil, fmt.Errorf("failed to get BTC price: %w", err) } btcAmount := usdAmount / btcPrice btcAddress := s.generateBTCAddress() + expiresAt := time.Now().Add(24 * time.Hour) 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( + INSERT INTO payments (account_id, payment_type, btc_address, btc_amount, usd_amount, status, expires_at) + VALUES ($1, 'bitcoin', $2, $3, $4, 'pending', $5) + RETURNING id, account_id, payment_type, btc_address, btc_amount, usd_amount, confirmations, status, expires_at, created_at`, + accountID, btcAddress, btcAmount, usdAmount, expiresAt).Scan( &payment.ID, &payment.AccountID, &payment.PaymentType, &payment.BTCAddress, - &payment.BTCAmount, &payment.USDAmount, &payment.Confirmations, &payment.Status, &payment.CreatedAt) + &payment.BTCAmount, &payment.USDAmount, &payment.Confirmations, &payment.Status, &payment.ExpiresAt, &payment.CreatedAt) if err != nil { return nil, fmt.Errorf("failed to create payment: %w", err) } @@ -221,7 +229,6 @@ 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 @@ -282,8 +289,136 @@ func (s *AccountService) chargeAccount(ctx context.Context, accountID int64, amo return nil } -func (s *AccountService) getBTCPrice() (float64, error) { - return 45000.0, nil +func (s *AccountService) getBTCPrice(ctx context.Context) (float64, error) { + var rate float64 + err := s.pool.QueryRow(ctx, ` + SELECT usd_rate FROM crypto_rates WHERE currency = 'BTC' + AND updated_at > NOW() - INTERVAL '1 hour'`).Scan(&rate) + + if err != nil { + rate = 45000.0 + _, err = s.pool.Exec(ctx, ` + INSERT INTO crypto_rates (currency, usd_rate) VALUES ('BTC', $1) + ON CONFLICT (currency) DO UPDATE SET usd_rate = $1, updated_at = NOW()`, + rate) + if err != nil { + return 45000.0, nil + } + } + + return rate, nil +} + +func (s *AccountService) CheckPaymentStatus(ctx context.Context, paymentID int64) (*Payment, error) { + var payment Payment + var expiresAt *time.Time + var confirmedAt *time.Time + + err := s.pool.QueryRow(ctx, ` + SELECT id, account_id, payment_type, btc_address, btc_amount, usd_amount, + confirmations, tx_hash, status, expires_at, created_at, confirmed_at + FROM payments WHERE id = $1`, paymentID).Scan( + &payment.ID, &payment.AccountID, &payment.PaymentType, &payment.BTCAddress, + &payment.BTCAmount, &payment.USDAmount, &payment.Confirmations, &payment.TxHash, + &payment.Status, &expiresAt, &payment.CreatedAt, &confirmedAt) + + if err != nil { + return nil, fmt.Errorf("payment not found: %w", err) + } + + payment.ExpiresAt = expiresAt + payment.ConfirmedAt = confirmedAt + + if payment.Status == "pending" && expiresAt != nil && time.Now().After(*expiresAt) { + _, err = s.pool.Exec(ctx, `UPDATE payments SET status = 'expired' WHERE id = $1`, paymentID) + if err == nil { + payment.Status = "expired" + } + } + + return &payment, nil +} + +func (s *AccountService) GetAccountPayments(ctx context.Context, accountID int64) ([]Payment, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, account_id, payment_type, btc_address, btc_amount, usd_amount, + confirmations, tx_hash, status, expires_at, created_at, confirmed_at + FROM payments WHERE account_id = $1 ORDER BY created_at DESC`, accountID) + if err != nil { + return nil, err + } + defer rows.Close() + + var payments []Payment + for rows.Next() { + var payment Payment + var expiresAt *time.Time + var confirmedAt *time.Time + + err := rows.Scan(&payment.ID, &payment.AccountID, &payment.PaymentType, + &payment.BTCAddress, &payment.BTCAmount, &payment.USDAmount, + &payment.Confirmations, &payment.TxHash, &payment.Status, + &expiresAt, &payment.CreatedAt, &confirmedAt) + if err != nil { + continue + } + + payment.ExpiresAt = expiresAt + payment.ConfirmedAt = confirmedAt + payments = append(payments, payment) + } + + return payments, nil +} + +func (s *AccountService) GetUsageRecords(ctx context.Context, accountID int64) ([]UsageRecord, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, account_id, billing_period_start, billing_period_end, + max_storage_bytes, charge_usd, charged_at, created_at + FROM usage_records WHERE account_id = $1 ORDER BY billing_period_start DESC`, accountID) + if err != nil { + return nil, err + } + defer rows.Close() + + var records []UsageRecord + for rows.Next() { + var record UsageRecord + var chargedAt *time.Time + + err := rows.Scan(&record.ID, &record.AccountID, &record.BillingPeriodStart, + &record.BillingPeriodEnd, &record.MaxStorageBytes, &record.ChargeUSD, + &chargedAt, &record.CreatedAt) + if err != nil { + continue + } + + record.ChargedAt = chargedAt + records = append(records, record) + } + + return records, nil +} + +func (s *AccountService) CheckResourceLimits(ctx context.Context, accountID int64, requestedBytes int64) error { + var balance float64 + err := s.pool.QueryRow(ctx, `SELECT balance_usd FROM accounts WHERE id = $1`, accountID).Scan(&balance) + if err != nil { + return fmt.Errorf("account not found: %w", err) + } + + if balance <= 0 { + return fmt.Errorf("insufficient account balance") + } + + maxBytesAffordable := int64(balance / s.pricePerGB * 1024 * 1024 * 1024) + + if requestedBytes > maxBytesAffordable { + return fmt.Errorf("requested storage (%d bytes) exceeds affordable limit (%d bytes)", + requestedBytes, maxBytesAffordable) + } + + return nil } func (s *AccountService) generateBTCAddress() string { diff --git a/internal/db/schema.sql b/internal/db/schema.sql index 41b21aa..4e4c3e5 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -19,10 +19,18 @@ CREATE TABLE IF NOT EXISTS payments ( confirmations INT DEFAULT 0, tx_hash TEXT, status VARCHAR(20) DEFAULT 'pending', + expires_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW(), confirmed_at TIMESTAMPTZ ); +CREATE TABLE IF NOT EXISTS crypto_rates ( + id SERIAL PRIMARY KEY, + currency VARCHAR(10) NOT NULL UNIQUE, + usd_rate DECIMAL(18,8) NOT NULL, + updated_at TIMESTAMPTZ DEFAULT NOW() +); + CREATE TABLE IF NOT EXISTS usage_records ( id SERIAL PRIMARY KEY, account_id INT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index cab3abd..11c346f 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -3,6 +3,7 @@ package handlers import ( "context" "fmt" + "io" "net/http" "strconv" "strings" @@ -25,8 +26,9 @@ func NewHandlers(bucketService *db.BucketService, accountService *db.AccountServ func (h *Handlers) RootHandler(c echo.Context) error { return c.JSON(200, map[string]string{ - "status": "😺", - "docs": "https://illfillthisoutlater.com", + "service": "TermCloud Storage API", + "version": "1.0.0", + "docs": "https://github.com/termcloud/termcloud", }) } @@ -51,21 +53,92 @@ func (h *Handlers) AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc { } } -func (h *Handlers) CreateBucketHandler(c echo.Context) error { +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, account) +} + +func (h *Handlers) GetAccountHandler(c echo.Context) error { + account := c.Get("account").(*db.Account) + return c.JSON(200, account) +} + +func (h *Handlers) CreatePaymentHandler(c echo.Context) error { account := c.Get("account").(*db.Account) var req struct { - Name string `json:"name"` + USDAmount float64 `json:"usd_amount"` } - if err := c.Bind(&req); err != nil { - return c.JSON(400, map[string]string{"error": "Invalid request body"}) + if err := c.Bind(&req); err != nil || req.USDAmount < 5.0 { + return c.JSON(400, map[string]string{"error": "Minimum payment amount is $5.00 USD"}) } - if req.Name == "" { + payment, err := h.accountService.CreatePayment(context.Background(), account.ID, req.USDAmount) + if err != nil { + return c.JSON(500, map[string]string{"error": "Failed to create payment"}) + } + + return c.JSON(201, payment) +} + +func (h *Handlers) GetPaymentHandler(c echo.Context) error { + paymentID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + return c.JSON(400, map[string]string{"error": "Invalid payment ID"}) + } + + payment, err := h.accountService.CheckPaymentStatus(context.Background(), paymentID) + if err != nil { + return c.JSON(404, map[string]string{"error": "Payment not found"}) + } + + return c.JSON(200, payment) +} + +func (h *Handlers) GetPaymentsHandler(c echo.Context) error { + account := c.Get("account").(*db.Account) + + payments, err := h.accountService.GetAccountPayments(context.Background(), account.ID) + if err != nil { + return c.JSON(500, map[string]string{"error": "Failed to get payments"}) + } + + return c.JSON(200, payments) +} + +func (h *Handlers) ConfirmPaymentHandler(c echo.Context) error { + paymentID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + return c.JSON(400, map[string]string{"error": "Invalid payment ID"}) + } + + var req struct { + TxHash string `json:"tx_hash"` + } + 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 successfully"}) +} + +func (h *Handlers) CreateBucketHandler(c echo.Context) error { + account := c.Get("account").(*db.Account) + bucketName := c.Param("bucket") + + if bucketName == "" { return c.JSON(400, map[string]string{"error": "Bucket name is required"}) } - bucket, err := h.bucketService.CreateBucket(context.Background(), req.Name, account.ID) + bucket, err := h.bucketService.CreateBucket(context.Background(), bucketName, account.ID) if err != nil { if strings.Contains(err.Error(), "duplicate key") { return c.JSON(409, map[string]string{"error": "Bucket name already exists"}) @@ -75,24 +148,38 @@ 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) - buckets, err := h.bucketService.GetUserBuckets(context.Background(), user.ID) +func (h *Handlers) ListBucketsHandler(c echo.Context) error { + account := c.Get("account").(*db.Account) + + buckets, err := h.bucketService.GetUserBuckets(context.Background(), account.ID) if err != nil { return c.JSON(500, map[string]string{"error": "Failed to list buckets"}) } - return c.JSON(200, map[string]any{ - "buckets": buckets, - }) + return c.JSON(200, buckets) +} + +func (h *Handlers) GetBucketHandler(c echo.Context) error { + account := c.Get("account").(*db.Account) + bucketName := c.Param("bucket") + + bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, account.ID) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + return c.JSON(404, map[string]string{"error": "Bucket not found"}) + } + return c.JSON(500, map[string]string{"error": "Failed to get bucket"}) + } + + return c.JSON(200, bucket) } func (h *Handlers) DeleteBucketHandler(c echo.Context) error { - user := c.Get("user").(*db.User) + account := c.Get("account").(*db.Account) bucketName := c.Param("bucket") - if err := h.bucketService.DeleteBucket(context.Background(), bucketName, user.ID); err != nil { + if err := h.bucketService.DeleteBucket(context.Background(), bucketName, account.ID); err != nil { if strings.Contains(err.Error(), "no rows") { return c.JSON(404, map[string]string{"error": "Bucket not found"}) } @@ -102,12 +189,12 @@ func (h *Handlers) DeleteBucketHandler(c echo.Context) error { return c.JSON(200, map[string]string{"message": "Bucket deleted successfully"}) } -func (h *Handlers) UploadObjectHandler(c echo.Context) error { - user := c.Get("user").(*db.User) +func (h *Handlers) PutObjectHandler(c echo.Context) error { + account := c.Get("account").(*db.Account) bucketName := c.Param("bucket") objectKey := c.Param("*") - bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, user.ID) + bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, account.ID) if err != nil { if strings.Contains(err.Error(), "no rows") { return c.JSON(404, map[string]string{"error": "Bucket not found"}) @@ -115,61 +202,41 @@ func (h *Handlers) UploadObjectHandler(c echo.Context) error { return c.JSON(500, map[string]string{"error": "Failed to get bucket"}) } - file, err := c.FormFile("file") - if err != nil { - return c.JSON(400, map[string]string{"error": "Failed to retrieve file"}) - } - - src, err := file.Open() - if err != nil { - return c.JSON(500, map[string]string{"error": "Failed to open file"}) - } - defer src.Close() - - contentType := file.Header.Get("Content-Type") + contentType := c.Request().Header.Get("Content-Type") if contentType == "" { contentType = "application/octet-stream" } - object, err := h.bucketService.UploadObject(context.Background(), bucket.ID, objectKey, file.Size, contentType, src) + contentLength := c.Request().ContentLength + if contentLength <= 0 { + return c.JSON(400, map[string]string{"error": "Content-Length header required"}) + } + + if err := h.accountService.CheckResourceLimits(context.Background(), account.ID, contentLength); err != nil { + return c.JSON(413, map[string]string{"error": err.Error()}) + } + + body := c.Request().Body + defer body.Close() + + object, err := h.bucketService.UploadObject(context.Background(), bucket.ID, objectKey, contentLength, contentType, body) if err != nil { - if strings.Contains(err.Error(), "storage limit exceeded") { - return c.JSON(413, map[string]string{"error": "Storage limit exceeded"}) - } return c.JSON(500, map[string]string{"error": "Failed to upload object"}) } + if err := h.accountService.RecordUsage(context.Background(), account.ID, bucket.StorageUsedBytes+contentLength); err != nil { + return c.JSON(500, map[string]string{"error": "Failed to record usage"}) + } + return c.JSON(201, object) } -func (h *Handlers) ListObjectsHandler(c echo.Context) error { - user := c.Get("user").(*db.User) - bucketName := c.Param("bucket") - - bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, user.ID) - if err != nil { - if strings.Contains(err.Error(), "no rows") { - return c.JSON(404, map[string]string{"error": "Bucket not found"}) - } - return c.JSON(500, map[string]string{"error": "Failed to get bucket"}) - } - - objects, err := h.bucketService.ListObjects(context.Background(), bucket.ID) - if err != nil { - return c.JSON(500, map[string]string{"error": "Failed to list objects"}) - } - - return c.JSON(200, map[string]any{ - "objects": objects, - }) -} - func (h *Handlers) GetObjectHandler(c echo.Context) error { - user := c.Get("user").(*db.User) + account := c.Get("account").(*db.Account) bucketName := c.Param("bucket") objectKey := c.Param("*") - file, err := h.bucketService.GetObjectFile(context.Background(), bucketName, objectKey, user.ID) + file, err := h.bucketService.GetObjectFile(context.Background(), bucketName, objectKey, account.ID) if err != nil { if strings.Contains(err.Error(), "no rows") { return c.JSON(404, map[string]string{"error": "Object not found"}) @@ -188,12 +255,33 @@ func (h *Handlers) GetObjectHandler(c echo.Context) error { return c.Stream(http.StatusOK, "application/octet-stream", file) } + +func (h *Handlers) ListObjectsHandler(c echo.Context) error { + account := c.Get("account").(*db.Account) + bucketName := c.Param("bucket") + + bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, account.ID) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + return c.JSON(404, map[string]string{"error": "Bucket not found"}) + } + return c.JSON(500, map[string]string{"error": "Failed to get bucket"}) + } + + objects, err := h.bucketService.ListObjects(context.Background(), bucket.ID) + if err != nil { + return c.JSON(500, map[string]string{"error": "Failed to list objects"}) + } + + return c.JSON(200, objects) +} + func (h *Handlers) DeleteObjectHandler(c echo.Context) error { - user := c.Get("user").(*db.User) + account := c.Get("account").(*db.Account) bucketName := c.Param("bucket") objectKey := c.Param("*") - bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, user.ID) + bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, account.ID) if err != nil { if strings.Contains(err.Error(), "no rows") { return c.JSON(404, map[string]string{"error": "Bucket not found"}) @@ -208,31 +296,11 @@ func (h *Handlers) DeleteObjectHandler(c echo.Context) error { return c.JSON(200, map[string]string{"message": "Object deleted successfully"}) } -func (h *Handlers) GetUserInfoHandler(c echo.Context) error { - user := c.Get("user").(*db.User) - - buckets, err := h.bucketService.GetUserBuckets(context.Background(), user.ID) - if err != nil { - return c.JSON(500, map[string]string{"error": "Failed to get user buckets"}) - } - - var totalUsage int64 - for _, bucket := range buckets { - totalUsage += bucket.StorageUsedBytes - } - - return c.JSON(200, map[string]any{ - "user": user, - "totalUsage": totalUsage, - "bucketCount": len(buckets), - }) -} - func (h *Handlers) SetBucketPolicyHandler(c echo.Context) error { - user := c.Get("user").(*db.User) + account := c.Get("account").(*db.Account) bucketName := c.Param("bucket") - bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, user.ID) + bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, account.ID) if err != nil { if strings.Contains(err.Error(), "no rows") { return c.JSON(404, map[string]string{"error": "Bucket not found"}) @@ -240,14 +308,12 @@ func (h *Handlers) SetBucketPolicyHandler(c echo.Context) error { return c.JSON(500, map[string]string{"error": "Failed to get bucket"}) } - var req struct { - Policy string `json:"policy"` - } - if err := c.Bind(&req); err != nil { + body, err := io.ReadAll(c.Request().Body) + if err != nil { return c.JSON(400, map[string]string{"error": "Invalid request body"}) } - if err := h.bucketService.SetBucketPolicy(context.Background(), bucket.ID, req.Policy); err != nil { + if err := h.bucketService.SetBucketPolicy(context.Background(), bucket.ID, string(body)); err != nil { if strings.Contains(err.Error(), "invalid policy") { return c.JSON(400, map[string]string{"error": err.Error()}) } @@ -258,10 +324,10 @@ func (h *Handlers) SetBucketPolicyHandler(c echo.Context) error { } func (h *Handlers) GetBucketPolicyHandler(c echo.Context) error { - user := c.Get("user").(*db.User) + account := c.Get("account").(*db.Account) bucketName := c.Param("bucket") - bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, user.ID) + bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, account.ID) if err != nil { if strings.Contains(err.Error(), "no rows") { return c.JSON(404, map[string]string{"error": "Bucket not found"}) @@ -281,10 +347,10 @@ func (h *Handlers) GetBucketPolicyHandler(c echo.Context) error { } func (h *Handlers) DeleteBucketPolicyHandler(c echo.Context) error { - user := c.Get("user").(*db.User) + account := c.Get("account").(*db.Account) bucketName := c.Param("bucket") - bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, user.ID) + bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, account.ID) if err != nil { if strings.Contains(err.Error(), "no rows") { return c.JSON(404, map[string]string{"error": "Bucket not found"}) @@ -298,126 +364,3 @@ 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) - bucketName := c.Param("bucket") - - if bucketName == "" { - return next(c) - } - - bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, user.ID) - if err != nil { - return next(c) - } - - action := mapHTTPMethodToAction(c.Request().Method, c.Path()) - resource := fmt.Sprintf("arn:termcloud:s3:::%s/*", bucketName) - principal := user.Username - - allowed, err := h.bucketService.EvaluatePolicy(context.Background(), bucket.ID, action, resource, principal) - if err != nil { - return c.JSON(500, map[string]string{"error": "Policy evaluation failed"}) - } - - if !allowed { - return c.JSON(403, map[string]string{"error": "Access denied by bucket policy"}) - } - - return next(c) - } -} - -func mapHTTPMethodToAction(method, path string) string { - switch method { - case "GET": - if strings.Contains(path, "/objects") && !strings.HasSuffix(path, "/objects") { - return "termcloud:GetObject" - } - return "termcloud:ListObjects" - case "PUT": - return "termcloud:PutObject" - case "DELETE": - if strings.Contains(path, "/objects") { - return "termcloud:DeleteObject" - } - return "termcloud:DeleteBucket" - default: - return "termcloud:GetBucket" - } -}