package handler import ( "context" "crypto/rand" "crypto/subtle" "encoding/binary" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "net/url" "os" "strings" "time" "unicode/utf8" "github.com/golang-jwt/jwt/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/analytics" "github.com/multica-ai/multica/server/internal/auth" "github.com/multica-ai/multica/server/internal/logger" obsmetrics "github.com/multica-ai/multica/server/internal/metrics" db "github.com/multica-ai/multica/server/pkg/db/generated" ) // SignupError represents signup restriction errors type SignupError struct { Message string } func (e SignupError) Error() string { return e.Message } var ErrSignupProhibited = SignupError{Message: "user registration is disabled on this self-hosted instance"} var ErrEmailNotAllowed = SignupError{Message: "email address or domain not allowed on this instance"} const devVerificationCodeEnv = "MULTICA_DEV_VERIFICATION_CODE" // supportedLanguages mirrors `SUPPORTED_LOCALES` in packages/core/i18n/types.ts. // Keep both lists in sync when adding a locale — the user-controlled `language` // field round-trips through GetMe back into i18n.changeLanguage(), so without // validation an arbitrary string would persist and echo to every device. var supportedLanguages = map[string]struct{}{ "en": {}, "zh-Hans": {}, "ko": {}, "ja": {}, } type UserResponse struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"` AvatarURL *string `json:"avatar_url"` Language *string `json:"language"` // Pinned IANA tz; nil = no preference (use browser-detected tz). Timezone *string `json:"timezone"` OnboardedAt *string `json:"onboarded_at"` OnboardingQuestionnaire json.RawMessage `json:"onboarding_questionnaire"` StarterContentState *string `json:"starter_content_state"` ProfileDescription string `json:"profile_description"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } // MaxProfileDescriptionLen caps the user-supplied profile_description body. // Picked at 2000 chars per MUL-2406: enough room for role / stack / a few // preferences, short enough that injecting it into every agent brief // doesn't move the needle on prompt cost. const MaxProfileDescriptionLen = 2000 func userToResponse(u db.User) UserResponse { // JSONB column is []byte with DEFAULT '{}', so it's never nil at the DB // level. Defensive coalesce just in case a future ALTER makes the column // nullable and some row comes back with no default applied. q := u.OnboardingQuestionnaire if len(q) == 0 { q = []byte("{}") } return UserResponse{ ID: uuidToString(u.ID), Name: u.Name, Email: u.Email, AvatarURL: textToPtr(u.AvatarUrl), Language: textToPtr(u.Language), Timezone: textToPtr(u.Timezone), OnboardedAt: timestampToPtr(u.OnboardedAt), OnboardingQuestionnaire: json.RawMessage(q), StarterContentState: textToPtr(u.StarterContentState), ProfileDescription: u.ProfileDescription, CreatedAt: timestampToString(u.CreatedAt), UpdatedAt: timestampToString(u.UpdatedAt), } } type LoginResponse struct { Token string `json:"token"` User UserResponse `json:"user"` } type SendCodeRequest struct { Email string `json:"email"` } type VerifyCodeRequest struct { Email string `json:"email"` Code string `json:"code"` } func generateCode() (string, error) { var buf [4]byte if _, err := rand.Read(buf[:]); err != nil { return "", err } n := binary.BigEndian.Uint32(buf[:]) % 1000000 return fmt.Sprintf("%06d", n), nil } func isDevVerificationCode(code string) bool { if isProductionEnv() { return false } devCode := strings.TrimSpace(os.Getenv(devVerificationCodeEnv)) if !isSixDigitCode(devCode) { return false } return subtle.ConstantTimeCompare([]byte(code), []byte(devCode)) == 1 } func isProductionEnv() bool { return strings.EqualFold(strings.TrimSpace(os.Getenv("APP_ENV")), "production") } func isSixDigitCode(code string) bool { if len(code) != 6 { return false } for _, ch := range code { if ch < '0' || ch > '9' { return false } } return true } func (h *Handler) issueJWT(user db.User) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": uuidToString(user.ID), "email": user.Email, "name": user.Name, "exp": time.Now().Add(auth.AuthTokenTTL()).Unix(), "iat": time.Now().Unix(), }) return token.SignedString(auth.JWTSecret()) } // findOrCreateUser returns the existing user for an email, or creates one if // none exists. isNew reports whether this call created the user — the signup // event fires on that edge, covering both the verification-code and Google // OAuth entry points. func (h *Handler) findOrCreateUser(ctx context.Context, email string) (user db.User, isNew bool, err error) { user, err = h.Queries.GetUserByEmail(ctx, email) isNew = isNotFound(err) if err != nil && !isNew { return db.User{}, false, err } if err := h.checkSignupAllowed(email, isNew); err != nil { return db.User{}, false, err } if !isNew { return user, false, nil } name := email if at := strings.Index(email, "@"); at > 0 { name = email[:at] } created, err := h.Queries.CreateUser(ctx, db.CreateUserParams{ Name: name, Email: email, }) if err != nil { return db.User{}, false, err } return created, true, nil } // signupSourceFromRequest reads the attribution cookie the web frontend // sets on the first pageview (UTM + referrer bundle). The frontend writes // a JSON string URL-encoded into the cookie value — Go does not // auto-decode Cookie.Value, so we have to unescape here before the string // lands in PostHog. Missing cookie / decode failures collapse to the // empty string; that simply omits signup_source from the event rather // than sending percent-encoded garbage. Never fall back to r.Referer() — // the frontend has already sanitised attribution and a raw referer can // leak OAuth code/state from the callback URL. // // The cap is the server-side defence against a client that manages to set // an oversize cookie; it matches SIGNUP_SOURCE_MAX_LEN on the frontend. const signupSourceMaxLen = 512 func signupSourceFromRequest(r *http.Request) string { c, err := r.Cookie("multica_signup_source") if err != nil || c == nil { return "" } decoded, err := url.QueryUnescape(c.Value) if err != nil { return "" } if len(decoded) > signupSourceMaxLen { return "" } return decoded } func (h *Handler) checkSignupAllowed(email string, isNewUser bool) error { if !isNewUser { return nil // existing users always allowed to log in } email = strings.ToLower(email) domain := "" if at := strings.Index(email, "@"); at > 0 { domain = email[at+1:] } // 1. explicit email whitelist always wins if len(h.cfg.AllowedEmails) > 0 && contains(h.cfg.AllowedEmails, email) { return nil } // 2. domain whitelist always wins if len(h.cfg.AllowedEmailDomains) > 0 && contains(h.cfg.AllowedEmailDomains, domain) { return nil } // 3. general signup flag if !h.cfg.AllowSignup { return ErrSignupProhibited } // 4. if allowlists are set but didn't match, block if len(h.cfg.AllowedEmailDomains) > 0 || len(h.cfg.AllowedEmails) > 0 { return ErrSignupProhibited } return nil } func contains(slice []string, s string) bool { for _, item := range slice { if strings.EqualFold(item, s) { return true } } return false } func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) { var req SendCodeRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } email := strings.ToLower(strings.TrimSpace(req.Email)) if email == "" { writeError(w, http.StatusBadRequest, "email is required") return } // Check signup restrictions before sending magic link _, err := h.Queries.GetUserByEmail(r.Context(), email) if err != nil { if !isNotFound(err) { // Real database/query error → return 500 writeError(w, http.StatusInternalServerError, "failed to lookup user") return } // User does not exist → treat as new user isNewUser := true if err := h.checkSignupAllowed(email, isNewUser); err != nil { var signupErr SignupError if errors.As(err, &signupErr) { writeError(w, http.StatusForbidden, signupErr.Error()) } else { writeError(w, http.StatusForbidden, "user registration is disabled") } return } } else { // User already exists → always allowed to login isNewUser := false if err := h.checkSignupAllowed(email, isNewUser); err != nil { // This should rarely happen, but handle it anyway var signupErr SignupError if errors.As(err, &signupErr) { writeError(w, http.StatusForbidden, signupErr.Error()) } else { writeError(w, http.StatusForbidden, "user registration is disabled") } return } } // Rate limit: max 1 code per 60 seconds per email latest, err := h.Queries.GetLatestCodeByEmail(r.Context(), email) if err == nil && time.Since(latest.CreatedAt.Time) < 60*time.Second { writeError(w, http.StatusTooManyRequests, "please wait before requesting another code") return } code, err := generateCode() if err != nil { writeError(w, http.StatusInternalServerError, "failed to generate code") return } _, err = h.Queries.CreateVerificationCode(r.Context(), db.CreateVerificationCodeParams{ Email: email, Code: code, ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(10 * time.Minute), Valid: true}, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to store verification code") return } if err := h.EmailService.SendVerificationCode(email, code); err != nil { slog.Error("failed to send verification code", "email", email, "error", err) writeError(w, http.StatusInternalServerError, "failed to send verification code") return } // Best-effort cleanup of expired codes _ = h.Queries.DeleteExpiredVerificationCodes(r.Context()) writeJSON(w, http.StatusOK, map[string]string{"message": "Verification code sent"}) } func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) { var req VerifyCodeRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } email := strings.ToLower(strings.TrimSpace(req.Email)) code := strings.TrimSpace(req.Code) if email == "" || code == "" { writeError(w, http.StatusBadRequest, "email and code are required") return } dbCode, err := h.Queries.GetLatestVerificationCode(r.Context(), email) if err != nil { writeError(w, http.StatusBadRequest, "invalid or expired code") return } isDevCode := isDevVerificationCode(code) if !isDevCode && subtle.ConstantTimeCompare([]byte(code), []byte(dbCode.Code)) != 1 { _ = h.Queries.IncrementVerificationCodeAttempts(r.Context(), dbCode.ID) writeError(w, http.StatusBadRequest, "invalid or expired code") return } if err := h.Queries.MarkVerificationCodeUsed(r.Context(), dbCode.ID); err != nil { writeError(w, http.StatusInternalServerError, "failed to verify code") return } user, isNew, err := h.findOrCreateUser(r.Context(), email) if err != nil { var signupErr SignupError if errors.As(err, &signupErr) { writeError(w, http.StatusForbidden, signupErr.Error()) return } writeError(w, http.StatusInternalServerError, "failed to create user") return } if isNew { obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.Signup(uuidToString(user.ID), user.Email, signupSourceFromRequest(r))) } tokenString, err := h.issueJWT(user) if err != nil { slog.Warn("login failed", append(logger.RequestAttrs(r), "error", err, "email", req.Email)...) writeError(w, http.StatusInternalServerError, "failed to generate token") return } // Set HttpOnly auth cookie (browser clients) + CSRF cookie. if err := auth.SetAuthCookies(w, tokenString); err != nil { slog.Warn("failed to set auth cookies", "error", err) } // Set CloudFront signed cookies for CDN access. if h.CFSigner != nil { for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(auth.AuthTokenTTL())) { 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, User: userToResponse(user), }) } func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) { userID, ok := requireUserID(w, r) if !ok { return } user, err := h.Queries.GetUser(r.Context(), parseUUID(userID)) if err != nil { writeError(w, http.StatusNotFound, "user not found") return } writeJSON(w, http.StatusOK, userToResponse(user)) } type UpdateMeRequest struct { Name *string `json:"name"` AvatarURL *string `json:"avatar_url"` Language *string `json:"language"` ProfileDescription *string `json:"profile_description"` // IANA tz to pin; "" clears back to NULL; nil leaves untouched. Timezone *string `json:"timezone"` } type GoogleLoginRequest struct { Code string `json:"code"` RedirectURI string `json:"redirect_uri"` } type googleTokenResponse struct { AccessToken string `json:"access_token"` IDToken string `json:"id_token"` TokenType string `json:"token_type"` } type googleUserInfo struct { Email string `json:"email"` Name string `json:"name"` Picture string `json:"picture"` } func (h *Handler) GoogleLogin(w http.ResponseWriter, r *http.Request) { var req GoogleLoginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if req.Code == "" { writeError(w, http.StatusBadRequest, "code is required") return } clientID := os.Getenv("GOOGLE_CLIENT_ID") clientSecret := os.Getenv("GOOGLE_CLIENT_SECRET") if clientID == "" || clientSecret == "" { writeError(w, http.StatusServiceUnavailable, "Google login is not configured") return } redirectURI := req.RedirectURI if redirectURI == "" { redirectURI = os.Getenv("GOOGLE_REDIRECT_URI") } // Exchange authorization code for tokens. tokenResp, err := http.PostForm("https://oauth2.googleapis.com/token", url.Values{ "code": {req.Code}, "client_id": {clientID}, "client_secret": {clientSecret}, "redirect_uri": {redirectURI}, "grant_type": {"authorization_code"}, }) if err != nil { slog.Error("google oauth token exchange failed", "error", err) writeError(w, http.StatusBadGateway, "failed to exchange code with Google") return } defer tokenResp.Body.Close() tokenBody, err := io.ReadAll(tokenResp.Body) if err != nil { writeError(w, http.StatusBadGateway, "failed to read Google token response") return } if tokenResp.StatusCode != http.StatusOK { slog.Error("google oauth token exchange returned error", "status", tokenResp.StatusCode, "body", string(tokenBody)) writeError(w, http.StatusBadRequest, "failed to exchange code with Google") return } var gToken googleTokenResponse if err := json.Unmarshal(tokenBody, &gToken); err != nil { writeError(w, http.StatusBadGateway, "failed to parse Google token response") return } // Fetch user info from Google. userInfoReq, err := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://www.googleapis.com/oauth2/v2/userinfo", nil) if err != nil { slog.Error("failed to create userinfo request", "error", err) writeError(w, http.StatusInternalServerError, "internal error") return } userInfoReq.Header.Set("Authorization", "Bearer "+gToken.AccessToken) userInfoResp, err := http.DefaultClient.Do(userInfoReq) if err != nil { slog.Error("google userinfo fetch failed", "error", err) writeError(w, http.StatusBadGateway, "failed to fetch user info from Google") return } defer userInfoResp.Body.Close() var gUser googleUserInfo if err := json.NewDecoder(userInfoResp.Body).Decode(&gUser); err != nil { writeError(w, http.StatusBadGateway, "failed to parse Google user info") return } if gUser.Email == "" { writeError(w, http.StatusBadRequest, "Google account has no email") return } email := strings.ToLower(strings.TrimSpace(gUser.Email)) user, isNew, err := h.findOrCreateUser(r.Context(), email) if err != nil { var signupErr SignupError if errors.As(err, &signupErr) { writeError(w, http.StatusForbidden, signupErr.Error()) return } writeError(w, http.StatusInternalServerError, "failed to create user") return } if isNew { evt := analytics.Signup(uuidToString(user.ID), user.Email, signupSourceFromRequest(r)) evt.Properties["auth_method"] = "google" obsmetrics.RecordEvent(h.Analytics, h.Metrics, evt) } // Update name and avatar from Google profile if the user was just created // (default name is email prefix) or has no avatar yet. needsUpdate := false newName := user.Name newAvatar := user.AvatarUrl if gUser.Name != "" && user.Name == strings.Split(email, "@")[0] { newName = gUser.Name needsUpdate = true } if gUser.Picture != "" && !user.AvatarUrl.Valid { newAvatar = pgtype.Text{String: gUser.Picture, Valid: true} needsUpdate = true } if needsUpdate { updated, err := h.Queries.UpdateUser(r.Context(), db.UpdateUserParams{ ID: user.ID, Name: newName, AvatarUrl: newAvatar, }) if err == nil { user = updated } } tokenString, err := h.issueJWT(user) if err != nil { slog.Warn("google login failed", append(logger.RequestAttrs(r), "error", err, "email", email)...) writeError(w, http.StatusInternalServerError, "failed to generate token") return } if err := auth.SetAuthCookies(w, tokenString); err != nil { slog.Warn("failed to set auth cookies", "error", err) } 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 via google", append(logger.RequestAttrs(r), "user_id", uuidToString(user.ID), "email", user.Email)...) writeJSON(w, http.StatusOK, LoginResponse{ Token: tokenString, User: userToResponse(user), }) } // IssueCliToken returns a fresh JWT for the authenticated user. // This allows cookie-authenticated browser sessions to obtain a bearer token // that can be handed off to the CLI via the cli_callback redirect. func (h *Handler) IssueCliToken(w http.ResponseWriter, r *http.Request) { userID, ok := requireUserID(w, r) if !ok { return } user, err := h.Queries.GetUser(r.Context(), parseUUID(userID)) if err != nil { writeError(w, http.StatusNotFound, "user not found") return } tokenString, err := h.issueJWT(user) if err != nil { slog.Warn("cli-token: failed to issue JWT", append(logger.RequestAttrs(r), "error", err, "user_id", userID)...) writeError(w, http.StatusInternalServerError, "failed to generate token") return } writeJSON(w, http.StatusOK, map[string]string{"token": tokenString}) } func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { auth.ClearAuthCookies(w) writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"}) } func (h *Handler) UpdateMe(w http.ResponseWriter, r *http.Request) { userID, ok := requireUserID(w, r) if !ok { return } var req UpdateMeRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } currentUser, err := h.Queries.GetUser(r.Context(), parseUUID(userID)) if err != nil { writeError(w, http.StatusNotFound, "user not found") return } name := currentUser.Name if req.Name != nil { name = strings.TrimSpace(*req.Name) if name == "" { writeError(w, http.StatusBadRequest, "name is required") return } } params := db.UpdateUserParams{ ID: currentUser.ID, Name: name, } if req.AvatarURL != nil { params.AvatarUrl = pgtype.Text{String: strings.TrimSpace(*req.AvatarURL), Valid: true} } if req.Language != nil { lang := strings.TrimSpace(*req.Language) if _, ok := supportedLanguages[lang]; !ok { writeError(w, http.StatusBadRequest, "unsupported language") return } params.Language = pgtype.Text{String: lang, Valid: true} } if req.ProfileDescription != nil { // Count runes, not bytes: 2000 chars of Chinese must not be rejected // as ~6000 bytes. utf8.RuneCountInString handles invalid UTF-8 by // counting each bad byte as one rune, which still bounds the column. desc := strings.TrimSpace(*req.ProfileDescription) if utf8.RuneCountInString(desc) > MaxProfileDescriptionLen { writeError(w, http.StatusBadRequest, fmt.Sprintf("profile_description exceeds %d characters", MaxProfileDescriptionLen)) return } params.ProfileDescription = pgtype.Text{String: desc, Valid: true} } if req.Timezone != nil { // Valid=false → column untouched; Valid=true + "" → clear to // NULL; Valid=true + IANA → set. Three-way semantics enforced // in the UpdateUser SQL CASE. tz := strings.TrimSpace(*req.Timezone) if tz != "" { if loc, err := time.LoadLocation(tz); err != nil || loc == nil { writeError(w, http.StatusBadRequest, "invalid timezone") return } } params.Timezone = pgtype.Text{String: tz, Valid: true} } updatedUser, err := h.Queries.UpdateUser(r.Context(), params) if err != nil { writeError(w, http.StatusInternalServerError, "failed to update user") return } writeJSON(w, http.StatusOK, userToResponse(updatedUser)) }