Compare commits

...

2 Commits

Author SHA1 Message Date
Keiran
efd008f12a add a user-facing cli 2025-08-07 19:10:13 +01:00
Keiran
46e3901470 finish the billing stuff on the backend 2025-08-07 19:10:05 +01:00
14 changed files with 1783 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

27
cmd/tcman/main.go Normal file
View File

@ -0,0 +1,27 @@
package main
import (
"fmt"
"os"
"git.keircn.com/keiran/termcloud/internal/cli"
"github.com/spf13/cobra"
)
func main() {
rootCmd := &cobra.Command{
Use: "tcman",
Short: "TermCloud Management CLI",
Long: "Command line interface for managing TermCloud accounts, buckets, and files",
}
rootCmd.AddCommand(cli.NewAccountCmd())
rootCmd.AddCommand(cli.NewBucketCmd())
rootCmd.AddCommand(cli.NewFileCmd())
rootCmd.AddCommand(cli.NewConfigCmd())
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}

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)

3
go.mod
View File

@ -9,12 +9,15 @@ require (
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/crypto v0.38.0 // indirect

8
go.sum
View File

@ -1,6 +1,9 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@ -19,6 +22,11 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=

266
internal/cli/account.go Normal file
View File

@ -0,0 +1,266 @@
package cli
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
)
func NewAccountCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "account",
Short: "Manage your TermCloud account",
Long: "Create, authenticate, and manage your TermCloud account",
}
cmd.AddCommand(newAccountCreateCmd())
cmd.AddCommand(newAccountLoginCmd())
cmd.AddCommand(newAccountInfoCmd())
cmd.AddCommand(newAccountTopUpCmd())
cmd.AddCommand(newAccountPaymentsCmd())
cmd.AddCommand(newAccountUsageCmd())
return cmd
}
func newAccountCreateCmd() *cobra.Command {
return &cobra.Command{
Use: "create",
Short: "Create a new TermCloud account",
RunE: func(cmd *cobra.Command, args []string) error {
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
client := NewClient(config.ServerURL, "")
ctx := context.Background()
account, err := client.CreateAccount(ctx)
if err != nil {
return fmt.Errorf("failed to create account: %w", err)
}
config.AccountNumber = account.AccountNumber
if err := config.Save(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
fmt.Printf("Account created successfully!\n")
fmt.Printf("Account Number: %s\n", account.AccountNumber)
fmt.Printf("Status: %s\n", getAccountStatus(account))
fmt.Printf("\nTo activate your account, add funds using: tcman account top-up\n")
return nil
},
}
}
func newAccountLoginCmd() *cobra.Command {
return &cobra.Command{
Use: "login <account-number> <access-token>",
Short: "Login to an existing account",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
accountNumber := args[0]
accessToken := args[1]
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
client := NewClient(config.ServerURL, accessToken)
ctx := context.Background()
account, err := client.GetAccount(ctx)
if err != nil {
return fmt.Errorf("failed to authenticate: %w", err)
}
if account.AccountNumber != accountNumber {
return fmt.Errorf("account number mismatch")
}
config.AccountNumber = accountNumber
config.AccessToken = accessToken
if err := config.Save(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
fmt.Printf("Successfully logged in!\n")
fmt.Printf("Account: %s\n", account.AccountNumber)
fmt.Printf("Status: %s\n", getAccountStatus(account))
fmt.Printf("Balance: $%.2f USD\n", account.BalanceUSD)
return nil
},
}
}
func newAccountInfoCmd() *cobra.Command {
return &cobra.Command{
Use: "info",
Short: "Show account information",
RunE: func(cmd *cobra.Command, args []string) error {
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if !config.IsAuthenticated() {
return fmt.Errorf("not logged in. Use 'tcman account login' or 'tcman account create'")
}
client := NewClient(config.ServerURL, config.AccessToken)
ctx := context.Background()
account, err := client.GetAccount(ctx)
if err != nil {
return fmt.Errorf("failed to get account info: %w", err)
}
fmt.Printf("Account Information:\n")
fmt.Printf(" Number: %s\n", account.AccountNumber)
fmt.Printf(" Status: %s\n", getAccountStatus(account))
fmt.Printf(" Balance: $%.2f USD\n", account.BalanceUSD)
fmt.Printf(" Created: %s\n", account.CreatedAt.Format("2006-01-02 15:04:05"))
if account.ActivatedAt != nil {
fmt.Printf(" Activated: %s\n", account.ActivatedAt.Format("2006-01-02 15:04:05"))
}
fmt.Printf(" Last Billing: %s\n", account.LastBillingDate.Format("2006-01-02"))
return nil
},
}
}
func newAccountTopUpCmd() *cobra.Command {
return &cobra.Command{
Use: "top-up <amount>",
Short: "Add funds to your account via Bitcoin",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
amount, err := strconv.ParseFloat(args[0], 64)
if err != nil {
return fmt.Errorf("invalid amount: %w", err)
}
if amount < 5.0 {
return fmt.Errorf("minimum top-up amount is $5.00 USD")
}
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if !config.IsAuthenticated() {
return fmt.Errorf("not logged in. Use 'tcman account login' or 'tcman account create'")
}
client := NewClient(config.ServerURL, config.AccessToken)
ctx := context.Background()
payment, err := client.CreatePayment(ctx, amount)
if err != nil {
return fmt.Errorf("failed to create payment: %w", err)
}
fmt.Printf("Bitcoin Payment Created:\n")
fmt.Printf(" Payment ID: %d\n", payment.ID)
fmt.Printf(" Amount: $%.2f USD (%.8f BTC)\n", payment.USDAmount, payment.BTCAmount)
fmt.Printf(" Address: %s\n", payment.BTCAddress)
fmt.Printf(" Status: %s\n", strings.ToUpper(payment.Status))
if payment.ExpiresAt != nil {
fmt.Printf(" Expires: %s\n", payment.ExpiresAt.Format("2006-01-02 15:04:05"))
}
fmt.Printf("\nSend exactly %.8f BTC to the address above.\n", payment.BTCAmount)
fmt.Printf("Check payment status with: tcman account payments\n")
return nil
},
}
}
func newAccountPaymentsCmd() *cobra.Command {
return &cobra.Command{
Use: "payments",
Short: "List payment history",
RunE: func(cmd *cobra.Command, args []string) error {
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if !config.IsAuthenticated() {
return fmt.Errorf("not logged in. Use 'tcman account login' or 'tcman account create'")
}
client := NewClient(config.ServerURL, config.AccessToken)
ctx := context.Background()
payments, err := client.GetPayments(ctx)
if err != nil {
return fmt.Errorf("failed to get payments: %w", err)
}
if len(payments) == 0 {
fmt.Printf("No payments found.\n")
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tAmount\tBTC Amount\tStatus\tCreated\tConfirmed")
fmt.Fprintln(w, "---\t------\t----------\t------\t-------\t---------")
for _, payment := range payments {
confirmedStr := "N/A"
if payment.ConfirmedAt != nil {
confirmedStr = payment.ConfirmedAt.Format("2006-01-02 15:04")
}
fmt.Fprintf(w, "%d\t$%.2f\t%.8f\t%s\t%s\t%s\n",
payment.ID,
payment.USDAmount,
payment.BTCAmount,
strings.ToUpper(payment.Status),
payment.CreatedAt.Format("2006-01-02 15:04"),
confirmedStr,
)
}
w.Flush()
return nil
},
}
}
func newAccountUsageCmd() *cobra.Command {
return &cobra.Command{
Use: "usage",
Short: "Show billing and usage history",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Printf("Usage tracking is handled automatically.\n")
fmt.Printf("You are charged monthly based on peak storage usage.\n")
fmt.Printf("Current rate: $0.50 per GB per month.\n")
fmt.Printf("\nUse 'tcman account info' to see your current balance.\n")
return nil
},
}
}
func getAccountStatus(account *Account) string {
if !account.IsActive {
return "INACTIVE (needs funds)"
}
if account.BalanceUSD <= 0 {
return "LOW BALANCE"
}
return "ACTIVE"
}

