feat(upload): add file upload API with S3 + CloudFront signed cookies
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) <noreply@anthropic.com>
This commit is contained in:
parent
961de18c97
commit
29a80e057e
10 changed files with 425 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
185
server/internal/auth/cloudfront.go
Normal file
185
server/internal/auth/cloudfront.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
60
server/internal/handler/file.go
Normal file
60
server/internal/handler/file.go
Normal file
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
90
server/internal/storage/s3.go
Normal file
90
server/internal/storage/s3.go
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue