Compare commits
12 Commits
9423e31841
...
d54bcc0b75
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d54bcc0b75 | ||
|
|
f142c45178 | ||
|
|
41392a253f | ||
|
|
8bec591238 | ||
|
|
f83a70a685 | ||
|
|
f367e5f840 | ||
|
|
0c70c973f7 | ||
|
|
5c3c5c1f99 | ||
|
|
642f16b048 | ||
|
|
8a8a466915 | ||
|
|
21ba61cd28 | ||
|
|
f49c015c04 |
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
DATABASE_URL="postgres://user:password@localhost/termcloud"
|
||||||
|
PORT="8080"
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
|||||||
build
|
build
|
||||||
uploads
|
uploads
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|||||||
16
Makefile
16
Makefile
@ -1,8 +1,24 @@
|
|||||||
BINARY = termcloud
|
BINARY = termcloud
|
||||||
|
ADMIN_BINARY = termcloud-admin
|
||||||
|
|
||||||
build: clean
|
build: clean
|
||||||
go build -o build/${BINARY} cmd/${BINARY}/main.go
|
go build -o build/${BINARY} cmd/${BINARY}/main.go
|
||||||
|
go build -o build/${ADMIN_BINARY} cmd/admin/main.go
|
||||||
|
|
||||||
|
run: build
|
||||||
|
./build/${BINARY}
|
||||||
|
|
||||||
|
admin: build
|
||||||
|
./build/${ADMIN_BINARY} $(ARGS)
|
||||||
|
|
||||||
|
dev:
|
||||||
|
DATABASE_URL="postgres://user:password@localhost/termcloud?sslmode=disable" go run cmd/${BINARY}/main.go
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
go clean ./...
|
go clean ./...
|
||||||
rm -rf build
|
rm -rf build
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
.PHONY: build run admin dev clean test
|
||||||
|
|||||||
76
README.md
76
README.md
@ -1,3 +1,75 @@
|
|||||||
# Termcord
|
# Termcloud
|
||||||
|
|
||||||
im just bored, this isnt gonna go anywhere.
|
A simple file storage service with user buckets and usage limits.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Set up PostgreSQL database and run the schema:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -d termcloud -f internal/db/schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Set environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env.example
|
||||||
|
DATABASE_URL="postgres://user:password@localhost/termcloud"
|
||||||
|
PORT="8080"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Build and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build
|
||||||
|
make run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Create a user and get API key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make admin ARGS="create-user mai sakurajima@waifu.club 5"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### Example Usage
|
||||||
|
|
||||||
|
```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"}'
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
# Download file
|
||||||
|
curl http://localhost:8080/api/v1/buckets/my-files/objects/test.txt \
|
||||||
|
-H "X-API-Key: your-api-key" \
|
||||||
|
-o downloaded.txt
|
||||||
|
```
|
||||||
|
|||||||
80
cmd/admin/main.go
Normal file
80
cmd/admin/main.go
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.keircn.com/keiran/termcloud/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func generateAPIKey() string {
|
||||||
|
bytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
log.Fatalf("Failed to generate API key: %v", err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
fmt.Println("Usage: termcloud-admin <command> [args...]")
|
||||||
|
fmt.Println("Commands:")
|
||||||
|
fmt.Println(" create-user <username> <email> [storage_limit_gb]")
|
||||||
|
fmt.Println(" list-users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := db.NewPool(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to create database pool: %v", err)
|
||||||
|
}
|
||||||
|
defer db.ClosePool(pool)
|
||||||
|
|
||||||
|
bucketService := db.NewBucketService(pool)
|
||||||
|
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "create-user":
|
||||||
|
if len(os.Args) < 4 {
|
||||||
|
fmt.Println("Usage: create-user <username> <email> [storage_limit_gb]")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := os.Args[2]
|
||||||
|
email := os.Args[3]
|
||||||
|
storageLimit := int64(1) * 1024 * 1024 * 1024
|
||||||
|
|
||||||
|
if len(os.Args) >= 5 {
|
||||||
|
var limitGB int64
|
||||||
|
if _, err := fmt.Sscanf(os.Args[4], "%d", &limitGB); err != nil {
|
||||||
|
fmt.Printf("Invalid storage limit: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
storageLimit = limitGB * 1024 * 1024 * 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey := generateAPIKey()
|
||||||
|
user, err := bucketService.CreateUser(ctx, username, email, apiKey, storageLimit)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to create user: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Created user:\n")
|
||||||
|
fmt.Printf(" ID: %d\n", user.ID)
|
||||||
|
fmt.Printf(" Username: %s\n", user.Username)
|
||||||
|
fmt.Printf(" Email: %s\n", user.Email)
|
||||||
|
fmt.Printf(" API Key: %s\n", apiKey)
|
||||||
|
fmt.Printf(" Storage Limit: %d GB\n", user.StorageLimitBytes/1024/1024/1024)
|
||||||
|
|
||||||
|
case "list-users":
|
||||||
|
fmt.Println("List users functionality not yet implemented")
|
||||||
|
|
||||||
|
default:
|
||||||
|
fmt.Printf("Unknown command: %s\n", os.Args[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.keircn.com/keiran/termcloud/internal/db"
|
||||||
"git.keircn.com/keiran/termcloud/internal/handlers"
|
"git.keircn.com/keiran/termcloud/internal/handlers"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/labstack/echo/v4/middleware"
|
"github.com/labstack/echo/v4/middleware"
|
||||||
@ -8,21 +13,50 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pool, err := db.NewPool(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to create database pool: %v", err)
|
||||||
|
}
|
||||||
|
defer db.ClosePool(pool)
|
||||||
|
|
||||||
|
bucketService := db.NewBucketService(pool)
|
||||||
|
h := handlers.NewHandlers(bucketService)
|
||||||
|
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
|
|
||||||
e.Use(middleware.BodyLimit("100M"))
|
e.Use(middleware.BodyLimit("100M"))
|
||||||
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||||
AllowOrigins: []string{"*"},
|
AllowOrigins: []string{"*"},
|
||||||
AllowMethods: []string{echo.GET, echo.POST},
|
AllowMethods: []string{echo.GET, echo.POST, echo.PUT, echo.DELETE},
|
||||||
|
AllowHeaders: []string{"*"},
|
||||||
}))
|
}))
|
||||||
e.Use(middleware.GzipWithConfig(middleware.GzipConfig{
|
e.Use(middleware.GzipWithConfig(middleware.GzipConfig{
|
||||||
Level: 5,
|
Level: 5,
|
||||||
}))
|
}))
|
||||||
e.Use(middleware.RateLimiter(middleware.NewRateLimiterMemoryStore(rate.Limit(20))))
|
e.Use(middleware.RateLimiter(middleware.NewRateLimiterMemoryStore(rate.Limit(20))))
|
||||||
e.Static("/uploads", "uploads")
|
|
||||||
|
|
||||||
e.GET("/", handlers.RootHandler)
|
e.Static("/storage", "storage")
|
||||||
e.POST("/upload", handlers.UploadHandler)
|
|
||||||
|
|
||||||
e.Logger.Fatal(e.Start(":8080"))
|
e.GET("/", h.RootHandler)
|
||||||
|
|
||||||
|
api := e.Group("/api/v1")
|
||||||
|
api.Use(h.AuthMiddleware)
|
||||||
|
|
||||||
|
api.GET("/user", h.GetUserInfoHandler)
|
||||||
|
api.GET("/buckets", h.ListBucketsHandler)
|
||||||
|
api.POST("/buckets", h.CreateBucketHandler)
|
||||||
|
api.DELETE("/buckets/:bucket", h.DeleteBucketHandler)
|
||||||
|
api.GET("/buckets/:bucket/objects", h.ListObjectsHandler)
|
||||||
|
api.PUT("/buckets/:bucket/objects/*", h.UploadObjectHandler)
|
||||||
|
api.GET("/buckets/:bucket/objects/*", h.GetObjectHandler)
|
||||||
|
api.DELETE("/buckets/:bucket/objects/*", h.DeleteObjectHandler)
|
||||||
|
|
||||||
|
port := os.Getenv("PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "8080"
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Logger.Fatal(e.Start(":" + port))
|
||||||
}
|
}
|
||||||
|
|||||||
306
internal/db/bucket.go
Normal file
306
internal/db/bucket.go
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
APIKey string `json:"apiKey,omitempty"`
|
||||||
|
StorageLimitBytes int64 `json:"storageLimitBytes"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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]string `json:"customMetadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BucketService struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBucketService(pool *pgxpool.Pool) *BucketService {
|
||||||
|
return &BucketService{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BucketService) CreateUser(ctx context.Context, username, email, apiKey string, storageLimit int64) (*User, error) {
|
||||||
|
var user User
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO users (username, email, api_key, storage_limit_bytes)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, username, email, storage_limit_bytes, created_at`,
|
||||||
|
username, email, apiKey, storageLimit).Scan(
|
||||||
|
&user.ID, &user.Username, &user.Email, &user.StorageLimitBytes, &user.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create user: %w", err)
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BucketService) GetUserByAPIKey(ctx context.Context, apiKey string) (*User, error) {
|
||||||
|
var user User
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, username, email, api_key, storage_limit_bytes, created_at
|
||||||
|
FROM users WHERE api_key = $1`,
|
||||||
|
apiKey).Scan(&user.ID, &user.Username, &user.Email, &user.APIKey, &user.StorageLimitBytes, &user.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get user by API key: %w", err)
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BucketService) CreateBucket(ctx context.Context, name string, ownerID int64) (*Bucket, error) {
|
||||||
|
var bucket Bucket
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO buckets (name, owner_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
RETURNING id, name, owner_id, storage_used_bytes, created_at`,
|
||||||
|
name, ownerID).Scan(
|
||||||
|
&bucket.ID, &bucket.Name, &bucket.OwnerID, &bucket.StorageUsedBytes, &bucket.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create bucket: %w", err)
|
||||||
|
}
|
||||||
|
return &bucket, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BucketService) GetUserBuckets(ctx context.Context, ownerID int64) ([]Bucket, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT id, name, owner_id, storage_used_bytes, created_at
|
||||||
|
FROM buckets WHERE owner_id = $1 ORDER BY created_at DESC`,
|
||||||
|
ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get user buckets: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var buckets []Bucket
|
||||||
|
for rows.Next() {
|
||||||
|
var bucket Bucket
|
||||||
|
err := rows.Scan(&bucket.ID, &bucket.Name, &bucket.OwnerID, &bucket.StorageUsedBytes, &bucket.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan bucket: %w", err)
|
||||||
|
}
|
||||||
|
buckets = append(buckets, bucket)
|
||||||
|
}
|
||||||
|
return buckets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BucketService) GetBucket(ctx context.Context, bucketName string, ownerID int64) (*Bucket, error) {
|
||||||
|
var bucket Bucket
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, name, owner_id, storage_used_bytes, created_at
|
||||||
|
FROM buckets WHERE name = $1 AND owner_id = $2`,
|
||||||
|
bucketName, ownerID).Scan(
|
||||||
|
&bucket.ID, &bucket.Name, &bucket.OwnerID, &bucket.StorageUsedBytes, &bucket.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get bucket: %w", err)
|
||||||
|
}
|
||||||
|
return &bucket, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BucketService) DeleteBucket(ctx context.Context, bucketName string, ownerID int64) error {
|
||||||
|
bucket, err := s.GetBucket(ctx, bucketName, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
objects, err := s.ListObjects(ctx, bucket.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list objects for deletion: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, obj := range objects {
|
||||||
|
if err := os.Remove(filepath.Join("storage", bucketName, obj.Key)); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
os.RemoveAll(filepath.Join("storage", bucketName))
|
||||||
|
|
||||||
|
_, err = s.pool.Exec(ctx, "DELETE FROM buckets WHERE name = $1 AND owner_id = $2", bucketName, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete bucket: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BucketService) UploadObject(ctx context.Context, bucketID int64, key string, size int64, contentType string, content io.Reader) (*Object, error) {
|
||||||
|
bucket, err := s.getBucketByID(ctx, bucketID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.getUserByID(ctx, bucket.OwnerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if bucket.StorageUsedBytes+size > user.StorageLimitBytes {
|
||||||
|
return nil, fmt.Errorf("storage limit exceeded")
|
||||||
|
}
|
||||||
|
|
||||||
|
bucketDir := filepath.Join("storage", bucket.Name)
|
||||||
|
if err := os.MkdirAll(bucketDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create bucket directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := filepath.Join(bucketDir, key)
|
||||||
|
file, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
hasher := md5.New()
|
||||||
|
writer := io.MultiWriter(file, hasher)
|
||||||
|
|
||||||
|
if _, err := io.Copy(writer, content); err != nil {
|
||||||
|
os.Remove(filePath)
|
||||||
|
return nil, fmt.Errorf("failed to write file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
md5Sum := fmt.Sprintf("%x", hasher.Sum(nil))
|
||||||
|
|
||||||
|
var object Object
|
||||||
|
err = s.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO objects (bucket_id, key, size_bytes, content_type, md5_checksum)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (bucket_id, key)
|
||||||
|
DO UPDATE SET size_bytes = $3, content_type = $4, md5_checksum = $5, last_modified = NOW()
|
||||||
|
RETURNING id, bucket_id, key, size_bytes, content_type, last_modified, md5_checksum`,
|
||||||
|
bucketID, key, size, contentType, md5Sum).Scan(
|
||||||
|
&object.ID, &object.BucketID, &object.Key, &object.SizeBytes, &object.ContentType, &object.LastModified, &object.MD5Checksum)
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(filePath)
|
||||||
|
return nil, fmt.Errorf("failed to create object record: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &object, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BucketService) ListObjects(ctx context.Context, bucketID int64) ([]Object, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT id, bucket_id, key, size_bytes, content_type, last_modified,
|
||||||
|
COALESCE(version_id, ''), COALESCE(md5_checksum, '')
|
||||||
|
FROM objects WHERE bucket_id = $1 ORDER BY last_modified DESC`,
|
||||||
|
bucketID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list objects: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var objects []Object
|
||||||
|
for rows.Next() {
|
||||||
|
var object Object
|
||||||
|
err := rows.Scan(&object.ID, &object.BucketID, &object.Key, &object.SizeBytes,
|
||||||
|
&object.ContentType, &object.LastModified, &object.VersionID, &object.MD5Checksum)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan object: %w", err)
|
||||||
|
}
|
||||||
|
objects = append(objects, object)
|
||||||
|
}
|
||||||
|
return objects, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BucketService) GetObject(ctx context.Context, bucketID int64, key string) (*Object, error) {
|
||||||
|
var object Object
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, bucket_id, key, size_bytes, content_type, last_modified,
|
||||||
|
COALESCE(version_id, ''), COALESCE(md5_checksum, '')
|
||||||
|
FROM objects WHERE bucket_id = $1 AND key = $2`,
|
||||||
|
bucketID, key).Scan(
|
||||||
|
&object.ID, &object.BucketID, &object.Key, &object.SizeBytes,
|
||||||
|
&object.ContentType, &object.LastModified, &object.VersionID, &object.MD5Checksum)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get object: %w", err)
|
||||||
|
}
|
||||||
|
return &object, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BucketService) DeleteObject(ctx context.Context, bucketID int64, key string) error {
|
||||||
|
bucket, err := s.getBucketByID(ctx, bucketID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := filepath.Join("storage", bucket.Name, key)
|
||||||
|
if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("failed to remove file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.pool.Exec(ctx, "DELETE FROM objects WHERE bucket_id = $1 AND key = $2", bucketID, key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete object record: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BucketService) GetObjectFile(ctx context.Context, bucketName, key string, ownerID int64) (*os.File, error) {
|
||||||
|
bucket, err := s.GetBucket(ctx, bucketName, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.GetObject(ctx, bucket.ID, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := filepath.Join("storage", bucketName, key)
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open file: %w", err)
|
||||||
|
}
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BucketService) getBucketByID(ctx context.Context, bucketID int64) (*Bucket, error) {
|
||||||
|
var bucket Bucket
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, name, owner_id, storage_used_bytes, created_at
|
||||||
|
FROM buckets WHERE id = $1`,
|
||||||
|
bucketID).Scan(
|
||||||
|
&bucket.ID, &bucket.Name, &bucket.OwnerID, &bucket.StorageUsedBytes, &bucket.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get bucket by ID: %w", err)
|
||||||
|
}
|
||||||
|
return &bucket, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BucketService) getUserByID(ctx context.Context, userID int64) (*User, error) {
|
||||||
|
var user User
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, username, email, storage_limit_bytes, created_at
|
||||||
|
FROM users WHERE id = $1`,
|
||||||
|
userID).Scan(&user.ID, &user.Username, &user.Email, &user.StorageLimitBytes, &user.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get user by ID: %w", err)
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
@ -28,6 +28,35 @@ CREATE TABLE IF NOT EXISTS objects (
|
|||||||
UNIQUE (bucket_id, key)
|
UNIQUE (bucket_id, key)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_buckets_owner_id ON buckets (owner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_api_key ON users (api_key);
|
||||||
CREATE INDEX IF NOT EXISTS idx_objects_bucket_id_key ON objects (bucket_id, key);
|
CREATE INDEX IF NOT EXISTS idx_objects_bucket_id_key ON objects (bucket_id, key);
|
||||||
CREATE INDEX IF NOT EXISTS idx_objects_bucket_id_last_modified ON objects (bucket_id, last_modified DESC);
|
CREATE INDEX IF NOT EXISTS idx_objects_bucket_id_last_modified ON objects (bucket_id, last_modified DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_objects_custom_metadata_gin ON objects USING GIN (custom_metadata);
|
CREATE INDEX IF NOT EXISTS idx_objects_custom_metadata_gin ON objects USING GIN (custom_metadata);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION update_bucket_storage() RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'INSERT' THEN
|
||||||
|
UPDATE buckets
|
||||||
|
SET storage_used_bytes = storage_used_bytes + NEW.size_bytes
|
||||||
|
WHERE id = NEW.bucket_id;
|
||||||
|
RETURN NEW;
|
||||||
|
ELSIF TG_OP = 'DELETE' THEN
|
||||||
|
UPDATE buckets
|
||||||
|
SET storage_used_bytes = storage_used_bytes - OLD.size_bytes
|
||||||
|
WHERE id = OLD.bucket_id;
|
||||||
|
RETURN OLD;
|
||||||
|
ELSIF TG_OP = 'UPDATE' THEN
|
||||||
|
UPDATE buckets
|
||||||
|
SET storage_used_bytes = storage_used_bytes + NEW.size_bytes - OLD.size_bytes
|
||||||
|
WHERE id = NEW.bucket_id;
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER update_bucket_storage_trigger
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON objects
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_bucket_storage();
|
||||||
|
|||||||
@ -1,75 +1,222 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"context"
|
||||||
"os"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.keircn.com/keiran/termcloud/internal/db"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FileInfo struct {
|
type Handlers struct {
|
||||||
FileName string `json:"fileName"`
|
bucketService *db.BucketService
|
||||||
FileSize int64 `json:"fileSize"`
|
|
||||||
FileType string `json:"fileType"`
|
|
||||||
FileURL string `json:"fileURL"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func RootHandler(c echo.Context) error {
|
func NewHandlers(bucketService *db.BucketService) *Handlers {
|
||||||
|
return &Handlers{bucketService: bucketService}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) RootHandler(c echo.Context) error {
|
||||||
return c.JSON(200, map[string]string{
|
return c.JSON(200, map[string]string{
|
||||||
"status": "😺",
|
"status": "😺",
|
||||||
"docs": "https://illfillthisoutlater.com",
|
"docs": "https://illfillthisoutlater.com",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func UploadHandler(c echo.Context) error {
|
func (h *Handlers) AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
file, err := c.FormFile("file")
|
return func(c echo.Context) error {
|
||||||
if err != nil {
|
apiKey := c.Request().Header.Get("X-API-Key")
|
||||||
c.Logger().Errorf("Error retrieving file from request: %v", err)
|
if apiKey == "" {
|
||||||
return c.JSON(400, map[string]string{"error": "Failed to retrieve file"})
|
return c.JSON(401, map[string]string{"error": "API key required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.bucketService.GetUserByAPIKey(context.Background(), apiKey)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(401, map[string]string{"error": "Invalid API key"})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("user", user)
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) CreateBucketHandler(c echo.Context) error {
|
||||||
|
user := c.Get("user").(*db.User)
|
||||||
|
|
||||||
|
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 file == nil {
|
if req.Name == "" {
|
||||||
c.Logger().Error("No file provided in the request")
|
return c.JSON(400, map[string]string{"error": "Bucket name is required"})
|
||||||
return c.JSON(400, map[string]string{"error": "No file provided"})
|
}
|
||||||
|
|
||||||
|
bucket, err := h.bucketService.CreateBucket(context.Background(), req.Name, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "duplicate key") {
|
||||||
|
return c.JSON(409, map[string]string{"error": "Bucket name already exists"})
|
||||||
|
}
|
||||||
|
return c.JSON(500, map[string]string{"error": "Failed to create bucket"})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(500, map[string]string{"error": "Failed to list buckets"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(200, map[string]any{
|
||||||
|
"buckets": buckets,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) DeleteBucketHandler(c echo.Context) error {
|
||||||
|
user := c.Get("user").(*db.User)
|
||||||
|
bucketName := c.Param("bucket")
|
||||||
|
|
||||||
|
if err := h.bucketService.DeleteBucket(context.Background(), bucketName, user.ID); 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 delete bucket"})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
bucketName := c.Param("bucket")
|
||||||
|
objectKey := c.Param("*")
|
||||||
|
|
||||||
|
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"})
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(400, map[string]string{"error": "Failed to retrieve file"})
|
||||||
}
|
}
|
||||||
|
|
||||||
src, err := file.Open()
|
src, err := file.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Logger().Errorf("Error opening uploaded file: %v", err)
|
|
||||||
return c.JSON(500, map[string]string{"error": "Failed to open file"})
|
return c.JSON(500, map[string]string{"error": "Failed to open file"})
|
||||||
}
|
}
|
||||||
defer src.Close()
|
defer src.Close()
|
||||||
|
|
||||||
fileName := file.Filename
|
contentType := file.Header.Get("Content-Type")
|
||||||
fileSize := file.Size
|
if contentType == "" {
|
||||||
fileType := file.Header.Get("Content-Type")
|
contentType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
c.Logger().Infof("Received file: %s, Size: %d bytes, Type: %s", fileName, fileSize, fileType)
|
object, err := h.bucketService.UploadObject(context.Background(), bucket.ID, objectKey, file.Size, contentType, src)
|
||||||
|
|
||||||
err = os.MkdirAll("uploads", 0o755)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Logger().Errorf("Error creating uploads directory: %v", err)
|
if strings.Contains(err.Error(), "storage limit exceeded") {
|
||||||
return c.JSON(500, map[string]string{"error": "Failed to create upload directory"})
|
return c.JSON(413, map[string]string{"error": "Storage limit exceeded"})
|
||||||
|
}
|
||||||
|
return c.JSON(500, map[string]string{"error": "Failed to upload object"})
|
||||||
}
|
}
|
||||||
|
|
||||||
dst, err := os.Create("uploads/" + fileName)
|
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 err != nil {
|
||||||
c.Logger().Errorf("Error creating file on server: %v", err)
|
if strings.Contains(err.Error(), "no rows") {
|
||||||
return c.JSON(500, map[string]string{"error": "Failed to save file"})
|
return c.JSON(404, map[string]string{"error": "Bucket not found"})
|
||||||
}
|
}
|
||||||
defer dst.Close()
|
return c.JSON(500, map[string]string{"error": "Failed to get bucket"})
|
||||||
|
|
||||||
if _, err := io.Copy(dst, src); err != nil {
|
|
||||||
c.Logger().Errorf("Error saving uploaded file: %v", err)
|
|
||||||
return c.JSON(500, map[string]string{"error": "Failed to save file"})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fileURL := "http://localhost:8080/uploads/" + fileName
|
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, FileInfo{
|
return c.JSON(200, map[string]any{
|
||||||
FileName: fileName,
|
"objects": objects,
|
||||||
FileSize: fileSize,
|
})
|
||||||
FileType: fileType,
|
}
|
||||||
FileURL: fileURL,
|
|
||||||
|
func (h *Handlers) GetObjectHandler(c echo.Context) error {
|
||||||
|
user := c.Get("user").(*db.User)
|
||||||
|
bucketName := c.Param("bucket")
|
||||||
|
objectKey := c.Param("*")
|
||||||
|
|
||||||
|
file, err := h.bucketService.GetObjectFile(context.Background(), bucketName, objectKey, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "no rows") {
|
||||||
|
return c.JSON(404, map[string]string{"error": "Object not found"})
|
||||||
|
}
|
||||||
|
return c.JSON(500, map[string]string{"error": "Failed to get object"})
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
stat, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(500, map[string]string{"error": "Failed to get file info"})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Response().Header().Set("Content-Length", strconv.FormatInt(stat.Size(), 10))
|
||||||
|
c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", objectKey))
|
||||||
|
|
||||||
|
return c.Stream(http.StatusOK, "application/octet-stream", file)
|
||||||
|
}
|
||||||
|
func (h *Handlers) DeleteObjectHandler(c echo.Context) error {
|
||||||
|
user := c.Get("user").(*db.User)
|
||||||
|
bucketName := c.Param("bucket")
|
||||||
|
objectKey := c.Param("*")
|
||||||
|
|
||||||
|
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"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.bucketService.DeleteObject(context.Background(), bucket.ID, objectKey); err != nil {
|
||||||
|
return c.JSON(500, map[string]string{"error": "Failed to delete object"})
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user