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
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue