mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
* feat(server): funnel/community/commercial business metrics + PostHog pairing (MUL-2949) PR3 of the Grafana board metrics split (parent MUL-2328). Adds 23 new Prometheus counter/histogram families to the PR2 BusinessMetrics collector covering the activation/community/commercial funnels, and binds every PostHog event emission to a matching metric increment so the two sides cannot drift. Funnel: signup, workspace_created, team_invite_sent/accepted, onboarding_*, cloud_waitlist_joined. Content: issue_created, chat_message_sent, agent_created, squad_created, autopilot_created, issue_executed. Runtime: runtime_registered/ready/failed/offline + ready_seconds histogram, daemon_ws_message_received_total. Autopilot: autopilot_run_started/terminal/skipped. Webhook/GitHub: webhook_delivery_total, github_event_received_total, github_pr_review_total, github_pr_merge_seconds histogram. CloudRuntime: cloudruntime_request_total + duration histogram, wired through a small RequestRecorder interface so the cloudruntime package stays decoupled from metrics. Commercial: feedback_submitted, contact_sales_submitted. The pairing helper metrics.RecordEvent(client, m, ev) emits the PostHog event AND increments the matching counter via IncForEvent dispatch, reading labels from the analytics event Properties. Every existing h.Analytics.Capture(analytics.X(...)) call site has been migrated to the helper across handler/, service/, and cmd/server/runtime_sweeper.go. Lint enforcement (server/internal/metrics/business_pairing_test.go): - TestEveryAnalyticsEventHasPrometheusCounter: every Event* constant in analytics/events.go either dispatches via IncForEvent or is in the taskMetricEvents allow-list (PR2 typed RecordTask* methods). - TestNoNakedAnalyticsCaptureInHandlersOrServices: AST-walks handler/ service/cmd-server for direct Analytics.Capture(...) calls — only service/task.go's captureTaskEvent helper is allow-listed. - TestEveryAnalyticsRecordEventTakesAnalyticsHelper: validates the third arg of every metrics.RecordEvent call is built from analytics.*. Cardinality protection: all new label values pass through fixed allow-lists in labels_pr3.go; unknown values collapse to 'other'/'unknown'/'error'. Refs: - Spec MUL-2328 / MUL-2949. - Builds on PR2 (MUL-2948) — collectors registered through the same BusinessMetrics struct, no separate Registry. - Uses PR1's taskfailure.Reason (MUL-2946) for runtime_failed's failure_reason label via NormalizeFailureReason. Out of scope: Sampler-class metrics (PR4 / MUL-2947), pr_review_total emission point (no review event handler exists yet — counter is defined, TODO to wire up when /api/webhooks/github grows pull_request_review handling). Co-authored-by: multica-agent <github@multica.ai> * fix(server): tighten PR3 review items — signup_source bucket, fill platform/kind/form_source enums, onboarding_started server emission, lint scope (MUL-2949) Addresses 张大彪's review on #3698: 1. signup_source: NormalizeSignupSource added to labels_pr3.go with a fixed allow-list bucket (direct/google/twitter/linkedin/.../other). Parses JSON cookie payload for utm_source/source/referrer fields, strips URL schemes, maps well-known hostnames to channel buckets. PostHog event still ships the raw cookie value for analytics; only the Prometheus label is bucketed. 2. Filled the unknown/other label gaps: - analytics.IssueCreated and analytics.ChatMessageSent now take a platform parameter sourced from middleware.ClientMetadataFromContext (X-Client-Platform header) at the handler. Autopilot-originated issues stamp PlatformServer. - analytics.FeedbackSubmitted now takes a kind parameter; CreateFeedback reads req.Kind (default "general") so the picker selection lights up the metric's kind label instead of long-term "other". - analytics.ContactSalesSubmitted now takes a formSource (page / onboarding / agents_page); CreateContactSales reads req.Source. The metric reads ev.Properties["form_source"] so the analytics CoreProperties.Source ("marketing_contact_sales") stays backward-compat for PostHog dashboards. 3. analytics.OnboardingStarted helper added; server-side emission lives in PatchOnboarding, fired exactly once per user on the first PATCH that carries a non-empty questionnaire payload (firstTouch logic compares prior bytes against {} / null). Frontend onboarding_started keeps firing on page open; the server emission is what guarantees the Prometheus counter exists so Grafana can be cross-checked against the PostHog funnel without depending on the SDK roundtrip. 4. business_pairing_test.go tightened: - TestNoNakedAnalyticsCaptureInHandlersOrServices now allow-lists at function granularity (just captureTaskEvent in service/task.go), not whole-file. Any future naked Capture in the same file fails CI. - TestEveryAnalyticsRecordEventTakesAnalyticsHelper now does def-use tracking inside the enclosing FuncDecl: when RecordEvent's third arg is an *ast.Ident, the test walks the function body for the assignment that defined it and confirms the RHS is an analytics.<Helper>(...) call. Bare local idents that didn't originate from analytics are now caught. 5. gofmt -w applied across the touched files; gofmt -l clean. Tests: go test ./internal/metrics/... ./internal/analytics/... pass. Pre-existing TestClaimTask_/TestWebhook_MergedPR/TestDeleteIssueByIdentifier failures on origin/main are DB-environment-dependent and not regressions from this change. Co-authored-by: multica-agent <github@multica.ai> * fix(server): normalise onboarding_started platform label + regression test (MUL-2949) Addresses 张大彪's last review nit: - IncForEvent's EventOnboardingStarted case now wraps the platform property with NormalizePlatform, matching every other platform-bearing metric. A misbehaving frontend can no longer leak a raw X-Client-Platform header value into the multica_onboarding_started_total{platform=...} series. - New labels_pr3_test.go covers every PR3 normalizer with both a happy-path value and an unknown value, asserting the unknown collapses to the documented fallback bucket. Includes a focused regression for onboarding_started: emits one event with an attacker-shaped platform string and asserts the metric only exposes web + unknown label values (no raw header bleed). - testutil.go gains a small GatherForTest helper so the regression test can pull the typed MetricFamily map without re-implementing the registry-walk dance. Co-authored-by: multica-agent <github@multica.ai> * fix(server): NormalizeTaskSource on workspace_created + document lint limitations (MUL-2949) Final review touch-ups before merge: - IncForEvent's EventWorkspaceCreated case wraps source through NormalizeTaskSource, matching the other source-bearing dispatches (issue_created, agent_created, issue_executed). Closes the last raw property leak in the dispatcher table. - business_pairing_test.go inline docstrings now spell out the two known limitations of the lint gate that 张大彪 / Eve flagged: analyticsBackedIdents matches by ident NAME (not SSA def-use, so a nested-scope shadow could pass) and isMetricsRecordEvent hard-codes the import alias set. PR description carries a Follow-ups section with the same two items so the work is visible after merge. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: 魏和尚 <agent+wei@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
723 lines
21 KiB
Go
723 lines
21 KiB
Go
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))
|
|
}
|