finish the billing stuff on the backend

This commit is contained in:
Keiran 2025-08-07 19:10:05 +01:00
parent 3be0ede054
commit 46e3901470
5 changed files with 567 additions and 378 deletions

396
README.md
View File

@ -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

View File

@ -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)

View File

@ -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 {

View File

@ -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,

View File

@ -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"
}
}