From 29a80e057e01bc8066b6a5575350baf0d5fd9f6e Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 31 Mar 2026 14:41:17 +0800 Subject: [PATCH 1/8] 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 +} From edf4c00c08727ca8f2bbd37797bb1f11d85aa652 Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 31 Mar 2026 14:55:27 +0800 Subject: [PATCH 2/8] fix(upload): add file type/size validation, Content-Disposition header - Add content type allowlist (images, PDF, text, video, audio, zip) - Enforce 10 MB upload limit via http.MaxBytesReader - Set Content-Disposition on S3 objects for proper download filenames - Remove unused CloudFrontSigner.Domain() method Co-Authored-By: Claude Opus 4.6 (1M context) --- server/go.mod | 12 ++++---- server/internal/auth/cloudfront.go | 5 ---- server/internal/handler/file.go | 47 +++++++++++++++++++++++++----- server/internal/storage/s3.go | 16 +++++----- 4 files changed, 54 insertions(+), 26 deletions(-) diff --git a/server/go.mod b/server/go.mod index 2cdbd6d4..30725711 100644 --- a/server/go.mod +++ b/server/go.mod @@ -3,20 +3,23 @@ module github.com/multica-ai/multica/server go 1.26.1 require ( + github.com/aws/aws-sdk-go-v2 v1.41.5 + github.com/aws/aws-sdk-go-v2/config v1.32.13 + github.com/aws/aws-sdk-go-v2/credentials v1.19.13 + github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.8.0 + github.com/lmittmann/tint v1.1.3 github.com/resend/resend-go/v2 v2.28.0 github.com/spf13/cobra v1.10.2 ) 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 @@ -26,8 +29,6 @@ require ( 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 @@ -37,7 +38,6 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/lmittmann/tint v1.1.3 // indirect github.com/spf13/pflag v1.0.9 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/text v0.35.0 // indirect diff --git a/server/internal/auth/cloudfront.go b/server/internal/auth/cloudfront.go index 03396aac..ee255378 100644 --- a/server/internal/auth/cloudfront.go +++ b/server/internal/auth/cloudfront.go @@ -137,11 +137,6 @@ func parseRSAPrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) { 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()) diff --git a/server/internal/handler/file.go b/server/internal/handler/file.go index e47c7423..ab7882ee 100644 --- a/server/internal/handler/file.go +++ b/server/internal/handler/file.go @@ -8,16 +8,46 @@ import ( "log/slog" "net/http" "path" + "strings" ) +const maxUploadSize = 10 << 20 // 10 MB + +// Allowed MIME type prefixes and exact types for uploads. +var allowedContentTypes = map[string]bool{ + "image/png": true, + "image/jpeg": true, + "image/gif": true, + "image/webp": true, + "image/svg+xml": true, + "application/pdf": true, + "text/plain": true, + "text/csv": true, + "application/json": true, + "video/mp4": true, + "video/webm": true, + "audio/mpeg": true, + "audio/wav": true, + "application/zip": true, +} + +func isContentTypeAllowed(ct string) bool { + // Normalize: take only the media type, strip parameters like charset. + ct = strings.TrimSpace(strings.SplitN(ct, ";", 2)[0]) + ct = strings.ToLower(ct) + return allowedContentTypes[ct] +} + 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") + r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) + + if err := r.ParseMultipartForm(maxUploadSize); err != nil { + writeError(w, http.StatusBadRequest, "file too large or invalid multipart form") return } defer r.MultipartForm.RemoveAll() @@ -29,9 +59,15 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { } defer file.Close() + contentType := header.Header.Get("Content-Type") + if !isContentTypeAllowed(contentType) { + writeError(w, http.StatusBadRequest, fmt.Sprintf("file type not allowed: %s", contentType)) + return + } + data, err := io.ReadAll(file) if err != nil { - writeError(w, http.StatusBadRequest, fmt.Sprintf("failed to read file: %v", err)) + writeError(w, http.StatusBadRequest, "failed to read file") return } @@ -43,10 +79,7 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { } 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, - }) + link, err := h.Storage.Upload(r.Context(), key, data, contentType, header.Filename) if err != nil { slog.Error("file upload failed", "error", err) writeError(w, http.StatusInternalServerError, "upload failed") diff --git a/server/internal/storage/s3.go b/server/internal/storage/s3.go index f839222f..3f03ea86 100644 --- a/server/internal/storage/s3.go +++ b/server/internal/storage/s3.go @@ -67,15 +67,15 @@ func NewS3StorageFromEnv() *S3Storage { } } -func (s *S3Storage) Upload(ctx context.Context, key string, data []byte, contentType string, metadata map[string]string) (string, error) { +func (s *S3Storage) Upload(ctx context.Context, key string, data []byte, contentType string, filename 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, + Bucket: aws.String(s.bucket), + Key: aws.String(key), + Body: bytes.NewReader(data), + ContentType: aws.String(contentType), + ContentDisposition: aws.String(fmt.Sprintf(`inline; filename="%s"`, filename)), + CacheControl: aws.String("max-age=432000,public"), + StorageClass: types.StorageClassIntelligentTiering, }) if err != nil { return "", fmt.Errorf("s3 PutObject: %w", err) From c27b7bab5eceade3657d579fe0219f024aa40c6d Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 31 Mar 2026 14:58:52 +0800 Subject: [PATCH 3/8] fix(upload): sniff content type, sanitize filename, add key prefix - Use http.DetectContentType() instead of trusting client-declared MIME type - Sanitize quotes in filename for Content-Disposition header injection - Add uploads/ prefix to S3 keys for better organization Co-Authored-By: Claude Opus 4.6 (1M context) --- server/internal/handler/file.go | 16 ++++++++++++++-- server/internal/storage/s3.go | 3 ++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/server/internal/handler/file.go b/server/internal/handler/file.go index ab7882ee..096e9a13 100644 --- a/server/internal/handler/file.go +++ b/server/internal/handler/file.go @@ -59,11 +59,23 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { } defer file.Close() - contentType := header.Header.Get("Content-Type") + // Sniff actual content type from file bytes instead of trusting the client header. + buf := make([]byte, 512) + n, err := file.Read(buf) + if err != nil && err != io.EOF { + writeError(w, http.StatusBadRequest, "failed to read file") + return + } + contentType := http.DetectContentType(buf[:n]) if !isContentTypeAllowed(contentType) { writeError(w, http.StatusBadRequest, fmt.Sprintf("file type not allowed: %s", contentType)) return } + // Seek back so the full file is uploaded. + if _, err := file.Seek(0, io.SeekStart); err != nil { + writeError(w, http.StatusInternalServerError, "failed to read file") + return + } data, err := io.ReadAll(file) if err != nil { @@ -77,7 +89,7 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "internal error") return } - key := hex.EncodeToString(b) + path.Ext(header.Filename) + key := "uploads/" + hex.EncodeToString(b) + path.Ext(header.Filename) link, err := h.Storage.Upload(r.Context(), key, data, contentType, header.Filename) if err != nil { diff --git a/server/internal/storage/s3.go b/server/internal/storage/s3.go index 3f03ea86..b29375f0 100644 --- a/server/internal/storage/s3.go +++ b/server/internal/storage/s3.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "os" + "strings" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" @@ -73,7 +74,7 @@ func (s *S3Storage) Upload(ctx context.Context, key string, data []byte, content Key: aws.String(key), Body: bytes.NewReader(data), ContentType: aws.String(contentType), - ContentDisposition: aws.String(fmt.Sprintf(`inline; filename="%s"`, filename)), + ContentDisposition: aws.String(fmt.Sprintf(`inline; filename="%s"`, strings.ReplaceAll(filename, `"`, "'"))), CacheControl: aws.String("max-age=432000,public"), StorageClass: types.StorageClassIntelligentTiering, }) From 978a5af5de945422535cad057fc936737619fab0 Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 31 Mar 2026 15:01:33 +0800 Subject: [PATCH 4/8] fix(upload): remove unnecessary uploads/ key prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-purpose bucket with randomized hex keys doesn't benefit from a prefix — no lifecycle policies or access controls scoped to it. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/internal/handler/file.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/internal/handler/file.go b/server/internal/handler/file.go index 096e9a13..230c0336 100644 --- a/server/internal/handler/file.go +++ b/server/internal/handler/file.go @@ -89,7 +89,7 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "internal error") return } - key := "uploads/" + hex.EncodeToString(b) + path.Ext(header.Filename) + key := hex.EncodeToString(b) + path.Ext(header.Filename) link, err := h.Storage.Upload(r.Context(), key, data, contentType, header.Filename) if err != nil { From 423aa3888836d226059674f2dabe4f6d093f8093 Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 31 Mar 2026 15:17:54 +0800 Subject: [PATCH 5/8] =?UTF-8?q?feat(upload):=20add=20file=20upload=20UI=20?= =?UTF-8?q?=E2=80=94=20avatar,=20editor=20paste/drop,=20attachments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add uploadFile method to ApiClient (FormData + 401 handling) - Add useFileUpload hook with client-side validation - ActorAvatar renders actual avatar images with fallback to initials - Account settings: replace URL input with clickable avatar upload - RichTextEditor: add Image extension, paste/drop/insertFile support - Markdown renderer: add img component for uploaded images - CommentInput & ReplyInput: add paperclip button for file attachments - Issue description: paste/drop file upload support Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/(dashboard)/issues/[id]/page.test.tsx | 1 + apps/web/app/(dashboard)/issues/page.test.tsx | 1 + .../settings/_components/account-tab.tsx | 88 ++++++++++++++---- apps/web/components/common/actor-avatar.tsx | 25 +++++- .../components/common/rich-text-editor.tsx | 89 +++++++++++++++++++ apps/web/components/markdown/Markdown.tsx | 9 ++ .../issues/components/comment-input.tsx | 44 ++++++++- .../issues/components/issue-detail.tsx | 15 ++++ .../issues/components/reply-input.tsx | 45 +++++++++- apps/web/features/workspace/hooks.ts | 8 +- apps/web/hooks/use-file-upload.ts | 58 ++++++++++++ apps/web/package.json | 1 + apps/web/shared/api/client.ts | 39 ++++++++ pnpm-lock.yaml | 12 +++ 14 files changed, 409 insertions(+), 26 deletions(-) create mode 100644 apps/web/hooks/use-file-upload.ts diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index 8e27d5e1..77739728 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -58,6 +58,7 @@ vi.mock("@/features/workspace", () => ({ if (type === "agent") return "CA"; return "??"; }, + getActorAvatarUrl: () => null, }), })); diff --git a/apps/web/app/(dashboard)/issues/page.test.tsx b/apps/web/app/(dashboard)/issues/page.test.tsx index c7af7dcc..5e9d662e 100644 --- a/apps/web/app/(dashboard)/issues/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/page.test.tsx @@ -34,6 +34,7 @@ vi.mock("@/features/workspace", () => ({ getActorName: (type: string, id: string) => type === "member" ? "Test User" : "Claude Agent", getActorInitials: () => "TU", + getActorAvatarUrl: () => null, }), useWorkspaceStore: Object.assign( (selector?: any) => { diff --git a/apps/web/app/(dashboard)/settings/_components/account-tab.tsx b/apps/web/app/(dashboard)/settings/_components/account-tab.tsx index d3ecb705..dbf40065 100644 --- a/apps/web/app/(dashboard)/settings/_components/account-tab.tsx +++ b/apps/web/app/(dashboard)/settings/_components/account-tab.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; -import { Save } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Camera, Loader2, Save } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; @@ -9,27 +9,48 @@ import { Card, CardContent } from "@/components/ui/card"; import { toast } from "sonner"; import { useAuthStore } from "@/features/auth"; import { api } from "@/shared/api"; +import { useFileUpload } from "@/hooks/use-file-upload"; export function AccountTab() { const user = useAuthStore((s) => s.user); const setUser = useAuthStore((s) => s.setUser); const [profileName, setProfileName] = useState(user?.name ?? ""); - const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? ""); const [profileSaving, setProfileSaving] = useState(false); + const { upload, uploading } = useFileUpload(); + const fileInputRef = useRef(null); useEffect(() => { setProfileName(user?.name ?? ""); - setAvatarUrl(user?.avatar_url ?? ""); }, [user]); + const initials = (user?.name ?? "") + .split(" ") + .map((w) => w[0]) + .join("") + .toUpperCase() + .slice(0, 2); + + const handleAvatarUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + // Reset input so the same file can be re-selected + e.target.value = ""; + try { + const result = await upload(file); + if (!result) return; + const updated = await api.updateMe({ avatar_url: result.link }); + setUser(updated); + toast.success("Avatar updated"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to upload avatar"); + } + }; + const handleProfileSave = async () => { setProfileSaving(true); try { - const updated = await api.updateMe({ - name: profileName, - avatar_url: avatarUrl || undefined, - }); + const updated = await api.updateMe({ name: profileName }); setUser(updated); toast.success("Profile updated"); } catch (e) { @@ -45,7 +66,46 @@ export function AccountTab() {

Profile

- + + {/* Avatar upload */} +
+ + +
+ Click to upload avatar +
+
+
-
- - setAvatarUrl(e.target.value)} - placeholder="https://example.com/avatar.png" - className="mt-1" - /> -
+
@@ -76,7 +101,23 @@ function ReplyInput({ }`} >
-
+
+ +
diff --git a/apps/web/features/issues/components/reply-input.tsx b/apps/web/features/issues/components/reply-input.tsx index db3cd80f..f8fb097b 100644 --- a/apps/web/features/issues/components/reply-input.tsx +++ b/apps/web/features/issues/components/reply-input.tsx @@ -13,6 +13,7 @@ import { toast } from "sonner"; // --------------------------------------------------------------------------- interface ReplyInputProps { + issueId: string; placeholder?: string; avatarType: string; avatarId: string; @@ -25,6 +26,7 @@ interface ReplyInputProps { // --------------------------------------------------------------------------- function ReplyInput({ + issueId, placeholder = "Leave a reply...", avatarType, avatarId, @@ -39,7 +41,7 @@ function ReplyInput({ const handleUpload = async (file: File) => { try { - const result = await upload(file); + const result = await upload(file, { issueId }); return result; } catch (err) { toast.error(err instanceof Error ? err.message : "Upload failed"); diff --git a/apps/web/hooks/use-file-upload.ts b/apps/web/hooks/use-file-upload.ts index 812e6238..bd3071fb 100644 --- a/apps/web/hooks/use-file-upload.ts +++ b/apps/web/hooks/use-file-upload.ts @@ -2,6 +2,7 @@ import { useState, useCallback } from "react"; import { api } from "@/shared/api"; +import type { Attachment } from "@/shared/types"; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB @@ -32,11 +33,16 @@ export interface UploadResult { link: string; } +export interface UploadContext { + issueId?: string; + commentId?: string; +} + export function useFileUpload() { const [uploading, setUploading] = useState(false); const upload = useCallback( - async (file: File): Promise => { + async (file: File, ctx?: UploadContext): Promise => { if (file.size > MAX_FILE_SIZE) { throw new Error("File exceeds 10 MB limit"); } @@ -46,7 +52,11 @@ export function useFileUpload() { setUploading(true); try { - return await api.uploadFile(file); + const att: Attachment = await api.uploadFile(file, { + issueId: ctx?.issueId, + commentId: ctx?.commentId, + }); + return { filename: att.filename, link: att.url }; } finally { setUploading(false); } diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index 994cc666..172efc4b 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -35,6 +35,7 @@ import type { RuntimePing, TimelineEntry, TaskMessagePayload, + Attachment, } from "@/shared/types"; import { type Logger, noopLogger } from "@/shared/logger"; @@ -520,10 +521,12 @@ export class ApiClient { await this.fetch(`/api/tokens/${id}`, { method: "DELETE" }); } - // File Upload - async uploadFile(file: File): Promise<{ filename: string; link: string }> { + // File Upload & Attachments + async uploadFile(file: File, opts?: { issueId?: string; commentId?: string }): Promise { const formData = new FormData(); formData.append("file", file); + if (opts?.issueId) formData.append("issue_id", opts.issueId); + if (opts?.commentId) formData.append("comment_id", opts.commentId); const headers: Record = {}; if (this.token) headers["Authorization"] = `Bearer ${this.token}`; @@ -556,6 +559,14 @@ export class ApiClient { throw new Error(message); } - return res.json() as Promise<{ filename: string; link: string }>; + return res.json() as Promise; + } + + async listAttachments(issueId: string): Promise { + return this.fetch(`/api/issues/${issueId}/attachments`); + } + + async deleteAttachment(id: string): Promise { + await this.fetch(`/api/attachments/${id}`, { method: "DELETE" }); } } diff --git a/apps/web/shared/types/attachment.ts b/apps/web/shared/types/attachment.ts new file mode 100644 index 00000000..c69ccc44 --- /dev/null +++ b/apps/web/shared/types/attachment.ts @@ -0,0 +1,13 @@ +export interface Attachment { + id: string; + workspace_id: string; + issue_id: string | null; + comment_id: string | null; + uploader_type: string; + uploader_id: string; + filename: string; + url: string; + content_type: string; + size_bytes: number; + created_at: string; +} diff --git a/apps/web/shared/types/index.ts b/apps/web/shared/types/index.ts index 5ef60118..4c105ff5 100644 --- a/apps/web/shared/types/index.ts +++ b/apps/web/shared/types/index.ts @@ -30,3 +30,4 @@ export type { IssueSubscriber } from "./subscriber"; export type { DaemonPairingSession, DaemonPairingSessionStatus, ApproveDaemonPairingSessionRequest } from "./daemon"; export type * from "./events"; export type * from "./api"; +export type { Attachment } from "./attachment"; diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index e65b6b9a..9b75fd5a 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -175,9 +175,13 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route r.Get("/task-runs", h.ListTasksByIssue) r.Post("/reactions", h.AddIssueReaction) r.Delete("/reactions", h.RemoveIssueReaction) + r.Get("/attachments", h.ListAttachments) }) }) + // Attachments + r.Delete("/api/attachments/{id}", h.DeleteAttachment) + // Comments r.Route("/api/comments/{commentId}", func(r chi.Router) { r.Put("/", h.UpdateComment) diff --git a/server/internal/handler/file.go b/server/internal/handler/file.go index 230c0336..c1afabc0 100644 --- a/server/internal/handler/file.go +++ b/server/internal/handler/file.go @@ -9,26 +9,29 @@ import ( "net/http" "path" "strings" + + "github.com/go-chi/chi/v5" + db "github.com/multica-ai/multica/server/pkg/db/generated" ) const maxUploadSize = 10 << 20 // 10 MB // Allowed MIME type prefixes and exact types for uploads. var allowedContentTypes = map[string]bool{ - "image/png": true, - "image/jpeg": true, - "image/gif": true, - "image/webp": true, - "image/svg+xml": true, - "application/pdf": true, - "text/plain": true, - "text/csv": true, - "application/json": true, - "video/mp4": true, - "video/webm": true, - "audio/mpeg": true, - "audio/wav": true, - "application/zip": true, + "image/png": true, + "image/jpeg": true, + "image/gif": true, + "image/webp": true, + "image/svg+xml": true, + "application/pdf": true, + "text/plain": true, + "text/csv": true, + "application/json": true, + "video/mp4": true, + "video/webm": true, + "audio/mpeg": true, + "audio/wav": true, + "application/zip": true, } func isContentTypeAllowed(ct string) bool { @@ -38,12 +41,64 @@ func isContentTypeAllowed(ct string) bool { return allowedContentTypes[ct] } +// --------------------------------------------------------------------------- +// Response types +// --------------------------------------------------------------------------- + +type AttachmentResponse struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + IssueID *string `json:"issue_id"` + CommentID *string `json:"comment_id"` + UploaderType string `json:"uploader_type"` + UploaderID string `json:"uploader_id"` + Filename string `json:"filename"` + URL string `json:"url"` + ContentType string `json:"content_type"` + SizeBytes int64 `json:"size_bytes"` + CreatedAt string `json:"created_at"` +} + +func attachmentToResponse(a db.Attachment) AttachmentResponse { + resp := AttachmentResponse{ + ID: uuidToString(a.ID), + WorkspaceID: uuidToString(a.WorkspaceID), + UploaderType: a.UploaderType, + UploaderID: uuidToString(a.UploaderID), + Filename: a.Filename, + URL: a.Url, + ContentType: a.ContentType, + SizeBytes: a.SizeBytes, + CreatedAt: a.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), + } + if a.IssueID.Valid { + s := uuidToString(a.IssueID) + resp.IssueID = &s + } + if a.CommentID.Valid { + s := uuidToString(a.CommentID) + resp.CommentID = &s + } + return resp +} + +// --------------------------------------------------------------------------- +// UploadFile — POST /api/upload-file +// --------------------------------------------------------------------------- + func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { if h.Storage == nil { writeError(w, http.StatusServiceUnavailable, "file upload not configured") return } + userID, ok := requireUserID(w, r) + if !ok { + return + } + + workspaceID := resolveWorkspaceID(r) + r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) if err := r.ParseMultipartForm(maxUploadSize); err != nil { @@ -98,8 +153,119 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { return } + // If workspace context is available, create an attachment record. + if workspaceID != "" { + uploaderType, uploaderID := h.resolveActor(r, userID, workspaceID) + + params := db.CreateAttachmentParams{ + WorkspaceID: parseUUID(workspaceID), + UploaderType: uploaderType, + UploaderID: parseUUID(uploaderID), + Filename: header.Filename, + Url: link, + ContentType: contentType, + SizeBytes: int64(len(data)), + } + + // Optional issue_id / comment_id from form fields + if issueID := r.FormValue("issue_id"); issueID != "" { + params.IssueID = parseUUID(issueID) + } + if commentID := r.FormValue("comment_id"); commentID != "" { + params.CommentID = parseUUID(commentID) + } + + att, err := h.Queries.CreateAttachment(r.Context(), params) + if err != nil { + slog.Error("failed to create attachment record", "error", err) + // S3 upload succeeded but DB record failed — still return the link + // so the file is usable. Log the error for investigation. + } else { + writeJSON(w, http.StatusOK, attachmentToResponse(att)) + return + } + } + + // Fallback response (no workspace context, e.g. avatar upload) writeJSON(w, http.StatusOK, map[string]string{ "filename": header.Filename, "link": link, }) } + +// --------------------------------------------------------------------------- +// ListAttachments — GET /api/issues/{id}/attachments +// --------------------------------------------------------------------------- + +func (h *Handler) ListAttachments(w http.ResponseWriter, r *http.Request) { + issueID := chi.URLParam(r, "id") + issue, ok := h.loadIssueForUser(w, r, issueID) + if !ok { + return + } + + attachments, err := h.Queries.ListAttachmentsByIssue(r.Context(), db.ListAttachmentsByIssueParams{ + IssueID: issue.ID, + WorkspaceID: issue.WorkspaceID, + }) + if err != nil { + slog.Error("failed to list attachments", "error", err) + writeError(w, http.StatusInternalServerError, "failed to list attachments") + return + } + + resp := make([]AttachmentResponse, len(attachments)) + for i, a := range attachments { + resp[i] = attachmentToResponse(a) + } + writeJSON(w, http.StatusOK, resp) +} + +// --------------------------------------------------------------------------- +// DeleteAttachment — DELETE /api/attachments/{id} +// --------------------------------------------------------------------------- + +func (h *Handler) DeleteAttachment(w http.ResponseWriter, r *http.Request) { + attachmentID := chi.URLParam(r, "id") + workspaceID := resolveWorkspaceID(r) + if workspaceID == "" { + writeError(w, http.StatusBadRequest, "workspace_id is required") + return + } + + userID, ok := requireUserID(w, r) + if !ok { + return + } + + att, err := h.Queries.GetAttachment(r.Context(), db.GetAttachmentParams{ + ID: parseUUID(attachmentID), + WorkspaceID: parseUUID(workspaceID), + }) + if err != nil { + writeError(w, http.StatusNotFound, "attachment not found") + return + } + + // Only the uploader (or workspace admin) can delete + uploaderID := uuidToString(att.UploaderID) + isUploader := att.UploaderType == "member" && uploaderID == userID + member, hasMember := ctxMember(r.Context()) + isAdmin := hasMember && (member.Role == "admin" || member.Role == "owner") + + if !isUploader && !isAdmin { + writeError(w, http.StatusForbidden, "not authorized to delete this attachment") + return + } + + if err := h.Queries.DeleteAttachment(r.Context(), db.DeleteAttachmentParams{ + ID: att.ID, + WorkspaceID: att.WorkspaceID, + }); err != nil { + slog.Error("failed to delete attachment", "error", err) + writeError(w, http.StatusInternalServerError, "failed to delete attachment") + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/migrations/029_attachment.down.sql b/server/migrations/029_attachment.down.sql new file mode 100644 index 00000000..4e5f6d4f --- /dev/null +++ b/server/migrations/029_attachment.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS attachment; diff --git a/server/migrations/029_attachment.up.sql b/server/migrations/029_attachment.up.sql new file mode 100644 index 00000000..225c373a --- /dev/null +++ b/server/migrations/029_attachment.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE attachment ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + issue_id UUID REFERENCES issue(id) ON DELETE CASCADE, + comment_id UUID REFERENCES comment(id) ON DELETE CASCADE, + uploader_type TEXT NOT NULL CHECK (uploader_type IN ('member', 'agent')), + uploader_id UUID NOT NULL, + filename TEXT NOT NULL, + url TEXT NOT NULL, + content_type TEXT NOT NULL, + size_bytes BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_attachment_issue ON attachment(issue_id) WHERE issue_id IS NOT NULL; +CREATE INDEX idx_attachment_comment ON attachment(comment_id) WHERE comment_id IS NOT NULL; +CREATE INDEX idx_attachment_workspace ON attachment(workspace_id); diff --git a/server/pkg/db/generated/attachment.sql.go b/server/pkg/db/generated/attachment.sql.go new file mode 100644 index 00000000..b653e2a9 --- /dev/null +++ b/server/pkg/db/generated/attachment.sql.go @@ -0,0 +1,188 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: attachment.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createAttachment = `-- name: CreateAttachment :one +INSERT INTO attachment (workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes) +VALUES ($1, $8, $9, $2, $3, $4, $5, $6, $7) +RETURNING id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at +` + +type CreateAttachmentParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + UploaderType string `json:"uploader_type"` + UploaderID pgtype.UUID `json:"uploader_id"` + Filename string `json:"filename"` + Url string `json:"url"` + ContentType string `json:"content_type"` + SizeBytes int64 `json:"size_bytes"` + IssueID pgtype.UUID `json:"issue_id"` + CommentID pgtype.UUID `json:"comment_id"` +} + +func (q *Queries) CreateAttachment(ctx context.Context, arg CreateAttachmentParams) (Attachment, error) { + row := q.db.QueryRow(ctx, createAttachment, + arg.WorkspaceID, + arg.UploaderType, + arg.UploaderID, + arg.Filename, + arg.Url, + arg.ContentType, + arg.SizeBytes, + arg.IssueID, + arg.CommentID, + ) + var i Attachment + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.IssueID, + &i.CommentID, + &i.UploaderType, + &i.UploaderID, + &i.Filename, + &i.Url, + &i.ContentType, + &i.SizeBytes, + &i.CreatedAt, + ) + return i, err +} + +const deleteAttachment = `-- name: DeleteAttachment :exec +DELETE FROM attachment WHERE id = $1 AND workspace_id = $2 +` + +type DeleteAttachmentParams struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +func (q *Queries) DeleteAttachment(ctx context.Context, arg DeleteAttachmentParams) error { + _, err := q.db.Exec(ctx, deleteAttachment, arg.ID, arg.WorkspaceID) + return err +} + +const getAttachment = `-- name: GetAttachment :one +SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at FROM attachment +WHERE id = $1 AND workspace_id = $2 +` + +type GetAttachmentParams struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +func (q *Queries) GetAttachment(ctx context.Context, arg GetAttachmentParams) (Attachment, error) { + row := q.db.QueryRow(ctx, getAttachment, arg.ID, arg.WorkspaceID) + var i Attachment + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.IssueID, + &i.CommentID, + &i.UploaderType, + &i.UploaderID, + &i.Filename, + &i.Url, + &i.ContentType, + &i.SizeBytes, + &i.CreatedAt, + ) + return i, err +} + +const listAttachmentsByComment = `-- name: ListAttachmentsByComment :many +SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at FROM attachment +WHERE comment_id = $1 AND workspace_id = $2 +ORDER BY created_at ASC +` + +type ListAttachmentsByCommentParams struct { + CommentID pgtype.UUID `json:"comment_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +func (q *Queries) ListAttachmentsByComment(ctx context.Context, arg ListAttachmentsByCommentParams) ([]Attachment, error) { + rows, err := q.db.Query(ctx, listAttachmentsByComment, arg.CommentID, arg.WorkspaceID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Attachment{} + for rows.Next() { + var i Attachment + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.IssueID, + &i.CommentID, + &i.UploaderType, + &i.UploaderID, + &i.Filename, + &i.Url, + &i.ContentType, + &i.SizeBytes, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAttachmentsByIssue = `-- name: ListAttachmentsByIssue :many +SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at FROM attachment +WHERE issue_id = $1 AND workspace_id = $2 +ORDER BY created_at ASC +` + +type ListAttachmentsByIssueParams struct { + IssueID pgtype.UUID `json:"issue_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +func (q *Queries) ListAttachmentsByIssue(ctx context.Context, arg ListAttachmentsByIssueParams) ([]Attachment, error) { + rows, err := q.db.Query(ctx, listAttachmentsByIssue, arg.IssueID, arg.WorkspaceID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Attachment{} + for rows.Next() { + var i Attachment + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.IssueID, + &i.CommentID, + &i.UploaderType, + &i.UploaderID, + &i.Filename, + &i.Url, + &i.ContentType, + &i.SizeBytes, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index 9547212e..9ba8f116 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -79,6 +79,20 @@ type AgentTaskQueue struct { TriggerCommentID pgtype.UUID `json:"trigger_comment_id"` } +type Attachment struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + IssueID pgtype.UUID `json:"issue_id"` + CommentID pgtype.UUID `json:"comment_id"` + UploaderType string `json:"uploader_type"` + UploaderID pgtype.UUID `json:"uploader_id"` + Filename string `json:"filename"` + Url string `json:"url"` + ContentType string `json:"content_type"` + SizeBytes int64 `json:"size_bytes"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Comment struct { ID pgtype.UUID `json:"id"` IssueID pgtype.UUID `json:"issue_id"` diff --git a/server/pkg/db/queries/attachment.sql b/server/pkg/db/queries/attachment.sql new file mode 100644 index 00000000..1505c2dd --- /dev/null +++ b/server/pkg/db/queries/attachment.sql @@ -0,0 +1,21 @@ +-- name: CreateAttachment :one +INSERT INTO attachment (workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes) +VALUES ($1, sqlc.narg(issue_id), sqlc.narg(comment_id), $2, $3, $4, $5, $6, $7) +RETURNING *; + +-- name: ListAttachmentsByIssue :many +SELECT * FROM attachment +WHERE issue_id = $1 AND workspace_id = $2 +ORDER BY created_at ASC; + +-- name: ListAttachmentsByComment :many +SELECT * FROM attachment +WHERE comment_id = $1 AND workspace_id = $2 +ORDER BY created_at ASC; + +-- name: GetAttachment :one +SELECT * FROM attachment +WHERE id = $1 AND workspace_id = $2; + +-- name: DeleteAttachment :exec +DELETE FROM attachment WHERE id = $1 AND workspace_id = $2; From f5353c66917e2a329b78f76354895280ba78e99f Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 31 Mar 2026 15:42:10 +0800 Subject: [PATCH 7/8] feat(upload): signed URLs for CLI + eager load attachments on comments - Add CloudFrontSigner.SignedURL() for generating per-resource signed URLs - Attachment responses include download_url (5-min signed URL for CLI) - Eager load attachments on comments and timeline (same pattern as reactions) - Add ListAttachmentsByCommentIDs query for batch loading - Update Comment and TimelineEntry types with attachments field Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/(dashboard)/issues/[id]/page.test.tsx | 1 + apps/web/shared/types/activity.ts | 2 + apps/web/shared/types/attachment.ts | 1 + apps/web/shared/types/comment.ts | 1 + server/internal/auth/cloudfront.go | 23 ++++++++ server/internal/handler/activity.go | 20 ++++--- server/internal/handler/comment.go | 59 +++++++++++-------- server/internal/handler/file.go | 30 +++++++++- server/pkg/db/generated/attachment.sql.go | 38 ++++++++++++ server/pkg/db/queries/attachment.sql | 5 ++ 10 files changed, 144 insertions(+), 36 deletions(-) diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index 77739728..065d8051 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -297,6 +297,7 @@ describe("IssueDetailPage", () => { author_id: "user-1", parent_id: null, reactions: [], + attachments: [], created_at: "2026-01-18T00:00:00Z", updated_at: "2026-01-18T00:00:00Z", }; diff --git a/apps/web/shared/types/activity.ts b/apps/web/shared/types/activity.ts index 5dc2e9fa..d14cbebc 100644 --- a/apps/web/shared/types/activity.ts +++ b/apps/web/shared/types/activity.ts @@ -1,4 +1,5 @@ import type { Reaction } from "./comment"; +import type { Attachment } from "./attachment"; export interface TimelineEntry { type: "activity" | "comment"; @@ -15,4 +16,5 @@ export interface TimelineEntry { updated_at?: string; comment_type?: string; reactions?: Reaction[]; + attachments?: Attachment[]; } diff --git a/apps/web/shared/types/attachment.ts b/apps/web/shared/types/attachment.ts index c69ccc44..9908850c 100644 --- a/apps/web/shared/types/attachment.ts +++ b/apps/web/shared/types/attachment.ts @@ -7,6 +7,7 @@ export interface Attachment { uploader_id: string; filename: string; url: string; + download_url: string; content_type: string; size_bytes: number; created_at: string; diff --git a/apps/web/shared/types/comment.ts b/apps/web/shared/types/comment.ts index bd2a4b57..c06c4f04 100644 --- a/apps/web/shared/types/comment.ts +++ b/apps/web/shared/types/comment.ts @@ -20,6 +20,7 @@ export interface Comment { type: CommentType; parent_id: string | null; reactions: Reaction[]; + attachments: import("./attachment").Attachment[]; created_at: string; updated_at: string; } diff --git a/server/internal/auth/cloudfront.go b/server/internal/auth/cloudfront.go index ee255378..e4cccd6d 100644 --- a/server/internal/auth/cloudfront.go +++ b/server/internal/auth/cloudfront.go @@ -172,6 +172,29 @@ func (s *CloudFrontSigner) SignedCookies(expiry time.Time) []*http.Cookie { } } +// SignedURL generates a CloudFront signed URL for the given resource URL. +// Used by CLI/API clients that don't have browser cookies. +func (s *CloudFrontSigner) SignedURL(rawURL string, expiry time.Time) string { + policy := fmt.Sprintf(`{"Statement":[{"Resource":"%s","Condition":{"DateLessThan":{"AWS:EpochTime":%d}}}]}`, rawURL, 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 URL", "error", err) + return rawURL + } + encodedSig := cfBase64Encode(sig) + + separator := "?" + if strings.Contains(rawURL, "?") { + separator = "&" + } + return fmt.Sprintf("%s%sPolicy=%s&Signature=%s&Key-Pair-Id=%s", rawURL, separator, encodedPolicy, encodedSig, s.keyPairID) +} + // cfBase64Encode applies CloudFront's URL-safe base64 encoding. func cfBase64Encode(data []byte) string { encoded := base64.StdEncoding.EncodeToString(data) diff --git a/server/internal/handler/activity.go b/server/internal/handler/activity.go index 5430b78a..b73810eb 100644 --- a/server/internal/handler/activity.go +++ b/server/internal/handler/activity.go @@ -25,11 +25,12 @@ type TimelineEntry struct { Details json.RawMessage `json:"details,omitempty"` // Comment-only fields - Content *string `json:"content,omitempty"` - ParentID *string `json:"parent_id,omitempty"` - UpdatedAt *string `json:"updated_at,omitempty"` - CommentType *string `json:"comment_type,omitempty"` - Reactions []ReactionResponse `json:"reactions,omitempty"` + Content *string `json:"content,omitempty"` + ParentID *string `json:"parent_id,omitempty"` + UpdatedAt *string `json:"updated_at,omitempty"` + CommentType *string `json:"comment_type,omitempty"` + Reactions []ReactionResponse `json:"reactions,omitempty"` + Attachments []AttachmentResponse `json:"attachments,omitempty"` } // ListTimeline returns a merged, chronologically-sorted timeline of activities @@ -79,20 +80,22 @@ func (h *Handler) ListTimeline(w http.ResponseWriter, r *http.Request) { }) } - // Fetch reactions for all comments in one batch. + // Fetch reactions and attachments for all comments in one batch. commentIDs := make([]pgtype.UUID, len(comments)) for i, c := range comments { commentIDs[i] = c.ID } grouped := h.groupReactions(r, commentIDs) + groupedAtt := h.groupAttachments(r, commentIDs) for _, c := range comments { content := c.Content commentType := c.Type updatedAt := timestampToString(c.UpdatedAt) + cid := uuidToString(c.ID) timeline = append(timeline, TimelineEntry{ Type: "comment", - ID: uuidToString(c.ID), + ID: cid, ActorType: c.AuthorType, ActorID: uuidToString(c.AuthorID), Content: &content, @@ -100,7 +103,8 @@ func (h *Handler) ListTimeline(w http.ResponseWriter, r *http.Request) { ParentID: uuidToPtr(c.ParentID), CreatedAt: timestampToString(c.CreatedAt), UpdatedAt: &updatedAt, - Reactions: grouped[uuidToString(c.ID)], + Reactions: grouped[cid], + Attachments: groupedAtt[cid], }) } diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index a1b2c38c..f00f15c2 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -13,33 +13,38 @@ import ( ) type CommentResponse struct { - ID string `json:"id"` - IssueID string `json:"issue_id"` - AuthorType string `json:"author_type"` - AuthorID string `json:"author_id"` - Content string `json:"content"` - Type string `json:"type"` - ParentID *string `json:"parent_id"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - Reactions []ReactionResponse `json:"reactions"` + ID string `json:"id"` + IssueID string `json:"issue_id"` + AuthorType string `json:"author_type"` + AuthorID string `json:"author_id"` + Content string `json:"content"` + Type string `json:"type"` + ParentID *string `json:"parent_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Reactions []ReactionResponse `json:"reactions"` + Attachments []AttachmentResponse `json:"attachments"` } -func commentToResponse(c db.Comment, reactions []ReactionResponse) CommentResponse { +func commentToResponse(c db.Comment, reactions []ReactionResponse, attachments []AttachmentResponse) CommentResponse { if reactions == nil { reactions = []ReactionResponse{} } + if attachments == nil { + attachments = []AttachmentResponse{} + } return CommentResponse{ - ID: uuidToString(c.ID), - IssueID: uuidToString(c.IssueID), - AuthorType: c.AuthorType, - AuthorID: uuidToString(c.AuthorID), - Content: c.Content, - Type: c.Type, - ParentID: uuidToPtr(c.ParentID), - CreatedAt: timestampToString(c.CreatedAt), - UpdatedAt: timestampToString(c.UpdatedAt), - Reactions: reactions, + ID: uuidToString(c.ID), + IssueID: uuidToString(c.IssueID), + AuthorType: c.AuthorType, + AuthorID: uuidToString(c.AuthorID), + Content: c.Content, + Type: c.Type, + ParentID: uuidToPtr(c.ParentID), + CreatedAt: timestampToString(c.CreatedAt), + UpdatedAt: timestampToString(c.UpdatedAt), + Reactions: reactions, + Attachments: attachments, } } @@ -64,10 +69,12 @@ func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) { commentIDs[i] = c.ID } grouped := h.groupReactions(r, commentIDs) + groupedAtt := h.groupAttachments(r, commentIDs) resp := make([]CommentResponse, len(comments)) for i, c := range comments { - resp[i] = commentToResponse(c, grouped[uuidToString(c.ID)]) + cid := uuidToString(c.ID) + resp[i] = commentToResponse(c, grouped[cid], groupedAtt[cid]) } writeJSON(w, http.StatusOK, resp) @@ -133,7 +140,7 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { return } - resp := commentToResponse(comment, nil) + resp := commentToResponse(comment, nil, nil) slog.Info("comment created", append(logger.RequestAttrs(r), "comment_id", uuidToString(comment.ID), "issue_id", issueID)...) h.publish(protocol.EventCommentCreated, uuidToString(issue.WorkspaceID), authorType, authorID, map[string]any{ "comment": resp, @@ -215,9 +222,11 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) { return } - // Fetch reactions for the updated comment. + // Fetch reactions and attachments for the updated comment. grouped := h.groupReactions(r, []pgtype.UUID{comment.ID}) - resp := commentToResponse(comment, grouped[uuidToString(comment.ID)]) + groupedAtt := h.groupAttachments(r, []pgtype.UUID{comment.ID}) + cid := uuidToString(comment.ID) + resp := commentToResponse(comment, grouped[cid], groupedAtt[cid]) slog.Info("comment updated", append(logger.RequestAttrs(r), "comment_id", commentId)...) h.publish(protocol.EventCommentUpdated, workspaceID, actorType, actorID, map[string]any{"comment": resp}) writeJSON(w, http.StatusOK, resp) diff --git a/server/internal/handler/file.go b/server/internal/handler/file.go index c1afabc0..04c70649 100644 --- a/server/internal/handler/file.go +++ b/server/internal/handler/file.go @@ -9,8 +9,10 @@ import ( "net/http" "path" "strings" + "time" "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgtype" db "github.com/multica-ai/multica/server/pkg/db/generated" ) @@ -54,12 +56,13 @@ type AttachmentResponse struct { UploaderID string `json:"uploader_id"` Filename string `json:"filename"` URL string `json:"url"` + DownloadURL string `json:"download_url"` ContentType string `json:"content_type"` SizeBytes int64 `json:"size_bytes"` CreatedAt string `json:"created_at"` } -func attachmentToResponse(a db.Attachment) AttachmentResponse { +func (h *Handler) attachmentToResponse(a db.Attachment) AttachmentResponse { resp := AttachmentResponse{ ID: uuidToString(a.ID), WorkspaceID: uuidToString(a.WorkspaceID), @@ -67,10 +70,14 @@ func attachmentToResponse(a db.Attachment) AttachmentResponse { UploaderID: uuidToString(a.UploaderID), Filename: a.Filename, URL: a.Url, + DownloadURL: a.Url, ContentType: a.ContentType, SizeBytes: a.SizeBytes, CreatedAt: a.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), } + if h.CFSigner != nil { + resp.DownloadURL = h.CFSigner.SignedURL(a.Url, time.Now().Add(5*time.Minute)) + } if a.IssueID.Valid { s := uuidToString(a.IssueID) resp.IssueID = &s @@ -82,6 +89,23 @@ func attachmentToResponse(a db.Attachment) AttachmentResponse { return resp } +// groupAttachments loads attachments for multiple comments and groups them by comment ID. +func (h *Handler) groupAttachments(r *http.Request, commentIDs []pgtype.UUID) map[string][]AttachmentResponse { + if len(commentIDs) == 0 { + return nil + } + attachments, err := h.Queries.ListAttachmentsByCommentIDs(r.Context(), commentIDs) + if err != nil { + return nil + } + grouped := make(map[string][]AttachmentResponse, len(commentIDs)) + for _, a := range attachments { + cid := uuidToString(a.CommentID) + grouped[cid] = append(grouped[cid], h.attachmentToResponse(a)) + } + return grouped +} + // --------------------------------------------------------------------------- // UploadFile — POST /api/upload-file // --------------------------------------------------------------------------- @@ -181,7 +205,7 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { // S3 upload succeeded but DB record failed — still return the link // so the file is usable. Log the error for investigation. } else { - writeJSON(w, http.StatusOK, attachmentToResponse(att)) + writeJSON(w, http.StatusOK, h.attachmentToResponse(att)) return } } @@ -216,7 +240,7 @@ func (h *Handler) ListAttachments(w http.ResponseWriter, r *http.Request) { resp := make([]AttachmentResponse, len(attachments)) for i, a := range attachments { - resp[i] = attachmentToResponse(a) + resp[i] = h.attachmentToResponse(a) } writeJSON(w, http.StatusOK, resp) } diff --git a/server/pkg/db/generated/attachment.sql.go b/server/pkg/db/generated/attachment.sql.go index b653e2a9..858365ad 100644 --- a/server/pkg/db/generated/attachment.sql.go +++ b/server/pkg/db/generated/attachment.sql.go @@ -144,6 +144,44 @@ func (q *Queries) ListAttachmentsByComment(ctx context.Context, arg ListAttachme return items, nil } +const listAttachmentsByCommentIDs = `-- name: ListAttachmentsByCommentIDs :many +SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at FROM attachment +WHERE comment_id = ANY($1::uuid[]) +ORDER BY created_at ASC +` + +func (q *Queries) ListAttachmentsByCommentIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Attachment, error) { + rows, err := q.db.Query(ctx, listAttachmentsByCommentIDs, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Attachment{} + for rows.Next() { + var i Attachment + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.IssueID, + &i.CommentID, + &i.UploaderType, + &i.UploaderID, + &i.Filename, + &i.Url, + &i.ContentType, + &i.SizeBytes, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listAttachmentsByIssue = `-- name: ListAttachmentsByIssue :many SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at FROM attachment WHERE issue_id = $1 AND workspace_id = $2 diff --git a/server/pkg/db/queries/attachment.sql b/server/pkg/db/queries/attachment.sql index 1505c2dd..6003ab88 100644 --- a/server/pkg/db/queries/attachment.sql +++ b/server/pkg/db/queries/attachment.sql @@ -17,5 +17,10 @@ ORDER BY created_at ASC; SELECT * FROM attachment WHERE id = $1 AND workspace_id = $2; +-- name: ListAttachmentsByCommentIDs :many +SELECT * FROM attachment +WHERE comment_id = ANY($1::uuid[]) +ORDER BY created_at ASC; + -- name: DeleteAttachment :exec DELETE FROM attachment WHERE id = $1 AND workspace_id = $2; From 9e23fb76fc189ed35df220e799aad4f545522a97 Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 31 Mar 2026 15:52:40 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix(upload):=20harden=20upload=20flow=20?= =?UTF-8?q?=E2=80=94=20sanitize=20filenames,=20refresh=20CF=20cookies,=20d?= =?UTF-8?q?eduplicate=20handlers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sanitize Content-Disposition filenames to prevent header injection (strip control chars, quotes, semicolons) - Add CloudFront cookie refresh middleware so cookies are re-issued when expired - Log errors in groupAttachments instead of silently swallowing them - Move useFileUpload hook to shared/hooks/ per project architecture conventions - Add uploadWithToast helper to deduplicate try/catch/toast pattern across 3 components - Refactor ApiClient.uploadFile to reuse auth headers, 401 handling, and error parsing - Allow empty MIME types client-side (let server sniff and decide) - Constrain Image extension max-width in rich-text-editor to prevent layout overflow Co-Authored-By: Claude Opus 4.6 (1M context) --- .../settings/_components/account-tab.tsx | 2 +- .../components/common/rich-text-editor.tsx | 8 +- .../issues/components/comment-input.tsx | 15 +--- .../issues/components/issue-detail.tsx | 15 +--- .../issues/components/reply-input.tsx | 15 +--- apps/web/shared/api/client.ts | 86 +++++++++---------- .../web/{ => shared}/hooks/use-file-upload.ts | 17 +++- server/cmd/server/router.go | 1 + server/internal/handler/file.go | 1 + server/internal/middleware/cloudfront.go | 28 ++++++ server/internal/storage/s3.go | 18 +++- 11 files changed, 120 insertions(+), 86 deletions(-) rename apps/web/{ => shared}/hooks/use-file-upload.ts (74%) create mode 100644 server/internal/middleware/cloudfront.go diff --git a/apps/web/app/(dashboard)/settings/_components/account-tab.tsx b/apps/web/app/(dashboard)/settings/_components/account-tab.tsx index dbf40065..78f3524e 100644 --- a/apps/web/app/(dashboard)/settings/_components/account-tab.tsx +++ b/apps/web/app/(dashboard)/settings/_components/account-tab.tsx @@ -9,7 +9,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { toast } from "sonner"; import { useAuthStore } from "@/features/auth"; import { api } from "@/shared/api"; -import { useFileUpload } from "@/hooks/use-file-upload"; +import { useFileUpload } from "@/shared/hooks/use-file-upload"; export function AccountTab() { const user = useAuthStore((s) => s.user); diff --git a/apps/web/components/common/rich-text-editor.tsx b/apps/web/components/common/rich-text-editor.tsx index 5d365649..058ece36 100644 --- a/apps/web/components/common/rich-text-editor.tsx +++ b/apps/web/components/common/rich-text-editor.tsx @@ -17,7 +17,7 @@ import { Markdown } from "tiptap-markdown"; import { Extension } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { cn } from "@/lib/utils"; -import type { UploadResult } from "@/hooks/use-file-upload"; +import type { UploadResult } from "@/shared/hooks/use-file-upload"; import { createMentionSuggestion } from "./mention-suggestion"; import "./rich-text-editor.css"; @@ -263,7 +263,11 @@ const RichTextEditor = forwardRef( LinkExtension, Typography, MentionExtension, - Image.configure({ inline: false, allowBase64: false }), + Image.configure({ + inline: false, + allowBase64: false, + HTMLAttributes: { style: "max-width: 100%; height: auto;" }, + }), Markdown.configure({ html: false, transformPastedText: true, diff --git a/apps/web/features/issues/components/comment-input.tsx b/apps/web/features/issues/components/comment-input.tsx index 34619713..e889565f 100644 --- a/apps/web/features/issues/components/comment-input.tsx +++ b/apps/web/features/issues/components/comment-input.tsx @@ -4,8 +4,7 @@ import { useRef, useState } from "react"; import { ArrowUp, Paperclip } from "lucide-react"; import { Button } from "@/components/ui/button"; import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor"; -import { useFileUpload } from "@/hooks/use-file-upload"; -import { toast } from "sonner"; +import { useFileUpload } from "@/shared/hooks/use-file-upload"; interface CommentInputProps { issueId: string; @@ -17,17 +16,9 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) { const fileInputRef = useRef(null); const [isEmpty, setIsEmpty] = useState(true); const [submitting, setSubmitting] = useState(false); - const { upload, uploading } = useFileUpload(); + const { uploadWithToast, uploading } = useFileUpload(); - const handleUpload = async (file: File) => { - try { - const result = await upload(file, { issueId }); - return result; - } catch (err) { - toast.error(err instanceof Error ? err.message : "Upload failed"); - return null; - } - }; + const handleUpload = (file: File) => uploadWithToast(file, { issueId }); const handleFileSelect = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index bc0d2a0a..fc3f2a09 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -69,7 +69,7 @@ import { useIssueTimeline } from "@/features/issues/hooks/use-issue-timeline"; import { useIssueReactions } from "@/features/issues/hooks/use-issue-reactions"; import { useIssueSubscribers } from "@/features/issues/hooks/use-issue-subscribers"; import { ReactionBar } from "@/components/common/reaction-bar"; -import { useFileUpload } from "@/hooks/use-file-upload"; +import { useFileUpload } from "@/shared/hooks/use-file-upload"; import { timeAgo } from "@/shared/utils"; function shortDate(date: string | null): string { @@ -180,7 +180,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo const prevIssue = currentIndex > 0 ? allIssues[currentIndex - 1] : null; const nextIssue = currentIndex < allIssues.length - 1 ? allIssues[currentIndex + 1] : null; const { getActorName, getActorInitials } = useActorName(); - const { upload: uploadFile } = useFileUpload(); + const { uploadWithToast } = useFileUpload(); const { defaultLayout, onLayoutChanged } = useDefaultLayout({ id: layoutId, }); @@ -252,15 +252,8 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo ); const handleDescriptionUpload = useCallback( - async (file: File) => { - try { - return await uploadFile(file, { issueId: id }); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Upload failed"); - return null; - } - }, - [uploadFile, id], + (file: File) => uploadWithToast(file, { issueId: id }), + [uploadWithToast, id], ); const handleDelete = async () => { diff --git a/apps/web/features/issues/components/reply-input.tsx b/apps/web/features/issues/components/reply-input.tsx index f8fb097b..0d61955f 100644 --- a/apps/web/features/issues/components/reply-input.tsx +++ b/apps/web/features/issues/components/reply-input.tsx @@ -5,8 +5,7 @@ import { ArrowUp, Paperclip } from "lucide-react"; import { Button } from "@/components/ui/button"; import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor"; import { ActorAvatar } from "@/components/common/actor-avatar"; -import { useFileUpload } from "@/hooks/use-file-upload"; -import { toast } from "sonner"; +import { useFileUpload } from "@/shared/hooks/use-file-upload"; // --------------------------------------------------------------------------- // Types @@ -37,17 +36,9 @@ function ReplyInput({ const fileInputRef = useRef(null); const [isEmpty, setIsEmpty] = useState(true); const [submitting, setSubmitting] = useState(false); - const { upload, uploading } = useFileUpload(); + const { uploadWithToast, uploading } = useFileUpload(); - const handleUpload = async (file: File) => { - try { - const result = await upload(file, { issueId }); - return result; - } catch (err) { - toast.error(err instanceof Error ? err.message : "Upload failed"); - return null; - } - }; + const handleUpload = (file: File) => uploadWithToast(file, { issueId }); const handleFileSelect = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index 172efc4b..f6a080b0 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -63,6 +63,35 @@ export class ApiClient { this.workspaceId = id; } + private authHeaders(): Record { + const headers: Record = {}; + if (this.token) headers["Authorization"] = `Bearer ${this.token}`; + if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId; + return headers; + } + + private handleUnauthorized() { + if (typeof window !== "undefined") { + localStorage.removeItem("multica_token"); + localStorage.removeItem("multica_workspace_id"); + this.token = null; + this.workspaceId = null; + if (window.location.pathname !== "/login") { + window.location.href = "/login"; + } + } + } + + private async parseErrorMessage(res: Response, fallback: string): Promise { + try { + const data = await res.json() as { error?: string }; + if (typeof data.error === "string" && data.error) return data.error; + } catch { + // Ignore non-JSON error bodies. + } + return fallback; + } + private async fetch(path: string, init?: RequestInit): Promise { const rid = crypto.randomUUID().slice(0, 8); const start = Date.now(); @@ -71,14 +100,9 @@ export class ApiClient { const headers: Record = { "Content-Type": "application/json", "X-Request-ID": rid, + ...this.authHeaders(), ...((init?.headers as Record) ?? {}), }; - if (this.token) { - headers["Authorization"] = `Bearer ${this.token}`; - } - if (this.workspaceId) { - headers["X-Workspace-ID"] = this.workspaceId; - } this.logger.info(`→ ${method} ${path}`, { rid }); @@ -88,25 +112,8 @@ export class ApiClient { }); if (!res.ok) { - if (res.status === 401 && typeof window !== "undefined") { - localStorage.removeItem("multica_token"); - localStorage.removeItem("multica_workspace_id"); - this.token = null; - this.workspaceId = null; - if (window.location.pathname !== "/login") { - window.location.href = "/login"; - } - } - - let message = `API error: ${res.status} ${res.statusText}`; - try { - const data = await res.json() as { error?: string }; - if (typeof data.error === "string" && data.error) { - message = data.error; - } - } catch { - // Ignore non-JSON error bodies. - } + if (res.status === 401) this.handleUnauthorized(); + const message = await this.parseErrorMessage(res, `API error: ${res.status} ${res.statusText}`); this.logger.error(`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message }); throw new Error(message); } @@ -528,37 +535,24 @@ export class ApiClient { if (opts?.issueId) formData.append("issue_id", opts.issueId); if (opts?.commentId) formData.append("comment_id", opts.commentId); - const headers: Record = {}; - if (this.token) headers["Authorization"] = `Bearer ${this.token}`; - if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId; + const rid = crypto.randomUUID().slice(0, 8); + const start = Date.now(); + this.logger.info("→ POST /api/upload-file", { rid }); const res = await fetch(`${this.baseUrl}/api/upload-file`, { method: "POST", - headers, + headers: this.authHeaders(), body: formData, }); if (!res.ok) { - if (res.status === 401 && typeof window !== "undefined") { - localStorage.removeItem("multica_token"); - localStorage.removeItem("multica_workspace_id"); - this.token = null; - this.workspaceId = null; - if (window.location.pathname !== "/login") { - window.location.href = "/login"; - } - } - - let message = `Upload failed: ${res.status}`; - try { - const data = (await res.json()) as { error?: string }; - if (typeof data.error === "string" && data.error) message = data.error; - } catch { - // Ignore non-JSON error bodies. - } + if (res.status === 401) this.handleUnauthorized(); + const message = await this.parseErrorMessage(res, `Upload failed: ${res.status}`); + this.logger.error(`← ${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms`, error: message }); throw new Error(message); } + this.logger.info(`← ${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms` }); return res.json() as Promise; } diff --git a/apps/web/hooks/use-file-upload.ts b/apps/web/shared/hooks/use-file-upload.ts similarity index 74% rename from apps/web/hooks/use-file-upload.ts rename to apps/web/shared/hooks/use-file-upload.ts index bd3071fb..ef5bafbe 100644 --- a/apps/web/hooks/use-file-upload.ts +++ b/apps/web/shared/hooks/use-file-upload.ts @@ -1,6 +1,7 @@ "use client"; import { useState, useCallback } from "react"; +import { toast } from "sonner"; import { api } from "@/shared/api"; import type { Attachment } from "@/shared/types"; @@ -24,6 +25,8 @@ const ALLOWED_TYPES = new Set([ ]); function isAllowedType(type: string): boolean { + // Empty MIME type (browser couldn't determine) — let the server sniff and decide. + if (!type) return true; const mediaType = type.split(";")[0] ?? ""; return ALLOWED_TYPES.has(mediaType.trim().toLowerCase()); } @@ -64,5 +67,17 @@ export function useFileUpload() { [], ); - return { upload, uploading }; + const uploadWithToast = useCallback( + async (file: File, ctx?: UploadContext): Promise => { + try { + return await upload(file, ctx); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Upload failed"); + return null; + } + }, + [upload], + ); + + return { upload, uploadWithToast, uploading }; } diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 9b75fd5a..8cc84c1c 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -110,6 +110,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route // Protected API routes r.Group(func(r chi.Router) { r.Use(middleware.Auth(queries)) + r.Use(middleware.RefreshCloudFrontCookies(cfSigner)) // --- User-scoped routes (no workspace context required) --- r.Get("/api/me", h.GetMe) diff --git a/server/internal/handler/file.go b/server/internal/handler/file.go index 04c70649..50bedc9d 100644 --- a/server/internal/handler/file.go +++ b/server/internal/handler/file.go @@ -96,6 +96,7 @@ func (h *Handler) groupAttachments(r *http.Request, commentIDs []pgtype.UUID) ma } attachments, err := h.Queries.ListAttachmentsByCommentIDs(r.Context(), commentIDs) if err != nil { + slog.Error("failed to load attachments for comments", "error", err) return nil } grouped := make(map[string][]AttachmentResponse, len(commentIDs)) diff --git a/server/internal/middleware/cloudfront.go b/server/internal/middleware/cloudfront.go new file mode 100644 index 00000000..ab749998 --- /dev/null +++ b/server/internal/middleware/cloudfront.go @@ -0,0 +1,28 @@ +package middleware + +import ( + "net/http" + "time" + + "github.com/multica-ai/multica/server/internal/auth" +) + +// RefreshCloudFrontCookies is middleware that refreshes CloudFront signed cookies +// on authenticated requests when the cookie is missing (expired or first request +// after login). This prevents 403s from the CDN when cookies expire before the +// user's session does. +func RefreshCloudFrontCookies(signer *auth.CloudFrontSigner) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + if signer == nil { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, err := r.Cookie("CloudFront-Policy"); err != nil { + for _, cookie := range signer.SignedCookies(time.Now().Add(72 * time.Hour)) { + http.SetCookie(w, cookie) + } + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/server/internal/storage/s3.go b/server/internal/storage/s3.go index b29375f0..86167c18 100644 --- a/server/internal/storage/s3.go +++ b/server/internal/storage/s3.go @@ -68,13 +68,29 @@ func NewS3StorageFromEnv() *S3Storage { } } +// sanitizeFilename removes characters that could cause header injection in Content-Disposition. +func sanitizeFilename(name string) string { + var b strings.Builder + b.Grow(len(name)) + for _, r := range name { + // Strip control chars, newlines, null bytes, quotes, semicolons, backslashes + if r < 0x20 || r == 0x7f || r == '"' || r == ';' || r == '\\' || r == '\x00' { + b.WriteRune('_') + } else { + b.WriteRune(r) + } + } + return b.String() +} + func (s *S3Storage) Upload(ctx context.Context, key string, data []byte, contentType string, filename string) (string, error) { + safe := sanitizeFilename(filename) _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(key), Body: bytes.NewReader(data), ContentType: aws.String(contentType), - ContentDisposition: aws.String(fmt.Sprintf(`inline; filename="%s"`, strings.ReplaceAll(filename, `"`, "'"))), + ContentDisposition: aws.String(fmt.Sprintf(`inline; filename="%s"`, safe)), CacheControl: aws.String("max-age=432000,public"), StorageClass: types.StorageClassIntelligentTiering, })