Merge branch 'main' into lunny/add_line_through_deleted_branch

This commit is contained in:
Giteabot 2024-11-21 16:07:08 +08:00 committed by GitHub
commit af7f0b3e01
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
110 changed files with 1739 additions and 1251 deletions

View File

@ -29,7 +29,6 @@
poetry poetry
# backend # backend
go_1_22
gofumpt gofumpt
sqlite sqlite
]; ];

View File

@ -68,7 +68,8 @@ func CheckCollations(x *xorm.Engine) (*CheckCollationsResult, error) {
var candidateCollations []string var candidateCollations []string
if x.Dialect().URI().DBType == schemas.MYSQL { if x.Dialect().URI().DBType == schemas.MYSQL {
if _, err = x.SQL("SELECT @@collation_database").Get(&res.DatabaseCollation); err != nil { _, err = x.SQL("SELECT DEFAULT_COLLATION_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?", setting.Database.Name).Get(&res.DatabaseCollation)
if err != nil {
return nil, err return nil, err
} }
res.IsCollationCaseSensitive = func(s string) bool { res.IsCollationCaseSensitive = func(s string) bool {

View File

@ -1,3 +1,22 @@
-
id: 46
attempt: 3
runner_id: 1
status: 3 # 3 is the status code for "cancelled"
started: 1683636528
stopped: 1683636626
repo_id: 4
owner_id: 1
commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
is_fork_pull_request: 0
token_hash: 6d8ef48297195edcc8e22c70b3020eaa06c52976db67d39b4260c64a69a2cc1508825121b7b8394e48e00b1bf8718b2aaaaa
token_salt: eeeeeeee
token_last_eight: eeeeeeee
log_filename: artifact-test2/2f/47.log
log_in_storage: 1
log_length: 707
log_size: 90179
log_expired: 0
- -
id: 47 id: 47
job_id: 192 job_id: 192

View File

@ -1,7 +1,7 @@
- -
id: 1 id: 1
setting_key: 'picture.disable_gravatar' setting_key: 'picture.disable_gravatar'
setting_value: 'false' setting_value: 'true'
version: 1 version: 1
created: 1653533198 created: 1653533198
updated: 1653533198 updated: 1653533198

View File

@ -23,9 +23,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar1 avatar: ""
avatar_email: user1@example.com avatar_email: user1@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -60,8 +60,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar2 avatar: ""
avatar_email: user2@example.com avatar_email: user2@example.com
# cause a random avatar to be generated when referenced for test purposes
use_custom_avatar: false use_custom_avatar: false
num_followers: 2 num_followers: 2
num_following: 1 num_following: 1
@ -97,9 +98,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar3 avatar: ""
avatar_email: org3@example.com avatar_email: org3@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -134,9 +135,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar4 avatar: ""
avatar_email: user4@example.com avatar_email: user4@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 1 num_following: 1
num_stars: 0 num_stars: 0
@ -171,9 +172,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: false allow_create_organization: false
prohibit_login: false prohibit_login: false
avatar: avatar5 avatar: ""
avatar_email: user5@example.com avatar_email: user5@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -208,9 +209,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar6 avatar: ""
avatar_email: org6@example.com avatar_email: org6@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -245,9 +246,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar7 avatar: ""
avatar_email: org7@example.com avatar_email: org7@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -282,9 +283,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar8 avatar: ""
avatar_email: user8@example.com avatar_email: user8@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 1 num_followers: 1
num_following: 1 num_following: 1
num_stars: 0 num_stars: 0
@ -319,9 +320,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar9 avatar: ""
avatar_email: user9@example.com avatar_email: user9@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -332,6 +333,7 @@
repo_admin_change_team_access: false repo_admin_change_team_access: false
theme: "" theme: ""
keep_activity_private: false keep_activity_private: false
created_unix: 1730468968
- -
id: 10 id: 10
@ -356,9 +358,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar10 avatar: ""
avatar_email: user10@example.com avatar_email: user10@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 2 num_stars: 2
@ -393,9 +395,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar11 avatar: ""
avatar_email: user11@example.com avatar_email: user11@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -430,9 +432,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar12 avatar: ""
avatar_email: user12@example.com avatar_email: user12@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -467,9 +469,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar13 avatar: ""
avatar_email: user13@example.com avatar_email: user13@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -504,9 +506,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar14 avatar: ""
avatar_email: user13@example.com avatar_email: user13@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -541,9 +543,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar15 avatar: ""
avatar_email: user15@example.com avatar_email: user15@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -578,9 +580,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar16 avatar: ""
avatar_email: user16@example.com avatar_email: user16@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -615,9 +617,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar17 avatar: ""
avatar_email: org17@example.com avatar_email: org17@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -652,9 +654,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar18 avatar: ""
avatar_email: user18@example.com avatar_email: user18@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -689,9 +691,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar19 avatar: ""
avatar_email: org19@example.com avatar_email: org19@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -726,9 +728,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar20 avatar: ""
avatar_email: user20@example.com avatar_email: user20@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -763,9 +765,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar21 avatar: ""
avatar_email: user21@example.com avatar_email: user21@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -800,9 +802,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar22 avatar: ""
avatar_email: limited_org@example.com avatar_email: limited_org@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -837,9 +839,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar23 avatar: ""
avatar_email: privated_org@example.com avatar_email: privated_org@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -874,9 +876,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar24 avatar: ""
avatar_email: user24@example.com avatar_email: user24@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -911,9 +913,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar25 avatar: ""
avatar_email: org25@example.com avatar_email: org25@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -948,9 +950,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar26 avatar: ""
avatar_email: org26@example.com avatar_email: org26@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -985,9 +987,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar27 avatar: ""
avatar_email: user27@example.com avatar_email: user27@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -1022,9 +1024,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar28 avatar: ""
avatar_email: user28@example.com avatar_email: user28@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -1059,9 +1061,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar29 avatar: ""
avatar_email: user29@example.com avatar_email: user29@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -1096,9 +1098,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar29 avatar: ""
avatar_email: user30@example.com avatar_email: user30@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -1133,9 +1135,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar31 avatar: ""
avatar_email: user31@example.com avatar_email: user31@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 1 num_following: 1
num_stars: 0 num_stars: 0
@ -1170,9 +1172,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar32 avatar: ""
avatar_email: user30@example.com avatar_email: user30@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -1207,9 +1209,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar33 avatar: ""
avatar_email: user33@example.com avatar_email: user33@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 1 num_followers: 1
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -1245,7 +1247,7 @@
allow_import_local: false allow_import_local: false
allow_create_organization: false allow_create_organization: false
prohibit_login: false prohibit_login: false
avatar: avatar34 avatar: ""
avatar_email: user34@example.com avatar_email: user34@example.com
use_custom_avatar: true use_custom_avatar: true
num_followers: 0 num_followers: 0
@ -1282,9 +1284,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar35 avatar: ""
avatar_email: private_org35@example.com avatar_email: private_org35@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -1319,9 +1321,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar22 avatar: ""
avatar_email: abcde@gitea.com avatar_email: abcde@gitea.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -1356,9 +1358,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: true prohibit_login: true
avatar: avatar29 avatar: ""
avatar_email: user37@example.com avatar_email: user37@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -1393,9 +1395,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar38 avatar: ""
avatar_email: user38@example.com avatar_email: user38@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -1430,9 +1432,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar39 avatar: ""
avatar_email: user39@example.com avatar_email: user39@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -1467,9 +1469,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar40 avatar: ""
avatar_email: user40@example.com avatar_email: user40@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -1504,9 +1506,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar41 avatar: ""
avatar_email: org41@example.com avatar_email: org41@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0
@ -1541,9 +1543,9 @@
allow_import_local: false allow_import_local: false
allow_create_organization: true allow_create_organization: true
prohibit_login: false prohibit_login: false
avatar: avatar42 avatar: ""
avatar_email: org42@example.com avatar_email: org42@example.com
use_custom_avatar: false use_custom_avatar: true
num_followers: 0 num_followers: 0
num_following: 0 num_following: 0
num_stars: 0 num_stars: 0

View File

@ -54,21 +54,6 @@ func GetUserFork(ctx context.Context, repoID, userID int64) (*Repository, error)
return &forkedRepo, nil return &forkedRepo, nil
} }
// GetForks returns all the forks of the repository
func GetForks(ctx context.Context, repo *Repository, listOptions db.ListOptions) ([]*Repository, error) {
sess := db.GetEngine(ctx)
var forks []*Repository
if listOptions.Page == 0 {
forks = make([]*Repository, 0, repo.NumForks)
} else {
forks = make([]*Repository, 0, listOptions.PageSize)
sess = db.SetSessionPagination(sess, &listOptions)
}
return forks, sess.Find(&forks, &Repository{ForkID: repo.ID})
}
// IncrementRepoForkNum increment repository fork number // IncrementRepoForkNum increment repository fork number
func IncrementRepoForkNum(ctx context.Context, repoID int64) error { func IncrementRepoForkNum(ctx context.Context, repoID int64) error {
_, err := db.GetEngine(ctx).Exec("UPDATE `repository` SET num_forks=num_forks+1 WHERE id=?", repoID) _, err := db.GetEngine(ctx).Exec("UPDATE `repository` SET num_forks=num_forks+1 WHERE id=?", repoID)

View File

@ -9,15 +9,13 @@ import (
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"xorm.io/builder" "xorm.io/builder"
) )
// ErrPushMirrorNotExist mirror does not exist error
var ErrPushMirrorNotExist = util.NewNotExistErrorf("PushMirror does not exist")
// PushMirror represents mirror information of a repository. // PushMirror represents mirror information of a repository.
type PushMirror struct { type PushMirror struct {
ID int64 `xorm:"pk autoincr"` ID int64 `xorm:"pk autoincr"`
@ -96,26 +94,46 @@ func DeletePushMirrors(ctx context.Context, opts PushMirrorOptions) error {
return util.NewInvalidArgumentErrorf("repoID required and must be set") return util.NewInvalidArgumentErrorf("repoID required and must be set")
} }
type findPushMirrorOptions struct {
db.ListOptions
RepoID int64
SyncOnCommit optional.Option[bool]
}
func (opts findPushMirrorOptions) ToConds() builder.Cond {
cond := builder.NewCond()
if opts.RepoID > 0 {
cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
}
if opts.SyncOnCommit.Has() {
cond = cond.And(builder.Eq{"sync_on_commit": opts.SyncOnCommit.Value()})
}
return cond
}
// GetPushMirrorsByRepoID returns push-mirror information of a repository. // GetPushMirrorsByRepoID returns push-mirror information of a repository.
func GetPushMirrorsByRepoID(ctx context.Context, repoID int64, listOptions db.ListOptions) ([]*PushMirror, int64, error) { func GetPushMirrorsByRepoID(ctx context.Context, repoID int64, listOptions db.ListOptions) ([]*PushMirror, int64, error) {
sess := db.GetEngine(ctx).Where("repo_id = ?", repoID) return db.FindAndCount[PushMirror](ctx, findPushMirrorOptions{
if listOptions.Page != 0 { ListOptions: listOptions,
sess = db.SetSessionPagination(sess, &listOptions) RepoID: repoID,
mirrors := make([]*PushMirror, 0, listOptions.PageSize) })
count, err := sess.FindAndCount(&mirrors) }
return mirrors, count, err
func GetPushMirrorByIDAndRepoID(ctx context.Context, id, repoID int64) (*PushMirror, bool, error) {
var pushMirror PushMirror
has, err := db.GetEngine(ctx).Where("id = ?", id).And("repo_id = ?", repoID).Get(&pushMirror)
if !has || err != nil {
return nil, has, err
} }
mirrors := make([]*PushMirror, 0, 10) return &pushMirror, true, nil
count, err := sess.FindAndCount(&mirrors)
return mirrors, count, err
} }
// GetPushMirrorsSyncedOnCommit returns push-mirrors for this repo that should be updated by new commits // GetPushMirrorsSyncedOnCommit returns push-mirrors for this repo that should be updated by new commits
func GetPushMirrorsSyncedOnCommit(ctx context.Context, repoID int64) ([]*PushMirror, error) { func GetPushMirrorsSyncedOnCommit(ctx context.Context, repoID int64) ([]*PushMirror, error) {
mirrors := make([]*PushMirror, 0, 10) return db.Find[PushMirror](ctx, findPushMirrorOptions{
return mirrors, db.GetEngine(ctx). RepoID: repoID,
Where("repo_id = ? AND sync_on_commit = ?", repoID, true). SyncOnCommit: optional.Some(true),
Find(&mirrors) })
} }
// PushMirrorsIterate iterates all push-mirror repositories. // PushMirrorsIterate iterates all push-mirror repositories.

View File

@ -98,8 +98,7 @@ func (repos RepositoryList) IDs() []int64 {
return repoIDs return repoIDs
} }
// LoadAttributes loads the attributes for the given RepositoryList func (repos RepositoryList) LoadOwners(ctx context.Context) error {
func (repos RepositoryList) LoadAttributes(ctx context.Context) error {
if len(repos) == 0 { if len(repos) == 0 {
return nil return nil
} }
@ -107,10 +106,6 @@ func (repos RepositoryList) LoadAttributes(ctx context.Context) error {
userIDs := container.FilterSlice(repos, func(repo *Repository) (int64, bool) { userIDs := container.FilterSlice(repos, func(repo *Repository) (int64, bool) {
return repo.OwnerID, true return repo.OwnerID, true
}) })
repoIDs := make([]int64, len(repos))
for i := range repos {
repoIDs[i] = repos[i].ID
}
// Load owners. // Load owners.
users := make(map[int64]*user_model.User, len(userIDs)) users := make(map[int64]*user_model.User, len(userIDs))
@ -123,12 +118,19 @@ func (repos RepositoryList) LoadAttributes(ctx context.Context) error {
for i := range repos { for i := range repos {
repos[i].Owner = users[repos[i].OwnerID] repos[i].Owner = users[repos[i].OwnerID]
} }
return nil
}
func (repos RepositoryList) LoadLanguageStats(ctx context.Context) error {
if len(repos) == 0 {
return nil
}
// Load primary language. // Load primary language.
stats := make(LanguageStatList, 0, len(repos)) stats := make(LanguageStatList, 0, len(repos))
if err := db.GetEngine(ctx). if err := db.GetEngine(ctx).
Where("`is_primary` = ? AND `language` != ?", true, "other"). Where("`is_primary` = ? AND `language` != ?", true, "other").
In("`repo_id`", repoIDs). In("`repo_id`", repos.IDs()).
Find(&stats); err != nil { Find(&stats); err != nil {
return fmt.Errorf("find primary languages: %w", err) return fmt.Errorf("find primary languages: %w", err)
} }
@ -141,10 +143,18 @@ func (repos RepositoryList) LoadAttributes(ctx context.Context) error {
} }
} }
} }
return nil return nil
} }
// LoadAttributes loads the attributes for the given RepositoryList
func (repos RepositoryList) LoadAttributes(ctx context.Context) error {
if err := repos.LoadOwners(ctx); err != nil {
return err
}
return repos.LoadLanguageStats(ctx)
}
// SearchRepoOptions holds the search options // SearchRepoOptions holds the search options
type SearchRepoOptions struct { type SearchRepoOptions struct {
db.ListOptions db.ListOptions

View File

@ -48,19 +48,19 @@ const (
UserTypeIndividual UserType = iota // Historic reason to make it starts at 0. UserTypeIndividual UserType = iota // Historic reason to make it starts at 0.
// UserTypeOrganization defines an organization // UserTypeOrganization defines an organization
UserTypeOrganization UserTypeOrganization // 1
// UserTypeUserReserved reserves a (non-existing) user, i.e. to prevent a spam user from re-registering after being deleted, or to reserve the name until the user is actually created later on // UserTypeUserReserved reserves a (non-existing) user, i.e. to prevent a spam user from re-registering after being deleted, or to reserve the name until the user is actually created later on
UserTypeUserReserved UserTypeUserReserved // 2
// UserTypeOrganizationReserved reserves a (non-existing) organization, to be used in combination with UserTypeUserReserved // UserTypeOrganizationReserved reserves a (non-existing) organization, to be used in combination with UserTypeUserReserved
UserTypeOrganizationReserved UserTypeOrganizationReserved // 3
// UserTypeBot defines a bot user // UserTypeBot defines a bot user
UserTypeBot UserTypeBot // 4
// UserTypeRemoteUser defines a remote user for federated users // UserTypeRemoteUser defines a remote user for federated users
UserTypeRemoteUser UserTypeRemoteUser // 5
) )
const ( const (
@ -884,7 +884,13 @@ func UpdateUserCols(ctx context.Context, u *User, cols ...string) error {
// GetInactiveUsers gets all inactive users // GetInactiveUsers gets all inactive users
func GetInactiveUsers(ctx context.Context, olderThan time.Duration) ([]*User, error) { func GetInactiveUsers(ctx context.Context, olderThan time.Duration) ([]*User, error) {
var cond builder.Cond = builder.Eq{"is_active": false} cond := builder.And(
builder.Eq{"is_active": false},
builder.Or( // only plain user
builder.Eq{"`type`": UserTypeIndividual},
builder.Eq{"`type`": UserTypeUserReserved},
),
)
if olderThan > 0 { if olderThan > 0 {
cond = cond.And(builder.Lt{"created_unix": time.Now().Add(-olderThan).Unix()}) cond = cond.And(builder.Lt{"created_unix": time.Now().Add(-olderThan).Unix()})

View File

@ -588,3 +588,17 @@ func TestDisabledUserFeatures(t *testing.T) {
assert.True(t, user_model.IsFeatureDisabledWithLoginType(user, f)) assert.True(t, user_model.IsFeatureDisabledWithLoginType(user, f))
} }
} }
func TestGetInactiveUsers(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
// all inactive users
// user1's createdunix is 1730468968
users, err := user_model.GetInactiveUsers(db.DefaultContext, 0)
assert.NoError(t, err)
assert.Len(t, users, 1)
interval := time.Now().Unix() - 1730468968 + 3600*24
users, err = user_model.GetInactiveUsers(db.DefaultContext, time.Duration(interval*int64(time.Second)))
assert.NoError(t, err)
assert.Len(t, users, 0)
}

View File

@ -9,7 +9,6 @@ import (
"bytes" "bytes"
"context" "context"
"errors" "errors"
"fmt"
"io" "io"
"os/exec" "os/exec"
"strconv" "strconv"
@ -29,7 +28,7 @@ type Commit struct {
Signature *CommitSignature Signature *CommitSignature
Parents []ObjectID // ID strings Parents []ObjectID // ID strings
submoduleCache *ObjectCache submoduleCache *ObjectCache[*SubModule]
} }
// CommitSignature represents a git commit signature part. // CommitSignature represents a git commit signature part.
@ -357,69 +356,6 @@ func (c *Commit) GetFileContent(filename string, limit int) (string, error) {
return string(bytes), nil return string(bytes), nil
} }
// GetSubModules get all the sub modules of current revision git tree
func (c *Commit) GetSubModules() (*ObjectCache, error) {
if c.submoduleCache != nil {
return c.submoduleCache, nil
}
entry, err := c.GetTreeEntryByPath(".gitmodules")
if err != nil {
if _, ok := err.(ErrNotExist); ok {
return nil, nil
}
return nil, err
}
rd, err := entry.Blob().DataAsync()
if err != nil {
return nil, err
}
defer rd.Close()
scanner := bufio.NewScanner(rd)
c.submoduleCache = newObjectCache()
var ismodule bool
var path string
for scanner.Scan() {
if strings.HasPrefix(scanner.Text(), "[submodule") {
ismodule = true
continue
}
if ismodule {
fields := strings.Split(scanner.Text(), "=")
k := strings.TrimSpace(fields[0])
if k == "path" {
path = strings.TrimSpace(fields[1])
} else if k == "url" {
c.submoduleCache.Set(path, &SubModule{path, strings.TrimSpace(fields[1])})
ismodule = false
}
}
}
if err = scanner.Err(); err != nil {
return nil, fmt.Errorf("GetSubModules scan: %w", err)
}
return c.submoduleCache, nil
}
// GetSubModule get the sub module according entryname
func (c *Commit) GetSubModule(entryname string) (*SubModule, error) {
modules, err := c.GetSubModules()
if err != nil {
return nil, err
}
if modules != nil {
module, has := modules.Get(entryname)
if has {
return module.(*SubModule), nil
}
}
return nil, nil
}
// GetBranchName gets the closest branch name (as returned by 'git name-rev --name-only') // GetBranchName gets the closest branch name (as returned by 'git name-rev --name-only')
func (c *Commit) GetBranchName() (string, error) { func (c *Commit) GetBranchName() (string, error) {
cmd := NewCommand(c.repo.Ctx, "name-rev") cmd := NewCommand(c.repo.Ctx, "name-rev")

View File

@ -7,5 +7,5 @@ package git
type CommitInfo struct { type CommitInfo struct {
Entry *TreeEntry Entry *TreeEntry
Commit *Commit Commit *Commit
SubModuleFile *SubModuleFile SubModuleFile *CommitSubModuleFile
} }

View File

@ -71,7 +71,7 @@ func (tes Entries) GetCommitsInfo(ctx context.Context, commit *Commit, treePath
commitsInfo[i].Commit = entryCommit commitsInfo[i].Commit = entryCommit
} }
// If the entry if a submodule add a submodule file for this // If the entry is a submodule add a submodule file for this
if entry.IsSubModule() { if entry.IsSubModule() {
subModuleURL := "" subModuleURL := ""
var fullPath string var fullPath string
@ -85,7 +85,7 @@ func (tes Entries) GetCommitsInfo(ctx context.Context, commit *Commit, treePath
} else if subModule != nil { } else if subModule != nil {
subModuleURL = subModule.URL subModuleURL = subModule.URL
} }
subModuleFile := NewSubModuleFile(commitsInfo[i].Commit, subModuleURL, entry.ID.String()) subModuleFile := NewCommitSubModuleFile(subModuleURL, entry.ID.String())
commitsInfo[i].SubModuleFile = subModuleFile commitsInfo[i].SubModuleFile = subModuleFile
} }
} }

View File

@ -79,7 +79,7 @@ func (tes Entries) GetCommitsInfo(ctx context.Context, commit *Commit, treePath
} else if subModule != nil { } else if subModule != nil {
subModuleURL = subModule.URL subModuleURL = subModule.URL
} }
subModuleFile := NewSubModuleFile(commitsInfo[i].Commit, subModuleURL, entry.ID.String()) subModuleFile := NewCommitSubModuleFile(subModuleURL, entry.ID.String())
commitsInfo[i].SubModuleFile = subModuleFile commitsInfo[i].SubModuleFile = subModuleFile
} }
} }