221
internal/cli/bucket.go Normal file
View File

@ -0,0 +1,221 @@
package cli
import (
"context"
"fmt"
"os"
"text/tabwriter"
"github.com/spf13/cobra"
)
func NewBucketCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "bucket",
Short: "Manage storage buckets",
Long: "Create, list, and manage your storage buckets",
}
cmd.AddCommand(newBucketCreateCmd())
cmd.AddCommand(newBucketListCmd())
cmd.AddCommand(newBucketDeleteCmd())
cmd.AddCommand(newBucketInfoCmd())
return cmd
}
func newBucketCreateCmd() *cobra.Command {
return &cobra.Command{
Use: "create <bucket-name>",
Short: "Create a new bucket",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if !config.IsAuthenticated() {
return fmt.Errorf("not logged in. Use 'tcman account login' or 'tcman account create'")
}
bucketName := args[0]
client := NewClient(config.ServerURL, config.AccessToken)
ctx := context.Background()
bucket, err := client.CreateBucket(ctx, bucketName)
if err != nil {
return fmt.Errorf("failed to create bucket: %w", err)
}
fmt.Printf("Bucket '%s' created successfully!\n", bucket.Name)
fmt.Printf("ID: %d\n", bucket.ID)
fmt.Printf("Storage Used: %d bytes\n", bucket.StorageUsedBytes)
fmt.Printf("Created: %s\n", bucket.CreatedAt.Format("2006-01-02 15:04:05"))
return nil
},
}
}
func newBucketListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all buckets",
RunE: func(cmd *cobra.Command, args []string) error {
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if !config.IsAuthenticated() {
return fmt.Errorf("not logged in. Use 'tcman account login' or 'tcman account create'")
}
client := NewClient(config.ServerURL, config.AccessToken)
ctx := context.Background()
buckets, err := client.ListBuckets(ctx)
if err != nil {
return fmt.Errorf("failed to list buckets: %w", err)
}
if len(buckets) == 0 {
fmt.Printf("No buckets found.\n")
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "Name\tStorage Used\tCreated")
fmt.Fprintln(w, "----\t------------\t-------")
for _, bucket := range buckets {
storageStr := formatBytes(bucket.StorageUsedBytes)
fmt.Fprintf(w, "%s\t%s\t%s\n",
bucket.Name,
storageStr,
bucket.CreatedAt.Format("2006-01-02 15:04"),
)
}
w.Flush()
return nil
},
}
}
func newBucketInfoCmd() *cobra.Command {
return &cobra.Command{
Use: "info <bucket-name>",
Short: "Show bucket information",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if !config.IsAuthenticated() {
return fmt.Errorf("not logged in. Use 'tcman account login' or 'tcman account create'")
}
bucketName := args[0]
client := NewClient(config.ServerURL, config.AccessToken)
ctx := context.Background()
bucket, err := client.GetBucket(ctx, bucketName)
if err != nil {
return fmt.Errorf("failed to get bucket info: %w", err)
}
fmt.Printf("Bucket Information:\n")
fmt.Printf(" Name: %s\n", bucket.Name)
fmt.Printf(" ID: %d\n", bucket.ID)
fmt.Printf(" Storage Used: %s\n", formatBytes(bucket.StorageUsedBytes))
fmt.Printf(" Created: %s\n", bucket.CreatedAt.Format("2006-01-02 15:04:05"))
objects, err := client.ListObjects(ctx, bucketName)
if err != nil {
fmt.Printf(" Objects: Error loading (%v)\n", err)
} else {
fmt.Printf(" Objects: %d\n", len(objects))
}
return nil
},
}
}
func newBucketDeleteCmd() *cobra.Command {
var force bool
cmd := &cobra.Command{
Use: "delete <bucket-name>",
Short: "Delete a bucket",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if !config.IsAuthenticated() {
return fmt.Errorf("not logged in. Use 'tcman account login' or 'tcman account create'")
}
bucketName := args[0]
client := NewClient(config.ServerURL, config.AccessToken)
ctx := context.Background()
if !force {
objects, err := client.ListObjects(ctx, bucketName)
if err != nil {
return fmt.Errorf("failed to check bucket contents: %w", err)
}
if len(objects) > 0 {
return fmt.Errorf("bucket '%s' contains %d objects. Use --force to delete anyway", bucketName, len(objects))
}
fmt.Printf("Are you sure you want to delete bucket '%s'? (y/N): ", bucketName)
var response string
fmt.Scanln(&response)
if response != "y" && response != "yes" && response != "Y" && response != "YES" {
fmt.Printf("Cancelled.\n")
return nil
}
}
err = client.DeleteBucket(ctx, bucketName)
if err != nil {
return fmt.Errorf("failed to delete bucket: %w", err)
}
fmt.Printf("Bucket '%s' deleted successfully.\n", bucketName)
return nil
},
}
cmd.Flags().BoolVar(&force, "force", false, "Force deletion without confirmation")
return cmd
}
func formatBytes(bytes int64) string {
if bytes == 0 {
return "0 B"
}
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
units := []string{"KB", "MB", "GB", "TB"}
return fmt.Sprintf("%.1f %s", float64(bytes)/float64(div), units[exp])
}

