Skip to content

Commit eff4ab6

Browse files
Oleg Komarovbartvdbraak
authored andcommitted
BLENDER: Sync user badges on sign-in
Don't escalate any errors, only log them, to avoid breaking sign-in.
1 parent 80ca6eb commit eff4ab6

File tree

3 files changed

+159
-0
lines changed

3 files changed

+159
-0
lines changed

routers/web/auth/oauth.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,8 +301,32 @@ func updateAvatarIfNeed(ctx *context.Context, url string, u *user_model.User) {
301301
}
302302
}
303303

304+
// BLENDER: sync user badges
305+
func updateBadgesIfNeed(ctx *context.Context, rawData map[string]any, u *user_model.User) error {
306+
blenderIDBadges, has := rawData["badges"]
307+
if !has {
308+
return nil
309+
}
310+
remoteBadgesMap, ok := blenderIDBadges.(map[string]any)
311+
if !ok {
312+
return fmt.Errorf("unexpected format of remote badges payload: %+v", blenderIDBadges)
313+
}
314+
315+
remoteBadges := make([]*user_model.Badge, 0, len(remoteBadgesMap))
316+
for slug := range remoteBadgesMap {
317+
remoteBadges = append(remoteBadges, &user_model.Badge{Slug: slug})
318+
}
319+
return user_service.UpdateBadgesBestEffort(ctx, u, remoteBadges)
320+
}
321+
304322
func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model.User, gothUser goth.User) {
305323
updateAvatarIfNeed(ctx, gothUser.AvatarURL, u)
324+
// BLENDER: sync user badges
325+
// Don't escalate any errors, only log them:
326+
// we don't want to break login process due to errors in badges sync
327+
if err := updateBadgesIfNeed(ctx, gothUser.RawData, u); err != nil {
328+
log.Error("Failed to update user badges for %s: %w", u.LoginName, err)
329+
}
306330

307331
needs2FA := false
308332
if !source.TwoFactorShouldSkip() {

services/user/badge.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package user
5+
6+
import (
7+
"context"
8+
"fmt"
9+
10+
"code.gitea.io/gitea/models/db"
11+
user_model "code.gitea.io/gitea/models/user"
12+
"code.gitea.io/gitea/modules/log"
13+
)
14+
15+
// BLENDER: sync user badges
16+
// This function works in a best-effort fashion:
17+
// it tolerates all errors and tries to perform all badge changes one-by-one.
18+
func UpdateBadgesBestEffort(ctx context.Context, u *user_model.User, newBadges []*user_model.Badge) error {
19+
return db.WithTx(ctx, func(ctx context.Context) error {
20+
oldUserBadges, _, err := user_model.GetUserBadges(ctx, u)
21+
if err != nil {
22+
return fmt.Errorf("failed to fetch local badges for %s: %w", u.LoginName, err)
23+
}
24+
25+
oldBadgeSlugs := map[string]struct{}{}
26+
for _, badge := range oldUserBadges {
27+
oldBadgeSlugs[badge.Slug] = struct{}{}
28+
}
29+
30+
newBadgeSlugs := map[string]struct{}{}
31+
for _, badge := range newBadges {
32+
newBadgeSlugs[badge.Slug] = struct{}{}
33+
}
34+
35+
for slug := range newBadgeSlugs {
36+
if _, has := oldBadgeSlugs[slug]; has {
37+
continue
38+
}
39+
if err := user_model.AddUserBadge(ctx, u, &user_model.Badge{Slug: slug}); err != nil {
40+
// Don't escalate, continue processing other badges
41+
log.Error("Failed to add badge slug %s to user %s: %v", slug, u.LoginName, err)
42+
}
43+
}
44+
for slug := range oldBadgeSlugs {
45+
if _, has := newBadgeSlugs[slug]; has {
46+
continue
47+
}
48+
if err := user_model.RemoveUserBadge(ctx, u, &user_model.Badge{Slug: slug}); err != nil {
49+
// Don't escalate, continue processing other badges
50+
log.Error("Failed to remove badge slug %s from user %s: %v", slug, u.LoginName, err)
51+
}
52+
}
53+
return nil
54+
})
55+
}

services/user/badge_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
// BLENDER: sync user badges
5+
6+
package user
7+
8+
import (
9+
"fmt"
10+
"slices"
11+
"sync"
12+
"testing"
13+
14+
"code.gitea.io/gitea/models/db"
15+
"code.gitea.io/gitea/models/unittest"
16+
user_model "code.gitea.io/gitea/models/user"
17+
18+
"github.com/stretchr/testify/assert"
19+
)
20+
21+
// TestUpdateBadgesBestEffort executes UpdateBadgesBestEffort concurrently.
22+
//
23+
// This test illustrates the need for a database transaction around AddUserBadge and RemoveUserBadge calls.
24+
// This test is not deterministic, but at least it can demonstrate the problem after a few non-cached runs:
25+
//
26+
// go test -count=1 -v -tags sqlite -run TestUpdateBadgesBestEffort ./services/user/...
27+
func TestUpdateBadgesBestEffort(t *testing.T) {
28+
assert.NoError(t, unittest.PrepareTestDatabase())
29+
30+
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
31+
badges := []*user_model.Badge{}
32+
for i := range 5 {
33+
badge := &user_model.Badge{Slug: fmt.Sprintf("update-badges-test-%d", i)}
34+
user_model.CreateBadge(db.DefaultContext, badge)
35+
badges = append(badges, badge)
36+
}
37+
var wg sync.WaitGroup
38+
start := make(chan struct{})
39+
f := func(wg *sync.WaitGroup, badges []*user_model.Badge) {
40+
<-start
41+
defer wg.Done()
42+
UpdateBadgesBestEffort(db.DefaultContext, user, badges)
43+
}
44+
updateSets := [][]*user_model.Badge{
45+
badges[0:1],
46+
badges[1:3],
47+
badges[3:5],
48+
}
49+
for _, s := range updateSets {
50+
wg.Add(1)
51+
go f(&wg, s)
52+
}
53+
t.Log("start")
54+
// Use the channel to start goroutines' execution as close as possible.
55+
close(start)
56+
wg.Wait()
57+
58+
result, _, _ := user_model.GetUserBadges(db.DefaultContext, user)
59+
resultSlugs := make([]string, 0, len(result))
60+
for _, b := range result {
61+
resultSlugs = append(resultSlugs, b.Slug)
62+
}
63+
64+
match := false
65+
for _, set := range updateSets {
66+
setSlugs := make([]string, 0, len(set))
67+
for _, b := range set {
68+
setSlugs = append(setSlugs, b.Slug)
69+
}
70+
// Expecting to confirm that what we get at the end is not a mish-mash of different update attempts,
71+
// but one complete attempt.
72+
if slices.Equal(setSlugs, resultSlugs) {
73+
match = true
74+
break
75+
}
76+
}
77+
if !match {
78+
t.Fail()
79+
}
80+
}

0 commit comments

Comments
 (0)