diff --git a/MAINTAINERS b/MAINTAINERS index 1c7afc6f6c..26192eff44 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -64,3 +64,4 @@ metiftikci (@metiftikci) Christopher Homberger (@ChristopherHX) Tobias Balle-Petersen (@tobiasbp) TheFox (@TheFox0x7) +Nicolas (@bircni) \ No newline at end of file diff --git a/models/fixtures/badge.yml b/models/fixtures/badge.yml new file mode 100644 index 0000000000..438cd0ca5d --- /dev/null +++ b/models/fixtures/badge.yml @@ -0,0 +1,5 @@ +- + id: 1 + slug: badge1 + description: just a test badge + image_url: badge1.png diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index dc5dc9f330..bb8dad5ec6 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -403,6 +403,7 @@ func prepareMigrationTasks() []*migration { newMigration(326, "Migrate commit status target URL to use run ID and job ID", v1_26.FixCommitStatusTargetURLToUseRunAndJobID), newMigration(327, "Add disabled state to action runners", v1_26.AddDisabledToActionRunner), newMigration(328, "Add TokenPermissions column to ActionRunJob", v1_26.AddTokenPermissionsToActionRunJob), + newMigration(329, "Add unique constraint for user badge", v1_26.AddUniqueIndexForUserBadge), } return preparedMigrations } diff --git a/models/migrations/v1_26/v329.go b/models/migrations/v1_26/v329.go new file mode 100644 index 0000000000..1afd155f05 --- /dev/null +++ b/models/migrations/v1_26/v329.go @@ -0,0 +1,62 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "fmt" + + "xorm.io/xorm" + "xorm.io/xorm/schemas" +) + +type UserBadge struct { //revive:disable-line:exported + ID int64 `xorm:"pk autoincr"` + BadgeID int64 + UserID int64 +} + +// TableIndices implements xorm's TableIndices interface +func (n *UserBadge) TableIndices() []*schemas.Index { + indices := make([]*schemas.Index, 0, 1) + ubUnique := schemas.NewIndex("unique_user_badge", schemas.UniqueType) + ubUnique.AddColumn("user_id", "badge_id") + indices = append(indices, ubUnique) + return indices +} + +// AddUniqueIndexForUserBadge adds a compound unique indexes for user badge table +// and it replaces an old index on user_id +func AddUniqueIndexForUserBadge(x *xorm.Engine) error { + // remove possible duplicated records in table user_badge + type result struct { + UserID int64 + BadgeID int64 + Cnt int + } + var results []result + if err := x.Select("user_id, badge_id, count(*) as cnt"). + Table("user_badge"). + GroupBy("user_id, badge_id"). + Having("count(*) > 1"). + Find(&results); err != nil { + return err + } + for _, r := range results { + if x.Dialect().URI().DBType == schemas.MSSQL { + if _, err := x.Exec(fmt.Sprintf("delete from user_badge where id in (SELECT top %d id FROM user_badge WHERE user_id = ? and badge_id = ?)", r.Cnt-1), r.UserID, r.BadgeID); err != nil { + return err + } + } else { + var ids []int64 + if err := x.SQL("SELECT id FROM user_badge WHERE user_id = ? and badge_id = ? limit ?", r.UserID, r.BadgeID, r.Cnt-1).Find(&ids); err != nil { + return err + } + if _, err := x.Table("user_badge").In("id", ids).Delete(); err != nil { + return err + } + } + } + + return x.Sync(new(UserBadge)) +} diff --git a/models/migrations/v1_26/v329_test.go b/models/migrations/v1_26/v329_test.go new file mode 100644 index 0000000000..cab8e79906 --- /dev/null +++ b/models/migrations/v1_26/v329_test.go @@ -0,0 +1,85 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "testing" + + "code.gitea.io/gitea/models/migrations/base" + + "github.com/stretchr/testify/assert" +) + +type UserBadgeBefore struct { + ID int64 `xorm:"pk autoincr"` + BadgeID int64 + UserID int64 `xorm:"INDEX"` +} + +func (UserBadgeBefore) TableName() string { + return "user_badge" +} + +func Test_AddUniqueIndexForUserBadge(t *testing.T) { + x, deferable := base.PrepareTestEnv(t, 0, new(UserBadgeBefore)) + defer deferable() + if x == nil || t.Failed() { + return + } + + testData := []*UserBadgeBefore{ + {UserID: 1, BadgeID: 1}, + {UserID: 1, BadgeID: 1}, // duplicate + {UserID: 2, BadgeID: 1}, + {UserID: 1, BadgeID: 2}, + {UserID: 3, BadgeID: 3}, + {UserID: 3, BadgeID: 3}, // duplicate + } + + for _, data := range testData { + _, err := x.Insert(data) + assert.NoError(t, err) + } + + // check that we have duplicates + count, err := x.Where("user_id = ? AND badge_id = ?", 1, 1).Count(&UserBadgeBefore{}) + assert.NoError(t, err) + assert.Equal(t, int64(2), count) + + count, err = x.Where("user_id = ? AND badge_id = ?", 3, 3).Count(&UserBadgeBefore{}) + assert.NoError(t, err) + assert.Equal(t, int64(2), count) + + totalCount, err := x.Count(&UserBadgeBefore{}) + assert.NoError(t, err) + assert.Equal(t, int64(6), totalCount) + + // run the migration + if err := AddUniqueIndexForUserBadge(x); err != nil { + assert.NoError(t, err) + return + } + + // verify the duplicates were removed + count, err = x.Where("user_id = ? AND badge_id = ?", 1, 1).Count(&UserBadgeBefore{}) + assert.NoError(t, err) + assert.Equal(t, int64(1), count) + + count, err = x.Where("user_id = ? AND badge_id = ?", 3, 3).Count(&UserBadgeBefore{}) + assert.NoError(t, err) + assert.Equal(t, int64(1), count) + + // check total count + totalCount, err = x.Count(&UserBadgeBefore{}) + assert.NoError(t, err) + assert.Equal(t, int64(4), totalCount) + + // fail to insert a duplicate + _, err = x.Insert(&UserBadge{UserID: 1, BadgeID: 1}) + assert.Error(t, err) + + // succeed adding a non-duplicate + _, err = x.Insert(&UserBadge{UserID: 4, BadgeID: 1}) + assert.NoError(t, err) +} diff --git a/models/user/badge.go b/models/user/badge.go index e475ceb748..fbba865926 100644 --- a/models/user/badge.go +++ b/models/user/badge.go @@ -5,9 +5,12 @@ package user import ( "context" - "fmt" "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" + "xorm.io/xorm/schemas" ) // Badge represents a user badge @@ -22,7 +25,16 @@ type Badge struct { type UserBadge struct { //nolint:revive // export stutter ID int64 `xorm:"pk autoincr"` BadgeID int64 - UserID int64 `xorm:"INDEX"` + UserID int64 +} + +// TableIndices implements xorm's TableIndices interface +func (n *UserBadge) TableIndices() []*schemas.Index { + indices := make([]*schemas.Index, 0, 1) + ubUnique := schemas.NewIndex("unique_user_badge", schemas.UniqueType) + ubUnique.AddColumn("user_id", "badge_id") + indices = append(indices, ubUnique) + return indices } func init() { @@ -42,32 +54,85 @@ func GetUserBadges(ctx context.Context, u *User) ([]*Badge, int64, error) { return badges, count, err } -// CreateBadge creates a new badge. -func CreateBadge(ctx context.Context, badge *Badge) error { - _, err := db.GetEngine(ctx).Insert(badge) - return err +// GetBadgeUsersOptions contains options for getting users with a specific badge +type GetBadgeUsersOptions struct { + db.ListOptions + BadgeSlug string } -// GetBadge returns a badge +// GetBadgeUsers returns the users that have a specific badge with pagination support. +func GetBadgeUsers(ctx context.Context, opts *GetBadgeUsersOptions) ([]*User, int64, error) { + sess := db.GetEngine(ctx). + Select("`user`.*"). + Join("INNER", "user_badge", "`user_badge`.user_id=user.id"). + Join("INNER", "badge", "`user_badge`.badge_id=badge.id"). + Where("badge.slug=?", opts.BadgeSlug) + + if opts.Page > 0 { + sess = db.SetSessionPagination(sess, opts) + } + + users := make([]*User, 0, opts.PageSize) + count, err := sess.FindAndCount(&users) + return users, count, err +} + +// CreateBadge creates a new badge. +func CreateBadge(ctx context.Context, badge *Badge) error { + exists, err := db.GetEngine(ctx).Where("slug = ?", badge.Slug).Exist(new(Badge)) + if err != nil { + return err + } + if exists { + return util.NewAlreadyExistErrorf("badge already exists [slug: %s]", badge.Slug) + } + + if _, err := db.GetEngine(ctx).Insert(badge); err != nil { + // Handle race between existence check and insert. + exists, existErr := db.GetEngine(ctx).Where("slug = ?", badge.Slug).Exist(new(Badge)) + if existErr == nil && exists { + return util.NewAlreadyExistErrorf("badge already exists [slug: %s]", badge.Slug) + } + return err + } + return nil +} + +// GetBadge returns a specific badge func GetBadge(ctx context.Context, slug string) (*Badge, error) { badge := new(Badge) has, err := db.GetEngine(ctx).Where("slug=?", slug).Get(badge) - if !has { + if err != nil { return nil, err } - return badge, err + if !has { + return nil, util.NewNotExistErrorf("badge does not exist [slug: %s]", slug) + } + return badge, nil } // UpdateBadge updates a badge based on its slug. func UpdateBadge(ctx context.Context, badge *Badge) error { - _, err := db.GetEngine(ctx).Where("slug=?", badge.Slug).Update(badge) + _, err := db.GetEngine(ctx).Where("slug=?", badge.Slug).Cols("description", "image_url").Update(badge) return err } -// DeleteBadge deletes a badge. +// DeleteBadge deletes a badge and all associated user_badge entries. func DeleteBadge(ctx context.Context, badge *Badge) error { - _, err := db.GetEngine(ctx).Where("slug=?", badge.Slug).Delete(badge) - return err + return db.WithTx(ctx, func(ctx context.Context) error { + // First delete all user_badge entries for this badge + if _, err := db.GetEngine(ctx). + Where("badge_id = ?", badge.ID). + Delete(&UserBadge{}); err != nil { + return err + } + + // Then delete the badge itself + if _, err := db.GetEngine(ctx).Where("slug=?", badge.Slug).Delete(badge); err != nil { + return err + } + return nil + }) } // AddUserBadge adds a badge to a user. @@ -84,12 +149,25 @@ func AddUserBadges(ctx context.Context, u *User, badges []*Badge) error { if err != nil { return err } else if !has { - return fmt.Errorf("badge with slug %s doesn't exist", badge.Slug) + return util.NewNotExistErrorf("badge does not exist [slug: %s]", badge.Slug) } + + exists, err := db.GetEngine(ctx).Where("badge_id = ? AND user_id = ?", badge.ID, u.ID).Exist(new(UserBadge)) + if err != nil { + return err + } + if exists { + return util.NewAlreadyExistErrorf("user badge already exists [user_id: %d, badge_id: %d]", u.ID, badge.ID) + } + if err := db.Insert(ctx, &UserBadge{ BadgeID: badge.ID, UserID: u.ID, }); err != nil { + exists, existErr := db.GetEngine(ctx).Where("badge_id = ? AND user_id = ?", badge.ID, u.ID).Exist(new(UserBadge)) + if existErr == nil && exists { + return util.NewAlreadyExistErrorf("user badge already exists [user_id: %d, badge_id: %d]", u.ID, badge.ID) + } return err } } @@ -102,16 +180,33 @@ func RemoveUserBadge(ctx context.Context, u *User, badge *Badge) error { return RemoveUserBadges(ctx, u, []*Badge{badge}) } -// RemoveUserBadges removes badges from a user. +// RemoveUserBadges removes specific badges from a user. func RemoveUserBadges(ctx context.Context, u *User, badges []*Badge) error { return db.WithTx(ctx, func(ctx context.Context) error { + if len(badges) == 0 { + return nil + } + + badgeSlugs := make([]string, 0, len(badges)) for _, badge := range badges { - if _, err := db.GetEngine(ctx). - Join("INNER", "badge", "badge.id = `user_badge`.badge_id"). - Where("`user_badge`.user_id=? AND `badge`.slug=?", u.ID, badge.Slug). - Delete(&UserBadge{}); err != nil { - return err - } + badgeSlugs = append(badgeSlugs, badge.Slug) + } + var userBadges []UserBadge + if err := db.GetEngine(ctx).Table("user_badge"). + Join("INNER", "badge", "badge.id = `user_badge`.badge_id"). + Where("`user_badge`.user_id = ?", u.ID).In("`badge`.slug", badgeSlugs). + Find(&userBadges); err != nil { + return err + } + userBadgeIDs := make([]int64, 0, len(userBadges)) + for _, ub := range userBadges { + userBadgeIDs = append(userBadgeIDs, ub.ID) + } + if len(userBadgeIDs) == 0 { + return nil + } + if _, err := db.GetEngine(ctx).Table("user_badge").In("id", userBadgeIDs).Delete(); err != nil { + return err } return nil }) @@ -122,3 +217,57 @@ func RemoveAllUserBadges(ctx context.Context, u *User) error { _, err := db.GetEngine(ctx).Where("user_id=?", u.ID).Delete(&UserBadge{}) return err } + +// SearchBadgeOptions represents the options when finding badges +type SearchBadgeOptions struct { + db.ListOptions + + Keyword string + Slug string + ID int64 + OrderBy db.SearchOrderBy +} + +func (opts *SearchBadgeOptions) ToConds() builder.Cond { + cond := builder.NewCond() + + if opts.Keyword != "" { + keywordCond := builder.Or( + db.BuildCaseInsensitiveLike("badge.slug", opts.Keyword), + db.BuildCaseInsensitiveLike("badge.description", opts.Keyword), + ) + cond = cond.And(keywordCond) + } + + if opts.ID > 0 { + cond = cond.And(builder.Eq{"badge.id": opts.ID}) + } + + if len(opts.Slug) > 0 { + cond = cond.And(builder.Eq{"badge.slug": opts.Slug}) + } + + return cond +} + +func (opts *SearchBadgeOptions) ToOrders() string { + return opts.OrderBy.String() +} + +// SearchBadges returns badges based on the provided SearchBadgeOptions options +func SearchBadges(ctx context.Context, opts *SearchBadgeOptions) ([]*Badge, int64, error) { + return db.FindAndCount[Badge](ctx, opts) +} + +// GetBadgeByID returns a specific badge by ID +func GetBadgeByID(ctx context.Context, id int64) (*Badge, error) { + badge := new(Badge) + has, err := db.GetEngine(ctx).ID(id).Get(badge) + if err != nil { + return nil, err + } + if !has { + return nil, util.NewNotExistErrorf("badge does not exist [id: %d]", id) + } + return badge, nil +} diff --git a/models/user/badge_test.go b/models/user/badge_test.go new file mode 100644 index 0000000000..04979a9caf --- /dev/null +++ b/models/user/badge_test.go @@ -0,0 +1,185 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user_test + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/util" + + "github.com/stretchr/testify/assert" +) + +func TestBadge(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + t.Run("GetBadgeNotExist", testGetBadgeNotExist) + t.Run("CreateBadgeAlreadyExists", testCreateBadgeAlreadyExists) + t.Run("GetBadgeUsers", testGetBadgeUsers) + t.Run("AddAndRemoveUserBadges", testAddAndRemoveUserBadges) + t.Run("SearchBadgesOrderingAndKeyword", testSearchBadgesOrderingAndKeyword) +} + +func testGetBadgeNotExist(t *testing.T) { + badge, err := user_model.GetBadge(t.Context(), "does-not-exist") + assert.Nil(t, badge) + assert.Error(t, err) + assert.ErrorIs(t, err, util.ErrNotExist) +} + +func testCreateBadgeAlreadyExists(t *testing.T) { + badge := &user_model.Badge{ + Slug: "duplicate-badge-slug", + Description: "First", + } + assert.NoError(t, user_model.CreateBadge(t.Context(), badge)) + + err := user_model.CreateBadge(t.Context(), &user_model.Badge{ + Slug: "duplicate-badge-slug", + Description: "Second", + }) + assert.Error(t, err) + assert.ErrorIs(t, err, util.ErrAlreadyExist) +} + +func testGetBadgeUsers(t *testing.T) { + // Create a test badge + badge := &user_model.Badge{ + Slug: "test-badge-users", + Description: "Test Badge", + ImageURL: "test.png", + } + assert.NoError(t, user_model.CreateBadge(t.Context(), badge)) + + // Create test users and assign badges + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + assert.NoError(t, user_model.AddUserBadge(t.Context(), user1, badge)) + assert.NoError(t, user_model.AddUserBadge(t.Context(), user2, badge)) + defer func() { + assert.NoError(t, user_model.RemoveUserBadge(t.Context(), user1, badge)) + assert.NoError(t, user_model.RemoveUserBadge(t.Context(), user2, badge)) + }() + + // Test getting users with pagination + opts := &user_model.GetBadgeUsersOptions{ + BadgeSlug: badge.Slug, + ListOptions: db.ListOptions{ + Page: 1, + PageSize: 1, + }, + } + + users, count, err := user_model.GetBadgeUsers(t.Context(), opts) + assert.NoError(t, err) + assert.EqualValues(t, 2, count) + assert.Len(t, users, 1) + + // Test second page + opts.Page = 2 + users, count, err = user_model.GetBadgeUsers(t.Context(), opts) + assert.NoError(t, err) + assert.EqualValues(t, 2, count) + assert.Len(t, users, 1) + + // Test with non-existent badge + opts.BadgeSlug = "non-existent" + users, count, err = user_model.GetBadgeUsers(t.Context(), opts) + assert.NoError(t, err) + assert.EqualValues(t, 0, count) + assert.Empty(t, users) +} + +func testAddAndRemoveUserBadges(t *testing.T) { + badge1 := unittest.AssertExistsAndLoadBean(t, &user_model.Badge{ID: 1}) + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + // Add a badge to user and verify that it is returned in the list + assert.NoError(t, user_model.AddUserBadge(t.Context(), user1, badge1)) + badges, count, err := user_model.GetUserBadges(t.Context(), user1) + assert.Equal(t, int64(1), count) + assert.Equal(t, badge1.Slug, badges[0].Slug) + assert.NoError(t, err) + + // Confirm that it is impossible to duplicate the same badge + err = user_model.AddUserBadge(t.Context(), user1, badge1) + assert.Error(t, err) + assert.ErrorIs(t, err, util.ErrAlreadyExist) + + // Nothing happened to the existing badge + badges, count, err = user_model.GetUserBadges(t.Context(), user1) + assert.Equal(t, int64(1), count) + assert.Equal(t, badge1.Slug, badges[0].Slug) + assert.NoError(t, err) + + // Remove a badge from user and verify that it is no longer in the list + assert.NoError(t, user_model.RemoveUserBadge(t.Context(), user1, badge1)) + _, count, err = user_model.GetUserBadges(t.Context(), user1) + assert.Equal(t, int64(0), count) + assert.NoError(t, err) + + // Removing empty or missing badge selections should be a no-op. + assert.NoError(t, user_model.RemoveUserBadges(t.Context(), user1, nil)) + assert.NoError(t, user_model.RemoveUserBadges(t.Context(), user1, []*user_model.Badge{{Slug: "does-not-exist"}})) +} + +func testSearchBadgesOrderingAndKeyword(t *testing.T) { + createdBadges := []*user_model.Badge{ + {Slug: "badge-sort-b", Description: "Badge Sort B"}, + {Slug: "badge-sort-c", Description: "Badge Sort C"}, + {Slug: "badge-sort-a", Description: "Badge Sort A"}, + {Slug: "badge-sort-case", Description: "MiXeDCaSeKeyword"}, + } + for _, badge := range createdBadges { + assert.NoError(t, user_model.CreateBadge(t.Context(), badge)) + } + + opts := &user_model.SearchBadgeOptions{ + ListOptions: db.ListOptions{ListAll: true}, + Keyword: "badge-sort-", + OrderBy: db.SearchOrderBy("`badge`.id ASC"), + } + + oldestFirst, count, err := user_model.SearchBadges(t.Context(), opts) + assert.NoError(t, err) + assert.EqualValues(t, 4, count) + assert.Equal(t, []string{"badge-sort-b", "badge-sort-c", "badge-sort-a", "badge-sort-case"}, collectBadgeSlugs(oldestFirst)) + + opts.OrderBy = db.SearchOrderBy("`badge`.id DESC") + newestFirst, count, err := user_model.SearchBadges(t.Context(), opts) + assert.NoError(t, err) + assert.EqualValues(t, 4, count) + assert.Equal(t, []string{"badge-sort-case", "badge-sort-a", "badge-sort-c", "badge-sort-b"}, collectBadgeSlugs(newestFirst)) + + opts.OrderBy = db.SearchOrderBy("`badge`.slug ASC") + alpha, count, err := user_model.SearchBadges(t.Context(), opts) + assert.NoError(t, err) + assert.EqualValues(t, 4, count) + assert.Equal(t, []string{"badge-sort-a", "badge-sort-b", "badge-sort-c", "badge-sort-case"}, collectBadgeSlugs(alpha)) + + opts.OrderBy = db.SearchOrderBy("`badge`.slug DESC") + reverseAlpha, count, err := user_model.SearchBadges(t.Context(), opts) + assert.NoError(t, err) + assert.EqualValues(t, 4, count) + assert.Equal(t, []string{"badge-sort-case", "badge-sort-c", "badge-sort-b", "badge-sort-a"}, collectBadgeSlugs(reverseAlpha)) + + opts.Keyword = "mixedcasekeyword" + opts.OrderBy = db.SearchOrderBy("`badge`.slug ASC") + caseInsensitive, count, err := user_model.SearchBadges(t.Context(), opts) + assert.NoError(t, err) + assert.EqualValues(t, 1, count) + assert.Equal(t, []string{"badge-sort-case"}, collectBadgeSlugs(caseInsensitive)) +} + +func collectBadgeSlugs(badges []*user_model.Badge) []string { + slugs := make([]string, 0, len(badges)) + for _, badge := range badges { + slugs = append(slugs, badge.Slug) + } + return slugs +} diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go index d28726e899..e8bf70ec31 100644 --- a/modules/actions/artifacts.go +++ b/modules/actions/artifacts.go @@ -20,7 +20,7 @@ func IsArtifactV4(art *actions_model.ActionArtifact) bool { func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.ActionArtifact) (bool, error) { if setting.Actions.ArtifactStorage.ServeDirect() { - u, err := storage.ActionsArtifacts.URL(art.StoragePath, art.ArtifactPath, ctx.Req.Method, nil) + u, err := storage.ActionsArtifacts.ServeDirectURL(art.StoragePath, art.ArtifactPath, ctx.Req.Method, nil) if u != nil && err == nil { ctx.Redirect(u.String(), http.StatusFound) return true, nil diff --git a/modules/httplib/serve.go b/modules/httplib/serve.go index 2d66a86a8b..fc7edc36c4 100644 --- a/modules/httplib/serve.go +++ b/modules/httplib/serve.go @@ -112,21 +112,20 @@ func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, mineBuf []byt opts.ContentTypeCharset = strings.ToLower(charset) } - isSVG := sniffedType.IsSvgImage() - // serve types that can present a security risk with CSP - if isSVG { - w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox") - } else if sniffedType.IsPDF() { - // no sandbox attribute for pdf as it breaks rendering in at least safari. this + w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox") + + if sniffedType.IsPDF() { + // no sandbox attribute for PDF as it breaks rendering in at least safari. this // should generally be safe as scripts inside PDF can not escape the PDF document // see https://bugs.chromium.org/p/chromium/issues/detail?id=413851 for more discussion // HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") } + // TODO: UNIFY-CONTENT-DISPOSITION-FROM-STORAGE opts.Disposition = "inline" - if isSVG && !setting.UI.SVG.Enabled { + if sniffedType.IsSvgImage() && !setting.UI.SVG.Enabled { opts.Disposition = "attachment" } diff --git a/modules/packages/content_store.go b/modules/packages/content_store.go index 57974515e2..4c61233b5e 100644 --- a/modules/packages/content_store.go +++ b/modules/packages/content_store.go @@ -36,8 +36,8 @@ func (s *ContentStore) ShouldServeDirect() bool { return setting.Packages.Storage.ServeDirect() } -func (s *ContentStore) GetServeDirectURL(key BlobHash256Key, filename, method string, reqParams url.Values) (*url.URL, error) { - return s.store.URL(KeyToRelativePath(key), filename, method, reqParams) +func (s *ContentStore) GetServeDirectURL(key BlobHash256Key, filename, method string, reqParams *storage.ServeDirectOptions) (*url.URL, error) { + return s.store.ServeDirectURL(KeyToRelativePath(key), filename, method, reqParams) } // FIXME: Workaround to be removed in v1.20 diff --git a/modules/public/mime_types.go b/modules/public/mime_types.go index 32bdf3bfa2..fef85d77cb 100644 --- a/modules/public/mime_types.go +++ b/modules/public/mime_types.go @@ -3,9 +3,11 @@ package public -import "strings" +import ( + "strings" +) -// wellKnownMimeTypesLower comes from Golang's builtin mime package: `builtinTypesLower`, see the comment of detectWellKnownMimeType +// wellKnownMimeTypesLower comes from Golang's builtin mime package: `builtinTypesLower`, see the comment of DetectWellKnownMimeType var wellKnownMimeTypesLower = map[string]string{ ".avif": "image/avif", ".css": "text/css; charset=utf-8", @@ -28,13 +30,13 @@ var wellKnownMimeTypesLower = map[string]string{ ".txt": "text/plain; charset=utf-8", } -// detectWellKnownMimeType will return the mime-type for a well-known file ext name +// DetectWellKnownMimeType will return the mime-type for a well-known file ext name // The purpose of this function is to bypass the unstable behavior of Golang's mime.TypeByExtension // mime.TypeByExtension would use OS's mime-type config to overwrite the well-known types (see its document). // If the user's OS has incorrect mime-type config, it would make Gitea can not respond a correct Content-Type to browsers. // For example, if Gitea returns `text/plain` for a `.js` file, the browser couldn't run the JS due to security reasons. -// detectWellKnownMimeType makes the Content-Type for well-known files stable. -func detectWellKnownMimeType(ext string) string { +// DetectWellKnownMimeType makes the Content-Type for well-known files stable. +func DetectWellKnownMimeType(ext string) string { ext = strings.ToLower(ext) return wellKnownMimeTypesLower[ext] } diff --git a/modules/public/public.go b/modules/public/public.go index 3a5a76637e..004aad5f3b 100644 --- a/modules/public/public.go +++ b/modules/public/public.go @@ -51,9 +51,9 @@ func parseAcceptEncoding(val string) container.Set[string] { } // setWellKnownContentType will set the Content-Type if the file is a well-known type. -// See the comments of detectWellKnownMimeType +// See the comments of DetectWellKnownMimeType func setWellKnownContentType(w http.ResponseWriter, file string) { - mimeType := detectWellKnownMimeType(path.Ext(file)) + mimeType := DetectWellKnownMimeType(path.Ext(file)) if mimeType != "" { w.Header().Set("Content-Type", mimeType) } diff --git a/modules/storage/azureblob.go b/modules/storage/azureblob.go index e7297cec77..a273a7770d 100644 --- a/modules/storage/azureblob.go +++ b/modules/storage/azureblob.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "net/http" "net/url" "os" "path" @@ -246,16 +247,53 @@ func (a *AzureBlobStorage) Delete(path string) error { return convertAzureBlobErr(err) } -// URL gets the redirect URL to a file. The presigned link is valid for 5 minutes. -func (a *AzureBlobStorage) URL(path, name, _ string, reqParams url.Values) (*url.URL, error) { - blobClient := a.getBlobClient(path) +func (a *AzureBlobStorage) getSasURL(b *blob.Client, template sas.BlobSignatureValues) (string, error) { + urlParts, err := blob.ParseURL(b.URL()) + if err != nil { + return "", err + } - // TODO: OBJECT-STORAGE-CONTENT-TYPE: "browser inline rendering images/PDF" needs proper Content-Type header from storage - startTime := time.Now() - u, err := blobClient.GetSASURL(sas.BlobPermissions{ - Read: true, - }, time.Now().Add(5*time.Minute), &blob.GetSASURLOptions{ - StartTime: &startTime, + var t time.Time + if urlParts.Snapshot == "" { + t = time.Time{} + } else { + t, err = time.Parse(blob.SnapshotTimeFormat, urlParts.Snapshot) + if err != nil { + return "", err + } + } + + template.ContainerName = urlParts.ContainerName + template.BlobName = urlParts.BlobName + template.SnapshotTime = t + template.Version = sas.Version + + qps, err := template.SignWithSharedKey(a.credential) + if err != nil { + return "", err + } + + endpoint := b.URL() + "?" + qps.Encode() + + return endpoint, nil +} + +func (a *AzureBlobStorage) ServeDirectURL(storePath, name, method string, reqParams *ServeDirectOptions) (*url.URL, error) { + blobClient := a.getBlobClient(storePath) + + startTime := time.Now().UTC() + + param := prepareServeDirectOptions(reqParams, name) + + u, err := a.getSasURL(blobClient, sas.BlobSignatureValues{ + Permissions: (&sas.BlobPermissions{ + Read: method == http.MethodGet || method == http.MethodHead, + Write: method == http.MethodPut, + }).String(), + StartTime: startTime, + ExpiryTime: startTime.Add(5 * time.Minute), + ContentDisposition: param.ContentDisposition, + ContentType: param.ContentType, }) if err != nil { return nil, convertAzureBlobErr(err) diff --git a/modules/storage/azureblob_test.go b/modules/storage/azureblob_test.go index b3791b4916..b5d5d0fecc 100644 --- a/modules/storage/azureblob_test.go +++ b/modules/storage/azureblob_test.go @@ -14,12 +14,13 @@ import ( "github.com/stretchr/testify/assert" ) -func TestAzureBlobStorageIterator(t *testing.T) { +func TestAzureBlobStorage(t *testing.T) { if os.Getenv("CI") == "" { t.Skip("azureBlobStorage not present outside of CI") return } - testStorageIterator(t, setting.AzureBlobStorageType, &setting.Storage{ + storageType := setting.AzureBlobStorageType + config := &setting.Storage{ AzureBlobConfig: setting.AzureBlobStorageConfig{ // https://learn.microsoft.com/azure/storage/common/storage-use-azurite?tabs=visual-studio-code#ip-style-url Endpoint: "http://devstoreaccount1.azurite.local:10000", @@ -28,7 +29,25 @@ func TestAzureBlobStorageIterator(t *testing.T) { AccountKey: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==", Container: "test", }, - }) + } + table := []struct { + name string + test func(t *testing.T, typStr Type, cfg *setting.Storage) + }{ + { + name: "iterator", + test: testStorageIterator, + }, + { + name: "testBlobStorageURLContentTypeAndDisposition", + test: testBlobStorageURLContentTypeAndDisposition, + }, + } + for _, entry := range table { + t.Run(entry.name, func(t *testing.T) { + entry.test(t, storageType, config) + }) + } } func TestAzureBlobStoragePath(t *testing.T) { diff --git a/modules/storage/helper.go b/modules/storage/helper.go index f6c3d5eebb..30d96a33ad 100644 --- a/modules/storage/helper.go +++ b/modules/storage/helper.go @@ -30,7 +30,7 @@ func (s discardStorage) Delete(_ string) error { return fmt.Errorf("%s", s) } -func (s discardStorage) URL(_, _, _ string, _ url.Values) (*url.URL, error) { +func (s discardStorage) ServeDirectURL(_, _, _ string, _ *ServeDirectOptions) (*url.URL, error) { return nil, fmt.Errorf("%s", s) } diff --git a/modules/storage/helper_test.go b/modules/storage/helper_test.go index 3cba1e13c0..ae64c8aca4 100644 --- a/modules/storage/helper_test.go +++ b/modules/storage/helper_test.go @@ -37,7 +37,7 @@ func Test_discardStorage(t *testing.T) { assert.Error(t, err, string(tt)) } { - got, err := tt.URL("path", "name", "GET", nil) + got, err := tt.ServeDirectURL("path", "name", "GET", nil) assert.Nil(t, got) assert.Errorf(t, err, string(tt)) } diff --git a/modules/storage/local.go b/modules/storage/local.go index 5ea6f055ce..04e3e05f95 100644 --- a/modules/storage/local.go +++ b/modules/storage/local.go @@ -133,8 +133,7 @@ func (l *LocalStorage) Delete(path string) error { return err } -// URL gets the redirect URL to a file -func (l *LocalStorage) URL(path, name, _ string, reqParams url.Values) (*url.URL, error) { +func (l *LocalStorage) ServeDirectURL(path, name, _ string, reqParams *ServeDirectOptions) (*url.URL, error) { return nil, ErrURLNotSupported } diff --git a/modules/storage/minio.go b/modules/storage/minio.go index 6993ac2d92..1355280f36 100644 --- a/modules/storage/minio.go +++ b/modules/storage/minio.go @@ -278,37 +278,16 @@ func (m *MinioStorage) Delete(path string) error { return convertMinioErr(err) } -// URL gets the redirect URL to a file. The presigned link is valid for 5 minutes. -func (m *MinioStorage) URL(storePath, name, method string, serveDirectReqParams url.Values) (*url.URL, error) { - // copy serveDirectReqParams - reqParams, err := url.ParseQuery(serveDirectReqParams.Encode()) - if err != nil { - return nil, err - } +func (m *MinioStorage) ServeDirectURL(storePath, name, method string, opt *ServeDirectOptions) (*url.URL, error) { + reqParams := url.Values{} - // Here we might not know the real filename, and it's quite inefficient to detect the mine type by pre-fetching the object head. - // So we just do a quick detection by extension name, at least if works for the "View Raw File" for an LFS file on the Web UI. - // Detect content type by extension name, only support the well-known safe types for inline rendering. - // TODO: OBJECT-STORAGE-CONTENT-TYPE: need a complete solution and refactor for Azure in the future - ext := path.Ext(name) - inlineExtMimeTypes := map[string]string{ - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".avif": "image/avif", - // ATTENTION! Don't support unsafe types like HTML/SVG due to security concerns: they can contain JS code, and maybe they need proper Content-Security-Policy - // HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context, it seems fine to render it inline - ".pdf": "application/pdf", - - // TODO: refactor with "modules/public/mime_types.go", for example: "DetectWellKnownSafeInlineMimeType" + param := prepareServeDirectOptions(opt, name) + // minio does not ignore empty params + if param.ContentType != "" { + reqParams.Set("response-content-type", param.ContentType) } - if mimeType, ok := inlineExtMimeTypes[ext]; ok { - reqParams.Set("response-content-type", mimeType) - reqParams.Set("response-content-disposition", "inline") - } else { - reqParams.Set("response-content-disposition", fmt.Sprintf(`attachment; filename="%s"`, quoteEscaper.Replace(name))) + if param.ContentDisposition != "" { + reqParams.Set("response-content-disposition", param.ContentDisposition) } expires := 5 * time.Minute @@ -323,6 +302,7 @@ func (m *MinioStorage) URL(storePath, name, method string, serveDirectReqParams // IterateObjects iterates across the objects in the miniostorage func (m *MinioStorage) IterateObjects(dirName string, fn func(path string, obj Object) error) error { opts := minio.GetObjectOptions{} + // FIXME: this loop is not right and causes resource leaking, see the comment of ListObjects for mObjInfo := range m.client.ListObjects(m.ctx, m.bucket, minio.ListObjectsOptions{ Prefix: m.buildMinioDirPrefix(dirName), Recursive: true, diff --git a/modules/storage/minio_test.go b/modules/storage/minio_test.go index 2726d765dd..5c15ee1ed6 100644 --- a/modules/storage/minio_test.go +++ b/modules/storage/minio_test.go @@ -16,12 +16,13 @@ import ( "github.com/stretchr/testify/assert" ) -func TestMinioStorageIterator(t *testing.T) { +func TestMinioStorage(t *testing.T) { if os.Getenv("CI") == "" { t.Skip("minioStorage not present outside of CI") return } - testStorageIterator(t, setting.MinioStorageType, &setting.Storage{ + storageType := setting.MinioStorageType + config := &setting.Storage{ MinioConfig: setting.MinioStorageConfig{ Endpoint: "minio:9000", AccessKeyID: "123456", @@ -29,7 +30,25 @@ func TestMinioStorageIterator(t *testing.T) { Bucket: "gitea", Location: "us-east-1", }, - }) + } + table := []struct { + name string + test func(t *testing.T, typStr Type, cfg *setting.Storage) + }{ + { + name: "iterator", + test: testStorageIterator, + }, + { + name: "testBlobStorageURLContentTypeAndDisposition", + test: testBlobStorageURLContentTypeAndDisposition, + }, + } + for _, entry := range table { + t.Run(entry.name, func(t *testing.T) { + entry.test(t, storageType, config) + }) + } } func TestMinioStoragePath(t *testing.T) { diff --git a/modules/storage/storage.go b/modules/storage/storage.go index 74d0cd47c8..2491c77a3e 100644 --- a/modules/storage/storage.go +++ b/modules/storage/storage.go @@ -10,8 +10,10 @@ import ( "io" "net/url" "os" + "path" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/setting" ) @@ -56,6 +58,38 @@ type Object interface { Stat() (os.FileInfo, error) } +// ServeDirectOptions customizes HTTP headers for a generated signed URL. +type ServeDirectOptions struct { + // Overrides the automatically detected MIME type. + ContentType string + // Overrides the default Content-Disposition header, which is `inline; filename="name"`. + ContentDisposition string +} + +// Safe defaults are applied only when not explicitly overridden by the caller. +func prepareServeDirectOptions(optsOptional *ServeDirectOptions, name string) (ret ServeDirectOptions) { + // Here we might not know the real filename, and it's quite inefficient to detect the MIME type by pre-fetching the object head. + // So we just do a quick detection by extension name, at least it works for the "View Raw File" for an LFS file on the Web UI. + // TODO: OBJECT-STORAGE-CONTENT-TYPE: need a complete solution and refactor for Azure in the future + + if optsOptional != nil { + ret = *optsOptional + } + + // TODO: UNIFY-CONTENT-DISPOSITION-FROM-STORAGE + if ret.ContentType == "" { + ext := path.Ext(name) + ret.ContentType = public.DetectWellKnownMimeType(ext) + } + if ret.ContentDisposition == "" { + // When using ServeDirect, the URL is from the object storage's web server, + // it is not the same origin as Gitea server, so it should be safe enough to use "inline" to render the content directly. + // If a browser doesn't support the content type to be displayed inline, browser will download with the filename. + ret.ContentDisposition = fmt.Sprintf(`inline; filename="%s"`, quoteEscaper.Replace(name)) + } + return ret +} + // ObjectStorage represents an object storage to handle a bucket and files type ObjectStorage interface { Open(path string) (Object, error) @@ -67,7 +101,15 @@ type ObjectStorage interface { Stat(path string) (os.FileInfo, error) Delete(path string) error - URL(path, name, method string, reqParams url.Values) (*url.URL, error) + + // ServeDirectURL generates a "serve-direct" URL for the specified blob storage file, + // end user (browser) will use this URL to access the file directly from the object storage, bypassing Gitea server. + // Usually the link is time-limited (a few minutes) and contains a signature to ensure security. + // The generated URL must NOT use the same origin as Gitea server, otherwise it will cause security issues. + // * method defines which HTTP method is permitted for certain storage providers (e.g., MinIO). + // * opt allows customizing the Content-Type and Content-Disposition headers. + // TODO: need to merge "ServeDirect()" check into this function, avoid duplicate code and potential inconsistency. + ServeDirectURL(path, name, method string, opt *ServeDirectOptions) (*url.URL, error) // IterateObjects calls the iterator function for each object in the storage with the given path as prefix // The "fullPath" argument in callback is the full path in this storage. @@ -136,7 +178,7 @@ var ( // Actions represents actions storage Actions ObjectStorage = uninitializedStorage - // Actions Artifacts represents actions artifacts storage + // ActionsArtifacts Artifacts represents actions artifacts storage ActionsArtifacts ObjectStorage = uninitializedStorage ) diff --git a/modules/storage/storage_test.go b/modules/storage/storage_test.go index 08f274e74b..4156723c36 100644 --- a/modules/storage/storage_test.go +++ b/modules/storage/storage_test.go @@ -4,12 +4,14 @@ package storage import ( + "net/http" "strings" "testing" "code.gitea.io/gitea/modules/setting" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func testStorageIterator(t *testing.T, typStr Type, cfg *setting.Storage) { @@ -50,3 +52,55 @@ func testStorageIterator(t *testing.T, typStr Type, cfg *setting.Storage) { assert.Len(t, expected, count) } } + +func testSingleBlobStorageURLContentTypeAndDisposition(t *testing.T, s ObjectStorage, path, name string, expected ServeDirectOptions, reqParams *ServeDirectOptions) { + u, err := s.ServeDirectURL(path, name, http.MethodGet, reqParams) + require.NoError(t, err) + resp, err := http.Get(u.String()) + require.NoError(t, err) + defer resp.Body.Close() + if expected.ContentType != "" { + assert.Equal(t, expected.ContentType, resp.Header.Get("Content-Type")) + } + if expected.ContentDisposition != "" { + assert.Equal(t, expected.ContentDisposition, resp.Header.Get("Content-Disposition")) + } +} + +func testBlobStorageURLContentTypeAndDisposition(t *testing.T, typStr Type, cfg *setting.Storage) { + s, err := NewStorage(typStr, cfg) + assert.NoError(t, err) + + data := "Q2xTckt6Y1hDOWh0" // arbitrary test content; specific value is irrelevant to this test + testfilename := "test.txt" // arbitrary file name; specific value is irrelevant to this test + _, err = s.Save(testfilename, strings.NewReader(data), int64(len(data))) + assert.NoError(t, err) + + testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.txt", ServeDirectOptions{ + ContentType: "text/plain; charset=utf-8", + ContentDisposition: `inline; filename="test.txt"`, + }, nil) + + testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.pdf", ServeDirectOptions{ + ContentType: "application/pdf", + ContentDisposition: `inline; filename="test.pdf"`, + }, nil) + + testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.wasm", ServeDirectOptions{ + ContentDisposition: `inline; filename="test.wasm"`, + }, nil) + + testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.wasm", ServeDirectOptions{ + ContentDisposition: `inline; filename="test.wasm"`, + }, &ServeDirectOptions{}) + + testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.txt", ServeDirectOptions{ + ContentType: "application/octet-stream", + ContentDisposition: `inline; filename="test.xml"`, + }, &ServeDirectOptions{ + ContentType: "application/octet-stream", + ContentDisposition: `inline; filename="test.xml"`, + }) + + assert.NoError(t, s.Delete(testfilename)) +} diff --git a/modules/validation/binding.go b/modules/validation/binding.go index 3f40e5ec97..86364e1173 100644 --- a/modules/validation/binding.go +++ b/modules/validation/binding.go @@ -27,6 +27,8 @@ const ( ErrUsername = "UsernameError" // ErrInvalidGroupTeamMap is returned when a group team mapping is invalid ErrInvalidGroupTeamMap = "InvalidGroupTeamMap" + // ErrInvalidBadgeSlug is returned when a badge slug is invalid + ErrInvalidBadgeSlug = "InvalidBadgeSlug" ) // AddBindingRules adds additional binding rules @@ -40,6 +42,7 @@ func AddBindingRules() { addGlobOrRegexPatternRule() addUsernamePatternRule() addValidGroupTeamMapRule() + addSlugPatternRule() } func addGitRefNameBindingRule() { @@ -123,6 +126,22 @@ func addValidSiteURLBindingRule() { }) } +func addSlugPatternRule() { + binding.AddRule(&binding.Rule{ + IsMatch: func(rule string) bool { + return rule == "BadgeSlug" + }, + IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) { + str := fmt.Sprintf("%v", val) + if !IsValidBadgeSlug(str) { + errs.Add([]string{name}, ErrInvalidBadgeSlug, "invalid badge slug") + return false, errs + } + return true, errs + }, + }) +} + func addGlobPatternRule() { binding.AddRule(&binding.Rule{ IsMatch: func(rule string) bool { diff --git a/modules/validation/helpers.go b/modules/validation/helpers.go index 051330e439..ee05de74bd 100644 --- a/modules/validation/helpers.go +++ b/modules/validation/helpers.go @@ -16,16 +16,20 @@ import ( ) type globalVarsStruct struct { - externalTrackerRegex *regexp.Regexp - validUsernamePattern *regexp.Regexp - invalidUsernamePattern *regexp.Regexp + externalTrackerRegex *regexp.Regexp + validUsernamePattern *regexp.Regexp + invalidUsernamePattern *regexp.Regexp + validBadgeSlugPattern *regexp.Regexp + invalidBadgeSlugPattern *regexp.Regexp } var globalVars = sync.OnceValue(func() *globalVarsStruct { return &globalVarsStruct{ - externalTrackerRegex: regexp.MustCompile(`({?)(?:user|repo|index)+?(}?)`), - validUsernamePattern: regexp.MustCompile(`^[\da-zA-Z][-.\w]*$`), - invalidUsernamePattern: regexp.MustCompile(`[-._]{2,}|[-._]$`), // No consecutive or trailing non-alphanumeric chars + externalTrackerRegex: regexp.MustCompile(`({?)(?:user|repo|index)+?(}?)`), + validUsernamePattern: regexp.MustCompile(`^[\da-zA-Z][-.\w]*$`), + invalidUsernamePattern: regexp.MustCompile(`[-._]{2,}|[-._]$`), // No consecutive or trailing non-alphanumeric chars + validBadgeSlugPattern: regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`), + invalidBadgeSlugPattern: regexp.MustCompile(`[-._]{2,}|[-._]$`), } }) @@ -131,3 +135,8 @@ func IsValidUsername(name string) bool { vars := globalVars() return vars.validUsernamePattern.MatchString(name) && !vars.invalidUsernamePattern.MatchString(name) } + +func IsValidBadgeSlug(slug string) bool { + vars := globalVars() + return vars.validBadgeSlugPattern.MatchString(slug) && !vars.invalidBadgeSlugPattern.MatchString(slug) +} diff --git a/modules/validation/helpers_test.go b/modules/validation/helpers_test.go index 6a982965f6..75f73b97fc 100644 --- a/modules/validation/helpers_test.go +++ b/modules/validation/helpers_test.go @@ -186,3 +186,24 @@ func TestIsValidUsername(t *testing.T) { }) } } + +func TestIsValidBadgeSlug(t *testing.T) { + tests := []struct { + arg string + want bool + }{ + {arg: "badge-1", want: true}, + {arg: "badge.slug", want: true}, + {arg: "new", want: true}, + {arg: "Badge_1", want: true}, + {arg: "a..b", want: false}, + {arg: "a/b", want: false}, + {arg: "Awesome!", want: false}, + {arg: "Emoji 💯", want: false}, + } + for _, tt := range tests { + t.Run(tt.arg, func(t *testing.T) { + assert.Equalf(t, tt.want, IsValidBadgeSlug(tt.arg), "IsValidBadgeSlug(%v)", tt.arg) + }) + } +} diff --git a/modules/web/middleware/binding.go b/modules/web/middleware/binding.go index ee4eca976e..05047ad3bd 100644 --- a/modules/web/middleware/binding.go +++ b/modules/web/middleware/binding.go @@ -138,6 +138,8 @@ func Validate(errs binding.Errors, data map[string]any, f Form, l translation.Lo data["ErrorMsg"] = trName + l.TrString("form.username_error") case validation.ErrInvalidGroupTeamMap: data["ErrorMsg"] = trName + l.TrString("form.invalid_group_team_map_error", errs[0].Message) + case validation.ErrInvalidBadgeSlug: + data["ErrorMsg"] = trName + l.TrString("form.invalid_slug_error") default: msg := errs[0].Classification if msg != "" && errs[0].Message != "" { diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 80744253d8..de3cbd1a91 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -169,6 +169,7 @@ "search.exact_tooltip": "Include only results that match the exact search term", "search.repo_kind": "Search repos…", "search.user_kind": "Search users…", + "search.badge_kind": "Search badges…", "search.org_kind": "Search orgs…", "search.team_kind": "Search teams…", "search.code_kind": "Search code…", @@ -543,6 +544,7 @@ "form.glob_pattern_error": " glob pattern is invalid: %s.", "form.regex_pattern_error": " regex pattern is invalid: %s.", "form.username_error": " can only contain alphanumeric characters ('0-9','a-z','A-Z'), dash ('-'), underscore ('_') and dot ('.'). It cannot begin or end with non-alphanumeric characters, and consecutive non-alphanumeric characters are also forbidden.", + "form.invalid_slug_error": " is invalid.", "form.invalid_group_team_map_error": " mapping is invalid: %s", "form.unknown_error": "Unknown error:", "form.captcha_incorrect": "The CAPTCHA code is incorrect.", @@ -2858,6 +2860,30 @@ "admin.hooks": "Webhooks", "admin.integrations": "Integrations", "admin.authentication": "Authentication Sources", + "admin.badges": "Badges", + "admin.badges.badges_manage_panel": "Badge Management", + "admin.badges.details": "Badge Details", + "admin.badges.new_badge": "Create New Badge", + "admin.badges.slug": "Slug", + "admin.badges.slug_been_taken": "The slug is already taken.", + "admin.badges.description": "Description", + "admin.badges.image_url": "Image URL", + "admin.badges.new_success": "The badge \"%s\" has been created.", + "admin.badges.update_success": "The badge has been updated.", + "admin.badges.deletion_success": "The badge has been deleted.", + "admin.badges.edit_badge": "Edit Badge", + "admin.badges.update_badge": "Update Badge", + "admin.badges.delete_badge": "Delete Badge", + "admin.badges.delete_badge_desc": "Are you sure you want to permanently delete this badge?", + "admin.badges.users_with_badge": "Users with badge: %s", + "admin.badges.not_found": "Badge not found.", + "admin.badges.user_already_has": "User already has this badge.", + "admin.badges.user_add_success": "Badge assigned to user successfully.", + "admin.badges.user_remove_success": "Badge removed from user successfully.", + "admin.badges.manage_users": "Manage Users", + "admin.badges.add_user": "Add User", + "admin.badges.remove_user": "Remove User", + "admin.badges.delete_user_desc": "Are you sure you want to remove this user from the badge?", "admin.emails": "User Email Addresses", "admin.config": "Configuration", "admin.config_summary": "Summary", diff --git a/routers/api/actions/artifacts.go b/routers/api/actions/artifacts.go index d7e7203a85..76facd769f 100644 --- a/routers/api/actions/artifacts.go +++ b/routers/api/actions/artifacts.go @@ -428,7 +428,7 @@ func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) { for _, artifact := range artifacts { var downloadURL string if setting.Actions.ArtifactStorage.ServeDirect() { - u, err := ar.fs.URL(artifact.StoragePath, artifact.ArtifactName, ctx.Req.Method, nil) + u, err := ar.fs.ServeDirectURL(artifact.StoragePath, artifact.ArtifactName, ctx.Req.Method, nil) if err != nil && !errors.Is(err, storage.ErrURLNotSupported) { log.Error("Error getting serve direct url: %v", err) } diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index d208785f63..62605f2702 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -562,7 +562,8 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) { respData := GetSignedArtifactURLResponse{} if setting.Actions.ArtifactStorage.ServeDirect() { - u, err := storage.ActionsArtifacts.URL(artifact.StoragePath, artifact.ArtifactPath, ctx.Req.Method, nil) + // DO NOT USE the http POST method coming from the getSignedArtifactURL endpoint + u, err := storage.ActionsArtifacts.ServeDirectURL(artifact.StoragePath, artifact.ArtifactPath, http.MethodGet, nil) if u != nil && err == nil { respData.SignedUrl = u.String() } diff --git a/routers/api/packages/container/container.go b/routers/api/packages/container/container.go index a6512181e0..0726302d41 100644 --- a/routers/api/packages/container/container.go +++ b/routers/api/packages/container/container.go @@ -26,6 +26,7 @@ import ( packages_module "code.gitea.io/gitea/modules/packages" container_module "code.gitea.io/gitea/modules/packages/container" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/packages/helper" auth_service "code.gitea.io/gitea/services/auth" @@ -706,9 +707,9 @@ func DeleteManifest(ctx *context.Context) { } func serveBlob(ctx *context.Context, pfd *packages_model.PackageFileDescriptor) { - serveDirectReqParams := make(url.Values) - serveDirectReqParams.Set("response-content-type", pfd.Properties.GetByName(container_module.PropertyMediaType)) - s, u, _, err := packages_service.OpenBlobForDownload(ctx, pfd.File, pfd.Blob, ctx.Req.Method, serveDirectReqParams) + s, u, _, err := packages_service.OpenBlobForDownload(ctx, pfd.File, pfd.Blob, ctx.Req.Method, &storage.ServeDirectOptions{ + ContentType: pfd.Properties.GetByName(container_module.PropertyMediaType), + }) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go index e31bc24eef..d0596d778b 100644 --- a/routers/api/v1/repo/file.go +++ b/routers/api/v1/repo/file.go @@ -207,7 +207,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) { if setting.LFS.Storage.ServeDirect() { // If we have a signed url (S3, object storage), redirect to this directly. - u, err := storage.LFS.URL(pointer.RelativePath(), blob.Name(), ctx.Req.Method, nil) + u, err := storage.LFS.ServeDirectURL(pointer.RelativePath(), blob.Name(), ctx.Req.Method, nil) if u != nil && err == nil { ctx.Redirect(u.String()) return diff --git a/routers/web/admin/badges.go b/routers/web/admin/badges.go new file mode 100644 index 0000000000..8a5d3d3eb2 --- /dev/null +++ b/routers/web/admin/badges.go @@ -0,0 +1,317 @@ +// Copyright 2026 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "errors" + "net/http" + "net/url" + "strings" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +const ( + tplBadges templates.TplName = "admin/badge/list" + tplBadgeNew templates.TplName = "admin/badge/new" + tplBadgeView templates.TplName = "admin/badge/view" + tplBadgeEdit templates.TplName = "admin/badge/edit" + tplBadgeUsers templates.TplName = "admin/badge/users" +) + +// BadgeSearchDefaultAdminSort is the default sort type for admin view +const BadgeSearchDefaultAdminSort = "oldest" + +// Badges show all the badges +func Badges(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.badges") + ctx.Data["PageIsAdminBadges"] = true + + RenderBadgeSearch(ctx, &user_model.SearchBadgeOptions{ + ListOptions: db.ListOptions{ + Page: max(ctx.FormInt("page"), 1), + PageSize: setting.UI.Admin.UserPagingNum, + }, + }, tplBadges) +} + +// NewBadge render adding a new badge +func NewBadge(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.badges.new_badge") + ctx.Data["PageIsAdminBadges"] = true + + ctx.HTML(http.StatusOK, tplBadgeNew) +} + +// NewBadgePost response for adding a new badge +func NewBadgePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AdminCreateBadgeForm) + + if ctx.HasError() { + ctx.JSONError(ctx.GetErrMsg()) + return + } + + b := &user_model.Badge{ + Slug: form.Slug, + Description: form.Description, + ImageURL: form.ImageURL, + } + + if err := user_model.CreateBadge(ctx, b); err != nil { + if errors.Is(err, util.ErrAlreadyExist) { + ctx.JSONError(ctx.Tr("admin.badges.slug_been_taken")) + } else { + ctx.ServerError("CreateBadge", err) + } + return + } + + log.Trace("Badge created by admin (%s): %s", ctx.Doer.Name, b.Slug) + + ctx.Flash.Success(ctx.Tr("admin.badges.new_success", b.Slug)) + ctx.JSONRedirect(setting.AppSubURL + "/-/admin/badges/slug/" + url.PathEscape(b.Slug)) +} + +func prepareBadgeInfo(ctx *context.Context) *user_model.Badge { + b, err := user_model.GetBadge(ctx, ctx.PathParam("badge_slug")) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Redirect(setting.AppSubURL + "/-/admin/badges") + } else { + ctx.ServerError("GetBadge", err) + } + return nil + } + ctx.Data["Badge"] = b + return b +} + +func ViewBadge(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.badges.details") + ctx.Data["PageIsAdminBadges"] = true + + prepareBadgeInfo(ctx) + if ctx.Written() { + return + } + + badge := ctx.Data["Badge"].(*user_model.Badge) + opts := &user_model.GetBadgeUsersOptions{ + ListOptions: db.ListOptions{ + Page: 1, + PageSize: setting.UI.Admin.UserPagingNum, + }, + BadgeSlug: badge.Slug, + } + users, count, err := user_model.GetBadgeUsers(ctx, opts) + if err != nil { + ctx.ServerError("GetBadgeUsers", err) + return + } + ctx.Data["Users"] = users + ctx.Data["UsersTotal"] = int(count) + + ctx.HTML(http.StatusOK, tplBadgeView) +} + +// EditBadge show editing badge page +func EditBadge(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.badges.edit_badge") + ctx.Data["PageIsAdminBadges"] = true + prepareBadgeInfo(ctx) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, tplBadgeEdit) +} + +// EditBadgePost response for editing badge +func EditBadgePost(ctx *context.Context) { + b := prepareBadgeInfo(ctx) + if ctx.Written() { + return + } + + form := web.GetForm(ctx).(*forms.AdminEditBadgeForm) + if ctx.HasError() { + ctx.JSONError(ctx.GetErrMsg()) + return + } + + b.ImageURL = form.ImageURL + b.Description = form.Description + + if err := user_model.UpdateBadge(ctx, b); err != nil { + ctx.ServerError("UpdateBadge", err) + return + } + + log.Trace("Badge updated by admin (%s): %s", ctx.Doer.Name, b.Slug) + + ctx.Flash.Success(ctx.Tr("admin.badges.update_success")) + ctx.JSONRedirect(setting.AppSubURL + "/-/admin/badges/slug/" + url.PathEscape(ctx.PathParam("badge_slug"))) +} + +// DeleteBadge response for deleting a badge +func DeleteBadge(ctx *context.Context) { + b, err := user_model.GetBadge(ctx, ctx.PathParam("badge_slug")) + if err != nil { + ctx.ServerError("GetBadge", err) + return + } + + if err = user_model.DeleteBadge(ctx, b); err != nil { + ctx.ServerError("DeleteBadge", err) + return + } + + log.Trace("Badge deleted by admin (%s): %s", ctx.Doer.Name, b.Slug) + + ctx.Flash.Success(ctx.Tr("admin.badges.deletion_success")) + ctx.Redirect(setting.AppSubURL + "/-/admin/badges") +} + +func BadgeUsers(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.badges.users_with_badge", ctx.PathParam("badge_slug")) + ctx.Data["PageIsAdminBadges"] = true + + page := max(ctx.FormInt("page"), 1) + + badge := &user_model.Badge{Slug: ctx.PathParam("badge_slug")} + opts := &user_model.GetBadgeUsersOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: setting.UI.Admin.UserPagingNum, + }, + BadgeSlug: badge.Slug, + } + users, count, err := user_model.GetBadgeUsers(ctx, opts) + if err != nil { + ctx.ServerError("GetBadgeUsers", err) + return + } + + ctx.Data["Users"] = users + ctx.Data["Total"] = count + ctx.Data["Page"] = context.NewPagination(count, setting.UI.Admin.UserPagingNum, page, 5) + + ctx.HTML(http.StatusOK, tplBadgeUsers) +} + +// BadgeUsersPost response for actions for user badges +func BadgeUsersPost(ctx *context.Context) { + name := strings.ToLower(ctx.FormString("user")) + + u, err := user_model.GetUserByName(ctx, name) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Flash.Error(ctx.Tr("form.user_not_exist")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) + } else { + ctx.ServerError("GetUserByName", err) + } + return + } + + if err = user_model.AddUserBadge(ctx, u, &user_model.Badge{Slug: ctx.PathParam("badge_slug")}); err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Flash.Error(ctx.Tr("admin.badges.not_found")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) + } else if errors.Is(err, util.ErrAlreadyExist) { + ctx.Flash.Error(ctx.Tr("admin.badges.user_already_has")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) + } else { + ctx.ServerError("AddUserBadge", err) + } + return + } + + ctx.Flash.Success(ctx.Tr("admin.badges.user_add_success")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) +} + +// DeleteBadgeUser delete a badge from a user +func DeleteBadgeUser(ctx *context.Context) { + badgeUsersURL := setting.AppSubURL + "/-/admin/badges/slug/" + url.PathEscape(ctx.PathParam("badge_slug")) + "/users" + + user, err := user_model.GetUserByID(ctx, ctx.FormInt64("id")) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Flash.Error(ctx.Tr("form.user_not_exist")) + ctx.JSONRedirect(badgeUsersURL) + return + } else { + ctx.ServerError("GetUserByID", err) + return + } + } + if err := user_model.RemoveUserBadge(ctx, user, &user_model.Badge{Slug: ctx.PathParam("badge_slug")}); err == nil { + ctx.Flash.Success(ctx.Tr("admin.badges.user_remove_success")) + } else { + ctx.ServerError("RemoveUserBadge", err) + return + } + + ctx.JSONRedirect(badgeUsersURL) +} + +func RenderBadgeSearch(ctx *context.Context, opts *user_model.SearchBadgeOptions, tplName templates.TplName) { + var ( + badges []*user_model.Badge + count int64 + err error + orderBy db.SearchOrderBy + ) + + sortOrder := ctx.FormString("sort") + if sortOrder == "" { + sortOrder = BadgeSearchDefaultAdminSort + } + ctx.Data["SortType"] = sortOrder + + switch sortOrder { + case "newest": + orderBy = "`badge`.id DESC" + case "oldest": + orderBy = "`badge`.id ASC" + case "reversealphabetically": + orderBy = "`badge`.slug DESC" + case "alphabetically": + orderBy = "`badge`.slug ASC" + default: + // In case the sort type is invalid, keep admin default sorting. + ctx.Data["SortType"] = "oldest" + orderBy = "`badge`.id ASC" + } + + opts.Keyword = ctx.FormTrim("q") + opts.OrderBy = orderBy + if len(opts.Keyword) == 0 || isKeywordValid(opts.Keyword) { + badges, count, err = user_model.SearchBadges(ctx, opts) + if err != nil { + ctx.ServerError("SearchBadges", err) + return + } + } + + ctx.Data["Keyword"] = opts.Keyword + ctx.Data["Total"] = count + ctx.Data["Badges"] = badges + + pager := context.NewPagination(count, opts.PageSize, opts.Page, 5) + pager.AddParamFromRequest(ctx.Req) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplName) +} diff --git a/routers/web/base.go b/routers/web/base.go index db96432bed..6afcac62e2 100644 --- a/routers/web/base.go +++ b/routers/web/base.go @@ -69,7 +69,7 @@ func avatarStorageHandler(storageSetting *setting.Storage, prefix string, objSto // So in theory, it doesn't work with the non-existing avatar fallback, it just gets the URL and redirects to it. // Checking "stat" requires one more request to the storage, which is inefficient. // Workaround: disable "SERVE_DIRECT". Leave the problem to the future. - u, err := objStore.URL(avatarPath, path.Base(avatarPath), req.Method, nil) + u, err := objStore.ServeDirectURL(avatarPath, path.Base(avatarPath), req.Method, nil) if handleError(w, req, avatarPath, err) { return } diff --git a/routers/web/repo/attachment.go b/routers/web/repo/attachment.go index be711afe6d..19d533f362 100644 --- a/routers/web/repo/attachment.go +++ b/routers/web/repo/attachment.go @@ -179,7 +179,7 @@ func ServeAttachment(ctx *context.Context, uuid string) { if setting.Attachment.Storage.ServeDirect() { // If we have a signed url (S3, object storage), redirect to this directly. - u, err := storage.Attachments.URL(attach.RelativePath(), attach.Name, ctx.Req.Method, nil) + u, err := storage.Attachments.ServeDirectURL(attach.RelativePath(), attach.Name, ctx.Req.Method, nil) if u != nil && err == nil { ctx.Redirect(u.String()) diff --git a/routers/web/repo/download.go b/routers/web/repo/download.go index 9412d2045d..073d3d7420 100644 --- a/routers/web/repo/download.go +++ b/routers/web/repo/download.go @@ -54,7 +54,7 @@ func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob, lastModified *time.Tim if setting.LFS.Storage.ServeDirect() { // If we have a signed url (S3, object storage, blob storage), redirect to this directly. - u, err := storage.LFS.URL(pointer.RelativePath(), blob.Name(), ctx.Req.Method, nil) + u, err := storage.LFS.ServeDirectURL(pointer.RelativePath(), blob.Name(), ctx.Req.Method, nil) if u != nil && err == nil { ctx.Redirect(u.String()) return nil diff --git a/routers/web/web.go b/routers/web/web.go index 6893e380df..a76a68ed80 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -788,6 +788,16 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Post("/{userid}/avatar/delete", admin.DeleteAvatar) }) + m.Group("/badges", func() { + m.Get("", admin.Badges) + m.Combo("/new").Get(admin.NewBadge).Post(web.Bind(forms.AdminCreateBadgeForm{}), admin.NewBadgePost) + m.Get("/slug/{badge_slug}", admin.ViewBadge) + m.Combo("/slug/{badge_slug}/edit").Get(admin.EditBadge).Post(web.Bind(forms.AdminEditBadgeForm{}), admin.EditBadgePost) + m.Post("/slug/{badge_slug}/delete", admin.DeleteBadge) + m.Combo("/slug/{badge_slug}/users").Get(admin.BadgeUsers).Post(admin.BadgeUsersPost) + m.Post("/slug/{badge_slug}/users/delete", admin.DeleteBadgeUser) + }) + m.Group("/emails", func() { m.Get("", admin.Emails) m.Post("/activate", admin.ActivateEmail) diff --git a/services/forms/admin.go b/services/forms/admin.go index 81276f8f46..c90ddf7d54 100644 --- a/services/forms/admin.go +++ b/services/forms/admin.go @@ -25,6 +25,31 @@ type AdminCreateUserForm struct { Visibility structs.VisibleType } +// AdminCreateBadgeForm form for admin to create badge +type AdminCreateBadgeForm struct { + Slug string `binding:"Required;BadgeSlug" locale:"admin.badges.slug"` + Description string `binding:"Required" locale:"admin.badges.description"` + ImageURL string `binding:"ValidUrl" locale:"admin.badges.image_url"` +} + +// AdminEditBadgeForm form for admin to edit badge +type AdminEditBadgeForm struct { + Description string `binding:"Required" locale:"admin.badges.description"` + ImageURL string `binding:"ValidUrl" locale:"admin.badges.image_url"` +} + +// Validate validates form fields +func (f *AdminCreateBadgeForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetValidateContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} + +// Validate validates form fields +func (f *AdminEditBadgeForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetValidateContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} + // Validate validates form fields func (f *AdminCreateUserForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { ctx := context.GetValidateContext(req) diff --git a/services/lfs/server.go b/services/lfs/server.go index 10b4dba222..fc09eb58ca 100644 --- a/services/lfs/server.go +++ b/services/lfs/server.go @@ -42,7 +42,6 @@ type requestContext struct { User string Repo string Authorization string - Method string RepoGitURL string } @@ -427,7 +426,6 @@ func getRequestContext(ctx *context.Context) *requestContext { User: ownerName, Repo: repoName, Authorization: ctx.Req.Header.Get("Authorization"), - Method: ctx.Req.Method, RepoGitURL: httplib.GuessCurrentAppURL(ctx) + url.PathEscape(ownerName) + "/" + url.PathEscape(repoName+".git"), } } @@ -490,7 +488,8 @@ func buildObjectResponse(rc *requestContext, pointer lfs_module.Pointer, downloa var link *lfs_module.Link if setting.LFS.Storage.ServeDirect() { // If we have a signed url (S3, object storage), redirect to this directly. - u, err := storage.LFS.URL(pointer.RelativePath(), pointer.Oid, rc.Method, nil) + // DO NOT USE the http POST method coming from the lfs batch endpoint + u, err := storage.LFS.ServeDirectURL(pointer.RelativePath(), pointer.Oid, http.MethodGet, nil) if u != nil && err == nil { link = lfs_module.NewLink(u.String()) // Presigned url does not need the Authorization header } diff --git a/services/packages/packages.go b/services/packages/packages.go index 22b26b6563..3b4e11e041 100644 --- a/services/packages/packages.go +++ b/services/packages/packages.go @@ -599,7 +599,7 @@ func OpenBlobStream(pb *packages_model.PackageBlob) (io.ReadSeekCloser, error) { // OpenBlobForDownload returns the content of the specific package blob and increases the download counter. // If the storage supports direct serving and it's enabled, only the direct serving url is returned. -func OpenBlobForDownload(ctx context.Context, pf *packages_model.PackageFile, pb *packages_model.PackageBlob, method string, serveDirectReqParams url.Values) (io.ReadSeekCloser, *url.URL, *packages_model.PackageFile, error) { +func OpenBlobForDownload(ctx context.Context, pf *packages_model.PackageFile, pb *packages_model.PackageBlob, method string, serveDirectReqParams *storage.ServeDirectOptions) (io.ReadSeekCloser, *url.URL, *packages_model.PackageFile, error) { key := packages_module.BlobHash256Key(pb.HashSHA256) cs := packages_module.NewContentStore() diff --git a/services/repository/archiver/archiver.go b/services/repository/archiver/archiver.go index 07214d0bfa..1d28e00655 100644 --- a/services/repository/archiver/archiver.go +++ b/services/repository/archiver/archiver.go @@ -346,7 +346,7 @@ func ServeRepoArchive(ctx *gitea_context.Base, archiveReq *ArchiveRequest) error rPath := archiver.RelativePath() if setting.RepoArchive.Storage.ServeDirect() { // If we have a signed url (S3, object storage), redirect to this directly. - u, err := storage.RepoArchives.URL(rPath, downloadName, ctx.Req.Method, nil) + u, err := storage.RepoArchives.ServeDirectURL(rPath, downloadName, ctx.Req.Method, nil) if u != nil && err == nil { ctx.Redirect(u.String()) return nil diff --git a/templates/admin/badge/edit.tmpl b/templates/admin/badge/edit.tmpl new file mode 100644 index 0000000000..f2cdc4ad44 --- /dev/null +++ b/templates/admin/badge/edit.tmpl @@ -0,0 +1,44 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin edit badge")}} +
+

+ {{ctx.Locale.Tr "admin.badges.edit_badge"}} +

+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ + +
+
+
+
+ + + +{{template "admin/layout_footer" .}} diff --git a/templates/admin/badge/list.tmpl b/templates/admin/badge/list.tmpl new file mode 100644 index 0000000000..3020b7b25a --- /dev/null +++ b/templates/admin/badge/list.tmpl @@ -0,0 +1,67 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin badge")}} +
+

+ {{ctx.Locale.Tr "admin.badges.badges_manage_panel"}} ({{ctx.Locale.Tr "admin.total" .Total}}) + +

+
+
+
+ {{template "shared/search/combo" dict "Value" .Keyword "Placeholder" (ctx.Locale.Tr "search.badge_kind")}} +
+ + +
+
+
+ + + + + + + + + + + {{range .Badges}} + + + + + + + {{end}} + +
ID{{SortArrow "oldest" "newest" .SortType false}} + {{ctx.Locale.Tr "admin.badges.slug"}} + {{SortArrow "alphabetically" "reversealphabetically" $.SortType true}} + {{ctx.Locale.Tr "admin.badges.description"}}
{{.ID}} + {{.Slug}} + {{.Description}} + +
+
+ + {{template "base/paginate" .}} +
+{{template "admin/layout_footer" .}} diff --git a/templates/admin/badge/new.tmpl b/templates/admin/badge/new.tmpl new file mode 100644 index 0000000000..5b67bed314 --- /dev/null +++ b/templates/admin/badge/new.tmpl @@ -0,0 +1,26 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin new badge")}} +
+

+ {{ctx.Locale.Tr "admin.badges.new_badge"}} +

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+{{template "admin/layout_footer" .}} diff --git a/templates/admin/badge/users.tmpl b/templates/admin/badge/users.tmpl new file mode 100644 index 0000000000..97d332010f --- /dev/null +++ b/templates/admin/badge/users.tmpl @@ -0,0 +1,40 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin badge")}} +
+

+ {{.Title}} +

+
+
+ + +
+
+ {{if .Users}} +
+
+ {{range .Users}} +
+ +
+
+ {{template "shared/user/name" .}} +
+
+ +
+ {{end}} +
+
+ {{end}} + {{template "base/paginate" .}} +
+ +{{template "admin/layout_footer" .}} diff --git a/templates/admin/badge/view.tmpl b/templates/admin/badge/view.tmpl new file mode 100644 index 0000000000..efd31f4c41 --- /dev/null +++ b/templates/admin/badge/view.tmpl @@ -0,0 +1,44 @@ +{{template "admin/layout_head" (dict "ctxData" .)}} + +
+
+
+

+ {{.Title}} + +

+
+
+
+ {{if .Badge.ImageURL}} +
+ {{.Badge.Description}} +
+ {{end}} +
+
+ {{.Badge.Slug}} +
+
+ {{.Badge.Description}} +
+
+
+
+
+
+
+

+ {{ctx.Locale.Tr "explore.users"}} ({{.UsersTotal}}) + +

+
+ {{template "explore/user_list" .}} +
+
+ +{{template "admin/layout_footer" .}} diff --git a/templates/admin/navbar.tmpl b/templates/admin/navbar.tmpl index 72584ec799..ce3048ed9f 100644 --- a/templates/admin/navbar.tmpl +++ b/templates/admin/navbar.tmpl @@ -13,7 +13,7 @@ -
+
{{ctx.Locale.Tr "admin.identity_access"}} {{end}} {{if and .IsSigned (ne .SignedUserID .ContextUser.ID)}} diff --git a/web_src/css/user.css b/web_src/css/user.css index a9b283b504..8685fdf914 100644 --- a/web_src/css/user.css +++ b/web_src/css/user.css @@ -92,15 +92,31 @@ } .user-badges { - display: grid; - grid-template-columns: repeat(auto-fill, 64px); - gap: 2px; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0; + min-width: 0; +} + +.user-badge-item { + display: inline-flex; + flex: 0 0 auto; + min-width: max-content; } .user-badges img { object-fit: contain; } +.user-badge-chip { + max-width: none !important; + overflow: visible !important; + text-overflow: clip !important; + white-space: nowrap; + min-width: max-content; +} + #readme_profile { padding: 1em 2em; border-radius: var(--border-radius);