mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 17:40:11 +02:00
* fix(avatar): serve avatars through a signed endpoint on private buckets (MUL-5393) Avatar uploads persisted the raw storage object URL into `avatar_url`. On a deployment whose bucket is private and has no public CDN domain (S3 with Block Public Access, R2, MinIO) that URL is a guaranteed 403 in the browser: ATTACHMENT_DOWNLOAD_MODE only ever applied to the attachment download endpoint, so every user / agent / squad / workspace avatar rendered broken even though the upload itself succeeded. Resolve at read time instead of at upload time. What is persisted stays the durable object reference, so nothing with a TTL is ever written to the database and avatars saved by an older build are fixed without a backfill. What is served is `/api/avatars/<sig>/<key>`, a stable URL the server resolves per request through the deployment's existing storage download policy (presigned redirect, CloudFront-signed redirect, or proxied body). The endpoint is unauthenticated and the HMAC signature is the credential: the session cookie is SameSite=Strict, so an auth-gated URL cannot be a native <img src> from Desktop, a mobile webview, or a split-origin self-hosted web app. The signature covers the storage key and only image extensions resolve, so an avatar_url pointed at a private document cannot launder it into a publicly fetchable URL. Deployments that already work are untouched: a public CDN domain without per-request signing, and the local-disk backend whose /uploads/* route is public, both keep returning the raw URL. Fixes #6024 Co-authored-by: multica-agent <github@multica.ai> * fix(avatar): only publish avatar-class objects through the signed endpoint (MUL-5393) Review found that being able to name a storage object was treated as permission to publish it. `ownedStorageKey` proved only that a URL came from this deployment's storage, and every image-shaped key was then signed — while the avatar update endpoints accepted any raw storage URL. A caller who had seen a private image attachment's URL could submit it as their own avatar, and the unauthenticated endpoint would keep re-signing it indefinitely. A user avatar propagates to every workspace that user belongs to, so the leak crossed workspace boundaries. Add the missing authorization rule: an object is serveable as an avatar only when it is avatar-class — a standalone image upload not attached to an issue, comment, chat session, chat message, or task. The check resolves the backing attachment row from the id UploadFile embeds in the object filename, so it needs no lookup by URL and no new index. It is enforced on both sides. The write side rejects such a value with 403 before anything is stored; the read side re-checks per request, which is what makes the guarantee hold for rows written before this existed and revokes the URL if an object is later bound to a comment or chat. Scope is the `workspaces/` namespace — the only place that can hold content belonging to someone other than whoever is setting the avatar, covering both uploads and channel media ingest. Keys elsewhere (the per-user standalone namespace, or objects an operator placed in the bucket) stay usable, which keeps the documented "an explicit avatar_url is preserved" contract intact. Uploader identity is deliberately not part of the rule: duplicating an agent legitimately reuses the source agent's avatar object, which a different admin may have uploaded. Publishing someone else's unbound image would require knowing its URL, and unbound rows appear in no listing endpoint. Also clamp the 302's cache lifetime to half the signed URL's own TTL (0 -> no-store). ATTACHMENT_DOWNLOAD_URL_TTL takes any positive duration, so the fixed 60s could outlive the target it pointed at on a short-TTL deployment. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
727 lines
22 KiB
Go
727 lines
22 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 (h *Handler) 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: h.resolveAvatarURLPtr(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: h.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, h.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: h.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 {
|
|
avatarURL, ok := h.acceptAvatarURL(w, r, *req.AvatarURL, currentUser.AvatarUrl.String)
|
|
if !ok {
|
|
return
|
|
}
|
|
params.AvatarUrl = pgtype.Text{String: 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, h.userToResponse(updatedUser))
|
|
}
|