View File

@ -0,0 +1,47 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
// GetSubModules get all the submodules of current revision git tree
func (c *Commit) GetSubModules() (*ObjectCache[*SubModule], error) {
if c.submoduleCache != nil {
return c.submoduleCache, nil
}
entry, err := c.GetTreeEntryByPath(".gitmodules")
if err != nil {
if _, ok := err.(ErrNotExist); ok {
return nil, nil
}
return nil, err
}
rd, err := entry.Blob().DataAsync()
if err != nil {
return nil, err
}
defer rd.Close()
// at the moment we do not strictly limit the size of the .gitmodules file because some users would have huge .gitmodules files (>1MB)
c.submoduleCache, err = configParseSubModules(rd)
if err != nil {
return nil, err
}
return c.submoduleCache, nil
}
// GetSubModule get the submodule according entry name
func (c *Commit) GetSubModule(entryName string) (*SubModule, error) {
modules, err := c.GetSubModules()
if err != nil {
return nil, err
}
if modules != nil {
if module, has := modules.Get(entryName); has {
return module, nil
}
}
return nil, nil
}

View File

@ -15,24 +15,15 @@ import (
var scpSyntax = regexp.MustCompile(`^([a-zA-Z0-9_]+@)?([a-zA-Z0-9._-]+):(.*)$`) var scpSyntax = regexp.MustCompile(`^([a-zA-Z0-9_]+@)?([a-zA-Z0-9._-]+):(.*)$`)
// SubModule submodule is a reference on git repository // CommitSubModuleFile represents a file with submodule type.
type SubModule struct { type CommitSubModuleFile struct {
Name string
URL string
}
// SubModuleFile represents a file with submodule type.
type SubModuleFile struct {
*Commit
refURL string refURL string
refID string refID string
} }
// NewSubModuleFile create a new submodule file // NewCommitSubModuleFile create a new submodule file
func NewSubModuleFile(c *Commit, refURL, refID string) *SubModuleFile { func NewCommitSubModuleFile(refURL, refID string) *CommitSubModuleFile {
return &SubModuleFile{ return &CommitSubModuleFile{
Commit: c,
refURL: refURL, refURL: refURL,
refID: refID, refID: refID,
} }
@ -109,11 +100,12 @@ func getRefURL(refURL, urlPrefix, repoFullName, sshDomain string) string {
} }
// RefURL guesses and returns reference URL. // RefURL guesses and returns reference URL.
func (sf *SubModuleFile) RefURL(urlPrefix, repoFullName, sshDomain string) string { // FIXME: template passes AppURL as urlPrefix, it needs to figure out the correct approach (no hard-coded AppURL anymore)
func (sf *CommitSubModuleFile) RefURL(urlPrefix, repoFullName, sshDomain string) string {
return getRefURL(sf.refURL, urlPrefix, repoFullName, sshDomain) return getRefURL(sf.refURL, urlPrefix, repoFullName, sshDomain)
} }
// RefID returns reference ID. // RefID returns reference ID.
func (sf *SubModuleFile) RefID() string { func (sf *CommitSubModuleFile) RefID() string {
return sf.refID return sf.refID
} }

View File

@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestGetRefURL(t *testing.T) { func TestCommitSubModuleFileGetRefURL(t *testing.T) {
kases := []struct { kases := []struct {
refURL string refURL string
prefixURL string prefixURL string

View File

@ -135,7 +135,7 @@ author KN4CK3R <admin@oldschoolhack.me> 1711702962 +0100
committer KN4CK3R <admin@oldschoolhack.me> 1711702962 +0100 committer KN4CK3R <admin@oldschoolhack.me> 1711702962 +0100
encoding ISO-8859-1 encoding ISO-8859-1
gpgsig -----BEGIN PGP SIGNATURE----- gpgsig -----BEGIN PGP SIGNATURE-----
<SPACE>
iQGzBAABCgAdFiEE9HRrbqvYxPT8PXbefPSEkrowAa8FAmYGg7IACgkQfPSEkrow iQGzBAABCgAdFiEE9HRrbqvYxPT8PXbefPSEkrowAa8FAmYGg7IACgkQfPSEkrow
Aa9olwv+P0HhtCM6CRvlUmPaqswRsDPNR4i66xyXGiSxdI9V5oJL7HLiQIM7KrFR Aa9olwv+P0HhtCM6CRvlUmPaqswRsDPNR4i66xyXGiSxdI9V5oJL7HLiQIM7KrFR
gizKa2COiGtugv8fE+TKqXKaJx6uJUJEjaBd8E9Af9PrAzjWj+A84lU6/PgPS8hq gizKa2COiGtugv8fE+TKqXKaJx6uJUJEjaBd8E9Af9PrAzjWj+A84lU6/PgPS8hq
@ -150,7 +150,7 @@ gpgsig -----BEGIN PGP SIGNATURE-----
-----END PGP SIGNATURE----- -----END PGP SIGNATURE-----
ISO-8859-1` ISO-8859-1`
commitString = strings.ReplaceAll(commitString, "<SPACE>", " ")
sha := &Sha1Hash{0xfe, 0xaf, 0x4b, 0xa6, 0xbc, 0x63, 0x5f, 0xec, 0x44, 0x2f, 0x46, 0xdd, 0xd4, 0x51, 0x24, 0x16, 0xec, 0x43, 0xc2, 0xc2} sha := &Sha1Hash{0xfe, 0xaf, 0x4b, 0xa6, 0xbc, 0x63, 0x5f, 0xec, 0x44, 0x2f, 0x46, 0xdd, 0xd4, 0x51, 0x24, 0x16, 0xec, 0x43, 0xc2, 0xc2}
gitRepo, err := openRepositoryWithDefaultContext(filepath.Join(testReposDir, "repo1_bare")) gitRepo, err := openRepositoryWithDefaultContext(filepath.Join(testReposDir, "repo1_bare"))
assert.NoError(t, err) assert.NoError(t, err)

187
modules/git/config.go Normal file
View File

@ -0,0 +1,187 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"fmt"
"os"
"regexp"
"runtime"
"strings"
"code.gitea.io/gitea/modules/setting"
)
// syncGitConfig only modifies gitconfig, won't change global variables (otherwise there will be data-race problem)
func syncGitConfig() (err error) {
if err = os.MkdirAll(HomeDir(), os.ModePerm); err != nil {
return fmt.Errorf("unable to prepare git home directory %s, err: %w", HomeDir(), err)
}
// first, write user's git config options to git config file
// user config options could be overwritten by builtin values later, because if a value is builtin, it must have some special purposes
for k, v := range setting.GitConfig.Options {
if err = configSet(strings.ToLower(k), v); err != nil {
return err
}
}
// Git requires setting user.name and user.email in order to commit changes - old comment: "if they're not set just add some defaults"
// TODO: need to confirm whether users really need to change these values manually. It seems that these values are dummy only and not really used.
// If these values are not really used, then they can be set (overwritten) directly without considering about existence.
for configKey, defaultValue := range map[string]string{
"user.name": "Gitea",
"user.email": "gitea@fake.local",
} {
if err := configSetNonExist(configKey, defaultValue); err != nil {
return err
}
}
// Set git some configurations - these must be set to these values for gitea to work correctly
if err := configSet("core.quotePath", "false"); err != nil {
return err
}
if DefaultFeatures().CheckVersionAtLeast("2.10") {
if err := configSet("receive.advertisePushOptions", "true"); err != nil {
return err
}
}
if DefaultFeatures().CheckVersionAtLeast("2.18") {
if err := configSet("core.commitGraph", "true"); err != nil {
return err
}
if err := configSet("gc.writeCommitGraph", "true"); err != nil {
return err
}
if err := configSet("fetch.writeCommitGraph", "true"); err != nil {
return err
}
}
if DefaultFeatures().SupportProcReceive {
// set support for AGit flow
if err := configAddNonExist("receive.procReceiveRefs", "refs/for"); err != nil {
return err
}
} else {
if err := configUnsetAll("receive.procReceiveRefs", "refs/for"); err != nil {
return err
}
}
// Due to CVE-2022-24765, git now denies access to git directories which are not owned by current user.
// However, some docker users and samba users find it difficult to configure their systems correctly,
// so that Gitea's git repositories are owned by the Gitea user.
// (Possibly Windows Service users - but ownership in this case should really be set correctly on the filesystem.)
// See issue: https://github.com/go-gitea/gitea/issues/19455
// As Gitea now always use its internal git config file, and access to the git repositories is managed through Gitea,
// it is now safe to set "safe.directory=*" for internal usage only.
// Although this setting is only supported by some new git versions, it is also tolerated by earlier versions
if err := configAddNonExist("safe.directory", "*"); err != nil {
return err
}
if runtime.GOOS == "windows" {
if err := configSet("core.longpaths", "true"); err != nil {
return err
}
if setting.Git.DisableCoreProtectNTFS {
err = configSet("core.protectNTFS", "false")
} else {
err = configUnsetAll("core.protectNTFS", "false")
}
if err != nil {
return err
}
}
// By default partial clones are disabled, enable them from git v2.22
if !setting.Git.DisablePartialClone && DefaultFeatures().CheckVersionAtLeast("2.22") {
if err = configSet("uploadpack.allowfilter", "true"); err != nil {
return err
}
err = configSet("uploadpack.allowAnySHA1InWant", "true")
} else {
if err = configUnsetAll("uploadpack.allowfilter", "true"); err != nil {
return err
}
err = configUnsetAll("uploadpack.allowAnySHA1InWant", "true")
}
return err
}
func configSet(key, value string) error {
stdout, _, err := NewCommand(DefaultContext, "config", "--global", "--get").AddDynamicArguments(key).RunStdString(nil)
if err != nil && !IsErrorExitCode(err, 1) {
return fmt.Errorf("failed to get git config %s, err: %w", key, err)
}
currValue := strings.TrimSpace(stdout)
if currValue == value {
return nil
}
_, _, err = NewCommand(DefaultContext, "config", "--global").AddDynamicArguments(key, value).RunStdString(nil)
if err != nil {
return fmt.Errorf("failed to set git global config %s, err: %w", key, err)
}
return nil
}
func configSetNonExist(key, value string) error {
_, _, err := NewCommand(DefaultContext, "config", "--global", "--get").AddDynamicArguments(key).RunStdString(nil)
if err == nil {
// already exist
return nil
}
if IsErrorExitCode(err, 1) {
// not exist, set new config
_, _, err = NewCommand(DefaultContext, "config", "--global").AddDynamicArguments(key, value).RunStdString(nil)
if err != nil {
return fmt.Errorf("failed to set git global config %s, err: %w", key, err)
}
return nil
}
return fmt.Errorf("failed to get git config %s, err: %w", key, err)
}
func configAddNonExist(key, value string) error {
_, _, err := NewCommand(DefaultContext, "config", "--global", "--get").AddDynamicArguments(key, regexp.QuoteMeta(value)).RunStdString(nil)
if err == nil {
// already exist
return nil
}
if IsErrorExitCode(err, 1) {
// not exist, add new config
_, _, err = NewCommand(DefaultContext, "config", "--global", "--add").AddDynamicArguments(key, value).RunStdString(nil)
if err != nil {
return fmt.Errorf("failed to add git global config %s, err: %w", key, err)
}
return nil
}
return fmt.Errorf("failed to get git config %s, err: %w", key, err)
}
func configUnsetAll(key, value string) error {
_, _, err := NewCommand(DefaultContext, "config", "--global", "--get").AddDynamicArguments(key).RunStdString(nil)
if err == nil {
// exist, need to remove
_, _, err = NewCommand(DefaultContext, "config", "--global", "--unset-all").AddDynamicArguments(key, regexp.QuoteMeta(value)).RunStdString(nil)
if err != nil {
return fmt.Errorf("failed to unset git global config %s, err: %w", key, err)
}
return nil
}
if IsErrorExitCode(err, 1) {
// not exist
return nil
}
return fmt.Errorf("failed to get git config %s, err: %w", key, err)
}

View File

@ -0,0 +1,75 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"bufio"
"fmt"
"io"
"strings"
)
// SubModule is a reference on git repository
type SubModule struct {
Path string
URL string
Branch string // this field is newly added but not really used
}
// configParseSubModules this is not a complete parse for gitmodules file, it only
// parses the url and path of submodules. At the moment it only parses well-formed gitmodules files.
// In the future, there should be a complete implementation of https://git-scm.com/docs/git-config#_syntax
func configParseSubModules(r io.Reader) (*ObjectCache[*SubModule], error) {
var subModule *SubModule
subModules := newObjectCache[*SubModule]()
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
// Section header [section]
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
if subModule != nil {
subModules.Set(subModule.Path, subModule)
}
if strings.HasPrefix(line, "[submodule") {
subModule = &SubModule{}
} else {
subModule = nil
}
continue
}
if subModule == nil {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
switch key {
case "path":
subModule.Path = value
case "url":
subModule.URL = value
case "branch":
subModule.Branch = value
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading file: %w", err)
}
if subModule != nil {
subModules.Set(subModule.Path, subModule)
}
return subModules, nil
}

View File

@ -0,0 +1,49 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestConfigSubmodule(t *testing.T) {
input := `
[core]
path = test
[submodule "submodule1"]
path = path1
url = https://gitea.io/foo/foo
#branch = b1
[other1]
branch = master
[submodule "submodule2"]
path = path2
url = https://gitea.io/bar/bar
branch = b2
[other2]
branch = main
[submodule "submodule3"]
path = path3
url = https://gitea.io/xxx/xxx
`
subModules, err := configParseSubModules(strings.NewReader(input))
assert.NoError(t, err)
assert.Len(t, subModules.cache, 3)
sm1, _ := subModules.Get("path1")
assert.Equal(t, &SubModule{Path: "path1", URL: "https://gitea.io/foo/foo", Branch: ""}, sm1)
sm2, _ := subModules.Get("path2")
assert.Equal(t, &SubModule{Path: "path2", URL: "https://gitea.io/bar/bar", Branch: "b2"}, sm2)
sm3, _ := subModules.Get("path3")
assert.Equal(t, &SubModule{Path: "path3", URL: "https://gitea.io/xxx/xxx", Branch: ""}, sm3)
}

View File

@ -0,0 +1,66 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"os"
"strings"
"testing"
"code.gitea.io/gitea/modules/setting"
"github.com/stretchr/testify/assert"
)
func gitConfigContains(sub string) bool {
if b, err := os.ReadFile(HomeDir() + "/.gitconfig"); err == nil {
return strings.Contains(string(b), sub)
}
return false
}
func TestGitConfig(t *testing.T) {
assert.False(t, gitConfigContains("key-a"))
assert.NoError(t, configSetNonExist("test.key-a", "val-a"))
assert.True(t, gitConfigContains("key-a = val-a"))
assert.NoError(t, configSetNonExist("test.key-a", "val-a-changed"))
assert.False(t, gitConfigContains("key-a = val-a-changed"))
assert.NoError(t, configSet("test.key-a", "val-a-changed"))
assert.True(t, gitConfigContains("key-a = val-a-changed"))
assert.NoError(t, configAddNonExist("test.key-b", "val-b"))
assert.True(t, gitConfigContains("key-b = val-b"))
assert.NoError(t, configAddNonExist("test.key-b", "val-2b"))
assert.True(t, gitConfigContains("key-b = val-b"))
assert.True(t, gitConfigContains("key-b = val-2b"))
assert.NoError(t, configUnsetAll("test.key-b", "val-b"))
assert.False(t, gitConfigContains("key-b = val-b"))
assert.True(t, gitConfigContains("key-b = val-2b"))
assert.NoError(t, configUnsetAll("test.key-b", "val-2b"))
assert.False(t, gitConfigContains("key-b = val-2b"))
assert.NoError(t, configSet("test.key-x", "*"))
assert.True(t, gitConfigContains("key-x = *"))
assert.NoError(t, configSetNonExist("test.key-x", "*"))
assert.NoError(t, configUnsetAll("test.key-x", "*"))
assert.False(t, gitConfigContains("key-x = *"))
}
func TestSyncConfig(t *testing.T) {
oldGitConfig := setting.GitConfig
defer func() {
setting.GitConfig = oldGitConfig
}()
setting.GitConfig.Options["sync-test.cfg-key-a"] = "CfgValA"
assert.NoError(t, syncGitConfig())
assert.True(t, gitConfigContains("[sync-test]"))
assert.True(t, gitConfigContains("cfg-key-a = CfgValA"))
}

14
modules/git/fsck.go Normal file
View File

@ -0,0 +1,14 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"time"
)
// Fsck verifies the connectivity and validity of the objects in the database
func Fsck(ctx context.Context, repoPath string, timeout time.Duration, args TrustedCmdArgs) error {
return NewCommand(ctx, "fsck").AddArguments(args...).Run(&RunOpts{Timeout: timeout, Dir: repoPath})
}

View File

@ -11,7 +11,6 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp"
"runtime" "runtime"
"strings" "strings"
"time" "time"
@ -95,17 +94,18 @@ func parseGitVersionLine(s string) (*version.Version, error) {
return version.NewVersion(versionString) return version.NewVersion(versionString)
} }
// SetExecutablePath changes the path of git executable and checks the file permission and version. func checkGitVersionCompatibility(gitVer *version.Version) error {
func SetExecutablePath(path string) error { badVersions := []struct {
// If path is empty, we use the default value of GitExecutable "git" to search for the location of git. Version *version.Version
if path != "" { Reason string
GitExecutable = path }{
{version.Must(version.NewVersion("2.43.1")), "regression bug of GIT_FLUSH"},
} }
absPath, err := exec.LookPath(GitExecutable) for _, bad := range badVersions {
if err != nil { if gitVer.Equal(bad.Version) {
return fmt.Errorf("git not found: %w", err) return errors.New(bad.Reason)
}
} }
GitExecutable = absPath
return nil return nil
} }
@ -128,6 +128,20 @@ func ensureGitVersion() error {
return nil return nil
} }
// SetExecutablePath changes the path of git executable and checks the file permission and version.
func SetExecutablePath(path string) error {
// If path is empty, we use the default value of GitExecutable "git" to search for the location of git.
if path != "" {
GitExecutable = path
}
absPath, err := exec.LookPath(GitExecutable)
if err != nil {
return fmt.Errorf("git not found: %w", err)
}
GitExecutable = absPath
return nil
}
// HomeDir is the home dir for git to store the global config file used by Gitea internally // HomeDir is the home dir for git to store the global config file used by Gitea internally
func HomeDir() string { func HomeDir() string {
if setting.Git.HomePath == "" { if setting.Git.HomePath == "" {
@ -204,196 +218,3 @@ func InitFull(ctx context.Context) (err error) {
return syncGitConfig() return syncGitConfig()
} }
// syncGitConfig only modifies gitconfig, won't change global variables (otherwise there will be data-race problem)
func syncGitConfig() (err error) {
if err = os.MkdirAll(HomeDir(), os.ModePerm); err != nil {
return fmt.Errorf("unable to prepare git home directory %s, err: %w", HomeDir(), err)
}
// first, write user's git config options to git config file
// user config options could be overwritten by builtin values later, because if a value is builtin, it must have some special purposes
for k, v := range setting.GitConfig.Options {
if err = configSet(strings.ToLower(k), v); err != nil {
return err
}
}
// Git requires setting user.name and user.email in order to commit changes - old comment: "if they're not set just add some defaults"
// TODO: need to confirm whether users really need to change these values manually. It seems that these values are dummy only and not really used.
// If these values are not really used, then they can be set (overwritten) directly without considering about existence.
for configKey, defaultValue := range map[string]string{
"user.name": "Gitea",
"user.email": "gitea@fake.local",
} {
if err := configSetNonExist(configKey, defaultValue); err != nil {
return err
}
}
// Set git some configurations - these must be set to these values for gitea to work correctly
if err := configSet("core.quotePath", "false"); err != nil {
return err
}
if DefaultFeatures().CheckVersionAtLeast("2.10") {
if err := configSet("receive.advertisePushOptions", "true"); err != nil {
return err
}
}
if DefaultFeatures().CheckVersionAtLeast("2.18") {
if err := configSet("core.commitGraph", "true"); err != nil {
return err
}
if err := configSet("gc.writeCommitGraph", "true"); err != nil {
return err
}
if err := configSet("fetch.writeCommitGraph", "true"); err != nil {
return err
}
}
if DefaultFeatures().SupportProcReceive {
// set support for AGit flow
if err := configAddNonExist("receive.procReceiveRefs", "refs/for"); err != nil {
return err
}
} else {
if err := configUnsetAll("receive.procReceiveRefs", "refs/for"); err != nil {
return err
}
}
// Due to CVE-2022-24765, git now denies access to git directories which are not owned by current user.
// However, some docker users and samba users find it difficult to configure their systems correctly,
// so that Gitea's git repositories are owned by the Gitea user.
// (Possibly Windows Service users - but ownership in this case should really be set correctly on the filesystem.)
// See issue: https://github.com/go-gitea/gitea/issues/19455
// As Gitea now always use its internal git config file, and access to the git repositories is managed through Gitea,
// it is now safe to set "safe.directory=*" for internal usage only.
// Although this setting is only supported by some new git versions, it is also tolerated by earlier versions
if err := configAddNonExist("safe.directory", "*"); err != nil {
return err
}
if runtime.GOOS == "windows" {
if err := configSet("core.longpaths", "true"); err != nil {
return err
}
if setting.Git.DisableCoreProtectNTFS {
err = configSet("core.protectNTFS", "false")
} else {
err = configUnsetAll("core.protectNTFS", "false")
}
if err != nil {
return err
}
}
// By default partial clones are disabled, enable them from git v2.22
if !setting.Git.DisablePartialClone && DefaultFeatures().CheckVersionAtLeast("2.22") {
if err = configSet("uploadpack.allowfilter", "true"); err != nil {
return err
}
err = configSet("uploadpack.allowAnySHA1InWant", "true")
} else {
if err = configUnsetAll("uploadpack.allowfilter", "true"); err != nil {
return err
}
err = configUnsetAll("uploadpack.allowAnySHA1InWant", "true")
}
return err
}
func checkGitVersionCompatibility(gitVer *version.Version) error {
badVersions := []struct {
Version *version.Version
Reason string
}{
{version.Must(version.NewVersion("2.43.1")), "regression bug of GIT_FLUSH"},
}
for _, bad := range badVersions {
if gitVer.Equal(bad.Version) {
return errors.New(bad.Reason)
}
}
return nil
}
func configSet(key, value string) error {
stdout, _, err := NewCommand(DefaultContext, "config", "--global", "--get").AddDynamicArguments(key).RunStdString(nil)
if err != nil && !IsErrorExitCode(err, 1) {
return fmt.Errorf("failed to get git config %s, err: %w", key, err)
}
currValue := strings.TrimSpace(stdout)
if currValue == value {
return nil
}
_, _, err = NewCommand(DefaultContext, "config", "--global").AddDynamicArguments(key, value).RunStdString(nil)
if err != nil {
return fmt.Errorf("failed to set git global config %s, err: %w", key, err)
}
return nil
}
func configSetNonExist(key, value string) error {
_, _, err := NewCommand(DefaultContext, "config", "--global", "--get").AddDynamicArguments(key).RunStdString(nil)
if err == nil {
// already exist
return nil
}
if IsErrorExitCode(err, 1) {
// not exist, set new config
_, _, err = NewCommand(DefaultContext, "config", "--global").AddDynamicArguments(key, value).RunStdString(nil)
if err != nil {
return fmt.Errorf("failed to set git global config %s, err: %w", key, err)
}
return nil
}
return fmt.Errorf("failed to get git config %s, err: %w", key, err)
}
func configAddNonExist(key, value string) error {
_, _, err := NewCommand(DefaultContext, "config", "--global", "--get").AddDynamicArguments(key, regexp.QuoteMeta(value)).RunStdString(nil)
if err == nil {
// already exist
return nil
}
if IsErrorExitCode(err, 1) {
// not exist, add new config
_, _, err = NewCommand(DefaultContext, "config", "--global", "--add").AddDynamicArguments(key, value).RunStdString(nil)
if err != nil {
return fmt.Errorf("failed to add git global config %s, err: %w", key, err)
}
return nil
}
return fmt.Errorf("failed to get git config %s, err: %w", key, err)
}
func configUnsetAll(key, value string) error {
_, _, err := NewCommand(DefaultContext, "config", "--global", "--get").AddDynamicArguments(key).RunStdString(nil)
if err == nil {
// exist, need to remove
_, _, err = NewCommand(DefaultContext, "config", "--global", "--unset-all").AddDynamicArguments(key, regexp.QuoteMeta(value)).RunStdString(nil)
if err != nil {
return fmt.Errorf("failed to unset git global config %s, err: %w", key, err)
}
return nil
}
if IsErrorExitCode(err, 1) {
// not exist
return nil
}
return fmt.Errorf("failed to get git config %s, err: %w", key, err)
}
// Fsck verifies the connectivity and validity of the objects in the database
func Fsck(ctx context.Context, repoPath string, timeout time.Duration, args TrustedCmdArgs) error {
return NewCommand(ctx, "fsck").AddArguments(args...).Run(&RunOpts{Timeout: timeout, Dir: repoPath})
}

View File

@ -7,7 +7,6 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"strings"
"testing" "testing"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
@ -43,58 +42,6 @@ func TestMain(m *testing.M) {
} }
} }
func gitConfigContains(sub string) bool {
if b, err := os.ReadFile(HomeDir() + "/.gitconfig"); err == nil {
return strings.Contains(string(b), sub)
}
return false
}
func TestGitConfig(t *testing.T) {
assert.False(t, gitConfigContains("key-a"))
assert.NoError(t, configSetNonExist("test.key-a", "val-a"))
assert.True(t, gitConfigContains("key-a = val-a"))
assert.NoError(t, configSetNonExist("test.key-a", "val-a-changed"))
assert.False(t, gitConfigContains("key-a = val-a-changed"))
assert.NoError(t, configSet("test.key-a", "val-a-changed"))
assert.True(t, gitConfigContains("key-a = val-a-changed"))
assert.NoError(t, configAddNonExist("test.key-b", "val-b"))
assert.True(t, gitConfigContains("key-b = val-b"))
assert.NoError(t, configAddNonExist("test.key-b", "val-2b"))
assert.True(t, gitConfigContains("key-b = val-b"))
assert.True(t, gitConfigContains("key-b = val-2b"))
assert.NoError(t, configUnsetAll("test.key-b", "val-b"))
assert.False(t, gitConfigContains("key-b = val-b"))
assert.True(t, gitConfigContains("key-b = val-2b"))
assert.NoError(t, configUnsetAll("test.key-b", "val-2b"))
assert.False(t, gitConfigContains("key-b = val-2b"))
assert.NoError(t, configSet("test.key-x", "*"))
assert.True(t, gitConfigContains("key-x = *"))
assert.NoError(t, configSetNonExist("test.key-x", "*"))
assert.NoError(t, configUnsetAll("test.key-x", "*"))
assert.False(t, gitConfigContains("key-x = *"))
}
func TestSyncConfig(t *testing.T) {
oldGitConfig := setting.GitConfig
defer func() {
setting.GitConfig = oldGitConfig
}()
setting.GitConfig.Options["sync-test.cfg-key-a"] = "CfgValA"
assert.NoError(t, syncGitConfig())
assert.True(t, gitConfigContains("[sync-test]"))
assert.True(t, gitConfigContains("cfg-key-a = CfgValA"))
}
func TestParseGitVersion(t *testing.T) { func TestParseGitVersion(t *testing.T) {
v, err := parseGitVersionLine("git version 2.29.3") v, err := parseGitVersionLine("git version 2.29.3")
assert.NoError(t, err) assert.NoError(t, err)

View File

@ -28,7 +28,7 @@ const isGogit = true
type Repository struct { type Repository struct {
Path string Path string
tagCache *ObjectCache tagCache *ObjectCache[*Tag]
gogitRepo *gogit.Repository gogitRepo *gogit.Repository
gogitStorage *filesystem.Storage gogitStorage *filesystem.Storage
@ -79,7 +79,7 @@ func OpenRepository(ctx context.Context, repoPath string) (*Repository, error) {
Path: repoPath, Path: repoPath,
gogitRepo: gogitRepo, gogitRepo: gogitRepo,
gogitStorage: storage, gogitStorage: storage,
tagCache: newObjectCache(), tagCache: newObjectCache[*Tag](),
Ctx: ctx, Ctx: ctx,
objectFormat: ParseGogitHash(plumbing.ZeroHash).Type(), objectFormat: ParseGogitHash(plumbing.ZeroHash).Type(),
}, nil }, nil

View File

@ -21,7 +21,7 @@ const isGogit = false
type Repository struct { type Repository struct {
Path string Path string
tagCache *ObjectCache tagCache *ObjectCache[*Tag]
gpgSettings *GPGSettings gpgSettings *GPGSettings
@ -53,7 +53,7 @@ func OpenRepository(ctx context.Context, repoPath string) (*Repository, error) {
return &Repository{ return &Repository{
Path: repoPath, Path: repoPath,
tagCache: newObjectCache(), tagCache: newObjectCache[*Tag](),
Ctx: ctx, Ctx: ctx,
}, nil }, nil
} }

View File

@ -72,7 +72,7 @@ func (repo *Repository) getTag(tagID ObjectID, name string) (*Tag, error) {
t, ok := repo.tagCache.Get(tagID.String()) t, ok := repo.tagCache.Get(tagID.String())
if ok { if ok {
log.Debug("Hit cache: %s", tagID) log.Debug("Hit cache: %s", tagID)
tagClone := *t.(*Tag) tagClone := *t
tagClone.Name = name // This is necessary because lightweight tags may have same id tagClone.Name = name // This is necessary because lightweight tags may have same id
return &tagClone, nil return &tagClone, nil
} }

View File

@ -51,7 +51,7 @@ func (repo *Repository) getTag(tagID ObjectID, name string) (*Tag, error) {
t, ok := repo.tagCache.Get(tagID.String()) t, ok := repo.tagCache.Get(tagID.String())
if ok { if ok {
log.Debug("Hit cache: %s", tagID) log.Debug("Hit cache: %s", tagID)
tagClone := *t.(*Tag) tagClone := *t
tagClone.Name = name // This is necessary because lightweight tags may have same id tagClone.Name = name // This is necessary because lightweight tags may have same id
return &tagClone, nil return &tagClone, nil
} }

View File

@ -15,27 +15,25 @@ import (
) )
// ObjectCache provides thread-safe cache operations. // ObjectCache provides thread-safe cache operations.
type ObjectCache struct { type ObjectCache[T any] struct {
lock sync.RWMutex lock sync.RWMutex
cache map[string]any cache map[string]T
} }
func newObjectCache() *ObjectCache { func newObjectCache[T any]() *ObjectCache[T] {
return &ObjectCache{ return &ObjectCache[T]{cache: make(map[string]T, 10)}
cache: make(map[string]any, 10),
}
} }
// Set add obj to cache // Set adds obj to cache
func (oc *ObjectCache) Set(id string, obj any) { func (oc *ObjectCache[T]) Set(id string, obj T) {
oc.lock.Lock() oc.lock.Lock()
defer oc.lock.Unlock() defer oc.lock.Unlock()
oc.cache[id] = obj oc.cache[id] = obj
} }
// Get get cached obj by id // Get gets cached obj by id
func (oc *ObjectCache) Get(id string) (any, bool) { func (oc *ObjectCache[T]) Get(id string) (T, bool) {
oc.lock.RLock() oc.lock.RLock()
defer oc.lock.RUnlock() defer oc.lock.RUnlock()

View File

@ -1,25 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package html
// ParseSizeAndClass get size and class from string with default values
// If present, "others" expects the new size first and then the classes to use
func ParseSizeAndClass(defaultSize int, defaultClass string, others ...any) (int, string) {
size := defaultSize
if len(others) >= 1 {
if v, ok := others[0].(int); ok && v != 0 {
size = v
}
}
class := defaultClass
if len(others) >= 2 {
if v, ok := others[1].(string); ok && v != "" {
if class != "" {
class += " "
}
class += v
}
}
return size, class
}

48
modules/htmlutil/html.go Normal file
View File

@ -0,0 +1,48 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package htmlutil
import (
"fmt"
"html/template"
"slices"
)
// ParseSizeAndClass get size and class from string with default values
// If present, "others" expects the new size first and then the classes to use
func ParseSizeAndClass(defaultSize int, defaultClass string, others ...any) (int, string) {
size := defaultSize
if len(others) >= 1 {
if v, ok := others[0].(int); ok && v != 0 {
size = v
}
}
class := defaultClass
if len(others) >= 2 {
if v, ok := others[1].(string); ok && v != "" {
if class != "" {
class += " "
}
class += v
}
}
return size, class
}
func HTMLFormat(s string, rawArgs ...any) template.HTML {
args := slices.Clone(rawArgs)
for i, v := range args {
switch v := v.(type) {
case nil, bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, template.HTML:
// for most basic types (including template.HTML which is safe), just do nothing and use it
case string:
args[i] = template.HTMLEscapeString(v)
case fmt.Stringer:
args[i] = template.HTMLEscapeString(v.String())
default:
args[i] = template.HTMLEscapeString(fmt.Sprint(v))
}
}
return template.HTML(fmt.Sprintf(s, args...))
}

View File

@ -0,0 +1,15 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package htmlutil
import (
"html/template"
"testing"
"github.com/stretchr/testify/assert"
)
func TestHTMLFormat(t *testing.T) {
assert.Equal(t, template.HTML("<a>&lt; < 1</a>"), HTMLFormat("<a>%s %s %d</a>", "<", template.HTML("<"), 1))
}

View File

@ -7,7 +7,6 @@ import (
"fmt" "fmt"
"io" "io"
"net/url" "net/url"
"regexp"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
@ -38,10 +37,7 @@ const (
// SanitizerRules implements markup.Renderer // SanitizerRules implements markup.Renderer
func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule { func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
return []setting.MarkupSanitizerRule{ return []setting.MarkupSanitizerRule{{Element: "div", AllowAttr: playerSrcAttr}}
{Element: "div", AllowAttr: "class", Regexp: regexp.MustCompile(playerClassName)},
{Element: "div", AllowAttr: playerSrcAttr},
}
} }
// Render implements markup.Renderer // Render implements markup.Renderer
@ -53,12 +49,5 @@ func (Renderer) Render(ctx *markup.RenderContext, _ io.Reader, output io.Writer)
ctx.Metas["BranchNameSubURL"], ctx.Metas["BranchNameSubURL"],
url.PathEscape(ctx.RelativePath), url.PathEscape(ctx.RelativePath),
) )
return ctx.RenderInternal.FormatWithSafeAttrs(output, `<div class="%s" %s="%s"></div>`, playerClassName, playerSrcAttr, rawURL)
_, err := io.WriteString(output, fmt.Sprintf(
`<div class="%s" %s="%s"></div>`,
playerClassName,
playerSrcAttr,
rawURL,
))
return err
} }

View File

@ -1,16 +0,0 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"mvdan.cc/xurls/v2"
)
// NOTE: All below regex matching do not perform any extra validation.
// Thus a link is produced even if the linked entity does not exist.
// While fast, this is also incorrect and lead to false positives.
// TODO: fix invalid linking issue
// LinkRegex is a regexp matching a valid link
var LinkRegex, _ = xurls.StrictMatchingScheme("https?://")

View File

@ -9,15 +9,27 @@ package common
import ( import (
"bytes" "bytes"
"regexp" "regexp"
"sync"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text" "github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util" "github.com/yuin/goldmark/util"
"mvdan.cc/xurls/v2"
) )
var wwwURLRegxp = regexp.MustCompile(`^www\.[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}((?:/|[#?])[-a-zA-Z0-9@:%_\+.~#!?&//=\(\);,'">\^{}\[\]` + "`" + `]*)?`) type GlobalVarsType struct {
wwwURLRegxp *regexp.Regexp
LinkRegex *regexp.Regexp // fast matching a URL link, no any extra validation.
}
var GlobalVars = sync.OnceValue[*GlobalVarsType](func() *GlobalVarsType {
v := &GlobalVarsType{}
v.wwwURLRegxp = regexp.MustCompile(`^www\.[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}((?:/|[#?])[-a-zA-Z0-9@:%_\+.~#!?&//=\(\);,'">\^{}\[\]` + "`" + `]*)?`)
v.LinkRegex, _ = xurls.StrictMatchingScheme("https?://")
return v
})
type linkifyParser struct{} type linkifyParser struct{}
@ -60,10 +72,10 @@ func (s *linkifyParser) Parse(parent ast.Node, block text.Reader, pc parser.Cont
var protocol []byte var protocol []byte
typ := ast.AutoLinkURL typ := ast.AutoLinkURL
if bytes.HasPrefix(line, protoHTTP) || bytes.HasPrefix(line, protoHTTPS) || bytes.HasPrefix(line, protoFTP) { if bytes.HasPrefix(line, protoHTTP) || bytes.HasPrefix(line, protoHTTPS) || bytes.HasPrefix(line, protoFTP) {
m = LinkRegex.FindSubmatchIndex(line) m = GlobalVars().LinkRegex.FindSubmatchIndex(line)
} }
if m == nil && bytes.HasPrefix(line, domainWWW) { if m == nil && bytes.HasPrefix(line, domainWWW) {
m = wwwURLRegxp.FindSubmatchIndex(line) m = GlobalVars().wwwURLRegxp.FindSubmatchIndex(line)
protocol = []byte("http") protocol = []byte("http")
} }
if m != nil { if m != nil {

View File

@ -6,8 +6,7 @@ package console
import ( import (
"bytes" "bytes"
"io" "io"
"path/filepath" "path"
"regexp"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
@ -36,7 +35,7 @@ func (Renderer) Extensions() []string {
// SanitizerRules implements markup.Renderer // SanitizerRules implements markup.Renderer
func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule { func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
return []setting.MarkupSanitizerRule{ return []setting.MarkupSanitizerRule{
{Element: "span", AllowAttr: "class", Regexp: regexp.MustCompile(`^term-((fg[ix]?|bg)\d+|container)$`)}, {Element: "span", AllowAttr: "class", Regexp: `^term-((fg[ix]?|bg)\d+|container)$`},
} }
} }
@ -46,7 +45,7 @@ func (Renderer) CanRender(filename string, input io.Reader) bool {
if err != nil { if err != nil {
return false return false
} }
if enry.GetLanguage(filepath.Base(filename), buf) != enry.OtherLanguage { if enry.GetLanguage(path.Base(filename), buf) != enry.OtherLanguage {
return false return false
} }
return bytes.ContainsRune(buf, '\x1b') return bytes.ContainsRune(buf, '\x1b')

View File

@ -7,7 +7,6 @@ import (
"bufio" "bufio"
"html" "html"
"io" "io"
"regexp"
"strconv" "strconv"
"code.gitea.io/gitea/modules/csv" "code.gitea.io/gitea/modules/csv"
@ -37,9 +36,9 @@ func (Renderer) Extensions() []string {
// SanitizerRules implements markup.Renderer // SanitizerRules implements markup.Renderer
func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule { func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
return []setting.MarkupSanitizerRule{ return []setting.MarkupSanitizerRule{
{Element: "table", AllowAttr: "class", Regexp: regexp.MustCompile(`data-table`)}, {Element: "table", AllowAttr: "class", Regexp: `^data-table$`},
{Element: "th", AllowAttr: "class", Regexp: regexp.MustCompile(`line-num`)}, {Element: "th", AllowAttr: "class", Regexp: `^line-num$`},
{Element: "td", AllowAttr: "class", Regexp: regexp.MustCompile(`line-num`)}, {Element: "td", AllowAttr: "class", Regexp: `^line-num$`},
} }
} }
@ -51,13 +50,13 @@ func writeField(w io.Writer, element, class, field string) error {
return err return err
} }
if len(class) > 0 { if len(class) > 0 {
if _, err := io.WriteString(w, " class=\""); err != nil { if _, err := io.WriteString(w, ` class="`); err != nil {
return err return err
} }
if _, err := io.WriteString(w, class); err != nil { if _, err := io.WriteString(w, class); err != nil {
return err return err
} }
if _, err := io.WriteString(w, "\""); err != nil { if _, err := io.WriteString(w, `"`); err != nil {
return err return err
} }
} }

View File

@ -102,7 +102,7 @@ func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.
_, err = io.Copy(f, input) _, err = io.Copy(f, input)
if err != nil { if err != nil {
f.Close() _ = f.Close()
return fmt.Errorf("%s write data to temp file when rendering %s failed: %w", p.Name(), p.Command, err) return fmt.Errorf("%s write data to temp file when rendering %s failed: %w", p.Name(), p.Command, err)
} }
@ -113,10 +113,9 @@ func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.
args = append(args, f.Name()) args = append(args, f.Name())
} }
if ctx == nil || ctx.Ctx == nil { if ctx.Ctx == nil {
if ctx == nil { if !setting.IsProd || setting.IsInTesting {
log.Warn("RenderContext not provided defaulting to empty ctx") panic("RenderContext did not provide context")
ctx = &markup.RenderContext{}
} }
log.Warn("RenderContext did not provide context, defaulting to Shutdown context") log.Warn("RenderContext did not provide context, defaulting to Shutdown context")
ctx.Ctx = graceful.GetManager().ShutdownContext() ctx.Ctx = graceful.GetManager().ShutdownContext()

View File

@ -25,9 +25,6 @@ const (
IssueNameStyleRegexp = "regexp" IssueNameStyleRegexp = "regexp"
) )
// CSS class for action keywords (e.g. "closes: #1")
const keywordClass = "issue-keyword"
type globalVarsType struct { type globalVarsType struct {
hashCurrentPattern *regexp.Regexp hashCurrentPattern *regexp.Regexp
shortLinkPattern *regexp.Regexp shortLinkPattern *regexp.Regexp
@ -39,6 +36,7 @@ type globalVarsType struct {
emojiShortCodeRegex *regexp.Regexp emojiShortCodeRegex *regexp.Regexp
issueFullPattern *regexp.Regexp issueFullPattern *regexp.Regexp
filesChangedFullPattern *regexp.Regexp filesChangedFullPattern *regexp.Regexp
codePreviewPattern *regexp.Regexp
tagCleaner *regexp.Regexp tagCleaner *regexp.Regexp
nulCleaner *strings.Replacer nulCleaner *strings.Replacer
@ -88,6 +86,9 @@ var globalVars = sync.OnceValue[*globalVarsType](func() *globalVarsType {
// example: https://domain/org/repo/pulls/27/files#hash // example: https://domain/org/repo/pulls/27/files#hash
v.filesChangedFullPattern = regexp.MustCompile(`https?://(?:\S+/)[\w_.-]+/[\w_.-]+/pulls/((?:\w{1,10}-)?[1-9][0-9]*)/files([\?|#](\S+)?)?\b`) v.filesChangedFullPattern = regexp.MustCompile(`https?://(?:\S+/)[\w_.-]+/[\w_.-]+/pulls/((?:\w{1,10}-)?[1-9][0-9]*)/files([\?|#](\S+)?)?\b`)
// codePreviewPattern matches "http://domain/.../{owner}/{repo}/src/commit/{commit}/{filepath}#L10-L20"
v.codePreviewPattern = regexp.MustCompile(`https?://\S+/([^\s/]+)/([^\s/]+)/src/commit/([0-9a-f]{7,64})(/\S+)#(L\d+(-L\d+)?)`)
v.tagCleaner = regexp.MustCompile(`<((?:/?\w+/\w+)|(?:/[\w ]+/)|(/?[hH][tT][mM][lL]\b)|(/?[hH][eE][aA][dD]\b))`) v.tagCleaner = regexp.MustCompile(`<((?:/?\w+/\w+)|(?:/[\w ]+/)|(/?[hH][tT][mM][lL]\b)|(/?[hH][eE][aA][dD]\b))`)
v.nulCleaner = strings.NewReplacer("\000", "") v.nulCleaner = strings.NewReplacer("\000", "")
return v return v
@ -129,7 +130,7 @@ func CustomLinkURLSchemes(schemes []string) {
} }
withAuth = append(withAuth, s) withAuth = append(withAuth, s)
} }
common.LinkRegex, _ = xurls.StrictMatchingScheme(strings.Join(withAuth, "|")) common.GlobalVars().LinkRegex, _ = xurls.StrictMatchingScheme(strings.Join(withAuth, "|"))
} }
type postProcessError struct { type postProcessError struct {
@ -164,11 +165,7 @@ var defaultProcessors = []processor{
// emails with HTML links, parsing shortlinks in the format of [[Link]], like // emails with HTML links, parsing shortlinks in the format of [[Link]], like
// MediaWiki, linking issues in the format #ID, and mentions in the format // MediaWiki, linking issues in the format #ID, and mentions in the format
// @user, and others. // @user, and others.
func PostProcess( func PostProcess(ctx *RenderContext, input io.Reader, output io.Writer) error {
ctx *RenderContext,
input io.Reader,
output io.Writer,
) error {
return postProcess(ctx, defaultProcessors, input, output) return postProcess(ctx, defaultProcessors, input, output)
} }
@ -189,10 +186,7 @@ var commitMessageProcessors = []processor{
// RenderCommitMessage will use the same logic as PostProcess, but will disable // RenderCommitMessage will use the same logic as PostProcess, but will disable
// the shortLinkProcessor and will add a defaultLinkProcessor if defaultLink is // the shortLinkProcessor and will add a defaultLinkProcessor if defaultLink is
// set, which changes every text node into a link to the passed default link. // set, which changes every text node into a link to the passed default link.
func RenderCommitMessage( func RenderCommitMessage(ctx *RenderContext, content string) (string, error) {
ctx *RenderContext,
content string,
) (string, error) {
procs := commitMessageProcessors procs := commitMessageProcessors
return renderProcessString(ctx, procs, content) return renderProcessString(ctx, procs, content)
} }
@ -219,10 +213,7 @@ var emojiProcessors = []processor{
// RenderCommitMessage, but will disable the shortLinkProcessor and // RenderCommitMessage, but will disable the shortLinkProcessor and
// emailAddressProcessor, will add a defaultLinkProcessor if defaultLink is set, // emailAddressProcessor, will add a defaultLinkProcessor if defaultLink is set,
// which changes every text node into a link to the passed default link. // which changes every text node into a link to the passed default link.
func RenderCommitMessageSubject( func RenderCommitMessageSubject(ctx *RenderContext, defaultLink, content string) (string, error) {
ctx *RenderContext,
defaultLink, content string,
) (string, error) {
procs := slices.Clone(commitMessageSubjectProcessors) procs := slices.Clone(commitMessageSubjectProcessors)
procs = append(procs, func(ctx *RenderContext, node *html.Node) { procs = append(procs, func(ctx *RenderContext, node *html.Node) {
ch := &html.Node{Parent: node, Type: html.TextNode, Data: node.Data} ch := &html.Node{Parent: node, Type: html.TextNode, Data: node.Data}
@ -236,10 +227,7 @@ func RenderCommitMessageSubject(
} }
// RenderIssueTitle to process title on individual issue/pull page // RenderIssueTitle to process title on individual issue/pull page
func RenderIssueTitle( func RenderIssueTitle(ctx *RenderContext, title string) (string, error) {
ctx *RenderContext,
title string,
) (string, error) {
// do not render other issue/commit links in an issue's title - which in most cases is already a link. // do not render other issue/commit links in an issue's title - which in most cases is already a link.
return renderProcessString(ctx, []processor{ return renderProcessString(ctx, []processor{
emojiShortCodeProcessor, emojiShortCodeProcessor,
@ -257,10 +245,7 @@ func renderProcessString(ctx *RenderContext, procs []processor, content string)
// RenderDescriptionHTML will use similar logic as PostProcess, but will // RenderDescriptionHTML will use similar logic as PostProcess, but will
// use a single special linkProcessor. // use a single special linkProcessor.
func RenderDescriptionHTML( func RenderDescriptionHTML(ctx *RenderContext, content string) (string, error) {
ctx *RenderContext,
content string,
) (string, error) {
return renderProcessString(ctx, []processor{ return renderProcessString(ctx, []processor{
descriptionLinkProcessor, descriptionLinkProcessor,
emojiShortCodeProcessor, emojiShortCodeProcessor,
@ -270,10 +255,7 @@ func RenderDescriptionHTML(
// RenderEmoji for when we want to just process emoji and shortcodes // RenderEmoji for when we want to just process emoji and shortcodes
// in various places it isn't already run through the normal markdown processor // in various places it isn't already run through the normal markdown processor
func RenderEmoji( func RenderEmoji(ctx *RenderContext, content string) (string, error) {
ctx *RenderContext,
content string,
) (string, error) {
return renderProcessString(ctx, emojiProcessors, content) return renderProcessString(ctx, emojiProcessors, content)
} }
@ -333,6 +315,17 @@ func postProcess(ctx *RenderContext, procs []processor, input io.Reader, output
return nil return nil
} }
func isEmojiNode(node *html.Node) bool {
if node.Type == html.ElementNode && node.Data == atom.Span.String() {
for _, attr := range node.Attr {
if (attr.Key == "class" || attr.Key == "data-attr-class") && strings.Contains(attr.Val, "emoji") {
return true
}
}
}
return false
}
func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Node { func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Node {
// Add user-content- to IDs and "#" links if they don't already have them // Add user-content- to IDs and "#" links if they don't already have them
for idx, attr := range node.Attr { for idx, attr := range node.Attr {
@ -346,47 +339,27 @@ func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Nod
if attr.Key == "href" && strings.HasPrefix(attr.Val, "#") && notHasPrefix { if attr.Key == "href" && strings.HasPrefix(attr.Val, "#") && notHasPrefix {
node.Attr[idx].Val = "#user-content-" + val node.Attr[idx].Val = "#user-content-" + val
} }
if attr.Key == "class" && attr.Val == "emoji" {
procs = nil
}
} }
switch node.Type { switch node.Type {
case html.TextNode: case html.TextNode:
processTextNodes(ctx, procs, node) for _, proc := range procs {
proc(ctx, node) // it might add siblings
}
case html.ElementNode: case html.ElementNode:
if node.Data == "code" || node.Data == "pre" { if isEmojiNode(node) {
// ignore code and pre nodes // TextNode emoji will be converted to `<span class="emoji">`, then the next iteration will visit the "span"
// if we don't stop it, it will go into the TextNode again and create an infinite recursion
return node.NextSibling return node.NextSibling
} else if node.Data == "code" || node.Data == "pre" {
return node.NextSibling // ignore code and pre nodes
} else if node.Data == "img" { } else if node.Data == "img" {
return visitNodeImg(ctx, node) return visitNodeImg(ctx, node)
} else if node.Data == "video" { } else if node.Data == "video" {
return visitNodeVideo(ctx, node) return visitNodeVideo(ctx, node)
} else if node.Data == "a" { } else if node.Data == "a" {
// Restrict text in links to emojis procs = emojiProcessors // Restrict text in links to emojis
procs = emojiProcessors
} else if node.Data == "i" {
for _, attr := range node.Attr {
if attr.Key != "class" {
continue
}
classes := strings.Split(attr.Val, " ")
for i, class := range classes {
if class == "icon" {
classes[0], classes[i] = classes[i], classes[0]
attr.Val = strings.Join(classes, " ")
// Remove all children of icons
child := node.FirstChild
for child != nil {
node.RemoveChild(child)
child = node.FirstChild
}
break
}
}
}
} }
for n := node.FirstChild; n != nil; { for n := node.FirstChild; n != nil; {
n = visitNode(ctx, procs, n) n = visitNode(ctx, procs, n)
@ -396,22 +369,17 @@ func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Nod
return node.NextSibling return node.NextSibling
} }
// processTextNodes runs the passed node through various processors, in order to handle
// all kinds of special links handled by the post-processing.
func processTextNodes(ctx *RenderContext, procs []processor, node *html.Node) {
for _, p := range procs {
p(ctx, node)
}
}
// createKeyword() renders a highlighted version of an action keyword // createKeyword() renders a highlighted version of an action keyword
func createKeyword(content string) *html.Node { func createKeyword(ctx *RenderContext, content string) *html.Node {
// CSS class for action keywords (e.g. "closes: #1")
const keywordClass = "issue-keyword"
span := &html.Node{ span := &html.Node{
Type: html.ElementNode, Type: html.ElementNode,
Data: atom.Span.String(), Data: atom.Span.String(),
Attr: []html.Attribute{}, Attr: []html.Attribute{},
} }
span.Attr = append(span.Attr, html.Attribute{Key: "class", Val: keywordClass}) span.Attr = append(span.Attr, ctx.RenderInternal.NodeSafeAttr("class", keywordClass))
text := &html.Node{ text := &html.Node{
Type: html.TextNode, Type: html.TextNode,
@ -422,7 +390,7 @@ func createKeyword(content string) *html.Node {
return span return span
} }
func createLink(href, content, class string) *html.Node { func createLink(ctx *RenderContext, href, content, class string) *html.Node {
a := &html.Node{ a := &html.Node{
Type: html.ElementNode, Type: html.ElementNode,
Data: atom.A.String(), Data: atom.A.String(),
@ -432,7 +400,7 @@ func createLink(href, content, class string) *html.Node {
a.Attr = append(a.Attr, html.Attribute{Key: "data-markdown-generated-content"}) a.Attr = append(a.Attr, html.Attribute{Key: "data-markdown-generated-content"})
} }
if class != "" { if class != "" {
a.Attr = append(a.Attr, html.Attribute{Key: "class", Val: class}) a.Attr = append(a.Attr, ctx.RenderInternal.NodeSafeAttr("class", class))
} }
text := &html.Node{ text := &html.Node{

View File

@ -6,7 +6,6 @@ package markup
import ( import (
"html/template" "html/template"
"net/url" "net/url"
"regexp"
"strconv" "strconv"
"strings" "strings"
@ -16,9 +15,6 @@ import (
"golang.org/x/net/html" "golang.org/x/net/html"
) )
// codePreviewPattern matches "http://domain/.../{owner}/{repo}/src/commit/{commit}/{filepath}#L10-L20"
var codePreviewPattern = regexp.MustCompile(`https?://\S+/([^\s/]+)/([^\s/]+)/src/commit/([0-9a-f]{7,64})(/\S+)#(L\d+(-L\d+)?)`)
type RenderCodePreviewOptions struct { type RenderCodePreviewOptions struct {
FullURL string FullURL string
OwnerName string OwnerName string
@ -30,7 +26,7 @@ type RenderCodePreviewOptions struct {
} }
func renderCodeBlock(ctx *RenderContext, node *html.Node) (urlPosStart, urlPosStop int, htm template.HTML, err error) { func renderCodeBlock(ctx *RenderContext, node *html.Node) (urlPosStart, urlPosStop int, htm template.HTML, err error) {
m := codePreviewPattern.FindStringSubmatchIndex(node.Data) m := globalVars().codePreviewPattern.FindStringSubmatchIndex(node.Data)
if m == nil { if m == nil {
return 0, 0, "", nil return 0, 0, "", nil
} }
@ -66,8 +62,8 @@ func codePreviewPatternProcessor(ctx *RenderContext, node *html.Node) {
node = node.NextSibling node = node.NextSibling
continue continue
} }
urlPosStart, urlPosEnd, h, err := renderCodeBlock(ctx, node) urlPosStart, urlPosEnd, renderedCodeBlock, err := renderCodeBlock(ctx, node)
if err != nil || h == "" { if err != nil || renderedCodeBlock == "" {
if err != nil { if err != nil {
log.Error("Unable to render code preview: %v", err) log.Error("Unable to render code preview: %v", err)
} }
@ -84,7 +80,8 @@ func codePreviewPatternProcessor(ctx *RenderContext, node *html.Node) {
// then it is resolved as: "<p>{TextBefore}</p><div NewNode/><p>{TextAfter}</p>", // then it is resolved as: "<p>{TextBefore}</p><div NewNode/><p>{TextAfter}</p>",
// so unless it could correctly replace the parent "p/li" node, it is very difficult to eliminate the "TextBefore" empty node. // so unless it could correctly replace the parent "p/li" node, it is very difficult to eliminate the "TextBefore" empty node.
node.Data = textBefore node.Data = textBefore
node.Parent.InsertBefore(&html.Node{Type: html.RawNode, Data: string(h)}, next) renderedCodeNode := &html.Node{Type: html.RawNode, Data: string(ctx.RenderInternal.ProtectSafeAttrs(renderedCodeBlock))}
node.Parent.InsertBefore(renderedCodeNode, next)
if textAfter != "" { if textAfter != "" {
node.Parent.InsertBefore(&html.Node{Type: html.TextNode, Data: textAfter}, next) node.Parent.InsertBefore(&html.Node{Type: html.TextNode, Data: textAfter}, next)
} }

View File

@ -15,7 +15,7 @@ func emailAddressProcessor(ctx *RenderContext, node *html.Node) {
} }
mail := node.Data[m[2]:m[3]] mail := node.Data[m[2]:m[3]]
replaceContent(node, m[2], m[3], createLink("mailto:"+mail, mail, "mailto")) replaceContent(node, m[2], m[3], createLink(ctx, "mailto:"+mail, mail, "" /*mailto*/))
node = node.NextSibling.NextSibling node = node.NextSibling.NextSibling
} }
} }

View File

@ -13,15 +13,13 @@ import (
"golang.org/x/net/html/atom" "golang.org/x/net/html/atom"
) )
func createEmoji(content, class, name string) *html.Node { func createEmoji(ctx *RenderContext, content, name string) *html.Node {
span := &html.Node{ span := &html.Node{
Type: html.ElementNode, Type: html.ElementNode,
Data: atom.Span.String(), Data: atom.Span.String(),
Attr: []html.Attribute{}, Attr: []html.Attribute{},
} }
if class != "" { span.Attr = append(span.Attr, ctx.RenderInternal.NodeSafeAttr("class", "emoji"))
span.Attr = append(span.Attr, html.Attribute{Key: "class", Val: class})
}
if name != "" { if name != "" {
span.Attr = append(span.Attr, html.Attribute{Key: "aria-label", Val: name}) span.Attr = append(span.Attr, html.Attribute{Key: "aria-label", Val: name})
} }
@ -35,13 +33,13 @@ func createEmoji(content, class, name string) *html.Node {
return span return span
} }
func createCustomEmoji(alias string) *html.Node { func createCustomEmoji(ctx *RenderContext, alias string) *html.Node {
span := &html.Node{ span := &html.Node{
Type: html.ElementNode, Type: html.ElementNode,
Data: atom.Span.String(), Data: atom.Span.String(),
Attr: []html.Attribute{}, Attr: []html.Attribute{},
} }
span.Attr = append(span.Attr, html.Attribute{Key: "class", Val: "emoji"}) span.Attr = append(span.Attr, ctx.RenderInternal.NodeSafeAttr("class", "emoji"))
span.Attr = append(span.Attr, html.Attribute{Key: "aria-label", Val: alias}) span.Attr = append(span.Attr, html.Attribute{Key: "aria-label", Val: alias})
img := &html.Node{ img := &html.Node{
@ -77,7 +75,7 @@ func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) {
if converted == nil { if converted == nil {
// check if this is a custom reaction // check if this is a custom reaction
if _, exist := setting.UI.CustomEmojisMap[alias]; exist { if _, exist := setting.UI.CustomEmojisMap[alias]; exist {
replaceContent(node, m[0], m[1], createCustomEmoji(alias)) replaceContent(node, m[0], m[1], createCustomEmoji(ctx, alias))
node = node.NextSibling.NextSibling node = node.NextSibling.NextSibling
start = 0 start = 0
continue continue
@ -85,7 +83,7 @@ func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) {
continue continue
} }
replaceContent(node, m[0], m[1], createEmoji(converted.Emoji, "emoji", converted.Description)) replaceContent(node, m[0], m[1], createEmoji(ctx, converted.Emoji, converted.Description))
node = node.NextSibling.NextSibling node = node.NextSibling.NextSibling
start = 0 start = 0
} }
@ -107,7 +105,7 @@ func emojiProcessor(ctx *RenderContext, node *html.Node) {
start = m[1] start = m[1]
val := emoji.FromCode(codepoint) val := emoji.FromCode(codepoint)
if val != nil { if val != nil {
replaceContent(node, m[0], m[1], createEmoji(codepoint, "emoji", val.Description)) replaceContent(node, m[0], m[1], createEmoji(ctx, codepoint, val.Description))
node = node.NextSibling.NextSibling node = node.NextSibling.NextSibling
start = 0 start = 0
} }

View File

@ -57,10 +57,10 @@ func fullIssuePatternProcessor(ctx *RenderContext, node *html.Node) {
matchRepo := linkParts[len(linkParts)-3] matchRepo := linkParts[len(linkParts)-3]
if matchOrg == ctx.Metas["user"] && matchRepo == ctx.Metas["repo"] { if matchOrg == ctx.Metas["user"] && matchRepo == ctx.Metas["repo"] {
replaceContent(node, m[0], m[1], createLink(link, text, "ref-issue")) replaceContent(node, m[0], m[1], createLink(ctx, link, text, "ref-issue"))
} else { } else {
text = matchOrg + "/" + matchRepo + text text = matchOrg + "/" + matchRepo + text
replaceContent(node, m[0], m[1], createLink(link, text, "ref-issue")) replaceContent(node, m[0], m[1], createLink(ctx, link, text, "ref-issue"))
} }
node = node.NextSibling.NextSibling node = node.NextSibling.NextSibling
} }
@ -129,16 +129,16 @@ func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {
log.Error("unable to expand template vars for ref %s, err: %v", ref.Issue, err) log.Error("unable to expand template vars for ref %s, err: %v", ref.Issue, err)
} }
link = createLink(res, reftext, "ref-issue ref-external-issue") link = createLink(ctx, res, reftext, "ref-issue ref-external-issue")
} else { } else {
// Path determines the type of link that will be rendered. It's unknown at this point whether // Path determines the type of link that will be rendered. It's unknown at this point whether
// the linked item is actually a PR or an issue. Luckily it's of no real consequence because // the linked item is actually a PR or an issue. Luckily it's of no real consequence because
// Gitea will redirect on click as appropriate. // Gitea will redirect on click as appropriate.
issuePath := util.Iif(ref.IsPull, "pulls", "issues") issuePath := util.Iif(ref.IsPull, "pulls", "issues")
if ref.Owner == "" { if ref.Owner == "" {
link = createLink(util.URLJoin(ctx.Links.Prefix(), ctx.Metas["user"], ctx.Metas["repo"], issuePath, ref.Issue), reftext, "ref-issue") link = createLink(ctx, util.URLJoin(ctx.Links.Prefix(), ctx.Metas["user"], ctx.Metas["repo"], issuePath, ref.Issue), reftext, "ref-issue")
} else { } else {
link = createLink(util.URLJoin(ctx.Links.Prefix(), ref.Owner, ref.Name, issuePath, ref.Issue), reftext, "ref-issue") link = createLink(ctx, util.URLJoin(ctx.Links.Prefix(), ref.Owner, ref.Name, issuePath, ref.Issue), reftext, "ref-issue")
} }
} }
@ -151,7 +151,7 @@ func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {
// Decorate action keywords if actionable // Decorate action keywords if actionable
var keyword *html.Node var keyword *html.Node
if references.IsXrefActionable(ref, hasExtTrackFormat) { if references.IsXrefActionable(ref, hasExtTrackFormat) {
keyword = createKeyword(node.Data[ref.ActionLocation.Start:ref.ActionLocation.End]) keyword = createKeyword(ctx, node.Data[ref.ActionLocation.Start:ref.ActionLocation.End])
} else { } else {
keyword = &html.Node{ keyword = &html.Node{
Type: html.TextNode, Type: html.TextNode,
@ -177,7 +177,7 @@ func commitCrossReferencePatternProcessor(ctx *RenderContext, node *html.Node) {
} }
reftext := ref.Owner + "/" + ref.Name + "@" + base.ShortSha(ref.CommitSha) reftext := ref.Owner + "/" + ref.Name + "@" + base.ShortSha(ref.CommitSha)
link := createLink(util.URLJoin(ctx.Links.Prefix(), ref.Owner, ref.Name, "commit", ref.CommitSha), reftext, "commit") link := createLink(ctx, util.URLJoin(ctx.Links.Prefix(), ref.Owner, ref.Name, "commit", ref.CommitSha), reftext, "commit")
replaceContent(node, ref.RefLocation.Start, ref.RefLocation.End, link) replaceContent(node, ref.RefLocation.Start, ref.RefLocation.End, link)
node = node.NextSibling.NextSibling node = node.NextSibling.NextSibling

View File

@ -189,13 +189,13 @@ func shortLinkProcessor(ctx *RenderContext, node *html.Node) {
func linkProcessor(ctx *RenderContext, node *html.Node) { func linkProcessor(ctx *RenderContext, node *html.Node) {
next := node.NextSibling next := node.NextSibling
for node != nil && node != next { for node != nil && node != next {
m := common.LinkRegex.FindStringIndex(node.Data) m := common.GlobalVars().LinkRegex.FindStringIndex(node.Data)
if m == nil { if m == nil {
return return
} }
uri := node.Data[m[0]:m[1]] uri := node.Data[m[0]:m[1]]
replaceContent(node, m[0], m[1], createLink(uri, uri, "link")) replaceContent(node, m[0], m[1], createLink(ctx, uri, uri, "" /*link*/))
node = node.NextSibling.NextSibling node = node.NextSibling.NextSibling
} }
} }
@ -204,7 +204,7 @@ func linkProcessor(ctx *RenderContext, node *html.Node) {
func descriptionLinkProcessor(ctx *RenderContext, node *html.Node) { func descriptionLinkProcessor(ctx *RenderContext, node *html.Node) {
next := node.NextSibling next := node.NextSibling
for node != nil && node != next { for node != nil && node != next {
m := common.LinkRegex.FindStringIndex(node.Data) m := common.GlobalVars().LinkRegex.FindStringIndex(node.Data)
if m == nil { if m == nil {
return return
} }

View File

@ -33,7 +33,7 @@ func mentionProcessor(ctx *RenderContext, node *html.Node) {
if ok && strings.Contains(mention, "/") { if ok && strings.Contains(mention, "/") {
mentionOrgAndTeam := strings.Split(mention, "/") mentionOrgAndTeam := strings.Split(mention, "/")
if mentionOrgAndTeam[0][1:] == ctx.Metas["org"] && strings.Contains(teams, ","+strings.ToLower(mentionOrgAndTeam[1])+",") { if mentionOrgAndTeam[0][1:] == ctx.Metas["org"] && strings.Contains(teams, ","+strings.ToLower(mentionOrgAndTeam[1])+",") {
replaceContent(node, loc.Start, loc.End, createLink(util.URLJoin(ctx.Links.Prefix(), "org", ctx.Metas["org"], "teams", mentionOrgAndTeam[1]), mention, "mention")) replaceContent(node, loc.Start, loc.End, createLink(ctx, util.URLJoin(ctx.Links.Prefix(), "org", ctx.Metas["org"], "teams", mentionOrgAndTeam[1]), mention, "" /*mention*/))
node = node.NextSibling.NextSibling node = node.NextSibling.NextSibling
start = 0 start = 0
continue continue
@ -44,7 +44,7 @@ func mentionProcessor(ctx *RenderContext, node *html.Node) {
mentionedUsername := mention[1:] mentionedUsername := mention[1:]
if DefaultProcessorHelper.IsUsernameMentionable != nil && DefaultProcessorHelper.IsUsernameMentionable(ctx.Ctx, mentionedUsername) { if DefaultProcessorHelper.IsUsernameMentionable != nil && DefaultProcessorHelper.IsUsernameMentionable(ctx.Ctx, mentionedUsername) {
replaceContent(node, loc.Start, loc.End, createLink(util.URLJoin(ctx.Links.Prefix(), mentionedUsername), mention, "mention")) replaceContent(node, loc.Start, loc.End, createLink(ctx, util.URLJoin(ctx.Links.Prefix(), mentionedUsername), mention, "" /*mention*/))
node = node.NextSibling.NextSibling node = node.NextSibling.NextSibling
start = 0 start = 0
} else { } else {

View File

@ -0,0 +1,30 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package internal
import (
"bytes"
"io"
)
type finalProcessor struct {
renderInternal *RenderInternal
output io.Writer
buf bytes.Buffer
}
func (p *finalProcessor) Write(data []byte) (int, error) {
p.buf.Write(data)
return len(data), nil
}
func (p *finalProcessor) Close() error {
// TODO: reading the whole markdown isn't a problem at the moment,
// because "postProcess" already does so. In the future we could optimize the code to process data on the fly.
buf := p.buf.Bytes()
buf = bytes.ReplaceAll(buf, []byte(` data-attr-class="`+p.renderInternal.secureIDPrefix), []byte(` class="`))
_, err := p.output.Write(buf)
return err
}

View File

@ -0,0 +1,61 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package internal
import (
"bytes"
"html/template"
"io"
"testing"
"github.com/stretchr/testify/assert"
)
func TestRenderInternal(t *testing.T) {
cases := []struct {
input, protected, recovered string
}{
{
input: `<div class="test">class="content"</div>`,
protected: `<div data-attr-class="sec:test">class="content"</div>`,
recovered: `<div class="test">class="content"</div>`,
},
{
input: "<div\nclass=\"test\" data-xxx></div>",
protected: `<div data-attr-class="sec:test" data-xxx></div>`,
recovered: `<div class="test" data-xxx></div>`,
},
}
for _, c := range cases {
var r RenderInternal
out := &bytes.Buffer{}
in := r.init("sec", out)
protected := r.ProtectSafeAttrs(template.HTML(c.input))
assert.EqualValues(t, c.protected, protected)
_, _ = io.WriteString(in, string(protected))
_ = in.Close()
assert.EqualValues(t, c.recovered, out.String())
}
var r1, r2 RenderInternal
protected := r1.ProtectSafeAttrs(`<div class="test"></div>`)
assert.EqualValues(t, `<div class="test"></div>`, protected, "non-initialized RenderInternal should not protect any attributes")
_ = r1.init("sec", nil)
protected = r1.ProtectSafeAttrs(`<div class="test"></div>`)
assert.EqualValues(t, `<div data-attr-class="sec:test"></div>`, protected)
assert.EqualValues(t, "data-attr-class", r1.SafeAttr("class"))
assert.EqualValues(t, "sec:val", r1.SafeValue("val"))
recovered, ok := r1.RecoverProtectedValue("sec:val")
assert.True(t, ok)
assert.EqualValues(t, "val", recovered)
recovered, ok = r1.RecoverProtectedValue("other:val")
assert.False(t, ok)
assert.Empty(t, recovered)
out2 := &bytes.Buffer{}
in2 := r2.init("sec-other", out2)
_, _ = io.WriteString(in2, string(protected))
_ = in2.Close()
assert.EqualValues(t, `<div data-attr-class="sec:test"></div>`, out2.String(), "different secureID should not recover the value")
}

View File

@ -0,0 +1,82 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package internal
import (
"crypto/rand"
"encoding/base64"
"html/template"
"io"
"regexp"
"strings"
"sync"
"code.gitea.io/gitea/modules/htmlutil"
"golang.org/x/net/html"
)
var reAttrClass = sync.OnceValue[*regexp.Regexp](func() *regexp.Regexp {
// TODO: it isn't a problem at the moment because our HTML contents are always well constructed
return regexp.MustCompile(`(<[^>]+)\s+class="([^"]+)"([^>]*>)`)
})
// RenderInternal also works without initialization
// If no initialization (no secureID), it will not protect any attributes and return the original name&value
type RenderInternal struct {
secureID string
secureIDPrefix string
}
func (r *RenderInternal) Init(output io.Writer) io.WriteCloser {
buf := make([]byte, 12)
_, err := rand.Read(buf)
if err != nil {
panic("unable to generate secure id")
}
return r.init(base64.URLEncoding.EncodeToString(buf), output)
}
func (r *RenderInternal) init(secID string, output io.Writer) io.WriteCloser {
r.secureID = secID
r.secureIDPrefix = r.secureID + ":"
return &finalProcessor{renderInternal: r, output: output}
}
func (r *RenderInternal) RecoverProtectedValue(v string) (string, bool) {
if !strings.HasPrefix(v, r.secureIDPrefix) {
return "", false
}
return v[len(r.secureIDPrefix):], true
}
func (r *RenderInternal) SafeAttr(name string) string {
if r.secureID == "" {
return name
}
return "data-attr-" + name
}
func (r *RenderInternal) SafeValue(val string) string {
if r.secureID == "" {
return val
}
return r.secureID + ":" + val
}
func (r *RenderInternal) NodeSafeAttr(attr, val string) html.Attribute {
return html.Attribute{Key: r.SafeAttr(attr), Val: r.SafeValue(val)}
}
func (r *RenderInternal) ProtectSafeAttrs(content template.HTML) template.HTML {
if r.secureID == "" {
return content
}
return template.HTML(reAttrClass().ReplaceAllString(string(content), `$1 data-attr-class="`+r.secureIDPrefix+`$2"$3`))
}
func (r *RenderInternal) FormatWithSafeAttrs(w io.Writer, fmt string, a ...any) error {
_, err := w.Write([]byte(r.ProtectSafeAttrs(htmlutil.HTMLFormat(fmt, a...))))
return err
}

View File

@ -34,13 +34,6 @@ func NewDetails() *Details {
} }
} }
// IsDetails returns true if the given node implements the Details interface,
// otherwise false.
func IsDetails(node ast.Node) bool {
_, ok := node.(*Details)
return ok
}
// Summary is a block that contains the summary of details block // Summary is a block that contains the summary of details block
type Summary struct { type Summary struct {
ast.BaseBlock ast.BaseBlock
@ -66,13 +59,6 @@ func NewSummary() *Summary {
} }
} }
// IsSummary returns true if the given node implements the Summary interface,
// otherwise false.
func IsSummary(node ast.Node) bool {
_, ok := node.(*Summary)
return ok
}
// TaskCheckBoxListItem is a block that represents a list item of a markdown block with a checkbox // TaskCheckBoxListItem is a block that represents a list item of a markdown block with a checkbox
type TaskCheckBoxListItem struct { type TaskCheckBoxListItem struct {
*ast.ListItem *ast.ListItem
@ -103,14 +89,7 @@ func NewTaskCheckBoxListItem(listItem *ast.ListItem) *TaskCheckBoxListItem {
} }
} }
// IsTaskCheckBoxListItem returns true if the given node implements the TaskCheckBoxListItem interface, // Icon is an inline for a Fomantic UI icon
// otherwise false.
func IsTaskCheckBoxListItem(node ast.Node) bool {
_, ok := node.(*TaskCheckBoxListItem)
return ok
}
// Icon is an inline for a fomantic icon
type Icon struct { type Icon struct {
ast.BaseInline ast.BaseInline
Name []byte Name []byte
@ -139,13 +118,6 @@ func NewIcon(name string) *Icon {
} }
} }
// IsIcon returns true if the given node implements the Icon interface,
// otherwise false.
func IsIcon(node ast.Node) bool {
_, ok := node.(*Icon)
return ok
}
// ColorPreview is an inline for a color preview // ColorPreview is an inline for a color preview
type ColorPreview struct { type ColorPreview struct {
ast.BaseInline ast.BaseInline

View File

@ -7,9 +7,11 @@ import (
"fmt" "fmt"
"regexp" "regexp"
"strings" "strings"
"sync"
"code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/internal"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/ast"
@ -23,11 +25,13 @@ import (
// ASTTransformer is a default transformer of the goldmark tree. // ASTTransformer is a default transformer of the goldmark tree.
type ASTTransformer struct { type ASTTransformer struct {
renderInternal *internal.RenderInternal
attentionTypes container.Set[string] attentionTypes container.Set[string]
} }
func NewASTTransformer() *ASTTransformer { func NewASTTransformer(renderInternal *internal.RenderInternal) *ASTTransformer {
return &ASTTransformer{ return &ASTTransformer{
renderInternal: renderInternal,
attentionTypes: container.SetOf("note", "tip", "important", "warning", "caution"), attentionTypes: container.SetOf("note", "tip", "important", "warning", "caution"),
} }
} }
@ -109,12 +113,16 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
} }
} }
// NewHTMLRenderer creates a HTMLRenderer to render // it is copied from old code, which is quite doubtful whether it is correct
// in the gitea form. var reValidIconName = sync.OnceValue[*regexp.Regexp](func() *regexp.Regexp {
func NewHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { return regexp.MustCompile(`^[-\w]+$`) // old: regexp.MustCompile("^[a-z ]+$")
})
// NewHTMLRenderer creates a HTMLRenderer to render in the gitea form.
func NewHTMLRenderer(renderInternal *internal.RenderInternal, opts ...html.Option) renderer.NodeRenderer {
r := &HTMLRenderer{ r := &HTMLRenderer{
Config: html.NewConfig(), renderInternal: renderInternal,
reValidName: regexp.MustCompile("^[a-z ]+$"), Config: html.NewConfig(),
} }
for _, opt := range opts { for _, opt := range opts {
opt.SetHTMLOption(&r.Config) opt.SetHTMLOption(&r.Config)
@ -126,7 +134,7 @@ func NewHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
// renders gitea specific features. // renders gitea specific features.
type HTMLRenderer struct { type HTMLRenderer struct {
html.Config html.Config
reValidName *regexp.Regexp renderInternal *internal.RenderInternal
} }
// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs. // RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
@ -214,12 +222,13 @@ func (r *HTMLRenderer) renderIcon(w util.BufWriter, source []byte, node ast.Node
return ast.WalkContinue, nil return ast.WalkContinue, nil
} }
if !r.reValidName.MatchString(name) { if !reValidIconName().MatchString(name) {
// skip this // skip this
return ast.WalkContinue, nil return ast.WalkContinue, nil
} }
_, err := w.WriteString(fmt.Sprintf(`<i class="icon %s"></i>`, name)) // FIXME: the "icon xxx" is from Fomantic UI, it's really questionable whether it still works correctly
err := r.renderInternal.FormatWithSafeAttrs(w, `<i class="icon %s"></i>`, name)
if err != nil { if err != nil {
return ast.WalkStop, err return ast.WalkStop, err
} }

View File

@ -9,7 +9,6 @@ import (
"html/template" "html/template"
"io" "io"
"strings" "strings"
"sync"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
@ -29,11 +28,6 @@ import (
"github.com/yuin/goldmark/util" "github.com/yuin/goldmark/util"
) )
var (
specMarkdown goldmark.Markdown
specMarkdownOnce sync.Once
)
var ( var (
renderContextKey = parser.NewContextKey() renderContextKey = parser.NewContextKey()
renderConfigKey = parser.NewContextKey() renderConfigKey = parser.NewContextKey()
@ -68,85 +62,95 @@ func newParserContext(ctx *markup.RenderContext) parser.Context {
return pc return pc
} }
// SpecializedMarkdown sets up the Gitea specific markdown extensions type GlodmarkRender struct {
func SpecializedMarkdown() goldmark.Markdown { ctx *markup.RenderContext
specMarkdownOnce.Do(func() {
specMarkdown = goldmark.New(
goldmark.WithExtensions(
extension.NewTable(
extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute)),
extension.Strikethrough,
extension.TaskList,
extension.DefinitionList,
common.FootnoteExtension,
highlighting.NewHighlighting(
highlighting.WithFormatOptions(
chromahtml.WithClasses(true),
chromahtml.PreventSurroundingPre(true),
),
highlighting.WithWrapperRenderer(func(w util.BufWriter, c highlighting.CodeBlockContext, entering bool) {
if entering {
language, _ := c.Language()
if language == nil {
language = []byte("text")
}
languageStr := string(language) goldmarkMarkdown goldmark.Markdown
preClasses := []string{"code-block"}
if languageStr == "mermaid" || languageStr == "math" {
preClasses = append(preClasses, "is-loading")
}
_, err := w.WriteString(`<pre class="` + strings.Join(preClasses, " ") + `">`)
if err != nil {
return
}
// include language-x class as part of commonmark spec
// the "display" class is used by "js/markup/math.js" to render the code element as a block
_, err = w.WriteString(`<code class="chroma language-` + string(language) + ` display">`)
if err != nil {
return
}
} else {
_, err := w.WriteString("</code></pre>")
if err != nil {
return
}
}
}),
),
math.NewExtension(
math.Enabled(setting.Markdown.EnableMath),
),
meta.Meta,
),
goldmark.WithParserOptions(
parser.WithAttribute(),
parser.WithAutoHeadingID(),
parser.WithASTTransformers(
util.Prioritized(NewASTTransformer(), 10000),
),
),
goldmark.WithRendererOptions(
html.WithUnsafe(),
),
)
// Override the original Tasklist renderer!
specMarkdown.Renderer().AddOptions(
renderer.WithNodeRenderers(
util.Prioritized(NewHTMLRenderer(), 10),
),
)
})
return specMarkdown
} }
// actualRender renders Markdown to HTML without handling special links. func (r *GlodmarkRender) Convert(source []byte, writer io.Writer, opts ...parser.ParseOption) error {
func actualRender(ctx *markup.RenderContext, input io.Reader, output io.Writer) error { return r.goldmarkMarkdown.Convert(source, writer, opts...)
converter := SpecializedMarkdown() }
func (r *GlodmarkRender) Renderer() renderer.Renderer {
return r.goldmarkMarkdown.Renderer()
}
func (r *GlodmarkRender) highlightingRenderer(w util.BufWriter, c highlighting.CodeBlockContext, entering bool) {
if entering {
language, _ := c.Language()
if language == nil {
language = []byte("text")
}
languageStr := string(language)
preClasses := []string{"code-block"}
if languageStr == "mermaid" || languageStr == "math" {
preClasses = append(preClasses, "is-loading")
}
err := r.ctx.RenderInternal.FormatWithSafeAttrs(w, `<pre class="%s">`, strings.Join(preClasses, " "))
if err != nil {
return
}
// include language-x class as part of commonmark spec
// the "display" class is used by "js/markup/math.js" to render the code element as a block
err = r.ctx.RenderInternal.FormatWithSafeAttrs(w, `<code class="chroma language-%s display">`, string(language))
if err != nil {
return
}
} else {
_, err := w.WriteString("</code></pre>")
if err != nil {
return
}
}
}
// SpecializedMarkdown sets up the Gitea specific markdown extensions
func SpecializedMarkdown(ctx *markup.RenderContext) *GlodmarkRender {
// TODO: it could use a pool to cache the renderers to reuse them with different contexts
// at the moment it is fast enough (see the benchmarks)
r := &GlodmarkRender{ctx: ctx}
r.goldmarkMarkdown = goldmark.New(
goldmark.WithExtensions(
extension.NewTable(extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute)),
extension.Strikethrough,
extension.TaskList,
extension.DefinitionList,
common.FootnoteExtension,
highlighting.NewHighlighting(
highlighting.WithFormatOptions(
chromahtml.WithClasses(true),
chromahtml.PreventSurroundingPre(true),
),
highlighting.WithWrapperRenderer(r.highlightingRenderer),
),
math.NewExtension(&ctx.RenderInternal, math.Enabled(setting.Markdown.EnableMath)),
meta.Meta,
),
goldmark.WithParserOptions(
parser.WithAttribute(),
parser.WithAutoHeadingID(),
parser.WithASTTransformers(util.Prioritized(NewASTTransformer(&ctx.RenderInternal), 10000)),
),
goldmark.WithRendererOptions(html.WithUnsafe()),
)
// Override the original Tasklist renderer!
r.goldmarkMarkdown.Renderer().AddOptions(
renderer.WithNodeRenderers(util.Prioritized(NewHTMLRenderer(&ctx.RenderInternal), 10)),
)
return r
}
// render calls goldmark render to convert Markdown to HTML
// NOTE: The output of this method MUST get sanitized separately!!!
func render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
converter := SpecializedMarkdown(ctx)
lw := &limitWriter{ lw := &limitWriter{
w: output, w: output,
limit: setting.UI.MaxDisplayFileSize * 3, limit: setting.UI.MaxDisplayFileSize * 3,
@ -160,8 +164,8 @@ func actualRender(ctx *markup.RenderContext, input io.Reader, output io.Writer)
} }
log.Warn("Unable to render markdown due to panic in goldmark: %v", err) log.Warn("Unable to render markdown due to panic in goldmark: %v", err)
if log.IsDebug() { if (!setting.IsProd && !setting.IsInTesting) || log.IsDebug() {
log.Debug("Panic in markdown: %v\n%s", err, log.Stack(2)) log.Error("Panic in markdown: %v\n%s", err, log.Stack(2))
} }
}() }()
@ -200,26 +204,6 @@ func actualRender(ctx *markup.RenderContext, input io.Reader, output io.Writer)
return nil return nil
} }
// Note: The output of this method must get sanitized.
func render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
defer func() {
err := recover()
if err == nil {
return
}
log.Warn("Unable to render markdown due to panic in goldmark - will return raw bytes")
if log.IsDebug() {
log.Debug("Panic in markdown: %v\n%s", err, log.Stack(2))
}
_, err = io.Copy(output, input)
if err != nil {
log.Error("io.Copy failed: %v", err)
}
}()
return actualRender(ctx, input, output)
}
// MarkupName describes markup's name // MarkupName describes markup's name
var MarkupName = "markdown" var MarkupName = "markdown"

View File

@ -1051,3 +1051,17 @@ func TestAttention(t *testing.T) {
// legacy GitHub style // legacy GitHub style
test(`> **warning**`, renderAttention("warning", "octicon-alert")+"\n</blockquote>") test(`> **warning**`, renderAttention("warning", "octicon-alert")+"\n</blockquote>")
} }
func BenchmarkSpecializedMarkdown(b *testing.B) {
// 240856 4719 ns/op
for i := 0; i < b.N; i++ {
markdown.SpecializedMarkdown(&markup.RenderContext{})
}
}
func BenchmarkMarkdownRender(b *testing.B) {
// 23202 50840 ns/op
for i := 0; i < b.N; i++ {
_, _ = markdown.RenderString(&markup.RenderContext{Ctx: context.Background()}, "https://example.com\n- a\n- b\n")
}
}

View File

@ -4,17 +4,21 @@
package math package math
import ( import (
"code.gitea.io/gitea/modules/markup/internal"
gast "github.com/yuin/goldmark/ast" gast "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/renderer" "github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/util" "github.com/yuin/goldmark/util"
) )
// BlockRenderer represents a renderer for math Blocks // BlockRenderer represents a renderer for math Blocks
type BlockRenderer struct{} type BlockRenderer struct {
renderInternal *internal.RenderInternal
}
// NewBlockRenderer creates a new renderer for math Blocks // NewBlockRenderer creates a new renderer for math Blocks
func NewBlockRenderer() renderer.NodeRenderer { func NewBlockRenderer(renderInternal *internal.RenderInternal) renderer.NodeRenderer {
return &BlockRenderer{} return &BlockRenderer{renderInternal: renderInternal}
} }
// RegisterFuncs registers the renderer for math Blocks // RegisterFuncs registers the renderer for math Blocks
@ -33,7 +37,7 @@ func (r *BlockRenderer) writeLines(w util.BufWriter, source []byte, n gast.Node)
func (r *BlockRenderer) renderBlock(w util.BufWriter, source []byte, node gast.Node, entering bool) (gast.WalkStatus, error) { func (r *BlockRenderer) renderBlock(w util.BufWriter, source []byte, node gast.Node, entering bool) (gast.WalkStatus, error) {
n := node.(*Block) n := node.(*Block)
if entering { if entering {
_, _ = w.WriteString(`<pre class="code-block is-loading"><code class="chroma language-math display">`) _ = r.renderInternal.FormatWithSafeAttrs(w, `<pre class="code-block is-loading"><code class="chroma language-math display">`)
r.writeLines(w, source, n) r.writeLines(w, source, n)
} else { } else {
_, _ = w.WriteString(`</code></pre>` + "\n") _, _ = w.WriteString(`</code></pre>` + "\n")

View File

@ -6,17 +6,21 @@ package math
import ( import (
"bytes" "bytes"
"code.gitea.io/gitea/modules/markup/internal"
"github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/renderer" "github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/util" "github.com/yuin/goldmark/util"
) )
// InlineRenderer is an inline renderer // InlineRenderer is an inline renderer
type InlineRenderer struct{} type InlineRenderer struct {
renderInternal *internal.RenderInternal
}
// NewInlineRenderer returns a new renderer for inline math // NewInlineRenderer returns a new renderer for inline math
func NewInlineRenderer() renderer.NodeRenderer { func NewInlineRenderer(renderInternal *internal.RenderInternal) renderer.NodeRenderer {
return &InlineRenderer{} return &InlineRenderer{renderInternal: renderInternal}
} }
func (r *InlineRenderer) renderInline(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { func (r *InlineRenderer) renderInline(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
@ -25,7 +29,7 @@ func (r *InlineRenderer) renderInline(w util.BufWriter, source []byte, n ast.Nod
if _, ok := n.(*InlineBlock); ok { if _, ok := n.(*InlineBlock); ok {
extraClass = "display " extraClass = "display "
} }
_, _ = w.WriteString(`<code class="language-math ` + extraClass + `is-loading">`) _ = r.renderInternal.FormatWithSafeAttrs(w, `<code class="language-math %sis-loading">`, extraClass)
for c := n.FirstChild(); c != nil; c = c.NextSibling() { for c := n.FirstChild(); c != nil; c = c.NextSibling() {
segment := c.(*ast.Text).Segment segment := c.(*ast.Text).Segment
value := util.EscapeHTML(segment.Value(source)) value := util.EscapeHTML(segment.Value(source))

View File

@ -4,6 +4,8 @@
package math package math
import ( import (
"code.gitea.io/gitea/modules/markup/internal"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
"github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer" "github.com/yuin/goldmark/renderer"
@ -12,6 +14,7 @@ import (
// Extension is a math extension // Extension is a math extension
type Extension struct { type Extension struct {
renderInternal *internal.RenderInternal
enabled bool enabled bool
parseDollarInline bool parseDollarInline bool
parseDollarBlock bool parseDollarBlock bool
@ -39,38 +42,10 @@ func Enabled(enable ...bool) Option {
}) })
} }
// WithInlineDollarParser enables or disables the parsing of $...$
func WithInlineDollarParser(enable ...bool) Option {
value := true
if len(enable) > 0 {
value = enable[0]
}
return extensionFunc(func(e *Extension) {
e.parseDollarInline = value
})
}
// WithBlockDollarParser enables or disables the parsing of $$...$$
func WithBlockDollarParser(enable ...bool) Option {
value := true
if len(enable) > 0 {
value = enable[0]
}
return extensionFunc(func(e *Extension) {
e.parseDollarBlock = value
})
}
// Math represents a math extension with default rendered delimiters
var Math = &Extension{
enabled: true,
parseDollarBlock: true,
parseDollarInline: true,
}
// NewExtension creates a new math extension with the provided options // NewExtension creates a new math extension with the provided options
func NewExtension(opts ...Option) *Extension { func NewExtension(renderInternal *internal.RenderInternal, opts ...Option) *Extension {
r := &Extension{ r := &Extension{
renderInternal: renderInternal,
enabled: true, enabled: true,
parseDollarBlock: true, parseDollarBlock: true,
parseDollarInline: true, parseDollarInline: true,
@ -102,7 +77,7 @@ func (e *Extension) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithInlineParsers(inlines...)) m.Parser().AddOptions(parser.WithInlineParsers(inlines...))
m.Renderer().AddOptions(renderer.WithNodeRenderers( m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(NewBlockRenderer(), 501), util.Prioritized(NewBlockRenderer(e.renderInternal), 501),
util.Prioritized(NewInlineRenderer(), 502), util.Prioritized(NewInlineRenderer(e.renderInternal), 502),
)) ))
} }

View File

@ -11,10 +11,8 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
/* // IssueTemplate is a legacy to keep the unit tests working.
IssueTemplate is a legacy to keep the unit tests working. // Copied from structs.IssueTemplate, the original type has been changed a lot to support yaml template.
Copied from structs.IssueTemplate, the original type has been changed a lot to support yaml template.
*/
type IssueTemplate struct { type IssueTemplate struct {
Name string `json:"name" yaml:"name"` Name string `json:"name" yaml:"name"`
Title string `json:"title" yaml:"title"` Title string `json:"title" yaml:"title"`

View File

@ -32,7 +32,8 @@ func (r *HTMLRenderer) renderAttention(w util.BufWriter, source []byte, node ast
default: // including "note" default: // including "note"
octiconName = "info" octiconName = "info"
} }
_, _ = w.WriteString(string(svg.RenderHTML("octicon-"+octiconName, 16, "attention-icon attention-"+n.AttentionType))) svgHTML := svg.RenderHTML("octicon-"+octiconName, 16, "attention-icon attention-"+n.AttentionType)
_, _ = w.WriteString(string(r.renderInternal.ProtectSafeAttrs(svgHTML)))
} }
return ast.WalkContinue, nil return ast.WalkContinue, nil
} }
@ -128,13 +129,13 @@ func (g *ASTTransformer) transformBlockquote(v *ast.Blockquote, reader text.Read
} }
// color the blockquote // color the blockquote
v.SetAttributeString("class", []byte("attention-header attention-"+attentionType)) v.SetAttributeString(g.renderInternal.SafeAttr("class"), []byte(g.renderInternal.SafeValue("attention-header attention-"+attentionType)))
// create an emphasis to make it bold // create an emphasis to make it bold
attentionParagraph := ast.NewParagraph() attentionParagraph := ast.NewParagraph()
g.applyElementDir(attentionParagraph) g.applyElementDir(attentionParagraph)
emphasis := ast.NewEmphasis(2) emphasis := ast.NewEmphasis(2)
emphasis.SetAttributeString("class", []byte("attention-"+attentionType)) emphasis.SetAttributeString(g.renderInternal.SafeAttr("class"), []byte(g.renderInternal.SafeValue("attention-"+attentionType)))
attentionAstString := ast.NewString([]byte(cases.Title(language.English).String(attentionType))) attentionAstString := ast.NewString([]byte(cases.Title(language.English).String(attentionType)))

View File

@ -5,7 +5,6 @@ package markdown
import ( import (
"bytes" "bytes"
"fmt"
"strings" "strings"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
@ -40,7 +39,7 @@ func (r *HTMLRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Nod
r.Writer.RawWrite(w, value) r.Writer.RawWrite(w, value)
} }
case *ColorPreview: case *ColorPreview:
_, _ = w.WriteString(fmt.Sprintf(`<span class="color-preview" style="background-color: %v"></span>`, string(v.Color))) _ = r.renderInternal.FormatWithSafeAttrs(w, `<span class="color-preview" style="background-color: %s"></span>`, string(v.Color))
} }
} }
return ast.WalkSkipChildren, nil return ast.WalkSkipChildren, nil

View File

@ -72,7 +72,7 @@ func (g *ASTTransformer) transformList(_ *markup.RenderContext, v *ast.List, rc
} }
newChild := NewTaskCheckBoxListItem(listItem) newChild := NewTaskCheckBoxListItem(listItem)
newChild.IsChecked = taskCheckBox.IsChecked newChild.IsChecked = taskCheckBox.IsChecked
newChild.SetAttributeString("class", []byte("task-list-item")) newChild.SetAttributeString(g.renderInternal.SafeAttr("class"), []byte(g.renderInternal.SafeValue("task-list-item")))
segments := newChild.FirstChild().Lines() segments := newChild.FirstChild().Lines()
if segments.Len() > 0 { if segments.Len() > 0 {
segment := segments.At(0) segment := segments.At(0)

View File

@ -9,14 +9,15 @@ import (
"io" "io"
"net/url" "net/url"
"strings" "strings"
"sync"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/markup/internal"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/ast"
"golang.org/x/sync/errgroup"
) )
type RenderMetaMode string type RenderMetaMode string
@ -65,6 +66,8 @@ type RenderContext struct {
SidebarTocNode ast.Node SidebarTocNode ast.Node
RenderMetaAs RenderMetaMode RenderMetaAs RenderMetaMode
InStandalonePage bool // used by external render. the router "/org/repo/render/..." will output the rendered content in a standalone page InStandalonePage bool // used by external render. the router "/org/repo/render/..." will output the rendered content in a standalone page
RenderInternal internal.RenderInternal
} }
// Cancel runs any cleanup functions that have been registered for this Ctx // Cancel runs any cleanup functions that have been registered for this Ctx
@ -156,59 +159,53 @@ sandbox="allow-scripts"
return err return err
} }
func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error { func pipes() (io.ReadCloser, io.WriteCloser, func()) {
var wg sync.WaitGroup
var err error
pr, pw := io.Pipe() pr, pw := io.Pipe()
defer func() { return pr, pw, func() {
_ = pr.Close() _ = pr.Close()
_ = pw.Close() _ = pw.Close()
}() }
}
var pr2 io.ReadCloser func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error {
var pw2 io.WriteCloser finalProcessor := ctx.RenderInternal.Init(output)
defer finalProcessor.Close()
var sanitizerDisabled bool // input -> (pw1=pr1) -> renderer -> (pw2=pr2) -> SanitizeReader -> finalProcessor -> output
if r, ok := renderer.(ExternalRenderer); ok { // no sanitizer: input -> (pw1=pr1) -> renderer -> pw2(finalProcessor) -> output
sanitizerDisabled = r.SanitizerDisabled() pr1, pw1, close1 := pipes()
defer close1()
eg, _ := errgroup.WithContext(ctx.Ctx)
var pw2 io.WriteCloser = util.NopCloser{Writer: finalProcessor}
if r, ok := renderer.(ExternalRenderer); !ok || !r.SanitizerDisabled() {
var pr2 io.ReadCloser
var close2 func()
pr2, pw2, close2 = pipes()
defer close2()
eg.Go(func() error {
defer pr2.Close()
return SanitizeReader(pr2, renderer.Name(), finalProcessor)
})
} }
if !sanitizerDisabled { eg.Go(func() (err error) {
pr2, pw2 = io.Pipe()
defer func() {
_ = pr2.Close()
_ = pw2.Close()
}()
wg.Add(1)
go func() {
err = SanitizeReader(pr2, renderer.Name(), output)
_ = pr2.Close()
wg.Done()
}()
} else {
pw2 = util.NopCloser{Writer: output}
}
wg.Add(1)
go func() {
if r, ok := renderer.(PostProcessRenderer); ok && r.NeedPostProcess() { if r, ok := renderer.(PostProcessRenderer); ok && r.NeedPostProcess() {
err = PostProcess(ctx, pr, pw2) err = PostProcess(ctx, pr1, pw2)
} else { } else {
_, err = io.Copy(pw2, pr) _, err = io.Copy(pw2, pr1)
} }
_ = pr.Close() _, _ = pr1.Close(), pw2.Close()
_ = pw2.Close() return err
wg.Done() })
}()
if err1 := renderer.Render(ctx, input, pw); err1 != nil { if err := renderer.Render(ctx, input, pw1); err != nil {
return err1 return err
} }
_ = pw.Close() _ = pw1.Close()
wg.Wait() return eg.Wait()
return err
} }
// Init initializes the render global variables // Init initializes the render global variables

View File

@ -4,6 +4,9 @@
package markup package markup
import ( import (
"regexp"
"strings"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"github.com/microcosm-cc/bluemonday" "github.com/microcosm-cc/bluemonday"
@ -15,8 +18,11 @@ func (st *Sanitizer) addSanitizerRules(policy *bluemonday.Policy, rules []settin
policy.AllowDataURIImages() policy.AllowDataURIImages()
} }
if rule.Element != "" { if rule.Element != "" {
if rule.Regexp != nil { if rule.Regexp != "" {
policy.AllowAttrs(rule.AllowAttr).Matching(rule.Regexp).OnElements(rule.Element) if !strings.HasPrefix(rule.Regexp, "^") || !strings.HasSuffix(rule.Regexp, "$") {
panic("Markup sanitizer rule regexp must start with ^ and end with $ to be strict")
}
policy.AllowAttrs(rule.AllowAttr).Matching(regexp.MustCompile(rule.Regexp)).OnElements(rule.Element)
} else { } else {
policy.AllowAttrs(rule.AllowAttr).OnElements(rule.Element) policy.AllowAttrs(rule.AllowAttr).OnElements(rule.Element)
} }

View File

@ -16,37 +16,12 @@ import (
func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy { func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy {
policy := bluemonday.UGCPolicy() policy := bluemonday.UGCPolicy()
// For JS code copy and Mermaid loading state // NOTICE: DO NOT add special "class" regexp rules here anymore, use RenderInternal.SafeAttr instead
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-block( is-loading)?$`)).OnElements("pre")
// For code preview // General safe SVG attributes
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-preview-[-\w]+( file-content)?$`)).Globally() policy.AllowAttrs("viewBox", "width", "height", "aria-hidden", "data-attr-class").OnElements("svg")
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^lines-num$`)).OnElements("td")
policy.AllowAttrs("data-line-number").OnElements("span")
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^lines-code chroma$`)).OnElements("td")
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-inner$`)).OnElements("div")
// For code preview (unicode escape)
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^file-view( unicode-escaped)?$`)).OnElements("table")
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^lines-escape$`)).OnElements("td")
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^toggle-escape-button btn interact-bg$`)).OnElements("a") // don't use button, button might submit a form
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(ambiguous-code-point|escaped-code-point|broken-code-point)$`)).OnElements("span")
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^char$`)).OnElements("span")
policy.AllowAttrs("data-tooltip-content", "data-escaped").OnElements("span")
// For color preview
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^color-preview$`)).OnElements("span")
// For attention
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^attention-header attention-\w+$`)).OnElements("blockquote")
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^attention-\w+$`)).OnElements("strong")
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^attention-icon attention-\w+ svg octicon-[\w-]+$`)).OnElements("svg")
policy.AllowAttrs("viewBox", "width", "height", "aria-hidden").OnElements("svg")
policy.AllowAttrs("fill-rule", "d").OnElements("path") policy.AllowAttrs("fill-rule", "d").OnElements("path")
// For Chroma markdown plugin
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(chroma )?language-[\w-]+( display)?( is-loading)?$`)).OnElements("code")
// Checkboxes // Checkboxes
policy.AllowAttrs("type").Matching(regexp.MustCompile(`^checkbox$`)).OnElements("input") policy.AllowAttrs("type").Matching(regexp.MustCompile(`^checkbox$`)).OnElements("input")
policy.AllowAttrs("checked", "disabled", "data-source-position").OnElements("input") policy.AllowAttrs("checked", "disabled", "data-source-position").OnElements("input")
@ -66,28 +41,15 @@ func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy {
policy.AllowURLSchemeWithCustomPolicy("data", disallowScheme) policy.AllowURLSchemeWithCustomPolicy("data", disallowScheme)
} }
// Allow classes for anchors
policy.AllowAttrs("class").Matching(regexp.MustCompile(`ref-issue( ref-external-issue)?`)).OnElements("a")
// Allow classes for task lists
policy.AllowAttrs("class").Matching(regexp.MustCompile(`task-list-item`)).OnElements("li")
// Allow classes for org mode list item status. // Allow classes for org mode list item status.
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(unchecked|checked|indeterminate)$`)).OnElements("li") policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(unchecked|checked|indeterminate)$`)).OnElements("li")
// Allow icons
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^icon(\s+[\p{L}\p{N}_-]+)+$`)).OnElements("i")
// Allow classes for emojis
policy.AllowAttrs("class").Matching(regexp.MustCompile(`emoji`)).OnElements("img")
// Allow icons, emojis, chroma syntax and keyword markup on span
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^((icon(\s+[\p{L}\p{N}_-]+)+)|(emoji)|(language-math display)|(language-math inline))$|^([a-z][a-z0-9]{0,2})$|^` + keywordClass + `$`)).OnElements("span")
// Allow 'color' and 'background-color' properties for the style attribute on text elements. // Allow 'color' and 'background-color' properties for the style attribute on text elements.
policy.AllowStyles("color", "background-color").OnElements("span", "p") policy.AllowStyles("color", "background-color").OnElements("span", "p")
// Allow generally safe attributes policy.AllowAttrs("src", "autoplay", "controls").OnElements("video")
// Allow generally safe attributes (reference: https://github.com/jch/html-pipeline)
generalSafeAttrs := []string{ generalSafeAttrs := []string{
"abbr", "accept", "accept-charset", "abbr", "accept", "accept-charset",
"accesskey", "action", "align", "alt", "accesskey", "action", "align", "alt",
@ -106,10 +68,9 @@ func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy {
"selected", "shape", "size", "span", "selected", "shape", "size", "span",
"start", "summary", "tabindex", "target", "start", "summary", "tabindex", "target",
"title", "type", "usemap", "valign", "value", "title", "type", "usemap", "valign", "value",
"vspace", "width", "itemprop", "vspace", "width", "itemprop", "itemscope", "itemtype",
"data-markdown-generated-content", "data-markdown-generated-content", "data-attr-class",
} }
generalSafeElements := []string{ generalSafeElements := []string{
"h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "br", "b", "i", "strong", "em", "a", "pre", "code", "img", "tt", "h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "br", "b", "i", "strong", "em", "a", "pre", "code", "img", "tt",
"div", "ins", "del", "sup", "sub", "p", "ol", "ul", "table", "thead", "tbody", "tfoot", "blockquote", "label", "div", "ins", "del", "sup", "sub", "p", "ol", "ul", "table", "thead", "tbody", "tfoot", "blockquote", "label",
@ -117,14 +78,8 @@ func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy {
"details", "caption", "figure", "figcaption", "details", "caption", "figure", "figcaption",
"abbr", "bdo", "cite", "dfn", "mark", "small", "span", "time", "video", "wbr", "abbr", "bdo", "cite", "dfn", "mark", "small", "span", "time", "video", "wbr",
} }
policy.AllowAttrs(generalSafeAttrs...).OnElements(generalSafeElements...)
policy.AllowAttrs("src", "autoplay", "controls").OnElements("video")
policy.AllowAttrs("itemscope", "itemtype").OnElements("div")
// FIXME: Need to handle longdesc in img but there is no easy way to do it // FIXME: Need to handle longdesc in img but there is no easy way to do it
policy.AllowAttrs(generalSafeAttrs...).OnElements(generalSafeElements...)
// Custom keyword markup // Custom keyword markup
defaultSanitizer.addSanitizerRules(policy, setting.ExternalSanitizerRules) defaultSanitizer.addSanitizerRules(policy, setting.ExternalSanitizerRules)

View File

@ -19,7 +19,6 @@ func TestSanitizer(t *testing.T) {
// Code highlighting class // Code highlighting class
`<code class="random string"></code>`, `<code></code>`, `<code class="random string"></code>`, `<code></code>`,
`<code class="language-random ui tab active menu attached animating sidebar following bar center"></code>`, `<code></code>`, `<code class="language-random ui tab active menu attached animating sidebar following bar center"></code>`, `<code></code>`,
`<code class="language-go"></code>`, `<code class="language-go"></code>`,
// Input checkbox // Input checkbox
`<input type="hidden">`, ``, `<input type="hidden">`, ``,
@ -38,10 +37,8 @@ func TestSanitizer(t *testing.T) {
// <kbd> tags // <kbd> tags
`<kbd>Ctrl + C</kbd>`, `<kbd>Ctrl + C</kbd>`, `<kbd>Ctrl + C</kbd>`, `<kbd>Ctrl + C</kbd>`,
`<i class="dropdown icon">NAUGHTY</i>`, `<i>NAUGHTY</i>`, `<i class="dropdown icon">NAUGHTY</i>`, `<i>NAUGHTY</i>`,
`<i class="icon dropdown"></i>`, `<i class="icon dropdown"></i>`,
`<input type="checkbox" disabled=""/>unchecked`, `<input type="checkbox" disabled=""/>unchecked`, `<input type="checkbox" disabled=""/>unchecked`, `<input type="checkbox" disabled=""/>unchecked`,
`<span class="emoji dropdown">NAUGHTY</span>`, `<span>NAUGHTY</span>`, `<span class="emoji dropdown">NAUGHTY</span>`, `<span>NAUGHTY</span>`,
`<span class="emoji">contents</span>`, `<span class="emoji">contents</span>`,
// Color property // Color property
`<span style="color: red">Hello World</span>`, `<span style="color: red">Hello World</span>`, `<span style="color: red">Hello World</span>`, `<span style="color: red">Hello World</span>`,

View File

@ -4,8 +4,6 @@
package repository package repository
import ( import (
"crypto/md5"
"fmt"
"strconv" "strconv"
"testing" "testing"
"time" "time"
@ -125,15 +123,12 @@ func TestPushCommits_AvatarLink(t *testing.T) {
}, },
} }
setting.GravatarSource = "https://secure.gravatar.com/avatar"
setting.OfflineMode = true
assert.Equal(t, assert.Equal(t,
"/avatars/avatar2?size="+strconv.Itoa(28*setting.Avatar.RenderedSizeFactor), "/avatars/ab53a2911ddf9b4817ac01ddcd3d975f?size="+strconv.Itoa(28*setting.Avatar.RenderedSizeFactor),
pushCommits.AvatarLink(db.DefaultContext, "user2@example.com")) pushCommits.AvatarLink(db.DefaultContext, "user2@example.com"))
assert.Equal(t, assert.Equal(t,
fmt.Sprintf("https://secure.gravatar.com/avatar/%x?d=identicon&s=%d", md5.Sum([]byte("nonexistent@example.com")), 28*setting.Avatar.RenderedSizeFactor), "/assets/img/avatar_default.png",
pushCommits.AvatarLink(db.DefaultContext, "nonexistent@example.com")) pushCommits.AvatarLink(db.DefaultContext, "nonexistent@example.com"))
} }

View File

@ -54,7 +54,7 @@ type MarkupRenderer struct {
type MarkupSanitizerRule struct { type MarkupSanitizerRule struct {
Element string Element string
AllowAttr string AllowAttr string
Regexp *regexp.Regexp Regexp string
AllowDataURIImages bool AllowDataURIImages bool
} }
@ -117,15 +117,24 @@ func createMarkupSanitizerRule(name string, sec ConfigSection) (MarkupSanitizerR
regexpStr := sec.Key("REGEXP").Value() regexpStr := sec.Key("REGEXP").Value()
if regexpStr != "" { if regexpStr != "" {
// Validate when parsing the config that this is a valid regular hasPrefix := strings.HasPrefix(regexpStr, "^")
// expression. Then we can use regexp.MustCompile(...) later. hasSuffix := strings.HasSuffix(regexpStr, "$")
compiled, err := regexp.Compile(regexpStr) if !hasPrefix || !hasSuffix {
log.Error("In markup.%s: REGEXP must start with ^ and end with $ to be strict", name)
// to avoid breaking existing user configurations and satisfy the strict requirement in addSanitizerRules
if !hasPrefix {
regexpStr = "^.*" + regexpStr
}
if !hasSuffix {
regexpStr += ".*$"
}
}
_, err := regexp.Compile(regexpStr)
if err != nil { if err != nil {
log.Error("In markup.%s: REGEXP (%s) failed to compile: %v", name, regexpStr, err) log.Error("In markup.%s: REGEXP (%s) failed to compile: %v", name, regexpStr, err)
return rule, false return rule, false
} }
rule.Regexp = regexpStr
rule.Regexp = compiled
} }
ok = true ok = true

View File

@ -9,7 +9,7 @@ import (
"path" "path"
"strings" "strings"
gitea_html "code.gitea.io/gitea/modules/html" gitea_html "code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/public"
) )

View File

@ -10,12 +10,12 @@ import (
"html/template" "html/template"
"net/url" "net/url"
"reflect" "reflect"
"slices"
"strings" "strings"
"time" "time"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/svg" "code.gitea.io/gitea/modules/svg"
@ -39,7 +39,7 @@ func NewFuncMap() template.FuncMap {
"Iif": iif, "Iif": iif,
"Eval": evalTokens, "Eval": evalTokens,
"SafeHTML": safeHTML, "SafeHTML": safeHTML,
"HTMLFormat": HTMLFormat, "HTMLFormat": htmlutil.HTMLFormat,
"HTMLEscape": htmlEscape, "HTMLEscape": htmlEscape,
"QueryEscape": queryEscape, "QueryEscape": queryEscape,
"JSEscape": jsEscapeSafe, "JSEscape": jsEscapeSafe,
@ -184,23 +184,6 @@ func NewFuncMap() template.FuncMap {
} }
} }
func HTMLFormat(s string, rawArgs ...any) template.HTML {
args := slices.Clone(rawArgs)
for i, v := range args {
switch v := v.(type) {
case nil, bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, template.HTML:
// for most basic types (including template.HTML which is safe), just do nothing and use it
case string:
args[i] = template.HTMLEscapeString(v)
case fmt.Stringer:
args[i] = template.HTMLEscapeString(v.String())
default:
args[i] = template.HTMLEscapeString(fmt.Sprint(v))
}
}
return template.HTML(fmt.Sprintf(s, args...))
}
// safeHTML render raw as HTML // safeHTML render raw as HTML
func safeHTML(s any) template.HTML { func safeHTML(s any) template.HTML {
switch v := s.(type) { switch v := s.(type) {

View File

@ -61,10 +61,6 @@ func TestJSEscapeSafe(t *testing.T) {
assert.EqualValues(t, `\u0026\u003C\u003E\'\"`, jsEscapeSafe(`&<>'"`)) assert.EqualValues(t, `\u0026\u003C\u003E\'\"`, jsEscapeSafe(`&<>'"`))
} }
func TestHTMLFormat(t *testing.T) {
assert.Equal(t, template.HTML("<a>&lt; < 1</a>"), HTMLFormat("<a>%s %s %d</a>", "<", template.HTML("<"), 1))
}
func TestSanitizeHTML(t *testing.T) { func TestSanitizeHTML(t *testing.T) {
assert.Equal(t, template.HTML(`<a href="/" rel="nofollow">link</a> xss <div>inline</div>`), SanitizeHTML(`<a href="/">link</a> <a href="javascript:">xss</a> <div style="dangerous">inline</div>`)) assert.Equal(t, template.HTML(`<a href="/" rel="nofollow">link</a> xss <div>inline</div>`), SanitizeHTML(`<a href="/">link</a> <a href="javascript:">xss</a> <div style="dangerous">inline</div>`))
} }

View File

@ -14,7 +14,7 @@ import (
"code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/organization"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
gitea_html "code.gitea.io/gitea/modules/html" gitea_html "code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
) )

View File

@ -16,6 +16,7 @@ import (
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/modules/emoji" "code.gitea.io/gitea/modules/emoji"
"code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/markup/markdown"
@ -140,7 +141,7 @@ func (ut *RenderUtils) RenderLabel(label *issues_model.Label) template.HTML {
if labelScope == "" { if labelScope == "" {
// Regular label // Regular label
return HTMLFormat(`<div class="ui label %s" style="color: %s !important; background-color: %s !important;" data-tooltip-content title="%s">%s</div>`, return htmlutil.HTMLFormat(`<div class="ui label %s" style="color: %s !important; background-color: %s !important;" data-tooltip-content title="%s">%s</div>`,
extraCSSClasses, textColor, label.Color, descriptionText, ut.RenderEmoji(label.Name)) extraCSSClasses, textColor, label.Color, descriptionText, ut.RenderEmoji(label.Name))
} }
@ -174,7 +175,7 @@ func (ut *RenderUtils) RenderLabel(label *issues_model.Label) template.HTML {
itemColor := "#" + hex.EncodeToString(itemBytes) itemColor := "#" + hex.EncodeToString(itemBytes)
scopeColor := "#" + hex.EncodeToString(scopeBytes) scopeColor := "#" + hex.EncodeToString(scopeBytes)
return HTMLFormat(`<span class="ui label %s scope-parent" data-tooltip-content title="%s">`+ return htmlutil.HTMLFormat(`<span class="ui label %s scope-parent" data-tooltip-content title="%s">`+
`<div class="ui label scope-left" style="color: %s !important; background-color: %s !important">%s</div>`+ `<div class="ui label scope-left" style="color: %s !important; background-color: %s !important">%s</div>`+
`<div class="ui label scope-right" style="color: %s !important; background-color: %s !important">%s</div>`+ `<div class="ui label scope-right" style="color: %s !important; background-color: %s !important">%s</div>`+
`</span>`, `</span>`,

View File

@ -113,34 +113,34 @@ func TestRenderCommitBody(t *testing.T) {
} }
expected := `/just/a/path.bin expected := `/just/a/path.bin
<a href="https://example.com/file.bin" class="link">https://example.com/file.bin</a> <a href="https://example.com/file.bin">https://example.com/file.bin</a>
[local link](file.bin) [local link](file.bin)
[remote link](<a href="https://example.com" class="link">https://example.com</a>) [remote link](<a href="https://example.com">https://example.com</a>)
[[local link|file.bin]] [[local link|file.bin]]
[[remote link|<a href="https://example.com" class="link">https://example.com</a>]] [[remote link|<a href="https://example.com">https://example.com</a>]]
![local image](image.jpg) ![local image](image.jpg)
![remote image](<a href="https://example.com/image.jpg" class="link">https://example.com/image.jpg</a>) ![remote image](<a href="https://example.com/image.jpg">https://example.com/image.jpg</a>)
[[local image|image.jpg]] [[local image|image.jpg]]
[[remote link|<a href="https://example.com/image.jpg" class="link">https://example.com/image.jpg</a>]] [[remote link|<a href="https://example.com/image.jpg">https://example.com/image.jpg</a>]]
<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" class="compare"><code class="nohighlight">88fc37a3c0...12fc37a3c0 (hash)</code></a> <a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" class="compare"><code class="nohighlight">88fc37a3c0...12fc37a3c0 (hash)</code></a>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" class="commit"><code class="nohighlight">88fc37a3c0</code></a> <a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" class="commit"><code class="nohighlight">88fc37a3c0</code></a>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
<span class="emoji" aria-label="thumbs up">👍</span> <span class="emoji" aria-label="thumbs up">👍</span>
<a href="mailto:mail@domain.com" class="mailto">mail@domain.com</a> <a href="mailto:mail@domain.com">mail@domain.com</a>
<a href="/mention-user" class="mention">@mention-user</a> test <a href="/mention-user">@mention-user</a> test
<a href="/user13/repo11/issues/123" class="ref-issue">#123</a> <a href="/user13/repo11/issues/123" class="ref-issue">#123</a>
space` space`
assert.EqualValues(t, expected, string(newTestRenderUtils().RenderCommitBody(testInput(), testMetas))) assert.EqualValues(t, expected, string(newTestRenderUtils().RenderCommitBody(testInput(), testMetas)))
} }
func TestRenderCommitMessage(t *testing.T) { func TestRenderCommitMessage(t *testing.T) {
expected := `space <a href="/mention-user" data-markdown-generated-content="" class="mention">@mention-user</a> ` expected := `space <a href="/mention-user" data-markdown-generated-content="">@mention-user</a> `
assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitMessage(testInput(), testMetas)) assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitMessage(testInput(), testMetas))
} }
func TestRenderCommitMessageLinkSubject(t *testing.T) { func TestRenderCommitMessageLinkSubject(t *testing.T) {
expected := `<a href="https://example.com/link" class="muted">space </a><a href="/mention-user" data-markdown-generated-content="" class="mention">@mention-user</a>` expected := `<a href="https://example.com/link" class="muted">space </a><a href="/mention-user" data-markdown-generated-content="">@mention-user</a>`
assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", testMetas)) assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", testMetas))
} }

View File

@ -104,6 +104,7 @@ copy_url = Copy URL
copy_hash = Copy hash copy_hash = Copy hash
copy_content = Copy content copy_content = Copy content
copy_branch = Copy branch name copy_branch = Copy branch name
copy_path = Copy path
copy_success = Copied! copy_success = Copied!
copy_error = Copy failed copy_error = Copy failed
copy_type_unsupported = This file type cannot be copied copy_type_unsupported = This file type cannot be copied
@ -352,6 +353,7 @@ enable_update_checker = Enable Update Checker
enable_update_checker_helper = Checks for new version releases periodically by connecting to gitea.io. enable_update_checker_helper = Checks for new version releases periodically by connecting to gitea.io.
env_config_keys = Environment Configuration env_config_keys = Environment Configuration
env_config_keys_prompt = The following environment variables will also be applied to your configuration file: env_config_keys_prompt = The following environment variables will also be applied to your configuration file:
config_write_file_prompt = These configuration options will be written into: %s
[home] [home]
nav_menu = Navigation Menu nav_menu = Navigation Menu

View File

@ -133,11 +133,6 @@ func DeleteBranch(ctx *context.APIContext) {
branchName := ctx.PathParam("*") branchName := ctx.PathParam("*")
if ctx.Repo.Repository.IsEmpty {
ctx.Error(http.StatusForbidden, "", "Git Repository is empty.")
return
}
// check whether branches of this repository has been synced // check whether branches of this repository has been synced
totalNumOfBranches, err := db.Count[git_model.Branch](ctx, git_model.FindBranchOptions{ totalNumOfBranches, err := db.Count[git_model.Branch](ctx, git_model.FindBranchOptions{
RepoID: ctx.Repo.Repository.ID, RepoID: ctx.Repo.Repository.ID,

View File

@ -55,11 +55,20 @@ func ListForks(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
forks, err := repo_model.GetForks(ctx, ctx.Repo.Repository, utils.GetListOptions(ctx)) forks, total, err := repo_service.FindForks(ctx, ctx.Repo.Repository, ctx.Doer, utils.GetListOptions(ctx))
if err != nil { if err != nil {
ctx.Error(http.StatusInternalServerError, "GetForks", err) ctx.Error(http.StatusInternalServerError, "FindForks", err)
return return
} }
if err := repo_model.RepositoryList(forks).LoadOwners(ctx); err != nil {
ctx.Error(http.StatusInternalServerError, "LoadOwners", err)
return
}
if err := repo_model.RepositoryList(forks).LoadUnits(ctx); err != nil {
ctx.Error(http.StatusInternalServerError, "LoadUnits", err)
return
}
apiForks := make([]*api.Repository, len(forks)) apiForks := make([]*api.Repository, len(forks))
for i, fork := range forks { for i, fork := range forks {
permission, err := access_model.GetUserRepoPermission(ctx, fork, ctx.Doer) permission, err := access_model.GetUserRepoPermission(ctx, fork, ctx.Doer)
@ -70,7 +79,7 @@ func ListForks(ctx *context.APIContext) {
apiForks[i] = convert.ToRepo(ctx, fork, permission) apiForks[i] = convert.ToRepo(ctx, fork, permission)
} }
ctx.SetTotalCountHeader(int64(ctx.Repo.Repository.NumForks)) ctx.SetTotalCountHeader(total)
ctx.JSON(http.StatusOK, apiForks) ctx.JSON(http.StatusOK, apiForks)
} }

View File

@ -80,12 +80,12 @@ func (err errCallback) Error() string {
} }
type userInfoResponse struct { type userInfoResponse struct {
Sub string `json:"sub"` Sub string `json:"sub"`
Name string `json:"name"` Name string `json:"name"`
Username string `json:"preferred_username"` PreferredUsername string `json:"preferred_username"`
Email string `json:"email"` Email string `json:"email"`
Picture string `json:"picture"` Picture string `json:"picture"`
Groups []string `json:"groups"` Groups []string `json:"groups"`
} }
// InfoOAuth manages request for userinfo endpoint // InfoOAuth manages request for userinfo endpoint
@ -97,11 +97,11 @@ func InfoOAuth(ctx *context.Context) {
} }
response := &userInfoResponse{ response := &userInfoResponse{
Sub: fmt.Sprint(ctx.Doer.ID), Sub: fmt.Sprint(ctx.Doer.ID),
Name: ctx.Doer.FullName, Name: ctx.Doer.DisplayName(),
Username: ctx.Doer.Name, PreferredUsername: ctx.Doer.Name,
Email: ctx.Doer.Email, Email: ctx.Doer.Email,
Picture: ctx.Doer.AvatarLink(ctx), Picture: ctx.Doer.AvatarLink(ctx),
} }
groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer) groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer)

View File

@ -10,7 +10,6 @@ import (
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/oauth2_provider" "code.gitea.io/gitea/services/oauth2_provider"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@ -66,25 +65,7 @@ func TestNewAccessTokenResponse_OIDCToken(t *testing.T) {
// Scopes: openid profile email // Scopes: openid profile email
oidcToken = createAndParseToken(t, grants[0]) oidcToken = createAndParseToken(t, grants[0])
assert.Equal(t, user.Name, oidcToken.Name) assert.Equal(t, user.DisplayName(), oidcToken.Name)
assert.Equal(t, user.Name, oidcToken.PreferredUsername)
assert.Equal(t, user.HTMLURL(), oidcToken.Profile)
assert.Equal(t, user.AvatarLink(db.DefaultContext), oidcToken.Picture)
assert.Equal(t, user.Website, oidcToken.Website)
assert.Equal(t, user.UpdatedUnix, oidcToken.UpdatedAt)
assert.Equal(t, user.Email, oidcToken.Email)
assert.Equal(t, user.IsActive, oidcToken.EmailVerified)
// set DefaultShowFullName to true
oldDefaultShowFullName := setting.UI.DefaultShowFullName
setting.UI.DefaultShowFullName = true
defer func() {
setting.UI.DefaultShowFullName = oldDefaultShowFullName
}()
// Scopes: openid profile email
oidcToken = createAndParseToken(t, grants[0])
assert.Equal(t, user.FullName, oidcToken.Name)
assert.Equal(t, user.Name, oidcToken.PreferredUsername) assert.Equal(t, user.Name, oidcToken.PreferredUsername)
assert.Equal(t, user.HTMLURL(), oidcToken.Profile) assert.Equal(t, user.HTMLURL(), oidcToken.Profile)
assert.Equal(t, user.AvatarLink(db.DefaultContext), oidcToken.Picture) assert.Equal(t, user.AvatarLink(db.DefaultContext), oidcToken.Picture)

View File

@ -8,7 +8,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@ -290,8 +289,8 @@ func SettingsPost(ctx *context.Context) {
return return
} }
m, err := selectPushMirrorByForm(ctx, form, repo) m, _, _ := repo_model.GetPushMirrorByIDAndRepoID(ctx, form.PushMirrorID, repo.ID)
if err != nil { if m == nil {
ctx.NotFound("", nil) ctx.NotFound("", nil)
return return
} }
@ -317,15 +316,13 @@ func SettingsPost(ctx *context.Context) {
return return
} }
id, err := strconv.ParseInt(form.PushMirrorID, 10, 64) m, _, _ := repo_model.GetPushMirrorByIDAndRepoID(ctx, form.PushMirrorID, repo.ID)
if err != nil { if m == nil {
ctx.ServerError("UpdatePushMirrorIntervalPushMirrorID", err) ctx.NotFound("", nil)
return return
} }
m := &repo_model.PushMirror{
ID: id, m.Interval = interval
Interval: interval,
}
if err := repo_model.UpdatePushMirrorInterval(ctx, m); err != nil { if err := repo_model.UpdatePushMirrorInterval(ctx, m); err != nil {
ctx.ServerError("UpdatePushMirrorInterval", err) ctx.ServerError("UpdatePushMirrorInterval", err)
return return
@ -334,7 +331,10 @@ func SettingsPost(ctx *context.Context) {
// If we observed its implementation in the context of `push-mirror-sync` where it // If we observed its implementation in the context of `push-mirror-sync` where it
// is evident that pushing to the queue is necessary for updates. // is evident that pushing to the queue is necessary for updates.
// So, there are updates within the given interval, it is necessary to update the queue accordingly. // So, there are updates within the given interval, it is necessary to update the queue accordingly.
mirror_service.AddPushMirrorToQueue(m.ID) if !ctx.FormBool("push_mirror_defer_sync") {
// push_mirror_defer_sync is mainly for testing purpose, we do not really want to sync the push mirror immediately
mirror_service.AddPushMirrorToQueue(m.ID)
}
ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
ctx.Redirect(repo.Link() + "/settings") ctx.Redirect(repo.Link() + "/settings")
@ -348,18 +348,18 @@ func SettingsPost(ctx *context.Context) {
// as an error on the UI for this action // as an error on the UI for this action
ctx.Data["Err_RepoName"] = nil ctx.Data["Err_RepoName"] = nil
m, err := selectPushMirrorByForm(ctx, form, repo) m, _, _ := repo_model.GetPushMirrorByIDAndRepoID(ctx, form.PushMirrorID, repo.ID)
if err != nil { if m == nil {
ctx.NotFound("", nil) ctx.NotFound("", nil)
return return
} }
if err = mirror_service.RemovePushMirrorRemote(ctx, m); err != nil { if err := mirror_service.RemovePushMirrorRemote(ctx, m); err != nil {
ctx.ServerError("RemovePushMirrorRemote", err) ctx.ServerError("RemovePushMirrorRemote", err)
return return
} }
if err = repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: m.ID, RepoID: m.RepoID}); err != nil { if err := repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: m.ID, RepoID: m.RepoID}); err != nil {
ctx.ServerError("DeletePushMirrorByID", err) ctx.ServerError("DeletePushMirrorByID", err)
return return
} }
@ -995,24 +995,3 @@ func handleSettingRemoteAddrError(ctx *context.Context, err error, form *forms.R
} }
ctx.RenderWithErr(ctx.Tr("repo.mirror_address_url_invalid"), tplSettingsOptions, form) ctx.RenderWithErr(ctx.Tr("repo.mirror_address_url_invalid"), tplSettingsOptions, form)
} }
func selectPushMirrorByForm(ctx *context.Context, form *forms.RepoSettingForm, repo *repo_model.Repository) (*repo_model.PushMirror, error) {
id, err := strconv.ParseInt(form.PushMirrorID, 10, 64)
if err != nil {
return nil, err
}
pushMirrors, _, err := repo_model.GetPushMirrorsByRepoID(ctx, repo.ID, db.ListOptions{})
if err != nil {
return nil, err
}
for _, m := range pushMirrors {
if m.ID == id {
m.Repo = repo
return m, nil
}
}
return nil, fmt.Errorf("PushMirror[%v] not associated to repository %v", id, repo)
}

View File

@ -1151,26 +1151,25 @@ func Forks(ctx *context.Context) {
if page <= 0 { if page <= 0 {
page = 1 page = 1
} }
pageSize := setting.ItemsPerPage
pager := context.NewPagination(ctx.Repo.Repository.NumForks, setting.ItemsPerPage, page, 5) forks, total, err := repo_service.FindForks(ctx, ctx.Repo.Repository, ctx.Doer, db.ListOptions{
ctx.Data["Page"] = pager Page: page,
PageSize: pageSize,
forks, err := repo_model.GetForks(ctx, ctx.Repo.Repository, db.ListOptions{
Page: pager.Paginater.Current(),
PageSize: setting.ItemsPerPage,
}) })
if err != nil { if err != nil {
ctx.ServerError("GetForks", err) ctx.ServerError("FindForks", err)
return return
} }
for _, fork := range forks { if err := repo_model.RepositoryList(forks).LoadOwners(ctx); err != nil {
if err = fork.LoadOwner(ctx); err != nil { ctx.ServerError("LoadAttributes", err)
ctx.ServerError("LoadOwner", err) return
return
}
} }
pager := context.NewPagination(int(total), pageSize, page, 5)
ctx.Data["Page"] = pager
ctx.Data["Forks"] = forks ctx.Data["Forks"] = forks
ctx.HTML(http.StatusOK, tplForks) ctx.HTML(http.StatusOK, tplForks)

View File

@ -326,7 +326,7 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) {
if rctx.SidebarTocNode != nil { if rctx.SidebarTocNode != nil {
sb := &strings.Builder{} sb := &strings.Builder{}
err = markdown.SpecializedMarkdown().Renderer().Render(sb, nil, rctx.SidebarTocNode) err = markdown.SpecializedMarkdown(rctx).Renderer().Render(sb, nil, rctx.SidebarTocNode)
if err != nil { if err != nil {
log.Error("Failed to render wiki sidebar TOC: %v", err) log.Error("Failed to render wiki sidebar TOC: %v", err)
} else { } else {

View File

@ -561,7 +561,7 @@ func registerRoutes(m *web.Router) {
m.Post("/authorize", web.Bind(forms.AuthorizationForm{}), auth.AuthorizeOAuth) m.Post("/authorize", web.Bind(forms.AuthorizationForm{}), auth.AuthorizeOAuth)
}, optSignInIgnoreCsrf, reqSignIn) }, optSignInIgnoreCsrf, reqSignIn)
m.Methods("GET, OPTIONS", "/userinfo", optionsCorsHandler(), optSignInIgnoreCsrf, auth.InfoOAuth) m.Methods("GET, POST, OPTIONS", "/userinfo", optionsCorsHandler(), optSignInIgnoreCsrf, auth.InfoOAuth)
m.Methods("POST, OPTIONS", "/access_token", optionsCorsHandler(), web.Bind(forms.AccessTokenForm{}), optSignInIgnoreCsrf, auth.AccessTokenOAuth) m.Methods("POST, OPTIONS", "/access_token", optionsCorsHandler(), web.Bind(forms.AccessTokenForm{}), optSignInIgnoreCsrf, auth.AccessTokenOAuth)
m.Methods("GET, OPTIONS", "/keys", optionsCorsHandler(), optSignInIgnoreCsrf, auth.OIDCKeys) m.Methods("GET, OPTIONS", "/keys", optionsCorsHandler(), optSignInIgnoreCsrf, auth.OIDCKeys)
m.Methods("POST, OPTIONS", "/introspect", optionsCorsHandler(), web.Bind(forms.IntrospectTokenForm{}), optSignInIgnoreCsrf, auth.IntrospectOAuth) m.Methods("POST, OPTIONS", "/introspect", optionsCorsHandler(), web.Bind(forms.IntrospectTokenForm{}), optSignInIgnoreCsrf, auth.IntrospectOAuth)

View File

@ -83,7 +83,12 @@ func ParseAuthorizationToken(req *http.Request) (int64, error) {
return 0, fmt.Errorf("split token failed") return 0, fmt.Errorf("split token failed")
} }
token, err := jwt.ParseWithClaims(parts[1], &actionsClaims{}, func(t *jwt.Token) (any, error) { return TokenToTaskID(parts[1])
}
// TokenToTaskID returns the TaskID associated with the provided JWT token
func TokenToTaskID(token string) (int64, error) {
parsedToken, err := jwt.ParseWithClaims(token, &actionsClaims{}, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
} }
@ -93,8 +98,8 @@ func ParseAuthorizationToken(req *http.Request) (int64, error) {
return 0, err return 0, err
} }
c, ok := token.Claims.(*actionsClaims) c, ok := parsedToken.Claims.(*actionsClaims)
if !token.Valid || !ok { if !parsedToken.Valid || !ok {
return 0, fmt.Errorf("invalid token claim") return 0, fmt.Errorf("invalid token claim")
} }

View File

@ -17,6 +17,7 @@ import (
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/modules/web/middleware"
"code.gitea.io/gitea/services/actions"
"code.gitea.io/gitea/services/oauth2_provider" "code.gitea.io/gitea/services/oauth2_provider"
) )
@ -54,6 +55,18 @@ func CheckOAuthAccessToken(ctx context.Context, accessToken string) int64 {
return grant.UserID return grant.UserID
} }
// CheckTaskIsRunning verifies that the TaskID corresponds to a running task
func CheckTaskIsRunning(ctx context.Context, taskID int64) bool {
// Verify the task exists
task, err := actions_model.GetTaskByID(ctx, taskID)
if err != nil {
return false
}
// Verify that it's running
return task.Status == actions_model.StatusRunning
}
// OAuth2 implements the Auth interface and authenticates requests // OAuth2 implements the Auth interface and authenticates requests
// (API requests only) by looking for an OAuth token in query parameters or the // (API requests only) by looking for an OAuth token in query parameters or the
// "Authorization" header. // "Authorization" header.
@ -97,6 +110,16 @@ func parseToken(req *http.Request) (string, bool) {
func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store DataStore) int64 { func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store DataStore) int64 {
// Let's see if token is valid. // Let's see if token is valid.
if strings.Contains(tokenSHA, ".") { if strings.Contains(tokenSHA, ".") {
// First attempt to decode an actions JWT, returning the actions user
if taskID, err := actions.TokenToTaskID(tokenSHA); err == nil {
if CheckTaskIsRunning(ctx, taskID) {
store.GetData()["IsActionsToken"] = true
store.GetData()["ActionsTaskID"] = taskID
return user_model.ActionsUserID
}
}
// Otherwise, check if this is an OAuth access token
uid := CheckOAuthAccessToken(ctx, tokenSHA) uid := CheckOAuthAccessToken(ctx, tokenSHA)
if uid != 0 { if uid != 0 {
store.GetData()["IsApiToken"] = true store.GetData()["IsApiToken"] = true

View File

@ -0,0 +1,55 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"testing"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/web/middleware"
"code.gitea.io/gitea/services/actions"
"github.com/stretchr/testify/assert"
)
func TestUserIDFromToken(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
t.Run("Actions JWT", func(t *testing.T) {
const RunningTaskID = 47
token, err := actions.CreateAuthorizationToken(RunningTaskID, 1, 2)
assert.NoError(t, err)
ds := make(middleware.ContextData)
o := OAuth2{}
uid := o.userIDFromToken(context.Background(), token, ds)
assert.Equal(t, int64(user_model.ActionsUserID), uid)
assert.Equal(t, ds["IsActionsToken"], true)
assert.Equal(t, ds["ActionsTaskID"], int64(RunningTaskID))
})
}
func TestCheckTaskIsRunning(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
cases := map[string]struct {
TaskID int64
Expected bool
}{
"Running": {TaskID: 47, Expected: true},
"Missing": {TaskID: 1, Expected: false},
"Cancelled": {TaskID: 46, Expected: false},
}
for name := range cases {
c := cases[name]
t.Run(name, func(t *testing.T) {
actual := CheckTaskIsRunning(context.Background(), c.TaskID)
assert.Equal(t, c.Expected, actual)
})
}
}

View File

@ -393,14 +393,7 @@ func repoAssignment(ctx *Context, repo *repo_model.Repository) {
} }
} }
pushMirrors, _, err := repo_model.GetPushMirrorsByRepoID(ctx, repo.ID, db.ListOptions{})
if err != nil {
ctx.ServerError("GetPushMirrorsByRepoID", err)
return
}
ctx.Repo.Repository = repo ctx.Repo.Repository = repo
ctx.Data["PushMirrors"] = pushMirrors
ctx.Data["RepoName"] = ctx.Repo.Repository.Name ctx.Data["RepoName"] = ctx.Repo.Repository.Name
ctx.Data["IsEmptyRepo"] = ctx.Repo.Repository.IsEmpty ctx.Data["IsEmptyRepo"] = ctx.Repo.Repository.IsEmpty

View File

@ -122,7 +122,7 @@ type RepoSettingForm struct {
MirrorPassword string MirrorPassword string
LFS bool `form:"mirror_lfs"` LFS bool `form:"mirror_lfs"`
LFSEndpoint string `form:"mirror_lfs_endpoint"` LFSEndpoint string `form:"mirror_lfs_endpoint"`
PushMirrorID string PushMirrorID int64
PushMirrorAddress string PushMirrorAddress string
PushMirrorUsername string PushMirrorUsername string
PushMirrorPassword string PushMirrorPassword string

View File

@ -8,7 +8,6 @@ import (
"fmt" "fmt"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/queue" "code.gitea.io/gitea/modules/queue"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
@ -119,14 +118,7 @@ func Update(ctx context.Context, pullLimit, pushLimit int) error {
return nil return nil
} }
func queueHandler(items ...*SyncRequest) []*SyncRequest {
for _, req := range items {
doMirrorSync(graceful.GetManager().ShutdownContext(), req)
}
return nil
}
// InitSyncMirrors initializes a go routine to sync the mirrors // InitSyncMirrors initializes a go routine to sync the mirrors
func InitSyncMirrors() { func InitSyncMirrors() {
StartSyncMirrors(queueHandler) StartSyncMirrors()
} }

View File

@ -28,12 +28,19 @@ type SyncRequest struct {
ReferenceID int64 // RepoID for pull mirror, MirrorID for push mirror ReferenceID int64 // RepoID for pull mirror, MirrorID for push mirror
} }
func queueHandler(items ...*SyncRequest) []*SyncRequest {
for _, req := range items {
doMirrorSync(graceful.GetManager().ShutdownContext(), req)
}
return nil
}
// StartSyncMirrors starts a go routine to sync the mirrors // StartSyncMirrors starts a go routine to sync the mirrors
func StartSyncMirrors(queueHandle func(data ...*SyncRequest) []*SyncRequest) { func StartSyncMirrors() {
if !setting.Mirror.Enabled { if !setting.Mirror.Enabled {
return return
} }
mirrorQueue = queue.CreateUniqueQueue(graceful.GetManager().ShutdownContext(), "mirror", queueHandle) mirrorQueue = queue.CreateUniqueQueue(graceful.GetManager().ShutdownContext(), "mirror", queueHandler)
if mirrorQueue == nil { if mirrorQueue == nil {
log.Fatal("Unable to create mirror queue") log.Fatal("Unable to create mirror queue")
} }

View File

@ -148,7 +148,7 @@ func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, server
Nonce: grant.Nonce, Nonce: grant.Nonce,
} }
if grant.ScopeContains("profile") { if grant.ScopeContains("profile") {
idToken.Name = user.GetDisplayName() idToken.Name = user.DisplayName()
idToken.PreferredUsername = user.Name idToken.PreferredUsername = user.Name
idToken.Profile = user.HTMLURL() idToken.Profile = user.HTMLURL()
idToken.Picture = user.AvatarLink(ctx) idToken.Picture = user.AvatarLink(ctx)

View File

@ -18,6 +18,7 @@ import (
func TestRepository_ContributorsGraph(t *testing.T) { func TestRepository_ContributorsGraph(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
assert.NoError(t, repo.LoadOwner(db.DefaultContext)) assert.NoError(t, repo.LoadOwner(db.DefaultContext))
mockCache, err := cache.NewStringCache(setting.Cache{}) mockCache, err := cache.NewStringCache(setting.Cache{})
@ -46,7 +47,7 @@ func TestRepository_ContributorsGraph(t *testing.T) {
assert.EqualValues(t, &ContributorData{ assert.EqualValues(t, &ContributorData{
Name: "Ethan Koenig", Name: "Ethan Koenig",
AvatarLink: "https://secure.gravatar.com/avatar/b42fb195faa8c61b8d88abfefe30e9e3?d=identicon", AvatarLink: "/assets/img/avatar_default.png",
TotalCommits: 1, TotalCommits: 1,
Weeks: map[int64]*WeekData{ Weeks: map[int64]*WeekData{
1511654400000: { 1511654400000: {

View File

@ -12,6 +12,7 @@ import (
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git" git_model "code.gitea.io/gitea/models/git"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
@ -20,6 +21,8 @@ import (
"code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
notify_service "code.gitea.io/gitea/services/notify" notify_service "code.gitea.io/gitea/services/notify"
"xorm.io/builder"
) )
// ErrForkAlreadyExist represents a "ForkAlreadyExist" kind of error. // ErrForkAlreadyExist represents a "ForkAlreadyExist" kind of error.
@ -247,3 +250,24 @@ func ConvertForkToNormalRepository(ctx context.Context, repo *repo_model.Reposit
return err return err
} }
type findForksOptions struct {
db.ListOptions
RepoID int64
Doer *user_model.User
}
func (opts findForksOptions) ToConds() builder.Cond {
return builder.Eq{"fork_id": opts.RepoID}.And(
repo_model.AccessibleRepositoryCondition(opts.Doer, unit.TypeInvalid),
)
}
// FindForks returns all the forks of the repository
func FindForks(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, listOptions db.ListOptions) ([]*repo_model.Repository, int64, error) {
return db.FindAndCount[repo_model.Repository](ctx, findForksOptions{
ListOptions: listOptions,
RepoID: repo.ID,
Doer: doer,
})
}

View File

@ -52,7 +52,7 @@
<tr> <tr>
<td>{{.ID}}</td> <td>{{.ID}}</td>
<td> <td>
<a href="{{.HomeLink}}">{{.Name}}</a> <a href="{{.HomeLink}}">{{if and DefaultShowFullName .FullName}}{{.FullName}} ({{.Name}}){{else}}{{.Name}}{{end}}</a>
{{if .Visibility.IsPrivate}} {{if .Visibility.IsPrivate}}
<span class="text gold">{{svg "octicon-lock"}}</span> <span class="text gold">{{svg "octicon-lock"}}</span>
{{end}} {{end}}

View File

@ -338,7 +338,9 @@
<div class="inline field"> <div class="inline field">
<div class="right-content"> <div class="right-content">
These configuration options will be written into: {{.CustomConfFile}} {{$copyBtn := svg "octicon-copy" 14}}
{{$filePath := HTMLFormat `<span class="ui label">%s</span> <button class="btn interact-fg" data-clipboard-text="%s">%s</button>` .CustomConfFile .CustomConfFile $copyBtn}}
{{ctx.Locale.Tr "install.config_write_file_prompt" $filePath}}
</div> </div>
<div class="tw-mt-4 tw-mb-2 tw-text-center"> <div class="tw-mt-4 tw-mb-2 tw-text-center">
<button class="ui primary button">{{ctx.Locale.Tr "install.install_btn_confirm"}}</button> <button class="ui primary button">{{ctx.Locale.Tr "install.install_btn_confirm"}}</button>

View File

@ -130,7 +130,7 @@
</div> </div>
<span class="file tw-flex tw-items-center tw-font-mono tw-flex-1"><a class="muted file-link" title="{{if $file.IsRenamed}}{{$file.OldName}}{{end}}{{$file.Name}}" href="#diff-{{$file.NameHash}}">{{if $file.IsRenamed}}{{$file.OldName}}{{end}}{{$file.Name}}</a> <span class="file tw-flex tw-items-center tw-font-mono tw-flex-1"><a class="muted file-link" title="{{if $file.IsRenamed}}{{$file.OldName}}{{end}}{{$file.Name}}" href="#diff-{{$file.NameHash}}">{{if $file.IsRenamed}}{{$file.OldName}}{{end}}{{$file.Name}}</a>
{{if .IsLFSFile}} ({{ctx.Locale.Tr "repo.stored_lfs"}}){{end}} {{if .IsLFSFile}} ({{ctx.Locale.Tr "repo.stored_lfs"}}){{end}}
<button class="btn interact-fg tw-p-2" data-clipboard-text="{{$file.Name}}">{{svg "octicon-copy" 14}}</button> <button class="btn interact-fg tw-p-2" data-clipboard-text="{{$file.Name}}" data-tooltip-content="{{ctx.Locale.Tr "copy_path"}}">{{svg "octicon-copy" 14}}</button>
{{if $file.IsGenerated}} {{if $file.IsGenerated}}
<span class="ui label">{{ctx.Locale.Tr "repo.diff.generated"}}</span> <span class="ui label">{{ctx.Locale.Tr "repo.diff.generated"}}</span>
{{end}} {{end}}

View File

@ -5,12 +5,14 @@
<h2 class="ui dividing header"> <h2 class="ui dividing header">
{{ctx.Locale.Tr "repo.forks"}} {{ctx.Locale.Tr "repo.forks"}}
</h2> </h2>
<div class="flex-list">
{{range .Forks}} {{range .Forks}}
<div class="tw-flex tw-items-center tw-py-2"> <div class="flex-item tw-border-0 repo-fork-item">
<span class="tw-mr-1">{{ctx.AvatarUtils.Avatar .Owner}}</span> <span>{{ctx.AvatarUtils.Avatar .Owner}}</span>
<a href="{{.Owner.HomeLink}}">{{.Owner.Name}}</a> / <a href="{{.Link}}">{{.Name}}</a> <span><a href="{{.Owner.HomeLink}}">{{.Owner.Name}}</a> / <a href="{{.Link}}">{{.Name}}</a></span>
</div> </div>
{{end}} {{end}}
</div>
</div> </div>
{{template "base/paginate" .}} {{template "base/paginate" .}}

View File

@ -106,6 +106,7 @@
<span class="breadcrumb-divider">/</span> <span class="breadcrumb-divider">/</span>
{{- if eq $i $l -}} {{- if eq $i $l -}}
<span class="active section" title="{{$v}}">{{$v}}</span> <span class="active section" title="{{$v}}">{{$v}}</span>
<button class="btn interact-fg tw-mx-1" data-clipboard-text="{{$.TreePath}}" data-tooltip-content="{{ctx.Locale.Tr "copy_path"}}">{{svg "octicon-copy" 14}}</button>
{{- else -}} {{- else -}}
{{$p := index $.Paths $i}}<span class="section"><a href="{{$.BranchLink}}/{{PathEscapeSegments $p}}" title="{{$v}}">{{$v}}</a></span> {{$p := index $.Paths $i}}<span class="section"><a href="{{$.BranchLink}}/{{PathEscapeSegments $p}}" title="{{$v}}">{{$v}}</a></span>
{{- end -}} {{- end -}}

View File

@ -16,12 +16,14 @@
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_assignees"}}"> <input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_assignees"}}">
</div> </div>
<div class="item clear-selection">{{ctx.Locale.Tr "repo.issues.new.clear_assignees"}}</div> <div class="item clear-selection">{{ctx.Locale.Tr "repo.issues.new.clear_assignees"}}</div>
{{range $data.CandidateAssignees}} <div class="scrolling menu">
<a class="item muted" href="#" data-value="{{.ID}}"> {{range $data.CandidateAssignees}}
<span class="item-check-mark">{{svg "octicon-check"}}</span> <a class="item muted" href="#" data-value="{{.ID}}">
{{ctx.AvatarUtils.Avatar . 20}} {{template "repo/search_name" .}} <span class="item-check-mark">{{svg "octicon-check"}}</span>
</a> {{ctx.AvatarUtils.Avatar . 20}} {{template "repo/search_name" .}}
{{end}} </a>
{{end}}
</div>
</div> </div>
</div> </div>
<div class="ui list tw-flex tw-flex-row tw-gap-2"> <div class="ui list tw-flex tw-flex-row tw-gap-2">

View File

@ -17,25 +17,27 @@
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_labels"}}"> <input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_labels"}}">
</div> </div>
<a class="item clear-selection" href="#">{{ctx.Locale.Tr "repo.issues.new.clear_labels"}}</a> <a class="item clear-selection" href="#">{{ctx.Locale.Tr "repo.issues.new.clear_labels"}}</a>
{{$previousExclusiveScope := "_no_scope"}} <div class="scrolling menu">
{{range $data.RepoLabels}} {{$previousExclusiveScope := "_no_scope"}}
{{$exclusiveScope := .ExclusiveScope}} {{range $data.RepoLabels}}
{{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}} {{$exclusiveScope := .ExclusiveScope}}
<div class="divider"></div> {{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
<div class="divider"></div>
{{end}}
{{$previousExclusiveScope = $exclusiveScope}}
{{template "repo/issue/sidebar/label_list_item" dict "Label" .}}
{{end}} {{end}}
{{$previousExclusiveScope = $exclusiveScope}} {{if and $data.RepoLabels $data.OrgLabels}}<div class="divider"></div>{{end}}
{{template "repo/issue/sidebar/label_list_item" dict "Label" .}} {{$previousExclusiveScope = "_no_scope"}}
{{end}} {{range $data.OrgLabels}}
<div class="divider"></div> {{$exclusiveScope := .ExclusiveScope}}
{{$previousExclusiveScope = "_no_scope"}} {{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
{{range $data.OrgLabels}} <div class="divider"></div>
{{$exclusiveScope := .ExclusiveScope}} {{end}}
{{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}} {{$previousExclusiveScope = $exclusiveScope}}
<div class="divider"></div> {{template "repo/issue/sidebar/label_list_item" dict "Label" .}}
{{end}} {{end}}
{{$previousExclusiveScope = $exclusiveScope}} </div>
{{template "repo/issue/sidebar/label_list_item" dict "Label" .}}
{{end}}
{{end}} {{end}}
</div> </div>
</div> </div>

View File

@ -20,25 +20,26 @@
</div> </div>
<div class="divider"></div> <div class="divider"></div>
<div class="item clear-selection">{{ctx.Locale.Tr "repo.issues.new.clear_milestone"}}</div> <div class="item clear-selection">{{ctx.Locale.Tr "repo.issues.new.clear_milestone"}}</div>
{{if $data.OpenMilestones}} <div class="scrolling menu">
<div class="divider"></div> {{if $data.OpenMilestones}}
<div class="header">{{ctx.Locale.Tr "repo.issues.new.open_milestone"}}</div> <div class="header">{{ctx.Locale.Tr "repo.issues.new.open_milestone"}}</div>
{{range $data.OpenMilestones}} {{range $data.OpenMilestones}}
<a class="item muted" data-value="{{.ID}}" href="{{$pageMeta.RepoLink}}/issues?milestone={{.ID}}"> <a class="item muted" data-value="{{.ID}}" href="{{$pageMeta.RepoLink}}/issues?milestone={{.ID}}">
{{svg "octicon-milestone" 18}} {{.Name}} {{svg "octicon-milestone" 18}} {{.Name}}
</a> </a>
{{end}}
{{end}}
{{if and $data.OpenMilestones $data.ClosedMilestones}}<div class="divider"></div>{{end}}
{{if $data.ClosedMilestones}}
<div class="header">{{ctx.Locale.Tr "repo.issues.new.closed_milestone"}}</div>
{{range $data.ClosedMilestones}}
<a class="item muted" data-value="{{.ID}}" href="{{$pageMeta.RepoLink}}/issues?milestone={{.ID}}">
{{svg "octicon-milestone" 18}} {{.Name}}
</a>
{{end}}
{{end}} {{end}}
{{end}} {{end}}
{{if $data.ClosedMilestones}} </div>
<div class="divider"></div>
<div class="header">{{ctx.Locale.Tr "repo.issues.new.closed_milestone"}}</div>
{{range $data.ClosedMilestones}}
<a class="item muted" data-value="{{.ID}}" href="{{$pageMeta.RepoLink}}/issues?milestone={{.ID}}">
{{svg "octicon-milestone" 18}} {{.Name}}
</a>
{{end}}
{{end}}
{{end}}
</div> </div>
</div> </div>

View File

@ -18,24 +18,25 @@
</div> </div>
{{end}} {{end}}
<div class="item clear-selection">{{ctx.Locale.Tr "repo.issues.new.clear_projects"}}</div> <div class="item clear-selection">{{ctx.Locale.Tr "repo.issues.new.clear_projects"}}</div>
{{if $data.OpenProjects}} <div class="scrolling menu">
<div class="divider"></div> {{if $data.OpenProjects}}
<div class="header">{{ctx.Locale.Tr "repo.issues.new.open_projects"}}</div> <div class="header">{{ctx.Locale.Tr "repo.issues.new.open_projects"}}</div>
{{range $data.OpenProjects}} {{range $data.OpenProjects}}
<a class="item muted" data-value="{{.ID}}" href="{{.Link ctx}}"> <a class="item muted" data-value="{{.ID}}" href="{{.Link ctx}}">
{{svg .IconName 18}} {{.Title}} {{svg .IconName 18}} {{.Title}}
</a> </a>
{{end}}
{{end}} {{end}}
{{end}} {{if and $data.OpenProjects $data.ClosedProjects}}<div class="divider"></div>{{end}}
{{if $data.ClosedProjects}} {{if $data.ClosedProjects}}
<div class="divider"></div> <div class="header">{{ctx.Locale.Tr "repo.issues.new.closed_projects"}}</div>
<div class="header">{{ctx.Locale.Tr "repo.issues.new.closed_projects"}}</div> {{range $data.ClosedProjects}}
{{range $data.ClosedProjects}} <a class="item muted" data-value="{{.ID}}" href="{{.Link ctx}}">
<a class="item muted" data-value="{{.ID}}" href="{{.Link ctx}}"> {{svg .IconName 18}} {{.Title}}
{{svg .IconName 18}} {{.Title}} </a>
</a> {{end}}
{{end}} {{end}}
{{end}} </div>
</div> </div>
</div> </div>
<div class="ui list"> <div class="ui list">

Some files were not shown because too many files have changed in this diff Show More