241
internal/cli/client.go Normal file
View File

@ -0,0 +1,241 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type Client struct {
baseURL string
httpClient *http.Client
token string
}
type Account struct {
ID int64 `json:"id"`
AccountNumber string `json:"accountNumber"`
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"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
ConfirmedAt *time.Time `json:"confirmedAt,omitempty"`
}
type Bucket struct {
ID int64 `json:"id"`
Name string `json:"name"`
OwnerID int64 `json:"ownerId"`
StorageUsedBytes int64 `json:"storageUsedBytes"`
CreatedAt time.Time `json:"createdAt"`
}
type Object struct {
ID int64 `json:"id"`
BucketID int64 `json:"bucketId"`
Key string `json:"key"`
SizeBytes int64 `json:"sizeBytes"`
ContentType string `json:"contentType"`
LastModified time.Time `json:"lastModified"`
VersionID string `json:"versionId"`
MD5Checksum string `json:"md5Checksum"`
CustomMetadata map[string]interface{} `json:"customMetadata"`
}
func NewClient(baseURL, token string) *Client {
return &Client{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
token: token,
}
}
func (c *Client) makeRequest(ctx context.Context, method, path string, body interface{}, result interface{}) error {
var reqBody io.Reader
if body != nil {
jsonData, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request body: %w", err)
}
reqBody = bytes.NewBuffer(jsonData)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.token != "" {
req.Header.Set("X-Access-Token", c.token)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("server error %d: %s", resp.StatusCode, string(body))
}
if result != nil && resp.StatusCode != http.StatusNoContent {
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
}
return nil
}
func (c *Client) CreateAccount(ctx context.Context) (*Account, error) {
var account Account
err := c.makeRequest(ctx, "POST", "/api/accounts", nil, &account)
return &account, err
}
func (c *Client) GetAccount(ctx context.Context) (*Account, error) {
var account Account
err := c.makeRequest(ctx, "GET", "/api/account", nil, &account)
return &account, err
}
func (c *Client) CreatePayment(ctx context.Context, usdAmount float64) (*Payment, error) {
req := map[string]interface{}{
"usd_amount": usdAmount,
}
var payment Payment
err := c.makeRequest(ctx, "POST", "/api/payments", req, &payment)
return &payment, err
}
func (c *Client) GetPayment(ctx context.Context, paymentID int64) (*Payment, error) {
var payment Payment
path := fmt.Sprintf("/api/payments/%d", paymentID)
err := c.makeRequest(ctx, "GET", path, nil, &payment)
return &payment, err
}
func (c *Client) GetPayments(ctx context.Context) ([]Payment, error) {
var payments []Payment
err := c.makeRequest(ctx, "GET", "/api/payments", nil, &payments)
return payments, err
}
func (c *Client) CreateBucket(ctx context.Context, name string) (*Bucket, error) {
req := map[string]interface{}{
"name": name,
}
var bucket Bucket
err := c.makeRequest(ctx, "PUT", "/api/buckets/"+name, req, &bucket)
return &bucket, err
}
func (c *Client) ListBuckets(ctx context.Context) ([]Bucket, error) {
var buckets []Bucket
err := c.makeRequest(ctx, "GET", "/api/buckets", nil, &buckets)
return buckets, err
}
func (c *Client) GetBucket(ctx context.Context, name string) (*Bucket, error) {
var bucket Bucket
path := fmt.Sprintf("/api/buckets/%s", name)
err := c.makeRequest(ctx, "GET", path, nil, &bucket)
return &bucket, err
}
func (c *Client) DeleteBucket(ctx context.Context, name string) error {
path := fmt.Sprintf("/api/buckets/%s", name)
return c.makeRequest(ctx, "DELETE", path, nil, nil)
}
func (c *Client) ListObjects(ctx context.Context, bucketName string) ([]Object, error) {
var objects []Object
path := fmt.Sprintf("/api/buckets/%s/objects", bucketName)
err := c.makeRequest(ctx, "GET", path, nil, &objects)
return objects, err
}
func (c *Client) PutObject(ctx context.Context, bucketName, key string, data []byte, contentType string) error {
req, err := http.NewRequestWithContext(ctx, "PUT",
fmt.Sprintf("%s/api/buckets/%s/objects/%s", c.baseURL, bucketName, key),
bytes.NewReader(data))
if err != nil {
return err
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if c.token != "" {
req.Header.Set("X-Access-Token", c.token)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("upload failed %d: %s", resp.StatusCode, string(body))
}
return nil
}
func (c *Client) GetObject(ctx context.Context, bucketName, key string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET",
fmt.Sprintf("%s/api/buckets/%s/objects/%s", c.baseURL, bucketName, key), nil)
if err != nil {
return nil, err
}
if c.token != "" {
req.Header.Set("X-Access-Token", c.token)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("download failed %d: %s", resp.StatusCode, string(body))
}
return io.ReadAll(resp.Body)
}
func (c *Client) DeleteObject(ctx context.Context, bucketName, key string) error {
path := fmt.Sprintf("/api/buckets/%s/objects/%s", bucketName, key)
return c.makeRequest(ctx, "DELETE", path, nil, nil)
}

