mirror of
https://github.com/go-gitea/gitea.git
synced 2026-05-11 11:25:42 +02:00
Merge branch 'main' into lunny/api_code_search
This commit is contained in:
commit
07547a1931
@ -64,3 +64,4 @@ metiftikci <metiftikci@hotmail.com> (@metiftikci)
|
||||
Christopher Homberger <christopher.homberger@web.de> (@ChristopherHX)
|
||||
Tobias Balle-Petersen <tobiasbp@gmail.com> (@tobiasbp)
|
||||
TheFox <thefox0x7@gmail.com> (@TheFox0x7)
|
||||
Nicolas <bircni@icloud.com> (@bircni)
|
||||
5
models/fixtures/badge.yml
Normal file
5
models/fixtures/badge.yml
Normal file
@ -0,0 +1,5 @@
|
||||
-
|
||||
id: 1
|
||||
slug: badge1
|
||||
description: just a test badge
|
||||
image_url: badge1.png
|
||||
@ -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
|
||||
}
|
||||
|
||||
62
models/migrations/v1_26/v329.go
Normal file
62
models/migrations/v1_26/v329.go
Normal file
@ -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))
|
||||
}
|
||||
85
models/migrations/v1_26/v329_test.go
Normal file
85
models/migrations/v1_26/v329_test.go
Normal file
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
185
models/user/badge_test.go
Normal file
185
models/user/badge_test.go
Normal file
@ -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
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 != "" {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
317
routers/web/admin/badges.go
Normal file
317
routers/web/admin/badges.go
Normal file
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
44
templates/admin/badge/edit.tmpl
Normal file
44
templates/admin/badge/edit.tmpl
Normal file
@ -0,0 +1,44 @@
|
||||
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin edit badge")}}
|
||||
<div class="admin-setting-content">
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "admin.badges.edit_badge"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<form class="ui form form-fetch-action" action="./edit" method="post">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "admin.badges.slug"}}</label>
|
||||
<input value="{{.Badge.Slug}}" readonly>
|
||||
</div>
|
||||
<div class="field {{if .Err_Description}}error{{end}}">
|
||||
<label>{{ctx.Locale.Tr "admin.badges.description"}}</label>
|
||||
<textarea name="description" rows="2">{{.Badge.Description}}</textarea>
|
||||
</div>
|
||||
<div class="field {{if .Err_ImageURL}}error{{end}}">
|
||||
<label>{{ctx.Locale.Tr "admin.badges.image_url"}}</label>
|
||||
<input type="url" name="image_url" value="{{.Badge.ImageURL}}">
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="field">
|
||||
<button class="ui primary button">{{ctx.Locale.Tr "admin.badges.update_badge"}}</button>
|
||||
<button class="ui red button show-modal" data-modal="#delete-badge-modal">{{ctx.Locale.Tr "admin.badges.delete_badge"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui g-modal-confirm modal" id="delete-badge-modal">
|
||||
<div class="header">
|
||||
{{svg "octicon-trash"}}
|
||||
{{ctx.Locale.Tr "admin.badges.delete_badge"}}
|
||||
</div>
|
||||
<form class="ui form" method="post" action="./delete">
|
||||
<div class="content">
|
||||
<p>{{ctx.Locale.Tr "admin.badges.delete_badge_desc"}}</p>
|
||||
</div>
|
||||
{{template "base/modal_actions_confirm" .}}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{template "admin/layout_footer" .}}
|
||||
67
templates/admin/badge/list.tmpl
Normal file
67
templates/admin/badge/list.tmpl
Normal file
@ -0,0 +1,67 @@
|
||||
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin badge")}}
|
||||
<div class="admin-setting-content">
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "admin.badges.badges_manage_panel"}} ({{ctx.Locale.Tr "admin.total" .Total}})
|
||||
<div class="ui right">
|
||||
<a class="ui primary tiny button" href="{{AppSubUrl}}/-/admin/badges/new">{{ctx.Locale.Tr "admin.badges.new_badge"}}</a>
|
||||
</div>
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<form class="ui form ignore-dirty flex-text-block" id="user-list-search-form">
|
||||
<div class="tw-flex-1">
|
||||
{{template "shared/search/combo" dict "Value" .Keyword "Placeholder" (ctx.Locale.Tr "search.badge_kind")}}
|
||||
</div>
|
||||
<!-- Right Menu -->
|
||||
<div class="ui secondary menu tw-m-0">
|
||||
<!-- Sort Menu Item -->
|
||||
<div class="ui dropdown type jump item">
|
||||
<span class="text">
|
||||
{{ctx.Locale.Tr "repo.issues.filter_sort"}}
|
||||
</span>
|
||||
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||
<div class="menu">
|
||||
<button class="{{if eq $.SortType "oldest"}}active {{end}}item" name="sort" value="oldest">{{ctx.Locale.Tr "repo.issues.filter_sort.oldest"}}</button>
|
||||
<button class="{{if eq $.SortType "newest"}}active {{end}}item" name="sort" value="newest">{{ctx.Locale.Tr "repo.issues.filter_sort.latest"}}</button>
|
||||
<button class="{{if eq $.SortType "alphabetically"}}active {{end}}item" name="sort" value="alphabetically">{{ctx.Locale.Tr "repo.issues.label.filter_sort.alphabetically"}}</button>
|
||||
<button class="{{if eq $.SortType "reversealphabetically"}}active {{end}}item" name="sort" value="reversealphabetically">{{ctx.Locale.Tr "repo.issues.label.filter_sort.reverse_alphabetically"}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="ui attached table segment">
|
||||
<table class="ui very basic striped table unstackable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sortt-asc="oldest" data-sortt-desc="newest" data-sortt-default="true">ID{{SortArrow "oldest" "newest" .SortType false}}</th>
|
||||
<th data-sortt-asc="alphabetically" data-sortt-desc="reversealphabetically">
|
||||
{{ctx.Locale.Tr "admin.badges.slug"}}
|
||||
{{SortArrow "alphabetically" "reversealphabetically" $.SortType true}}
|
||||
</th>
|
||||
<th>{{ctx.Locale.Tr "admin.badges.description"}}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Badges}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>
|
||||
<a href="{{$.Link}}/slug/{{.Slug | PathEscape}}">{{.Slug}}</a>
|
||||
</td>
|
||||
<td class="gt-ellipsis tw-max-w-48">{{.Description}}</td>
|
||||
<td>
|
||||
<div class="tw-flex tw-gap-2">
|
||||
<a href="{{$.Link}}/slug/{{.Slug | PathEscape}}" data-tooltip-content="{{ctx.Locale.Tr "admin.badges.details"}}">{{svg "octicon-star"}}</a>
|
||||
<a href="{{$.Link}}/slug/{{.Slug | PathEscape}}/edit" data-tooltip-content="{{ctx.Locale.Tr "edit"}}">{{svg "octicon-pencil"}}</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{template "base/paginate" .}}
|
||||
</div>
|
||||
{{template "admin/layout_footer" .}}
|
||||
26
templates/admin/badge/new.tmpl
Normal file
26
templates/admin/badge/new.tmpl
Normal file
@ -0,0 +1,26 @@
|
||||
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin new badge")}}
|
||||
<div class="admin-setting-content">
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "admin.badges.new_badge"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<form class="ui form form-fetch-action" action="{{.Link}}" method="post">
|
||||
<div class="required field">
|
||||
<label>{{ctx.Locale.Tr "admin.badges.slug"}}</label>
|
||||
<input autofocus required name="slug">
|
||||
</div>
|
||||
<div class="required field">
|
||||
<label>{{ctx.Locale.Tr "admin.badges.description"}}</label>
|
||||
<textarea name="description" rows="2" required></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "admin.badges.image_url"}}</label>
|
||||
<input type="url" name="image_url">
|
||||
</div>
|
||||
<div class="field">
|
||||
<button class="ui primary button">{{ctx.Locale.Tr "admin.badges.new_badge"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{template "admin/layout_footer" .}}
|
||||
40
templates/admin/badge/users.tmpl
Normal file
40
templates/admin/badge/users.tmpl
Normal file
@ -0,0 +1,40 @@
|
||||
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin badge")}}
|
||||
<div class="admin-setting-content">
|
||||
<h4 class="ui top attached header">
|
||||
{{.Title}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<form class="ui form" action="{{.Link}}" method="post">
|
||||
<div id="search-user-box" class="ui search input tw-align-middle">
|
||||
<input class="prompt" name="user" placeholder="{{ctx.Locale.Tr "search.user_kind"}}" autocomplete="off" autofocus required>
|
||||
</div>
|
||||
<button class="ui primary button">{{ctx.Locale.Tr "admin.badges.add_user"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
{{if .Users}}
|
||||
<div class="ui attached segment">
|
||||
<div class="flex-list">
|
||||
{{range .Users}}
|
||||
<div class="flex-item tw-items-center">
|
||||
<div class="flex-item-leading">
|
||||
<a href="{{.HomeLink}}">{{ctx.AvatarUtils.Avatar . 32}}</a>
|
||||
</div>
|
||||
<div class="flex-item-main">
|
||||
<div class="flex-item-title">
|
||||
{{template "shared/user/name" .}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-item-trailing">
|
||||
<a class="ui red tiny button inline link-action" data-url="{{$.Link}}/delete?id={{.ID}}" data-modal-confirm="{{ctx.Locale.Tr "admin.badges.delete_user_desc"}}">
|
||||
{{ctx.Locale.Tr "admin.badges.remove_user"}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{template "base/paginate" .}}
|
||||
</div>
|
||||
|
||||
{{template "admin/layout_footer" .}}
|
||||
44
templates/admin/badge/view.tmpl
Normal file
44
templates/admin/badge/view.tmpl
Normal file
@ -0,0 +1,44 @@
|
||||
{{template "admin/layout_head" (dict "ctxData" .)}}
|
||||
|
||||
<div class="admin-setting-content">
|
||||
<div class="admin-responsive-columns">
|
||||
<div class="tw-flex-1">
|
||||
<h4 class="ui top attached header">
|
||||
{{.Title}}
|
||||
<div class="ui right">
|
||||
<a class="ui primary tiny button" href="{{.Link}}/edit">{{ctx.Locale.Tr "admin.badges.edit_badge"}}</a>
|
||||
</div>
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<div class="flex-list">
|
||||
<div class="flex-item">
|
||||
{{if .Badge.ImageURL}}
|
||||
<div class="flex-item-leading">
|
||||
<img width="64" height="64" src="{{.Badge.ImageURL}}" alt="{{.Badge.Description}}" data-tooltip-content="{{.Badge.Description}}">
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="flex-item-main">
|
||||
<div class="flex-item-title">
|
||||
{{.Badge.Slug}}
|
||||
</div>
|
||||
<div class="flex-item-body">
|
||||
{{.Badge.Description}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "explore.users"}} ({{.UsersTotal}})
|
||||
<div class="ui right">
|
||||
<a class="ui primary tiny button" href="{{.Link}}/users">{{ctx.Locale.Tr "admin.badges.manage_users"}}</a>
|
||||
</div>
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
{{template "explore/user_list" .}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "admin/layout_footer" .}}
|
||||
@ -13,7 +13,7 @@
|
||||
</a>
|
||||
</div>
|
||||
</details>
|
||||
<details class="item toggleable-item" {{if or .PageIsAdminUsers .PageIsAdminEmails .PageIsAdminOrganizations .PageIsAdminAuthentications}}open{{end}}>
|
||||
<details class="item toggleable-item" {{if or .PageIsAdminUsers .PageIsAdminBadges .PageIsAdminEmails .PageIsAdminOrganizations .PageIsAdminAuthentications}}open{{end}}>
|
||||
<summary>{{ctx.Locale.Tr "admin.identity_access"}}</summary>
|
||||
<div class="menu">
|
||||
<a class="{{if .PageIsAdminAuthentications}}active {{end}}item" href="{{AppSubUrl}}/-/admin/auths">
|
||||
@ -25,6 +25,9 @@
|
||||
<a class="{{if .PageIsAdminUsers}}active {{end}}item" href="{{AppSubUrl}}/-/admin/users">
|
||||
{{ctx.Locale.Tr "admin.users"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsAdminBadges}}active {{end}}item" href="{{AppSubUrl}}/-/admin/badges">
|
||||
{{ctx.Locale.Tr "admin.badges"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsAdminEmails}}active {{end}}item" href="{{AppSubUrl}}/-/admin/emails">
|
||||
{{ctx.Locale.Tr "admin.emails"}}
|
||||
</a>
|
||||
|
||||
@ -100,13 +100,17 @@
|
||||
{{end}}
|
||||
{{if .Badges}}
|
||||
<li>
|
||||
<ul class="user-badges">
|
||||
<div class="user-badges">
|
||||
{{range .Badges}}
|
||||
<li>
|
||||
<span class="user-badge-item">
|
||||
{{if .ImageURL}}
|
||||
<img loading="lazy" width="64" height="64" src="{{.ImageURL}}" alt="{{.Description}}" data-tooltip-content="{{.Description}}">
|
||||
</li>
|
||||
{{else}}
|
||||
<span class="ui label user-badge-chip" data-tooltip-content="{{if .Description}}{{.Description}}{{else}}{{.Slug}}{{end}}">{{.Slug}}</span>
|
||||
{{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
{{end}}
|
||||
{{if and .IsSigned (ne .SignedUserID .ContextUser.ID)}}
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user