From 29a80e057e01bc8066b6a5575350baf0d5fd9f6e Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 31 Mar 2026 14:41:17 +0800 Subject: [PATCH] feat(upload): add file upload API with S3 + CloudFront signed cookies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add POST /api/upload-file endpoint that uploads files to S3 and returns CDN URLs protected by CloudFront signed cookies (same pattern as Linear). Infrastructure: - Two private S3 buckets (static.multica.ai, static-staging.multica.ai) - Two CloudFront distributions with OAC and Trusted Key Groups - ACM wildcard cert in us-east-1, DNS records in Route 53 - RSA signing key stored in AWS Secrets Manager Backend: - S3 storage service with CloudFront CDN domain support - CloudFront signed cookie generation (RSA-SHA1) - Private key loaded from Secrets Manager (env var fallback for local dev) - Cookies set on login (VerifyCode) with 72h expiry matching JWT - Upload handler: multipart form → S3 → CloudFront URL response Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 9 ++ server/cmd/server/router.go | 7 +- server/go.mod | 20 +++ server/go.sum | 40 +++++ server/internal/auth/cloudfront.go | 185 ++++++++++++++++++++++++ server/internal/handler/auth.go | 7 + server/internal/handler/file.go | 60 ++++++++ server/internal/handler/handler.go | 8 +- server/internal/handler/handler_test.go | 2 +- server/internal/storage/s3.go | 90 ++++++++++++ 10 files changed, 425 insertions(+), 3 deletions(-) create mode 100644 server/internal/auth/cloudfront.go create mode 100644 server/internal/handler/file.go create mode 100644 server/internal/storage/s3.go diff --git a/.env.example b/.env.example index 1c0c93ab..e627d3f9 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,15 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback +# S3 / CloudFront +S3_BUCKET= +S3_REGION=us-west-2 +CLOUDFRONT_KEY_PAIR_ID= +CLOUDFRONT_PRIVATE_KEY_SECRET=multica/cloudfront-signing-key +CLOUDFRONT_PRIVATE_KEY= +CLOUDFRONT_DOMAIN= +COOKIE_DOMAIN= + # Frontend FRONTEND_PORT=3000 FRONTEND_ORIGIN=http://localhost:3000 diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 2de70b1e..e65b6b9a 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -12,11 +12,13 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" + "github.com/multica-ai/multica/server/internal/auth" "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/handler" "github.com/multica-ai/multica/server/internal/middleware" "github.com/multica-ai/multica/server/internal/realtime" "github.com/multica-ai/multica/server/internal/service" + "github.com/multica-ai/multica/server/internal/storage" db "github.com/multica-ai/multica/server/pkg/db/generated" ) @@ -47,7 +49,9 @@ func allowedOrigins() []string { func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Router { queries := db.New(pool) emailSvc := service.NewEmailService() - h := handler.New(queries, pool, hub, bus, emailSvc) + s3 := storage.NewS3StorageFromEnv() + cfSigner := auth.NewCloudFrontSignerFromEnv() + h := handler.New(queries, pool, hub, bus, emailSvc, s3, cfSigner) r := chi.NewRouter() @@ -110,6 +114,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route // --- User-scoped routes (no workspace context required) --- r.Get("/api/me", h.GetMe) r.Patch("/api/me", h.UpdateMe) + r.Post("/api/upload-file", h.UploadFile) r.Route("/api/workspaces", func(r chi.Router) { r.Get("/", h.ListWorkspaces) diff --git a/server/go.mod b/server/go.mod index 05c33813..2cdbd6d4 100644 --- a/server/go.mod +++ b/server/go.mod @@ -13,6 +13,26 @@ require ( ) require ( + github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.13 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.13 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 // indirect + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect + github.com/aws/smithy-go v1.24.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/server/go.sum b/server/go.sum index da00dcd6..7017544c 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,3 +1,43 @@ +github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= +github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= +github.com/aws/aws-sdk-go-v2/config v1.32.13 h1:5KgbxMaS2coSWRrx9TX/QtWbqzgQkOdEa3sZPhBhCSg= +github.com/aws/aws-sdk-go-v2/config v1.32.13/go.mod h1:8zz7wedqtCbw5e9Mi2doEwDyEgHcEE9YOJp6a8jdSMY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.13 h1:mA59E3fokBvyEGHKFdnpNNrvaR351cqiHgRg+JzOSRI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.13/go.mod h1:yoTXOQKea18nrM69wGF9jBdG4WocSZA1h38A+t/MAsk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5 h1:z2ayoK3pOvf8ODj/vPR0FgAS5ONruBq0F94SRoW/BIU= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5/go.mod h1:mpZB5HAl4ZIISod9qCi12xZ170TbHX9CCJV5y7nb7QU= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 h1:GcLE9ba5ehAQma6wlopUesYg/hbcOhFNWTjELkiWkh4= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.14/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 h1:mP49nTpfKtpXLt5SLn8Uv8z6W+03jYVoOSAl/c02nog= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/server/internal/auth/cloudfront.go b/server/internal/auth/cloudfront.go new file mode 100644 index 00000000..03396aac --- /dev/null +++ b/server/internal/auth/cloudfront.go @@ -0,0 +1,185 @@ +package auth + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "log/slog" + "net/http" + "os" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" +) + +// CloudFrontSigner generates signed cookies for CloudFront private distributions. +type CloudFrontSigner struct { + keyPairID string + privateKey *rsa.PrivateKey + domain string // CDN domain, e.g. "static.multica.ai" + cookieDomain string // cookie scope, e.g. ".multica.ai" +} + +// NewCloudFrontSignerFromEnv creates a signer from environment variables. +// Returns nil if CLOUDFRONT_KEY_PAIR_ID is not set (disables signed cookies). +// +// Private key resolution order: +// 1. AWS Secrets Manager (CLOUDFRONT_PRIVATE_KEY_SECRET — secret name/ARN) +// 2. Environment variable fallback (CLOUDFRONT_PRIVATE_KEY — base64-encoded PEM, for local dev only) +// +// Other required environment variables: +// - CLOUDFRONT_KEY_PAIR_ID +// - CLOUDFRONT_DOMAIN (e.g. "static.multica.ai") +// - COOKIE_DOMAIN (e.g. ".multica.ai") +func NewCloudFrontSignerFromEnv() *CloudFrontSigner { + keyPairID := os.Getenv("CLOUDFRONT_KEY_PAIR_ID") + if keyPairID == "" { + slog.Info("CLOUDFRONT_KEY_PAIR_ID not set, signed cookies disabled") + return nil + } + + domain := os.Getenv("CLOUDFRONT_DOMAIN") + if domain == "" { + slog.Error("CLOUDFRONT_DOMAIN not set") + return nil + } + + cookieDomain := os.Getenv("COOKIE_DOMAIN") + if cookieDomain == "" { + slog.Error("COOKIE_DOMAIN not set") + return nil + } + + rsaKey, err := loadPrivateKey() + if err != nil { + slog.Error("failed to load CloudFront private key", "error", err) + return nil + } + + slog.Info("CloudFront cookie signer initialized", "key_pair_id", keyPairID, "domain", domain) + return &CloudFrontSigner{ + keyPairID: keyPairID, + privateKey: rsaKey, + domain: domain, + cookieDomain: cookieDomain, + } +} + +// loadPrivateKey loads the RSA private key from Secrets Manager or env var fallback. +func loadPrivateKey() (*rsa.PrivateKey, error) { + // 1. Try Secrets Manager + if secretName := os.Getenv("CLOUDFRONT_PRIVATE_KEY_SECRET"); secretName != "" { + slog.Info("loading CloudFront private key from Secrets Manager", "secret", secretName) + return loadKeyFromSecretsManager(secretName) + } + + // 2. Fallback: base64-encoded env var (local dev) + if pkB64 := os.Getenv("CLOUDFRONT_PRIVATE_KEY"); pkB64 != "" { + slog.Info("loading CloudFront private key from environment variable (local dev)") + pemBytes, err := base64.StdEncoding.DecodeString(pkB64) + if err != nil { + return nil, fmt.Errorf("base64 decode: %w", err) + } + return parseRSAPrivateKey(pemBytes) + } + + return nil, fmt.Errorf("neither CLOUDFRONT_PRIVATE_KEY_SECRET nor CLOUDFRONT_PRIVATE_KEY is set") +} + +func loadKeyFromSecretsManager(secretName string) (*rsa.PrivateKey, error) { + cfg, err := awsconfig.LoadDefaultConfig(context.Background()) + if err != nil { + return nil, fmt.Errorf("load AWS config: %w", err) + } + + client := secretsmanager.NewFromConfig(cfg) + result, err := client.GetSecretValue(context.Background(), &secretsmanager.GetSecretValueInput{ + SecretId: aws.String(secretName), + }) + if err != nil { + return nil, fmt.Errorf("get secret %q: %w", secretName, err) + } + + if result.SecretString == nil { + return nil, fmt.Errorf("secret %q has no string value", secretName) + } + + return parseRSAPrivateKey([]byte(*result.SecretString)) +} + +func parseRSAPrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) { + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil, fmt.Errorf("no PEM block found") + } + + // Try PKCS8 first, then PKCS1 + if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { + if rsaKey, ok := key.(*rsa.PrivateKey); ok { + return rsaKey, nil + } + return nil, fmt.Errorf("PKCS8 key is not RSA") + } + + rsaKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse private key: %w", err) + } + return rsaKey, nil +} + +// Domain returns the CDN domain (e.g. "static.multica.ai"). +func (s *CloudFrontSigner) Domain() string { + return s.domain +} + +// SignedCookies generates the three CloudFront signed cookies with the given expiry. +func (s *CloudFrontSigner) SignedCookies(expiry time.Time) []*http.Cookie { + policy := fmt.Sprintf(`{"Statement":[{"Resource":"https://%s/*","Condition":{"DateLessThan":{"AWS:EpochTime":%d}}}]}`, s.domain, expiry.Unix()) + + encodedPolicy := cfBase64Encode([]byte(policy)) + + h := sha1.New() + h.Write([]byte(policy)) + sig, err := rsa.SignPKCS1v15(rand.Reader, s.privateKey, crypto.SHA1, h.Sum(nil)) + if err != nil { + slog.Error("failed to sign CloudFront policy", "error", err) + return nil + } + encodedSig := cfBase64Encode(sig) + + cookieAttrs := func(name, value string) *http.Cookie { + return &http.Cookie{ + Name: name, + Value: value, + Domain: s.cookieDomain, + Path: "/", + Expires: expiry, + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteNoneMode, + } + } + + return []*http.Cookie{ + cookieAttrs("CloudFront-Policy", encodedPolicy), + cookieAttrs("CloudFront-Signature", encodedSig), + cookieAttrs("CloudFront-Key-Pair-Id", s.keyPairID), + } +} + +// cfBase64Encode applies CloudFront's URL-safe base64 encoding. +func cfBase64Encode(data []byte) string { + encoded := base64.StdEncoding.EncodeToString(data) + r := strings.NewReplacer("+", "-", "=", "_", "/", "~") + return r.Replace(encoded) +} diff --git a/server/internal/handler/auth.go b/server/internal/handler/auth.go index e073ef82..5339190c 100644 --- a/server/internal/handler/auth.go +++ b/server/internal/handler/auth.go @@ -300,6 +300,13 @@ func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) { return } + // Set CloudFront signed cookies for CDN access. + if h.CFSigner != nil { + for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(72 * time.Hour)) { + http.SetCookie(w, cookie) + } + } + slog.Info("user logged in", append(logger.RequestAttrs(r), "user_id", uuidToString(user.ID), "email", user.Email)...) writeJSON(w, http.StatusOK, LoginResponse{ Token: tokenString, diff --git a/server/internal/handler/file.go b/server/internal/handler/file.go new file mode 100644 index 00000000..e47c7423 --- /dev/null +++ b/server/internal/handler/file.go @@ -0,0 +1,60 @@ +package handler + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "log/slog" + "net/http" + "path" +) + +func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { + if h.Storage == nil { + writeError(w, http.StatusServiceUnavailable, "file upload not configured") + return + } + + if err := r.ParseMultipartForm(32 << 20); err != nil { + writeError(w, http.StatusBadRequest, "invalid multipart form") + return + } + defer r.MultipartForm.RemoveAll() + + file, header, err := r.FormFile("file") + if err != nil { + writeError(w, http.StatusBadRequest, fmt.Sprintf("missing file field: %v", err)) + return + } + defer file.Close() + + data, err := io.ReadAll(file) + if err != nil { + writeError(w, http.StatusBadRequest, fmt.Sprintf("failed to read file: %v", err)) + return + } + + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + slog.Error("failed to generate file key", "error", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + key := hex.EncodeToString(b) + path.Ext(header.Filename) + + contentType := header.Header.Get("Content-Type") + link, err := h.Storage.Upload(r.Context(), key, data, contentType, map[string]string{ + "filename": header.Filename, + }) + if err != nil { + slog.Error("file upload failed", "error", err) + writeError(w, http.StatusInternalServerError, "upload failed") + return + } + + writeJSON(w, http.StatusOK, map[string]string{ + "filename": header.Filename, + "link": link, + }) +} diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go index d8086a79..cdf81027 100644 --- a/server/internal/handler/handler.go +++ b/server/internal/handler/handler.go @@ -12,10 +12,12 @@ import ( "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" db "github.com/multica-ai/multica/server/pkg/db/generated" + "github.com/multica-ai/multica/server/internal/auth" "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/middleware" "github.com/multica-ai/multica/server/internal/realtime" "github.com/multica-ai/multica/server/internal/service" + "github.com/multica-ai/multica/server/internal/storage" "github.com/multica-ai/multica/server/internal/util" ) @@ -38,9 +40,11 @@ type Handler struct { TaskService *service.TaskService EmailService *service.EmailService PingStore *PingStore + Storage *storage.S3Storage + CFSigner *auth.CloudFrontSigner } -func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService) *Handler { +func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, s3 *storage.S3Storage, cfSigner *auth.CloudFrontSigner) *Handler { var executor dbExecutor if candidate, ok := txStarter.(dbExecutor); ok { executor = candidate @@ -55,6 +59,8 @@ func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *event TaskService: service.NewTaskService(queries, hub, bus), EmailService: emailService, PingStore: NewPingStore(), + Storage: s3, + CFSigner: cfSigner, } } diff --git a/server/internal/handler/handler_test.go b/server/internal/handler/handler_test.go index daf68e0a..15e1769b 100644 --- a/server/internal/handler/handler_test.go +++ b/server/internal/handler/handler_test.go @@ -53,7 +53,7 @@ func TestMain(m *testing.M) { go hub.Run() bus := events.New() emailSvc := service.NewEmailService() - testHandler = New(queries, pool, hub, bus, emailSvc) + testHandler = New(queries, pool, hub, bus, emailSvc, nil, nil) testPool = pool testUserID, testWorkspaceID, err = setupHandlerTestFixture(ctx, pool) diff --git a/server/internal/storage/s3.go b/server/internal/storage/s3.go new file mode 100644 index 00000000..f839222f --- /dev/null +++ b/server/internal/storage/s3.go @@ -0,0 +1,90 @@ +package storage + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "os" + + "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" + "github.com/aws/aws-sdk-go-v2/service/s3/types" +) + +type S3Storage struct { + client *s3.Client + bucket string + cdnDomain string // if set, returned URLs use this instead of bucket name +} + +// NewS3StorageFromEnv creates an S3Storage from environment variables. +// Returns nil if S3_BUCKET is not set. +// +// Environment variables: +// - S3_BUCKET (required) +// - S3_REGION (default: us-west-2) +// - AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (optional; falls back to default credential chain) +func NewS3StorageFromEnv() *S3Storage { + bucket := os.Getenv("S3_BUCKET") + if bucket == "" { + slog.Info("S3_BUCKET not set, file upload disabled") + return nil + } + + region := os.Getenv("S3_REGION") + if region == "" { + region = "us-west-2" + } + + opts := []func(*config.LoadOptions) error{ + config.WithRegion(region), + } + + accessKey := os.Getenv("AWS_ACCESS_KEY_ID") + secretKey := os.Getenv("AWS_SECRET_ACCESS_KEY") + if accessKey != "" && secretKey != "" { + opts = append(opts, config.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider(accessKey, secretKey, ""), + )) + } + + cfg, err := config.LoadDefaultConfig(context.Background(), opts...) + if err != nil { + slog.Error("failed to load AWS config", "error", err) + return nil + } + + cdnDomain := os.Getenv("CLOUDFRONT_DOMAIN") + + slog.Info("S3 storage initialized", "bucket", bucket, "region", region, "cdn_domain", cdnDomain) + return &S3Storage{ + client: s3.NewFromConfig(cfg), + bucket: bucket, + cdnDomain: cdnDomain, + } +} + +func (s *S3Storage) Upload(ctx context.Context, key string, data []byte, contentType string, metadata map[string]string) (string, error) { + _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + Body: bytes.NewReader(data), + ContentType: aws.String(contentType), + CacheControl: aws.String("max-age=432000,public"), + StorageClass: types.StorageClassIntelligentTiering, + Metadata: metadata, + }) + if err != nil { + return "", fmt.Errorf("s3 PutObject: %w", err) + } + + domain := s.bucket + if s.cdnDomain != "" { + domain = s.cdnDomain + } + link := fmt.Sprintf("https://%s/%s", domain, key) + return link, nil +}