132
internal/cli/cmd_config.go Normal file
View File

@ -0,0 +1,132 @@
package cli
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
func NewConfigCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Manage CLI configuration",
Long: "View and modify CLI configuration settings",
}
cmd.AddCommand(newConfigShowCmd())
cmd.AddCommand(newConfigSetCmd())
cmd.AddCommand(newConfigResetCmd())
return cmd
}
func newConfigShowCmd() *cobra.Command {
return &cobra.Command{
Use: "show",
Short: "Show current configuration",
RunE: func(cmd *cobra.Command, args []string) error {
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
configPath, _ := GetConfigPath()
fmt.Printf("Configuration:\n")
fmt.Printf(" Config file: %s\n", configPath)
fmt.Printf(" Server URL: %s\n", config.ServerURL)
fmt.Printf(" Account Number: %s\n", getConfigValue(config.AccountNumber, "Not set"))
fmt.Printf(" Access Token: %s\n", getConfigValue(config.AccessToken, "Not set"))
if config.IsAuthenticated() {
fmt.Printf(" Status: Authenticated\n")
} else {
fmt.Printf(" Status: Not authenticated\n")
}
return nil
},
}
}
func newConfigSetCmd() *cobra.Command {
return &cobra.Command{
Use: "set <key> <value>",
Short: "Set a configuration value",
Long: `Set a configuration value. Available keys:
server-url - TermCloud server URL (e.g., https://api.termcloud.com)`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
key := args[0]
value := args[1]
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
switch key {
case "server-url":
config.ServerURL = value
fmt.Printf("Server URL set to: %s\n", value)
default:
return fmt.Errorf("unknown config key: %s", key)
}
if err := config.Save(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
},
}
}
func newConfigResetCmd() *cobra.Command {
var force bool
cmd := &cobra.Command{
Use: "reset",
Short: "Reset configuration to defaults",
RunE: func(cmd *cobra.Command, args []string) error {
if !force {
fmt.Printf("This will delete all configuration including login credentials.\n")
fmt.Printf("Are you sure? (y/N): ")
var response string
fmt.Scanln(&response)
if response != "y" && response != "yes" && response != "Y" && response != "YES" {
fmt.Printf("Cancelled.\n")
return nil
}
}
configPath, err := GetConfigPath()
if err != nil {
return fmt.Errorf("failed to get config path: %w", err)
}
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove config file: %w", err)
}
fmt.Printf("Configuration reset successfully.\n")
fmt.Printf("You'll need to log in again with 'tcman account login' or create a new account.\n")
return nil
},
}
cmd.Flags().BoolVar(&force, "force", false, "Force reset without confirmation")
return cmd
}
func getConfigValue(value, defaultValue string) string {
if value == "" {
return defaultValue
}
if len(value) > 20 {
return value[:17] + "..."
}
return value
}

