From c6e0a70d504f5397eda1100d99ff84794e7c3317 Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Thu, 11 Dec 2025 14:41:11 -0300 Subject: [PATCH 1/3] feat(backend): add PostgreSQL SSL support and DB_SSLMODE env var - Update database.go to use DB_SSLMODE environment variable - Default to sslmode=require for production security - Update .env.example with SSL and S3 configuration examples --- backend/.env.example | 8 ++++++ backend/go.mod | 19 ++++++++++++++ backend/go.sum | 38 +++++++++++++++++++++++++++ backend/internal/database/database.go | 9 +++++-- 4 files changed, 72 insertions(+), 2 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 09d9e83..d56d6cc 100755 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,6 +6,14 @@ DB_PORT=5432 DB_USER=postgres DB_PASSWORD=yourpassword DB_NAME=gohorsejobs +DB_SSLMODE=require + +# S3/Object Storage Configuration (S3-compatible) +AWS_REGION=nyc1 +AWS_ACCESS_KEY_ID=your-access-key +AWS_SECRET_ACCESS_KEY=your-secret-key +AWS_ENDPOINT=https://objectstore.nyc1.civo.com +S3_BUCKET=your-bucket-name # JWT Secret (CHANGE IN PRODUCTION!) JWT_SECRET=your-secret-key-change-this-in-production-use-strong-random-value diff --git a/backend/go.mod b/backend/go.mod index d916ad7..b0d14c1 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -14,6 +14,25 @@ require ( require ( github.com/KyleBanks/depth v1.2.1 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.5 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.5 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect + github.com/aws/smithy-go v1.24.0 // indirect github.com/go-openapi/jsonpointer v0.22.3 // indirect github.com/go-openapi/jsonreference v0.21.3 // indirect github.com/go-openapi/spec v0.22.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 1f67f93..83cf5f6 100755 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,5 +1,43 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= +github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= +github.com/aws/aws-sdk-go-v2/config v1.32.5 h1:pz3duhAfUgnxbtVhIK39PGF/AHYyrzGEyRD9Og0QrE8= +github.com/aws/aws-sdk-go-v2/config v1.32.5/go.mod h1:xmDjzSUs/d0BB7ClzYPAZMmgQdrodNjPPhd6bGASwoE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.5 h1:xMo63RlqP3ZZydpJDMBsH9uJ10hgHYfQFIk1cHDXrR4= +github.com/aws/aws-sdk-go-v2/credentials v1.19.5/go.mod h1:hhbH6oRcou+LpXfA/0vPElh/e0M3aFeOblE1sssAAEk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A= +github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2 h1:U3ygWUhCpiSPYSHOrRhb3gOl9T5Y3kB8k5Vjs//57bE= +github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 h1:eYnlt6QxnFINKzwxP5/Ucs1vkG7VT3Iezmvfgc2waUw= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.7/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= 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/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8= diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go index ce1f83e..3c6c66e 100755 --- a/backend/internal/database/database.go +++ b/backend/internal/database/database.go @@ -34,8 +34,13 @@ func InitDB() { port = "5432" } - connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", - host, port, user, password, dbname) + sslmode := os.Getenv("DB_SSLMODE") + if sslmode == "" { + sslmode = "require" // Default to require for production security + } + + connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", + host, port, user, password, dbname, sslmode) DB, err = sql.Open("postgres", connStr) if err != nil { From ce6e35aefd19946f6a868d8d23038ed8c3b057ee Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Thu, 11 Dec 2025 14:41:25 -0300 Subject: [PATCH 2/3] feat(backend): implement S3 object storage with pre-signed URLs - Add s3_storage.go service using AWS SDK v2 - Support custom S3-compatible endpoints (Civo) - Implement pre-signed URL generation for uploads/downloads - Add storage_handler.go with REST endpoints - Register protected storage routes in router - Graceful degradation when S3 not configured --- backend/internal/handlers/storage_handler.go | 170 ++++++++++++++++++ .../infrastructure/storage/s3_storage.go | 139 ++++++++++++++ backend/internal/router/router.go | 15 ++ 3 files changed, 324 insertions(+) create mode 100644 backend/internal/handlers/storage_handler.go create mode 100644 backend/internal/infrastructure/storage/s3_storage.go diff --git a/backend/internal/handlers/storage_handler.go b/backend/internal/handlers/storage_handler.go new file mode 100644 index 0000000..3572d13 --- /dev/null +++ b/backend/internal/handlers/storage_handler.go @@ -0,0 +1,170 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" + "github.com/rede5/gohorsejobs/backend/internal/infrastructure/storage" +) + +// StorageHandler handles file storage operations +type StorageHandler struct { + Storage *storage.S3Storage +} + +// NewStorageHandler creates a new storage handler +func NewStorageHandler(s *storage.S3Storage) *StorageHandler { + return &StorageHandler{Storage: s} +} + +// UploadURLRequest represents a request for a pre-signed upload URL +type UploadURLRequest struct { + Filename string `json:"filename"` + ContentType string `json:"contentType"` + Folder string `json:"folder"` // Optional: logos, resumes, documents +} + +// UploadURLResponse represents the response with a pre-signed upload URL +type UploadURLResponse struct { + UploadURL string `json:"uploadUrl"` + Key string `json:"key"` + PublicURL string `json:"publicUrl"` + ExpiresIn int `json:"expiresIn"` // seconds +} + +// DownloadURLRequest represents a request for a pre-signed download URL +type DownloadURLRequest struct { + Key string `json:"key"` +} + +// DownloadURLResponse represents the response with a pre-signed download URL +type DownloadURLResponse struct { + DownloadURL string `json:"downloadUrl"` + ExpiresIn int `json:"expiresIn"` // seconds +} + +// GenerateUploadURL handles POST /api/v1/storage/upload-url +func (h *StorageHandler) GenerateUploadURL(w http.ResponseWriter, r *http.Request) { + var req UploadURLRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.Filename == "" { + http.Error(w, "Filename is required", http.StatusBadRequest) + return + } + + if req.ContentType == "" { + // Try to infer from extension + ext := strings.ToLower(filepath.Ext(req.Filename)) + switch ext { + case ".jpg", ".jpeg": + req.ContentType = "image/jpeg" + case ".png": + req.ContentType = "image/png" + case ".gif": + req.ContentType = "image/gif" + case ".webp": + req.ContentType = "image/webp" + case ".pdf": + req.ContentType = "application/pdf" + case ".doc": + req.ContentType = "application/msword" + case ".docx": + req.ContentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + default: + req.ContentType = "application/octet-stream" + } + } + + // Validate folder + folder := "uploads" + if req.Folder != "" { + validFolders := map[string]bool{ + "logos": true, + "resumes": true, + "documents": true, + "avatars": true, + } + if validFolders[req.Folder] { + folder = req.Folder + } + } + + // Generate unique key + ext := filepath.Ext(req.Filename) + uniqueID := uuid.New().String() + timestamp := time.Now().Format("20060102") + key := fmt.Sprintf("%s/%s/%s%s", folder, timestamp, uniqueID, ext) + + // Generate pre-signed URL (15 minutes expiry) + expiryMinutes := 15 + uploadURL, err := h.Storage.GenerateUploadURL(key, req.ContentType, expiryMinutes) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to generate upload URL: %v", err), http.StatusInternalServerError) + return + } + + response := UploadURLResponse{ + UploadURL: uploadURL, + Key: key, + PublicURL: h.Storage.GetPublicURL(key), + ExpiresIn: expiryMinutes * 60, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// GenerateDownloadURL handles POST /api/v1/storage/download-url +func (h *StorageHandler) GenerateDownloadURL(w http.ResponseWriter, r *http.Request) { + var req DownloadURLRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.Key == "" { + http.Error(w, "Key is required", http.StatusBadRequest) + return + } + + // Generate pre-signed URL (1 hour expiry) + expiryMinutes := 60 + downloadURL, err := h.Storage.GenerateDownloadURL(req.Key, expiryMinutes) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to generate download URL: %v", err), http.StatusInternalServerError) + return + } + + response := DownloadURLResponse{ + DownloadURL: downloadURL, + ExpiresIn: expiryMinutes * 60, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// DeleteFile handles DELETE /api/v1/storage/files +func (h *StorageHandler) DeleteFile(w http.ResponseWriter, r *http.Request) { + key := r.URL.Query().Get("key") + if key == "" { + http.Error(w, "Key query parameter is required", http.StatusBadRequest) + return + } + + if err := h.Storage.DeleteObject(key); err != nil { + http.Error(w, fmt.Sprintf("Failed to delete file: %v", err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/backend/internal/infrastructure/storage/s3_storage.go b/backend/internal/infrastructure/storage/s3_storage.go new file mode 100644 index 0000000..5577377 --- /dev/null +++ b/backend/internal/infrastructure/storage/s3_storage.go @@ -0,0 +1,139 @@ +package storage + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +// S3Storage handles S3-compatible object storage operations +type S3Storage struct { + client *s3.Client + presigner *s3.PresignClient + bucket string + endpoint string +} + +// NewS3Storage creates a new S3 storage service +func NewS3Storage() (*S3Storage, error) { + region := os.Getenv("AWS_REGION") + if region == "" { + region = "us-east-1" + } + + accessKey := os.Getenv("AWS_ACCESS_KEY_ID") + secretKey := os.Getenv("AWS_SECRET_ACCESS_KEY") + endpoint := os.Getenv("AWS_ENDPOINT") + bucket := os.Getenv("S3_BUCKET") + + if accessKey == "" || secretKey == "" || bucket == "" { + return nil, fmt.Errorf("missing required S3 configuration (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, S3_BUCKET)") + } + + // Create custom credentials provider + creds := credentials.NewStaticCredentialsProvider(accessKey, secretKey, "") + + // Build S3 config + cfg, err := config.LoadDefaultConfig(context.Background(), + config.WithRegion(region), + config.WithCredentialsProvider(creds), + ) + if err != nil { + return nil, fmt.Errorf("failed to load AWS config: %w", err) + } + + // Create S3 client with custom endpoint for S3-compatible storage (like Civo) + var client *s3.Client + if endpoint != "" { + client = s3.NewFromConfig(cfg, func(o *s3.Options) { + o.BaseEndpoint = aws.String(endpoint) + o.UsePathStyle = true // Required for most S3-compatible services + }) + } else { + client = s3.NewFromConfig(cfg) + } + + presigner := s3.NewPresignClient(client) + + return &S3Storage{ + client: client, + presigner: presigner, + bucket: bucket, + endpoint: endpoint, + }, nil +} + +// GenerateUploadURL generates a pre-signed URL for uploading a file +func (s *S3Storage) GenerateUploadURL(key string, contentType string, expiryMinutes int) (string, error) { + if expiryMinutes <= 0 { + expiryMinutes = 15 // Default 15 minutes + } + + input := &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + ContentType: aws.String(contentType), + } + + presignResult, err := s.presigner.PresignPutObject(context.Background(), input, + s3.WithPresignExpires(time.Duration(expiryMinutes)*time.Minute)) + if err != nil { + return "", fmt.Errorf("failed to generate upload URL: %w", err) + } + + return presignResult.URL, nil +} + +// GenerateDownloadURL generates a pre-signed URL for downloading a file +func (s *S3Storage) GenerateDownloadURL(key string, expiryMinutes int) (string, error) { + if expiryMinutes <= 0 { + expiryMinutes = 60 // Default 1 hour + } + + input := &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + } + + presignResult, err := s.presigner.PresignGetObject(context.Background(), input, + s3.WithPresignExpires(time.Duration(expiryMinutes)*time.Minute)) + if err != nil { + return "", fmt.Errorf("failed to generate download URL: %w", err) + } + + return presignResult.URL, nil +} + +// DeleteObject deletes an object from the bucket +func (s *S3Storage) DeleteObject(key string) error { + input := &s3.DeleteObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + } + + _, err := s.client.DeleteObject(context.Background(), input) + if err != nil { + return fmt.Errorf("failed to delete object: %w", err) + } + + return nil +} + +// GetPublicURL returns the public URL for an object (if bucket is public) +func (s *S3Storage) GetPublicURL(key string) string { + if s.endpoint != "" { + return fmt.Sprintf("%s/%s/%s", s.endpoint, s.bucket, key) + } + return fmt.Sprintf("https://%s.s3.amazonaws.com/%s", s.bucket, key) +} + +// GetBucket returns the bucket name +func (s *S3Storage) GetBucket() string { + return s.bucket +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 4fd9214..b4b582e 100755 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -1,6 +1,7 @@ package router import ( + "log" "net/http" "os" @@ -8,6 +9,7 @@ import ( "github.com/rede5/gohorsejobs/backend/internal/database" "github.com/rede5/gohorsejobs/backend/internal/handlers" "github.com/rede5/gohorsejobs/backend/internal/infrastructure/persistence/postgres" + "github.com/rede5/gohorsejobs/backend/internal/infrastructure/storage" "github.com/rede5/gohorsejobs/backend/internal/services" // Core Imports @@ -87,6 +89,19 @@ func NewRouter() http.Handler { mux.HandleFunc("GET /applications/{id}", applicationHandler.GetApplicationByID) mux.HandleFunc("PUT /applications/{id}/status", applicationHandler.UpdateApplicationStatus) + // --- STORAGE ROUTES --- + // Initialize S3 Storage (optional - graceful degradation if not configured) + s3Storage, err := storage.NewS3Storage() + if err != nil { + log.Printf("Warning: S3 storage not available: %v", err) + } else { + storageHandler := handlers.NewStorageHandler(s3Storage) + mux.Handle("POST /api/v1/storage/upload-url", authMiddleware.HeaderAuthGuard(http.HandlerFunc(storageHandler.GenerateUploadURL))) + mux.Handle("POST /api/v1/storage/download-url", authMiddleware.HeaderAuthGuard(http.HandlerFunc(storageHandler.GenerateDownloadURL))) + mux.Handle("DELETE /api/v1/storage/files", authMiddleware.HeaderAuthGuard(http.HandlerFunc(storageHandler.DeleteFile))) + log.Println("S3 storage routes registered successfully") + } + // Swagger Route mux.HandleFunc("/swagger/", httpSwagger.WrapHandler) From 9630730d69799714cd6cedada0e7df5a9ec97028 Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Thu, 11 Dec 2025 14:42:29 -0300 Subject: [PATCH 3/3] feat(frontend): add storage service for S3 file uploads - Create storage.ts with pre-signed URL handling - Implement getUploadUrl, uploadFileToS3 helper functions - Add complete uploadFile workflow for easy file uploads - Support logos, resumes, documents, avatars folders --- frontend/src/lib/storage.ts | 143 ++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 frontend/src/lib/storage.ts diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts new file mode 100644 index 0000000..fe94c8f --- /dev/null +++ b/frontend/src/lib/storage.ts @@ -0,0 +1,143 @@ +/** + * Storage service for S3 file uploads via pre-signed URLs + */ + +const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080/api/v1"; + +interface UploadUrlResponse { + uploadUrl: string; + key: string; + publicUrl: string; + expiresIn: number; +} + +interface DownloadUrlResponse { + downloadUrl: string; + expiresIn: number; +} + +/** + * Get a pre-signed URL for uploading a file + */ +export async function getUploadUrl( + filename: string, + contentType: string, + folder: 'logos' | 'resumes' | 'documents' | 'avatars' = 'documents' +): Promise { + const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null; + + if (!token) { + throw new Error('Not authenticated'); + } + + const response = await fetch(`${API_URL}/storage/upload-url`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ + filename, + contentType, + folder, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to get upload URL: ${error}`); + } + + return response.json(); +} + +/** + * Upload a file directly to S3 using a pre-signed URL + */ +export async function uploadFileToS3( + file: File, + uploadUrl: string +): Promise { + const response = await fetch(uploadUrl, { + method: 'PUT', + headers: { + 'Content-Type': file.type, + }, + body: file, + }); + + if (!response.ok) { + throw new Error(`Failed to upload file: ${response.statusText}`); + } +} + +/** + * Get a pre-signed URL for downloading a file + */ +export async function getDownloadUrl(key: string): Promise { + const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null; + + if (!token) { + throw new Error('Not authenticated'); + } + + const response = await fetch(`${API_URL}/storage/download-url`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ key }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to get download URL: ${error}`); + } + + return response.json(); +} + +/** + * Complete file upload workflow: get pre-signed URL and upload file + * Returns the public URL of the uploaded file + */ +export async function uploadFile( + file: File, + folder: 'logos' | 'resumes' | 'documents' | 'avatars' = 'documents' +): Promise<{ key: string; publicUrl: string }> { + // Step 1: Get pre-signed upload URL from backend + const { uploadUrl, key, publicUrl } = await getUploadUrl( + file.name, + file.type, + folder + ); + + // Step 2: Upload file directly to S3 + await uploadFileToS3(file, uploadUrl); + + return { key, publicUrl }; +} + +/** + * Delete a file from storage + */ +export async function deleteFile(key: string): Promise { + const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null; + + if (!token) { + throw new Error('Not authenticated'); + } + + const response = await fetch(`${API_URL}/storage/files?key=${encodeURIComponent(key)}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to delete file: ${error}`); + } +}