forked from NYANDEV/forgejo
Merge branch 'rebase-forgejo-dependency' into forgejo
This commit is contained in:
commit
36010fb2ee
99 changed files with 1132 additions and 155 deletions
10
assets/go-licenses.json
generated
10
assets/go-licenses.json
generated
File diff suppressed because one or more lines are too long
|
@ -410,6 +410,10 @@ USER = root
|
|||
;;
|
||||
;; Whether execute database models migrations automatically
|
||||
;AUTO_MIGRATION = true
|
||||
;;
|
||||
;; Threshold value (in seconds) beyond which query execution time is logged as a warning in the xorm logger
|
||||
;;
|
||||
;SLOW_QUERY_TRESHOLD = 5s
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
@ -810,6 +814,11 @@ LEVEL = Info
|
|||
;; Every new user will have restricted permissions depending on this setting
|
||||
;DEFAULT_USER_IS_RESTRICTED = false
|
||||
;;
|
||||
;; Users will be able to use dots when choosing their username. Disabling this is
|
||||
;; helpful if your usersare having issues with e.g. RSS feeds or advanced third-party
|
||||
;; extensions that use strange regex patterns.
|
||||
; ALLOW_DOTS_IN_USERNAMES = true
|
||||
;;
|
||||
;; Either "public", "limited" or "private", default is "public"
|
||||
;; Limited is for users visible only to signed users
|
||||
;; Private is for users visible only to members of their organizations
|
||||
|
@ -1452,6 +1461,8 @@ LEVEL = Info
|
|||
;;
|
||||
;; Default configuration for email notifications for users (user configurable). Options: enabled, onmention, disabled
|
||||
;DEFAULT_EMAIL_NOTIFICATIONS = enabled
|
||||
;; Send an email to all admins when a new user signs up to inform the admins about this act. Options: true, false
|
||||
;SEND_NOTIFICATION_EMAIL_ON_NEW_USER = false
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
@ -1766,9 +1777,6 @@ LEVEL = Info
|
|||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;
|
||||
;AVATAR_UPLOAD_PATH = data/avatars
|
||||
;REPOSITORY_AVATAR_UPLOAD_PATH = data/repo-avatars
|
||||
;;
|
||||
;; How Gitea deals with missing repository avatars
|
||||
;; none = no avatar will be displayed; random = random avatar will be displayed; image = default image will be used
|
||||
;REPOSITORY_AVATAR_FALLBACK = none
|
||||
|
|
|
@ -454,6 +454,7 @@ The following configuration set `Content-Type: application/vnd.android.package-a
|
|||
- `MAX_IDLE_CONNS` **2**: Max idle database connections on connection pool, default is 2 - this will be capped to `MAX_OPEN_CONNS`.
|
||||
- `CONN_MAX_LIFETIME` **0 or 3s**: Sets the maximum amount of time a DB connection may be reused - default is 0, meaning there is no limit (except on MySQL where it is 3s - see #6804 & #7071).
|
||||
- `AUTO_MIGRATION` **true**: Whether execute database models migrations automatically.
|
||||
- `SLOW_QUERY_TRESHOLD` **5s**: Threshold value in seconds beyond which query execution time is logged as a warning in the xorm logger.
|
||||
|
||||
[^1]: It may be necessary to specify a hostport even when listening on a unix socket, as the port is part of the socket name. see [#24552](https://github.com/go-gitea/gitea/issues/24552#issuecomment-1681649367) for additional details.
|
||||
|
||||
|
@ -513,6 +514,7 @@ And the following unique queues:
|
|||
|
||||
- `DEFAULT_EMAIL_NOTIFICATIONS`: **enabled**: Default configuration for email notifications for users (user configurable). Options: enabled, onmention, disabled
|
||||
- `DISABLE_REGULAR_ORG_CREATION`: **false**: Disallow regular (non-admin) users from creating organizations.
|
||||
- `SEND_NOTIFICATION_EMAIL_ON_NEW_USER`: **false**: Send an email to all admins when a new user signs up to inform the admins about this act.
|
||||
|
||||
## Security (`security`)
|
||||
|
||||
|
|
6
go.mod
6
go.mod
|
@ -15,7 +15,6 @@ require (
|
|||
gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4
|
||||
github.com/42wim/sshsig v0.0.0-20211121163825-841cf5bbc121
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358
|
||||
github.com/NYTimes/gziphandler v1.1.1
|
||||
github.com/PuerkitoBio/goquery v1.8.1
|
||||
github.com/alecthomas/chroma/v2 v2.10.0
|
||||
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb
|
||||
|
@ -78,7 +77,6 @@ require (
|
|||
github.com/mholt/archiver/v3 v3.5.1
|
||||
github.com/microcosm-cc/bluemonday v1.0.26
|
||||
github.com/minio/minio-go/v7 v7.0.63
|
||||
github.com/minio/sha256-simd v1.0.1
|
||||
github.com/msteinert/pam v1.2.0
|
||||
github.com/nektos/act v0.2.52
|
||||
github.com/niklasfasching/go-org v1.7.0
|
||||
|
@ -101,7 +99,6 @@ require (
|
|||
github.com/ulikunitz/xz v0.5.11
|
||||
github.com/urfave/cli/v2 v2.25.7
|
||||
github.com/xanzy/go-gitlab v0.93.1
|
||||
github.com/xeipuuv/gojsonschema v1.2.0
|
||||
github.com/yohcop/openid-go v1.0.1
|
||||
github.com/yuin/goldmark v1.5.6
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||
|
@ -232,6 +229,7 @@ require (
|
|||
github.com/mholt/acmez v1.2.0 // indirect
|
||||
github.com/miekg/dns v1.1.56 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/minio/sha256-simd v1.0.1 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
|
@ -277,8 +275,6 @@ require (
|
|||
github.com/valyala/fastjson v1.6.4 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||
github.com/zeebo/blake3 v0.2.3 // indirect
|
||||
|
|
9
go.sum
9
go.sum
|
@ -101,8 +101,6 @@ github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBa
|
|||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
|
||||
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
|
||||
github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I=
|
||||
github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c h1:kMFnB0vCcX7IL/m9Y5LO+KQYv+t1CQOiFe6+SV2J7bE=
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
|
||||
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
|
||||
|
@ -1047,13 +1045,6 @@ github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3k
|
|||
github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM=
|
||||
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
|
|
|
@ -169,7 +169,12 @@ func RewriteAllPublicKeys(ctx context.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
t.Close()
|
||||
if err := t.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := t.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return util.Rename(tmpPath, fPath)
|
||||
}
|
||||
|
||||
|
|
|
@ -92,7 +92,12 @@ func RewriteAllPrincipalKeys(ctx context.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
t.Close()
|
||||
if err := t.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := t.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return util.Rename(tmpPath, fPath)
|
||||
}
|
||||
|
||||
|
|
|
@ -250,7 +250,7 @@ func (s AccessTokenScope) parse() (accessTokenScopeBitmap, error) {
|
|||
remainingScopes = remainingScopes[i+1:]
|
||||
}
|
||||
singleScope := AccessTokenScope(v)
|
||||
if singleScope == "" {
|
||||
if singleScope == "" || singleScope == "sudo" {
|
||||
continue
|
||||
}
|
||||
if singleScope == AccessTokenScopeAll {
|
||||
|
|
|
@ -20,7 +20,7 @@ func TestAccessTokenScope_Normalize(t *testing.T) {
|
|||
tests := []scopeTestNormalize{
|
||||
{"", "", nil},
|
||||
{"write:misc,write:notification,read:package,write:notification,public-only", "public-only,write:misc,write:notification,read:package", nil},
|
||||
{"all", "all", nil},
|
||||
{"all,sudo", "all", nil},
|
||||
{"write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user", "all", nil},
|
||||
{"write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user,public-only", "public-only,all", nil},
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ package auth
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base32"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
@ -19,7 +20,6 @@ import (
|
|||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
uuid "github.com/google/uuid"
|
||||
"github.com/minio/sha256-simd"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"xorm.io/builder"
|
||||
"xorm.io/xorm"
|
||||
|
|
|
@ -6,6 +6,7 @@ package auth
|
|||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base32"
|
||||
"encoding/base64"
|
||||
|
@ -18,7 +19,6 @@ import (
|
|||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"github.com/minio/sha256-simd"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
|
|
@ -11,10 +11,13 @@ import (
|
|||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
|
||||
"xorm.io/xorm"
|
||||
"xorm.io/xorm/contexts"
|
||||
"xorm.io/xorm/names"
|
||||
"xorm.io/xorm/schemas"
|
||||
|
||||
|
@ -147,6 +150,13 @@ func InitEngine(ctx context.Context) error {
|
|||
xormEngine.SetConnMaxLifetime(setting.Database.ConnMaxLifetime)
|
||||
xormEngine.SetDefaultContext(ctx)
|
||||
|
||||
if setting.Database.SlowQueryTreshold > 0 {
|
||||
xormEngine.AddHook(&SlowQueryHook{
|
||||
Treshold: setting.Database.SlowQueryTreshold,
|
||||
Logger: log.GetLogger("xorm"),
|
||||
})
|
||||
}
|
||||
|
||||
SetDefaultEngine(ctx, xormEngine)
|
||||
return nil
|
||||
}
|
||||
|
@ -300,3 +310,21 @@ func SetLogSQL(ctx context.Context, on bool) {
|
|||
sess.Engine().ShowSQL(on)
|
||||
}
|
||||
}
|
||||
|
||||
type SlowQueryHook struct {
|
||||
Treshold time.Duration
|
||||
Logger log.Logger
|
||||
}
|
||||
|
||||
var _ contexts.Hook = &SlowQueryHook{}
|
||||
|
||||
func (SlowQueryHook) BeforeProcess(c *contexts.ContextHook) (context.Context, error) {
|
||||
return c.Ctx, nil
|
||||
}
|
||||
|
||||
func (h *SlowQueryHook) AfterProcess(c *contexts.ContextHook) error {
|
||||
if c.ExecuteTime >= h.Treshold {
|
||||
h.Logger.Log(8, log.WARN, "[Slow SQL Query] %s %v - %v", c.SQL, c.Args, c.ExecuteTime)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -6,15 +6,19 @@ package db_test
|
|||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/test"
|
||||
|
||||
_ "code.gitea.io/gitea/cmd" // for TestPrimaryKeys
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func TestDumpDatabase(t *testing.T) {
|
||||
|
@ -85,3 +89,37 @@ func TestPrimaryKeys(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlowQuery(t *testing.T) {
|
||||
lc, cleanup := test.NewLogChecker("slow-query")
|
||||
lc.StopMark("[Slow SQL Query]")
|
||||
defer cleanup()
|
||||
|
||||
e := db.GetEngine(db.DefaultContext)
|
||||
engine, ok := e.(*xorm.Engine)
|
||||
assert.True(t, ok)
|
||||
|
||||
// It's not possible to clean this up with XORM, but it's luckily not harmful
|
||||
// to leave around.
|
||||
engine.AddHook(&db.SlowQueryHook{
|
||||
Treshold: time.Second * 10,
|
||||
Logger: log.GetLogger("slow-query"),
|
||||
})
|
||||
|
||||
// NOOP query.
|
||||
e.Exec("SELECT 1 WHERE false;")
|
||||
|
||||
_, stopped := lc.Check(100 * time.Millisecond)
|
||||
assert.False(t, stopped)
|
||||
|
||||
engine.AddHook(&db.SlowQueryHook{
|
||||
Treshold: 0, // Every query should be logged.
|
||||
Logger: log.GetLogger("slow-query"),
|
||||
})
|
||||
|
||||
// NOOP query.
|
||||
e.Exec("SELECT 1 WHERE false;")
|
||||
|
||||
_, stopped = lc.Check(100 * time.Millisecond)
|
||||
assert.True(t, stopped)
|
||||
}
|
||||
|
|
|
@ -150,3 +150,17 @@
|
|||
is_prerelease: false
|
||||
is_tag: false
|
||||
created_unix: 946684803
|
||||
|
||||
- id: 12
|
||||
repo_id: 59
|
||||
publisher_id: 2
|
||||
tag_name: "v1.0"
|
||||
lower_tag_name: "v1.0"
|
||||
target: "main"
|
||||
title: "v1.0"
|
||||
sha1: "d8f53dfb33f6ccf4169c34970b5e747511c18beb"
|
||||
num_commits: 1
|
||||
is_draft: false
|
||||
is_prerelease: false
|
||||
is_tag: false
|
||||
created_unix: 946684803
|
||||
|
|
|
@ -608,6 +608,38 @@
|
|||
type: 1
|
||||
created_unix: 946684810
|
||||
|
||||
# BEGIN Forgejo [GITEA] Improve HTML title on repositories
|
||||
-
|
||||
id: 1093
|
||||
repo_id: 59
|
||||
type: 1
|
||||
created_unix: 946684810
|
||||
|
||||
-
|
||||
id: 1094
|
||||
repo_id: 59
|
||||
type: 2
|
||||
created_unix: 946684810
|
||||
|
||||
-
|
||||
id: 1095
|
||||
repo_id: 59
|
||||
type: 3
|
||||
created_unix: 946684810
|
||||
|
||||
-
|
||||
id: 1096
|
||||
repo_id: 59
|
||||
type: 4
|
||||
created_unix: 946684810
|
||||
|
||||
-
|
||||
id: 1097
|
||||
repo_id: 59
|
||||
type: 5
|
||||
created_unix: 946684810
|
||||
# END Forgejo [GITEA] Improve HTML title on repositories
|
||||
|
||||
-
|
||||
id: 91
|
||||
repo_id: 58
|
||||
|
|
|
@ -1467,6 +1467,7 @@
|
|||
owner_name: user27
|
||||
lower_name: repo49
|
||||
name: repo49
|
||||
description: A wonderful repository with more than just a README.md
|
||||
default_branch: master
|
||||
num_watches: 0
|
||||
num_stars: 0
|
||||
|
@ -1693,3 +1694,16 @@
|
|||
size: 0
|
||||
is_fsck_enabled: true
|
||||
close_issues_via_commit_in_any_branch: false
|
||||
|
||||
-
|
||||
id: 59
|
||||
owner_id: 2
|
||||
owner_name: user2
|
||||
lower_name: repo59
|
||||
name: repo59
|
||||
default_branch: master
|
||||
is_empty: false
|
||||
is_archived: false
|
||||
is_private: false
|
||||
status: 0
|
||||
num_issues: 0
|
||||
|
|
|
@ -66,7 +66,7 @@
|
|||
num_followers: 2
|
||||
num_following: 1
|
||||
num_stars: 2
|
||||
num_repos: 14
|
||||
num_repos: 15
|
||||
num_teams: 0
|
||||
num_members: 0
|
||||
visibility: 0
|
||||
|
|
|
@ -342,7 +342,7 @@ func (c *Comment) AfterLoad(session *xorm.Session) {
|
|||
|
||||
// LoadPoster loads comment poster
|
||||
func (c *Comment) LoadPoster(ctx context.Context) (err error) {
|
||||
if c.PosterID <= 0 || c.Poster != nil {
|
||||
if c.Poster != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
@ -4,9 +4,9 @@
|
|||
package base
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
|
||||
"github.com/minio/sha256-simd"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
|
|
|
@ -4,9 +4,9 @@
|
|||
package v1_14 //nolint
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
|
||||
"github.com/minio/sha256-simd"
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
|
|
|
@ -584,9 +584,9 @@ func (repo *Repository) DescriptionHTML(ctx context.Context) template.HTML {
|
|||
}, repo.Description)
|
||||
if err != nil {
|
||||
log.Error("Failed to render description for %s (ID: %d): %v", repo.Name, repo.ID, err)
|
||||
return template.HTML(markup.Sanitize(repo.Description))
|
||||
return template.HTML(markup.SanitizeDescription(repo.Description))
|
||||
}
|
||||
return template.HTML(markup.Sanitize(desc))
|
||||
return template.HTML(markup.SanitizeDescription(desc))
|
||||
}
|
||||
|
||||
// CloneLink represents different types of clone URLs of repository.
|
||||
|
|
|
@ -138,12 +138,12 @@ func getTestCases() []struct {
|
|||
{
|
||||
name: "AllPublic/PublicRepositoriesOfUserIncludingCollaborative",
|
||||
opts: &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, AllPublic: true, Template: util.OptionalBoolFalse},
|
||||
count: 31,
|
||||
count: 32,
|
||||
},
|
||||
{
|
||||
name: "AllPublic/PublicAndPrivateRepositoriesOfUserIncludingCollaborative",
|
||||
opts: &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, Private: true, AllPublic: true, AllLimited: true, Template: util.OptionalBoolFalse},
|
||||
count: 36,
|
||||
count: 37,
|
||||
},
|
||||
{
|
||||
name: "AllPublic/PublicAndPrivateRepositoriesOfUserIncludingCollaborativeByName",
|
||||
|
@ -158,7 +158,7 @@ func getTestCases() []struct {
|
|||
{
|
||||
name: "AllPublic/PublicRepositoriesOfOrganization",
|
||||
opts: &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 17, AllPublic: true, Collaborate: util.OptionalBoolFalse, Template: util.OptionalBoolFalse},
|
||||
count: 31,
|
||||
count: 32,
|
||||
},
|
||||
{
|
||||
name: "AllTemplates",
|
||||
|
|
|
@ -223,6 +223,12 @@ func GetAllUsers(ctx context.Context) ([]*User, error) {
|
|||
return users, db.GetEngine(ctx).OrderBy("id").Where("type = ?", UserTypeIndividual).Find(&users)
|
||||
}
|
||||
|
||||
// GetAllAdmins returns a slice of all adminusers found in DB.
|
||||
func GetAllAdmins(ctx context.Context) ([]*User, error) {
|
||||
users := make([]*User, 0)
|
||||
return users, db.GetEngine(ctx).OrderBy("id").Where("type = ?", UserTypeIndividual).And("is_admin = ?", true).Find(&users)
|
||||
}
|
||||
|
||||
// IsLocal returns true if user login type is LoginPlain.
|
||||
func (u *User) IsLocal() bool {
|
||||
return u.LoginType <= auth.Plain
|
||||
|
|
|
@ -550,3 +550,13 @@ func Test_ValidateUser(t *testing.T) {
|
|||
assert.EqualValues(t, expected, err == nil, fmt.Sprintf("case: %+v", kase))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllAdmins(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
admins, err := user_model.GetAllAdmins(db.DefaultContext)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, admins, 1)
|
||||
assert.Equal(t, int64(1), admins[0].ID)
|
||||
}
|
||||
|
|
|
@ -4,12 +4,12 @@
|
|||
package hash
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
|
||||
"github.com/minio/sha256-simd"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
|
|
|
@ -4,10 +4,9 @@
|
|||
package avatar
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
|
||||
"github.com/minio/sha256-simd"
|
||||
)
|
||||
|
||||
// HashAvatar will generate a unique string, which ensures that when there's a
|
||||
|
|
|
@ -7,11 +7,10 @@
|
|||
package identicon
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"github.com/minio/sha256-simd"
|
||||
)
|
||||
|
||||
const minImageSize = 16
|
||||
|
|
|
@ -6,6 +6,7 @@ package base
|
|||
import (
|
||||
"crypto/md5"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
|
@ -24,7 +25,6 @@ import (
|
|||
"code.gitea.io/gitea/modules/setting"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/minio/sha256-simd"
|
||||
)
|
||||
|
||||
// EncodeMD5 encodes string to md5 hex value.
|
||||
|
|
|
@ -509,6 +509,62 @@ func GetCommitFileStatus(ctx context.Context, repoPath, commitID string) (*Commi
|
|||
return fileStatus, nil
|
||||
}
|
||||
|
||||
func parseCommitRenames(renames *[][2]string, stdout io.Reader) {
|
||||
rd := bufio.NewReader(stdout)
|
||||
for {
|
||||
// Skip (R || three digits || NULL byte)
|
||||
_, err := rd.Discard(5)
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
oldFileName, err := rd.ReadString('\x00')
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
newFileName, err := rd.ReadString('\x00')
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
oldFileName = strings.TrimSuffix(oldFileName, "\x00")
|
||||
newFileName = strings.TrimSuffix(newFileName, "\x00")
|
||||
*renames = append(*renames, [2]string{oldFileName, newFileName})
|
||||
}
|
||||
}
|
||||
|
||||
// GetCommitFileRenames returns the renames that the commit contains.
|
||||
func GetCommitFileRenames(ctx context.Context, repoPath, commitID string) ([][2]string, error) {
|
||||
renames := [][2]string{}
|
||||
stdout, w := io.Pipe()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
parseCommitRenames(&renames, stdout)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
stderr := new(bytes.Buffer)
|
||||
err := NewCommand(ctx, "show", "--name-status", "--pretty=format:", "-z", "--diff-filter=R").AddDynamicArguments(commitID).Run(&RunOpts{
|
||||
Dir: repoPath,
|
||||
Stdout: w,
|
||||
Stderr: stderr,
|
||||
})
|
||||
w.Close() // Close writer to exit parsing goroutine
|
||||
if err != nil {
|
||||
return nil, ConcatenateError(err, stderr.String())
|
||||
}
|
||||
|
||||
<-done
|
||||
return renames, nil
|
||||
}
|
||||
|
||||
// GetFullCommitID returns full length (40) of commit ID by given short SHA in a repository.
|
||||
func GetFullCommitID(ctx context.Context, repoPath, shortID string) (string, error) {
|
||||
commitID, _, err := NewCommand(ctx, "rev-parse").AddDynamicArguments(shortID).RunStdString(&RunOpts{Dir: repoPath})
|
||||
|
|
|
@ -278,3 +278,30 @@ func TestGetCommitFileStatusMerges(t *testing.T) {
|
|||
assert.Equal(t, commitFileStatus.Removed, expected.Removed)
|
||||
assert.Equal(t, commitFileStatus.Modified, expected.Modified)
|
||||
}
|
||||
|
||||
func TestParseCommitRenames(t *testing.T) {
|
||||
testcases := []struct {
|
||||
output string
|
||||
renames [][2]string
|
||||
}{
|
||||
{
|
||||
output: "R090\x00renamed.txt\x00history.txt\x00",
|
||||
renames: [][2]string{{"renamed.txt", "history.txt"}},
|
||||
},
|
||||
{
|
||||
output: "R090\x00renamed.txt\x00history.txt\x00R000\x00corruptedstdouthere",
|
||||
renames: [][2]string{{"renamed.txt", "history.txt"}},
|
||||
},
|
||||
{
|
||||
output: "R100\x00renamed.txt\x00history.txt\x00R001\x00readme.md\x00README.md\x00",
|
||||
renames: [][2]string{{"renamed.txt", "history.txt"}, {"readme.md", "README.md"}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testcase := range testcases {
|
||||
renames := [][2]string{}
|
||||
parseCommitRenames(&renames, strings.NewReader(testcase.output))
|
||||
|
||||
assert.Equal(t, testcase.renames, renames)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,12 +4,11 @@
|
|||
package git
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
|
||||
"github.com/minio/sha256-simd"
|
||||
)
|
||||
|
||||
// Cache represents a caching interface
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
package lfs
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"hash"
|
||||
|
@ -12,8 +13,6 @@ import (
|
|||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/storage"
|
||||
|
||||
"github.com/minio/sha256-simd"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
package lfs
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
@ -12,8 +13,6 @@ import (
|
|||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/minio/sha256-simd"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
|
@ -18,9 +18,10 @@ import (
|
|||
// Sanitizer is a protection wrapper of *bluemonday.Policy which does not allow
|
||||
// any modification to the underlying policies once it's been created.
|
||||
type Sanitizer struct {
|
||||
defaultPolicy *bluemonday.Policy
|
||||
rendererPolicies map[string]*bluemonday.Policy
|
||||
init sync.Once
|
||||
defaultPolicy *bluemonday.Policy
|
||||
descriptionPolicy *bluemonday.Policy
|
||||
rendererPolicies map[string]*bluemonday.Policy
|
||||
init sync.Once
|
||||
}
|
||||
|
||||
var (
|
||||
|
@ -41,6 +42,7 @@ func NewSanitizer() {
|
|||
func InitializeSanitizer() {
|
||||
sanitizer.rendererPolicies = map[string]*bluemonday.Policy{}
|
||||
sanitizer.defaultPolicy = createDefaultPolicy()
|
||||
sanitizer.descriptionPolicy = createRepoDescriptionPolicy()
|
||||
|
||||
for name, renderer := range renderers {
|
||||
sanitizerRules := renderer.SanitizerRules()
|
||||
|
@ -161,6 +163,27 @@ func createDefaultPolicy() *bluemonday.Policy {
|
|||
return policy
|
||||
}
|
||||
|
||||
// createRepoDescriptionPolicy returns a minimal more strict policy that is used for
|
||||
// repository descriptions.
|
||||
func createRepoDescriptionPolicy() *bluemonday.Policy {
|
||||
policy := bluemonday.NewPolicy()
|
||||
|
||||
// Allow italics and bold.
|
||||
policy.AllowElements("i", "b", "em", "strong")
|
||||
|
||||
// Allow code.
|
||||
policy.AllowElements("code")
|
||||
|
||||
// Allow links
|
||||
policy.AllowAttrs("href", "target", "rel").OnElements("a")
|
||||
|
||||
// Allow classes for emojis
|
||||
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^emoji$`)).OnElements("img", "span")
|
||||
policy.AllowAttrs("aria-label").OnElements("span")
|
||||
|
||||
return policy
|
||||
}
|
||||
|
||||
func addSanitizerRules(policy *bluemonday.Policy, rules []setting.MarkupSanitizerRule) {
|
||||
for _, rule := range rules {
|
||||
if rule.AllowDataURIImages {
|
||||
|
@ -176,6 +199,12 @@ func addSanitizerRules(policy *bluemonday.Policy, rules []setting.MarkupSanitize
|
|||
}
|
||||
}
|
||||
|
||||
// SanitizeDescription sanitizes the HTML generated for a repository description.
|
||||
func SanitizeDescription(s string) string {
|
||||
NewSanitizer()
|
||||
return sanitizer.descriptionPolicy.Sanitize(s)
|
||||
}
|
||||
|
||||
// Sanitize takes a string that contains a HTML fragment or document and applies policy whitelist.
|
||||
func Sanitize(s string) string {
|
||||
NewSanitizer()
|
||||
|
|
|
@ -73,6 +73,28 @@ func Test_Sanitizer(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDescriptionSanitizer(t *testing.T) {
|
||||
NewSanitizer()
|
||||
|
||||
testCases := []string{
|
||||
`<h1>Title</h1>`, `Title`,
|
||||
`<img src='img.png' alt='image'>`, ``,
|
||||
`<span class="emoji" aria-label="thumbs up">THUMBS UP</span>`, `<span class="emoji" aria-label="thumbs up">THUMBS UP</span>`,
|
||||
`<span style="color: red">Hello World</span>`, `<span>Hello World</span>`,
|
||||
`<br>`, ``,
|
||||
`<a href="https://example.com" target="_blank" rel="noopener noreferrer">https://example.com</a>`, `<a href="https://example.com" target="_blank" rel="noopener noreferrer">https://example.com</a>`,
|
||||
`<mark>Important!</mark>`, `Important!`,
|
||||
`<details>Click me! <summary>Nothing to see here.</summary></details>`, `Click me! Nothing to see here.`,
|
||||
`<input type="hidden">`, ``,
|
||||
`<b>I</b> have a <i>strong</i> <strong>opinion</strong> about <em>this</em>.`, `<b>I</b> have a <i>strong</i> <strong>opinion</strong> about <em>this</em>.`,
|
||||
`Provides alternative <code>wg(8)</code> tool`, `Provides alternative <code>wg(8)</code> tool`,
|
||||
}
|
||||
|
||||
for i := 0; i < len(testCases); i += 2 {
|
||||
assert.Equal(t, testCases[i+1], SanitizeDescription(testCases[i]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeNonEscape(t *testing.T) {
|
||||
descStr := "<scrİpt><script>alert(document.domain)</script></scrİpt>"
|
||||
|
||||
|
|
|
@ -7,13 +7,12 @@ import (
|
|||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/minio/sha256-simd"
|
||||
)
|
||||
|
||||
// AesEncrypt encrypts text and given key with AES.
|
||||
|
|
|
@ -5,8 +5,9 @@ package setting
|
|||
|
||||
// Admin settings
|
||||
var Admin struct {
|
||||
DisableRegularOrgCreation bool
|
||||
DefaultEmailNotification string
|
||||
DisableRegularOrgCreation bool
|
||||
DefaultEmailNotification string
|
||||
SendNotificationEmailOnNewUser bool
|
||||
}
|
||||
|
||||
func loadAdminFrom(rootCfg ConfigProvider) {
|
||||
|
|
|
@ -45,6 +45,7 @@ var (
|
|||
ConnMaxLifetime time.Duration
|
||||
IterateBufferSize int
|
||||
AutoMigration bool
|
||||
SlowQueryTreshold time.Duration
|
||||
}{
|
||||
Timeout: 500,
|
||||
IterateBufferSize: 50,
|
||||
|
@ -87,6 +88,7 @@ func loadDBSetting(rootCfg ConfigProvider) {
|
|||
Database.DBConnectRetries = sec.Key("DB_RETRIES").MustInt(10)
|
||||
Database.DBConnectBackoff = sec.Key("DB_RETRY_BACKOFF").MustDuration(3 * time.Second)
|
||||
Database.AutoMigration = sec.Key("AUTO_MIGRATION").MustBool(true)
|
||||
Database.SlowQueryTreshold = sec.Key("SLOW_QUERY_TRESHOLD").MustDuration(5 * time.Second)
|
||||
}
|
||||
|
||||
// DBConnStr returns database connection string
|
||||
|
|
|
@ -68,6 +68,7 @@ var Service = struct {
|
|||
DefaultKeepEmailPrivate bool
|
||||
DefaultAllowCreateOrganization bool
|
||||
DefaultUserIsRestricted bool
|
||||
AllowDotsInUsernames bool
|
||||
EnableTimetracking bool
|
||||
DefaultEnableTimetracking bool
|
||||
DefaultEnableDependencies bool
|
||||
|
@ -180,6 +181,7 @@ func loadServiceFrom(rootCfg ConfigProvider) {
|
|||
Service.DefaultKeepEmailPrivate = sec.Key("DEFAULT_KEEP_EMAIL_PRIVATE").MustBool()
|
||||
Service.DefaultAllowCreateOrganization = sec.Key("DEFAULT_ALLOW_CREATE_ORGANIZATION").MustBool(true)
|
||||
Service.DefaultUserIsRestricted = sec.Key("DEFAULT_USER_IS_RESTRICTED").MustBool(false)
|
||||
Service.AllowDotsInUsernames = sec.Key("ALLOW_DOTS_IN_USERNAMES").MustBool(true)
|
||||
Service.EnableTimetracking = sec.Key("ENABLE_TIMETRACKING").MustBool(true)
|
||||
if Service.EnableTimetracking {
|
||||
Service.DefaultEnableTimetracking = sec.Key("DEFAULT_ENABLE_TIMETRACKING").MustBool(true)
|
||||
|
|
|
@ -7,10 +7,9 @@ import (
|
|||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
|
||||
"github.com/minio/sha256-simd"
|
||||
)
|
||||
|
||||
// GenerateKeyPair generates a public and private keypair
|
||||
|
|
|
@ -7,12 +7,12 @@ import (
|
|||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/minio/sha256-simd"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
|
|
@ -117,13 +117,20 @@ func IsValidExternalTrackerURLFormat(uri string) bool {
|
|||
}
|
||||
|
||||
var (
|
||||
validUsernamePattern = regexp.MustCompile(`^[\da-zA-Z][-.\w]*$`)
|
||||
invalidUsernamePattern = regexp.MustCompile(`[-._]{2,}|[-._]$`) // No consecutive or trailing non-alphanumeric chars
|
||||
validUsernamePatternWithDots = regexp.MustCompile(`^[\da-zA-Z][-.\w]*$`)
|
||||
validUsernamePatternWithoutDots = regexp.MustCompile(`^[\da-zA-Z][-\w]*$`)
|
||||
|
||||
// No consecutive or trailing non-alphanumeric chars, catches both cases
|
||||
invalidUsernamePattern = regexp.MustCompile(`[-._]{2,}|[-._]$`)
|
||||
)
|
||||
|
||||
// IsValidUsername checks if username is valid
|
||||
func IsValidUsername(name string) bool {
|
||||
// It is difficult to find a single pattern that is both readable and effective,
|
||||
// but it's easier to use positive and negative checks.
|
||||
return validUsernamePattern.MatchString(name) && !invalidUsernamePattern.MatchString(name)
|
||||
if setting.Service.AllowDotsInUsernames {
|
||||
return validUsernamePatternWithDots.MatchString(name) && !invalidUsernamePattern.MatchString(name)
|
||||
}
|
||||
|
||||
return validUsernamePatternWithoutDots.MatchString(name) && !invalidUsernamePattern.MatchString(name)
|
||||
}
|
||||
|
|
|
@ -155,7 +155,8 @@ func Test_IsValidExternalTrackerURLFormat(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestIsValidUsername(t *testing.T) {
|
||||
func TestIsValidUsernameAllowDots(t *testing.T) {
|
||||
setting.Service.AllowDotsInUsernames = true
|
||||
tests := []struct {
|
||||
arg string
|
||||
want bool
|
||||
|
@ -185,3 +186,31 @@ func TestIsValidUsername(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidUsernameBanDots(t *testing.T) {
|
||||
setting.Service.AllowDotsInUsernames = false
|
||||
defer func() {
|
||||
setting.Service.AllowDotsInUsernames = true
|
||||
}()
|
||||
|
||||
tests := []struct {
|
||||
arg string
|
||||
want bool
|
||||
}{
|
||||
{arg: "a", want: true},
|
||||
{arg: "abc", want: true},
|
||||
{arg: "0.b-c", want: false},
|
||||
{arg: "a.b-c_d", want: false},
|
||||
{arg: ".abc", want: false},
|
||||
{arg: "abc.", want: false},
|
||||
{arg: "a..bc", want: false},
|
||||
{arg: "a...bc", want: false},
|
||||
{arg: "a.-bc", want: false},
|
||||
{arg: "a._bc", want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.arg, func(t *testing.T) {
|
||||
assert.Equalf(t, tt.want, IsValidUsername(tt.arg), "IsValidUsername[AllowDotsInUsernames=false](%v)", tt.arg)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -147,6 +147,16 @@ func toHandlerProvider(handler any) func(next http.Handler) http.Handler {
|
|||
}
|
||||
}
|
||||
|
||||
if hp, ok := handler.(func(next http.Handler) http.HandlerFunc); ok {
|
||||
return func(next http.Handler) http.Handler {
|
||||
h := hp(next) // this handle could be dynamically generated, so we can't use it for debug info
|
||||
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
|
||||
routing.UpdateFuncInfo(req.Context(), funcInfo)
|
||||
h.ServeHTTP(resp, req)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
provider := func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(respOrig http.ResponseWriter, req *http.Request) {
|
||||
// wrap the response writer to check whether the response has been written
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
"reflect"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/translation"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/modules/validation"
|
||||
|
@ -135,7 +136,11 @@ func Validate(errs binding.Errors, data map[string]any, f Form, l translation.Lo
|
|||
case validation.ErrRegexPattern:
|
||||
data["ErrorMsg"] = trName + l.Tr("form.regex_pattern_error", errs[0].Message)
|
||||
case validation.ErrUsername:
|
||||
data["ErrorMsg"] = trName + l.Tr("form.username_error")
|
||||
if setting.Service.AllowDotsInUsernames {
|
||||
data["ErrorMsg"] = trName + l.Tr("form.username_error")
|
||||
} else {
|
||||
data["ErrorMsg"] = trName + l.Tr("form.username_error_no_dots")
|
||||
}
|
||||
case validation.ErrInvalidGroupTeamMap:
|
||||
data["ErrorMsg"] = trName + l.Tr("form.invalid_group_team_map_error", errs[0].Message)
|
||||
default:
|
||||
|
|
|
@ -295,6 +295,7 @@ default_allow_create_organization = Allow Creation of Organizations by Default
|
|||
default_allow_create_organization_popup = Allow new user accounts to create organizations by default.
|
||||
default_enable_timetracking = Enable Time Tracking by Default
|
||||
default_enable_timetracking_popup = Enable time tracking for new repositories by default.
|
||||
allow_dots_in_usernames = Allow users to use dots in their usernames. Doesn't affect existing accounts.
|
||||
no_reply_address = Hidden Email Domain
|
||||
no_reply_address_helper = Domain name for users with a hidden email address. For example, the username 'joe' will be logged in Git as 'joe@noreply.example.org' if the hidden email domain is set to 'noreply.example.org'.
|
||||
password_algorithm = Password Hash Algorithm
|
||||
|
@ -440,6 +441,10 @@ activate_email = Verify your email address
|
|||
activate_email.title = %s, please verify your email address
|
||||
activate_email.text = Please click the following link to verify your email address within <b>%s</b>:
|
||||
|
||||
admin.new_user.subject = New user %s just signed up
|
||||
admin.new_user.user_info = User Information
|
||||
admin.new_user.text = Please <a href="%s">click here</a> to manage the user from the admin panel.
|
||||
|
||||
register_notify = Welcome to Gitea
|
||||
register_notify.title = %[1]s, welcome to %[2]s
|
||||
register_notify.text_1 = this is your registration confirmation email for %s!
|
||||
|
@ -534,6 +539,7 @@ include_error = ` must contain substring "%s".`
|
|||
glob_pattern_error = ` glob pattern is invalid: %s.`
|
||||
regex_pattern_error = ` regex pattern is invalid: %s.`
|
||||
username_error = ` can only contain alphanumeric chars ('0-9','a-z','A-Z'), dash ('-'), underscore ('_') and dot ('.'). It cannot begin or end with non-alphanumeric chars, and consecutive non-alphanumeric chars are also forbidden.`
|
||||
username_error_no_dots = ` can only contain alphanumeric chars ('0-9','a-z','A-Z'), dash ('-') and underscore ('_'). It cannot begin or end with non-alphanumeric chars, and consecutive non-alphanumeric chars are also forbidden.`
|
||||
invalid_group_team_map_error = ` mapping is invalid: %s`
|
||||
unknown_error = Unknown error:
|
||||
captcha_incorrect = The CAPTCHA code is incorrect.
|
||||
|
@ -1289,6 +1295,8 @@ commits.find = Search
|
|||
commits.search_all = All Branches
|
||||
commits.author = Author
|
||||
commits.message = Message
|
||||
commits.browse_further = Browse further
|
||||
commits.renamed_from = Renamed from %s
|
||||
commits.date = Date
|
||||
commits.older = Older
|
||||
commits.newer = Newer
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
"crypto"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
|
@ -26,8 +27,6 @@ import (
|
|||
chef_module "code.gitea.io/gitea/modules/packages/chef"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/services/auth"
|
||||
|
||||
"github.com/minio/sha256-simd"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
|
@ -6,6 +6,7 @@ package maven
|
|||
import (
|
||||
"crypto/md5"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
|
@ -26,8 +27,6 @@ import (
|
|||
maven_module "code.gitea.io/gitea/modules/packages/maven"
|
||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
|
||||
"github.com/minio/sha256-simd"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
|
@ -55,19 +55,33 @@ func Search(ctx *context.APIContext) {
|
|||
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
|
||||
users, maxResults, err := user_model.SearchUsers(ctx, &user_model.SearchUserOptions{
|
||||
Actor: ctx.Doer,
|
||||
Keyword: ctx.FormTrim("q"),
|
||||
UID: ctx.FormInt64("uid"),
|
||||
Type: user_model.UserTypeIndividual,
|
||||
ListOptions: listOptions,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, map[string]any{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
uid := ctx.FormInt64("uid")
|
||||
var users []*user_model.User
|
||||
var maxResults int64
|
||||
var err error
|
||||
|
||||
switch uid {
|
||||
case user_model.GhostUserID:
|
||||
maxResults = 1
|
||||
users = []*user_model.User{user_model.NewGhostUser()}
|
||||
case user_model.ActionsUserID:
|
||||
maxResults = 1
|
||||
users = []*user_model.User{user_model.NewActionsUser()}
|
||||
default:
|
||||
users, maxResults, err = user_model.SearchUsers(ctx, &user_model.SearchUserOptions{
|
||||
Actor: ctx.Doer,
|
||||
Keyword: ctx.FormTrim("q"),
|
||||
UID: uid,
|
||||
Type: user_model.UserTypeIndividual,
|
||||
ListOptions: listOptions,
|
||||
})
|
||||
return
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, map[string]any{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(maxResults), listOptions.PageSize)
|
||||
|
|
|
@ -360,6 +360,12 @@ func SubmitInstall(ctx *context.Context) {
|
|||
ctx.RenderWithErr(ctx.Tr("form.password_not_match"), tplInstall, form)
|
||||
return
|
||||
}
|
||||
if len(form.AdminPasswd) < setting.MinPasswordLength {
|
||||
ctx.Data["Err_Admin"] = true
|
||||
ctx.Data["Err_AdminPasswd"] = true
|
||||
ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplInstall, form)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Init the engine with migration
|
||||
|
|
|
@ -30,6 +30,7 @@ import (
|
|||
"code.gitea.io/gitea/services/externalaccount"
|
||||
"code.gitea.io/gitea/services/forms"
|
||||
"code.gitea.io/gitea/services/mailer"
|
||||
notify_service "code.gitea.io/gitea/services/notify"
|
||||
|
||||
"github.com/markbates/goth"
|
||||
)
|
||||
|
@ -598,6 +599,7 @@ func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth.
|
|||
}
|
||||
}
|
||||
|
||||
notify_service.NewUserSignUp(ctx, u)
|
||||
// update external user information
|
||||
if gothUser != nil {
|
||||
if err := externalaccount.UpdateExternalUser(ctx, u, *gothUser); err != nil {
|
||||
|
@ -621,7 +623,6 @@ func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth.
|
|||
ctx.Data["Email"] = u.Email
|
||||
ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale)
|
||||
ctx.HTML(http.StatusOK, TplActivate)
|
||||
|
||||
if setting.CacheService.Enabled {
|
||||
if err := ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil {
|
||||
log.Error("Set cache(MailResendLimit) fail: %v", err)
|
||||
|
|
|
@ -21,6 +21,7 @@ import (
|
|||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"github.com/gorilla/feeds"
|
||||
"github.com/jaytaylor/html2text"
|
||||
)
|
||||
|
||||
func toBranchLink(ctx *context.Context, act *activities_model.Action) string {
|
||||
|
@ -239,8 +240,15 @@ func feedActionsToFeedItems(ctx *context.Context, actions activities_model.Actio
|
|||
content = desc
|
||||
}
|
||||
|
||||
// It's a common practice for feed generators to use plain text titles.
|
||||
// See https://codeberg.org/forgejo/forgejo/pulls/1595
|
||||
plainTitle, err := html2text.FromString(title, html2text.Options{OmitLinks: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items = append(items, &feeds.Item{
|
||||
Title: title,
|
||||
Title: plainTitle,
|
||||
Link: link,
|
||||
Description: desc,
|
||||
Author: &feeds.Author{
|
||||
|
|
|
@ -8,11 +8,12 @@ import (
|
|||
)
|
||||
|
||||
// RenderBranchFeed render format for branch or file
|
||||
func RenderBranchFeed(ctx *context.Context) {
|
||||
_, _, showFeedType := GetFeedType(ctx.Params(":reponame"), ctx.Req)
|
||||
if ctx.Repo.TreePath == "" {
|
||||
ShowBranchFeed(ctx, ctx.Repo.Repository, showFeedType)
|
||||
} else {
|
||||
ShowFileFeed(ctx, ctx.Repo.Repository, showFeedType)
|
||||
func RenderBranchFeed(feedType string) func(ctx *context.Context) {
|
||||
return func(ctx *context.Context) {
|
||||
if ctx.Repo.TreePath == "" {
|
||||
ShowBranchFeed(ctx, ctx.Repo.Repository, feedType)
|
||||
} else {
|
||||
ShowFileFeed(ctx, ctx.Repo.Repository, feedType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -239,6 +239,22 @@ func FileHistory(ctx *context.Context) {
|
|||
ctx.ServerError("CommitsByFileAndRange", err)
|
||||
return
|
||||
}
|
||||
oldestCommit := commits[len(commits)-1]
|
||||
|
||||
renamedFiles, err := git.GetCommitFileRenames(ctx, ctx.Repo.GitRepo.Path, oldestCommit.ID.String())
|
||||
if err != nil {
|
||||
ctx.ServerError("GetCommitFileRenames", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, renames := range renamedFiles {
|
||||
if renames[1] == fileName {
|
||||
ctx.Data["OldFilename"] = renames[0]
|
||||
ctx.Data["OldFilenameHistory"] = fmt.Sprintf("%s/commits/commit/%s/%s", ctx.Repo.RepoLink, oldestCommit.ID.String(), renames[0])
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Data["Commits"] = git_model.ConvertFromGitCommit(ctx, commits, ctx.Repo.Repository)
|
||||
|
||||
ctx.Data["Username"] = ctx.Repo.Owner.Name
|
||||
|
|
|
@ -390,7 +390,9 @@ func NewReleasePost(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
if !ctx.Repo.GitRepo.IsBranchExist(form.Target) {
|
||||
// form.Target can be a branch name or a full commitID.
|
||||
if !ctx.Repo.GitRepo.IsBranchExist(form.Target) &&
|
||||
len(form.Target) == git.SHAFullLength && !ctx.Repo.GitRepo.IsCommitExist(form.Target) {
|
||||
ctx.RenderWithErr(ctx.Tr("form.target_branch_not_exist"), tplReleaseNew, &form)
|
||||
return
|
||||
}
|
||||
|
|
|
@ -165,7 +165,7 @@ func renderDirectory(ctx *context.Context, treeLink string) {
|
|||
|
||||
if ctx.Repo.TreePath != "" {
|
||||
ctx.Data["HideRepoInfo"] = true
|
||||
ctx.Data["Title"] = ctx.Tr("repo.file.title", ctx.Repo.Repository.Name+"/"+path.Base(ctx.Repo.TreePath), ctx.Repo.RefName)
|
||||
ctx.Data["Title"] = ctx.Tr("repo.file.title", ctx.Repo.Repository.Name+"/"+ctx.Repo.TreePath, ctx.Repo.RefName)
|
||||
}
|
||||
|
||||
subfolder, readmeFile, err := findReadmeFileInEntries(ctx, entries, true)
|
||||
|
@ -344,7 +344,7 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st
|
|||
}
|
||||
defer dataRc.Close()
|
||||
|
||||
ctx.Data["Title"] = ctx.Tr("repo.file.title", ctx.Repo.Repository.Name+"/"+path.Base(ctx.Repo.TreePath), ctx.Repo.RefName)
|
||||
ctx.Data["Title"] = ctx.Tr("repo.file.title", ctx.Repo.Repository.Name+"/"+ctx.Repo.TreePath, ctx.Repo.RefName)
|
||||
ctx.Data["FileIsSymlink"] = entry.IsLink()
|
||||
ctx.Data["FileName"] = blob.Name()
|
||||
ctx.Data["RawFileLink"] = rawLink + "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
|
||||
|
@ -734,12 +734,19 @@ func Home(ctx *context.Context) {
|
|||
if setting.Other.EnableFeed {
|
||||
isFeed, _, showFeedType := feed.GetFeedType(ctx.Params(":reponame"), ctx.Req)
|
||||
if isFeed {
|
||||
switch {
|
||||
case ctx.Link == fmt.Sprintf("%s.%s", ctx.Repo.RepoLink, showFeedType):
|
||||
if ctx.Link == fmt.Sprintf("%s.%s", ctx.Repo.RepoLink, showFeedType) {
|
||||
feed.ShowRepoFeed(ctx, ctx.Repo.Repository, showFeedType)
|
||||
case ctx.Repo.TreePath == "":
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.Repo.Repository.IsEmpty {
|
||||
ctx.NotFound("MustBeNotEmpty", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.Repo.TreePath == "" {
|
||||
feed.ShowBranchFeed(ctx, ctx.Repo.Repository, showFeedType)
|
||||
case ctx.Repo.TreePath != "":
|
||||
} else {
|
||||
feed.ShowFileFeed(ctx, ctx.Repo.Repository, showFeedType)
|
||||
}
|
||||
return
|
||||
|
|
|
@ -49,17 +49,12 @@ import (
|
|||
_ "code.gitea.io/gitea/modules/session" // to registers all internal adapters
|
||||
|
||||
"gitea.com/go-chi/captcha"
|
||||
"github.com/NYTimes/gziphandler"
|
||||
chi_middleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/klauspost/compress/gzhttp"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
const (
|
||||
// GzipMinSize represents min size to compress for the body size of response
|
||||
GzipMinSize = 1400
|
||||
)
|
||||
|
||||
// CorsHandler return a http handler who set CORS options if enabled by config
|
||||
func CorsHandler() func(next http.Handler) http.Handler {
|
||||
if setting.CORSConfig.Enabled {
|
||||
|
@ -230,11 +225,11 @@ func Routes() *web.Route {
|
|||
var mid []any
|
||||
|
||||
if setting.EnableGzip {
|
||||
h, err := gziphandler.GzipHandlerWithOpts(gziphandler.MinSize(GzipMinSize))
|
||||
wrapper, err := gzhttp.NewWrapper(gzhttp.RandomJitter(32, 0, false))
|
||||
if err != nil {
|
||||
log.Fatal("GzipHandlerWithOpts failed: %v", err)
|
||||
log.Fatal("gzhttp.NewWrapper failed: %v", err)
|
||||
}
|
||||
mid = append(mid, h)
|
||||
mid = append(mid, wrapper)
|
||||
}
|
||||
|
||||
if setting.Service.EnableCaptcha {
|
||||
|
@ -1478,8 +1473,8 @@ func registerRoutes(m *web.Route) {
|
|||
m.Get("/cherry-pick/{sha:([a-f0-9]{7,40})$}", repo.SetEditorconfigIfExists, repo.CherryPick)
|
||||
}, repo.MustBeNotEmpty, context.RepoRef(), reqRepoCodeReader)
|
||||
|
||||
m.Get("/rss/branch/*", context.RepoRefByType(context.RepoRefBranch), feedEnabled, feed.RenderBranchFeed)
|
||||
m.Get("/atom/branch/*", context.RepoRefByType(context.RepoRefBranch), feedEnabled, feed.RenderBranchFeed)
|
||||
m.Get("/rss/branch/*", repo.MustBeNotEmpty, context.RepoRefByType(context.RepoRefBranch), feedEnabled, feed.RenderBranchFeed("rss"))
|
||||
m.Get("/atom/branch/*", repo.MustBeNotEmpty, context.RepoRefByType(context.RepoRefBranch), feedEnabled, feed.RenderBranchFeed("atom"))
|
||||
|
||||
m.Group("/src", func() {
|
||||
m.Get("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.Home)
|
||||
|
|
|
@ -365,7 +365,7 @@ func (f *EditVariableForm) Validate(req *http.Request, errs binding.Errors) bind
|
|||
|
||||
// NewAccessTokenForm form for creating access token
|
||||
type NewAccessTokenForm struct {
|
||||
Name string `binding:"Required;MaxSize(255)"`
|
||||
Name string `binding:"Required;MaxSize(255)" locale:"settings.token_name"`
|
||||
Scope []string
|
||||
}
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ package lfs
|
|||
|
||||
import (
|
||||
stdCtx "context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
|
@ -33,7 +34,6 @@ import (
|
|||
"code.gitea.io/gitea/modules/storage"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/minio/sha256-simd"
|
||||
)
|
||||
|
||||
// requestContext contain variables from the HTTP request.
|
||||
|
|
80
services/mailer/mail_admin_new_user.go
Normal file
80
services/mailer/mail_admin_new_user.go
Normal file
|
@ -0,0 +1,80 @@
|
|||
// Copyright 2023 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/base"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/templates"
|
||||
"code.gitea.io/gitea/modules/translation"
|
||||
)
|
||||
|
||||
const (
|
||||
tplNewUserMail base.TplName = "notify/admin_new_user"
|
||||
)
|
||||
|
||||
var sa = SendAsync
|
||||
|
||||
// MailNewUser sends notification emails on new user registrations to all admins
|
||||
func MailNewUser(ctx context.Context, u *user_model.User) {
|
||||
if !setting.Admin.SendNotificationEmailOnNewUser {
|
||||
return
|
||||
}
|
||||
|
||||
if setting.MailService == nil {
|
||||
// No mail service configured
|
||||
return
|
||||
}
|
||||
|
||||
recipients, err := user_model.GetAllAdmins(ctx)
|
||||
if err != nil {
|
||||
log.Error("user_model.GetAllAdmins: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
langMap := make(map[string][]string)
|
||||
for _, r := range recipients {
|
||||
langMap[r.Language] = append(langMap[r.Language], r.Email)
|
||||
}
|
||||
|
||||
for lang, tos := range langMap {
|
||||
mailNewUser(ctx, u, lang, tos)
|
||||
}
|
||||
}
|
||||
|
||||
func mailNewUser(ctx context.Context, u *user_model.User, lang string, tos []string) {
|
||||
locale := translation.NewLocale(lang)
|
||||
|
||||
subject := locale.Tr("mail.admin.new_user.subject", u.Name)
|
||||
manageUserURL := setting.AppSubURL + "/admin/users/" + strconv.FormatInt(u.ID, 10)
|
||||
body := locale.Tr("mail.admin.new_user.text", manageUserURL)
|
||||
mailMeta := map[string]any{
|
||||
"NewUser": u,
|
||||
"Subject": subject,
|
||||
"Body": body,
|
||||
"Language": locale.Language(),
|
||||
"locale": locale,
|
||||
"Str2html": templates.Str2html,
|
||||
}
|
||||
|
||||
var mailBody bytes.Buffer
|
||||
|
||||
if err := bodyTemplates.ExecuteTemplate(&mailBody, string(tplNewUserMail), mailMeta); err != nil {
|
||||
log.Error("ExecuteTemplate [%s]: %v", string(tplNewUserMail)+"/body", err)
|
||||
return
|
||||
}
|
||||
|
||||
msgs := make([]*Message, 0, len(tos))
|
||||
for _, to := range tos {
|
||||
msg := NewMessage(to, subject, mailBody.String())
|
||||
msg.Info = subject
|
||||
msgs = append(msgs, msg)
|
||||
}
|
||||
sa(msgs...)
|
||||
}
|
88
services/mailer/mail_admin_new_user_test.go
Normal file
88
services/mailer/mail_admin_new_user_test.go
Normal file
|
@ -0,0 +1,88 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func getTestUsers() []*user_model.User {
|
||||
admin := new(user_model.User)
|
||||
admin.Name = "admin"
|
||||
admin.IsAdmin = true
|
||||
admin.Language = "en_US"
|
||||
admin.Email = "admin@example.com"
|
||||
|
||||
newUser := new(user_model.User)
|
||||
newUser.Name = "new_user"
|
||||
newUser.Language = "en_US"
|
||||
newUser.IsAdmin = false
|
||||
newUser.Email = "new_user@example.com"
|
||||
newUser.LastLoginUnix = 1693648327
|
||||
newUser.CreatedUnix = 1693648027
|
||||
|
||||
user_model.CreateUser(db.DefaultContext, admin)
|
||||
user_model.CreateUser(db.DefaultContext, newUser)
|
||||
|
||||
users := make([]*user_model.User, 0)
|
||||
users = append(users, admin)
|
||||
users = append(users, newUser)
|
||||
|
||||
return users
|
||||
}
|
||||
|
||||
func cleanUpUsers(ctx context.Context, users []*user_model.User) {
|
||||
for _, u := range users {
|
||||
db.DeleteByID(ctx, u.ID, new(user_model.User))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminNotificationMail_test(t *testing.T) {
|
||||
mailService := setting.Mailer{
|
||||
From: "test@example.com",
|
||||
Protocol: "dummy",
|
||||
}
|
||||
|
||||
setting.MailService = &mailService
|
||||
setting.Domain = "localhost"
|
||||
setting.AppSubURL = "http://localhost"
|
||||
|
||||
// test with SEND_NOTIFICATION_EMAIL_ON_NEW_USER enabled
|
||||
setting.Admin.SendNotificationEmailOnNewUser = true
|
||||
|
||||
ctx := context.Background()
|
||||
NewContext(ctx)
|
||||
|
||||
users := getTestUsers()
|
||||
oldSendAsync := sa
|
||||
defer func() {
|
||||
sa = oldSendAsync
|
||||
cleanUpUsers(ctx, users)
|
||||
}()
|
||||
|
||||
sa = func(msgs ...*Message) {
|
||||
assert.Equal(t, len(msgs), 1, "Test provides only one admin user, so only one email must be sent")
|
||||
assert.Equal(t, msgs[0].To, users[0].Email, "checks if the recipient is the admin of the instance")
|
||||
manageUserURL := "/admin/users/" + strconv.FormatInt(users[1].ID, 10)
|
||||
assert.True(t, strings.ContainsAny(msgs[0].Body, manageUserURL), "checks if the message contains the link to manage the newly created user from the admin panel")
|
||||
}
|
||||
MailNewUser(ctx, users[1])
|
||||
|
||||
// test with SEND_NOTIFICATION_EMAIL_ON_NEW_USER disabled; emails shouldn't be sent
|
||||
setting.Admin.SendNotificationEmailOnNewUser = false
|
||||
sa = func(msgs ...*Message) {
|
||||
assert.Equal(t, 1, 0, "this shouldn't execute. MailNewUser must exit early since SEND_NOTIFICATION_EMAIL_ON_NEW_USER is disabled")
|
||||
}
|
||||
|
||||
MailNewUser(ctx, users[1])
|
||||
}
|
|
@ -202,3 +202,7 @@ func (m *mailNotifier) RepoPendingTransfer(ctx context.Context, doer, newOwner *
|
|||
log.Error("SendRepoTransferNotifyMail: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mailNotifier) NewUserSignUp(ctx context.Context, newUser *user_model.User) {
|
||||
MailNewUser(ctx, newUser)
|
||||
}
|
||||
|
|
|
@ -6,14 +6,13 @@ package token
|
|||
import (
|
||||
"context"
|
||||
crypto_hmac "crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base32"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"github.com/minio/sha256-simd"
|
||||
)
|
||||
|
||||
// A token is a verifiable container describing an action.
|
||||
|
|
|
@ -859,6 +859,11 @@ func (g *GiteaLocalUploader) CreateReviews(reviews ...*base.Review) error {
|
|||
}
|
||||
|
||||
for _, comment := range review.Comments {
|
||||
// Skip code comment if it doesn't have a diff it is commeting on.
|
||||
if comment.DiffHunk == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
line := comment.Line
|
||||
if line != 0 {
|
||||
comment.Position = 1
|
||||
|
|
|
@ -59,6 +59,8 @@ type Notifier interface {
|
|||
EditWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, comment string)
|
||||
DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page string)
|
||||
|
||||
NewUserSignUp(ctx context.Context, newUser *user_model.User)
|
||||
|
||||
NewRelease(ctx context.Context, rel *repo_model.Release)
|
||||
UpdateRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release)
|
||||
DeleteRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release)
|
||||
|
|
|
@ -347,6 +347,13 @@ func RepoPendingTransfer(ctx context.Context, doer, newOwner *user_model.User, r
|
|||
}
|
||||
}
|
||||
|
||||
// NewUserSignUp notifies about a newly signed up user to notifiers
|
||||
func NewUserSignUp(ctx context.Context, newUser *user_model.User) {
|
||||
for _, notifier := range notifiers {
|
||||
notifier.NewUserSignUp(ctx, newUser)
|
||||
}
|
||||
}
|
||||
|
||||
// PackageCreate notifies creation of a package to notifiers
|
||||
func PackageCreate(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) {
|
||||
for _, notifier := range notifiers {
|
||||
|
|
|
@ -197,6 +197,9 @@ func (*NullNotifier) SyncDeleteRef(ctx context.Context, doer *user_model.User, r
|
|||
func (*NullNotifier) RepoPendingTransfer(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) {
|
||||
}
|
||||
|
||||
func (*NullNotifier) NewUserSignUp(ctx context.Context, newUser *user_model.User) {
|
||||
}
|
||||
|
||||
// PackageCreate places a place holder function
|
||||
func (*NullNotifier) PackageCreate(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) {
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import (
|
|||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
@ -29,7 +30,6 @@ import (
|
|||
webhook_module "code.gitea.io/gitea/modules/webhook"
|
||||
|
||||
"github.com/gobwas/glob"
|
||||
"github.com/minio/sha256-simd"
|
||||
)
|
||||
|
||||
// Deliver deliver hook task
|
||||
|
|
|
@ -159,6 +159,8 @@
|
|||
<dd>{{if .Service.DefaultKeepEmailPrivate}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}</dd>
|
||||
<dt>{{ctx.Locale.Tr "admin.config.default_allow_create_organization"}}</dt>
|
||||
<dd>{{if .Service.DefaultAllowCreateOrganization}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}</dd>
|
||||
<dt>{{ctx.Locale.Tr "admin.config.allow_dots_in_usernames"}}</dt>
|
||||
<dd>{{if .Service.AllowDotsInUsernames}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}</dd>
|
||||
<dt>{{ctx.Locale.Tr "admin.config.enable_timetracking"}}</dt>
|
||||
<dd>{{if .Service.EnableTimetracking}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}</dd>
|
||||
{{if .Service.EnableTimetracking}}
|
||||
|
|
|
@ -2,7 +2,8 @@
|
|||
<html lang="{{ctx.Locale.Lang}}" data-theme="{{ThemeName .SignedUser}}">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{if .Title}}{{.Title | RenderEmojiPlain}} - {{end}}{{if .Repository.Name}}{{.Repository.Name}} - {{end}}{{AppName}}</title>
|
||||
{{/* Display `- .Repsository.FullName` only if `.Title` does not already start with that. */}}
|
||||
<title>{{if .Title}}{{.Title | RenderEmojiPlain}} - {{end}}{{if and (.Repository.Name) (not (StringUtils.HasPrefix .Title .Repository.FullName))}}{{.Repository.FullName}} - {{end}}{{AppName}}</title>
|
||||
{{if .ManifestData}}<link rel="manifest" href="data:{{.ManifestData}}">{{end}}
|
||||
<meta name="author" content="{{if .Repository}}{{.Owner.Name}}{{else}}{{MetaAuthor}}{{end}}">
|
||||
<meta name="description" content="{{if .Repository}}{{.Repository.Name}}{{if .Repository.Description}} - {{.Repository.Description}}{{end}}{{else}}{{MetaDescription}}{{end}}">
|
||||
|
|
22
templates/mail/notify/admin_new_user.tmpl
Normal file
22
templates/mail/notify/admin_new_user.tmpl
Normal file
|
@ -0,0 +1,22 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<title>{{.Subject}}</title>
|
||||
|
||||
<style>
|
||||
blockquote { padding-left: 1em; margin: 1em 0; border-left: 1px solid grey; color: #777}
|
||||
.footer { font-size:small; color:#666;}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<ul>
|
||||
<h3>{{ctx.Locale.Tr "mail.admin.new_user.user_info"}}</h3>
|
||||
<li>{{ctx.Locale.Tr "admin.users.created"}}: {{DateTime "full" .NewUser.LastLoginUnix}}</li>
|
||||
<li>{{ctx.Locale.Tr "admin.users.last_login"}}: {{DateTime "full" .NewUser.CreatedUnix}}</li>
|
||||
</ul>
|
||||
<p> {{.Body | Str2html}} </p>
|
||||
</body>
|
||||
</html>
|
|
@ -7,7 +7,7 @@
|
|||
<div class="markup"><pre class="code-block"><code><repositories>
|
||||
<repository>
|
||||
<id>gitea</id>
|
||||
<url><gitea-origin-url data-url="{{AppSubUrl}}/api/packages/{{.PackageDescriptor.Owner.Name}}/maven"></gitea-origin-url></url>
|
||||
<url><gitea-origin-url data-url="{{AppSubUrl}}/api/packages/{{.PackageDescriptor.Owner.Name}}/maven"></gitea-origin-url></url>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
|
|
|
@ -13,6 +13,11 @@
|
|||
</div>
|
||||
</div>
|
||||
{{template "repo/commits_table" .}}
|
||||
{{if .OldFilename}}
|
||||
<div class="ui bottom attached header">
|
||||
<span>{{ctx.Locale.Tr "repo.commits.renamed_from" .OldFilename}} (<a href="{{.OldFilenameHistory}}">{{ctx.Locale.Tr "repo.commits.browse_further"}}</a>)</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
</div>
|
||||
{{if .MilestoneID}}
|
||||
<div class="meta gt-my-2">
|
||||
<a class="milestone" href="{{$.Page.RepoLink}}/milestone/{{.MilestoneID}}">
|
||||
<a class="milestone" href="{{.Repo.Link}}/milestone/{{.MilestoneID}}">
|
||||
{{svg "octicon-milestone" 16 "gt-mr-2 gt-vm"}}
|
||||
<span class="gt-vm">{{.Milestone.Name}}</span>
|
||||
</a>
|
||||
|
@ -42,7 +42,7 @@
|
|||
{{if $.Page.LinkedPRs}}
|
||||
{{range index $.Page.LinkedPRs .ID}}
|
||||
<div class="meta gt-my-2">
|
||||
<a href="{{$.Page.RepoLink}}/pulls/{{.Index}}">
|
||||
<a href="{{$.Issue.Repo.Link}}/pulls/{{.Index}}">
|
||||
<span class="gt-m-0 text {{if .PullRequest.HasMerged}}purple{{else if .IsClosed}}red{{else}}green{{end}}">{{svg "octicon-git-merge" 16 "gt-mr-2 gt-vm"}}</span>
|
||||
<span class="gt-vm">{{.Title}} <span class="text light grey">#{{.Index}}</span></span>
|
||||
</a>
|
||||
|
@ -61,7 +61,7 @@
|
|||
{{if or .Labels .Assignees}}
|
||||
<div class="extra content labels-list gt-p-0 gt-pt-2">
|
||||
{{range .Labels}}
|
||||
<a target="_blank" href="{{$.Page.RepoLink}}/issues?labels={{.ID}}">{{RenderLabel ctx .}}</a>
|
||||
<a target="_blank" href="{{$.Issue.Repo.Link}}/issues?labels={{.ID}}">{{RenderLabel ctx .}}</a>
|
||||
{{end}}
|
||||
<div class="right floated">
|
||||
{{range .Assignees}}
|
||||
|
|
|
@ -976,20 +976,23 @@
|
|||
{{end}}
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>
|
||||
<div class="ui warning message">
|
||||
{{if .Repository.IsArchived}}
|
||||
{{ctx.Locale.Tr "repo.settings.unarchive.text"}}
|
||||
{{else}}
|
||||
{{ctx.Locale.Tr "repo.settings.archive.text"}}
|
||||
{{end}}
|
||||
</p>
|
||||
</div>
|
||||
<form action="{{.Link}}" method="post">
|
||||
{{.CsrfTokenHtml}}
|
||||
<input type="hidden" name="action" value="{{if .Repository.IsArchived}}unarchive{{else}}archive{{end}}">
|
||||
<input type="hidden" name="repo_id" value="{{.Repository.ID}}">
|
||||
{{template "base/modal_actions_confirm" .}}
|
||||
</div>
|
||||
<form action="{{.Link}}" method="post">
|
||||
{{.CsrfTokenHtml}}
|
||||
<input type="hidden" name="action" value="{{if .Repository.IsArchived}}unarchive{{else}}archive{{end}}">
|
||||
<input type="hidden" name="repo_id" value="{{.Repository.ID}}">
|
||||
<div class="text right actions">
|
||||
<button class="ui cancel button">{{ctx.Locale.Tr "settings.cancel"}}</button>
|
||||
<button class="ui red button">{{ctx.Locale.Tr "repo.settings.archive.button"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
|
1
tests/gitea-repositories-meta/user2/repo59.git/HEAD
Normal file
1
tests/gitea-repositories-meta/user2/repo59.git/HEAD
Normal file
|
@ -0,0 +1 @@
|
|||
ref: refs/heads/master
|
4
tests/gitea-repositories-meta/user2/repo59.git/config
Normal file
4
tests/gitea-repositories-meta/user2/repo59.git/config
Normal file
|
@ -0,0 +1,4 @@
|
|||
[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = true
|
|
@ -0,0 +1 @@
|
|||
Unnamed repository; edit this file 'description' to name the repository.
|
|
@ -0,0 +1,6 @@
|
|||
# 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]
|
||||
# *~
|
Binary file not shown.
|
@ -0,0 +1,2 @@
|
|||
P pack-6dd3a6fe138f1d77e14c2e6b8e6c41e5ae242adf.pack
|
||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,4 @@
|
|||
# pack-refs with: peeled fully-peeled sorted
|
||||
d8f53dfb33f6ccf4169c34970b5e747511c18beb refs/heads/cake-recipe
|
||||
80b83c5c8220c3aa3906e081f202a2a7563ec879 refs/heads/master
|
||||
d8f53dfb33f6ccf4169c34970b5e747511c18beb refs/tags/v1.0
|
|
@ -288,6 +288,43 @@ func TestAPIGetComment(t *testing.T) {
|
|||
assert.Equal(t, expect.Created.Unix(), apiComment.Created.Unix())
|
||||
}
|
||||
|
||||
func TestAPIGetSystemUserComment(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{})
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
|
||||
repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
|
||||
for _, systemUser := range []*user_model.User{
|
||||
user_model.NewGhostUser(),
|
||||
user_model.NewActionsUser(),
|
||||
} {
|
||||
body := fmt.Sprintf("Hello %s", systemUser.Name)
|
||||
comment, err := issues_model.CreateComment(db.DefaultContext, &issues_model.CreateCommentOptions{
|
||||
Type: issues_model.CommentTypeComment,
|
||||
Doer: systemUser,
|
||||
Repo: repo,
|
||||
Issue: issue,
|
||||
Content: body,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/comments/%d", repoOwner.Name, repo.Name, comment.ID)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
var apiComment api.Comment
|
||||
DecodeJSON(t, resp, &apiComment)
|
||||
|
||||
if assert.NotNil(t, apiComment.Poster) {
|
||||
if assert.Equal(t, systemUser.ID, apiComment.Poster.ID) {
|
||||
assert.NoError(t, comment.LoadPoster(db.DefaultContext))
|
||||
assert.Equal(t, systemUser.Name, apiComment.Poster.UserName)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, body, apiComment.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIEditComment(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
const newCommentBody = "This is the new comment body"
|
||||
|
|
40
tests/integration/api_feed_plain_text_titles_test.go
Normal file
40
tests/integration/api_feed_plain_text_titles_test.go
Normal file
|
@ -0,0 +1,40 @@
|
|||
// Copyright 2023 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFeedPlainTextTitles(t *testing.T) {
|
||||
// This test verifies that items' titles in feeds are generated as plain text.
|
||||
// See https://codeberg.org/forgejo/forgejo/pulls/1595
|
||||
|
||||
t.Run("Feed plain text titles", func(t *testing.T) {
|
||||
t.Run("Atom", func(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
req := NewRequest(t, "GET", "/user2/repo1.atom")
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
data := resp.Body.String()
|
||||
assert.Contains(t, data, "<title>the_1-user.with.all.allowedChars closed issue user2/repo1#4</title>")
|
||||
})
|
||||
|
||||
t.Run("RSS", func(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
req := NewRequest(t, "GET", "/user2/repo1.rss")
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
data := resp.Body.String()
|
||||
assert.Contains(t, data, "<title>the_1-user.with.all.allowedChars closed issue user2/repo1#4</title>")
|
||||
})
|
||||
})
|
||||
}
|
|
@ -7,15 +7,19 @@ import (
|
|||
"net/http"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFeed(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
t.Run("User", func(t *testing.T) {
|
||||
t.Run("Atom", func(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
req := NewRequest(t, "GET", "/user2.atom")
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
@ -25,7 +29,7 @@ func TestFeed(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("RSS", func(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
req := NewRequest(t, "GET", "/user2.rss")
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
@ -34,4 +38,51 @@ func TestFeed(t *testing.T) {
|
|||
assert.Contains(t, data, `<rss version="2.0"`)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Repo", func(t *testing.T) {
|
||||
t.Run("Normal", func(t *testing.T) {
|
||||
t.Run("Atom", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
req := NewRequest(t, "GET", "/user2/repo1/atom/branch/master")
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
data := resp.Body.String()
|
||||
assert.Contains(t, data, `<feed xmlns="http://www.w3.org/2005/Atom"`)
|
||||
})
|
||||
t.Run("RSS", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
req := NewRequest(t, "GET", "/user2/repo1/rss/branch/master")
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
data := resp.Body.String()
|
||||
assert.Contains(t, data, `<rss version="2.0"`)
|
||||
})
|
||||
})
|
||||
t.Run("Empty", func(t *testing.T) {
|
||||
err := user_model.UpdateUserCols(db.DefaultContext, &user_model.User{ID: 30, ProhibitLogin: false}, "prohibit_login")
|
||||
assert.NoError(t, err)
|
||||
|
||||
session := loginUser(t, "user30")
|
||||
t.Run("Atom", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
req := NewRequest(t, "GET", "/user30/empty/atom/branch/master")
|
||||
session.MakeRequest(t, req, http.StatusNotFound)
|
||||
|
||||
req = NewRequest(t, "GET", "/user30/empty.atom/src/branch/master")
|
||||
session.MakeRequest(t, req, http.StatusNotFound)
|
||||
})
|
||||
t.Run("RSS", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
req := NewRequest(t, "GET", "/user30/empty/rss/branch/master")
|
||||
session.MakeRequest(t, req, http.StatusNotFound)
|
||||
|
||||
req = NewRequest(t, "GET", "/user30/empty.rss/src/branch/master")
|
||||
session.MakeRequest(t, req, http.StatusNotFound)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
|
@ -33,7 +34,6 @@ import (
|
|||
chef_router "code.gitea.io/gitea/routers/api/packages/chef"
|
||||
"code.gitea.io/gitea/tests"
|
||||
|
||||
"github.com/minio/sha256-simd"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ package integration
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
@ -23,7 +24,6 @@ import (
|
|||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/tests"
|
||||
|
||||
"github.com/minio/sha256-simd"
|
||||
oci "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
|
|
@ -5,6 +5,7 @@ package integration
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
@ -24,7 +25,6 @@ import (
|
|||
packages_cleanup_service "code.gitea.io/gitea/services/packages/cleanup"
|
||||
"code.gitea.io/gitea/tests"
|
||||
|
||||
"github.com/minio/sha256-simd"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
|
|
@ -93,9 +93,9 @@ func TestAPISearchRepo(t *testing.T) {
|
|||
}{
|
||||
{
|
||||
name: "RepositoriesMax50", requestURL: "/api/v1/repos/search?limit=50&private=false", expectedResults: expectedResults{
|
||||
nil: {count: 33},
|
||||
user: {count: 33},
|
||||
user2: {count: 33},
|
||||
nil: {count: 34},
|
||||
user: {count: 34},
|
||||
user2: {count: 34},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -56,6 +56,28 @@ func TestAPIUserSearchNotLoggedIn(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAPIUserSearchSystemUsers(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
for _, systemUser := range []*user_model.User{
|
||||
user_model.NewGhostUser(),
|
||||
user_model.NewActionsUser(),
|
||||
} {
|
||||
t.Run(systemUser.Name, func(t *testing.T) {
|
||||
req := NewRequestf(t, "GET", "/api/v1/users/search?uid=%d", systemUser.ID)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
var results SearchResults
|
||||
DecodeJSON(t, resp, &results)
|
||||
assert.NotEmpty(t, results.Data)
|
||||
if assert.EqualValues(t, 1, len(results.Data)) {
|
||||
user := results.Data[0]
|
||||
assert.EqualValues(t, user.UserName, systemUser.Name)
|
||||
assert.EqualValues(t, user.ID, systemUser.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIUserSearchAdminLoggedInUserHidden(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
adminUsername := "user1"
|
||||
|
|
|
@ -42,8 +42,8 @@ import (
|
|||
"github.com/markbates/goth"
|
||||
"github.com/markbates/goth/gothic"
|
||||
goth_gitlab "github.com/markbates/goth/providers/gitlab"
|
||||
"github.com/santhosh-tekuri/jsonschema/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/xeipuuv/gojsonschema"
|
||||
)
|
||||
|
||||
var testWebRoutes *web.Route
|
||||
|
@ -511,16 +511,15 @@ func VerifyJSONSchema(t testing.TB, resp *httptest.ResponseRecorder, schemaFile
|
|||
_, schemaFileErr := os.Stat(schemaFilePath)
|
||||
assert.Nil(t, schemaFileErr)
|
||||
|
||||
schema, schemaFileReadErr := os.ReadFile(schemaFilePath)
|
||||
assert.Nil(t, schemaFileReadErr)
|
||||
assert.True(t, len(schema) > 0)
|
||||
schema, err := jsonschema.Compile(schemaFilePath)
|
||||
assert.NoError(t, err)
|
||||
|
||||
nodeinfoSchema := gojsonschema.NewStringLoader(string(schema))
|
||||
nodeinfoString := gojsonschema.NewStringLoader(resp.Body.String())
|
||||
result, schemaValidationErr := gojsonschema.Validate(nodeinfoSchema, nodeinfoString)
|
||||
assert.Nil(t, schemaValidationErr)
|
||||
assert.Empty(t, result.Errors())
|
||||
assert.True(t, result.Valid())
|
||||
var data interface{}
|
||||
err = json.Unmarshal(resp.Body.Bytes(), &data)
|
||||
assert.NoError(t, err)
|
||||
|
||||
schemaValidation := schema.Validate(data)
|
||||
assert.Nil(t, schemaValidation)
|
||||
}
|
||||
|
||||
func GetCSRF(t testing.TB, session *TestSession, urlStr string) string {
|
||||
|
@ -530,3 +529,18 @@ func GetCSRF(t testing.TB, session *TestSession, urlStr string) string {
|
|||
doc := NewHTMLParser(t, resp.Body)
|
||||
return doc.GetCSRF()
|
||||
}
|
||||
|
||||
func GetHTMLTitle(t testing.TB, session *TestSession, urlStr string) string {
|
||||
t.Helper()
|
||||
|
||||
req := NewRequest(t, "GET", urlStr)
|
||||
var resp *httptest.ResponseRecorder
|
||||
if session == nil {
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
} else {
|
||||
resp = session.MakeRequest(t, req, http.StatusOK)
|
||||
}
|
||||
|
||||
doc := NewHTMLParser(t, resp.Body)
|
||||
return doc.Find("head title").Text()
|
||||
}
|
||||
|
|
|
@ -18,9 +18,9 @@ import (
|
|||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/lfs"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/routers/web"
|
||||
"code.gitea.io/gitea/tests"
|
||||
|
||||
"github.com/klauspost/compress/gzhttp"
|
||||
gzipp "github.com/klauspost/compress/gzip"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
@ -132,7 +132,7 @@ func TestGetLFSSmallTokenFail(t *testing.T) {
|
|||
|
||||
func TestGetLFSLarge(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
content := make([]byte, web.GzipMinSize*10)
|
||||
content := make([]byte, gzhttp.DefaultMinSize*10)
|
||||
for i := range content {
|
||||
content[i] = byte(i % 256)
|
||||
}
|
||||
|
@ -143,7 +143,7 @@ func TestGetLFSLarge(t *testing.T) {
|
|||
|
||||
func TestGetLFSGzip(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
b := make([]byte, web.GzipMinSize*10)
|
||||
b := make([]byte, gzhttp.DefaultMinSize*10)
|
||||
for i := range b {
|
||||
b[i] = byte(i % 256)
|
||||
}
|
||||
|
@ -159,7 +159,7 @@ func TestGetLFSGzip(t *testing.T) {
|
|||
|
||||
func TestGetLFSZip(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
b := make([]byte, web.GzipMinSize*10)
|
||||
b := make([]byte, gzhttp.DefaultMinSize*10)
|
||||
for i := range b {
|
||||
b[i] = byte(i % 256)
|
||||
}
|
||||
|
|
|
@ -21,6 +21,10 @@ import (
|
|||
)
|
||||
|
||||
func createNewRelease(t *testing.T, session *TestSession, repoURL, tag, title string, preRelease, draft bool) {
|
||||
createNewReleaseTarget(t, session, repoURL, tag, title, "master", preRelease, draft)
|
||||
}
|
||||
|
||||
func createNewReleaseTarget(t *testing.T, session *TestSession, repoURL, tag, title, target string, preRelease, draft bool) {
|
||||
req := NewRequest(t, "GET", repoURL+"/releases/new")
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
htmlDoc := NewHTMLParser(t, resp.Body)
|
||||
|
@ -31,7 +35,7 @@ func createNewRelease(t *testing.T, session *TestSession, repoURL, tag, title st
|
|||
postData := map[string]string{
|
||||
"_csrf": htmlDoc.GetCSRF(),
|
||||
"tag_name": tag,
|
||||
"tag_target": "master",
|
||||
"tag_target": target,
|
||||
"title": title,
|
||||
"content": "",
|
||||
}
|
||||
|
@ -217,6 +221,15 @@ func TestViewReleaseListLogin(t *testing.T) {
|
|||
}, links)
|
||||
}
|
||||
|
||||
func TestReleaseOnCommit(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
session := loginUser(t, "user2")
|
||||
createNewReleaseTarget(t, session, "/user2/repo1", "v0.0.1", "v0.0.1", "65f1bf27bc3bf70f64657658635e66094edbcb4d", false, false)
|
||||
|
||||
checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", translation.NewLocale("en-US").Tr("repo.release.stable"), 4)
|
||||
}
|
||||
|
||||
func TestViewTagsList(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
|
|
|
@ -15,8 +15,8 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func testRepoMigrate(t testing.TB, session *TestSession, cloneAddr, repoName string) *httptest.ResponseRecorder {
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("/repo/migrate?service_type=%d", structs.PlainGitService)) // render plain git migration page
|
||||
func testRepoMigrate(t testing.TB, session *TestSession, cloneAddr, repoName string, service structs.GitServiceType) *httptest.ResponseRecorder {
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("/repo/migrate?service_type=%d", service)) // render plain git migration page
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
htmlDoc := NewHTMLParser(t, resp.Body)
|
||||
|
||||
|
@ -31,7 +31,7 @@ func testRepoMigrate(t testing.TB, session *TestSession, cloneAddr, repoName str
|
|||
"clone_addr": cloneAddr,
|
||||
"uid": uid,
|
||||
"repo_name": repoName,
|
||||
"service": fmt.Sprintf("%d", structs.PlainGitService),
|
||||
"service": fmt.Sprintf("%d", service),
|
||||
})
|
||||
resp = session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
|
||||
|
@ -41,5 +41,17 @@ func testRepoMigrate(t testing.TB, session *TestSession, cloneAddr, repoName str
|
|||
func TestRepoMigrate(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
session := loginUser(t, "user2")
|
||||
testRepoMigrate(t, session, "https://github.com/go-gitea/test_repo.git", "git")
|
||||
for _, s := range []struct {
|
||||
testName string
|
||||
cloneAddr string
|
||||
repoName string
|
||||
service structs.GitServiceType
|
||||
}{
|
||||
{"TestMigrateGithub", "https://github.com/go-gitea/test_repo.git", "git", structs.PlainGitService},
|
||||
{"TestMigrateGithub", "https://github.com/go-gitea/test_repo.git", "github", structs.GithubService},
|
||||
} {
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
testRepoMigrate(t, session, s.cloneAddr, s.repoName, s.service)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -203,6 +203,110 @@ func TestViewAsRepoAdmin(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRepoHTMLTitle(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
t.Run("Repository homepage", func(t *testing.T) {
|
||||
t.Run("Without description", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
htmlTitle := GetHTMLTitle(t, nil, "/user2/repo1")
|
||||
assert.EqualValues(t, "user2/repo1 - Gitea: Git with a cup of tea", htmlTitle)
|
||||
})
|
||||
t.Run("With description", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
htmlTitle := GetHTMLTitle(t, nil, "/user27/repo49")
|
||||
assert.EqualValues(t, "user27/repo49: A wonderful repository with more than just a README.md - Gitea: Git with a cup of tea", htmlTitle)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Code view", func(t *testing.T) {
|
||||
t.Run("Directory", func(t *testing.T) {
|
||||
t.Run("Default branch", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
htmlTitle := GetHTMLTitle(t, nil, "/user2/repo59/src/branch/master/deep/nesting")
|
||||
assert.EqualValues(t, "repo59/deep/nesting at master - user2/repo59 - Gitea: Git with a cup of tea", htmlTitle)
|
||||
})
|
||||
t.Run("Non-default branch", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
htmlTitle := GetHTMLTitle(t, nil, "/user2/repo59/src/branch/cake-recipe/deep/nesting")
|
||||
assert.EqualValues(t, "repo59/deep/nesting at cake-recipe - user2/repo59 - Gitea: Git with a cup of tea", htmlTitle)
|
||||
})
|
||||
t.Run("Commit", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
htmlTitle := GetHTMLTitle(t, nil, "/user2/repo59/src/commit/d8f53dfb33f6ccf4169c34970b5e747511c18beb/deep/nesting/")
|
||||
assert.EqualValues(t, "repo59/deep/nesting at d8f53dfb33f6ccf4169c34970b5e747511c18beb - user2/repo59 - Gitea: Git with a cup of tea", htmlTitle)
|
||||
})
|
||||
t.Run("Tag", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
htmlTitle := GetHTMLTitle(t, nil, "/user2/repo59/src/tag/v1.0/deep/nesting/")
|
||||
assert.EqualValues(t, "repo59/deep/nesting at v1.0 - user2/repo59 - Gitea: Git with a cup of tea", htmlTitle)
|
||||
})
|
||||
})
|
||||
t.Run("File", func(t *testing.T) {
|
||||
t.Run("Default branch", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
htmlTitle := GetHTMLTitle(t, nil, "/user2/repo59/src/branch/master/deep/nesting/folder/secret_sauce_recipe.txt")
|
||||
assert.EqualValues(t, "repo59/deep/nesting/folder/secret_sauce_recipe.txt at master - user2/repo59 - Gitea: Git with a cup of tea", htmlTitle)
|
||||
})
|
||||
t.Run("Non-default branch", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
htmlTitle := GetHTMLTitle(t, nil, "/user2/repo59/src/branch/cake-recipe/deep/nesting/folder/secret_sauce_recipe.txt")
|
||||
assert.EqualValues(t, "repo59/deep/nesting/folder/secret_sauce_recipe.txt at cake-recipe - user2/repo59 - Gitea: Git with a cup of tea", htmlTitle)
|
||||
})
|
||||
t.Run("Commit", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
htmlTitle := GetHTMLTitle(t, nil, "/user2/repo59/src/commit/d8f53dfb33f6ccf4169c34970b5e747511c18beb/deep/nesting/folder/secret_sauce_recipe.txt")
|
||||
assert.EqualValues(t, "repo59/deep/nesting/folder/secret_sauce_recipe.txt at d8f53dfb33f6ccf4169c34970b5e747511c18beb - user2/repo59 - Gitea: Git with a cup of tea", htmlTitle)
|
||||
})
|
||||
t.Run("Tag", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
htmlTitle := GetHTMLTitle(t, nil, "/user2/repo59/src/tag/v1.0/deep/nesting/folder/secret_sauce_recipe.txt")
|
||||
assert.EqualValues(t, "repo59/deep/nesting/folder/secret_sauce_recipe.txt at v1.0 - user2/repo59 - Gitea: Git with a cup of tea", htmlTitle)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Issues view", func(t *testing.T) {
|
||||
t.Run("Overview page", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
htmlTitle := GetHTMLTitle(t, nil, "/user2/repo1/issues")
|
||||
assert.EqualValues(t, "Issues - user2/repo1 - Gitea: Git with a cup of tea", htmlTitle)
|
||||
})
|
||||
t.Run("View issue page", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
htmlTitle := GetHTMLTitle(t, nil, "/user2/repo1/issues/1")
|
||||
assert.EqualValues(t, "#1 - issue1 - user2/repo1 - Gitea: Git with a cup of tea", htmlTitle)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Pull requests view", func(t *testing.T) {
|
||||
t.Run("Overview page", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
htmlTitle := GetHTMLTitle(t, nil, "/user2/repo1/pulls")
|
||||
assert.EqualValues(t, "Pull Requests - user2/repo1 - Gitea: Git with a cup of tea", htmlTitle)
|
||||
})
|
||||
t.Run("View pull request", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
htmlTitle := GetHTMLTitle(t, nil, "/user2/repo1/pulls/2")
|
||||
assert.EqualValues(t, "#2 - issue2 - user2/repo1 - Gitea: Git with a cup of tea", htmlTitle)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestViewFileInRepo repo description, topics and summary should not be displayed when viewing a file
|
||||
func TestViewFileInRepo(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
@ -586,3 +690,33 @@ func TestDangerZoneConfirmation(t *testing.T) {
|
|||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestRenamedFileHistory(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
t.Run("Renamed file", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
req := NewRequest(t, "GET", "/user2/repo59/commits/branch/master/license")
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
htmlDoc := NewHTMLParser(t, resp.Body)
|
||||
|
||||
renameNotice := htmlDoc.doc.Find(".ui.bottom.attached.header")
|
||||
assert.Equal(t, 1, renameNotice.Length())
|
||||
assert.Contains(t, renameNotice.Text(), "Renamed from licnse (Browse further)")
|
||||
|
||||
oldFileHistoryLink, ok := renameNotice.Find("a").Attr("href")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "/user2/repo59/commits/commit/80b83c5c8220c3aa3906e081f202a2a7563ec879/licnse", oldFileHistoryLink)
|
||||
})
|
||||
|
||||
t.Run("Non renamed file", func(t *testing.T) {
|
||||
req := NewRequest(t, "GET", "/user2/repo59/commits/branch/master/README.md")
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
htmlDoc := NewHTMLParser(t, resp.Body)
|
||||
|
||||
htmlDoc.AssertElement(t, ".ui.bottom.attached.header", false)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -17,10 +17,6 @@
|
|||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.issue-list-toolbar-right .dropdown .menu {
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
}
|
||||
.issue-list-navbar {
|
||||
order: 0;
|
||||
}
|
||||
|
@ -31,6 +27,37 @@
|
|||
.issue-list-search {
|
||||
order: 2 !important;
|
||||
}
|
||||
/* Don't use flex wrap on mobile as it takes too much vertical space.
|
||||
* Only set overflow properties on mobile screens, because while the
|
||||
* CSS trick to pop out from overflowing works on desktop screen, it
|
||||
* has a massive flaw that it cannot inherited any max width from it's 'real'
|
||||
* parent and therefor ends up taking more vertical space than is desired.
|
||||
**/
|
||||
.issue-list-toolbar-right .filter.menu {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
/* The following few CSS was created with care and built with the information
|
||||
* from CSS-Tricks: https://css-tricks.com/popping-hidden-overflow/
|
||||
*/
|
||||
|
||||
/* It's important that every element up to .issue-list-toolbar-right doesn't
|
||||
* have a position set, such that element that wants to pop out will use
|
||||
* .issue-list-toolbar-right as 'clip parent' and thereby avoids the
|
||||
* overflow-y: hidden.
|
||||
*/
|
||||
.issue-list-toolbar-right .filter.menu > .dropdown.item {
|
||||
position: initial;
|
||||
}
|
||||
/* It's important that this element and not an child has `position` set.
|
||||
* Set width so that overflow-x knows where to stop overflowing.
|
||||
*/
|
||||
.issue-list-toolbar-right {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
#issue-list .flex-item-title .labels-list {
|
||||
|
|
Loading…
Reference in a new issue