83
internal/cli/config.go Normal file
View File

@ -0,0 +1,83 @@
package cli
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
type Config struct {
ServerURL string `json:"serverUrl"`
AccessToken string `json:"accessToken"`
AccountNumber string `json:"accountNumber"`
}
func GetConfigDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
configDir := filepath.Join(home, ".tcman")
if err := os.MkdirAll(configDir, 0755); err != nil {
return "", err
}
return configDir, nil
}
func GetConfigPath() (string, error) {
configDir, err := GetConfigDir()
if err != nil {
return "", err
}
return filepath.Join(configDir, "config.json"), nil
}
func LoadConfig() (*Config, error) {
configPath, err := GetConfigPath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return &Config{
ServerURL: "http://localhost:8080",
}, nil
}
return nil, err
}
var config Config
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("invalid config file: %w", err)
}
if config.ServerURL == "" {
config.ServerURL = "http://localhost:8080"
}
return &config, nil
}
func (c *Config) Save() error {
configPath, err := GetConfigPath()
if err != nil {
return err
}
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return os.WriteFile(configPath, data, 0644)
}
func (c *Config) IsAuthenticated() bool {
return c.AccessToken != "" && c.AccountNumber != ""
}

235
internal/cli/file.go Normal file
View File

@ -0,0 +1,235 @@
package cli
import (
"context"
"fmt"
"mime"
"os"
"path/filepath"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
)
func NewFileCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "file",
Short: "Manage files in buckets",
Long: "Upload, download, list, and manage files in your buckets",
}
cmd.AddCommand(newFileUploadCmd())
cmd.AddCommand(newFileDownloadCmd())
cmd.AddCommand(newFileListCmd())
cmd.AddCommand(newFileDeleteCmd())
return cmd
}
func newFileUploadCmd() *cobra.Command {
var contentType string
cmd := &cobra.Command{
Use: "upload <bucket-name> <local-file> [remote-key]",
Short: "Upload a file to a bucket",
Args: cobra.RangeArgs(2, 3),
RunE: func(cmd *cobra.Command, args []string) error {
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if !config.IsAuthenticated() {
return fmt.Errorf("not logged in. Use 'tcman account login' or 'tcman account create'")
}
bucketName := args[0]
localFile := args[1]
remoteKey := filepath.Base(localFile)
if len(args) == 3 {
remoteKey = args[2]
}
data, err := os.ReadFile(localFile)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
if contentType == "" {
contentType = mime.TypeByExtension(filepath.Ext(localFile))
}
client := NewClient(config.ServerURL, config.AccessToken)
ctx := context.Background()
fmt.Printf("Uploading %s to %s/%s (%s)...\n", localFile, bucketName, remoteKey, formatBytes(int64(len(data))))
err = client.PutObject(ctx, bucketName, remoteKey, data, contentType)
if err != nil {
return fmt.Errorf("failed to upload file: %w", err)
}
fmt.Printf("File uploaded successfully!\n")
fmt.Printf("Bucket: %s\n", bucketName)
fmt.Printf("Key: %s\n", remoteKey)
fmt.Printf("Size: %s\n", formatBytes(int64(len(data))))
if contentType != "" {
fmt.Printf("Content-Type: %s\n", contentType)
}
return nil
},
}
cmd.Flags().StringVar(&contentType, "content-type", "", "Override content type")
return cmd
}
func newFileDownloadCmd() *cobra.Command {
return &cobra.Command{
Use: "download <bucket-name> <remote-key> [local-file]",
Short: "Download a file from a bucket",
Args: cobra.RangeArgs(2, 3),
RunE: func(cmd *cobra.Command, args []string) error {
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if !config.IsAuthenticated() {
return fmt.Errorf("not logged in. Use 'tcman account login' or 'tcman account create'")
}
bucketName := args[0]
remoteKey := args[1]
localFile := filepath.Base(remoteKey)
if len(args) == 3 {
localFile = args[2]
}
client := NewClient(config.ServerURL, config.AccessToken)
ctx := context.Background()
fmt.Printf("Downloading %s/%s to %s...\n", bucketName, remoteKey, localFile)
data, err := client.GetObject(ctx, bucketName, remoteKey)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
err = os.WriteFile(localFile, data, 0644)
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
fmt.Printf("File downloaded successfully!\n")
fmt.Printf("Size: %s\n", formatBytes(int64(len(data))))
fmt.Printf("Saved to: %s\n", localFile)
return nil
},
}
}
func newFileListCmd() *cobra.Command {
return &cobra.Command{
Use: "list <bucket-name>",
Short: "List files in a bucket",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if !config.IsAuthenticated() {
return fmt.Errorf("not logged in. Use 'tcman account login' or 'tcman account create'")
}
bucketName := args[0]
client := NewClient(config.ServerURL, config.AccessToken)
ctx := context.Background()
objects, err := client.ListObjects(ctx, bucketName)
if err != nil {
return fmt.Errorf("failed to list objects: %w", err)
}
if len(objects) == 0 {
fmt.Printf("No files found in bucket '%s'.\n", bucketName)
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "Key\tSize\tContent-Type\tLast Modified")
fmt.Fprintln(w, "---\t----\t------------\t-------------")
for _, obj := range objects {
contentType := obj.ContentType
if contentType == "" {
contentType = "application/octet-stream"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
obj.Key,
formatBytes(obj.SizeBytes),
contentType,
obj.LastModified.Format("2006-01-02 15:04:05"),
)
}
w.Flush()
return nil
},
}
}
func newFileDeleteCmd() *cobra.Command {
var force bool
cmd := &cobra.Command{
Use: "delete <bucket-name> <remote-key>",
Short: "Delete a file from a bucket",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if !config.IsAuthenticated() {
return fmt.Errorf("not logged in. Use 'tcman account login' or 'tcman account create'")
}
bucketName := args[0]
remoteKey := args[1]
client := NewClient(config.ServerURL, config.AccessToken)
ctx := context.Background()
if !force {
fmt.Printf("Are you sure you want to delete '%s' from bucket '%s'? (y/N): ", remoteKey, bucketName)
var response string
fmt.Scanln(&response)
response = strings.ToLower(response)
if response != "y" && response != "yes" {
fmt.Printf("Cancelled.\n")
return nil
}
}
err = client.DeleteObject(ctx, bucketName, remoteKey)
if err != nil {
return fmt.Errorf("failed to delete file: %w", err)
}
fmt.Printf("File '%s' deleted from bucket '%s'.\n", remoteKey, bucketName)
return nil
},
}
cmd.Flags().BoolVar(&force, "force", false, "Force deletion without confirmation")
return cmd
}

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