Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions apps/api/internal/store/avatar.go
Original file line number Diff line number Diff line change
@@ -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)
}
28 changes: 28 additions & 0 deletions apps/api/internal/store/avatar_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
26 changes: 22 additions & 4 deletions apps/api/internal/store/postgres/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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{
Expand All @@ -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"),
Expand Down
14 changes: 14 additions & 0 deletions apps/api/internal/store/postgres/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
57 changes: 55 additions & 2 deletions apps/api/internal/store/postgres/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 == "" {
Expand Down
43 changes: 42 additions & 1 deletion apps/api/internal/store/postgres/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
46 changes: 45 additions & 1 deletion apps/api/internal/store/postgres/postgres_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
43 changes: 43 additions & 0 deletions apps/api/internal/store/postgres/sqlc/queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading