Merge branch 'main' into feat-32257-add-comments-unchanged-lines-and-show

This commit is contained in:
Rajesh Jonnalagadda 2024-11-18 16:52:42 +05:30 committed by GitHub
commit f5a83a7916
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
215 changed files with 1711 additions and 1731 deletions

View File

@ -154,12 +154,15 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services: services:
mysql: mysql:
image: mysql:8.0 # the bitnami mysql image has more options than the official one, it's easier to customize
image: bitnami/mysql:8.0
env: env:
MYSQL_ALLOW_EMPTY_PASSWORD: true ALLOW_EMPTY_PASSWORD: true
MYSQL_DATABASE: testgitea MYSQL_DATABASE: testgitea
ports: ports:
- "3306:3306" - "3306:3306"
options: >-
--mount type=tmpfs,destination=/bitnami/mysql/data
elasticsearch: elasticsearch:
image: elasticsearch:7.5.0 image: elasticsearch:7.5.0
env: env:
@ -188,7 +191,8 @@ jobs:
- name: run migration tests - name: run migration tests
run: make test-mysql-migration run: make test-mysql-migration
- name: run tests - name: run tests
run: make integration-test-coverage # run: make integration-test-coverage (at the moment, no coverage is really handled)
run: make test-mysql
env: env:
TAGS: bindata TAGS: bindata
RACE_ENABLED: true RACE_ENABLED: true

View File

@ -1912,7 +1912,7 @@ LEVEL = Info
;ENABLED = true ;ENABLED = true
;; ;;
;; Comma-separated list of allowed file extensions (`.zip`), mime types (`text/plain`) or wildcard type (`image/*`, `audio/*`, `video/*`). Empty value or `*/*` allows all types. ;; Comma-separated list of allowed file extensions (`.zip`), mime types (`text/plain`) or wildcard type (`image/*`, `audio/*`, `video/*`). Empty value or `*/*` allows all types.
;ALLOWED_TYPES = .csv,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.xls,.xlsx,.zip ;ALLOWED_TYPES = .avif,.cpuprofile,.csv,.dmp,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.json,.jsonc,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.webp,.xls,.xlsx,.zip
;; ;;
;; Max size of each file. Defaults to 2048MB ;; Max size of each file. Defaults to 2048MB
;MAX_SIZE = 2048 ;MAX_SIZE = 2048

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

@ -26,7 +26,7 @@
fork_id: 0 fork_id: 0
is_template: false is_template: false
template_id: 0 template_id: 0
size: 8478 size: 0
is_fsck_enabled: true is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false close_issues_via_commit_in_any_branch: false

View File

@ -8,7 +8,6 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"path"
"path/filepath" "path/filepath"
"runtime" "runtime"
"testing" "testing"
@ -16,7 +15,6 @@ import (
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/testlogger" "code.gitea.io/gitea/modules/testlogger"
@ -35,27 +33,7 @@ func PrepareTestEnv(t *testing.T, skip int, syncModels ...any) (*xorm.Engine, fu
ourSkip := 2 ourSkip := 2
ourSkip += skip ourSkip += skip
deferFn := testlogger.PrintCurrentTest(t, ourSkip) deferFn := testlogger.PrintCurrentTest(t, ourSkip)
assert.NoError(t, os.RemoveAll(setting.RepoRootPath)) assert.NoError(t, unittest.SyncDirs(filepath.Join(filepath.Dir(setting.AppPath), "tests/gitea-repositories-meta"), setting.RepoRootPath))
assert.NoError(t, unittest.CopyDir(path.Join(filepath.Dir(setting.AppPath), "tests/gitea-repositories-meta"), setting.RepoRootPath))
ownerDirs, err := os.ReadDir(setting.RepoRootPath)
if err != nil {
assert.NoError(t, err, "unable to read the new repo root: %v\n", err)
}
for _, ownerDir := range ownerDirs {
if !ownerDir.Type().IsDir() {
continue
}
repoDirs, err := os.ReadDir(filepath.Join(setting.RepoRootPath, ownerDir.Name()))
if err != nil {
assert.NoError(t, err, "unable to read the new repo root: %v\n", err)
}
for _, repoDir := range repoDirs {
_ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "objects", "pack"), 0o755)
_ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "objects", "info"), 0o755)
_ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "refs", "heads"), 0o755)
_ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "refs", "tag"), 0o755)
}
}
if err := deleteDB(); err != nil { if err := deleteDB(); err != nil {
t.Errorf("unable to reset database: %v", err) t.Errorf("unable to reset database: %v", err)
@ -112,39 +90,36 @@ func PrepareTestEnv(t *testing.T, skip int, syncModels ...any) (*xorm.Engine, fu
} }
func MainTest(m *testing.M) { func MainTest(m *testing.M) {
log.RegisterEventWriter("test", testlogger.NewTestLoggerWriter) testlogger.Init()
giteaRoot := base.SetupGiteaRoot() giteaRoot := base.SetupGiteaRoot()
if giteaRoot == "" { if giteaRoot == "" {
fmt.Println("Environment variable $GITEA_ROOT not set") testlogger.Fatalf("Environment variable $GITEA_ROOT not set\n")
os.Exit(1)
} }
giteaBinary := "gitea" giteaBinary := "gitea"
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
giteaBinary += ".exe" giteaBinary += ".exe"
} }
setting.AppPath = path.Join(giteaRoot, giteaBinary) setting.AppPath = filepath.Join(giteaRoot, giteaBinary)
if _, err := os.Stat(setting.AppPath); err != nil { if _, err := os.Stat(setting.AppPath); err != nil {
fmt.Printf("Could not find gitea binary at %s\n", setting.AppPath) testlogger.Fatalf("Could not find gitea binary at %s\n", setting.AppPath)
os.Exit(1)
} }
giteaConf := os.Getenv("GITEA_CONF") giteaConf := os.Getenv("GITEA_CONF")
if giteaConf == "" { if giteaConf == "" {
giteaConf = path.Join(filepath.Dir(setting.AppPath), "tests/sqlite.ini") giteaConf = filepath.Join(filepath.Dir(setting.AppPath), "tests/sqlite.ini")
fmt.Printf("Environment variable $GITEA_CONF not set - defaulting to %s\n", giteaConf) fmt.Printf("Environment variable $GITEA_CONF not set - defaulting to %s\n", giteaConf)
} }
if !path.IsAbs(giteaConf) { if !filepath.IsAbs(giteaConf) {
setting.CustomConf = path.Join(giteaRoot, giteaConf) setting.CustomConf = filepath.Join(giteaRoot, giteaConf)
} else { } else {
setting.CustomConf = giteaConf setting.CustomConf = giteaConf
} }
tmpDataPath, err := os.MkdirTemp("", "data") tmpDataPath, err := os.MkdirTemp("", "data")
if err != nil { if err != nil {
fmt.Printf("Unable to create temporary data path %v\n", err) testlogger.Fatalf("Unable to create temporary data path %v\n", err)
os.Exit(1)
} }
setting.CustomPath = filepath.Join(setting.AppWorkPath, "custom") setting.CustomPath = filepath.Join(setting.AppWorkPath, "custom")
@ -152,8 +127,7 @@ func MainTest(m *testing.M) {
unittest.InitSettings() unittest.InitSettings()
if err = git.InitFull(context.Background()); err != nil { if err = git.InitFull(context.Background()); err != nil {
fmt.Printf("Unable to InitFull: %v\n", err) testlogger.Fatalf("Unable to InitFull: %v\n", err)
os.Exit(1)
} }
setting.LoadDBSetting() setting.LoadDBSetting()
setting.InitLoggersForTest() setting.InitLoggersForTest()

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

@ -7,6 +7,7 @@ import (
"context" "context"
"fmt" "fmt"
"html/template" "html/template"
"maps"
"net" "net"
"net/url" "net/url"
"path/filepath" "path/filepath"
@ -165,10 +166,10 @@ type Repository struct {
Status RepositoryStatus `xorm:"NOT NULL DEFAULT 0"` Status RepositoryStatus `xorm:"NOT NULL DEFAULT 0"`
RenderingMetas map[string]string `xorm:"-"` commonRenderingMetas map[string]string `xorm:"-"`
DocumentRenderingMetas map[string]string `xorm:"-"`
Units []*RepoUnit `xorm:"-"` Units []*RepoUnit `xorm:"-"`
PrimaryLanguage *LanguageStat `xorm:"-"` PrimaryLanguage *LanguageStat `xorm:"-"`
IsFork bool `xorm:"INDEX NOT NULL DEFAULT false"` IsFork bool `xorm:"INDEX NOT NULL DEFAULT false"`
ForkID int64 `xorm:"INDEX"` ForkID int64 `xorm:"INDEX"`
@ -473,9 +474,8 @@ func (repo *Repository) MustOwner(ctx context.Context) *user_model.User {
return repo.Owner return repo.Owner
} }
// ComposeMetas composes a map of metas for properly rendering issue links and external issue trackers. func (repo *Repository) composeCommonMetas(ctx context.Context) map[string]string {
func (repo *Repository) ComposeMetas(ctx context.Context) map[string]string { if len(repo.commonRenderingMetas) == 0 {
if len(repo.RenderingMetas) == 0 {
metas := map[string]string{ metas := map[string]string{
"user": repo.OwnerName, "user": repo.OwnerName,
"repo": repo.Name, "repo": repo.Name,
@ -508,21 +508,34 @@ func (repo *Repository) ComposeMetas(ctx context.Context) map[string]string {
metas["org"] = strings.ToLower(repo.OwnerName) metas["org"] = strings.ToLower(repo.OwnerName)
} }
repo.RenderingMetas = metas repo.commonRenderingMetas = metas
} }
return repo.RenderingMetas return repo.commonRenderingMetas
} }
// ComposeDocumentMetas composes a map of metas for properly rendering documents // ComposeMetas composes a map of metas for properly rendering comments or comment-like contents (commit message)
func (repo *Repository) ComposeMetas(ctx context.Context) map[string]string {
metas := maps.Clone(repo.composeCommonMetas(ctx))
metas["markdownLineBreakStyle"] = "comment"
metas["markupAllowShortIssuePattern"] = "true"
return metas
}
// ComposeWikiMetas composes a map of metas for properly rendering wikis
func (repo *Repository) ComposeWikiMetas(ctx context.Context) map[string]string {
// does wiki need the "teams" and "org" from common metas?
metas := maps.Clone(repo.composeCommonMetas(ctx))
metas["markdownLineBreakStyle"] = "document"
metas["markupAllowShortIssuePattern"] = "true"
return metas
}
// ComposeDocumentMetas composes a map of metas for properly rendering documents (repo files)
func (repo *Repository) ComposeDocumentMetas(ctx context.Context) map[string]string { func (repo *Repository) ComposeDocumentMetas(ctx context.Context) map[string]string {
if len(repo.DocumentRenderingMetas) == 0 { // does document(file) need the "teams" and "org" from common metas?
metas := map[string]string{} metas := maps.Clone(repo.composeCommonMetas(ctx))
for k, v := range repo.ComposeMetas(ctx) { metas["markdownLineBreakStyle"] = "document"
metas[k] = v return metas
}
repo.DocumentRenderingMetas = metas
}
return repo.DocumentRenderingMetas
} }
// GetBaseRepo populates repo.BaseRepo for a fork repository and // GetBaseRepo populates repo.BaseRepo for a fork repository and

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

@ -1,13 +1,12 @@
// Copyright 2017 The Gitea Authors. All rights reserved. // Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
package repo_test package repo
import ( import (
"testing" "testing"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unit"
"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"
@ -20,18 +19,18 @@ import (
) )
var ( var (
countRepospts = repo_model.CountRepositoryOptions{OwnerID: 10} countRepospts = CountRepositoryOptions{OwnerID: 10}
countReposptsPublic = repo_model.CountRepositoryOptions{OwnerID: 10, Private: optional.Some(false)} countReposptsPublic = CountRepositoryOptions{OwnerID: 10, Private: optional.Some(false)}
countReposptsPrivate = repo_model.CountRepositoryOptions{OwnerID: 10, Private: optional.Some(true)} countReposptsPrivate = CountRepositoryOptions{OwnerID: 10, Private: optional.Some(true)}
) )
func TestGetRepositoryCount(t *testing.T) { func TestGetRepositoryCount(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
ctx := db.DefaultContext ctx := db.DefaultContext
count, err1 := repo_model.CountRepositories(ctx, countRepospts) count, err1 := CountRepositories(ctx, countRepospts)
privateCount, err2 := repo_model.CountRepositories(ctx, countReposptsPrivate) privateCount, err2 := CountRepositories(ctx, countReposptsPrivate)
publicCount, err3 := repo_model.CountRepositories(ctx, countReposptsPublic) publicCount, err3 := CountRepositories(ctx, countReposptsPublic)
assert.NoError(t, err1) assert.NoError(t, err1)
assert.NoError(t, err2) assert.NoError(t, err2)
assert.NoError(t, err3) assert.NoError(t, err3)
@ -42,7 +41,7 @@ func TestGetRepositoryCount(t *testing.T) {
func TestGetPublicRepositoryCount(t *testing.T) { func TestGetPublicRepositoryCount(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
count, err := repo_model.CountRepositories(db.DefaultContext, countReposptsPublic) count, err := CountRepositories(db.DefaultContext, countReposptsPublic)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, int64(1), count) assert.Equal(t, int64(1), count)
} }
@ -50,14 +49,14 @@ func TestGetPublicRepositoryCount(t *testing.T) {
func TestGetPrivateRepositoryCount(t *testing.T) { func TestGetPrivateRepositoryCount(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
count, err := repo_model.CountRepositories(db.DefaultContext, countReposptsPrivate) count, err := CountRepositories(db.DefaultContext, countReposptsPrivate)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, int64(2), count) assert.Equal(t, int64(2), count)
} }
func TestRepoAPIURL(t *testing.T) { func TestRepoAPIURL(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10}) repo := unittest.AssertExistsAndLoadBean(t, &Repository{ID: 10})
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user12/repo10", repo.APIURL()) assert.Equal(t, "https://try.gitea.io/api/v1/repos/user12/repo10", repo.APIURL())
} }
@ -65,22 +64,22 @@ func TestRepoAPIURL(t *testing.T) {
func TestWatchRepo(t *testing.T) { func TestWatchRepo(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) repo := unittest.AssertExistsAndLoadBean(t, &Repository{ID: 3})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
assert.NoError(t, repo_model.WatchRepo(db.DefaultContext, user, repo, true)) assert.NoError(t, WatchRepo(db.DefaultContext, user, repo, true))
unittest.AssertExistsAndLoadBean(t, &repo_model.Watch{RepoID: repo.ID, UserID: user.ID}) unittest.AssertExistsAndLoadBean(t, &Watch{RepoID: repo.ID, UserID: user.ID})
unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repo.ID}) unittest.CheckConsistencyFor(t, &Repository{ID: repo.ID})
assert.NoError(t, repo_model.WatchRepo(db.DefaultContext, user, repo, false)) assert.NoError(t, WatchRepo(db.DefaultContext, user, repo, false))
unittest.AssertNotExistsBean(t, &repo_model.Watch{RepoID: repo.ID, UserID: user.ID}) unittest.AssertNotExistsBean(t, &Watch{RepoID: repo.ID, UserID: user.ID})
unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repo.ID}) unittest.CheckConsistencyFor(t, &Repository{ID: repo.ID})
} }
func TestMetas(t *testing.T) { func TestMetas(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
repo := &repo_model.Repository{Name: "testRepo"} repo := &Repository{Name: "testRepo"}
repo.Owner = &user_model.User{Name: "testOwner"} repo.Owner = &user_model.User{Name: "testOwner"}
repo.OwnerName = repo.Owner.Name repo.OwnerName = repo.Owner.Name
@ -90,16 +89,16 @@ func TestMetas(t *testing.T) {
assert.Equal(t, "testRepo", metas["repo"]) assert.Equal(t, "testRepo", metas["repo"])
assert.Equal(t, "testOwner", metas["user"]) assert.Equal(t, "testOwner", metas["user"])
externalTracker := repo_model.RepoUnit{ externalTracker := RepoUnit{
Type: unit.TypeExternalTracker, Type: unit.TypeExternalTracker,
Config: &repo_model.ExternalTrackerConfig{ Config: &ExternalTrackerConfig{
ExternalTrackerFormat: "https://someurl.com/{user}/{repo}/{issue}", ExternalTrackerFormat: "https://someurl.com/{user}/{repo}/{issue}",
}, },
} }
testSuccess := func(expectedStyle string) { testSuccess := func(expectedStyle string) {
repo.Units = []*repo_model.RepoUnit{&externalTracker} repo.Units = []*RepoUnit{&externalTracker}
repo.RenderingMetas = nil repo.commonRenderingMetas = nil
metas := repo.ComposeMetas(db.DefaultContext) metas := repo.ComposeMetas(db.DefaultContext)
assert.Equal(t, expectedStyle, metas["style"]) assert.Equal(t, expectedStyle, metas["style"])
assert.Equal(t, "testRepo", metas["repo"]) assert.Equal(t, "testRepo", metas["repo"])
@ -118,7 +117,7 @@ func TestMetas(t *testing.T) {
externalTracker.ExternalTrackerConfig().ExternalTrackerStyle = markup.IssueNameStyleRegexp externalTracker.ExternalTrackerConfig().ExternalTrackerStyle = markup.IssueNameStyleRegexp
testSuccess(markup.IssueNameStyleRegexp) testSuccess(markup.IssueNameStyleRegexp)
repo, err := repo_model.GetRepositoryByID(db.DefaultContext, 3) repo, err := GetRepositoryByID(db.DefaultContext, 3)
assert.NoError(t, err) assert.NoError(t, err)
metas = repo.ComposeMetas(db.DefaultContext) metas = repo.ComposeMetas(db.DefaultContext)
@ -132,7 +131,7 @@ func TestGetRepositoryByURL(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
t.Run("InvalidPath", func(t *testing.T) { t.Run("InvalidPath", func(t *testing.T) {
repo, err := repo_model.GetRepositoryByURL(db.DefaultContext, "something") repo, err := GetRepositoryByURL(db.DefaultContext, "something")
assert.Nil(t, repo) assert.Nil(t, repo)
assert.Error(t, err) assert.Error(t, err)
@ -140,7 +139,7 @@ func TestGetRepositoryByURL(t *testing.T) {
t.Run("ValidHttpURL", func(t *testing.T) { t.Run("ValidHttpURL", func(t *testing.T) {
test := func(t *testing.T, url string) { test := func(t *testing.T, url string) {
repo, err := repo_model.GetRepositoryByURL(db.DefaultContext, url) repo, err := GetRepositoryByURL(db.DefaultContext, url)
assert.NotNil(t, repo) assert.NotNil(t, repo)
assert.NoError(t, err) assert.NoError(t, err)
@ -155,7 +154,7 @@ func TestGetRepositoryByURL(t *testing.T) {
t.Run("ValidGitSshURL", func(t *testing.T) { t.Run("ValidGitSshURL", func(t *testing.T) {
test := func(t *testing.T, url string) { test := func(t *testing.T, url string) {
repo, err := repo_model.GetRepositoryByURL(db.DefaultContext, url) repo, err := GetRepositoryByURL(db.DefaultContext, url)
assert.NotNil(t, repo) assert.NotNil(t, repo)
assert.NoError(t, err) assert.NoError(t, err)
@ -173,7 +172,7 @@ func TestGetRepositoryByURL(t *testing.T) {
t.Run("ValidImplicitSshURL", func(t *testing.T) { t.Run("ValidImplicitSshURL", func(t *testing.T) {
test := func(t *testing.T, url string) { test := func(t *testing.T, url string) {
repo, err := repo_model.GetRepositoryByURL(db.DefaultContext, url) repo, err := GetRepositoryByURL(db.DefaultContext, url)
assert.NotNil(t, repo) assert.NotNil(t, repo)
assert.NoError(t, err) assert.NoError(t, err)
@ -200,21 +199,21 @@ func TestComposeSSHCloneURL(t *testing.T) {
setting.SSH.Domain = "domain" setting.SSH.Domain = "domain"
setting.SSH.Port = 22 setting.SSH.Port = 22
setting.Repository.UseCompatSSHURI = false setting.Repository.UseCompatSSHURI = false
assert.Equal(t, "git@domain:user/repo.git", repo_model.ComposeSSHCloneURL("user", "repo")) assert.Equal(t, "git@domain:user/repo.git", ComposeSSHCloneURL("user", "repo"))
setting.Repository.UseCompatSSHURI = true setting.Repository.UseCompatSSHURI = true
assert.Equal(t, "ssh://git@domain/user/repo.git", repo_model.ComposeSSHCloneURL("user", "repo")) assert.Equal(t, "ssh://git@domain/user/repo.git", ComposeSSHCloneURL("user", "repo"))
// test SSH_DOMAIN while use non-standard SSH port // test SSH_DOMAIN while use non-standard SSH port
setting.SSH.Port = 123 setting.SSH.Port = 123
setting.Repository.UseCompatSSHURI = false setting.Repository.UseCompatSSHURI = false
assert.Equal(t, "ssh://git@domain:123/user/repo.git", repo_model.ComposeSSHCloneURL("user", "repo")) assert.Equal(t, "ssh://git@domain:123/user/repo.git", ComposeSSHCloneURL("user", "repo"))
setting.Repository.UseCompatSSHURI = true setting.Repository.UseCompatSSHURI = true
assert.Equal(t, "ssh://git@domain:123/user/repo.git", repo_model.ComposeSSHCloneURL("user", "repo")) assert.Equal(t, "ssh://git@domain:123/user/repo.git", ComposeSSHCloneURL("user", "repo"))
// test IPv6 SSH_DOMAIN // test IPv6 SSH_DOMAIN
setting.Repository.UseCompatSSHURI = false setting.Repository.UseCompatSSHURI = false
setting.SSH.Domain = "::1" setting.SSH.Domain = "::1"
setting.SSH.Port = 22 setting.SSH.Port = 22
assert.Equal(t, "git@[::1]:user/repo.git", repo_model.ComposeSSHCloneURL("user", "repo")) assert.Equal(t, "git@[::1]:user/repo.git", ComposeSSHCloneURL("user", "repo"))
setting.SSH.Port = 123 setting.SSH.Port = 123
assert.Equal(t, "ssh://git@[::1]:123/user/repo.git", repo_model.ComposeSSHCloneURL("user", "repo")) assert.Equal(t, "ssh://git@[::1]:123/user/repo.git", ComposeSSHCloneURL("user", "repo"))
} }

View File

@ -36,6 +36,7 @@ var OrderByMap = map[string]map[string]db.SearchOrderBy{
var OrderByFlatMap = map[string]db.SearchOrderBy{ var OrderByFlatMap = map[string]db.SearchOrderBy{
"newest": OrderByMap["desc"]["created"], "newest": OrderByMap["desc"]["created"],
"oldest": OrderByMap["asc"]["created"], "oldest": OrderByMap["asc"]["created"],
"recentupdate": OrderByMap["desc"]["updated"],
"leastupdate": OrderByMap["asc"]["updated"], "leastupdate": OrderByMap["asc"]["updated"],
"reversealphabetically": OrderByMap["desc"]["alpha"], "reversealphabetically": OrderByMap["desc"]["alpha"],
"alphabetically": OrderByMap["asc"]["alpha"], "alphabetically": OrderByMap["asc"]["alpha"],

View File

@ -4,10 +4,8 @@
package unittest package unittest
import ( import (
"errors"
"io"
"os" "os"
"path" "path/filepath"
"strings" "strings"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
@ -32,67 +30,73 @@ func Copy(src, dest string) error {
return os.Symlink(target, dest) return os.Symlink(target, dest)
} }
sr, err := os.Open(src) return util.CopyFile(src, dest)
if err != nil {
return err
}
defer sr.Close()
dw, err := os.Create(dest)
if err != nil {
return err
}
defer dw.Close()
if _, err = io.Copy(dw, sr); err != nil {
return err
}
// Set back file information.
if err = os.Chtimes(dest, si.ModTime(), si.ModTime()); err != nil {
return err
}
return os.Chmod(dest, si.Mode())
} }
// CopyDir copy files recursively from source to target directory. // Sync synchronizes the two files. This is skipped if both files
// // exist and the size, modtime, and mode match.
// The filter accepts a function that process the path info. func Sync(srcPath, destPath string) error {
// and should return true for need to filter. dest, err := os.Stat(destPath)
// if err != nil {
// It returns error when error occurs in underlying functions. if os.IsNotExist(err) {
func CopyDir(srcPath, destPath string, filters ...func(filePath string) bool) error { return Copy(srcPath, destPath)
// Check if target directory exists. }
if _, err := os.Stat(destPath); !errors.Is(err, os.ErrNotExist) { return err
return util.NewAlreadyExistErrorf("file or directory already exists: %s", destPath)
} }
src, err := os.Stat(srcPath)
if err != nil {
return err
}
if src.Size() == dest.Size() &&
src.ModTime() == dest.ModTime() &&
src.Mode() == dest.Mode() {
return nil
}
return Copy(srcPath, destPath)
}
// SyncDirs synchronizes files recursively from source to target directory.
// It returns error when error occurs in underlying functions.
func SyncDirs(srcPath, destPath string) error {
err := os.MkdirAll(destPath, os.ModePerm) err := os.MkdirAll(destPath, os.ModePerm)
if err != nil { if err != nil {
return err return err
} }
// Gather directory info. // find and delete all untracked files
infos, err := util.StatDir(srcPath, true) destFiles, err := util.StatDir(destPath, true)
if err != nil { if err != nil {
return err return err
} }
for _, destFile := range destFiles {
var filter func(filePath string) bool destFilePath := filepath.Join(destPath, destFile)
if len(filters) > 0 { if _, err = os.Stat(filepath.Join(srcPath, destFile)); err != nil {
filter = filters[0] if os.IsNotExist(err) {
// if src file does not exist, remove dest file
if err = os.RemoveAll(destFilePath); err != nil {
return err
}
} else {
return err
}
}
} }
for _, info := range infos { // sync src files to dest
if filter != nil && filter(info) { srcFiles, err := util.StatDir(srcPath, true)
continue if err != nil {
} return err
}
curPath := path.Join(destPath, info) for _, srcFile := range srcFiles {
if strings.HasSuffix(info, "/") { destFilePath := filepath.Join(destPath, srcFile)
err = os.MkdirAll(curPath, os.ModePerm) // util.StatDir appends a slash to the directory name
if strings.HasSuffix(srcFile, "/") {
err = os.MkdirAll(destFilePath, os.ModePerm)
} else { } else {
err = Copy(path.Join(srcPath, info), curPath) err = Sync(filepath.Join(srcPath, srcFile), destFilePath)
} }
if err != nil { if err != nil {
return err return err

View File

@ -164,35 +164,13 @@ func MainTest(m *testing.M, testOpts ...*TestOptions) {
if err = storage.Init(); err != nil { if err = storage.Init(); err != nil {
fatalTestError("storage.Init: %v\n", err) fatalTestError("storage.Init: %v\n", err)
} }
if err = util.RemoveAll(repoRootPath); err != nil { if err = SyncDirs(filepath.Join(giteaRoot, "tests", "gitea-repositories-meta"), setting.RepoRootPath); err != nil {
fatalTestError("util.RemoveAll: %v\n", err) fatalTestError("util.SyncDirs: %v\n", err)
}
if err = CopyDir(filepath.Join(giteaRoot, "tests", "gitea-repositories-meta"), setting.RepoRootPath); err != nil {
fatalTestError("util.CopyDir: %v\n", err)
} }
if err = git.InitFull(context.Background()); err != nil { if err = git.InitFull(context.Background()); err != nil {
fatalTestError("git.Init: %v\n", err) fatalTestError("git.Init: %v\n", err)
} }
ownerDirs, err := os.ReadDir(setting.RepoRootPath)
if err != nil {
fatalTestError("unable to read the new repo root: %v\n", err)
}
for _, ownerDir := range ownerDirs {
if !ownerDir.Type().IsDir() {
continue
}
repoDirs, err := os.ReadDir(filepath.Join(setting.RepoRootPath, ownerDir.Name()))
if err != nil {
fatalTestError("unable to read the new repo root: %v\n", err)
}
for _, repoDir := range repoDirs {
_ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "objects", "pack"), 0o755)
_ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "objects", "info"), 0o755)
_ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "refs", "heads"), 0o755)
_ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "refs", "tag"), 0o755)
}
}
if len(testOpts) > 0 && testOpts[0].SetUp != nil { if len(testOpts) > 0 && testOpts[0].SetUp != nil {
if err := testOpts[0].SetUp(); err != nil { if err := testOpts[0].SetUp(); err != nil {
@ -255,24 +233,7 @@ func PrepareTestDatabase() error {
// by tests that use the above MainTest(..) function. // by tests that use the above MainTest(..) function.
func PrepareTestEnv(t testing.TB) { func PrepareTestEnv(t testing.TB) {
assert.NoError(t, PrepareTestDatabase()) assert.NoError(t, PrepareTestDatabase())
assert.NoError(t, util.RemoveAll(setting.RepoRootPath))
metaPath := filepath.Join(giteaRoot, "tests", "gitea-repositories-meta") metaPath := filepath.Join(giteaRoot, "tests", "gitea-repositories-meta")
assert.NoError(t, CopyDir(metaPath, setting.RepoRootPath)) assert.NoError(t, SyncDirs(metaPath, setting.RepoRootPath))
ownerDirs, err := os.ReadDir(setting.RepoRootPath)
assert.NoError(t, err)
for _, ownerDir := range ownerDirs {
if !ownerDir.Type().IsDir() {
continue
}
repoDirs, err := os.ReadDir(filepath.Join(setting.RepoRootPath, ownerDir.Name()))
assert.NoError(t, err)
for _, repoDir := range repoDirs {
_ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "objects", "pack"), 0o755)
_ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "objects", "info"), 0o755)
_ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "refs", "heads"), 0o755)
_ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "refs", "tag"), 0o755)
}
}
base.SetupGiteaRoot() // Makes sure GITEA_ROOT is set base.SetupGiteaRoot() // Makes sure GITEA_ROOT is set
} }

View File

@ -1 +0,0 @@
Unnamed repository; edit this file 'description' to name the repository.

View File

@ -1,6 +0,0 @@
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~

View File

@ -1 +0,0 @@
Unnamed repository; edit this file 'description' to name the repository.

View File

@ -1,6 +0,0 @@
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~

View File

@ -1 +0,0 @@
Unnamed repository; edit this file 'description' to name the repository.

View File

@ -1,6 +0,0 @@
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~

View File

@ -1 +0,0 @@
Unnamed repository; edit this file 'description' to name the repository.

View File

@ -1,6 +0,0 @@
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~

View File

@ -1 +0,0 @@
Unnamed repository; edit this file 'description' to name the repository.

View File

@ -1 +0,0 @@
Unnamed repository; edit this file 'description' to name the repository.

View File

@ -1,6 +0,0 @@
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~

View File

@ -1 +0,0 @@
Unnamed repository; edit this file 'description' to name the repository.

View File

@ -1 +0,0 @@
Unnamed repository; edit this file 'description' to name the repository.

View File

@ -1,6 +0,0 @@
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~

View File

@ -1 +0,0 @@
Unnamed repository; edit this file 'description' to name the repository.

View File

@ -1,6 +0,0 @@
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~

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

@ -46,7 +46,7 @@ func ServeSetHeaders(w http.ResponseWriter, opts *ServeHeaderOptions) {
w.Header().Add(gzhttp.HeaderNoCompression, "1") w.Header().Add(gzhttp.HeaderNoCompression, "1")
} }
contentType := typesniffer.ApplicationOctetStream contentType := typesniffer.MimeTypeApplicationOctetStream
if opts.ContentType != "" { if opts.ContentType != "" {
if opts.ContentTypeCharset != "" { if opts.ContentTypeCharset != "" {
contentType = opts.ContentType + "; charset=" + strings.ToLower(opts.ContentTypeCharset) contentType = opts.ContentType + "; charset=" + strings.ToLower(opts.ContentTypeCharset)
@ -107,7 +107,7 @@ func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, filePath stri
} else if isPlain { } else if isPlain {
opts.ContentType = "text/plain" opts.ContentType = "text/plain"
} else { } else {
opts.ContentType = typesniffer.ApplicationOctetStream opts.ContentType = typesniffer.MimeTypeApplicationOctetStream
} }
} }

View File

@ -21,6 +21,7 @@ import (
_ "code.gitea.io/gitea/models/activities" _ "code.gitea.io/gitea/models/activities"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
@ -284,15 +285,11 @@ func TestBleveIndexAndSearch(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
idx := bleve.NewIndexer(dir) idx := bleve.NewIndexer(dir)
_, err := idx.Init(context.Background())
if err != nil {
if idx != nil {
idx.Close()
}
assert.FailNow(t, "Unable to create bleve indexer Error: %v", err)
}
defer idx.Close() defer idx.Close()
_, err := idx.Init(context.Background())
require.NoError(t, err)
testIndexer("beleve", t, idx) testIndexer("beleve", t, idx)
} }

View File

@ -86,6 +86,8 @@ type ColoredValue struct {
colors []ColorAttribute colors []ColorAttribute
} }
var _ fmt.Formatter = (*ColoredValue)(nil)
func (c *ColoredValue) Format(f fmt.State, verb rune) { func (c *ColoredValue) Format(f fmt.State, verb rune) {
_, _ = f.Write(ColorBytes(c.colors...)) _, _ = f.Write(ColorBytes(c.colors...))
s := fmt.Sprintf(fmt.FormatString(f, verb), c.v) s := fmt.Sprintf(fmt.FormatString(f, verb), c.v)
@ -93,6 +95,10 @@ func (c *ColoredValue) Format(f fmt.State, verb rune) {
_, _ = f.Write(resetBytes) _, _ = f.Write(resetBytes)
} }
func (c *ColoredValue) Value() any {
return c.v
}
func NewColoredValue(v any, color ...ColorAttribute) *ColoredValue { func NewColoredValue(v any, color ...ColorAttribute) *ColoredValue {
return &ColoredValue{v: v, colors: color} return &ColoredValue{v: v, colors: color}
} }

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

@ -7,11 +7,11 @@ import (
"bytes" "bytes"
"io" "io"
"regexp" "regexp"
"slices"
"strings" "strings"
"sync" "sync"
"code.gitea.io/gitea/modules/markup/common" "code.gitea.io/gitea/modules/markup/common"
"code.gitea.io/gitea/modules/setting"
"golang.org/x/net/html" "golang.org/x/net/html"
"golang.org/x/net/html/atom" "golang.org/x/net/html/atom"
@ -25,7 +25,25 @@ const (
IssueNameStyleRegexp = "regexp" IssueNameStyleRegexp = "regexp"
) )
var ( type globalVarsType struct {
hashCurrentPattern *regexp.Regexp
shortLinkPattern *regexp.Regexp
anyHashPattern *regexp.Regexp
comparePattern *regexp.Regexp
fullURLPattern *regexp.Regexp
emailRegex *regexp.Regexp
blackfridayExtRegex *regexp.Regexp
emojiShortCodeRegex *regexp.Regexp
issueFullPattern *regexp.Regexp
filesChangedFullPattern *regexp.Regexp
codePreviewPattern *regexp.Regexp
tagCleaner *regexp.Regexp
nulCleaner *strings.Replacer
}
var globalVars = sync.OnceValue[*globalVarsType](func() *globalVarsType {
v := &globalVarsType{}
// NOTE: All below regex matching do not perform any extra validation. // NOTE: All below regex matching do not perform any extra validation.
// Thus a link is produced even if the linked entity does not exist. // Thus a link is produced even if the linked entity does not exist.
// While fast, this is also incorrect and lead to false positives. // While fast, this is also incorrect and lead to false positives.
@ -36,79 +54,59 @@ var (
// hashCurrentPattern matches string that represents a commit SHA, e.g. d8a994ef243349f321568f9e36d5c3f444b99cae // hashCurrentPattern matches string that represents a commit SHA, e.g. d8a994ef243349f321568f9e36d5c3f444b99cae
// Although SHA1 hashes are 40 chars long, SHA256 are 64, the regex matches the hash from 7 to 64 chars in length // Although SHA1 hashes are 40 chars long, SHA256 are 64, the regex matches the hash from 7 to 64 chars in length
// so that abbreviated hash links can be used as well. This matches git and GitHub usability. // so that abbreviated hash links can be used as well. This matches git and GitHub usability.
hashCurrentPattern = regexp.MustCompile(`(?:\s|^|\(|\[)([0-9a-f]{7,64})(?:\s|$|\)|\]|[.,:](\s|$))`) v.hashCurrentPattern = regexp.MustCompile(`(?:\s|^|\(|\[)([0-9a-f]{7,64})(?:\s|$|\)|\]|[.,:](\s|$))`)
// shortLinkPattern matches short but difficult to parse [[name|link|arg=test]] syntax // shortLinkPattern matches short but difficult to parse [[name|link|arg=test]] syntax
shortLinkPattern = regexp.MustCompile(`\[\[(.*?)\]\](\w*)`) v.shortLinkPattern = regexp.MustCompile(`\[\[(.*?)\]\](\w*)`)
// anyHashPattern splits url containing SHA into parts // anyHashPattern splits url containing SHA into parts
anyHashPattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{40,64})(/[-+~%./\w]+)?(\?[-+~%.\w&=]+)?(#[-+~%.\w]+)?`) v.anyHashPattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{40,64})(/[-+~%./\w]+)?(\?[-+~%.\w&=]+)?(#[-+~%.\w]+)?`)
// comparePattern matches "http://domain/org/repo/compare/COMMIT1...COMMIT2#hash" // comparePattern matches "http://domain/org/repo/compare/COMMIT1...COMMIT2#hash"
comparePattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{7,64})(\.\.\.?)([0-9a-f]{7,64})?(#[-+~_%.a-zA-Z0-9]+)?`) v.comparePattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{7,64})(\.\.\.?)([0-9a-f]{7,64})?(#[-+~_%.a-zA-Z0-9]+)?`)
// fullURLPattern matches full URL like "mailto:...", "https://..." and "ssh+git://..." // fullURLPattern matches full URL like "mailto:...", "https://..." and "ssh+git://..."
fullURLPattern = regexp.MustCompile(`^[a-z][-+\w]+:`) v.fullURLPattern = regexp.MustCompile(`^[a-z][-+\w]+:`)
// emailRegex is definitely not perfect with edge cases, // emailRegex is definitely not perfect with edge cases,
// it is still accepted by the CommonMark specification, as well as the HTML5 spec: // it is still accepted by the CommonMark specification, as well as the HTML5 spec:
// http://spec.commonmark.org/0.28/#email-address // http://spec.commonmark.org/0.28/#email-address
// https://html.spec.whatwg.org/multipage/input.html#e-mail-state-(type%3Demail) // https://html.spec.whatwg.org/multipage/input.html#e-mail-state-(type%3Demail)
emailRegex = regexp.MustCompile("(?:\\s|^|\\(|\\[)([a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9]{2,}(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+)(?:\\s|$|\\)|\\]|;|,|\\?|!|\\.(\\s|$))") v.emailRegex = regexp.MustCompile("(?:\\s|^|\\(|\\[)([a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9]{2,}(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+)(?:\\s|$|\\)|\\]|;|,|\\?|!|\\.(\\s|$))")
// blackfridayExtRegex is for blackfriday extensions create IDs like fn:user-content-footnote // blackfridayExtRegex is for blackfriday extensions create IDs like fn:user-content-footnote
blackfridayExtRegex = regexp.MustCompile(`[^:]*:user-content-`) v.blackfridayExtRegex = regexp.MustCompile(`[^:]*:user-content-`)
// emojiShortCodeRegex find emoji by alias like :smile: // emojiShortCodeRegex find emoji by alias like :smile:
emojiShortCodeRegex = regexp.MustCompile(`:[-+\w]+:`) v.emojiShortCodeRegex = regexp.MustCompile(`:[-+\w]+:`)
)
// CSS class for action keywords (e.g. "closes: #1") // example: https://domain/org/repo/pulls/27#hash
const keywordClass = "issue-keyword" v.issueFullPattern = regexp.MustCompile(`https?://(?:\S+/)[\w_.-]+/[\w_.-]+/(?:issues|pulls)/((?:\w{1,10}-)?[1-9][0-9]*)([\?|#](\S+)?)?\b`)
// 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`)
// 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.nulCleaner = strings.NewReplacer("\000", "")
return v
})
// IsFullURLBytes reports whether link fits valid format. // IsFullURLBytes reports whether link fits valid format.
func IsFullURLBytes(link []byte) bool { func IsFullURLBytes(link []byte) bool {
return fullURLPattern.Match(link) return globalVars().fullURLPattern.Match(link)
} }
func IsFullURLString(link string) bool { func IsFullURLString(link string) bool {
return fullURLPattern.MatchString(link) return globalVars().fullURLPattern.MatchString(link)
} }
func IsNonEmptyRelativePath(link string) bool { func IsNonEmptyRelativePath(link string) bool {
return link != "" && !IsFullURLString(link) && link[0] != '/' && link[0] != '?' && link[0] != '#' return link != "" && !IsFullURLString(link) && link[0] != '/' && link[0] != '?' && link[0] != '#'
} }
// regexp for full links to issues/pulls
var issueFullPattern *regexp.Regexp
// Once for to prevent races
var issueFullPatternOnce sync.Once
// regexp for full links to hash comment in pull request files changed tab
var filesChangedFullPattern *regexp.Regexp
// Once for to prevent races
var filesChangedFullPatternOnce sync.Once
func getIssueFullPattern() *regexp.Regexp {
issueFullPatternOnce.Do(func() {
// example: https://domain/org/repo/pulls/27#hash
issueFullPattern = regexp.MustCompile(regexp.QuoteMeta(setting.AppURL) +
`[\w_.-]+/[\w_.-]+/(?:issues|pulls)/((?:\w{1,10}-)?[1-9][0-9]*)([\?|#](\S+)?)?\b`)
})
return issueFullPattern
}
func getFilesChangedFullPattern() *regexp.Regexp {
filesChangedFullPatternOnce.Do(func() {
// example: https://domain/org/repo/pulls/27/files#hash
filesChangedFullPattern = regexp.MustCompile(regexp.QuoteMeta(setting.AppURL) +
`[\w_.-]+/[\w_.-]+/pulls/((?:\w{1,10}-)?[1-9][0-9]*)/files([\?|#](\S+)?)?\b`)
})
return filesChangedFullPattern
}
// CustomLinkURLSchemes allows for additional schemes to be detected when parsing links within text // CustomLinkURLSchemes allows for additional schemes to be detected when parsing links within text
func CustomLinkURLSchemes(schemes []string) { func CustomLinkURLSchemes(schemes []string) {
schemes = append(schemes, "http", "https") schemes = append(schemes, "http", "https")
@ -132,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 {
@ -167,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)
} }
@ -192,18 +186,8 @@ 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
if ctx.DefaultLink != "" {
// we don't have to fear data races, because being
// commitMessageProcessors of fixed len and cap, every time we append
// something to it the slice is realloc+copied, so append always
// generates the slice ex-novo.
procs = append(procs, genDefaultLinkProcessor(ctx.DefaultLink))
}
return renderProcessString(ctx, procs, content) return renderProcessString(ctx, procs, content)
} }
@ -229,30 +213,23 @@ 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, procs := slices.Clone(commitMessageSubjectProcessors)
content string, procs = append(procs, func(ctx *RenderContext, node *html.Node) {
) (string, error) { ch := &html.Node{Parent: node, Type: html.TextNode, Data: node.Data}
procs := commitMessageSubjectProcessors node.Type = html.ElementNode
if ctx.DefaultLink != "" { node.Data = "a"
// we don't have to fear data races, because being node.DataAtom = atom.A
// commitMessageSubjectProcessors of fixed len and cap, every time we node.Attr = []html.Attribute{{Key: "href", Val: defaultLink}, {Key: "class", Val: "muted"}}
// append something to it the slice is realloc+copied, so append always node.FirstChild, node.LastChild = ch, ch
// generates the slice ex-novo. })
procs = append(procs, genDefaultLinkProcessor(ctx.DefaultLink))
}
return renderProcessString(ctx, procs, content) return renderProcessString(ctx, procs, content)
} }
// 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, // do not render other issue/commit links in an issue's title - which in most cases is already a link.
title string,
) (string, error) {
return renderProcessString(ctx, []processor{ return renderProcessString(ctx, []processor{
issueIndexPatternProcessor,
commitCrossReferencePatternProcessor,
hashCurrentPatternProcessor,
emojiShortCodeProcessor, emojiShortCodeProcessor,
emojiProcessor, emojiProcessor,
}, title) }, title)
@ -268,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,
@ -281,18 +255,10 @@ 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)
} }
var (
tagCleaner = regexp.MustCompile(`<((?:/?\w+/\w+)|(?:/[\w ]+/)|(/?[hH][tT][mM][lL]\b)|(/?[hH][eE][aA][dD]\b))`)
nulCleaner = strings.NewReplacer("\000", "")
)
func postProcess(ctx *RenderContext, procs []processor, input io.Reader, output io.Writer) error { func postProcess(ctx *RenderContext, procs []processor, input io.Reader, output io.Writer) error {
defer ctx.Cancel() defer ctx.Cancel()
// FIXME: don't read all content to memory // FIXME: don't read all content to memory
@ -306,7 +272,7 @@ func postProcess(ctx *RenderContext, procs []processor, input io.Reader, output
// prepend "<html><body>" // prepend "<html><body>"
strings.NewReader("<html><body>"), strings.NewReader("<html><body>"),
// Strip out nuls - they're always invalid // Strip out nuls - they're always invalid
bytes.NewReader(tagCleaner.ReplaceAll([]byte(nulCleaner.Replace(string(rawHTML))), []byte("&lt;$1"))), bytes.NewReader(globalVars().tagCleaner.ReplaceAll([]byte(globalVars().nulCleaner.Replace(string(rawHTML))), []byte("&lt;$1"))),
// close the tags // close the tags
strings.NewReader("</body></html>"), strings.NewReader("</body></html>"),
)) ))
@ -349,11 +315,22 @@ 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 {
val := strings.TrimPrefix(attr.Val, "#") val := strings.TrimPrefix(attr.Val, "#")
notHasPrefix := !(strings.HasPrefix(val, "user-content-") || blackfridayExtRegex.MatchString(val)) notHasPrefix := !(strings.HasPrefix(val, "user-content-") || globalVars().blackfridayExtRegex.MatchString(val))
if attr.Key == "id" && notHasPrefix { if attr.Key == "id" && notHasPrefix {
node.Attr[idx].Val = "user-content-" + attr.Val node.Attr[idx].Val = "user-content-" + attr.Val
@ -362,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)
@ -412,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,
@ -438,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(),
@ -448,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

@ -54,7 +54,7 @@ func createCodeLink(href, content, class string) *html.Node {
} }
func anyHashPatternExtract(s string) (ret anyHashPatternResult, ok bool) { func anyHashPatternExtract(s string) (ret anyHashPatternResult, ok bool) {
m := anyHashPattern.FindStringSubmatchIndex(s) m := globalVars().anyHashPattern.FindStringSubmatchIndex(s)
if m == nil { if m == nil {
return ret, false return ret, false
} }
@ -120,7 +120,7 @@ func comparePatternProcessor(ctx *RenderContext, node *html.Node) {
node = node.NextSibling node = node.NextSibling
continue continue
} }
m := comparePattern.FindStringSubmatchIndex(node.Data) m := globalVars().comparePattern.FindStringSubmatchIndex(node.Data)
if m == nil || slices.Contains(m[:8], -1) { // ensure that every group (m[0]...m[7]) has a match if m == nil || slices.Contains(m[:8], -1) { // ensure that every group (m[0]...m[7]) has a match
node = node.NextSibling node = node.NextSibling
continue continue
@ -173,7 +173,7 @@ func hashCurrentPatternProcessor(ctx *RenderContext, node *html.Node) {
ctx.ShaExistCache = make(map[string]bool) ctx.ShaExistCache = make(map[string]bool)
} }
for node != nil && node != next && start < len(node.Data) { for node != nil && node != next && start < len(node.Data) {
m := hashCurrentPattern.FindStringSubmatchIndex(node.Data[start:]) m := globalVars().hashCurrentPattern.FindStringSubmatchIndex(node.Data[start:])
if m == nil { if m == nil {
return return
} }

View File

@ -9,13 +9,13 @@ import "golang.org/x/net/html"
func emailAddressProcessor(ctx *RenderContext, node *html.Node) { func emailAddressProcessor(ctx *RenderContext, node *html.Node) {
next := node.NextSibling next := node.NextSibling
for node != nil && node != next { for node != nil && node != next {
m := emailRegex.FindStringSubmatchIndex(node.Data) m := globalVars().emailRegex.FindStringSubmatchIndex(node.Data)
if m == nil { if m == nil {
return return
} }
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{
@ -62,7 +60,7 @@ func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) {
start := 0 start := 0
next := node.NextSibling next := node.NextSibling
for node != nil && node != next && start < len(node.Data) { for node != nil && node != next && start < len(node.Data) {
m := emojiShortCodeRegex.FindStringSubmatchIndex(node.Data[start:]) m := globalVars().emojiShortCodeRegex.FindStringSubmatchIndex(node.Data[start:])
if m == nil { if m == nil {
return return
} }
@ -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

@ -40,17 +40,19 @@ func link(href, class, contents string) string {
} }
var numericMetas = map[string]string{ var numericMetas = map[string]string{
"format": "https://someurl.com/{user}/{repo}/{index}", "format": "https://someurl.com/{user}/{repo}/{index}",
"user": "someUser", "user": "someUser",
"repo": "someRepo", "repo": "someRepo",
"style": IssueNameStyleNumeric, "style": IssueNameStyleNumeric,
"markupAllowShortIssuePattern": "true",
} }
var alphanumericMetas = map[string]string{ var alphanumericMetas = map[string]string{
"format": "https://someurl.com/{user}/{repo}/{index}", "format": "https://someurl.com/{user}/{repo}/{index}",
"user": "someUser", "user": "someUser",
"repo": "someRepo", "repo": "someRepo",
"style": IssueNameStyleAlphanumeric, "style": IssueNameStyleAlphanumeric,
"markupAllowShortIssuePattern": "true",
} }
var regexpMetas = map[string]string{ var regexpMetas = map[string]string{
@ -62,8 +64,15 @@ var regexpMetas = map[string]string{
// these values should match the TestOrgRepo const above // these values should match the TestOrgRepo const above
var localMetas = map[string]string{ var localMetas = map[string]string{
"user": "test-owner", "user": "test-owner",
"repo": "test-repo", "repo": "test-repo",
"markupAllowShortIssuePattern": "true",
}
var localWikiMetas = map[string]string{
"user": "test-owner",
"repo": "test-repo",
"markupContentMode": "wiki",
} }
func TestRender_IssueIndexPattern(t *testing.T) { func TestRender_IssueIndexPattern(t *testing.T) {
@ -124,9 +133,8 @@ func TestRender_IssueIndexPattern2(t *testing.T) {
} }
expectedNil := fmt.Sprintf(expectedFmt, links...) expectedNil := fmt.Sprintf(expectedFmt, links...)
testRenderIssueIndexPattern(t, s, expectedNil, &RenderContext{ testRenderIssueIndexPattern(t, s, expectedNil, &RenderContext{
Ctx: git.DefaultContext, Ctx: git.DefaultContext,
Metas: localMetas, Metas: localMetas,
ContentMode: RenderContentAsComment,
}) })
class := "ref-issue" class := "ref-issue"
@ -139,9 +147,8 @@ func TestRender_IssueIndexPattern2(t *testing.T) {
} }
expectedNum := fmt.Sprintf(expectedFmt, links...) expectedNum := fmt.Sprintf(expectedFmt, links...)
testRenderIssueIndexPattern(t, s, expectedNum, &RenderContext{ testRenderIssueIndexPattern(t, s, expectedNum, &RenderContext{
Ctx: git.DefaultContext, Ctx: git.DefaultContext,
Metas: numericMetas, Metas: numericMetas,
ContentMode: RenderContentAsComment,
}) })
} }
@ -262,7 +269,7 @@ func TestRender_IssueIndexPattern5(t *testing.T) {
}) })
} }
func TestRender_IssueIndexPattern_Document(t *testing.T) { func TestRender_IssueIndexPattern_NoShortPattern(t *testing.T) {
setting.AppURL = TestAppURL setting.AppURL = TestAppURL
metas := map[string]string{ metas := map[string]string{
"format": "https://someurl.com/{user}/{repo}/{index}", "format": "https://someurl.com/{user}/{repo}/{index}",
@ -285,6 +292,22 @@ func TestRender_IssueIndexPattern_Document(t *testing.T) {
}) })
} }
func TestRender_RenderIssueTitle(t *testing.T) {
setting.AppURL = TestAppURL
metas := map[string]string{
"format": "https://someurl.com/{user}/{repo}/{index}",
"user": "someUser",
"repo": "someRepo",
"style": IssueNameStyleNumeric,
}
actual, err := RenderIssueTitle(&RenderContext{
Ctx: git.DefaultContext,
Metas: metas,
}, "#1")
assert.NoError(t, err)
assert.Equal(t, "#1", actual)
}
func testRenderIssueIndexPattern(t *testing.T, input, expected string, ctx *RenderContext) { func testRenderIssueIndexPattern(t *testing.T, input, expected string, ctx *RenderContext) {
ctx.Links.AbsolutePrefix = true ctx.Links.AbsolutePrefix = true
if ctx.Links.Base == "" { if ctx.Links.Base == "" {
@ -318,8 +341,7 @@ func TestRender_AutoLink(t *testing.T) {
Links: Links{ Links: Links{
Base: TestRepoURL, Base: TestRepoURL,
}, },
Metas: localMetas, Metas: localWikiMetas,
ContentMode: RenderContentAsWiki,
}, strings.NewReader(input), &buffer) }, strings.NewReader(input), &buffer)
assert.Equal(t, err, nil) assert.Equal(t, err, nil)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer.String())) assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer.String()))
@ -391,10 +413,10 @@ func TestRegExp_sha1CurrentPattern(t *testing.T) {
} }
for _, testCase := range trueTestCases { for _, testCase := range trueTestCases {
assert.True(t, hashCurrentPattern.MatchString(testCase)) assert.True(t, globalVars().hashCurrentPattern.MatchString(testCase))
} }
for _, testCase := range falseTestCases { for _, testCase := range falseTestCases {
assert.False(t, hashCurrentPattern.MatchString(testCase)) assert.False(t, globalVars().hashCurrentPattern.MatchString(testCase))
} }
} }
@ -474,9 +496,9 @@ func TestRegExp_shortLinkPattern(t *testing.T) {
} }
for _, testCase := range trueTestCases { for _, testCase := range trueTestCases {
assert.True(t, shortLinkPattern.MatchString(testCase)) assert.True(t, globalVars().shortLinkPattern.MatchString(testCase))
} }
for _, testCase := range falseTestCases { for _, testCase := range falseTestCases {
assert.False(t, shortLinkPattern.MatchString(testCase)) assert.False(t, globalVars().shortLinkPattern.MatchString(testCase))
} }
} }

View File

@ -7,6 +7,7 @@ import (
"strings" "strings"
"code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/references" "code.gitea.io/gitea/modules/references"
"code.gitea.io/gitea/modules/regexplru" "code.gitea.io/gitea/modules/regexplru"
@ -23,18 +24,21 @@ func fullIssuePatternProcessor(ctx *RenderContext, node *html.Node) {
} }
next := node.NextSibling next := node.NextSibling
for node != nil && node != next { for node != nil && node != next {
m := getIssueFullPattern().FindStringSubmatchIndex(node.Data) m := globalVars().issueFullPattern.FindStringSubmatchIndex(node.Data)
if m == nil { if m == nil {
return return
} }
mDiffView := getFilesChangedFullPattern().FindStringSubmatchIndex(node.Data) mDiffView := globalVars().filesChangedFullPattern.FindStringSubmatchIndex(node.Data)
// leave it as it is if the link is from "Files Changed" tab in PR Diff View https://domain/org/repo/pulls/27/files // leave it as it is if the link is from "Files Changed" tab in PR Diff View https://domain/org/repo/pulls/27/files
if mDiffView != nil { if mDiffView != nil {
return return
} }
link := node.Data[m[0]:m[1]] link := node.Data[m[0]:m[1]]
if !httplib.IsCurrentGiteaSiteURL(ctx.Ctx, link) {
return
}
text := "#" + node.Data[m[2]:m[3]] text := "#" + node.Data[m[2]:m[3]]
// if m[4] and m[5] is not -1, then link is to a comment // if m[4] and m[5] is not -1, then link is to a comment
// indicate that in the text by appending (comment) // indicate that in the text by appending (comment)
@ -53,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
} }
@ -67,8 +71,10 @@ func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {
return return
} }
// crossLinkOnly if not comment and not wiki // crossLinkOnly: do not parse "#123", only parse "owner/repo#123"
crossLinkOnly := ctx.ContentMode != RenderContentAsTitle && ctx.ContentMode != RenderContentAsComment && ctx.ContentMode != RenderContentAsWiki // if there is no repo in the context, then the "#123" format can't be parsed
// old logic: crossLinkOnly := ctx.Metas["mode"] == "document" && !ctx.IsWiki
crossLinkOnly := ctx.Metas["markupAllowShortIssuePattern"] != "true"
var ( var (
found bool found bool
@ -123,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")
} }
} }
@ -145,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,
@ -171,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

@ -20,9 +20,9 @@ func ResolveLink(ctx *RenderContext, link, userContentAnchorPrefix string) (resu
isAnchorFragment := link != "" && link[0] == '#' isAnchorFragment := link != "" && link[0] == '#'
if !isAnchorFragment && !IsFullURLString(link) { if !isAnchorFragment && !IsFullURLString(link) {
linkBase := ctx.Links.Base linkBase := ctx.Links.Base
if ctx.ContentMode == RenderContentAsWiki { if ctx.IsMarkupContentWiki() {
// no need to check if the link should be resolved as a wiki link or a wiki raw link // no need to check if the link should be resolved as a wiki link or a wiki raw link
// just use wiki link here and it will be redirected to a wiki raw link if necessary // just use wiki link here, and it will be redirected to a wiki raw link if necessary
linkBase = ctx.Links.WikiLink() linkBase = ctx.Links.WikiLink()
} else if ctx.Links.BranchPath != "" || ctx.Links.TreePath != "" { } else if ctx.Links.BranchPath != "" || ctx.Links.TreePath != "" {
// if there is no BranchPath, then the link will be something like "/owner/repo/src/{the-file-path}" // if there is no BranchPath, then the link will be something like "/owner/repo/src/{the-file-path}"
@ -40,7 +40,7 @@ func ResolveLink(ctx *RenderContext, link, userContentAnchorPrefix string) (resu
func shortLinkProcessor(ctx *RenderContext, node *html.Node) { func shortLinkProcessor(ctx *RenderContext, node *html.Node) {
next := node.NextSibling next := node.NextSibling
for node != nil && node != next { for node != nil && node != next {
m := shortLinkPattern.FindStringSubmatchIndex(node.Data) m := globalVars().shortLinkPattern.FindStringSubmatchIndex(node.Data)
if m == nil { if m == nil {
return return
} }
@ -147,7 +147,7 @@ func shortLinkProcessor(ctx *RenderContext, node *html.Node) {
} }
if image { if image {
if !absoluteLink { if !absoluteLink {
link = util.URLJoin(ctx.Links.ResolveMediaLink(ctx.ContentMode == RenderContentAsWiki), link) link = util.URLJoin(ctx.Links.ResolveMediaLink(ctx.IsMarkupContentWiki()), link)
} }
title := props["title"] title := props["title"]
if title == "" { if title == "" {
@ -189,41 +189,22 @@ 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
} }
} }
func genDefaultLinkProcessor(defaultLink string) processor {
return func(ctx *RenderContext, node *html.Node) {
ch := &html.Node{
Parent: node,
Type: html.TextNode,
Data: node.Data,
}
node.Type = html.ElementNode
node.Data = "a"
node.DataAtom = atom.A
node.Attr = []html.Attribute{
{Key: "href", Val: defaultLink},
{Key: "class", Val: "default-link muted"},
}
node.FirstChild, node.LastChild = ch, ch
}
}
// descriptionLinkProcessor creates links for DescriptionHTML // descriptionLinkProcessor creates links for DescriptionHTML
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

@ -17,7 +17,7 @@ func visitNodeImg(ctx *RenderContext, img *html.Node) (next *html.Node) {
} }
if IsNonEmptyRelativePath(attr.Val) { if IsNonEmptyRelativePath(attr.Val) {
attr.Val = util.URLJoin(ctx.Links.ResolveMediaLink(ctx.ContentMode == RenderContentAsWiki), attr.Val) attr.Val = util.URLJoin(ctx.Links.ResolveMediaLink(ctx.IsMarkupContentWiki()), attr.Val)
// By default, the "<img>" tag should also be clickable, // By default, the "<img>" tag should also be clickable,
// because frontend use `<img>` to paste the re-scaled image into the markdown, // because frontend use `<img>` to paste the re-scaled image into the markdown,
@ -53,7 +53,7 @@ func visitNodeVideo(ctx *RenderContext, node *html.Node) (next *html.Node) {
continue continue
} }
if IsNonEmptyRelativePath(attr.Val) { if IsNonEmptyRelativePath(attr.Val) {
attr.Val = util.URLJoin(ctx.Links.ResolveMediaLink(ctx.ContentMode == RenderContentAsWiki), attr.Val) attr.Val = util.URLJoin(ctx.Links.ResolveMediaLink(ctx.IsMarkupContentWiki()), attr.Val)
} }
attr.Val = camoHandleLink(attr.Val) attr.Val = camoHandleLink(attr.Val)
node.Attr[i] = attr node.Attr[i] = attr

View File

@ -27,6 +27,11 @@ var (
"user": testRepoOwnerName, "user": testRepoOwnerName,
"repo": testRepoName, "repo": testRepoName,
} }
localWikiMetas = map[string]string{
"user": testRepoOwnerName,
"repo": testRepoName,
"markupContentMode": "wiki",
}
) )
type mockRepo struct { type mockRepo struct {
@ -413,8 +418,7 @@ func TestRender_ShortLinks(t *testing.T) {
Links: markup.Links{ Links: markup.Links{
Base: markup.TestRepoURL, Base: markup.TestRepoURL,
}, },
Metas: localMetas, Metas: localWikiMetas,
ContentMode: markup.RenderContentAsWiki,
}, input) }, input)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expectedWiki), strings.TrimSpace(string(buffer))) assert.Equal(t, strings.TrimSpace(expectedWiki), strings.TrimSpace(string(buffer)))
@ -526,10 +530,9 @@ func TestRender_ShortLinks(t *testing.T) {
func TestRender_RelativeMedias(t *testing.T) { func TestRender_RelativeMedias(t *testing.T) {
render := func(input string, isWiki bool, links markup.Links) string { render := func(input string, isWiki bool, links markup.Links) string {
buffer, err := markdown.RenderString(&markup.RenderContext{ buffer, err := markdown.RenderString(&markup.RenderContext{
Ctx: git.DefaultContext, Ctx: git.DefaultContext,
Links: links, Links: links,
Metas: localMetas, Metas: util.Iif(isWiki, localWikiMetas, localMetas),
ContentMode: util.Iif(isWiki, markup.RenderContentAsWiki, markup.RenderContentAsComment),
}, input) }, input)
assert.NoError(t, err) assert.NoError(t, err)
return strings.TrimSpace(string(buffer)) return strings.TrimSpace(string(buffer))

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"),
} }
} }
@ -75,11 +79,12 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
// TODO: this was a quite unclear part, old code: `if metas["mode"] != "document" { use comment link break setting }` // TODO: this was a quite unclear part, old code: `if metas["mode"] != "document" { use comment link break setting }`
// many places render non-comment contents with no mode=document, then these contents also use comment's hard line break setting // many places render non-comment contents with no mode=document, then these contents also use comment's hard line break setting
// especially in many tests. // especially in many tests.
markdownLineBreakStyle := ctx.Metas["markdownLineBreakStyle"]
if markup.RenderBehaviorForTesting.ForceHardLineBreak { if markup.RenderBehaviorForTesting.ForceHardLineBreak {
v.SetHardLineBreak(true) v.SetHardLineBreak(true)
} else if ctx.ContentMode == markup.RenderContentAsComment { } else if markdownLineBreakStyle == "comment" {
v.SetHardLineBreak(setting.Markdown.EnableHardLineBreakInComments) v.SetHardLineBreak(setting.Markdown.EnableHardLineBreakInComments)
} else { } else if markdownLineBreakStyle == "document" {
v.SetHardLineBreak(setting.Markdown.EnableHardLineBreakInDocuments) v.SetHardLineBreak(setting.Markdown.EnableHardLineBreakInDocuments)
} }
} }
@ -108,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)
@ -125,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.
@ -213,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

@ -37,6 +37,12 @@ var localMetas = map[string]string{
"repo": testRepoName, "repo": testRepoName,
} }
var localWikiMetas = map[string]string{
"user": testRepoOwnerName,
"repo": testRepoName,
"markupContentMode": "wiki",
}
type mockRepo struct { type mockRepo struct {
OwnerName string OwnerName string
RepoName string RepoName string
@ -75,7 +81,7 @@ func TestRender_StandardLinks(t *testing.T) {
Links: markup.Links{ Links: markup.Links{
Base: FullURL, Base: FullURL,
}, },
ContentMode: markup.RenderContentAsWiki, Metas: localWikiMetas,
}, input) }, input)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expectedWiki), strings.TrimSpace(string(buffer))) assert.Equal(t, strings.TrimSpace(expectedWiki), strings.TrimSpace(string(buffer)))
@ -307,9 +313,8 @@ func TestTotal_RenderWiki(t *testing.T) {
Links: markup.Links{ Links: markup.Links{
Base: FullURL, Base: FullURL,
}, },
Repo: newMockRepo(testRepoOwnerName, testRepoName), Repo: newMockRepo(testRepoOwnerName, testRepoName),
Metas: localMetas, Metas: localWikiMetas,
ContentMode: markup.RenderContentAsWiki,
}, sameCases[i]) }, sameCases[i])
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, answers[i], string(line)) assert.Equal(t, answers[i], string(line))
@ -334,7 +339,7 @@ func TestTotal_RenderWiki(t *testing.T) {
Links: markup.Links{ Links: markup.Links{
Base: FullURL, Base: FullURL,
}, },
ContentMode: markup.RenderContentAsWiki, Metas: localWikiMetas,
}, testCases[i]) }, testCases[i])
assert.NoError(t, err) assert.NoError(t, err)
assert.EqualValues(t, testCases[i+1], string(line)) assert.EqualValues(t, testCases[i+1], string(line))
@ -657,9 +662,9 @@ mail@domain.com
<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/> <a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/>
<a href="/image.jpg" rel="nofollow"><img src="/image.jpg" title="local image" alt="local image"/></a><br/> <a href="/image.jpg" rel="nofollow"><img src="/image.jpg" title="local image" alt="local image"/></a><br/>
<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/> <a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/>
<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/> <a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow"><code>88fc37a3c0...12fc37a3c0 (hash)</code></a><br/>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/> com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/>
<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/> <a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow"><code>88fc37a3c0</code></a><br/>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/> com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/>
<span class="emoji" aria-label="thumbs up">👍</span><br/> <span class="emoji" aria-label="thumbs up">👍</span><br/>
<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/> <a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/>
@ -684,9 +689,9 @@ space</p>
<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/> <a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/>
<a href="/wiki/raw/image.jpg" rel="nofollow"><img src="/wiki/raw/image.jpg" title="local image" alt="local image"/></a><br/> <a href="/wiki/raw/image.jpg" rel="nofollow"><img src="/wiki/raw/image.jpg" title="local image" alt="local image"/></a><br/>
<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/> <a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/>
<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/> <a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow"><code>88fc37a3c0...12fc37a3c0 (hash)</code></a><br/>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/> com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/>
<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/> <a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow"><code>88fc37a3c0</code></a><br/>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/> com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/>
<span class="emoji" aria-label="thumbs up">👍</span><br/> <span class="emoji" aria-label="thumbs up">👍</span><br/>
<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/> <a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/>
@ -713,9 +718,9 @@ space</p>
<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/> <a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/>
<a href="https://gitea.io/image.jpg" rel="nofollow"><img src="https://gitea.io/image.jpg" title="local image" alt="local image"/></a><br/> <a href="https://gitea.io/image.jpg" rel="nofollow"><img src="https://gitea.io/image.jpg" title="local image" alt="local image"/></a><br/>
<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/> <a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/>
<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/> <a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow"><code>88fc37a3c0...12fc37a3c0 (hash)</code></a><br/>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/> com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/>
<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/> <a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow"><code>88fc37a3c0</code></a><br/>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/> com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/>
<span class="emoji" aria-label="thumbs up">👍</span><br/> <span class="emoji" aria-label="thumbs up">👍</span><br/>
<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/> <a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/>
@ -742,9 +747,9 @@ space</p>
<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/> <a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/>
<a href="https://gitea.io/wiki/raw/image.jpg" rel="nofollow"><img src="https://gitea.io/wiki/raw/image.jpg" title="local image" alt="local image"/></a><br/> <a href="https://gitea.io/wiki/raw/image.jpg" rel="nofollow"><img src="https://gitea.io/wiki/raw/image.jpg" title="local image" alt="local image"/></a><br/>
<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/> <a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/>
<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/> <a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow"><code>88fc37a3c0...12fc37a3c0 (hash)</code></a><br/>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/> com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/>
<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/> <a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow"><code>88fc37a3c0</code></a><br/>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/> com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/>
<span class="emoji" aria-label="thumbs up">👍</span><br/> <span class="emoji" aria-label="thumbs up">👍</span><br/>
<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/> <a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/>
@ -771,9 +776,9 @@ space</p>
<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/> <a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/>
<a href="/relative/path/image.jpg" rel="nofollow"><img src="/relative/path/image.jpg" title="local image" alt="local image"/></a><br/> <a href="/relative/path/image.jpg" rel="nofollow"><img src="/relative/path/image.jpg" title="local image" alt="local image"/></a><br/>
<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/> <a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/>
<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/> <a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow"><code>88fc37a3c0...12fc37a3c0 (hash)</code></a><br/>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/> com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/>
<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/> <a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow"><code>88fc37a3c0</code></a><br/>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/> com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/>
<span class="emoji" aria-label="thumbs up">👍</span><br/> <span class="emoji" aria-label="thumbs up">👍</span><br/>
<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/> <a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/>
@ -800,9 +805,9 @@ space</p>
<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/> <a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/>
<a href="/relative/path/wiki/raw/image.jpg" rel="nofollow"><img src="/relative/path/wiki/raw/image.jpg" title="local image" alt="local image"/></a><br/> <a href="/relative/path/wiki/raw/image.jpg" rel="nofollow"><img src="/relative/path/wiki/raw/image.jpg" title="local image" alt="local image"/></a><br/>
<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/> <a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/>
<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/> <a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow"><code>88fc37a3c0...12fc37a3c0 (hash)</code></a><br/>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/> com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/>
<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/> <a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow"><code>88fc37a3c0</code></a><br/>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/> com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/>
<span class="emoji" aria-label="thumbs up">👍</span><br/> <span class="emoji" aria-label="thumbs up">👍</span><br/>
<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/> <a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/>
@ -830,9 +835,9 @@ space</p>
<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/> <a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/>
<a href="/user/repo/media/branch/main/image.jpg" rel="nofollow"><img src="/user/repo/media/branch/main/image.jpg" title="local image" alt="local image"/></a><br/> <a href="/user/repo/media/branch/main/image.jpg" rel="nofollow"><img src="/user/repo/media/branch/main/image.jpg" title="local image" alt="local image"/></a><br/>
<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/> <a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/>
<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/> <a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow"><code>88fc37a3c0...12fc37a3c0 (hash)</code></a><br/>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/> com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/>
<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/> <a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow"><code>88fc37a3c0</code></a><br/>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/> com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/>
<span class="emoji" aria-label="thumbs up">👍</span><br/> <span class="emoji" aria-label="thumbs up">👍</span><br/>
<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/> <a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/>
@ -860,9 +865,9 @@ space</p>
<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/> <a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/>
<a href="/relative/path/wiki/raw/image.jpg" rel="nofollow"><img src="/relative/path/wiki/raw/image.jpg" title="local image" alt="local image"/></a><br/> <a href="/relative/path/wiki/raw/image.jpg" rel="nofollow"><img src="/relative/path/wiki/raw/image.jpg" title="local image" alt="local image"/></a><br/>
<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/> <a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/>
<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/> <a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow"><code>88fc37a3c0...12fc37a3c0 (hash)</code></a><br/>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/> com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/>
<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/> <a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow"><code>88fc37a3c0</code></a><br/>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/> com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/>
<span class="emoji" aria-label="thumbs up">👍</span><br/> <span class="emoji" aria-label="thumbs up">👍</span><br/>
<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/> <a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/>
@ -890,9 +895,9 @@ space</p>
<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/> <a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/>
<a href="/user/repo/image.jpg" rel="nofollow"><img src="/user/repo/image.jpg" title="local image" alt="local image"/></a><br/> <a href="/user/repo/image.jpg" rel="nofollow"><img src="/user/repo/image.jpg" title="local image" alt="local image"/></a><br/>
<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/> <a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/>
<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/> <a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow"><code>88fc37a3c0...12fc37a3c0 (hash)</code></a><br/>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/> com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/>
<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/> <a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow"><code>88fc37a3c0</code></a><br/>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/> com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/>
<span class="emoji" aria-label="thumbs up">👍</span><br/> <span class="emoji" aria-label="thumbs up">👍</span><br/>
<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/> <a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/>
@ -920,9 +925,9 @@ space</p>
<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/> <a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/>
<a href="/relative/path/wiki/raw/image.jpg" rel="nofollow"><img src="/relative/path/wiki/raw/image.jpg" title="local image" alt="local image"/></a><br/> <a href="/relative/path/wiki/raw/image.jpg" rel="nofollow"><img src="/relative/path/wiki/raw/image.jpg" title="local image" alt="local image"/></a><br/>
<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/> <a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/>
<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/> <a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow"><code>88fc37a3c0...12fc37a3c0 (hash)</code></a><br/>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/> com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/>
<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/> <a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow"><code>88fc37a3c0</code></a><br/>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/> com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/>
<span class="emoji" aria-label="thumbs up">👍</span><br/> <span class="emoji" aria-label="thumbs up">👍</span><br/>
<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/> <a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/>
@ -951,9 +956,9 @@ space</p>
<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/> <a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/>
<a href="/user/repo/media/branch/main/sub/folder/image.jpg" rel="nofollow"><img src="/user/repo/media/branch/main/sub/folder/image.jpg" title="local image" alt="local image"/></a><br/> <a href="/user/repo/media/branch/main/sub/folder/image.jpg" rel="nofollow"><img src="/user/repo/media/branch/main/sub/folder/image.jpg" title="local image" alt="local image"/></a><br/>
<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/> <a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/>
<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/> <a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow"><code>88fc37a3c0...12fc37a3c0 (hash)</code></a><br/>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/> com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/>
<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/> <a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow"><code>88fc37a3c0</code></a><br/>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/> com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/>
<span class="emoji" aria-label="thumbs up">👍</span><br/> <span class="emoji" aria-label="thumbs up">👍</span><br/>
<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/> <a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/>
@ -982,9 +987,9 @@ space</p>
<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/> <a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/>
<a href="/relative/path/wiki/raw/image.jpg" rel="nofollow"><img src="/relative/path/wiki/raw/image.jpg" title="local image" alt="local image"/></a><br/> <a href="/relative/path/wiki/raw/image.jpg" rel="nofollow"><img src="/relative/path/wiki/raw/image.jpg" title="local image" alt="local image"/></a><br/>
<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/> <a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/>
<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/> <a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow"><code>88fc37a3c0...12fc37a3c0 (hash)</code></a><br/>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/> com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/>
<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/> <a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow"><code>88fc37a3c0</code></a><br/>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/> com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/>
<span class="emoji" aria-label="thumbs up">👍</span><br/> <span class="emoji" aria-label="thumbs up">👍</span><br/>
<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/> <a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/>
@ -999,9 +1004,9 @@ space</p>
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableInternalAttributes, true)() defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableInternalAttributes, true)()
for i, c := range cases { for i, c := range cases {
result, err := markdown.RenderString(&markup.RenderContext{ result, err := markdown.RenderString(&markup.RenderContext{
Ctx: context.Background(), Ctx: context.Background(),
Links: c.Links, Links: c.Links,
ContentMode: util.Iif(c.IsWiki, markup.RenderContentAsWiki, markup.RenderContentAsDefault), Metas: util.Iif(c.IsWiki, map[string]string{"markupContentMode": "wiki"}, map[string]string{}),
}, input) }, input)
assert.NoError(t, err, "Unexpected error in testcase: %v", i) assert.NoError(t, err, "Unexpected error in testcase: %v", i)
assert.Equal(t, c.Expected, string(result), "Unexpected result in testcase %v", i) assert.Equal(t, c.Expected, string(result), "Unexpected result in testcase %v", i)
@ -1046,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

@ -21,7 +21,7 @@ func (g *ASTTransformer) transformImage(ctx *markup.RenderContext, v *ast.Image)
// Check if the destination is a real link // Check if the destination is a real link
if len(v.Destination) > 0 && !markup.IsFullURLBytes(v.Destination) { if len(v.Destination) > 0 && !markup.IsFullURLBytes(v.Destination) {
v.Destination = []byte(giteautil.URLJoin( v.Destination = []byte(giteautil.URLJoin(
ctx.Links.ResolveMediaLink(ctx.ContentMode == markup.RenderContentAsWiki), ctx.Links.ResolveMediaLink(ctx.IsMarkupContentWiki()),
strings.TrimLeft(string(v.Destination), "/"), strings.TrimLeft(string(v.Destination), "/"),
)) ))
} }

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

@ -144,15 +144,14 @@ func (r *Writer) resolveLink(kind, link string) string {
} }
base := r.Ctx.Links.Base base := r.Ctx.Links.Base
isWiki := r.Ctx.ContentMode == markup.RenderContentAsWiki if r.Ctx.IsMarkupContentWiki() {
if isWiki {
base = r.Ctx.Links.WikiLink() base = r.Ctx.Links.WikiLink()
} else if r.Ctx.Links.HasBranchInfo() { } else if r.Ctx.Links.HasBranchInfo() {
base = r.Ctx.Links.SrcLink() base = r.Ctx.Links.SrcLink()
} }
if kind == "image" || kind == "video" { if kind == "image" || kind == "video" {
base = r.Ctx.Links.ResolveMediaLink(isWiki) base = r.Ctx.Links.ResolveMediaLink(r.Ctx.IsMarkupContentWiki())
} }
link = util.URLJoin(base, link) link = util.URLJoin(base, link)

View File

@ -27,7 +27,7 @@ func TestRender_StandardLinks(t *testing.T) {
Base: "/relative-path", Base: "/relative-path",
BranchPath: "branch/main", BranchPath: "branch/main",
}, },
ContentMode: util.Iif(isWiki, markup.RenderContentAsWiki, markup.RenderContentAsDefault), Metas: map[string]string{"markupContentMode": util.Iif(isWiki, "wiki", "")},
}, input) }, input)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer)) assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))

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
@ -27,15 +28,6 @@ const (
RenderMetaAsTable RenderMetaMode = "table" RenderMetaAsTable RenderMetaMode = "table"
) )
type RenderContentMode string
const (
RenderContentAsDefault RenderContentMode = "" // empty means "default", no special handling, maybe just a simple "document"
RenderContentAsComment RenderContentMode = "comment"
RenderContentAsTitle RenderContentMode = "title"
RenderContentAsWiki RenderContentMode = "wiki"
)
var RenderBehaviorForTesting struct { var RenderBehaviorForTesting struct {
// Markdown line break rendering has 2 default behaviors: // Markdown line break rendering has 2 default behaviors:
// * Use hard: replace "\n" with "<br>" for comments, setting.Markdown.EnableHardLineBreakInComments=true // * Use hard: replace "\n" with "<br>" for comments, setting.Markdown.EnableHardLineBreakInComments=true
@ -59,12 +51,14 @@ type RenderContext struct {
// for file mode, it could be left as empty, and will be detected by file extension in RelativePath // for file mode, it could be left as empty, and will be detected by file extension in RelativePath
MarkupType string MarkupType string
// what the content will be used for: eg: for comment or for wiki? or just render a file? Links Links // special link references for rendering, especially when there is a branch/tree path
ContentMode RenderContentMode
// user&repo, format&style&regexp (for external issue pattern), teams&org (for mention)
// BranchNameSubURL (for iframe&asciicast)
// markupAllowShortIssuePattern, markupContentMode (wiki)
// markdownLineBreakStyle (comment, document)
Metas map[string]string
Links Links // special link references for rendering, especially when there is a branch/tree path
Metas map[string]string // user&repo, format&style&regexp (for external issue pattern), teams&org (for mention), BranchNameSubURL(for iframe&asciicast)
DefaultLink string // TODO: need to figure out
GitRepo *git.Repository GitRepo *git.Repository
Repo gitrepo.Repository Repo gitrepo.Repository
ShaExistCache map[string]bool ShaExistCache map[string]bool
@ -72,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
@ -102,6 +98,10 @@ func (ctx *RenderContext) AddCancel(fn func()) {
} }
} }
func (ctx *RenderContext) IsMarkupContentWiki() bool {
return ctx.Metas != nil && ctx.Metas["markupContentMode"] == "wiki"
}
// Render renders markup file to HTML with all specific handling stuff. // Render renders markup file to HTML with all specific handling stuff.
func Render(ctx *RenderContext, input io.Reader, output io.Writer) error { func Render(ctx *RenderContext, input io.Reader, output io.Writer) error {
if ctx.MarkupType == "" && ctx.RelativePath != "" { if ctx.MarkupType == "" && ctx.RelativePath != "" {
@ -159,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
@ -232,3 +226,7 @@ func Init(ph *ProcessorHelper) {
} }
} }
} }
func ComposeSimpleDocumentMetas() map[string]string {
return map[string]string{"markdownLineBreakStyle": "document"}
}

