Compare commits

..

No commits in common. "efd008f12aaf7415a2fbc60bd4621a0003176077" and "3be0ede054f20488f7573be77979ebf7c770ae05" have entirely different histories.

14 changed files with 378 additions and 1783 deletions

388
README.md
View File

@ -1,212 +1,148 @@
# TermCloud Storage Platform # Termcloud
A cloud storage platform with Mullvad-style anonymity, usage-based billing, and S3-compatible bucket policies. A Mullvad-style file storage service with Bitcoin payments and usage-based billing.
## Quick Start ## Features
### 1. Set up the Server - **Account-based System**: Anonymous account creation with 16-digit account numbers
- **Bitcoin Payments**: Pay with Bitcoin to activate and fund your account
```bash - **Usage-based Billing**: Charged monthly for peak storage usage (pay for what you use)
git clone https://git.keircn.com/keiran/termcloud - **Bucket Policies**: AWS S3-compatible JSON policies for access control
cd termcloud - **RESTful API**: Complete REST API for all operations
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 ## Authentication System
Similar to Mullvad VPN:
1. **Create Account**: Generate anonymous 16-digit account number + access token 1. **Create Account**: Generate anonymous 16-digit account number + access token
2. **Add Funds**: Pay $5+ worth of Bitcoin to activate account 2. **Add Funds**: Pay ~$5 worth of Bitcoin to activate account
3. **Usage Billing**: Charged monthly for peak storage usage ($0.50/GB default) 3. **Usage Billing**: Charged monthly based on peak storage usage ($0.50/GB default)
4. **No Personal Info**: No emails, usernames, or personal information required 4. **No Personal Info**: No emails, usernames, or personal information required
## Server Configuration ## Setup
1. Set up PostgreSQL database and run the schema:
```bash
psql -d termcloud -f internal/db/schema.sql
```
2. Configure environment variables (copy `.env.example` to `.env` and customize):
```bash
cp .env.example .env
# Edit .env with your settings
```
### Configuration Options
| Variable | Default | Description | | Variable | Default | Description |
| ------------------ | ------- | ------------------------------------- | |----------|---------|-------------|
| `PRICE_PER_GB_USD` | 0.50 | Monthly charge per GB of peak storage | | `PRICE_PER_GB_USD` | 0.50 | Monthly charge per GB of peak storage |
| `DATABASE_URL` | - | PostgreSQL connection string | | `DATABASE_URL` | - | PostgreSQL connection string |
| `PORT` | 8080 | Server port | | `PORT` | 8080 | Server port |
| `STORAGE_DIR` | storage | Directory for file storage | | `STORAGE_DIR` | storage | Directory for file storage |
| `MAX_FILE_SIZE` | 100MB | Maximum file upload size |
| `RATE_LIMIT` | 100 | Requests per second per IP |
## API Endpoints
### Account Management
3. Build and run:
```bash ```bash
# Create account (public endpoint) make build
POST /api/accounts make run
# 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
``` ```
### Bucket Operations ## Usage
### 1. Create Account
```bash ```bash
# List buckets curl -X POST http://localhost:8080/api/v1/accounts
GET /api/buckets ```
Headers: X-Access-Token: your-token Response:
```json
# Create bucket {
PUT /api/buckets/my-bucket "accountNumber": "1234567890123456",
Headers: X-Access-Token: your-token "accessToken": "abc123...",
"balanceUsd": 0.00,
# Get bucket info "isActive": false,
GET /api/buckets/my-bucket "message": "Account created. Add funds to activate."
Headers: X-Access-Token: your-token }
# Delete bucket
DELETE /api/buckets/my-bucket
Headers: X-Access-Token: your-token
``` ```
### Object Operations ### 2. Add Funds (Bitcoin Payment)
```bash ```bash
# Upload file curl -X POST http://localhost:8080/api/v1/account/payments \
PUT /api/buckets/my-bucket/objects/file.txt -H "X-Access-Token: your-access-token" \
Headers: X-Access-Token: your-token -H "Content-Type: application/json" \
Headers: Content-Type: text/plain -d '{"amount": 5.00}'
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
``` ```
## Bucket Policies (S3-Compatible) ### 3. Use Storage (requires active account)
All storage endpoints require `X-Access-Token` header:
Set access control policies using AWS S3-compatible JSON syntax: ### 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
```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/*"
]
}
]
}
```
### Supported Actions
- `termcloud:GetObject` - Download files
- `termcloud:PutObject` - Upload files
- `termcloud:DeleteObject` - Delete files
- `termcloud:ListObjects` - List files in bucket
- `termcloud:GetBucket` - Get bucket info
- `termcloud:DeleteBucket` - Delete bucket
- `*` - All actions
### Policy Examples
**Read-only access:**
```json ```json
{ {
"Version": "2012-10-17", "Version": "2012-10-17",
@ -221,73 +157,37 @@ Set access control policies using AWS S3-compatible JSON syntax:
} }
``` ```
### Supported Actions **Deny delete operations:**
```json
- `termcloud:GetObject` - Download files {
- `termcloud:PutObject` - Upload files "Version": "2012-10-17",
- `termcloud:DeleteObject` - Delete files "Statement": [
- `termcloud:ListObjects` - List files {
- `termcloud:GetBucket` - Get bucket info "Effect": "Deny",
- `termcloud:DeleteBucket` - Delete bucket "Principal": {"User": ["*"]},
"Action": ["termcloud:DeleteObject"],
### Policy Management "Resource": ["arn:termcloud:s3:::my-bucket/*"]
}
```bash ]
# Set bucket policy }
PUT /api/buckets/my-bucket/policy
Headers: X-Access-Token: your-token
Body: [JSON policy]
# Get bucket policy
GET /api/buckets/my-bucket/policy
Headers: X-Access-Token: your-token
# Delete bucket policy
DELETE /api/buckets/my-bucket/policy
Headers: X-Access-Token: your-token
``` ```
## Billing System ### Example Usage
- **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 ```bash
# Build server # Create bucket
go build -o termcloud cmd/termcloud/main.go 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"}'
# Build CLI # Upload file
go build -o tcman cmd/tcman/main.go 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"
# Run tests # Download file
go test ./... curl http://localhost:8080/api/v1/buckets/my-files/objects/test.txt \
-H "X-API-Key: your-api-key" \
# Format code -o downloaded.txt
go fmt ./...
``` ```
## License
MIT License

View File

@ -1,27 +0,0 @@
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,20 +44,17 @@ func main() {
e.GET("/", h.RootHandler) e.GET("/", h.RootHandler)
e.POST("/api/accounts", h.CreateAccountHandler) e.POST("/api/v1/accounts", h.CreateAccountHandler)
api := e.Group("/api") api := e.Group("/api/v1")
api.Use(h.AuthMiddleware) api.Use(h.AuthMiddleware)
api.GET("/account", h.GetAccountHandler) api.GET("/account", h.GetAccountHandler)
api.POST("/payments", h.CreatePaymentHandler) api.POST("/account/payments", h.CreatePaymentHandler)
api.GET("/payments", h.GetPaymentsHandler) api.POST("/account/payments/:paymentId/confirm", h.ConfirmPaymentHandler)
api.GET("/payments/:id", h.GetPaymentHandler)
api.POST("/payments/:id/confirm", h.ConfirmPaymentHandler)
api.GET("/buckets", h.ListBucketsHandler) api.GET("/buckets", h.ListBucketsHandler)
api.PUT("/buckets/:bucket", h.CreateBucketHandler) api.POST("/buckets", h.CreateBucketHandler)
api.GET("/buckets/:bucket", h.GetBucketHandler)
api.DELETE("/buckets/:bucket", h.DeleteBucketHandler) api.DELETE("/buckets/:bucket", h.DeleteBucketHandler)
api.PUT("/buckets/:bucket/policy", h.SetBucketPolicyHandler) api.PUT("/buckets/:bucket/policy", h.SetBucketPolicyHandler)
@ -65,7 +62,7 @@ func main() {
api.DELETE("/buckets/:bucket/policy", h.DeleteBucketPolicyHandler) api.DELETE("/buckets/:bucket/policy", h.DeleteBucketPolicyHandler)
api.GET("/buckets/:bucket/objects", h.ListObjectsHandler) api.GET("/buckets/:bucket/objects", h.ListObjectsHandler)
api.PUT("/buckets/:bucket/objects/*", h.PutObjectHandler) api.PUT("/buckets/:bucket/objects/*", h.UploadObjectHandler)
api.GET("/buckets/:bucket/objects/*", h.GetObjectHandler) api.GET("/buckets/:bucket/objects/*", h.GetObjectHandler)
api.DELETE("/buckets/:bucket/objects/*", h.DeleteObjectHandler) api.DELETE("/buckets/:bucket/objects/*", h.DeleteObjectHandler)

3
go.mod
View File

@ -9,15 +9,12 @@ require (
) )
require ( require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/labstack/gommon v0.4.2 // indirect github.com/labstack/gommon v0.4.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/crypto v0.38.0 // indirect golang.org/x/crypto v0.38.0 // indirect

8
go.sum
View File

@ -1,9 +1,6 @@
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@ -22,11 +19,6 @@ 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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=

View File

@ -1,266 +0,0 @@
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"
}

View File

@ -1,221 +0,0 @@
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])
}

View File

@ -1,241 +0,0 @@
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)
}

View File

@ -1,132 +0,0 @@
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
}

View File

@ -1,83 +0,0 @@
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 != ""
}

View File

@ -1,235 +0,0 @@
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,17 +32,10 @@ type Payment struct {
Confirmations int `json:"confirmations"` Confirmations int `json:"confirmations"`
TxHash string `json:"txHash,omitempty"` TxHash string `json:"txHash,omitempty"`
Status string `json:"status"` Status string `json:"status"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
ConfirmedAt *time.Time `json:"confirmedAt,omitempty"` 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 { type UsageRecord struct {
ID int64 `json:"id"` ID int64 `json:"id"`
AccountID int64 `json:"accountId"` AccountID int64 `json:"accountId"`
@ -148,23 +141,22 @@ func (s *AccountService) GetAccountByNumber(ctx context.Context, accountNumber s
} }
func (s *AccountService) CreatePayment(ctx context.Context, accountID int64, usdAmount float64) (*Payment, error) { func (s *AccountService) CreatePayment(ctx context.Context, accountID int64, usdAmount float64) (*Payment, error) {
btcPrice, err := s.getBTCPrice(ctx) btcPrice, err := s.getBTCPrice()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get BTC price: %w", err) return nil, fmt.Errorf("failed to get BTC price: %w", err)
} }
btcAmount := usdAmount / btcPrice btcAmount := usdAmount / btcPrice
btcAddress := s.generateBTCAddress() btcAddress := s.generateBTCAddress()
expiresAt := time.Now().Add(24 * time.Hour)
var payment Payment var payment Payment
err = s.pool.QueryRow(ctx, ` err = s.pool.QueryRow(ctx, `
INSERT INTO payments (account_id, payment_type, btc_address, btc_amount, usd_amount, status, expires_at) INSERT INTO payments (account_id, payment_type, btc_address, btc_amount, usd_amount, status)
VALUES ($1, 'bitcoin', $2, $3, $4, 'pending', $5) VALUES ($1, 'bitcoin', $2, $3, $4, 'pending')
RETURNING id, account_id, payment_type, btc_address, btc_amount, usd_amount, confirmations, status, expires_at, created_at`, RETURNING id, account_id, payment_type, btc_address, btc_amount, usd_amount, confirmations, status, created_at`,
accountID, btcAddress, btcAmount, usdAmount, expiresAt).Scan( accountID, btcAddress, btcAmount, usdAmount).Scan(
&payment.ID, &payment.AccountID, &payment.PaymentType, &payment.BTCAddress, &payment.ID, &payment.AccountID, &payment.PaymentType, &payment.BTCAddress,
&payment.BTCAmount, &payment.USDAmount, &payment.Confirmations, &payment.Status, &payment.ExpiresAt, &payment.CreatedAt) &payment.BTCAmount, &payment.USDAmount, &payment.Confirmations, &payment.Status, &payment.CreatedAt)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create payment: %w", err) return nil, fmt.Errorf("failed to create payment: %w", err)
} }
@ -229,6 +221,7 @@ func (s *AccountService) ProcessMonthlyBilling(ctx context.Context) error {
now := time.Now() now := time.Now()
lastMonth := now.AddDate(0, -1, 0) lastMonth := now.AddDate(0, -1, 0)
billingStart := time.Date(lastMonth.Year(), lastMonth.Month(), 1, 0, 0, 0, 0, lastMonth.Location()) 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, ` rows, err := s.pool.Query(ctx, `
SELECT account_id, max_storage_bytes SELECT account_id, max_storage_bytes
@ -289,137 +282,9 @@ func (s *AccountService) chargeAccount(ctx context.Context, accountID int64, amo
return nil return nil
} }
func (s *AccountService) getBTCPrice(ctx context.Context) (float64, error) { func (s *AccountService) getBTCPrice() (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 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 { func (s *AccountService) generateBTCAddress() string {
bytes := make([]byte, 20) bytes := make([]byte, 20)

View File

@ -19,18 +19,10 @@ CREATE TABLE IF NOT EXISTS payments (
confirmations INT DEFAULT 0, confirmations INT DEFAULT 0,
tx_hash TEXT, tx_hash TEXT,
status VARCHAR(20) DEFAULT 'pending', status VARCHAR(20) DEFAULT 'pending',
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
confirmed_at TIMESTAMPTZ 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 ( CREATE TABLE IF NOT EXISTS usage_records (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
account_id INT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, account_id INT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,

View File

@ -3,7 +3,6 @@ package handlers
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@ -26,9 +25,8 @@ func NewHandlers(bucketService *db.BucketService, accountService *db.AccountServ
func (h *Handlers) RootHandler(c echo.Context) error { func (h *Handlers) RootHandler(c echo.Context) error {
return c.JSON(200, map[string]string{ return c.JSON(200, map[string]string{
"service": "TermCloud Storage API", "status": "😺",
"version": "1.0.0", "docs": "https://illfillthisoutlater.com",
"docs": "https://github.com/termcloud/termcloud",
}) })
} }
@ -53,92 +51,21 @@ func (h *Handlers) AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
} }
} }
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 {
USDAmount float64 `json:"usd_amount"`
}
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"})
}
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 { func (h *Handlers) CreateBucketHandler(c echo.Context) error {
account := c.Get("account").(*db.Account) account := c.Get("account").(*db.Account)
bucketName := c.Param("bucket")
if bucketName == "" { var req struct {
Name string `json:"name"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(400, map[string]string{"error": "Invalid request body"})
}
if req.Name == "" {
return c.JSON(400, map[string]string{"error": "Bucket name is required"}) return c.JSON(400, map[string]string{"error": "Bucket name is required"})
} }
bucket, err := h.bucketService.CreateBucket(context.Background(), bucketName, account.ID) bucket, err := h.bucketService.CreateBucket(context.Background(), req.Name, account.ID)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "duplicate key") { if strings.Contains(err.Error(), "duplicate key") {
return c.JSON(409, map[string]string{"error": "Bucket name already exists"}) return c.JSON(409, map[string]string{"error": "Bucket name already exists"})
@ -148,38 +75,24 @@ func (h *Handlers) CreateBucketHandler(c echo.Context) error {
return c.JSON(201, bucket) return c.JSON(201, bucket)
} }
func (h *Handlers) ListBucketsHandler(c echo.Context) error { func (h *Handlers) ListBucketsHandler(c echo.Context) error {
account := c.Get("account").(*db.Account) user := c.Get("user").(*db.User)
buckets, err := h.bucketService.GetUserBuckets(context.Background(), account.ID) buckets, err := h.bucketService.GetUserBuckets(context.Background(), user.ID)
if err != nil { if err != nil {
return c.JSON(500, map[string]string{"error": "Failed to list buckets"}) return c.JSON(500, map[string]string{"error": "Failed to list buckets"})
} }
return c.JSON(200, buckets) return c.JSON(200, map[string]any{
} "buckets": 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 { func (h *Handlers) DeleteBucketHandler(c echo.Context) error {
account := c.Get("account").(*db.Account) user := c.Get("user").(*db.User)
bucketName := c.Param("bucket") bucketName := c.Param("bucket")
if err := h.bucketService.DeleteBucket(context.Background(), bucketName, account.ID); err != nil { if err := h.bucketService.DeleteBucket(context.Background(), bucketName, user.ID); err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
return c.JSON(404, map[string]string{"error": "Bucket not found"}) return c.JSON(404, map[string]string{"error": "Bucket not found"})
} }
@ -189,12 +102,12 @@ func (h *Handlers) DeleteBucketHandler(c echo.Context) error {
return c.JSON(200, map[string]string{"message": "Bucket deleted successfully"}) return c.JSON(200, map[string]string{"message": "Bucket deleted successfully"})
} }
func (h *Handlers) PutObjectHandler(c echo.Context) error { func (h *Handlers) UploadObjectHandler(c echo.Context) error {
account := c.Get("account").(*db.Account) user := c.Get("user").(*db.User)
bucketName := c.Param("bucket") bucketName := c.Param("bucket")
objectKey := c.Param("*") objectKey := c.Param("*")
bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, account.ID) bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, user.ID)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
return c.JSON(404, map[string]string{"error": "Bucket not found"}) return c.JSON(404, map[string]string{"error": "Bucket not found"})
@ -202,41 +115,61 @@ func (h *Handlers) PutObjectHandler(c echo.Context) error {
return c.JSON(500, map[string]string{"error": "Failed to get bucket"}) return c.JSON(500, map[string]string{"error": "Failed to get bucket"})
} }
contentType := c.Request().Header.Get("Content-Type") 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")
if contentType == "" { if contentType == "" {
contentType = "application/octet-stream" contentType = "application/octet-stream"
} }
contentLength := c.Request().ContentLength object, err := h.bucketService.UploadObject(context.Background(), bucket.ID, objectKey, file.Size, contentType, src)
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 err != nil {
return c.JSON(500, map[string]string{"error": "Failed to upload object"}) 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) 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 { func (h *Handlers) GetObjectHandler(c echo.Context) error {
account := c.Get("account").(*db.Account) user := c.Get("user").(*db.User)
bucketName := c.Param("bucket") bucketName := c.Param("bucket")
objectKey := c.Param("*") objectKey := c.Param("*")
file, err := h.bucketService.GetObjectFile(context.Background(), bucketName, objectKey, account.ID) file, err := h.bucketService.GetObjectFile(context.Background(), bucketName, objectKey, user.ID)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
return c.JSON(404, map[string]string{"error": "Object not found"}) return c.JSON(404, map[string]string{"error": "Object not found"})
@ -255,33 +188,12 @@ func (h *Handlers) GetObjectHandler(c echo.Context) error {
return c.Stream(http.StatusOK, "application/octet-stream", file) 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 { func (h *Handlers) DeleteObjectHandler(c echo.Context) error {
account := c.Get("account").(*db.Account) user := c.Get("user").(*db.User)
bucketName := c.Param("bucket") bucketName := c.Param("bucket")
objectKey := c.Param("*") objectKey := c.Param("*")
bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, account.ID) bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, user.ID)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
return c.JSON(404, map[string]string{"error": "Bucket not found"}) return c.JSON(404, map[string]string{"error": "Bucket not found"})
@ -296,11 +208,31 @@ func (h *Handlers) DeleteObjectHandler(c echo.Context) error {
return c.JSON(200, map[string]string{"message": "Object deleted successfully"}) 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 { func (h *Handlers) SetBucketPolicyHandler(c echo.Context) error {
account := c.Get("account").(*db.Account) user := c.Get("user").(*db.User)
bucketName := c.Param("bucket") bucketName := c.Param("bucket")
bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, account.ID) bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, user.ID)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
return c.JSON(404, map[string]string{"error": "Bucket not found"}) return c.JSON(404, map[string]string{"error": "Bucket not found"})
@ -308,12 +240,14 @@ func (h *Handlers) SetBucketPolicyHandler(c echo.Context) error {
return c.JSON(500, map[string]string{"error": "Failed to get bucket"}) return c.JSON(500, map[string]string{"error": "Failed to get bucket"})
} }
body, err := io.ReadAll(c.Request().Body) var req struct {
if err != nil { Policy string `json:"policy"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(400, map[string]string{"error": "Invalid request body"}) return c.JSON(400, map[string]string{"error": "Invalid request body"})
} }
if err := h.bucketService.SetBucketPolicy(context.Background(), bucket.ID, string(body)); err != nil { if err := h.bucketService.SetBucketPolicy(context.Background(), bucket.ID, req.Policy); err != nil {
if strings.Contains(err.Error(), "invalid policy") { if strings.Contains(err.Error(), "invalid policy") {
return c.JSON(400, map[string]string{"error": err.Error()}) return c.JSON(400, map[string]string{"error": err.Error()})
} }
@ -324,10 +258,10 @@ func (h *Handlers) SetBucketPolicyHandler(c echo.Context) error {
} }
func (h *Handlers) GetBucketPolicyHandler(c echo.Context) error { func (h *Handlers) GetBucketPolicyHandler(c echo.Context) error {
account := c.Get("account").(*db.Account) user := c.Get("user").(*db.User)
bucketName := c.Param("bucket") bucketName := c.Param("bucket")
bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, account.ID) bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, user.ID)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
return c.JSON(404, map[string]string{"error": "Bucket not found"}) return c.JSON(404, map[string]string{"error": "Bucket not found"})
@ -347,10 +281,10 @@ func (h *Handlers) GetBucketPolicyHandler(c echo.Context) error {
} }
func (h *Handlers) DeleteBucketPolicyHandler(c echo.Context) error { func (h *Handlers) DeleteBucketPolicyHandler(c echo.Context) error {
account := c.Get("account").(*db.Account) user := c.Get("user").(*db.User)
bucketName := c.Param("bucket") bucketName := c.Param("bucket")
bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, account.ID) bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, user.ID)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
return c.JSON(404, map[string]string{"error": "Bucket not found"}) return c.JSON(404, map[string]string{"error": "Bucket not found"})
@ -364,3 +298,126 @@ func (h *Handlers) DeleteBucketPolicyHandler(c echo.Context) error {
return c.JSON(200, map[string]string{"message": "Bucket policy deleted successfully"}) return c.JSON(200, map[string]string{"message": "Bucket policy deleted successfully"})
} }
func (h *Handlers) CreateAccountHandler(c echo.Context) error {
account, err := h.accountService.CreateAccount(context.Background())
if err != nil {
return c.JSON(500, map[string]string{"error": "Failed to create account"})
}
return c.JSON(201, map[string]any{
"accountNumber": account.AccountNumber,
"accessToken": account.AccessToken,
"balanceUsd": account.BalanceUSD,
"isActive": account.IsActive,
"message": "Account created. Add funds to activate.",
})
}
func (h *Handlers) GetAccountHandler(c echo.Context) error {
account := c.Get("account").(*db.Account)
return c.JSON(200, map[string]any{
"accountNumber": account.AccountNumber,
"balanceUsd": account.BalanceUSD,
"isActive": account.IsActive,
"createdAt": account.CreatedAt,
"activatedAt": account.ActivatedAt,
"lastBilling": account.LastBillingDate,
})
}
func (h *Handlers) CreatePaymentHandler(c echo.Context) error {
account := c.Get("account").(*db.Account)
var req struct {
Amount float64 `json:"amount"`
}
if err := c.Bind(&req); err != nil || req.Amount < 5.0 {
return c.JSON(400, map[string]string{"error": "Minimum payment amount is $5.00"})
}
payment, err := h.accountService.CreatePayment(context.Background(), account.ID, req.Amount)
if err != nil {
return c.JSON(500, map[string]string{"error": "Failed to create payment"})
}
return c.JSON(201, map[string]any{
"paymentId": payment.ID,
"btcAddress": payment.BTCAddress,
"btcAmount": payment.BTCAmount,
"usdAmount": payment.USDAmount,
"status": payment.Status,
"message": "Send exact BTC amount to the address above",
})
}
func (h *Handlers) ConfirmPaymentHandler(c echo.Context) error {
paymentID, err := strconv.ParseInt(c.Param("paymentId"), 10, 64)
if err != nil {
return c.JSON(400, map[string]string{"error": "Invalid payment ID"})
}
var req struct {
TxHash string `json:"txHash"`
}
if err := c.Bind(&req); err != nil || req.TxHash == "" {
return c.JSON(400, map[string]string{"error": "Transaction hash required"})
}
if err := h.accountService.ConfirmPayment(context.Background(), paymentID, req.TxHash); err != nil {
return c.JSON(500, map[string]string{"error": "Failed to confirm payment"})
}
return c.JSON(200, map[string]string{"message": "Payment confirmed and account credited"})
}
func (h *Handlers) PolicyEnforcementMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
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"
}
}