diff --git a/CHANGELOG.md b/CHANGELOG.md index cb2d8f4b..eb11eeed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.2.2 - Unreleased +- Added Gravatar fallbacks for email-backed users without an explicit or provider-supplied avatar. + ## 0.2.1 - 2026-07-19 - Shipped the first signed and notarized macOS desktop release bundles with the OpenClaw Foundation identity, hardened runtime, and strict sealed-artifact verification. diff --git a/apps/api/internal/store/avatar.go b/apps/api/internal/store/avatar.go new file mode 100644 index 00000000..3f2a61e2 --- /dev/null +++ b/apps/api/internal/store/avatar.go @@ -0,0 +1,19 @@ +package store + +import ( + "crypto/sha256" + "fmt" + "strings" +) + +func ResolveAvatarURL(avatarURL, email string) string { + if explicit := strings.TrimSpace(avatarURL); explicit != "" { + return explicit + } + normalizedEmail := strings.ToLower(strings.TrimSpace(email)) + if normalizedEmail == "" { + return "" + } + hash := sha256.Sum256([]byte(normalizedEmail)) + return fmt.Sprintf("https://gravatar.com/avatar/%x?d=identicon", hash) +} diff --git a/apps/api/internal/store/avatar_test.go b/apps/api/internal/store/avatar_test.go new file mode 100644 index 00000000..85942e8b --- /dev/null +++ b/apps/api/internal/store/avatar_test.go @@ -0,0 +1,28 @@ +package store + +import "testing" + +func TestResolveAvatarURL(t *testing.T) { + t.Parallel() + + const expected = "https://gravatar.com/avatar/84059b07d4be67b806386c0aad8070a23f18836bbaae342275dc0a83414c32ee?d=identicon" + tests := []struct { + name string + avatarURL string + email string + want string + }{ + {name: "explicit avatar wins", avatarURL: " https://example.com/avatar.png ", email: "user@example.com", want: "https://example.com/avatar.png"}, + {name: "normalized email", email: " MyEmailAddress@example.com ", want: expected}, + {name: "lowercase email", email: "myemailaddress@example.com", want: expected}, + {name: "missing email", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := ResolveAvatarURL(tt.avatarURL, tt.email); got != tt.want { + t.Fatalf("ResolveAvatarURL(%q, %q) = %q, want %q", tt.avatarURL, tt.email, got, tt.want) + } + }) + } +} diff --git a/apps/api/internal/store/postgres/auth.go b/apps/api/internal/store/postgres/auth.go index 1b26ecb8..4b3347da 100644 --- a/apps/api/internal/store/postgres/auth.go +++ b/apps/api/internal/store/postgres/auth.go @@ -127,7 +127,7 @@ func (s *Store) GetOrCreateUserByEmail(ctx context.Context, provider, email, dis _ = tx.Rollback() row, lookupErr := s.q.GetUserByIdentityEmail(ctx, email) if lookupErr == nil { - return storeUserFromIdentityEmail(row), nil + return ensureUserAvatarForEmail(ctx, s.q, storeUserFromIdentityEmail(row), email) } return store.User{}, err } @@ -140,16 +140,16 @@ func getOrCreateUserByEmail(ctx context.Context, q *storedb.Queries, provider, e } row, err := q.GetUserByIdentityEmail(ctx, email) if err == nil { - return storeUserFromIdentityEmail(row), nil + return ensureUserAvatarForEmail(ctx, q, storeUserFromIdentityEmail(row), email) } if !errors.Is(err, sql.ErrNoRows) { return store.User{}, err } - user := store.User{ID: newID("usr"), Kind: "human", DisplayName: strings.TrimSpace(displayName), Handle: "", AvatarURL: "", CreatedAt: now()} + user := store.User{ID: newID("usr"), Kind: "human", DisplayName: strings.TrimSpace(displayName), Handle: "", AvatarURL: store.ResolveAvatarURL("", email), CreatedAt: now()} if user.DisplayName == "" { user.DisplayName = email } - if err := q.InsertHumanUser(ctx, storedb.InsertHumanUserParams{ID: user.ID, DisplayName: user.DisplayName, AvatarUrl: "", CreatedAt: user.CreatedAt}); err != nil { + if err := q.InsertHumanUser(ctx, storedb.InsertHumanUserParams{ID: user.ID, DisplayName: user.DisplayName, AvatarUrl: user.AvatarURL, CreatedAt: user.CreatedAt}); err != nil { return store.User{}, err } err = q.InsertIdentity(ctx, storedb.InsertIdentityParams{ @@ -163,6 +163,24 @@ func getOrCreateUserByEmail(ctx context.Context, q *storedb.Queries, provider, e return user, err } +func ensureUserAvatarForEmail(ctx context.Context, q *storedb.Queries, user store.User, email string) (store.User, error) { + if user.AvatarURL != "" { + return user, nil + } + avatarURL := store.ResolveAvatarURL("", email) + if avatarURL == "" { + return user, nil + } + if err := q.SetUserAvatarIfEmpty(ctx, storedb.SetUserAvatarIfEmptyParams{ID: user.ID, AvatarUrl: avatarURL}); err != nil { + return store.User{}, err + } + row, err := q.GetUserByIdentityEmail(ctx, email) + if err != nil { + return store.User{}, err + } + return storeUserFromIdentityEmail(row), nil +} + func createSessionTx(ctx context.Context, q *storedb.Queries, userID string) (store.Session, error) { session := store.Session{ ID: newID("ses"), diff --git a/apps/api/internal/store/postgres/helpers.go b/apps/api/internal/store/postgres/helpers.go index 516946fc..39d00a26 100644 --- a/apps/api/internal/store/postgres/helpers.go +++ b/apps/api/internal/store/postgres/helpers.go @@ -195,6 +195,20 @@ func normalizeAvatarURL(value string) (string, error) { return avatarURL, nil } +func resolveProfileAvatarURL(ctx context.Context, q *storedb.Queries, userID, avatarURL string) (string, error) { + if avatarURL != "" { + return avatarURL, nil + } + email, err := q.GetIdentityEmailForUser(ctx, userID) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { + return "", err + } + return store.ResolveAvatarURL("", email), nil +} + func scanMessages(rows *sql.Rows) ([]store.Message, error) { out := []store.Message{} for rows.Next() { diff --git a/apps/api/internal/store/postgres/identity.go b/apps/api/internal/store/postgres/identity.go index 6cbef5d1..24a26712 100644 --- a/apps/api/internal/store/postgres/identity.go +++ b/apps/api/internal/store/postgres/identity.go @@ -16,8 +16,61 @@ func (s *Store) UpsertIdentityUser(ctx context.Context, input store.UpsertIdenti if provider == "" || subject == "" { return store.User{}, errors.New("identity provider and subject are required") } - row, err := s.q.GetUserByIdentityProviderSubject(ctx, storedb.GetUserByIdentityProviderSubjectParams{Provider: provider, ProviderSubject: subject}) + lookup := storedb.GetUserByIdentityProviderSubjectParams{Provider: provider, ProviderSubject: subject} + row, err := s.q.GetUserByIdentityProviderSubject(ctx, lookup) if err == nil { + user := storeUserFromIdentityProviderSubject(row) + email := strings.TrimSpace(input.Email) + if email != "" { + if err := s.q.UpdateIdentityEmailIfEmpty(ctx, storedb.UpdateIdentityEmailIfEmptyParams{ + Email: email, + Provider: provider, + ProviderSubject: subject, + }); err != nil { + return store.User{}, err + } + } + storedEmail, emailErr := s.q.GetIdentityEmailForUser(ctx, user.ID) + if emailErr == nil { + email = storedEmail + } else if !errors.Is(emailErr, sql.ErrNoRows) { + return store.User{}, emailErr + } + explicitAvatarURL := strings.TrimSpace(input.AvatarURL) + fallbackURL := store.ResolveAvatarURL("", email) + if explicitAvatarURL != "" { + updated, err := s.q.SetProviderAvatarUnlessExplicit(ctx, storedb.SetProviderAvatarUnlessExplicitParams{ + ID: user.ID, + AvatarUrl: explicitAvatarURL, + FallbackUrl: fallbackURL, + }) + if err != nil { + return store.User{}, err + } + if updated == 0 { + latestEmail, emailErr := s.q.GetIdentityEmailForUser(ctx, user.ID) + if emailErr == nil { + _, err = s.q.SetProviderAvatarUnlessExplicit(ctx, storedb.SetProviderAvatarUnlessExplicitParams{ + ID: user.ID, + AvatarUrl: explicitAvatarURL, + FallbackUrl: store.ResolveAvatarURL("", latestEmail), + }) + if err != nil { + return store.User{}, err + } + } else if !errors.Is(emailErr, sql.ErrNoRows) { + return store.User{}, emailErr + } + } + } else if fallbackURL != "" { + if err := s.q.SetUserAvatarIfEmpty(ctx, storedb.SetUserAvatarIfEmptyParams{ID: user.ID, AvatarUrl: fallbackURL}); err != nil { + return store.User{}, err + } + } + row, err = s.q.GetUserByIdentityProviderSubject(ctx, lookup) + if err != nil { + return store.User{}, err + } return s.hydrateUserNotificationSettings(ctx, storeUserFromIdentityProviderSubject(row)) } if !errors.Is(err, sql.ErrNoRows) { @@ -34,7 +87,7 @@ func (s *Store) UpsertIdentityUser(ctx context.Context, input store.UpsertIdenti Kind: "human", DisplayName: strings.TrimSpace(input.DisplayName), Handle: "", - AvatarURL: strings.TrimSpace(input.AvatarURL), + AvatarURL: store.ResolveAvatarURL(input.AvatarURL, input.Email), CreatedAt: now(), } if user.DisplayName == "" { diff --git a/apps/api/internal/store/postgres/postgres.go b/apps/api/internal/store/postgres/postgres.go index dddf6d2b..8c9a8086 100644 --- a/apps/api/internal/store/postgres/postgres.go +++ b/apps/api/internal/store/postgres/postgres.go @@ -105,9 +105,28 @@ func (s *Store) Migrate(ctx context.Context) (err error) { return err } } + if err := s.backfillGravatarAvatars(ctx); err != nil { + return err + } return s.backfillRouteIDsOnce(ctx) } +func (s *Store) backfillGravatarAvatars(ctx context.Context) error { + users, err := s.q.ListUsersMissingAvatar(ctx) + if err != nil { + return err + } + for _, user := range users { + if err := s.q.SetUserAvatarIfEmpty(ctx, storedb.SetUserAvatarIfEmptyParams{ + ID: user.UserID, + AvatarUrl: store.ResolveAvatarURL("", user.Email), + }); err != nil { + return err + } + } + return nil +} + func (s *Store) EnsureBootstrap(ctx context.Context, name, email string) (store.User, error) { user, err := s.FirstUser(ctx) if err == nil { @@ -135,7 +154,7 @@ func (s *Store) CreateUser(ctx context.Context, input store.CreateUserInput) (st Kind: "human", DisplayName: strings.TrimSpace(input.DisplayName), Handle: "", - AvatarURL: "", + AvatarURL: store.ResolveAvatarURL("", input.Email), CreatedAt: now(), } if user.DisplayName == "" { @@ -200,6 +219,17 @@ func (s *Store) UpdateUserProfile(ctx context.Context, input store.UpdateUserPro }); err != nil { return store.User{}, profileUpdateError(err) } + if avatarURL == "" { + fallbackURL, err := resolveProfileAvatarURL(ctx, qtx, input.UserID, "") + if err != nil { + return store.User{}, err + } + if fallbackURL != "" { + if err := qtx.SetUserAvatarIfEmpty(ctx, storedb.SetUserAvatarIfEmptyParams{ID: input.UserID, AvatarUrl: fallbackURL}); err != nil { + return store.User{}, err + } + } + } if err := qtx.UpdateWorkspaceMemberSortKeys(ctx, storedb.UpdateWorkspaceMemberSortKeysParams{ DisplayName: displayName, Handle: handle, @@ -245,6 +275,17 @@ func (s *Store) UpdateUserProfileAndNotificationSettings(ctx context.Context, in }); err != nil { return store.User{}, profileUpdateError(err) } + if avatarURL == "" { + fallbackURL, err := resolveProfileAvatarURL(ctx, qtx, input.UserID, "") + if err != nil { + return store.User{}, err + } + if fallbackURL != "" { + if err := qtx.SetUserAvatarIfEmpty(ctx, storedb.SetUserAvatarIfEmptyParams{ID: input.UserID, AvatarUrl: fallbackURL}); err != nil { + return store.User{}, err + } + } + } if err := qtx.UpdateWorkspaceMemberSortKeys(ctx, storedb.UpdateWorkspaceMemberSortKeysParams{ DisplayName: displayName, Handle: handle, diff --git a/apps/api/internal/store/postgres/postgres_test.go b/apps/api/internal/store/postgres/postgres_test.go index c139bf3b..22cc6162 100644 --- a/apps/api/internal/store/postgres/postgres_test.go +++ b/apps/api/internal/store/postgres/postgres_test.go @@ -139,10 +139,54 @@ func TestPostgresStoreSmoke(t *testing.T) { t.Fatal(err) } suffix := time.Now().UTC().Format("20060102150405.000000000") - owner, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Postgres Owner", Email: "pg-owner-" + suffix + "@example.com"}) + ownerEmail := "pg-owner-" + suffix + "@example.com" + owner, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Postgres Owner", Email: ownerEmail}) if err != nil { t.Fatal(err) } + ownerGravatar := store.ResolveAvatarURL("", ownerEmail) + if owner.AvatarURL != ownerGravatar { + t.Fatalf("expected Postgres Gravatar %q, got %#v", ownerGravatar, owner) + } + ownerSession, err := st.CreateSession(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + if _, err := st.db.ExecContext(ctx, `UPDATE users SET avatar_url = '' WHERE id = $1`, owner.ID); err != nil { + t.Fatal(err) + } + if err := st.Migrate(ctx); err != nil { + t.Fatal(err) + } + sessionUser, err := st.GetSessionUser(ctx, ownerSession.Token) + if err != nil { + t.Fatal(err) + } + if sessionUser.AvatarURL != ownerGravatar { + t.Fatalf("expected migrated Postgres Gravatar %q, got %#v", ownerGravatar, sessionUser) + } + identitySubject := "pg-avatar-" + suffix + identityUser, err := st.UpsertIdentityUser(ctx, store.UpsertIdentityUserInput{ + Provider: "github", + ProviderSubject: identitySubject, + Email: identitySubject + "@example.com", + DisplayName: "Postgres Avatar", + }) + if err != nil { + t.Fatal(err) + } + identityUser, err = st.UpsertIdentityUser(ctx, store.UpsertIdentityUserInput{ + Provider: "github", + ProviderSubject: identitySubject, + Email: identitySubject + "@example.com", + AvatarURL: "https://example.com/postgres-provider.png", + }) + if err != nil { + t.Fatal(err) + } + if identityUser.AvatarURL != "https://example.com/postgres-provider.png" { + t.Fatalf("expected Postgres provider avatar to replace Gravatar, got %#v", identityUser) + } workspace, err := st.CreateWorkspace(ctx, store.CreateWorkspaceInput{Name: "Postgres Smoke " + suffix}, owner.ID) if err != nil { t.Fatal(err) diff --git a/apps/api/internal/store/postgres/sqlc/queries.sql b/apps/api/internal/store/postgres/sqlc/queries.sql index 6bd7ef3a..37eab00f 100644 --- a/apps/api/internal/store/postgres/sqlc/queries.sql +++ b/apps/api/internal/store/postgres/sqlc/queries.sql @@ -121,6 +121,49 @@ WHERE i.provider = sqlc.arg(provider) INSERT INTO users (id, display_name, avatar_url, created_at) VALUES (sqlc.arg(id), sqlc.arg(display_name), sqlc.arg(avatar_url), sqlc.arg(created_at)); +-- name: GetIdentityEmailForUser :one +SELECT email +FROM identities +WHERE user_id = sqlc.arg(user_id) + AND email <> '' +ORDER BY created_at, id +LIMIT 1; + +-- name: ListUsersMissingAvatar :many +SELECT u.id AS user_id, i.email +FROM users u +JOIN identities i ON i.id = ( + SELECT candidate.id + FROM identities candidate + WHERE candidate.user_id = u.id + AND candidate.email <> '' + ORDER BY candidate.created_at, candidate.id + LIMIT 1 +) +WHERE u.kind = 'human' + AND u.avatar_url = '' +ORDER BY u.id; + +-- name: SetUserAvatarIfEmpty :exec +UPDATE users +SET avatar_url = sqlc.arg(avatar_url) +WHERE id = sqlc.arg(id) + AND avatar_url = ''; + +-- Avatar URLs equal to the generated fallback remain fallback-equivalent. +-- name: SetProviderAvatarUnlessExplicit :execrows +UPDATE users +SET avatar_url = sqlc.arg(avatar_url) +WHERE id = sqlc.arg(id) + AND (avatar_url = '' OR avatar_url = sqlc.arg(fallback_url)); + +-- name: UpdateIdentityEmailIfEmpty :exec +UPDATE identities +SET email = sqlc.arg(email) +WHERE provider = sqlc.arg(provider) + AND provider_subject = sqlc.arg(provider_subject) + AND email = ''; + -- name: InsertIdentity :exec INSERT INTO identities (id, user_id, provider, provider_subject, email, created_at) VALUES (sqlc.arg(id), sqlc.arg(user_id), sqlc.arg(provider), sqlc.arg(provider_subject), sqlc.arg(email), sqlc.arg(created_at)); diff --git a/apps/api/internal/store/postgres/storedb/queries.sql.go b/apps/api/internal/store/postgres/storedb/queries.sql.go index c8f93d44..d758c0c0 100644 --- a/apps/api/internal/store/postgres/storedb/queries.sql.go +++ b/apps/api/internal/store/postgres/storedb/queries.sql.go @@ -1365,6 +1365,22 @@ func (q *Queries) GetEventDeliveryAttemptCursor(ctx context.Context, arg GetEven return created_at, err } +const getIdentityEmailForUser = `-- name: GetIdentityEmailForUser :one +SELECT email +FROM identities +WHERE user_id = $1 + AND email <> '' +ORDER BY created_at, id +LIMIT 1 +` + +func (q *Queries) GetIdentityEmailForUser(ctx context.Context, userID string) (string, error) { + row := q.db.QueryRowContext(ctx, getIdentityEmailForUser, userID) + var email string + err := row.Scan(&email) + return email, err +} + const getMagicLinkByToken = `-- name: GetMagicLinkByToken :one SELECT id, token, token_hash, email, display_name, created_at, expires_at, used_at FROM auth_magic_links @@ -3652,6 +3668,50 @@ func (q *Queries) ListThreadStates(ctx context.Context, rootMessageIds []string) return items, nil } +const listUsersMissingAvatar = `-- name: ListUsersMissingAvatar :many +SELECT u.id AS user_id, i.email +FROM users u +JOIN identities i ON i.id = ( + SELECT candidate.id + FROM identities candidate + WHERE candidate.user_id = u.id + AND candidate.email <> '' + ORDER BY candidate.created_at, candidate.id + LIMIT 1 +) +WHERE u.kind = 'human' + AND u.avatar_url = '' +ORDER BY u.id +` + +type ListUsersMissingAvatarRow struct { + UserID string `json:"user_id"` + Email string `json:"email"` +} + +func (q *Queries) ListUsersMissingAvatar(ctx context.Context) ([]ListUsersMissingAvatarRow, error) { + rows, err := q.db.QueryContext(ctx, listUsersMissingAvatar) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListUsersMissingAvatarRow + for rows.Next() { + var i ListUsersMissingAvatarRow + if err := rows.Scan(&i.UserID, &i.Email); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listWorkspaceActiveBotMemberIDs = `-- name: ListWorkspaceActiveBotMemberIDs :many SELECT u.id FROM users u @@ -4782,6 +4842,45 @@ func (q *Queries) RevokeAllBotTokens(ctx context.Context, arg RevokeAllBotTokens return result.RowsAffected() } +const setProviderAvatarUnlessExplicit = `-- name: SetProviderAvatarUnlessExplicit :execrows +UPDATE users +SET avatar_url = $1 +WHERE id = $2 + AND (avatar_url = '' OR avatar_url = $3) +` + +type SetProviderAvatarUnlessExplicitParams struct { + AvatarUrl string `json:"avatar_url"` + ID string `json:"id"` + FallbackUrl string `json:"fallback_url"` +} + +// Avatar URLs equal to the generated fallback remain fallback-equivalent. +func (q *Queries) SetProviderAvatarUnlessExplicit(ctx context.Context, arg SetProviderAvatarUnlessExplicitParams) (int64, error) { + result, err := q.db.ExecContext(ctx, setProviderAvatarUnlessExplicit, arg.AvatarUrl, arg.ID, arg.FallbackUrl) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const setUserAvatarIfEmpty = `-- name: SetUserAvatarIfEmpty :exec +UPDATE users +SET avatar_url = $1 +WHERE id = $2 + AND avatar_url = '' +` + +type SetUserAvatarIfEmptyParams struct { + AvatarUrl string `json:"avatar_url"` + ID string `json:"id"` +} + +func (q *Queries) SetUserAvatarIfEmpty(ctx context.Context, arg SetUserAvatarIfEmptyParams) error { + _, err := q.db.ExecContext(ctx, setUserAvatarIfEmpty, arg.AvatarUrl, arg.ID) + return err +} + const threadNextSeq = `-- name: ThreadNextSeq :one SELECT CAST(COALESCE(MAX(thread_seq), 0) + 1 AS BIGINT) AS next_seq FROM messages @@ -4880,6 +4979,25 @@ func (q *Queries) UpdateChannel(ctx context.Context, arg UpdateChannelParams) er return err } +const updateIdentityEmailIfEmpty = `-- name: UpdateIdentityEmailIfEmpty :exec +UPDATE identities +SET email = $1 +WHERE provider = $2 + AND provider_subject = $3 + AND email = '' +` + +type UpdateIdentityEmailIfEmptyParams struct { + Email string `json:"email"` + Provider string `json:"provider"` + ProviderSubject string `json:"provider_subject"` +} + +func (q *Queries) UpdateIdentityEmailIfEmpty(ctx context.Context, arg UpdateIdentityEmailIfEmptyParams) error { + _, err := q.db.ExecContext(ctx, updateIdentityEmailIfEmpty, arg.Email, arg.Provider, arg.ProviderSubject) + return err +} + const updateMessageBody = `-- name: UpdateMessageBody :execrows UPDATE messages SET body = $1, diff --git a/apps/api/internal/store/sqlite/auth.go b/apps/api/internal/store/sqlite/auth.go index 8d6c2cf6..a5a72b70 100644 --- a/apps/api/internal/store/sqlite/auth.go +++ b/apps/api/internal/store/sqlite/auth.go @@ -127,7 +127,7 @@ func (s *Store) GetOrCreateUserByEmail(ctx context.Context, provider, email, dis _ = tx.Rollback() row, lookupErr := s.q.GetUserByIdentityEmail(ctx, email) if lookupErr == nil { - return storeUserFromIdentityEmail(row), nil + return ensureUserAvatarForEmail(ctx, s.q, storeUserFromIdentityEmail(row), email) } return store.User{}, err } @@ -140,16 +140,16 @@ func getOrCreateUserByEmail(ctx context.Context, q *storedb.Queries, provider, e } row, err := q.GetUserByIdentityEmail(ctx, email) if err == nil { - return storeUserFromIdentityEmail(row), nil + return ensureUserAvatarForEmail(ctx, q, storeUserFromIdentityEmail(row), email) } if !errors.Is(err, sql.ErrNoRows) { return store.User{}, err } - user := store.User{ID: newID("usr"), Kind: "human", DisplayName: strings.TrimSpace(displayName), Handle: "", AvatarURL: "", CreatedAt: now()} + user := store.User{ID: newID("usr"), Kind: "human", DisplayName: strings.TrimSpace(displayName), Handle: "", AvatarURL: store.ResolveAvatarURL("", email), CreatedAt: now()} if user.DisplayName == "" { user.DisplayName = email } - if err := q.InsertHumanUser(ctx, storedb.InsertHumanUserParams{ID: user.ID, DisplayName: user.DisplayName, AvatarUrl: "", CreatedAt: user.CreatedAt}); err != nil { + if err := q.InsertHumanUser(ctx, storedb.InsertHumanUserParams{ID: user.ID, DisplayName: user.DisplayName, AvatarUrl: user.AvatarURL, CreatedAt: user.CreatedAt}); err != nil { return store.User{}, err } err = q.InsertIdentity(ctx, storedb.InsertIdentityParams{ @@ -163,6 +163,24 @@ func getOrCreateUserByEmail(ctx context.Context, q *storedb.Queries, provider, e return user, err } +func ensureUserAvatarForEmail(ctx context.Context, q *storedb.Queries, user store.User, email string) (store.User, error) { + if user.AvatarURL != "" { + return user, nil + } + avatarURL := store.ResolveAvatarURL("", email) + if avatarURL == "" { + return user, nil + } + if err := q.SetUserAvatarIfEmpty(ctx, storedb.SetUserAvatarIfEmptyParams{ID: user.ID, AvatarUrl: avatarURL}); err != nil { + return store.User{}, err + } + row, err := q.GetUserByIdentityEmail(ctx, email) + if err != nil { + return store.User{}, err + } + return storeUserFromIdentityEmail(row), nil +} + func createSessionTx(ctx context.Context, q *storedb.Queries, userID string) (store.Session, error) { session := store.Session{ ID: newID("ses"), diff --git a/apps/api/internal/store/sqlite/helpers.go b/apps/api/internal/store/sqlite/helpers.go index 265e12ba..f8b78c6a 100644 --- a/apps/api/internal/store/sqlite/helpers.go +++ b/apps/api/internal/store/sqlite/helpers.go @@ -195,6 +195,20 @@ func normalizeAvatarURL(value string) (string, error) { return avatarURL, nil } +func resolveProfileAvatarURL(ctx context.Context, q *storedb.Queries, userID, avatarURL string) (string, error) { + if avatarURL != "" { + return avatarURL, nil + } + email, err := q.GetIdentityEmailForUser(ctx, userID) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { + return "", err + } + return store.ResolveAvatarURL("", email), nil +} + func scanMessages(rows *sql.Rows) ([]store.Message, error) { out := []store.Message{} for rows.Next() { diff --git a/apps/api/internal/store/sqlite/identity.go b/apps/api/internal/store/sqlite/identity.go index fbd5bd26..a2ea469e 100644 --- a/apps/api/internal/store/sqlite/identity.go +++ b/apps/api/internal/store/sqlite/identity.go @@ -16,8 +16,61 @@ func (s *Store) UpsertIdentityUser(ctx context.Context, input store.UpsertIdenti if provider == "" || subject == "" { return store.User{}, errors.New("identity provider and subject are required") } - row, err := s.q.GetUserByIdentityProviderSubject(ctx, storedb.GetUserByIdentityProviderSubjectParams{Provider: provider, ProviderSubject: subject}) + lookup := storedb.GetUserByIdentityProviderSubjectParams{Provider: provider, ProviderSubject: subject} + row, err := s.q.GetUserByIdentityProviderSubject(ctx, lookup) if err == nil { + user := storeUserFromIdentityProviderSubject(row) + email := strings.TrimSpace(input.Email) + if email != "" { + if err := s.q.UpdateIdentityEmailIfEmpty(ctx, storedb.UpdateIdentityEmailIfEmptyParams{ + Email: email, + Provider: provider, + ProviderSubject: subject, + }); err != nil { + return store.User{}, err + } + } + storedEmail, emailErr := s.q.GetIdentityEmailForUser(ctx, user.ID) + if emailErr == nil { + email = storedEmail + } else if !errors.Is(emailErr, sql.ErrNoRows) { + return store.User{}, emailErr + } + explicitAvatarURL := strings.TrimSpace(input.AvatarURL) + fallbackURL := store.ResolveAvatarURL("", email) + if explicitAvatarURL != "" { + updated, err := s.q.SetProviderAvatarUnlessExplicit(ctx, storedb.SetProviderAvatarUnlessExplicitParams{ + ID: user.ID, + AvatarUrl: explicitAvatarURL, + FallbackUrl: fallbackURL, + }) + if err != nil { + return store.User{}, err + } + if updated == 0 { + latestEmail, emailErr := s.q.GetIdentityEmailForUser(ctx, user.ID) + if emailErr == nil { + _, err = s.q.SetProviderAvatarUnlessExplicit(ctx, storedb.SetProviderAvatarUnlessExplicitParams{ + ID: user.ID, + AvatarUrl: explicitAvatarURL, + FallbackUrl: store.ResolveAvatarURL("", latestEmail), + }) + if err != nil { + return store.User{}, err + } + } else if !errors.Is(emailErr, sql.ErrNoRows) { + return store.User{}, emailErr + } + } + } else if fallbackURL != "" { + if err := s.q.SetUserAvatarIfEmpty(ctx, storedb.SetUserAvatarIfEmptyParams{ID: user.ID, AvatarUrl: fallbackURL}); err != nil { + return store.User{}, err + } + } + row, err = s.q.GetUserByIdentityProviderSubject(ctx, lookup) + if err != nil { + return store.User{}, err + } return s.hydrateUserNotificationSettings(ctx, storeUserFromIdentityProviderSubject(row)) } if !errors.Is(err, sql.ErrNoRows) { @@ -34,7 +87,7 @@ func (s *Store) UpsertIdentityUser(ctx context.Context, input store.UpsertIdenti Kind: "human", DisplayName: strings.TrimSpace(input.DisplayName), Handle: "", - AvatarURL: strings.TrimSpace(input.AvatarURL), + AvatarURL: store.ResolveAvatarURL(input.AvatarURL, input.Email), CreatedAt: now(), } if user.DisplayName == "" { diff --git a/apps/api/internal/store/sqlite/misc_test.go b/apps/api/internal/store/sqlite/misc_test.go index b89e2b4e..670d6c70 100644 --- a/apps/api/internal/store/sqlite/misc_test.go +++ b/apps/api/internal/store/sqlite/misc_test.go @@ -26,11 +26,15 @@ func TestStoreMiscBranches(t *testing.T) { if err != nil { t.Fatal(err) } + ownerGravatar := store.ResolveAvatarURL("", "owner@example.com") + if owner.AvatarURL != ownerGravatar { + t.Fatalf("expected bootstrap Gravatar %q, got %#v", ownerGravatar, owner) + } unnamed, err := st.CreateUser(ctx, store.CreateUserInput{}) if err != nil { t.Fatal(err) } - if unnamed.DisplayName != "Local User" { + if unnamed.DisplayName != "Local User" || unnamed.AvatarURL != "" { t.Fatalf("unexpected default user: %#v", unnamed) } updatedOwner, err := st.UpdateUserProfile(ctx, store.UpdateUserProfileInput{ @@ -42,9 +46,21 @@ func TestStoreMiscBranches(t *testing.T) { if err != nil { t.Fatal(err) } - if updatedOwner.Handle != "steipete" || updatedOwner.AvatarURL == "" { + if updatedOwner.Handle != "steipete" || updatedOwner.AvatarURL != "https://example.com/avatar.png" { t.Fatalf("unexpected profile update: %#v", updatedOwner) } + clearedOwner, err := st.UpdateUserProfileAndNotificationSettings(ctx, store.UpdateUserProfileAndNotificationSettingsInput{ + UserID: owner.ID, + DisplayName: updatedOwner.DisplayName, + Handle: updatedOwner.Handle, + AvatarURL: "", + }) + if err != nil { + t.Fatal(err) + } + if clearedOwner.AvatarURL != ownerGravatar { + t.Fatalf("expected cleared avatar to restore Gravatar %q, got %#v", ownerGravatar, clearedOwner) + } if _, err := st.UpdateUserProfile(ctx, store.UpdateUserProfileInput{UserID: unnamed.ID, DisplayName: "Other", Handle: "STEIPETE"}); err == nil { t.Fatal("expected duplicate handle error") } @@ -98,8 +114,8 @@ func TestStoreMiscBranches(t *testing.T) { if err != nil { t.Fatal(err) } - if againIdentity.ID != identityUser.ID { - t.Fatalf("expected existing identity user, got %#v", againIdentity) + if againIdentity.ID != identityUser.ID || againIdentity.AvatarURL != "https://example.com/a.png" { + t.Fatalf("expected existing identity user with provider avatar, got %#v", againIdentity) } session, err := st.CreateSession(ctx, identityUser.ID) if err != nil { @@ -115,15 +131,58 @@ func TestStoreMiscBranches(t *testing.T) { if err != nil { t.Fatal(err) } - if fallbackIdentity.DisplayName != "github:fallback" { - t.Fatalf("unexpected fallback identity display: %#v", fallbackIdentity) + if fallbackIdentity.DisplayName != "github:fallback" || fallbackIdentity.AvatarURL != "" { + t.Fatalf("unexpected fallback identity: %#v", fallbackIdentity) + } + fallbackIdentity, err = st.UpsertIdentityUser(ctx, store.UpsertIdentityUserInput{ + Provider: "github", + ProviderSubject: "fallback", + Email: "fallback@example.com", + }) + if err != nil { + t.Fatal(err) + } + fallbackGravatar := store.ResolveAvatarURL("", "fallback@example.com") + if fallbackIdentity.AvatarURL != fallbackGravatar { + t.Fatalf("expected existing identity Gravatar %q, got %#v", fallbackGravatar, fallbackIdentity) + } + fallbackIdentity, err = st.UpsertIdentityUser(ctx, store.UpsertIdentityUserInput{ + Provider: "github", + ProviderSubject: "fallback", + Email: "fallback@example.com", + AvatarURL: "https://example.com/provider.png", + }) + if err != nil { + t.Fatal(err) + } + if fallbackIdentity.AvatarURL != "https://example.com/provider.png" { + t.Fatalf("expected provider avatar to replace Gravatar, got %#v", fallbackIdentity) + } + fallbackIdentity, err = st.UpdateUserProfile(ctx, store.UpdateUserProfileInput{ + UserID: fallbackIdentity.ID, + DisplayName: fallbackIdentity.DisplayName, + AvatarURL: "https://example.com/custom.png", + }) + if err != nil { + t.Fatal(err) + } + fallbackIdentity, err = st.UpdateUserProfileAndNotificationSettings(ctx, store.UpdateUserProfileAndNotificationSettingsInput{ + UserID: fallbackIdentity.ID, + DisplayName: fallbackIdentity.DisplayName, + AvatarURL: "", + }) + if err != nil { + t.Fatal(err) + } + if fallbackIdentity.AvatarURL != fallbackGravatar { + t.Fatalf("expected late identity email to restore Gravatar %q, got %#v", fallbackGravatar, fallbackIdentity) } emailIdentity, err := st.UpsertIdentityUser(ctx, store.UpsertIdentityUserInput{Provider: "github", ProviderSubject: "email", Email: "email@example.com"}) if err != nil { t.Fatal(err) } - if emailIdentity.DisplayName != "email@example.com" { - t.Fatalf("unexpected email identity display: %#v", emailIdentity) + if emailIdentity.DisplayName != "email@example.com" || emailIdentity.AvatarURL != store.ResolveAvatarURL("", "email@example.com") { + t.Fatalf("unexpected email identity: %#v", emailIdentity) } if _, err := st.CreateSession(ctx, "usr_missing"); err == nil { t.Fatal("expected missing session user error") @@ -311,12 +370,155 @@ func TestGetOrCreateUserByEmailConcurrent(t *testing.T) { if user.ID != userID { t.Errorf("concurrent get-or-create returned different users: %q and %q", userID, user.ID) } + if want := store.ResolveAvatarURL("", "concurrent@example.com"); user.AvatarURL != want { + t.Errorf("concurrent get-or-create avatar = %q, want %q", user.AvatarURL, want) + } } if count != callers { t.Fatalf("successful callers = %d, want %d", count, callers) } } +func TestConcurrentProviderAvatarWinsLateEmailFallback(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := newTestStore(t) + identity := store.UpsertIdentityUserInput{ + Provider: "github", + ProviderSubject: "concurrent-avatar", + DisplayName: "Concurrent Avatar", + } + user, err := st.UpsertIdentityUser(ctx, identity) + if err != nil { + t.Fatal(err) + } + if user.AvatarURL != "" { + t.Fatalf("expected initial blank avatar, got %#v", user) + } + + start := make(chan struct{}) + errors := make(chan error, 2) + var group sync.WaitGroup + for _, input := range []store.UpsertIdentityUserInput{ + { + Provider: identity.Provider, + ProviderSubject: identity.ProviderSubject, + Email: "concurrent-avatar@example.com", + }, + { + Provider: identity.Provider, + ProviderSubject: identity.ProviderSubject, + AvatarURL: "https://example.com/provider-concurrent.png", + }, + } { + group.Add(1) + go func() { + defer group.Done() + <-start + _, err := st.UpsertIdentityUser(ctx, input) + errors <- err + }() + } + close(start) + group.Wait() + close(errors) + for err := range errors { + if err != nil { + t.Fatal(err) + } + } + user, err = st.GetUser(ctx, user.ID) + if err != nil { + t.Fatal(err) + } + if user.AvatarURL != "https://example.com/provider-concurrent.png" { + t.Fatalf("expected provider avatar to win concurrent late-email fallback, got %#v", user) + } +} + +func TestConcurrentProfileClearRestoresLateEmailFallback(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := newTestStore(t) + identity := store.UpsertIdentityUserInput{ + Provider: "github", + ProviderSubject: "concurrent-clear", + DisplayName: "Concurrent Clear", + AvatarURL: "https://example.com/custom-before-clear.png", + } + user, err := st.UpsertIdentityUser(ctx, identity) + if err != nil { + t.Fatal(err) + } + + start := make(chan struct{}) + errors := make(chan error, 2) + var group sync.WaitGroup + group.Add(2) + go func() { + defer group.Done() + <-start + _, err := st.UpsertIdentityUser(ctx, store.UpsertIdentityUserInput{ + Provider: identity.Provider, + ProviderSubject: identity.ProviderSubject, + Email: "concurrent-clear@example.com", + }) + errors <- err + }() + go func() { + defer group.Done() + <-start + _, err := st.UpdateUserProfileAndNotificationSettings(ctx, store.UpdateUserProfileAndNotificationSettingsInput{ + UserID: user.ID, + DisplayName: user.DisplayName, + AvatarURL: "", + }) + errors <- err + }() + close(start) + group.Wait() + close(errors) + for err := range errors { + if err != nil { + t.Fatal(err) + } + } + user, err = st.GetUser(ctx, user.ID) + if err != nil { + t.Fatal(err) + } + if want := store.ResolveAvatarURL("", "concurrent-clear@example.com"); user.AvatarURL != want { + t.Fatalf("expected late-email Gravatar %q after concurrent clear, got %#v", want, user) + } +} + +func TestMigrateBackfillsLegacyGravatarForExistingSession(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := newTestStore(t) + user, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Legacy User", Email: "legacy@example.com"}) + if err != nil { + t.Fatal(err) + } + session, err := st.CreateSession(ctx, user.ID) + if err != nil { + t.Fatal(err) + } + if _, err := st.db.ExecContext(ctx, `UPDATE users SET avatar_url = '' WHERE id = ?`, user.ID); err != nil { + t.Fatal(err) + } + if err := st.Migrate(ctx); err != nil { + t.Fatal(err) + } + sessionUser, err := st.GetSessionUser(ctx, session.Token) + if err != nil { + t.Fatal(err) + } + if want := store.ResolveAvatarURL("", "legacy@example.com"); sessionUser.AvatarURL != want { + t.Fatalf("expected migrated session user Gravatar %q, got %#v", want, sessionUser) + } +} + func TestEnsureDefaultWorkspaceMemberCreatesWorkspace(t *testing.T) { t.Parallel() ctx := context.Background() diff --git a/apps/api/internal/store/sqlite/sqlc/queries.sql b/apps/api/internal/store/sqlite/sqlc/queries.sql index e9301010..e986bfba 100644 --- a/apps/api/internal/store/sqlite/sqlc/queries.sql +++ b/apps/api/internal/store/sqlite/sqlc/queries.sql @@ -119,6 +119,49 @@ WHERE i.provider = sqlc.arg(provider) INSERT INTO users (id, display_name, avatar_url, created_at) VALUES (sqlc.arg(id), sqlc.arg(display_name), sqlc.arg(avatar_url), sqlc.arg(created_at)); +-- name: GetIdentityEmailForUser :one +SELECT email +FROM identities +WHERE user_id = sqlc.arg(user_id) + AND email <> '' +ORDER BY created_at, id +LIMIT 1; + +-- name: ListUsersMissingAvatar :many +SELECT u.id AS user_id, i.email +FROM users u +JOIN identities i ON i.id = ( + SELECT candidate.id + FROM identities candidate + WHERE candidate.user_id = u.id + AND candidate.email <> '' + ORDER BY candidate.created_at, candidate.id + LIMIT 1 +) +WHERE u.kind = 'human' + AND u.avatar_url = '' +ORDER BY u.id; + +-- name: SetUserAvatarIfEmpty :exec +UPDATE users +SET avatar_url = sqlc.arg(avatar_url) +WHERE id = sqlc.arg(id) + AND avatar_url = ''; + +-- Avatar URLs equal to the generated fallback remain fallback-equivalent. +-- name: SetProviderAvatarUnlessExplicit :execrows +UPDATE users +SET avatar_url = sqlc.arg(avatar_url) +WHERE id = sqlc.arg(id) + AND (avatar_url = '' OR avatar_url = sqlc.arg(fallback_url)); + +-- name: UpdateIdentityEmailIfEmpty :exec +UPDATE identities +SET email = sqlc.arg(email) +WHERE provider = sqlc.arg(provider) + AND provider_subject = sqlc.arg(provider_subject) + AND email = ''; + -- name: InsertIdentity :exec INSERT INTO identities (id, user_id, provider, provider_subject, email, created_at) VALUES (sqlc.arg(id), sqlc.arg(user_id), sqlc.arg(provider), sqlc.arg(provider_subject), sqlc.arg(email), sqlc.arg(created_at)); diff --git a/apps/api/internal/store/sqlite/sqlite.go b/apps/api/internal/store/sqlite/sqlite.go index 8e722b57..b9b05c6c 100644 --- a/apps/api/internal/store/sqlite/sqlite.go +++ b/apps/api/internal/store/sqlite/sqlite.go @@ -119,9 +119,28 @@ func (s *Store) Migrate(ctx context.Context) error { if err := s.backfillAuthTokenHashes(ctx); err != nil { return err } + if err := s.backfillGravatarAvatars(ctx); err != nil { + return err + } return s.backfillRouteIDsOnce(ctx) } +func (s *Store) backfillGravatarAvatars(ctx context.Context) error { + users, err := s.q.ListUsersMissingAvatar(ctx) + if err != nil { + return err + } + for _, user := range users { + if err := s.q.SetUserAvatarIfEmpty(ctx, storedb.SetUserAvatarIfEmptyParams{ + ID: user.UserID, + AvatarUrl: store.ResolveAvatarURL("", user.Email), + }); err != nil { + return err + } + } + return nil +} + func (s *Store) EnsureBootstrap(ctx context.Context, name, email string) (store.User, error) { user, err := s.FirstUser(ctx) if err == nil { @@ -149,7 +168,7 @@ func (s *Store) CreateUser(ctx context.Context, input store.CreateUserInput) (st Kind: "human", DisplayName: strings.TrimSpace(input.DisplayName), Handle: "", - AvatarURL: "", + AvatarURL: store.ResolveAvatarURL("", input.Email), CreatedAt: now(), } if user.DisplayName == "" { @@ -214,6 +233,17 @@ func (s *Store) UpdateUserProfile(ctx context.Context, input store.UpdateUserPro }); err != nil { return store.User{}, profileUpdateError(err) } + if avatarURL == "" { + fallbackURL, err := resolveProfileAvatarURL(ctx, qtx, input.UserID, "") + if err != nil { + return store.User{}, err + } + if fallbackURL != "" { + if err := qtx.SetUserAvatarIfEmpty(ctx, storedb.SetUserAvatarIfEmptyParams{ID: input.UserID, AvatarUrl: fallbackURL}); err != nil { + return store.User{}, err + } + } + } if err := qtx.UpdateWorkspaceMemberSortKeys(ctx, storedb.UpdateWorkspaceMemberSortKeysParams{ DisplayName: displayName, Handle: handle, @@ -259,6 +289,17 @@ func (s *Store) UpdateUserProfileAndNotificationSettings(ctx context.Context, in }); err != nil { return store.User{}, profileUpdateError(err) } + if avatarURL == "" { + fallbackURL, err := resolveProfileAvatarURL(ctx, qtx, input.UserID, "") + if err != nil { + return store.User{}, err + } + if fallbackURL != "" { + if err := qtx.SetUserAvatarIfEmpty(ctx, storedb.SetUserAvatarIfEmptyParams{ID: input.UserID, AvatarUrl: fallbackURL}); err != nil { + return store.User{}, err + } + } + } if err := qtx.UpdateWorkspaceMemberSortKeys(ctx, storedb.UpdateWorkspaceMemberSortKeysParams{ DisplayName: displayName, Handle: handle, diff --git a/apps/api/internal/store/sqlite/sqlite_test.go b/apps/api/internal/store/sqlite/sqlite_test.go index a56e004f..b70b4af0 100644 --- a/apps/api/internal/store/sqlite/sqlite_test.go +++ b/apps/api/internal/store/sqlite/sqlite_test.go @@ -85,6 +85,9 @@ func TestStoreValidationAndAdminHelpers(t *testing.T) { if magicUser.DisplayName != "Magic User" || session.Token == "" { t.Fatalf("unexpected magic auth result: %#v %#v", magicUser, session) } + if want := store.ResolveAvatarURL("", "magic@example.com"); magicUser.AvatarURL != want { + t.Fatalf("expected magic-link Gravatar %q, got %#v", want, magicUser) + } if _, err := st.UpdateNotificationSettings(ctx, store.UpdateNotificationSettingsInput{ UserID: magicUser.ID, PushoverEnabled: true, diff --git a/apps/api/internal/store/sqlite/storedb/queries.sql.go b/apps/api/internal/store/sqlite/storedb/queries.sql.go index c9af78d2..a4a6e4ec 100644 --- a/apps/api/internal/store/sqlite/storedb/queries.sql.go +++ b/apps/api/internal/store/sqlite/storedb/queries.sql.go @@ -1359,6 +1359,22 @@ func (q *Queries) GetEventDeliveryAttemptCursor(ctx context.Context, arg GetEven return created_at, err } +const getIdentityEmailForUser = `-- name: GetIdentityEmailForUser :one +SELECT email +FROM identities +WHERE user_id = ?1 + AND email <> '' +ORDER BY created_at, id +LIMIT 1 +` + +func (q *Queries) GetIdentityEmailForUser(ctx context.Context, userID string) (string, error) { + row := q.db.QueryRowContext(ctx, getIdentityEmailForUser, userID) + var email string + err := row.Scan(&email) + return email, err +} + const getMagicLinkByToken = `-- name: GetMagicLinkByToken :one SELECT id, token, token_hash, email, display_name, created_at, expires_at, used_at FROM auth_magic_links @@ -3664,6 +3680,50 @@ func (q *Queries) ListThreadStates(ctx context.Context, rootMessageIds []string) return items, nil } +const listUsersMissingAvatar = `-- name: ListUsersMissingAvatar :many +SELECT u.id AS user_id, i.email +FROM users u +JOIN identities i ON i.id = ( + SELECT candidate.id + FROM identities candidate + WHERE candidate.user_id = u.id + AND candidate.email <> '' + ORDER BY candidate.created_at, candidate.id + LIMIT 1 +) +WHERE u.kind = 'human' + AND u.avatar_url = '' +ORDER BY u.id +` + +type ListUsersMissingAvatarRow struct { + UserID string `json:"user_id"` + Email string `json:"email"` +} + +func (q *Queries) ListUsersMissingAvatar(ctx context.Context) ([]ListUsersMissingAvatarRow, error) { + rows, err := q.db.QueryContext(ctx, listUsersMissingAvatar) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListUsersMissingAvatarRow + for rows.Next() { + var i ListUsersMissingAvatarRow + if err := rows.Scan(&i.UserID, &i.Email); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listWorkspaceActiveServiceBotIDs = `-- name: ListWorkspaceActiveServiceBotIDs :many SELECT DISTINCT u.id FROM users u @@ -4684,6 +4744,45 @@ func (q *Queries) RevokeAllBotTokens(ctx context.Context, arg RevokeAllBotTokens return result.RowsAffected() } +const setProviderAvatarUnlessExplicit = `-- name: SetProviderAvatarUnlessExplicit :execrows +UPDATE users +SET avatar_url = ?1 +WHERE id = ?2 + AND (avatar_url = '' OR avatar_url = ?3) +` + +type SetProviderAvatarUnlessExplicitParams struct { + AvatarUrl string `json:"avatar_url"` + ID string `json:"id"` + FallbackUrl string `json:"fallback_url"` +} + +// Avatar URLs equal to the generated fallback remain fallback-equivalent. +func (q *Queries) SetProviderAvatarUnlessExplicit(ctx context.Context, arg SetProviderAvatarUnlessExplicitParams) (int64, error) { + result, err := q.db.ExecContext(ctx, setProviderAvatarUnlessExplicit, arg.AvatarUrl, arg.ID, arg.FallbackUrl) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const setUserAvatarIfEmpty = `-- name: SetUserAvatarIfEmpty :exec +UPDATE users +SET avatar_url = ?1 +WHERE id = ?2 + AND avatar_url = '' +` + +type SetUserAvatarIfEmptyParams struct { + AvatarUrl string `json:"avatar_url"` + ID string `json:"id"` +} + +func (q *Queries) SetUserAvatarIfEmpty(ctx context.Context, arg SetUserAvatarIfEmptyParams) error { + _, err := q.db.ExecContext(ctx, setUserAvatarIfEmpty, arg.AvatarUrl, arg.ID) + return err +} + const threadNextSeq = `-- name: ThreadNextSeq :one SELECT CAST(COALESCE(MAX(thread_seq), 0) + 1 AS INTEGER) AS next_seq FROM messages @@ -4782,6 +4881,25 @@ func (q *Queries) UpdateChannel(ctx context.Context, arg UpdateChannelParams) er return err } +const updateIdentityEmailIfEmpty = `-- name: UpdateIdentityEmailIfEmpty :exec +UPDATE identities +SET email = ?1 +WHERE provider = ?2 + AND provider_subject = ?3 + AND email = '' +` + +type UpdateIdentityEmailIfEmptyParams struct { + Email string `json:"email"` + Provider string `json:"provider"` + ProviderSubject string `json:"provider_subject"` +} + +func (q *Queries) UpdateIdentityEmailIfEmpty(ctx context.Context, arg UpdateIdentityEmailIfEmptyParams) error { + _, err := q.db.ExecContext(ctx, updateIdentityEmailIfEmpty, arg.Email, arg.Provider, arg.ProviderSubject) + return err +} + const updateMessageBody = `-- name: UpdateMessageBody :execrows UPDATE messages SET body = ?1, diff --git a/docs/features/profiles.md b/docs/features/profiles.md index 48540904..23fa25f9 100644 --- a/docs/features/profiles.md +++ b/docs/features/profiles.md @@ -6,7 +6,8 @@ read_when: # Profiles Each user has a display name, optional handle, optional avatar URL, and -per-user notification settings. +per-user notification settings. Email-backed users without an explicit or +provider-supplied avatar use a Gravatar generated from their normalized email. The handle is the human-friendly short name shown as `@name` in the app. The API accepts it with or without the leading `@`, normalizes it to lowercase, and @@ -31,7 +32,10 @@ PATCH /api/me `PATCH /api/me` returns `{ "user": ... }`. Handles must be unique when set and must be 2-32 characters using letters, numbers, `_`, or `-`. Avatar URLs can be -blank or an `http`/`https` URL. +blank or an `http`/`https` URL. An explicit URL takes precedence over Gravatar; +clearing it restores the email-backed Gravatar fallback. Gravatar requests are +served by `gravatar.com`, so clients loading those images contact that external +service. Pushover notifications require `CLICKCLACK_PUSHOVER_API_TOKEN` on the server. Each user opts in from account settings with their own 30-character Pushover