View File

@ -10,7 +10,7 @@ import (
type Links struct { type Links struct {
AbsolutePrefix bool // add absolute URL prefix to auto-resolved links like "#issue", but not for pre-provided links and medias AbsolutePrefix bool // add absolute URL prefix to auto-resolved links like "#issue", but not for pre-provided links and medias
Base string // base prefix for pre-provided links and medias (images, videos) Base string // base prefix for pre-provided links and medias (images, videos), usually it is the path to the repo
BranchPath string // actually it is the ref path, eg: "branch/features/feat-12", "tag/v1.0" BranchPath string // actually it is the ref path, eg: "branch/features/feat-12", "tag/v1.0"
TreePath string // the dir of the file, eg: "doc" if the file "doc/CHANGE.md" is being rendered TreePath string // the dir of the file, eg: "doc" if the file "doc/CHANGE.md" is being rendered
} }

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

@ -5,6 +5,7 @@ package queue
import ( import (
"context" "context"
"errors"
"sync" "sync"
"time" "time"
@ -32,6 +33,7 @@ type ManagedWorkerPoolQueue interface {
// FlushWithContext tries to make the handler process all items in the queue synchronously. // FlushWithContext tries to make the handler process all items in the queue synchronously.
// It is for testing purpose only. It's not designed to be used in a cluster. // It is for testing purpose only. It's not designed to be used in a cluster.
// Negative timeout means discarding all items in the queue.
FlushWithContext(ctx context.Context, timeout time.Duration) error FlushWithContext(ctx context.Context, timeout time.Duration) error
// RemoveAllItems removes all items in the base queue (on-the-fly items are not affected) // RemoveAllItems removes all items in the base queue (on-the-fly items are not affected)
@ -76,15 +78,16 @@ func (m *Manager) ManagedQueues() map[int64]ManagedWorkerPoolQueue {
// FlushAll tries to make all managed queues process all items synchronously, until timeout or the queue is empty. // FlushAll tries to make all managed queues process all items synchronously, until timeout or the queue is empty.
// It is for testing purpose only. It's not designed to be used in a cluster. // It is for testing purpose only. It's not designed to be used in a cluster.
// Negative timeout means discarding all items in the queue.
func (m *Manager) FlushAll(ctx context.Context, timeout time.Duration) error { func (m *Manager) FlushAll(ctx context.Context, timeout time.Duration) error {
var finalErr error var finalErrors []error
qs := m.ManagedQueues() qs := m.ManagedQueues()
for _, q := range qs { for _, q := range qs {
if err := q.FlushWithContext(ctx, timeout); err != nil { if err := q.FlushWithContext(ctx, timeout); err != nil {
finalErr = err // TODO: in Go 1.20: errors.Join finalErrors = append(finalErrors, err)
} }
} }
return finalErr return errors.Join(finalErrors...)
} }
// CreateSimpleQueue creates a simple queue from global setting config provider by name // CreateSimpleQueue creates a simple queue from global setting config provider by name

View File

@ -23,7 +23,7 @@ var (
) )
func init() { func init() {
unhandledItemRequeueDuration.Store(int64(5 * time.Second)) unhandledItemRequeueDuration.Store(int64(time.Second))
} }
// workerGroup is a group of workers to work with a WorkerPoolQueue // workerGroup is a group of workers to work with a WorkerPoolQueue
@ -104,7 +104,12 @@ func (q *WorkerPoolQueue[T]) doWorkerHandle(batch []T) {
// if none of the items were handled, it should back-off for a few seconds // if none of the items were handled, it should back-off for a few seconds
// in this case the handler (eg: document indexer) may have encountered some errors/failures // in this case the handler (eg: document indexer) may have encountered some errors/failures
if len(unhandled) == len(batch) && unhandledItemRequeueDuration.Load() != 0 { if len(unhandled) == len(batch) && unhandledItemRequeueDuration.Load() != 0 {
if q.isFlushing.Load() {
return // do not requeue items when flushing, since all items failed, requeue them will continue failing.
}
log.Error("Queue %q failed to handle batch of %d items, backoff for a few seconds", q.GetName(), len(batch)) log.Error("Queue %q failed to handle batch of %d items, backoff for a few seconds", q.GetName(), len(batch))
// TODO: ideally it shouldn't "sleep" here (blocks the worker, then blocks flush).
// It could debounce the requeue operation, and try to requeue the items in the future.
select { select {
case <-q.ctxRun.Done(): case <-q.ctxRun.Done():
case <-time.After(time.Duration(unhandledItemRequeueDuration.Load())): case <-time.After(time.Duration(unhandledItemRequeueDuration.Load())):
@ -193,19 +198,37 @@ func (q *WorkerPoolQueue[T]) doStartNewWorker(wp *workerGroup[T]) {
// doFlush flushes the queue: it tries to read all items from the queue and handles them. // doFlush flushes the queue: it tries to read all items from the queue and handles them.
// It is for testing purpose only. It's not designed to work for a cluster. // It is for testing purpose only. It's not designed to work for a cluster.
func (q *WorkerPoolQueue[T]) doFlush(wg *workerGroup[T], flush flushType) { func (q *WorkerPoolQueue[T]) doFlush(wg *workerGroup[T], flush flushType) {
q.isFlushing.Store(true)
defer q.isFlushing.Store(false)
log.Debug("Queue %q starts flushing", q.GetName()) log.Debug("Queue %q starts flushing", q.GetName())
defer log.Debug("Queue %q finishes flushing", q.GetName()) defer log.Debug("Queue %q finishes flushing", q.GetName())
// stop all workers, and prepare a new worker context to start new workers // stop all workers, and prepare a new worker context to start new workers
wg.ctxWorkerCancel() wg.ctxWorkerCancel()
wg.wg.Wait() wg.wg.Wait()
defer func() { defer func() {
close(flush) close(flush.c)
wg.doPrepareWorkerContext() wg.doPrepareWorkerContext()
}() }()
if flush.timeout < 0 {
// discard everything
wg.batchBuffer = nil
for {
select {
case <-wg.popItemChan:
case <-wg.popItemErr:
case <-q.batchChan:
case <-q.ctxRun.Done():
return
default:
return
}
}
}
// drain the batch channel first // drain the batch channel first
loop: loop:
for { for {
@ -221,6 +244,9 @@ loop:
emptyCounter := 0 emptyCounter := 0
for { for {
select { select {
case <-q.ctxRun.Done():
log.Debug("Queue %q is shutting down", q.GetName())
return
case data, dataOk := <-wg.popItemChan: case data, dataOk := <-wg.popItemChan:
if !dataOk { if !dataOk {
return return
@ -236,9 +262,6 @@ loop:
log.Error("Failed to pop item from queue %q (doFlush): %v", q.GetName(), err) log.Error("Failed to pop item from queue %q (doFlush): %v", q.GetName(), err)
} }
return return
case <-q.ctxRun.Done():
log.Debug("Queue %q is shutting down", q.GetName())
return
case <-time.After(20 * time.Millisecond): case <-time.After(20 * time.Millisecond):
// There is no reliable way to make sure all queue items are consumed by the Flush, there always might be some items stored in some buffers/temp variables. // There is no reliable way to make sure all queue items are consumed by the Flush, there always might be some items stored in some buffers/temp variables.
// If we run Gitea in a cluster, we can even not guarantee all items are consumed in a deterministic instance. // If we run Gitea in a cluster, we can even not guarantee all items are consumed in a deterministic instance.
@ -316,6 +339,15 @@ func (q *WorkerPoolQueue[T]) doRun() {
var batchDispatchC <-chan time.Time = infiniteTimerC var batchDispatchC <-chan time.Time = infiniteTimerC
for { for {
select { select {
case flush := <-q.flushChan:
// before flushing, it needs to try to dispatch the batch to worker first, in case there is no worker running
// after the flushing, there is at least one worker running, so "doFlush" could wait for workers to finish
// since we are already in a "flush" operation, so the dispatching function shouldn't read the flush chan.
q.doDispatchBatchToWorker(wg, skipFlushChan)
q.doFlush(wg, flush)
case <-q.ctxRun.Done():
log.Debug("Queue %q is shutting down", q.GetName())
return
case data, dataOk := <-wg.popItemChan: case data, dataOk := <-wg.popItemChan:
if !dataOk { if !dataOk {
return return
@ -334,20 +366,11 @@ func (q *WorkerPoolQueue[T]) doRun() {
case <-batchDispatchC: case <-batchDispatchC:
batchDispatchC = infiniteTimerC batchDispatchC = infiniteTimerC
q.doDispatchBatchToWorker(wg, q.flushChan) q.doDispatchBatchToWorker(wg, q.flushChan)
case flush := <-q.flushChan:
// before flushing, it needs to try to dispatch the batch to worker first, in case there is no worker running
// after the flushing, there is at least one worker running, so "doFlush" could wait for workers to finish
// since we are already in a "flush" operation, so the dispatching function shouldn't read the flush chan.
q.doDispatchBatchToWorker(wg, skipFlushChan)
q.doFlush(wg, flush)
case err := <-wg.popItemErr: case err := <-wg.popItemErr:
if !q.isCtxRunCanceled() { if !q.isCtxRunCanceled() {
log.Error("Failed to pop item from queue %q (doRun): %v", q.GetName(), err) log.Error("Failed to pop item from queue %q (doRun): %v", q.GetName(), err)
} }
return return
case <-q.ctxRun.Done():
log.Debug("Queue %q is shutting down", q.GetName())
return
} }
} }
} }

View File

@ -32,8 +32,9 @@ type WorkerPoolQueue[T any] struct {
baseConfig *BaseConfig baseConfig *BaseConfig
baseQueue baseQueue baseQueue baseQueue
batchChan chan []T batchChan chan []T
flushChan chan flushType flushChan chan flushType
isFlushing atomic.Bool
batchLength int batchLength int
workerNum int workerNum int
@ -42,7 +43,10 @@ type WorkerPoolQueue[T any] struct {
workerNumMu sync.Mutex workerNumMu sync.Mutex
} }
type flushType chan struct{} type flushType struct {
timeout time.Duration
c chan struct{}
}
var _ ManagedWorkerPoolQueue = (*WorkerPoolQueue[any])(nil) var _ ManagedWorkerPoolQueue = (*WorkerPoolQueue[any])(nil)
@ -104,12 +108,12 @@ func (q *WorkerPoolQueue[T]) FlushWithContext(ctx context.Context, timeout time.
if timeout > 0 { if timeout > 0 {
after = time.After(timeout) after = time.After(timeout)
} }
c := make(flushType) flush := flushType{timeout: timeout, c: make(chan struct{})}
// send flush request // send flush request
// if it blocks, it means that there is a flush in progress or the queue hasn't been started yet // if it blocks, it means that there is a flush in progress or the queue hasn't been started yet
select { select {
case q.flushChan <- c: case q.flushChan <- flush:
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()
case <-q.ctxRun.Done(): case <-q.ctxRun.Done():
@ -120,7 +124,7 @@ func (q *WorkerPoolQueue[T]) FlushWithContext(ctx context.Context, timeout time.
// wait for flush to finish // wait for flush to finish
select { select {
case <-c: case <-flush.c:
return nil return nil
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()

View File

@ -38,8 +38,8 @@ func TestGetDirectorySize(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
repo, err := repo_model.GetRepositoryByID(db.DefaultContext, 1) repo, err := repo_model.GetRepositoryByID(db.DefaultContext, 1)
assert.NoError(t, err) assert.NoError(t, err)
size, err := getDirectorySize(repo.RepoPath()) size, err := getDirectorySize(repo.RepoPath())
assert.NoError(t, err) assert.NoError(t, err)
assert.EqualValues(t, size, repo.Size) repo.Size = 8165 // real size on the disk
assert.EqualValues(t, repo.Size, size)
} }

View File

@ -3,33 +3,33 @@
package setting package setting
// Attachment settings type AttachmentSettingType struct {
var Attachment = struct {
Storage *Storage Storage *Storage
AllowedTypes string AllowedTypes string
MaxSize int64 MaxSize int64
MaxFiles int MaxFiles int
Enabled bool Enabled bool
}{
Storage: &Storage{},
AllowedTypes: ".cpuprofile,.csv,.dmp,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.json,.jsonc,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.xls,.xlsx,.zip",
MaxSize: 2048,
MaxFiles: 5,
Enabled: true,
} }
var Attachment AttachmentSettingType
func loadAttachmentFrom(rootCfg ConfigProvider) (err error) { func loadAttachmentFrom(rootCfg ConfigProvider) (err error) {
Attachment = AttachmentSettingType{
AllowedTypes: ".avif,.cpuprofile,.csv,.dmp,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.json,.jsonc,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.webp,.xls,.xlsx,.zip",
MaxSize: 2048,
MaxFiles: 5,
Enabled: true,
}
sec, _ := rootCfg.GetSection("attachment") sec, _ := rootCfg.GetSection("attachment")
if sec == nil { if sec == nil {
Attachment.Storage, err = getStorage(rootCfg, "attachments", "", nil) Attachment.Storage, err = getStorage(rootCfg, "attachments", "", nil)
return err return err
} }
Attachment.AllowedTypes = sec.Key("ALLOWED_TYPES").MustString(".cpuprofile,.csv,.dmp,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.json,.jsonc,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.xls,.xlsx,.zip") Attachment.AllowedTypes = sec.Key("ALLOWED_TYPES").MustString(Attachment.AllowedTypes)
Attachment.MaxSize = sec.Key("MAX_SIZE").MustInt64(2048) Attachment.MaxSize = sec.Key("MAX_SIZE").MustInt64(Attachment.MaxSize)
Attachment.MaxFiles = sec.Key("MAX_FILES").MustInt(5) Attachment.MaxFiles = sec.Key("MAX_FILES").MustInt(Attachment.MaxFiles)
Attachment.Enabled = sec.Key("ENABLED").MustBool(true) Attachment.Enabled = sec.Key("ENABLED").MustBool(Attachment.Enabled)
Attachment.Storage, err = getStorage(rootCfg, "attachments", "", sec) Attachment.Storage, err = getStorage(rootCfg, "attachments", "", sec)
return err return err
} }

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

@ -86,6 +86,7 @@ var UI = struct {
Reactions: []string{`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket`, `eyes`}, Reactions: []string{`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket`, `eyes`},
CustomEmojis: []string{`git`, `gitea`, `codeberg`, `gitlab`, `github`, `gogs`}, CustomEmojis: []string{`git`, `gitea`, `codeberg`, `gitlab`, `github`, `gogs`},
CustomEmojisMap: map[string]string{"git": ":git:", "gitea": ":gitea:", "codeberg": ":codeberg:", "gitlab": ":gitlab:", "github": ":github:", "gogs": ":gogs:"}, CustomEmojisMap: map[string]string{"git": ":git:", "gitea": ":gitea:", "codeberg": ":codeberg:", "gitlab": ":gitlab:", "github": ":github:", "gogs": ":gogs:"},
ExploreDefaultSort: "recentupdate",
PreferredTimestampTense: "mixed", PreferredTimestampTense: "mixed",
AmbiguousUnicodeDetection: true, AmbiguousUnicodeDetection: 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"
@ -62,19 +63,18 @@ func (ut *RenderUtils) RenderCommitMessageLinkSubject(msg, urlDefault string, me
} }
msgLine = strings.TrimRightFunc(msgLine, unicode.IsSpace) msgLine = strings.TrimRightFunc(msgLine, unicode.IsSpace)
if len(msgLine) == 0 { if len(msgLine) == 0 {
return template.HTML("") return ""
} }
// we can safely assume that it will not return any error, since there // we can safely assume that it will not return any error, since there
// shouldn't be any special HTML. // shouldn't be any special HTML.
renderedMessage, err := markup.RenderCommitMessageSubject(&markup.RenderContext{ renderedMessage, err := markup.RenderCommitMessageSubject(&markup.RenderContext{
Ctx: ut.ctx, Ctx: ut.ctx,
DefaultLink: urlDefault, Metas: metas,
Metas: metas, }, urlDefault, template.HTMLEscapeString(msgLine))
}, template.HTMLEscapeString(msgLine))
if err != nil { if err != nil {
log.Error("RenderCommitMessageSubject: %v", err) log.Error("RenderCommitMessageSubject: %v", err)
return template.HTML("") return ""
} }
return renderCodeBlock(template.HTML(renderedMessage)) return renderCodeBlock(template.HTML(renderedMessage))
} }
@ -94,9 +94,8 @@ func (ut *RenderUtils) RenderCommitBody(msg string, metas map[string]string) tem
} }
renderedMessage, err := markup.RenderCommitMessage(&markup.RenderContext{ renderedMessage, err := markup.RenderCommitMessage(&markup.RenderContext{
Ctx: ut.ctx, Ctx: ut.ctx,
Metas: metas, Metas: metas,
ContentMode: markup.RenderContentAsComment,
}, template.HTMLEscapeString(msgLine)) }, template.HTMLEscapeString(msgLine))
if err != nil { if err != nil {
log.Error("RenderCommitMessage: %v", err) log.Error("RenderCommitMessage: %v", err)
@ -117,9 +116,8 @@ func renderCodeBlock(htmlEscapedTextToRender template.HTML) template.HTML {
// RenderIssueTitle renders issue/pull title with defined post processors // RenderIssueTitle renders issue/pull title with defined post processors
func (ut *RenderUtils) RenderIssueTitle(text string, metas map[string]string) template.HTML { func (ut *RenderUtils) RenderIssueTitle(text string, metas map[string]string) template.HTML {
renderedText, err := markup.RenderIssueTitle(&markup.RenderContext{ renderedText, err := markup.RenderIssueTitle(&markup.RenderContext{
Ctx: ut.ctx, Ctx: ut.ctx,
ContentMode: markup.RenderContentAsTitle, Metas: metas,
Metas: metas,
}, template.HTMLEscapeString(text)) }, template.HTMLEscapeString(text))
if err != nil { if err != nil {
log.Error("RenderIssueTitle: %v", err) log.Error("RenderIssueTitle: %v", err)
@ -143,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))
} }
@ -177,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>`,
@ -212,7 +210,7 @@ func reactionToEmoji(reaction string) template.HTML {
func (ut *RenderUtils) MarkdownToHtml(input string) template.HTML { //nolint:revive func (ut *RenderUtils) MarkdownToHtml(input string) template.HTML { //nolint:revive
output, err := markdown.RenderString(&markup.RenderContext{ output, err := markdown.RenderString(&markup.RenderContext{
Ctx: ut.ctx, Ctx: ut.ctx,
Metas: map[string]string{"mode": "document"}, Metas: markup.ComposeSimpleDocumentMetas(),
}, input) }, input)
if err != nil { if err != nil {
log.Error("RenderString: %v", err) log.Error("RenderString: %v", err)

View File

@ -47,10 +47,11 @@ mail@domain.com
} }
var testMetas = map[string]string{ var testMetas = map[string]string{
"user": "user13", "user": "user13",
"repo": "repo11", "repo": "repo11",
"repoPath": "../../tests/gitea-repositories-meta/user13/repo11.git/", "repoPath": "../../tests/gitea-repositories-meta/user13/repo11.git/",
"mode": "comment", "markdownLineBreakStyle": "comment",
"markupAllowShortIssuePattern": "true",
} }
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
@ -75,8 +76,7 @@ func newTestRenderUtils() *RenderUtils {
func TestRenderCommitBody(t *testing.T) { func TestRenderCommitBody(t *testing.T) {
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableInternalAttributes, true)() defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableInternalAttributes, true)()
type args struct { type args struct {
msg string msg string
metas map[string]string
} }
tests := []struct { tests := []struct {
name string name string
@ -108,39 +108,39 @@ func TestRenderCommitBody(t *testing.T) {
ut := newTestRenderUtils() ut := newTestRenderUtils()
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
assert.Equalf(t, tt.want, ut.RenderCommitBody(tt.args.msg, tt.args.metas), "RenderCommitBody(%v, %v)", tt.args.msg, tt.args.metas) assert.Equalf(t, tt.want, ut.RenderCommitBody(tt.args.msg, nil), "RenderCommitBody(%v, %v)", tt.args.msg, nil)
}) })
} }
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="default-link 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))
} }
@ -164,11 +164,11 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
<span class="emoji" aria-label="thumbs up">👍</span> <span class="emoji" aria-label="thumbs up">👍</span>
mail@domain.com mail@domain.com
@mention-user test @mention-user test
<a href="/user13/repo11/issues/123" class="ref-issue">#123</a> #123
space<SPACE><SPACE> space<SPACE><SPACE>
` `
expected = strings.ReplaceAll(expected, "<SPACE>", " ") expected = strings.ReplaceAll(expected, "<SPACE>", " ")
assert.EqualValues(t, expected, string(newTestRenderUtils().RenderIssueTitle(testInput(), testMetas))) assert.EqualValues(t, expected, string(newTestRenderUtils().RenderIssueTitle(testInput(), nil)))
} }
func TestRenderMarkdownToHtml(t *testing.T) { func TestRenderMarkdownToHtml(t *testing.T) {

View File

@ -19,9 +19,10 @@ import (
) )
var ( var (
prefix string prefix string
SlowTest = 10 * time.Second TestTimeout = 10 * time.Minute
SlowFlush = 5 * time.Second TestSlowRun = 10 * time.Second
TestSlowFlush = 1 * time.Second
) )
var WriterCloser = &testLoggerWriterCloser{} var WriterCloser = &testLoggerWriterCloser{}
@ -89,79 +90,97 @@ func (w *testLoggerWriterCloser) Reset() {
w.Unlock() w.Unlock()
} }
// Printf takes a format and args and prints the string to os.Stdout
func Printf(format string, args ...any) {
if !log.CanColorStdout {
for i := 0; i < len(args); i++ {
if c, ok := args[i].(*log.ColoredValue); ok {
args[i] = c.Value()
}
}
}
_, _ = fmt.Fprintf(os.Stdout, format, args...)
}
// PrintCurrentTest prints the current test to os.Stdout // PrintCurrentTest prints the current test to os.Stdout
func PrintCurrentTest(t testing.TB, skip ...int) func() { func PrintCurrentTest(t testing.TB, skip ...int) func() {
t.Helper() t.Helper()
start := time.Now() runStart := time.Now()
actualSkip := util.OptionalArg(skip) + 1 actualSkip := util.OptionalArg(skip) + 1
_, filename, line, _ := runtime.Caller(actualSkip) _, filename, line, _ := runtime.Caller(actualSkip)
if log.CanColorStdout { Printf("=== %s (%s:%d)\n", log.NewColoredValue(t.Name()), strings.TrimPrefix(filename, prefix), line)
_, _ = fmt.Fprintf(os.Stdout, "=== %s (%s:%d)\n", fmt.Formatter(log.NewColoredValue(t.Name())), strings.TrimPrefix(filename, prefix), line)
} else {
_, _ = fmt.Fprintf(os.Stdout, "=== %s (%s:%d)\n", t.Name(), strings.TrimPrefix(filename, prefix), line)
}
WriterCloser.pushT(t) WriterCloser.pushT(t)
return func() { timeoutChecker := time.AfterFunc(TestTimeout, func() {
took := time.Since(start) l := 128 * 1024
if took > SlowTest { var stack []byte
if log.CanColorStdout { for {
_, _ = fmt.Fprintf(os.Stdout, "+++ %s is a slow test (took %v)\n", fmt.Formatter(log.NewColoredValue(t.Name(), log.Bold, log.FgYellow)), fmt.Formatter(log.NewColoredValue(took, log.Bold, log.FgYellow))) stack = make([]byte, l)
} else { n := runtime.Stack(stack, true)
_, _ = fmt.Fprintf(os.Stdout, "+++ %s is a slow test (took %v)\n", t.Name(), took) if n <= l {
stack = stack[:n]
break
} }
l = n
} }
timer := time.AfterFunc(SlowFlush, func() { Printf("!!! %s ... timeout: %v ... stacktrace:\n%s\n\n", log.NewColoredValue(t.Name(), log.Bold, log.FgRed), TestTimeout, string(stack))
if log.CanColorStdout { })
_, _ = fmt.Fprintf(os.Stdout, "+++ %s ... still flushing after %v ...\n", fmt.Formatter(log.NewColoredValue(t.Name(), log.Bold, log.FgRed)), SlowFlush) return func() {
} else { flushStart := time.Now()
_, _ = fmt.Fprintf(os.Stdout, "+++ %s ... still flushing after %v ...\n", t.Name(), SlowFlush) slowFlushChecker := time.AfterFunc(TestSlowFlush, func() {
} Printf("+++ %s ... still flushing after %v ...\n", log.NewColoredValue(t.Name(), log.Bold, log.FgRed), TestSlowFlush)
}) })
if err := queue.GetManager().FlushAll(context.Background(), time.Minute); err != nil { if err := queue.GetManager().FlushAll(context.Background(), -1); err != nil {
t.Errorf("Flushing queues failed with error %v", err) t.Errorf("Flushing queues failed with error %v", err)
} }
timer.Stop() slowFlushChecker.Stop()
flushTook := time.Since(start) - took timeoutChecker.Stop()
if flushTook > SlowFlush {
if log.CanColorStdout { runDuration := time.Since(runStart)
_, _ = fmt.Fprintf(os.Stdout, "+++ %s had a slow clean-up flush (took %v)\n", fmt.Formatter(log.NewColoredValue(t.Name(), log.Bold, log.FgRed)), fmt.Formatter(log.NewColoredValue(flushTook, log.Bold, log.FgRed))) flushDuration := time.Since(flushStart)
} else { if runDuration > TestSlowRun {
_, _ = fmt.Fprintf(os.Stdout, "+++ %s had a slow clean-up flush (took %v)\n", t.Name(), flushTook) Printf("+++ %s is a slow test (run: %v, flush: %v)\n", log.NewColoredValue(t.Name(), log.Bold, log.FgYellow), runDuration, flushDuration)
}
} }
WriterCloser.popT() WriterCloser.popT()
} }
} }
// Printf takes a format and args and prints the string to os.Stdout
func Printf(format string, args ...any) {
if log.CanColorStdout {
for i := 0; i < len(args); i++ {
args[i] = log.NewColoredValue(args[i])
}
}
_, _ = fmt.Fprintf(os.Stdout, "\t"+format, args...)
}
// TestLogEventWriter is a logger which will write to the testing log // TestLogEventWriter is a logger which will write to the testing log
type TestLogEventWriter struct { type TestLogEventWriter struct {
*log.EventWriterBaseImpl *log.EventWriterBaseImpl
} }
// NewTestLoggerWriter creates a TestLogEventWriter as a log.LoggerProvider // newTestLoggerWriter creates a TestLogEventWriter as a log.LoggerProvider
func NewTestLoggerWriter(name string, mode log.WriterMode) log.EventWriter { func newTestLoggerWriter(name string, mode log.WriterMode) log.EventWriter {
w := &TestLogEventWriter{} w := &TestLogEventWriter{}
w.EventWriterBaseImpl = log.NewEventWriterBase(name, "test-log-writer", mode) w.EventWriterBaseImpl = log.NewEventWriterBase(name, "test-log-writer", mode)
w.OutputWriteCloser = WriterCloser w.OutputWriteCloser = WriterCloser
return w return w
} }
func init() { func Init() {
const relFilePath = "modules/testlogger/testlogger.go" const relFilePath = "modules/testlogger/testlogger.go"
_, filename, _, _ := runtime.Caller(0) _, filename, _, _ := runtime.Caller(0)
if !strings.HasSuffix(filename, relFilePath) { if !strings.HasSuffix(filename, relFilePath) {
panic("source code file path doesn't match expected: " + relFilePath) panic("source code file path doesn't match expected: " + relFilePath)
} }
prefix = strings.TrimSuffix(filename, relFilePath) prefix = strings.TrimSuffix(filename, relFilePath)
log.RegisterEventWriter("test", newTestLoggerWriter)
duration, err := time.ParseDuration(os.Getenv("GITEA_TEST_SLOW_RUN"))
if err == nil && duration > 0 {
TestSlowRun = duration
}
duration, err = time.ParseDuration(os.Getenv("GITEA_TEST_SLOW_FLUSH"))
if err == nil && duration > 0 {
TestSlowFlush = duration
}
}
func Fatalf(format string, args ...any) {
Printf(format+"\n", args...)
os.Exit(1)
} }

View File

@ -5,10 +5,12 @@ package typesniffer
import ( import (
"bytes" "bytes"
"encoding/binary"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"regexp" "regexp"
"slices"
"strings" "strings"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
@ -18,10 +20,10 @@ import (
const sniffLen = 1024 const sniffLen = 1024
const ( const (
// SvgMimeType MIME type of SVG images. MimeTypeImageSvg = "image/svg+xml"
SvgMimeType = "image/svg+xml" MimeTypeImageAvif = "image/avif"
// ApplicationOctetStream MIME type of binary files.
ApplicationOctetStream = "application/octet-stream" MimeTypeApplicationOctetStream = "application/octet-stream"
) )
var ( var (
@ -47,7 +49,7 @@ func (ct SniffedType) IsImage() bool {
// IsSvgImage detects if data is an SVG image format // IsSvgImage detects if data is an SVG image format
func (ct SniffedType) IsSvgImage() bool { func (ct SniffedType) IsSvgImage() bool {
return strings.Contains(ct.contentType, SvgMimeType) return strings.Contains(ct.contentType, MimeTypeImageSvg)
} }
// IsPDF detects if data is a PDF format // IsPDF detects if data is a PDF format
@ -81,6 +83,26 @@ func (ct SniffedType) GetMimeType() string {
return strings.SplitN(ct.contentType, ";", 2)[0] return strings.SplitN(ct.contentType, ";", 2)[0]
} }
// https://en.wikipedia.org/wiki/ISO_base_media_file_format#File_type_box
func detectFileTypeBox(data []byte) (brands []string, found bool) {
if len(data) < 12 {
return nil, false
}
boxSize := int(binary.BigEndian.Uint32(data[:4]))
if boxSize < 12 || boxSize > len(data) {
return nil, false
}
tag := string(data[4:8])
if tag != "ftyp" {
return nil, false
}
brands = append(brands, string(data[8:12]))
for i := 16; i+4 <= boxSize; i += 4 {
brands = append(brands, string(data[i:i+4]))
}
return brands, true
}
// DetectContentType extends http.DetectContentType with more content types. Defaults to text/unknown if input is empty. // DetectContentType extends http.DetectContentType with more content types. Defaults to text/unknown if input is empty.
func DetectContentType(data []byte) SniffedType { func DetectContentType(data []byte) SniffedType {
if len(data) == 0 { if len(data) == 0 {
@ -94,7 +116,6 @@ func DetectContentType(data []byte) SniffedType {
} }
// SVG is unsupported by http.DetectContentType, https://github.com/golang/go/issues/15888 // SVG is unsupported by http.DetectContentType, https://github.com/golang/go/issues/15888
detectByHTML := strings.Contains(ct, "text/plain") || strings.Contains(ct, "text/html") detectByHTML := strings.Contains(ct, "text/plain") || strings.Contains(ct, "text/html")
detectByXML := strings.Contains(ct, "text/xml") detectByXML := strings.Contains(ct, "text/xml")
if detectByHTML || detectByXML { if detectByHTML || detectByXML {
@ -102,7 +123,7 @@ func DetectContentType(data []byte) SniffedType {
dataProcessed = bytes.TrimSpace(dataProcessed) dataProcessed = bytes.TrimSpace(dataProcessed)
if detectByHTML && svgTagRegex.Match(dataProcessed) || if detectByHTML && svgTagRegex.Match(dataProcessed) ||
detectByXML && svgTagInXMLRegex.Match(dataProcessed) { detectByXML && svgTagInXMLRegex.Match(dataProcessed) {
ct = SvgMimeType ct = MimeTypeImageSvg
} }
} }
@ -116,6 +137,11 @@ func DetectContentType(data []byte) SniffedType {
} }
} }
fileTypeBrands, found := detectFileTypeBox(data)
if found && slices.Contains(fileTypeBrands, "avif") {
ct = MimeTypeImageAvif
}
if ct == "application/ogg" { if ct == "application/ogg" {
dataHead := data dataHead := data
if len(dataHead) > 256 { if len(dataHead) > 256 {

View File

@ -134,3 +134,33 @@ func TestDetectContentTypeOgg(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, st.IsVideo()) assert.True(t, st.IsVideo())
} }
func TestDetectFileTypeBox(t *testing.T) {
_, found := detectFileTypeBox([]byte("\x00\x00\xff\xffftypAAAA...."))
assert.False(t, found)
brands, found := detectFileTypeBox([]byte("\x00\x00\x00\x0cftypAAAA"))
assert.True(t, found)
assert.Equal(t, []string{"AAAA"}, brands)
brands, found = detectFileTypeBox([]byte("\x00\x00\x00\x10ftypAAAA....BBBB"))
assert.True(t, found)
assert.Equal(t, []string{"AAAA"}, brands)
brands, found = detectFileTypeBox([]byte("\x00\x00\x00\x14ftypAAAA....BBBB"))
assert.True(t, found)
assert.Equal(t, []string{"AAAA", "BBBB"}, brands)
_, found = detectFileTypeBox([]byte("\x00\x00\x00\x14ftypAAAA....BBB"))
assert.False(t, found)
brands, found = detectFileTypeBox([]byte("\x00\x00\x00\x13ftypAAAA....BBB"))
assert.True(t, found)
assert.Equal(t, []string{"AAAA"}, brands)
}
func TestDetectContentTypeAvif(t *testing.T) {
buf := []byte("\x00\x00\x00\x20ftypavif.......................")
st := DetectContentType(buf)
assert.Equal(t, MimeTypeImageAvif, st.contentType)
}

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

@ -47,11 +47,12 @@ func RenderMarkup(ctx *context.Base, repo *context.Repository, mode, text, urlPa
switch mode { switch mode {
case "gfm": // legacy mode, do nothing case "gfm": // legacy mode, do nothing
case "comment": case "comment":
renderCtx.ContentMode = markup.RenderContentAsComment renderCtx.Metas = map[string]string{"markdownLineBreakStyle": "comment"}
case "wiki": case "wiki":
renderCtx.ContentMode = markup.RenderContentAsWiki renderCtx.Metas = map[string]string{"markdownLineBreakStyle": "document", "markupContentMode": "wiki"}
case "file": case "file":
// render the repo file content by its extension // render the repo file content by its extension
renderCtx.Metas = map[string]string{"markdownLineBreakStyle": "document"}
renderCtx.MarkupType = "" renderCtx.MarkupType = ""
renderCtx.RelativePath = filePath renderCtx.RelativePath = filePath
renderCtx.InStandalonePage = true renderCtx.InStandalonePage = true
@ -74,10 +75,12 @@ func RenderMarkup(ctx *context.Base, repo *context.Repository, mode, text, urlPa
if repo != nil && repo.Repository != nil { if repo != nil && repo.Repository != nil {
renderCtx.Repo = repo.Repository renderCtx.Repo = repo.Repository
if renderCtx.ContentMode == markup.RenderContentAsComment { if mode == "file" {
renderCtx.Metas = repo.Repository.ComposeMetas(ctx)
} else {
renderCtx.Metas = repo.Repository.ComposeDocumentMetas(ctx) renderCtx.Metas = repo.Repository.ComposeDocumentMetas(ctx)
} else if mode == "wiki" {
renderCtx.Metas = repo.Repository.ComposeWikiMetas(ctx)
} else if mode == "comment" {
renderCtx.Metas = repo.Repository.ComposeMetas(ctx)
} }
} }
if err := markup.Render(renderCtx, strings.NewReader(text), ctx.Resp); err != nil { if err := markup.Render(renderCtx, strings.NewReader(text), ctx.Resp); err != nil {

View File

@ -122,6 +122,8 @@ func SignInOAuthCallback(ctx *context.Context) {
} }
if err, ok := err.(*go_oauth2.RetrieveError); ok { if err, ok := err.(*go_oauth2.RetrieveError); ok {
ctx.Flash.Error("OAuth2 RetrieveError: "+err.Error(), true) ctx.Flash.Error("OAuth2 RetrieveError: "+err.Error(), true)
ctx.Redirect(setting.AppSubURL + "/user/login")
return
} }
ctx.ServerError("UserSignIn", err) ctx.ServerError("UserSignIn", err)
return return

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.FullName,
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

@ -56,7 +56,7 @@ func renderMarkdown(ctx *context.Context, act *activities_model.Action, content
Links: markup.Links{ Links: markup.Links{
Base: act.GetRepoLink(ctx), Base: act.GetRepoLink(ctx),
}, },
Metas: map[string]string{ Metas: map[string]string{ // FIXME: not right here, it should use issue to compose the metas
"user": act.GetRepoUserName(ctx), "user": act.GetRepoUserName(ctx),
"repo": act.GetRepoName(ctx), "repo": act.GetRepoName(ctx),
}, },

View File

@ -46,9 +46,7 @@ func showUserFeed(ctx *context.Context, formatType string) {
Links: markup.Links{ Links: markup.Links{
Base: ctx.ContextUser.HTMLURL(), Base: ctx.ContextUser.HTMLURL(),
}, },
Metas: map[string]string{ Metas: markup.ComposeSimpleDocumentMetas(),
"user": ctx.ContextUser.GetDisplayName(),
},
}, ctx.ContextUser.Description) }, ctx.ContextUser.Description)
if err != nil { if err != nil {
ctx.ServerError("RenderString", err) ctx.ServerError("RenderString", err)

View File

@ -189,7 +189,7 @@ func prepareOrgProfileReadme(ctx *context.Context, viewRepositories bool) bool {
Base: profileDbRepo.Link(), Base: profileDbRepo.Link(),
BranchPath: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)), BranchPath: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)),
}, },
Metas: map[string]string{"mode": "document"}, Metas: markup.ComposeSimpleDocumentMetas(),
}, bytes); err != nil { }, bytes); err != nil {
log.Error("failed to RenderString: %v", err) log.Error("failed to RenderString: %v", err)
} else { } else {

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

@ -289,9 +289,8 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) {
} }
rctx := &markup.RenderContext{ rctx := &markup.RenderContext{
Ctx: ctx, Ctx: ctx,
ContentMode: markup.RenderContentAsWiki, Metas: ctx.Repo.Repository.ComposeWikiMetas(ctx),
Metas: ctx.Repo.Repository.ComposeDocumentMetas(ctx),
Links: markup.Links{ Links: markup.Links{
Base: ctx.Repo.RepoLink, Base: ctx.Repo.RepoLink,
}, },
@ -327,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 {

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