mirror of
https://github.com/go-gitea/gitea
synced 2024-11-25 04:22:46 +01:00
Merge branch 'main' into lunny/fix_get_reviewers_bug
This commit is contained in:
commit
b6653068b8
66 changed files with 815 additions and 616 deletions
|
@ -171,7 +171,10 @@ func (a *Action) TableIndices() []*schemas.Index {
|
|||
cudIndex := schemas.NewIndex("c_u_d", schemas.IndexType)
|
||||
cudIndex.AddColumn("created_unix", "user_id", "is_deleted")
|
||||
|
||||
indices := []*schemas.Index{actUserIndex, repoIndex, cudIndex}
|
||||
cuIndex := schemas.NewIndex("c_u", schemas.IndexType)
|
||||
cuIndex.AddColumn("user_id", "is_deleted")
|
||||
|
||||
indices := []*schemas.Index{actUserIndex, repoIndex, cudIndex, cuIndex}
|
||||
|
||||
return indices
|
||||
}
|
||||
|
|
|
@ -365,6 +365,7 @@ func prepareMigrationTasks() []*migration {
|
|||
newMigration(305, "Add Repository Licenses", v1_23.AddRepositoryLicenses),
|
||||
newMigration(306, "Add BlockAdminMergeOverride to ProtectedBranch", v1_23.AddBlockAdminMergeOverrideBranchProtection),
|
||||
newMigration(307, "Fix milestone deadline_unix when there is no due date", v1_23.FixMilestoneNoDueDate),
|
||||
newMigration(308, "Add index(user_id, is_deleted) for action table", v1_23.AddNewIndexForUserDashboard),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
|
52
models/migrations/v1_23/v308.go
Normal file
52
models/migrations/v1_23/v308.go
Normal file
|
@ -0,0 +1,52 @@
|
|||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_23 //nolint
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
|
||||
"xorm.io/xorm"
|
||||
"xorm.io/xorm/schemas"
|
||||
)
|
||||
|
||||
type improveActionTableIndicesAction struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
UserID int64 `xorm:"INDEX"` // Receiver user id.
|
||||
OpType int
|
||||
ActUserID int64 // Action user id.
|
||||
RepoID int64
|
||||
CommentID int64 `xorm:"INDEX"`
|
||||
IsDeleted bool `xorm:"NOT NULL DEFAULT false"`
|
||||
RefName string
|
||||
IsPrivate bool `xorm:"NOT NULL DEFAULT false"`
|
||||
Content string `xorm:"TEXT"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
}
|
||||
|
||||
// TableName sets the name of this table
|
||||
func (*improveActionTableIndicesAction) TableName() string {
|
||||
return "action"
|
||||
}
|
||||
|
||||
func (a *improveActionTableIndicesAction) TableIndices() []*schemas.Index {
|
||||
repoIndex := schemas.NewIndex("r_u_d", schemas.IndexType)
|
||||
repoIndex.AddColumn("repo_id", "user_id", "is_deleted")
|
||||
|
||||
actUserIndex := schemas.NewIndex("au_r_c_u_d", schemas.IndexType)
|
||||
actUserIndex.AddColumn("act_user_id", "repo_id", "created_unix", "user_id", "is_deleted")
|
||||
|
||||
cudIndex := schemas.NewIndex("c_u_d", schemas.IndexType)
|
||||
cudIndex.AddColumn("created_unix", "user_id", "is_deleted")
|
||||
|
||||
cuIndex := schemas.NewIndex("c_u", schemas.IndexType)
|
||||
cuIndex.AddColumn("user_id", "is_deleted")
|
||||
|
||||
indices := []*schemas.Index{actUserIndex, repoIndex, cudIndex, cuIndex}
|
||||
|
||||
return indices
|
||||
}
|
||||
|
||||
func AddNewIndexForUserDashboard(x *xorm.Engine) error {
|
||||
return x.Sync(new(improveActionTableIndicesAction))
|
||||
}
|
|
@ -60,3 +60,6 @@ func ParseAccessMode(permission string, allowed ...AccessMode) AccessMode {
|
|||
}
|
||||
return util.Iif(slices.Contains(allowed, m), m, AccessModeNone)
|
||||
}
|
||||
|
||||
// ErrInvalidAccessMode is returned when an invalid access mode is used
|
||||
var ErrInvalidAccessMode = util.NewInvalidArgumentErrorf("Invalid access mode")
|
||||
|
|
|
@ -31,6 +31,7 @@ import (
|
|||
"github.com/blevesearch/bleve/v2/analysis/token/camelcase"
|
||||
"github.com/blevesearch/bleve/v2/analysis/token/lowercase"
|
||||
"github.com/blevesearch/bleve/v2/analysis/token/unicodenorm"
|
||||
"github.com/blevesearch/bleve/v2/analysis/tokenizer/letter"
|
||||
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
|
||||
"github.com/blevesearch/bleve/v2/mapping"
|
||||
"github.com/blevesearch/bleve/v2/search/query"
|
||||
|
@ -69,7 +70,7 @@ const (
|
|||
filenameIndexerAnalyzer = "filenameIndexerAnalyzer"
|
||||
filenameIndexerTokenizer = "filenameIndexerTokenizer"
|
||||
repoIndexerDocType = "repoIndexerDocType"
|
||||
repoIndexerLatestVersion = 7
|
||||
repoIndexerLatestVersion = 8
|
||||
)
|
||||
|
||||
// generateBleveIndexMapping generates a bleve index mapping for the repo indexer
|
||||
|
@ -105,7 +106,7 @@ func generateBleveIndexMapping() (mapping.IndexMapping, error) {
|
|||
} else if err := mapping.AddCustomAnalyzer(repoIndexerAnalyzer, map[string]any{
|
||||
"type": analyzer_custom.Name,
|
||||
"char_filters": []string{},
|
||||
"tokenizer": unicode.Name,
|
||||
"tokenizer": letter.Name,
|
||||
"token_filters": []string{unicodeNormalizeName, camelcase.Name, lowercase.Name},
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
|
|
|
@ -30,7 +30,7 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
esRepoIndexerLatestVersion = 2
|
||||
esRepoIndexerLatestVersion = 3
|
||||
// multi-match-types, currently only 2 types are used
|
||||
// Reference: https://www.elastic.co/guide/en/elasticsearch/reference/7.0/query-dsl-multi-match-query.html#multi-match-types
|
||||
esMultiMatchTypeBestFields = "best_fields"
|
||||
|
@ -60,6 +60,10 @@ const (
|
|||
"settings": {
|
||||
"analysis": {
|
||||
"analyzer": {
|
||||
"content_analyzer": {
|
||||
"tokenizer": "content_tokenizer",
|
||||
"filter" : ["lowercase"]
|
||||
},
|
||||
"filename_path_analyzer": {
|
||||
"tokenizer": "path_tokenizer"
|
||||
},
|
||||
|
@ -68,6 +72,10 @@ const (
|
|||
}
|
||||
},
|
||||
"tokenizer": {
|
||||
"content_tokenizer": {
|
||||
"type": "simple_pattern_split",
|
||||
"pattern": "[^a-zA-Z0-9]"
|
||||
},
|
||||
"path_tokenizer": {
|
||||
"type": "path_hierarchy",
|
||||
"delimiter": "/"
|
||||
|
@ -104,7 +112,8 @@ const (
|
|||
"content": {
|
||||
"type": "text",
|
||||
"term_vector": "with_positions_offsets",
|
||||
"index": true
|
||||
"index": true,
|
||||
"analyzer": "content_analyzer"
|
||||
},
|
||||
"commit_id": {
|
||||
"type": "keyword",
|
||||
|
|
|
@ -181,6 +181,55 @@ func testIndexer(name string, t *testing.T, indexer internal.Indexer) {
|
|||
},
|
||||
},
|
||||
},
|
||||
// Search for matches on the contents of files regardless of case.
|
||||
{
|
||||
RepoIDs: nil,
|
||||
Keyword: "dESCRIPTION",
|
||||
Langs: 1,
|
||||
Results: []codeSearchResult{
|
||||
{
|
||||
Filename: "README.md",
|
||||
Content: "# repo1\n\nDescription for repo1",
|
||||
},
|
||||
},
|
||||
},
|
||||
// Search for an exact match on the filename within the repo '62' (case insenstive).
|
||||
// This scenario yields a single result (the file avocado.md on the repo '62')
|
||||
{
|
||||
RepoIDs: []int64{62},
|
||||
Keyword: "AVOCADO.MD",
|
||||
Langs: 1,
|
||||
Results: []codeSearchResult{
|
||||
{
|
||||
Filename: "avocado.md",
|
||||
Content: "# repo1\n\npineaple pie of cucumber juice",
|
||||
},
|
||||
},
|
||||
},
|
||||
// Search for matches on the contents of files when the criteria is a expression.
|
||||
{
|
||||
RepoIDs: []int64{62},
|
||||
Keyword: "console.log",
|
||||
Langs: 1,
|
||||
Results: []codeSearchResult{
|
||||
{
|
||||
Filename: "example-file.js",
|
||||
Content: "console.log(\"Hello, World!\")",
|
||||
},
|
||||
},
|
||||
},
|
||||
// Search for matches on the contents of files when the criteria is part of a expression.
|
||||
{
|
||||
RepoIDs: []int64{62},
|
||||
Keyword: "log",
|
||||
Langs: 1,
|
||||
Results: []codeSearchResult{
|
||||
{
|
||||
Filename: "example-file.js",
|
||||
Content: "console.log(\"Hello, World!\")",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, kw := range keywords {
|
||||
|
|
|
@ -6,12 +6,13 @@ package bleve
|
|||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"unicode"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"github.com/blevesearch/bleve/v2"
|
||||
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
|
||||
unicode_tokenizer "github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
|
||||
"github.com/blevesearch/bleve/v2/index/upsidedown"
|
||||
"github.com/ethantkoenig/rupture"
|
||||
)
|
||||
|
@ -57,7 +58,7 @@ func openIndexer(path string, latestVersion int) (bleve.Index, int, error) {
|
|||
// may be different on two string and they still be considered equivalent.
|
||||
// Given a phrasse, its shortest word determines its fuzziness. If a phrase uses CJK (eg: `갃갃갃` `啊啊啊`), the fuzziness is zero.
|
||||
func GuessFuzzinessByKeyword(s string) int {
|
||||
tokenizer := unicode.NewUnicodeTokenizer()
|
||||
tokenizer := unicode_tokenizer.NewUnicodeTokenizer()
|
||||
tokens := tokenizer.Tokenize([]byte(s))
|
||||
|
||||
if len(tokens) > 0 {
|
||||
|
@ -77,8 +78,10 @@ func guessFuzzinessByKeyword(s string) int {
|
|||
// according to https://github.com/blevesearch/bleve/issues/1563, the supported max fuzziness is 2
|
||||
// magic number 4 was chosen to determine the levenshtein distance per each character of a keyword
|
||||
// BUT, when using CJK (eg: `갃갃갃` `啊啊啊`), it mismatches a lot.
|
||||
// Likewise, queries whose terms contains characters that are *not* letters should not use fuzziness
|
||||
|
||||
for _, r := range s {
|
||||
if r >= 128 {
|
||||
if r >= 128 || !unicode.IsLetter(r) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,6 +35,14 @@ func TestBleveGuessFuzzinessByKeyword(t *testing.T) {
|
|||
Input: "갃갃갃",
|
||||
Fuzziness: 0,
|
||||
},
|
||||
{
|
||||
Input: "repo1",
|
||||
Fuzziness: 0,
|
||||
},
|
||||
{
|
||||
Input: "avocado.md",
|
||||
Fuzziness: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
|
|
@ -442,7 +442,10 @@ func createLink(href, content, class string) *html.Node {
|
|||
a := &html.Node{
|
||||
Type: html.ElementNode,
|
||||
Data: atom.A.String(),
|
||||
Attr: []html.Attribute{{Key: "href", Val: href}},
|
||||
Attr: []html.Attribute{
|
||||
{Key: "href", Val: href},
|
||||
{Key: "data-markdown-generated-content"},
|
||||
},
|
||||
}
|
||||
|
||||
if class != "" {
|
||||
|
|
|
@ -30,5 +30,5 @@ func TestRenderCodePreview(t *testing.T) {
|
|||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
test("http://localhost:3000/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20", "<p><div>code preview</div></p>")
|
||||
test("http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20", `<p><a href="http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20" rel="nofollow">http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20</a></p>`)
|
||||
test("http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20", `<p><a href="http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20" data-markdown-generated-content="" rel="nofollow">http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20</a></p>`)
|
||||
}
|
||||
|
|
|
@ -33,11 +33,9 @@ func numericIssueLink(baseURL, class string, index int, marker string) string {
|
|||
|
||||
// link an HTML link
|
||||
func link(href, class, contents string) string {
|
||||
if class != "" {
|
||||
class = " class=\"" + class + "\""
|
||||
}
|
||||
|
||||
return fmt.Sprintf("<a href=\"%s\"%s>%s</a>", href, class, contents)
|
||||
extra := ` data-markdown-generated-content=""`
|
||||
extra += util.Iif(class != "", ` class="`+class+`"`, "")
|
||||
return fmt.Sprintf(`<a href="%s"%s>%s</a>`, href, extra, contents)
|
||||
}
|
||||
|
||||
var numericMetas = map[string]string{
|
||||
|
@ -353,7 +351,9 @@ func TestRender_FullIssueURLs(t *testing.T) {
|
|||
Metas: localMetas,
|
||||
}, []processor{fullIssuePatternProcessor}, strings.NewReader(input), &result)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, result.String())
|
||||
actual := result.String()
|
||||
actual = strings.ReplaceAll(actual, ` data-markdown-generated-content=""`, "")
|
||||
assert.Equal(t, expected, actual)
|
||||
}
|
||||
test("Here is a link https://git.osgeo.org/gogs/postgis/postgis/pulls/6",
|
||||
"Here is a link https://git.osgeo.org/gogs/postgis/postgis/pulls/6")
|
||||
|
|
|
@ -116,7 +116,9 @@ func TestRender_CrossReferences(t *testing.T) {
|
|||
Metas: localMetas,
|
||||
}, input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
actual := strings.TrimSpace(buffer)
|
||||
actual = strings.ReplaceAll(actual, ` data-markdown-generated-content=""`, "")
|
||||
assert.Equal(t, strings.TrimSpace(expected), actual)
|
||||
}
|
||||
|
||||
test(
|
||||
|
@ -156,7 +158,9 @@ func TestRender_links(t *testing.T) {
|
|||
},
|
||||
}, input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
actual := strings.TrimSpace(buffer)
|
||||
actual = strings.ReplaceAll(actual, ` data-markdown-generated-content=""`, "")
|
||||
assert.Equal(t, strings.TrimSpace(expected), actual)
|
||||
}
|
||||
|
||||
oldCustomURLSchemes := setting.Markdown.CustomURLSchemes
|
||||
|
@ -267,7 +271,9 @@ func TestRender_email(t *testing.T) {
|
|||
},
|
||||
}, input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(res))
|
||||
actual := strings.TrimSpace(res)
|
||||
actual = strings.ReplaceAll(actual, ` data-markdown-generated-content=""`, "")
|
||||
assert.Equal(t, strings.TrimSpace(expected), actual)
|
||||
}
|
||||
// Text that should be turned into email link
|
||||
|
||||
|
@ -616,7 +622,9 @@ func TestPostProcess_RenderDocument(t *testing.T) {
|
|||
Metas: localMetas,
|
||||
}, strings.NewReader(input), &res)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(res.String()))
|
||||
actual := strings.TrimSpace(res.String())
|
||||
actual = strings.ReplaceAll(actual, ` data-markdown-generated-content=""`, "")
|
||||
assert.Equal(t, strings.TrimSpace(expected), actual)
|
||||
}
|
||||
|
||||
// Issue index shouldn't be post processing in a document.
|
||||
|
|
|
@ -311,7 +311,8 @@ func TestTotal_RenderWiki(t *testing.T) {
|
|||
IsWiki: true,
|
||||
}, sameCases[i])
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, template.HTML(answers[i]), line)
|
||||
actual := strings.ReplaceAll(string(line), ` data-markdown-generated-content=""`, "")
|
||||
assert.Equal(t, answers[i], actual)
|
||||
}
|
||||
|
||||
testCases := []string{
|
||||
|
@ -336,7 +337,8 @@ func TestTotal_RenderWiki(t *testing.T) {
|
|||
IsWiki: true,
|
||||
}, testCases[i])
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, template.HTML(testCases[i+1]), line)
|
||||
actual := strings.ReplaceAll(string(line), ` data-markdown-generated-content=""`, "")
|
||||
assert.EqualValues(t, testCases[i+1], actual)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -356,7 +358,8 @@ func TestTotal_RenderString(t *testing.T) {
|
|||
Metas: localMetas,
|
||||
}, sameCases[i])
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, template.HTML(answers[i]), line)
|
||||
actual := strings.ReplaceAll(string(line), ` data-markdown-generated-content=""`, "")
|
||||
assert.Equal(t, answers[i], actual)
|
||||
}
|
||||
|
||||
testCases := []string{}
|
||||
|
@ -996,7 +999,8 @@ space</p>
|
|||
for i, c := range cases {
|
||||
result, err := markdown.RenderString(&markup.RenderContext{Ctx: context.Background(), Links: c.Links, IsWiki: c.IsWiki}, input)
|
||||
assert.NoError(t, err, "Unexpected error in testcase: %v", i)
|
||||
assert.Equal(t, c.Expected, string(result), "Unexpected result in testcase %v", i)
|
||||
actual := strings.ReplaceAll(string(result), ` data-markdown-generated-content=""`, "")
|
||||
assert.Equal(t, c.Expected, actual, "Unexpected result in testcase %v", i)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -107,6 +107,7 @@ func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy {
|
|||
"start", "summary", "tabindex", "target",
|
||||
"title", "type", "usemap", "valign", "value",
|
||||
"vspace", "width", "itemprop",
|
||||
"data-markdown-generated-content",
|
||||
}
|
||||
|
||||
generalSafeElements := []string{
|
||||
|
|
|
@ -1,48 +0,0 @@
|
|||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
func AddCollaborator(ctx context.Context, repo *repo_model.Repository, u *user_model.User) error {
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if user_model.IsUserBlockedBy(ctx, u, repo.OwnerID) || user_model.IsUserBlockedBy(ctx, repo.Owner, u.ID) {
|
||||
return user_model.ErrBlockedUser
|
||||
}
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
has, err := db.Exist[repo_model.Collaboration](ctx, builder.Eq{
|
||||
"repo_id": repo.ID,
|
||||
"user_id": u.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
} else if has {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err = db.Insert(ctx, &repo_model.Collaboration{
|
||||
RepoID: repo.ID,
|
||||
UserID: u.ID,
|
||||
Mode: perm.AccessModeWrite,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return access_model.RecalculateUserAccess(ctx, repo, u.ID)
|
||||
})
|
||||
}
|
|
@ -1,280 +0,0 @@
|
|||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
perm_model "code.gitea.io/gitea/models/perm"
|
||||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRepository_AddCollaborator(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
testSuccess := func(repoID, userID int64) {
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID})
|
||||
assert.NoError(t, repo.LoadOwner(db.DefaultContext))
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID})
|
||||
assert.NoError(t, AddCollaborator(db.DefaultContext, repo, user))
|
||||
unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repoID}, &user_model.User{ID: userID})
|
||||
}
|
||||
testSuccess(1, 4)
|
||||
testSuccess(1, 4)
|
||||
testSuccess(3, 4)
|
||||
}
|
||||
|
||||
func TestRepoPermissionPublicNonOrgRepo(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// public non-organization repo
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
assert.NoError(t, repo.LoadUnits(db.DefaultContext))
|
||||
|
||||
// plain user
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
perm, err := access_model.GetUserRepoPermission(db.DefaultContext, repo, user)
|
||||
assert.NoError(t, err)
|
||||
for _, unit := range repo.Units {
|
||||
assert.True(t, perm.CanRead(unit.Type))
|
||||
assert.False(t, perm.CanWrite(unit.Type))
|
||||
}
|
||||
|
||||
// change to collaborator
|
||||
assert.NoError(t, AddCollaborator(db.DefaultContext, repo, user))
|
||||
perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, user)
|
||||
assert.NoError(t, err)
|
||||
for _, unit := range repo.Units {
|
||||
assert.True(t, perm.CanRead(unit.Type))
|
||||
assert.True(t, perm.CanWrite(unit.Type))
|
||||
}
|
||||
|
||||
// collaborator
|
||||
collaborator := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, collaborator)
|
||||
assert.NoError(t, err)
|
||||
for _, unit := range repo.Units {
|
||||
assert.True(t, perm.CanRead(unit.Type))
|
||||
assert.True(t, perm.CanWrite(unit.Type))
|
||||
}
|
||||
|
||||
// owner
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
|
||||
perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, owner)
|
||||
assert.NoError(t, err)
|
||||
for _, unit := range repo.Units {
|
||||
assert.True(t, perm.CanRead(unit.Type))
|
||||
assert.True(t, perm.CanWrite(unit.Type))
|
||||
}
|
||||
|
||||
// admin
|
||||
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, admin)
|
||||
assert.NoError(t, err)
|
||||
for _, unit := range repo.Units {
|
||||
assert.True(t, perm.CanRead(unit.Type))
|
||||
assert.True(t, perm.CanWrite(unit.Type))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoPermissionPrivateNonOrgRepo(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// private non-organization repo
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
assert.NoError(t, repo.LoadUnits(db.DefaultContext))
|
||||
|
||||
// plain user
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
perm, err := access_model.GetUserRepoPermission(db.DefaultContext, repo, user)
|
||||
assert.NoError(t, err)
|
||||
for _, unit := range repo.Units {
|
||||
assert.False(t, perm.CanRead(unit.Type))
|
||||
assert.False(t, perm.CanWrite(unit.Type))
|
||||
}
|
||||
|
||||
// change to collaborator to default write access
|
||||
assert.NoError(t, AddCollaborator(db.DefaultContext, repo, user))
|
||||
perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, user)
|
||||
assert.NoError(t, err)
|
||||
for _, unit := range repo.Units {
|
||||
assert.True(t, perm.CanRead(unit.Type))
|
||||
assert.True(t, perm.CanWrite(unit.Type))
|
||||
}
|
||||
|
||||
assert.NoError(t, repo_model.ChangeCollaborationAccessMode(db.DefaultContext, repo, user.ID, perm_model.AccessModeRead))
|
||||
perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, user)
|
||||
assert.NoError(t, err)
|
||||
for _, unit := range repo.Units {
|
||||
assert.True(t, perm.CanRead(unit.Type))
|
||||
assert.False(t, perm.CanWrite(unit.Type))
|
||||
}
|
||||
|
||||
// owner
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, owner)
|
||||
assert.NoError(t, err)
|
||||
for _, unit := range repo.Units {
|
||||
assert.True(t, perm.CanRead(unit.Type))
|
||||
assert.True(t, perm.CanWrite(unit.Type))
|
||||
}
|
||||
|
||||
// admin
|
||||
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, admin)
|
||||
assert.NoError(t, err)
|
||||
for _, unit := range repo.Units {
|
||||
assert.True(t, perm.CanRead(unit.Type))
|
||||
assert.True(t, perm.CanWrite(unit.Type))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoPermissionPublicOrgRepo(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// public organization repo
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 32})
|
||||
assert.NoError(t, repo.LoadUnits(db.DefaultContext))
|
||||
|
||||
// plain user
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
|
||||
perm, err := access_model.GetUserRepoPermission(db.DefaultContext, repo, user)
|
||||
assert.NoError(t, err)
|
||||
for _, unit := range repo.Units {
|
||||
assert.True(t, perm.CanRead(unit.Type))
|
||||
assert.False(t, perm.CanWrite(unit.Type))
|
||||
}
|
||||
|
||||
// change to collaborator to default write access
|
||||
assert.NoError(t, AddCollaborator(db.DefaultContext, repo, user))
|
||||
perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, user)
|
||||
assert.NoError(t, err)
|
||||
for _, unit := range repo.Units {
|
||||
assert.True(t, perm.CanRead(unit.Type))
|
||||
assert.True(t, perm.CanWrite(unit.Type))
|
||||
}
|
||||
|
||||
assert.NoError(t, repo_model.ChangeCollaborationAccessMode(db.DefaultContext, repo, user.ID, perm_model.AccessModeRead))
|
||||
perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, user)
|
||||
assert.NoError(t, err)
|
||||
for _, unit := range repo.Units {
|
||||
assert.True(t, perm.CanRead(unit.Type))
|
||||
assert.False(t, perm.CanWrite(unit.Type))
|
||||
}
|
||||
|
||||
// org member team owner
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, owner)
|
||||
assert.NoError(t, err)
|
||||
for _, unit := range repo.Units {
|
||||
assert.True(t, perm.CanRead(unit.Type))
|
||||
assert.True(t, perm.CanWrite(unit.Type))
|
||||
}
|
||||
|
||||
// org member team tester
|
||||
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15})
|
||||
perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, member)
|
||||
assert.NoError(t, err)
|
||||
for _, unit := range repo.Units {
|
||||
assert.True(t, perm.CanRead(unit.Type))
|
||||
}
|
||||
assert.True(t, perm.CanWrite(unit.TypeIssues))
|
||||
assert.False(t, perm.CanWrite(unit.TypeCode))
|
||||
|
||||
// admin
|
||||
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, admin)
|
||||
assert.NoError(t, err)
|
||||
for _, unit := range repo.Units {
|
||||
assert.True(t, perm.CanRead(unit.Type))
|
||||
assert.True(t, perm.CanWrite(unit.Type))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoPermissionPrivateOrgRepo(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// private organization repo
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 24})
|
||||
assert.NoError(t, repo.LoadUnits(db.DefaultContext))
|
||||
|
||||
// plain user
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
|
||||
perm, err := access_model.GetUserRepoPermission(db.DefaultContext, repo, user)
|
||||
assert.NoError(t, err)
|
||||
for _, unit := range repo.Units {
|
||||
assert.False(t, perm.CanRead(unit.Type))
|
||||
assert.False(t, perm.CanWrite(unit.Type))
|
||||
}
|
||||
|
||||
// change to collaborator to default write access
|
||||
assert.NoError(t, AddCollaborator(db.DefaultContext, repo, user))
|
||||
perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, user)
|
||||
assert.NoError(t, err)
|
||||
for _, unit := range repo.Units {
|
||||
assert.True(t, perm.CanRead(unit.Type))
|
||||
assert.True(t, perm.CanWrite(unit.Type))
|
||||
}
|
||||
|
||||
assert.NoError(t, repo_model.ChangeCollaborationAccessMode(db.DefaultContext, repo, user.ID, perm_model.AccessModeRead))
|
||||
perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, user)
|
||||
assert.NoError(t, err)
|
||||
for _, unit := range repo.Units {
|
||||
assert.True(t, perm.CanRead(unit.Type))
|
||||
assert.False(t, perm.CanWrite(unit.Type))
|
||||
}
|
||||
|
||||
// org member team owner
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15})
|
||||
perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, owner)
|
||||
assert.NoError(t, err)
|
||||
for _, unit := range repo.Units {
|
||||
assert.True(t, perm.CanRead(unit.Type))
|
||||
assert.True(t, perm.CanWrite(unit.Type))
|
||||
}
|
||||
|
||||
// update team information and then check permission
|
||||
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 5})
|
||||
err = organization.UpdateTeamUnits(db.DefaultContext, team, nil)
|
||||
assert.NoError(t, err)
|
||||
perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, owner)
|
||||
assert.NoError(t, err)
|
||||
for _, unit := range repo.Units {
|
||||
assert.True(t, perm.CanRead(unit.Type))
|
||||
assert.True(t, perm.CanWrite(unit.Type))
|
||||
}
|
||||
|
||||
// org member team tester
|
||||
tester := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, tester)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, perm.CanWrite(unit.TypeIssues))
|
||||
assert.False(t, perm.CanWrite(unit.TypeCode))
|
||||
assert.False(t, perm.CanRead(unit.TypeCode))
|
||||
|
||||
// org member team reviewer
|
||||
reviewer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 20})
|
||||
perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, reviewer)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, perm.CanRead(unit.TypeIssues))
|
||||
assert.False(t, perm.CanWrite(unit.TypeCode))
|
||||
assert.True(t, perm.CanRead(unit.TypeCode))
|
||||
|
||||
// admin
|
||||
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
perm, err = access_model.GetUserRepoPermission(db.DefaultContext, repo, admin)
|
||||
assert.NoError(t, err)
|
||||
for _, unit := range repo.Units {
|
||||
assert.True(t, perm.CanRead(unit.Type))
|
||||
assert.True(t, perm.CanWrite(unit.Type))
|
||||
}
|
||||
}
|
|
@ -11,160 +11,17 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models"
|
||||
activities_model "code.gitea.io/gitea/models/activities"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
git_model "code.gitea.io/gitea/models/git"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/models/webhook"
|
||||
issue_indexer "code.gitea.io/gitea/modules/indexer/issues"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
)
|
||||
|
||||
// CreateRepositoryByExample creates a repository for the user/organization.
|
||||
func CreateRepositoryByExample(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository, overwriteOrAdopt, isFork bool) (err error) {
|
||||
if err = repo_model.IsUsableRepoName(repo.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
has, err := repo_model.IsRepositoryModelExist(ctx, u, repo.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("IsRepositoryExist: %w", err)
|
||||
} else if has {
|
||||
return repo_model.ErrRepoAlreadyExist{
|
||||
Uname: u.Name,
|
||||
Name: repo.Name,
|
||||
}
|
||||
}
|
||||
|
||||
repoPath := repo_model.RepoPath(u.Name, repo.Name)
|
||||
isExist, err := util.IsExist(repoPath)
|
||||
if err != nil {
|
||||
log.Error("Unable to check if %s exists. Error: %v", repoPath, err)
|
||||
return err
|
||||
}
|
||||
if !overwriteOrAdopt && isExist {
|
||||
log.Error("Files already exist in %s and we are not going to adopt or delete.", repoPath)
|
||||
return repo_model.ErrRepoFilesAlreadyExist{
|
||||
Uname: u.Name,
|
||||
Name: repo.Name,
|
||||
}
|
||||
}
|
||||
|
||||
if err = db.Insert(ctx, repo); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = repo_model.DeleteRedirect(ctx, u.ID, repo.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// insert units for repo
|
||||
defaultUnits := unit.DefaultRepoUnits
|
||||
if isFork {
|
||||
defaultUnits = unit.DefaultForkRepoUnits
|
||||
}
|
||||
units := make([]repo_model.RepoUnit, 0, len(defaultUnits))
|
||||
for _, tp := range defaultUnits {
|
||||
if tp == unit.TypeIssues {
|
||||
units = append(units, repo_model.RepoUnit{
|
||||
RepoID: repo.ID,
|
||||
Type: tp,
|
||||
Config: &repo_model.IssuesConfig{
|
||||
EnableTimetracker: setting.Service.DefaultEnableTimetracking,
|
||||
AllowOnlyContributorsToTrackTime: setting.Service.DefaultAllowOnlyContributorsToTrackTime,
|
||||
EnableDependencies: setting.Service.DefaultEnableDependencies,
|
||||
},
|
||||
})
|
||||
} else if tp == unit.TypePullRequests {
|
||||
units = append(units, repo_model.RepoUnit{
|
||||
RepoID: repo.ID,
|
||||
Type: tp,
|
||||
Config: &repo_model.PullRequestsConfig{
|
||||
AllowMerge: true, AllowRebase: true, AllowRebaseMerge: true, AllowSquash: true, AllowFastForwardOnly: true,
|
||||
DefaultMergeStyle: repo_model.MergeStyle(setting.Repository.PullRequest.DefaultMergeStyle),
|
||||
AllowRebaseUpdate: true,
|
||||
},
|
||||
})
|
||||
} else if tp == unit.TypeProjects {
|
||||
units = append(units, repo_model.RepoUnit{
|
||||
RepoID: repo.ID,
|
||||
Type: tp,
|
||||
Config: &repo_model.ProjectsConfig{ProjectsMode: repo_model.ProjectsModeAll},
|
||||
})
|
||||
} else {
|
||||
units = append(units, repo_model.RepoUnit{
|
||||
RepoID: repo.ID,
|
||||
Type: tp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if err = db.Insert(ctx, units); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remember visibility preference.
|
||||
u.LastRepoVisibility = repo.IsPrivate
|
||||
if err = user_model.UpdateUserCols(ctx, u, "last_repo_visibility"); err != nil {
|
||||
return fmt.Errorf("UpdateUserCols: %w", err)
|
||||
}
|
||||
|
||||
if err = user_model.IncrUserRepoNum(ctx, u.ID); err != nil {
|
||||
return fmt.Errorf("IncrUserRepoNum: %w", err)
|
||||
}
|
||||
u.NumRepos++
|
||||
|
||||
// Give access to all members in teams with access to all repositories.
|
||||
if u.IsOrganization() {
|
||||
teams, err := organization.FindOrgTeams(ctx, u.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("FindOrgTeams: %w", err)
|
||||
}
|
||||
for _, t := range teams {
|
||||
if t.IncludesAllRepositories {
|
||||
if err := models.AddRepository(ctx, t, repo); err != nil {
|
||||
return fmt.Errorf("AddRepository: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isAdmin, err := access_model.IsUserRepoAdmin(ctx, repo, doer); err != nil {
|
||||
return fmt.Errorf("IsUserRepoAdmin: %w", err)
|
||||
} else if !isAdmin {
|
||||
// Make creator repo admin if it wasn't assigned automatically
|
||||
if err = AddCollaborator(ctx, repo, doer); err != nil {
|
||||
return fmt.Errorf("AddCollaborator: %w", err)
|
||||
}
|
||||
if err = repo_model.ChangeCollaborationAccessMode(ctx, repo, doer.ID, perm.AccessModeAdmin); err != nil {
|
||||
return fmt.Errorf("ChangeCollaborationAccessModeCtx: %w", err)
|
||||
}
|
||||
}
|
||||
} else if err = access_model.RecalculateAccesses(ctx, repo); err != nil {
|
||||
// Organization automatically called this in AddRepository method.
|
||||
return fmt.Errorf("RecalculateAccesses: %w", err)
|
||||
}
|
||||
|
||||
if setting.Service.AutoWatchNewRepos {
|
||||
if err = repo_model.WatchRepo(ctx, doer, repo, true); err != nil {
|
||||
return fmt.Errorf("WatchRepo: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err = webhook.CopyDefaultWebhooksToRepo(ctx, repo.ID); err != nil {
|
||||
return fmt.Errorf("CopyDefaultWebhooksToRepo: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const notRegularFileMode = os.ModeSymlink | os.ModeNamedPipe | os.ModeSocket | os.ModeDevice | os.ModeCharDevice | os.ModeIrregular
|
||||
|
||||
// getDirectorySize returns the disk consumption for a given path
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
|
||||
_ "code.gitea.io/gitea/models"
|
||||
_ "code.gitea.io/gitea/models/actions"
|
||||
)
|
||||
|
||||
|
|
|
@ -129,18 +129,18 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
|
|||
<a href="/mention-user" class="mention">@mention-user</a> test
|
||||
<a href="/user13/repo11/issues/123" class="ref-issue">#123</a>
|
||||
space`
|
||||
|
||||
assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitBody(testInput(), testMetas))
|
||||
actual := strings.ReplaceAll(string(newTestRenderUtils().RenderCommitBody(testInput(), testMetas)), ` data-markdown-generated-content=""`, "")
|
||||
assert.EqualValues(t, expected, actual)
|
||||
}
|
||||
|
||||
func TestRenderCommitMessage(t *testing.T) {
|
||||
expected := `space <a href="/mention-user" class="mention">@mention-user</a> `
|
||||
expected := `space <a href="/mention-user" data-markdown-generated-content="" class="mention">@mention-user</a> `
|
||||
|
||||
assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitMessage(testInput(), testMetas))
|
||||
}
|
||||
|
||||
func TestRenderCommitMessageLinkSubject(t *testing.T) {
|
||||
expected := `<a href="https://example.com/link" class="default-link muted">space </a><a href="/mention-user" class="mention">@mention-user</a>`
|
||||
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>`
|
||||
|
||||
assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", testMetas))
|
||||
}
|
||||
|
@ -168,7 +168,8 @@ mail@domain.com
|
|||
space<SPACE><SPACE>
|
||||
`
|
||||
expected = strings.ReplaceAll(expected, "<SPACE>", " ")
|
||||
assert.EqualValues(t, expected, newTestRenderUtils().RenderIssueTitle(testInput(), testMetas))
|
||||
actual := strings.ReplaceAll(string(newTestRenderUtils().RenderIssueTitle(testInput(), testMetas)), ` data-markdown-generated-content=""`, "")
|
||||
assert.EqualValues(t, expected, actual)
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToHtml(t *testing.T) {
|
||||
|
@ -193,7 +194,8 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
|
|||
#123
|
||||
space</p>
|
||||
`
|
||||
assert.Equal(t, expected, string(newTestRenderUtils().MarkdownToHtml(testInput())))
|
||||
actual := strings.ReplaceAll(string(newTestRenderUtils().MarkdownToHtml(testInput())), ` data-markdown-generated-content=""`, "")
|
||||
assert.Equal(t, expected, actual)
|
||||
}
|
||||
|
||||
func TestRenderLabels(t *testing.T) {
|
||||
|
@ -211,5 +213,5 @@ func TestRenderLabels(t *testing.T) {
|
|||
|
||||
func TestUserMention(t *testing.T) {
|
||||
rendered := newTestRenderUtils().MarkdownToHtml("@no-such-user @mention-user @mention-user")
|
||||
assert.EqualValues(t, `<p>@no-such-user <a href="/mention-user" rel="nofollow">@mention-user</a> <a href="/mention-user" rel="nofollow">@mention-user</a></p>`, strings.TrimSpace(string(rendered)))
|
||||
assert.EqualValues(t, `<p>@no-such-user <a href="/mention-user" data-markdown-generated-content="" rel="nofollow">@mention-user</a> <a href="/mention-user" data-markdown-generated-content="" rel="nofollow">@mention-user</a></p>`, strings.TrimSpace(string(rendered)))
|
||||
}
|
||||
|
|
|
@ -1172,7 +1172,7 @@ func Routes() *web.Router {
|
|||
m.Get("", reqAnyRepoReader(), repo.ListCollaborators)
|
||||
m.Group("/{collaborator}", func() {
|
||||
m.Combo("").Get(reqAnyRepoReader(), repo.IsCollaborator).
|
||||
Put(reqAdmin(), bind(api.AddCollaboratorOption{}), repo.AddCollaborator).
|
||||
Put(reqAdmin(), bind(api.AddCollaboratorOption{}), repo.AddOrUpdateCollaborator).
|
||||
Delete(reqAdmin(), repo.DeleteCollaborator)
|
||||
m.Get("/permission", repo.GetRepoPermissions)
|
||||
})
|
||||
|
|
|
@ -38,7 +38,8 @@ func testRenderMarkup(t *testing.T, mode string, wiki bool, filePath, text, expe
|
|||
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markup")
|
||||
web.SetForm(ctx, &options)
|
||||
Markup(ctx)
|
||||
assert.Equal(t, expectedBody, resp.Body.String())
|
||||
actual := strings.ReplaceAll(resp.Body.String(), ` data-markdown-generated-content=""`, "")
|
||||
assert.Equal(t, expectedBody, actual)
|
||||
assert.Equal(t, expectedCode, resp.Code)
|
||||
resp.Body.Reset()
|
||||
}
|
||||
|
@ -58,7 +59,8 @@ func testRenderMarkdown(t *testing.T, mode string, wiki bool, text, responseBody
|
|||
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown")
|
||||
web.SetForm(ctx, &options)
|
||||
Markdown(ctx)
|
||||
assert.Equal(t, responseBody, resp.Body.String())
|
||||
actual := strings.ReplaceAll(resp.Body.String(), ` data-markdown-generated-content=""`, "")
|
||||
assert.Equal(t, responseBody, actual)
|
||||
assert.Equal(t, responseCode, resp.Code)
|
||||
resp.Body.Reset()
|
||||
}
|
||||
|
|
|
@ -12,7 +12,6 @@ import (
|
|||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
repo_module "code.gitea.io/gitea/modules/repository"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||
|
@ -124,11 +123,11 @@ func IsCollaborator(ctx *context.APIContext) {
|
|||
}
|
||||
}
|
||||
|
||||
// AddCollaborator add a collaborator to a repository
|
||||
func AddCollaborator(ctx *context.APIContext) {
|
||||
// AddOrUpdateCollaborator add or update a collaborator to a repository
|
||||
func AddOrUpdateCollaborator(ctx *context.APIContext) {
|
||||
// swagger:operation PUT /repos/{owner}/{repo}/collaborators/{collaborator} repository repoAddCollaborator
|
||||
// ---
|
||||
// summary: Add a collaborator to a repository
|
||||
// summary: Add or Update a collaborator to a repository
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
|
@ -178,20 +177,18 @@ func AddCollaborator(ctx *context.APIContext) {
|
|||
return
|
||||
}
|
||||
|
||||
if err := repo_module.AddCollaborator(ctx, ctx.Repo.Repository, collaborator); err != nil {
|
||||
if errors.Is(err, user_model.ErrBlockedUser) {
|
||||
ctx.Error(http.StatusForbidden, "AddCollaborator", err)
|
||||
} else {
|
||||
ctx.Error(http.StatusInternalServerError, "AddCollaborator", err)
|
||||
}
|
||||
return
|
||||
p := perm.AccessModeWrite
|
||||
if form.Permission != nil {
|
||||
p = perm.ParseAccessMode(*form.Permission)
|
||||
}
|
||||
|
||||
if form.Permission != nil {
|
||||
if err := repo_model.ChangeCollaborationAccessMode(ctx, ctx.Repo.Repository, collaborator.ID, perm.ParseAccessMode(*form.Permission)); err != nil {
|
||||
ctx.Error(http.StatusInternalServerError, "ChangeCollaborationAccessMode", err)
|
||||
return
|
||||
if err := repo_service.AddOrUpdateCollaborator(ctx, ctx.Repo.Repository, collaborator, p); err != nil {
|
||||
if errors.Is(err, user_model.ErrBlockedUser) {
|
||||
ctx.Error(http.StatusForbidden, "AddOrUpdateCollaborator", err)
|
||||
} else {
|
||||
ctx.Error(http.StatusInternalServerError, "AddOrUpdateCollaborator", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
|
|
|
@ -12,7 +12,7 @@ import (
|
|||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/services/attachment"
|
||||
attachment_service "code.gitea.io/gitea/services/attachment"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/context/upload"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
|
@ -181,7 +181,7 @@ func CreateIssueAttachment(ctx *context.APIContext) {
|
|||
filename = query
|
||||
}
|
||||
|
||||
attachment, err := attachment.UploadAttachment(ctx, file, setting.Attachment.AllowedTypes, header.Size, &repo_model.Attachment{
|
||||
attachment, err := attachment_service.UploadAttachment(ctx, file, setting.Attachment.AllowedTypes, header.Size, &repo_model.Attachment{
|
||||
Name: filename,
|
||||
UploaderID: ctx.Doer.ID,
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
|
@ -247,6 +247,8 @@ func EditIssueAttachment(ctx *context.APIContext) {
|
|||
// "$ref": "#/responses/Attachment"
|
||||
// "404":
|
||||
// "$ref": "#/responses/error"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
// "423":
|
||||
// "$ref": "#/responses/repoArchivedError"
|
||||
|
||||
|
@ -261,8 +263,13 @@ func EditIssueAttachment(ctx *context.APIContext) {
|
|||
attachment.Name = form.Name
|
||||
}
|
||||
|
||||
if err := repo_model.UpdateAttachment(ctx, attachment); err != nil {
|
||||
if err := attachment_service.UpdateAttachment(ctx, setting.Attachment.AllowedTypes, attachment); err != nil {
|
||||
if upload.IsErrFileTypeForbidden(err) {
|
||||
ctx.Error(http.StatusUnprocessableEntity, "", err)
|
||||
return
|
||||
}
|
||||
ctx.Error(http.StatusInternalServerError, "UpdateAttachment", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attachment))
|
||||
|
|
|
@ -14,7 +14,7 @@ import (
|
|||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/services/attachment"
|
||||
attachment_service "code.gitea.io/gitea/services/attachment"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/context/upload"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
|
@ -189,7 +189,7 @@ func CreateIssueCommentAttachment(ctx *context.APIContext) {
|
|||
filename = query
|
||||
}
|
||||
|
||||
attachment, err := attachment.UploadAttachment(ctx, file, setting.Attachment.AllowedTypes, header.Size, &repo_model.Attachment{
|
||||
attachment, err := attachment_service.UploadAttachment(ctx, file, setting.Attachment.AllowedTypes, header.Size, &repo_model.Attachment{
|
||||
Name: filename,
|
||||
UploaderID: ctx.Doer.ID,
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
|
@ -263,6 +263,8 @@ func EditIssueCommentAttachment(ctx *context.APIContext) {
|
|||
// "$ref": "#/responses/Attachment"
|
||||
// "404":
|
||||
// "$ref": "#/responses/error"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
// "423":
|
||||
// "$ref": "#/responses/repoArchivedError"
|
||||
attach := getIssueCommentAttachmentSafeWrite(ctx)
|
||||
|
@ -275,8 +277,13 @@ func EditIssueCommentAttachment(ctx *context.APIContext) {
|
|||
attach.Name = form.Name
|
||||
}
|
||||
|
||||
if err := repo_model.UpdateAttachment(ctx, attach); err != nil {
|
||||
if err := attachment_service.UpdateAttachment(ctx, setting.Attachment.AllowedTypes, attach); err != nil {
|
||||
if upload.IsErrFileTypeForbidden(err) {
|
||||
ctx.Error(http.StatusUnprocessableEntity, "", err)
|
||||
return
|
||||
}
|
||||
ctx.Error(http.StatusInternalServerError, "UpdateAttachment", attach)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach))
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ import (
|
|||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/services/attachment"
|
||||
attachment_service "code.gitea.io/gitea/services/attachment"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/context/upload"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
|
@ -234,7 +234,7 @@ func CreateReleaseAttachment(ctx *context.APIContext) {
|
|||
}
|
||||
|
||||
// Create a new attachment and save the file
|
||||
attach, err := attachment.UploadAttachment(ctx, content, setting.Repository.Release.AllowedTypes, size, &repo_model.Attachment{
|
||||
attach, err := attachment_service.UploadAttachment(ctx, content, setting.Repository.Release.AllowedTypes, size, &repo_model.Attachment{
|
||||
Name: filename,
|
||||
UploaderID: ctx.Doer.ID,
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
|
@ -291,6 +291,8 @@ func EditReleaseAttachment(ctx *context.APIContext) {
|
|||
// responses:
|
||||
// "201":
|
||||
// "$ref": "#/responses/Attachment"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
|
@ -322,8 +324,13 @@ func EditReleaseAttachment(ctx *context.APIContext) {
|
|||
attach.Name = form.Name
|
||||
}
|
||||
|
||||
if err := repo_model.UpdateAttachment(ctx, attach); err != nil {
|
||||
if err := attachment_service.UpdateAttachment(ctx, setting.Repository.Release.AllowedTypes, attach); err != nil {
|
||||
if upload.IsErrFileTypeForbidden(err) {
|
||||
ctx.Error(http.StatusUnprocessableEntity, "", err)
|
||||
return
|
||||
}
|
||||
ctx.Error(http.StatusInternalServerError, "UpdateAttachment", attach)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach))
|
||||
}
|
||||
|
|
|
@ -14,7 +14,6 @@ import (
|
|||
unit_model "code.gitea.io/gitea/models/unit"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
repo_module "code.gitea.io/gitea/modules/repository"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/mailer"
|
||||
|
@ -100,12 +99,12 @@ func CollaborationPost(ctx *context.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
if err = repo_module.AddCollaborator(ctx, ctx.Repo.Repository, u); err != nil {
|
||||
if err = repo_service.AddOrUpdateCollaborator(ctx, ctx.Repo.Repository, u, perm.AccessModeWrite); err != nil {
|
||||
if errors.Is(err, user_model.ErrBlockedUser) {
|
||||
ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator.blocked_user"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration")
|
||||
} else {
|
||||
ctx.ServerError("AddCollaborator", err)
|
||||
ctx.ServerError("AddOrUpdateCollaborator", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
|
@ -50,3 +50,12 @@ func UploadAttachment(ctx context.Context, file io.Reader, allowedTypes string,
|
|||
|
||||
return NewAttachment(ctx, attach, io.MultiReader(bytes.NewReader(buf), file), fileSize)
|
||||
}
|
||||
|
||||
// UpdateAttachment updates an attachment, verifying that its name is among the allowed types.
|
||||
func UpdateAttachment(ctx context.Context, allowedTypes string, attach *repo_model.Attachment) error {
|
||||
if err := upload.Verify(nil, attach.Name, allowedTypes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return repo_model.UpdateAttachment(ctx, attach)
|
||||
}
|
||||
|
|
|
@ -28,12 +28,13 @@ func IsErrFileTypeForbidden(err error) bool {
|
|||
}
|
||||
|
||||
func (err ErrFileTypeForbidden) Error() string {
|
||||
return "This file extension or type is not allowed to be uploaded."
|
||||
return "This file cannot be uploaded or modified due to a forbidden file extension or type."
|
||||
}
|
||||
|
||||
var wildcardTypeRe = regexp.MustCompile(`^[a-z]+/\*$`)
|
||||
|
||||
// Verify validates whether a file is allowed to be uploaded.
|
||||
// Verify validates whether a file is allowed to be uploaded. If buf is empty, it will just check if the file
|
||||
// has an allowed file extension.
|
||||
func Verify(buf []byte, fileName, allowedTypesStr string) error {
|
||||
allowedTypesStr = strings.ReplaceAll(allowedTypesStr, "|", ",") // compat for old config format
|
||||
|
||||
|
@ -56,21 +57,31 @@ func Verify(buf []byte, fileName, allowedTypesStr string) error {
|
|||
return ErrFileTypeForbidden{Type: fullMimeType}
|
||||
}
|
||||
extension := strings.ToLower(path.Ext(fileName))
|
||||
isBufEmpty := len(buf) <= 1
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Unique_file_type_specifiers
|
||||
for _, allowEntry := range allowedTypes {
|
||||
if allowEntry == "*/*" {
|
||||
return nil // everything allowed
|
||||
} else if strings.HasPrefix(allowEntry, ".") && allowEntry == extension {
|
||||
}
|
||||
if strings.HasPrefix(allowEntry, ".") && allowEntry == extension {
|
||||
return nil // extension is allowed
|
||||
} else if mimeType == allowEntry {
|
||||
}
|
||||
if isBufEmpty {
|
||||
continue // skip mime type checks if buffer is empty
|
||||
}
|
||||
if mimeType == allowEntry {
|
||||
return nil // mime type is allowed
|
||||
} else if wildcardTypeRe.MatchString(allowEntry) && strings.HasPrefix(mimeType, allowEntry[:len(allowEntry)-1]) {
|
||||
}
|
||||
if wildcardTypeRe.MatchString(allowEntry) && strings.HasPrefix(mimeType, allowEntry[:len(allowEntry)-1]) {
|
||||
return nil // wildcard match, e.g. image/*
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("Attachment with type %s blocked from upload", fullMimeType)
|
||||
if !isBufEmpty {
|
||||
log.Info("Attachment with type %s blocked from upload", fullMimeType)
|
||||
}
|
||||
|
||||
return ErrFileTypeForbidden{Type: fullMimeType}
|
||||
}
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ import (
|
|||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
|
||||
_ "code.gitea.io/gitea/models"
|
||||
_ "code.gitea.io/gitea/models/actions"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
|
||||
_ "code.gitea.io/gitea/models"
|
||||
_ "code.gitea.io/gitea/models/actions"
|
||||
)
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
|
||||
_ "code.gitea.io/gitea/models"
|
||||
_ "code.gitea.io/gitea/models/actions"
|
||||
)
|
||||
|
||||
|
|
|
@ -66,7 +66,7 @@ func AdoptRepository(ctx context.Context, doer, u *user_model.User, opts CreateR
|
|||
}
|
||||
}
|
||||
|
||||
if err := repo_module.CreateRepositoryByExample(ctx, doer, u, repo, true, false); err != nil {
|
||||
if err := CreateRepositoryByExample(ctx, doer, u, repo, true, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
|
@ -9,11 +9,60 @@ import (
|
|||
|
||||
"code.gitea.io/gitea/models"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
func AddOrUpdateCollaborator(ctx context.Context, repo *repo_model.Repository, u *user_model.User, mode perm.AccessMode) error {
|
||||
// only allow valid access modes, read, write and admin
|
||||
if mode < perm.AccessModeRead || mode > perm.AccessModeAdmin {
|
||||
return perm.ErrInvalidAccessMode
|
||||
}
|
||||
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if user_model.IsUserBlockedBy(ctx, u, repo.OwnerID) || user_model.IsUserBlockedBy(ctx, repo.Owner, u.ID) {
|
||||
return user_model.ErrBlockedUser
|
||||
}
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
collaboration, has, err := db.Get[repo_model.Collaboration](ctx, builder.Eq{
|
||||
"repo_id": repo.ID,
|
||||
"user_id": u.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
} else if has {
|
||||
if collaboration.Mode == mode {
|
||||
return nil
|
||||
}
|
||||
if _, err = db.GetEngine(ctx).
|
||||
Where("repo_id=?", repo.ID).
|
||||
And("user_id=?", u.ID).
|
||||
Cols("mode").
|
||||
Update(&repo_model.Collaboration{
|
||||
Mode: mode,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err = db.Insert(ctx, &repo_model.Collaboration{
|
||||
RepoID: repo.ID,
|
||||
UserID: u.ID,
|
||||
Mode: mode,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return access_model.RecalculateUserAccess(ctx, repo, u.ID)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteCollaboration removes collaboration relation between the user and repository.
|
||||
func DeleteCollaboration(ctx context.Context, repo *repo_model.Repository, collaborator *user_model.User) (err error) {
|
||||
collaboration := &repo_model.Collaboration{
|
||||
|
|
|
@ -7,6 +7,7 @@ import (
|
|||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
|
@ -14,6 +15,21 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRepository_AddCollaborator(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
testSuccess := func(repoID, userID int64) {
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID})
|
||||
assert.NoError(t, repo.LoadOwner(db.DefaultContext))
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID})
|
||||
assert.NoError(t, AddOrUpdateCollaborator(db.DefaultContext, repo, user, perm.AccessModeWrite))
|
||||
unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repoID}, &user_model.User{ID: userID})
|
||||
}
|
||||
testSuccess(1, 4)
|
||||
testSuccess(1, 4)
|
||||
testSuccess(3, 4)
|
||||
}
|
||||
|
||||
func TestRepository_DeleteCollaboration(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
|
|
|
@ -12,9 +12,15 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/models/webhook"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/gitrepo"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
|
@ -243,7 +249,7 @@ func CreateRepositoryDirectly(ctx context.Context, doer, u *user_model.User, opt
|
|||
var rollbackRepo *repo_model.Repository
|
||||
|
||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
if err := repo_module.CreateRepositoryByExample(ctx, doer, u, repo, false, false); err != nil {
|
||||
if err := CreateRepositoryByExample(ctx, doer, u, repo, false, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -335,3 +341,136 @@ func CreateRepositoryDirectly(ctx context.Context, doer, u *user_model.User, opt
|
|||
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
// CreateRepositoryByExample creates a repository for the user/organization.
|
||||
func CreateRepositoryByExample(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository, overwriteOrAdopt, isFork bool) (err error) {
|
||||
if err = repo_model.IsUsableRepoName(repo.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
has, err := repo_model.IsRepositoryModelExist(ctx, u, repo.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("IsRepositoryExist: %w", err)
|
||||
} else if has {
|
||||
return repo_model.ErrRepoAlreadyExist{
|
||||
Uname: u.Name,
|
||||
Name: repo.Name,
|
||||
}
|
||||
}
|
||||
|
||||
repoPath := repo_model.RepoPath(u.Name, repo.Name)
|
||||
isExist, err := util.IsExist(repoPath)
|
||||
if err != nil {
|
||||
log.Error("Unable to check if %s exists. Error: %v", repoPath, err)
|
||||
return err
|
||||
}
|
||||
if !overwriteOrAdopt && isExist {
|
||||
log.Error("Files already exist in %s and we are not going to adopt or delete.", repoPath)
|
||||
return repo_model.ErrRepoFilesAlreadyExist{
|
||||
Uname: u.Name,
|
||||
Name: repo.Name,
|
||||
}
|
||||
}
|
||||
|
||||
if err = db.Insert(ctx, repo); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = repo_model.DeleteRedirect(ctx, u.ID, repo.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// insert units for repo
|
||||
defaultUnits := unit.DefaultRepoUnits
|
||||
if isFork {
|
||||
defaultUnits = unit.DefaultForkRepoUnits
|
||||
}
|
||||
units := make([]repo_model.RepoUnit, 0, len(defaultUnits))
|
||||
for _, tp := range defaultUnits {
|
||||
if tp == unit.TypeIssues {
|
||||
units = append(units, repo_model.RepoUnit{
|
||||
RepoID: repo.ID,
|
||||
Type: tp,
|
||||
Config: &repo_model.IssuesConfig{
|
||||
EnableTimetracker: setting.Service.DefaultEnableTimetracking,
|
||||
AllowOnlyContributorsToTrackTime: setting.Service.DefaultAllowOnlyContributorsToTrackTime,
|
||||
EnableDependencies: setting.Service.DefaultEnableDependencies,
|
||||
},
|
||||
})
|
||||
} else if tp == unit.TypePullRequests {
|
||||
units = append(units, repo_model.RepoUnit{
|
||||
RepoID: repo.ID,
|
||||
Type: tp,
|
||||
Config: &repo_model.PullRequestsConfig{
|
||||
AllowMerge: true, AllowRebase: true, AllowRebaseMerge: true, AllowSquash: true, AllowFastForwardOnly: true,
|
||||
DefaultMergeStyle: repo_model.MergeStyle(setting.Repository.PullRequest.DefaultMergeStyle),
|
||||
AllowRebaseUpdate: true,
|
||||
},
|
||||
})
|
||||
} else if tp == unit.TypeProjects {
|
||||
units = append(units, repo_model.RepoUnit{
|
||||
RepoID: repo.ID,
|
||||
Type: tp,
|
||||
Config: &repo_model.ProjectsConfig{ProjectsMode: repo_model.ProjectsModeAll},
|
||||
})
|
||||
} else {
|
||||
units = append(units, repo_model.RepoUnit{
|
||||
RepoID: repo.ID,
|
||||
Type: tp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if err = db.Insert(ctx, units); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remember visibility preference.
|
||||
u.LastRepoVisibility = repo.IsPrivate
|
||||
if err = user_model.UpdateUserCols(ctx, u, "last_repo_visibility"); err != nil {
|
||||
return fmt.Errorf("UpdateUserCols: %w", err)
|
||||
}
|
||||
|
||||
if err = user_model.IncrUserRepoNum(ctx, u.ID); err != nil {
|
||||
return fmt.Errorf("IncrUserRepoNum: %w", err)
|
||||
}
|
||||
u.NumRepos++
|
||||
|
||||
// Give access to all members in teams with access to all repositories.
|
||||
if u.IsOrganization() {
|
||||
teams, err := organization.FindOrgTeams(ctx, u.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("FindOrgTeams: %w", err)
|
||||
}
|
||||
for _, t := range teams {
|
||||
if t.IncludesAllRepositories {
|
||||
if err := models.AddRepository(ctx, t, repo); err != nil {
|
||||
return fmt.Errorf("AddRepository: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isAdmin, err := access_model.IsUserRepoAdmin(ctx, repo, doer); err != nil {
|
||||
return fmt.Errorf("IsUserRepoAdmin: %w", err)
|
||||
} else if !isAdmin {
|
||||
// Make creator repo admin if it wasn't assigned automatically
|
||||
if err = AddOrUpdateCollaborator(ctx, repo, doer, perm.AccessModeAdmin); err != nil {
|
||||
return fmt.Errorf("AddCollaborator: %w", err)
|
||||
}
|
||||
}
|
||||
} else if err = access_model.RecalculateAccesses(ctx, repo); err != nil {
|
||||
// Organization automatically called this in AddRepository method.
|
||||
return fmt.Errorf("RecalculateAccesses: %w", err)
|
||||
}
|
||||
|
||||
if setting.Service.AutoWatchNewRepos {
|
||||
if err = repo_model.WatchRepo(ctx, doer, repo, true); err != nil {
|
||||
return fmt.Errorf("WatchRepo: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err = webhook.CopyDefaultWebhooksToRepo(ctx, repo.ID); err != nil {
|
||||
return fmt.Errorf("CopyDefaultWebhooksToRepo: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -134,7 +134,7 @@ func ForkRepository(ctx context.Context, doer, owner *user_model.User, opts Fork
|
|||
}()
|
||||
|
||||
err = db.WithTx(ctx, func(txCtx context.Context) error {
|
||||
if err = repo_module.CreateRepositoryByExample(txCtx, doer, owner, repo, false, true); err != nil {
|
||||
if err = CreateRepositoryByExample(txCtx, doer, owner, repo, false, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
|
@ -343,7 +343,7 @@ func generateRepository(ctx context.Context, doer, owner *user_model.User, templ
|
|||
ObjectFormatName: templateRepo.ObjectFormatName,
|
||||
}
|
||||
|
||||
if err = repo_module.CreateRepositoryByExample(ctx, doer, owner, generateRepo, false, false); err != nil {
|
||||
if err = CreateRepositoryByExample(ctx, doer, owner, generateRepo, false, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
|
@ -20,7 +20,6 @@ import (
|
|||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/globallock"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
repo_module "code.gitea.io/gitea/modules/repository"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
notify_service "code.gitea.io/gitea/services/notify"
|
||||
)
|
||||
|
@ -419,10 +418,7 @@ func StartRepositoryTransfer(ctx context.Context, doer, newOwner *user_model.Use
|
|||
return err
|
||||
}
|
||||
if !hasAccess {
|
||||
if err := repo_module.AddCollaborator(ctx, repo, newOwner); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repo_model.ChangeCollaborationAccessMode(ctx, repo, newOwner.ID, perm.AccessModeRead); err != nil {
|
||||
if err := AddOrUpdateCollaborator(ctx, repo, newOwner, perm.AccessModeRead); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
|
@ -49,7 +49,7 @@
|
|||
{{end}}
|
||||
{{end}}
|
||||
{{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.root.RepoLink .ID)}}
|
||||
{{template "repo/issue/view_content/context_menu" dict "ctxData" $.root "item" . "delete" true "issue" false "diff" true "IsCommentPoster" (and $.root.IsSigned (eq $.root.SignedUserID .PosterID))}}
|
||||
{{template "repo/issue/view_content/context_menu" dict "item" . "delete" true "issue" false "diff" true "IsCommentPoster" (and $.root.IsSigned (eq $.root.SignedUserID .PosterID))}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui attached segment comment-body">
|
||||
|
|
|
@ -48,7 +48,7 @@
|
|||
{{if not $.Repository.IsArchived}}
|
||||
{{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/issues/%d/reactions" $.RepoLink .Issue.Index)}}
|
||||
{{end}}
|
||||
{{template "repo/issue/view_content/context_menu" dict "ctxData" $ "item" .Issue "delete" false "issue" true "diff" false "IsCommentPoster" $.IsIssuePoster}}
|
||||
{{template "repo/issue/view_content/context_menu" dict "item" .Issue "delete" false "issue" true "diff" false "IsCommentPoster" $.IsIssuePoster}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui attached segment comment-body" role="article">
|
||||
|
|
|
@ -55,7 +55,7 @@
|
|||
{{if not $.Repository.IsArchived}}
|
||||
{{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID)}}
|
||||
{{end}}
|
||||
{{template "repo/issue/view_content/context_menu" dict "ctxData" $ "item" . "delete" true "issue" true "diff" false "IsCommentPoster" (and $.IsSigned (eq $.SignedUserID .PosterID))}}
|
||||
{{template "repo/issue/view_content/context_menu" dict "item" . "delete" true "issue" true "diff" false "IsCommentPoster" (and $.IsSigned (eq $.SignedUserID .PosterID))}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui attached segment comment-body" role="article">
|
||||
|
@ -430,7 +430,7 @@
|
|||
{{template "repo/issue/view_content/show_role" dict "ShowRole" .ShowRole}}
|
||||
{{if not $.Repository.IsArchived}}
|
||||
{{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID)}}
|
||||
{{template "repo/issue/view_content/context_menu" dict "ctxData" $ "item" . "delete" false "issue" true "diff" false "IsCommentPoster" (and $.IsSigned (eq $.SignedUserID .PosterID))}}
|
||||
{{template "repo/issue/view_content/context_menu" dict "item" . "delete" false "issue" true "diff" false "IsCommentPoster" (and $.IsSigned (eq $.SignedUserID .PosterID))}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -5,29 +5,29 @@
|
|||
<div class="menu">
|
||||
{{$referenceUrl := ""}}
|
||||
{{if .issue}}
|
||||
{{$referenceUrl = printf "%s#%s" .ctxData.Issue.Link .item.HashTag}}
|
||||
{{$referenceUrl = printf "%s#%s" ctx.RootData.Issue.Link .item.HashTag}}
|
||||
{{else}}
|
||||
{{$referenceUrl = printf "%s/files#%s" .ctxData.Issue.Link .item.HashTag}}
|
||||
{{$referenceUrl = printf "%s/files#%s" ctx.RootData.Issue.Link .item.HashTag}}
|
||||
{{end}}
|
||||
<div class="item context js-aria-clickable" data-clipboard-text-type="url" data-clipboard-text="{{$referenceUrl}}">{{ctx.Locale.Tr "repo.issues.context.copy_link"}}</div>
|
||||
{{if .ctxData.IsSigned}}
|
||||
{{if ctx.RootData.IsSigned}}
|
||||
{{$needDivider := false}}
|
||||
{{if not .ctxData.Repository.IsArchived}}
|
||||
{{if not ctx.RootData.Repository.IsArchived}}
|
||||
{{$needDivider = true}}
|
||||
<div class="item context js-aria-clickable quote-reply {{if .diff}}quote-reply-diff{{end}}" data-target="{{.item.HashTag}}-raw">{{ctx.Locale.Tr "repo.issues.context.quote_reply"}}</div>
|
||||
{{if not ctx.Consts.RepoUnitTypeIssues.UnitGlobalDisabled}}
|
||||
<div class="item context js-aria-clickable reference-issue" data-target="{{.item.HashTag}}-raw" data-modal="#reference-issue-modal" data-poster="{{.item.Poster.GetDisplayName}}" data-poster-username="{{.item.Poster.Name}}" data-reference="{{$referenceUrl}}">{{ctx.Locale.Tr "repo.issues.context.reference_issue"}}</div>
|
||||
{{end}}
|
||||
{{if or .ctxData.Permission.IsAdmin .IsCommentPoster .ctxData.HasIssuesOrPullsWritePermission}}
|
||||
{{if or ctx.RootData.Permission.IsAdmin .IsCommentPoster ctx.RootData.HasIssuesOrPullsWritePermission}}
|
||||
<div class="divider"></div>
|
||||
<div class="item context js-aria-clickable edit-content">{{ctx.Locale.Tr "repo.issues.context.edit"}}</div>
|
||||
{{if .delete}}
|
||||
<div class="item context js-aria-clickable delete-comment" data-comment-id={{.item.HashTag}} data-url="{{.ctxData.RepoLink}}/comments/{{.item.ID}}/delete" data-locale="{{ctx.Locale.Tr "repo.issues.delete_comment_confirm"}}">{{ctx.Locale.Tr "repo.issues.context.delete"}}</div>
|
||||
<div class="item context js-aria-clickable delete-comment" data-comment-id={{.item.HashTag}} data-url="{{ctx.RootData.RepoLink}}/comments/{{.item.ID}}/delete" data-locale="{{ctx.Locale.Tr "repo.issues.delete_comment_confirm"}}">{{ctx.Locale.Tr "repo.issues.context.delete"}}</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{$canUserBlock := call .ctxData.CanBlockUser .ctxData.SignedUser .item.Poster}}
|
||||
{{$canOrgBlock := and .ctxData.Repository.Owner.IsOrganization (call .ctxData.CanBlockUser .ctxData.Repository.Owner .item.Poster)}}
|
||||
{{$canUserBlock := call ctx.RootData.CanBlockUser ctx.RootData.SignedUser .item.Poster}}
|
||||
{{$canOrgBlock := and ctx.RootData.Repository.Owner.IsOrganization (call ctx.RootData.CanBlockUser ctx.RootData.Repository.Owner .item.Poster)}}
|
||||
{{if or $canOrgBlock $canUserBlock}}
|
||||
{{if $needDivider}}
|
||||
<div class="divider"></div>
|
||||
|
@ -36,7 +36,7 @@
|
|||
<div class="item context js-aria-clickable show-modal" data-modal="#block-user-modal" data-modal-modal-blockee="{{.item.Poster.Name}}" data-modal-modal-blockee-name="{{.item.Poster.GetDisplayName}}" data-modal-modal-form.action="{{AppSubUrl}}/user/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.user"}}</div>
|
||||
{{end}}
|
||||
{{if $canOrgBlock}}
|
||||
<div class="item context js-aria-clickable show-modal" data-modal="#block-user-modal" data-modal-modal-blockee="{{.item.Poster.Name}}" data-modal-modal-blockee-name="{{.item.Poster.GetDisplayName}}" data-modal-modal-form.action="{{.ctxData.Repository.Owner.OrganisationLink}}/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.org"}}</div>
|
||||
<div class="item context js-aria-clickable show-modal" data-modal="#block-user-modal" data-modal-modal-blockee="{{.item.Poster.Name}}" data-modal-modal-blockee-name="{{.item.Poster.GetDisplayName}}" data-modal-modal-form.action="{{ctx.RootData.Repository.Owner.OrganisationLink}}/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.org"}}</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
|
|
@ -83,7 +83,7 @@
|
|||
{{template "repo/issue/view_content/show_role" dict "ShowRole" .ShowRole}}
|
||||
{{if not $.Repository.IsArchived}}
|
||||
{{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID)}}
|
||||
{{template "repo/issue/view_content/context_menu" dict "ctxData" $ "item" . "delete" true "issue" true "diff" true "IsCommentPoster" (and $.IsSigned (eq $.SignedUserID .PosterID))}}
|
||||
{{template "repo/issue/view_content/context_menu" dict "item" . "delete" true "issue" true "diff" true "IsCommentPoster" (and $.IsSigned (eq $.SignedUserID .PosterID))}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
|
11
templates/swagger/v1_json.tmpl
generated
11
templates/swagger/v1_json.tmpl
generated
|
@ -5095,7 +5095,7 @@
|
|||
"tags": [
|
||||
"repository"
|
||||
],
|
||||
"summary": "Add a collaborator to a repository",
|
||||
"summary": "Add or Update a collaborator to a repository",
|
||||
"operationId": "repoAddCollaborator",
|
||||
"parameters": [
|
||||
{
|
||||
|
@ -7706,6 +7706,9 @@
|
|||
"404": {
|
||||
"$ref": "#/responses/error"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#/responses/validationError"
|
||||
},
|
||||
"423": {
|
||||
"$ref": "#/responses/repoArchivedError"
|
||||
}
|
||||
|
@ -8328,6 +8331,9 @@
|
|||
"404": {
|
||||
"$ref": "#/responses/error"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#/responses/validationError"
|
||||
},
|
||||
"423": {
|
||||
"$ref": "#/responses/repoArchivedError"
|
||||
}
|
||||
|
@ -13474,6 +13480,9 @@
|
|||
},
|
||||
"404": {
|
||||
"$ref": "#/responses/notFound"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#/responses/validationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,5 +4,6 @@ This repository will be used to test code search. The snippet below shows its di
|
|||
├── avocado.md
|
||||
├── cucumber.md
|
||||
├── ham.md
|
||||
└── potato
|
||||
└── ham.md
|
||||
├── potato
|
||||
| └── ham.md
|
||||
└── example-file.js
|
|
@ -3,7 +3,7 @@
|
|||
65f1bf27bc3bf70f64657658635e66094edbcb4d refs/heads/develop
|
||||
65f1bf27bc3bf70f64657658635e66094edbcb4d refs/heads/feature/1
|
||||
78fb907e3a3309eae4fe8fef030874cebbf1cd5e refs/heads/home-md-img-check
|
||||
3731fe53b763859aaf83e703ee731f6b9447ff1e refs/heads/master
|
||||
9f894b61946fd2f7b8b9d8e370e4d62f915522f5 refs/heads/master
|
||||
62fb502a7172d4453f0322a2cc85bddffa57f07a refs/heads/pr-to-update
|
||||
4649299398e4d39a5c09eb4f534df6f1e1eb87cc refs/heads/sub-home-md-img-check
|
||||
3fa2f829675543ecfc16b2891aebe8bf0608a8f4 refs/notes/commits
|
||||
|
|
Binary file not shown.
|
@ -1,2 +1,2 @@
|
|||
P pack-393dc29256bc27cb2ec73898507df710be7a3cf5.pack
|
||||
P pack-a7bef76cf6e2b46bc816936ab69306fb10aea571.pack
|
||||
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -4,7 +4,7 @@
|
|||
65f1bf27bc3bf70f64657658635e66094edbcb4d refs/heads/develop
|
||||
65f1bf27bc3bf70f64657658635e66094edbcb4d refs/heads/feature/1
|
||||
78fb907e3a3309eae4fe8fef030874cebbf1cd5e refs/heads/home-md-img-check
|
||||
3731fe53b763859aaf83e703ee731f6b9447ff1e refs/heads/master
|
||||
9f894b61946fd2f7b8b9d8e370e4d62f915522f5 refs/heads/master
|
||||
62fb502a7172d4453f0322a2cc85bddffa57f07a refs/heads/pr-to-update
|
||||
4649299398e4d39a5c09eb4f534df6f1e1eb87cc refs/heads/sub-home-md-img-check
|
||||
3fa2f829675543ecfc16b2891aebe8bf0608a8f4 refs/notes/commits
|
||||
|
|
|
@ -151,7 +151,7 @@ func TestAPICreateCommentAttachmentWithUnallowedFile(t *testing.T) {
|
|||
func TestAPIEditCommentAttachment(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
const newAttachmentName = "newAttachmentName"
|
||||
const newAttachmentName = "newAttachmentName.txt"
|
||||
|
||||
attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 6})
|
||||
comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: attachment.CommentID})
|
||||
|
@ -173,6 +173,27 @@ func TestAPIEditCommentAttachment(t *testing.T) {
|
|||
unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, CommentID: comment.ID, Name: apiAttachment.Name})
|
||||
}
|
||||
|
||||
func TestAPIEditCommentAttachmentWithUnallowedFile(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 6})
|
||||
comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: attachment.CommentID})
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
|
||||
repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
session := loginUser(t, repoOwner.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
filename := "file.bad"
|
||||
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/assets/%d",
|
||||
repoOwner.Name, repo.Name, comment.ID, attachment.ID)
|
||||
req := NewRequestWithValues(t, "PATCH", urlStr, map[string]string{
|
||||
"name": filename,
|
||||
}).AddTokenAuth(token)
|
||||
|
||||
session.MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
}
|
||||
|
||||
func TestAPIDeleteCommentAttachment(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
|
|
|
@ -126,7 +126,7 @@ func TestAPICreateIssueAttachmentWithUnallowedFile(t *testing.T) {
|
|||
func TestAPIEditIssueAttachment(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
const newAttachmentName = "newAttachmentName"
|
||||
const newAttachmentName = "hello_world.txt"
|
||||
|
||||
attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 1})
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: attachment.RepoID})
|
||||
|
@ -147,6 +147,26 @@ func TestAPIEditIssueAttachment(t *testing.T) {
|
|||
unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID, Name: apiAttachment.Name})
|
||||
}
|
||||
|
||||
func TestAPIEditIssueAttachmentWithUnallowedFile(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 1})
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: attachment.RepoID})
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: attachment.IssueID})
|
||||
repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
|
||||
session := loginUser(t, repoOwner.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
filename := "file.bad"
|
||||
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets/%d", repoOwner.Name, repo.Name, issue.Index, attachment.ID)
|
||||
req := NewRequestWithValues(t, "PATCH", urlStr, map[string]string{
|
||||
"name": filename,
|
||||
}).AddTokenAuth(token)
|
||||
|
||||
session.MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
}
|
||||
|
||||
func TestAPIDeleteIssueAttachment(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
|
|
40
tests/integration/api_releases_attachment_test.go
Normal file
40
tests/integration/api_releases_attachment_test.go
Normal file
|
@ -0,0 +1,40 @@
|
|||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/test"
|
||||
"code.gitea.io/gitea/tests"
|
||||
)
|
||||
|
||||
func TestAPIEditReleaseAttachmentWithUnallowedFile(t *testing.T) {
|
||||
// Limit the allowed release types (since by default there is no restriction)
|
||||
defer test.MockVariableValue(&setting.Repository.Release.AllowedTypes, ".exe")()
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 9})
|
||||
release := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ID: attachment.ReleaseID})
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: attachment.RepoID})
|
||||
repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
|
||||
session := loginUser(t, repoOwner.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
||||
|
||||
filename := "file.bad"
|
||||
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets/%d", repoOwner.Name, repo.Name, release.ID, attachment.ID)
|
||||
req := NewRequestWithValues(t, "PATCH", urlStr, map[string]string{
|
||||
"name": filename,
|
||||
}).AddTokenAuth(token)
|
||||
|
||||
session.MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
import {querySingleVisibleElem} from '../../utils/dom.ts';
|
||||
|
||||
export function handleGlobalEnterQuickSubmit(target) {
|
||||
let form = target.closest('form');
|
||||
if (form) {
|
||||
|
@ -12,7 +14,11 @@ export function handleGlobalEnterQuickSubmit(target) {
|
|||
}
|
||||
form = target.closest('.ui.form');
|
||||
if (form) {
|
||||
form.querySelector('.ui.primary.button')?.click();
|
||||
// A form should only have at most one "primary" button to do quick-submit.
|
||||
// Here we don't use a special class to mark the primary button,
|
||||
// because there could be a lot of forms with a primary button, the quick submit should work out-of-box,
|
||||
// but not keeps asking developers to add that special class again and again (it could be forgotten easily)
|
||||
querySingleVisibleElem<HTMLButtonElement>(form, '.ui.primary.button')?.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
|
|
@ -1,17 +1,19 @@
|
|||
import $ from 'jquery';
|
||||
import {handleReply} from './repo-issue.ts';
|
||||
import {getComboMarkdownEditor, initComboMarkdownEditor, ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {hideElem, showElem} from '../utils/dom.ts';
|
||||
import {hideElem, querySingleVisibleElem, showElem} from '../utils/dom.ts';
|
||||
import {attachRefIssueContextPopup} from './contextpopup.ts';
|
||||
import {initCommentContent, initMarkupContent} from '../markup/content.ts';
|
||||
import {triggerUploadStateChanged} from './comp/EditorUpload.ts';
|
||||
import {convertHtmlToMarkdown} from '../markup/html2markdown.ts';
|
||||
|
||||
async function onEditContent(event) {
|
||||
event.preventDefault();
|
||||
async function tryOnEditContent(e) {
|
||||
const clickTarget = e.target.closest('.edit-content');
|
||||
if (!clickTarget) return;
|
||||
|
||||
const segment = this.closest('.header').nextElementSibling;
|
||||
e.preventDefault();
|
||||
const segment = clickTarget.closest('.header').nextElementSibling;
|
||||
const editContentZone = segment.querySelector('.edit-content-zone');
|
||||
const renderContent = segment.querySelector('.render-content');
|
||||
const rawContent = segment.querySelector('.raw-content');
|
||||
|
@ -77,20 +79,22 @@ async function onEditContent(event) {
|
|||
}
|
||||
};
|
||||
|
||||
comboMarkdownEditor = getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'));
|
||||
if (!comboMarkdownEditor) {
|
||||
editContentZone.innerHTML = document.querySelector('#issue-comment-editor-template').innerHTML;
|
||||
const saveButton = editContentZone.querySelector('.ui.primary.button');
|
||||
comboMarkdownEditor = await initComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'));
|
||||
const syncUiState = () => saveButton.disabled = comboMarkdownEditor.isUploading();
|
||||
comboMarkdownEditor.container.addEventListener(ComboMarkdownEditor.EventUploadStateChanged, syncUiState);
|
||||
editContentZone.querySelector('.ui.cancel.button').addEventListener('click', cancelAndReset);
|
||||
saveButton.addEventListener('click', saveAndRefresh);
|
||||
}
|
||||
|
||||
// Show write/preview tab and copy raw content as needed
|
||||
showElem(editContentZone);
|
||||
hideElem(renderContent);
|
||||
|
||||
comboMarkdownEditor = getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'));
|
||||
if (!comboMarkdownEditor) {
|
||||
editContentZone.innerHTML = document.querySelector('#issue-comment-editor-template').innerHTML;
|
||||
const saveButton = querySingleVisibleElem<HTMLButtonElement>(editContentZone, '.ui.primary.button');
|
||||
const cancelButton = querySingleVisibleElem<HTMLButtonElement>(editContentZone, '.ui.cancel.button');
|
||||
comboMarkdownEditor = await initComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'));
|
||||
const syncUiState = () => saveButton.disabled = comboMarkdownEditor.isUploading();
|
||||
comboMarkdownEditor.container.addEventListener(ComboMarkdownEditor.EventUploadStateChanged, syncUiState);
|
||||
cancelButton.addEventListener('click', cancelAndReset);
|
||||
saveButton.addEventListener('click', saveAndRefresh);
|
||||
}
|
||||
|
||||
// FIXME: ideally here should reload content and attachment list from backend for existing editor, to avoid losing data
|
||||
if (!comboMarkdownEditor.value()) {
|
||||
comboMarkdownEditor.value(rawContent.textContent);
|
||||
|
@ -100,33 +104,53 @@ async function onEditContent(event) {
|
|||
triggerUploadStateChanged(comboMarkdownEditor.container);
|
||||
}
|
||||
|
||||
function extractSelectedMarkdown(container: HTMLElement) {
|
||||
const selection = window.getSelection();
|
||||
if (!selection.rangeCount) return '';
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!container.contains(range.commonAncestorContainer)) return '';
|
||||
|
||||
// todo: if commonAncestorContainer parent has "[data-markdown-original-content]" attribute, use the parent's markdown content
|
||||
// otherwise, use the selected HTML content and respect all "[data-markdown-original-content]/[data-markdown-generated-content]" attributes
|
||||
const contents = selection.getRangeAt(0).cloneContents();
|
||||
const el = document.createElement('div');
|
||||
el.append(contents);
|
||||
return convertHtmlToMarkdown(el);
|
||||
}
|
||||
|
||||
async function tryOnQuoteReply(e) {
|
||||
const clickTarget = (e.target as HTMLElement).closest('.quote-reply');
|
||||
if (!clickTarget) return;
|
||||
|
||||
e.preventDefault();
|
||||
const contentToQuoteId = clickTarget.getAttribute('data-target');
|
||||
const targetRawToQuote = document.querySelector<HTMLElement>(`#${contentToQuoteId}.raw-content`);
|
||||
const targetMarkupToQuote = targetRawToQuote.parentElement.querySelector<HTMLElement>('.render-content.markup');
|
||||
let contentToQuote = extractSelectedMarkdown(targetMarkupToQuote);
|
||||
if (!contentToQuote) contentToQuote = targetRawToQuote.textContent;
|
||||
const quotedContent = `${contentToQuote.replace(/^/mg, '> ')}\n`;
|
||||
|
||||
let editor;
|
||||
if (clickTarget.classList.contains('quote-reply-diff')) {
|
||||
const replyBtn = clickTarget.closest('.comment-code-cloud').querySelector('button.comment-form-reply');
|
||||
editor = await handleReply(replyBtn);
|
||||
} else {
|
||||
// for normal issue/comment page
|
||||
editor = getComboMarkdownEditor(document.querySelector('#comment-form .combo-markdown-editor'));
|
||||
}
|
||||
|
||||
if (editor.value()) {
|
||||
editor.value(`${editor.value()}\n\n${quotedContent}`);
|
||||
} else {
|
||||
editor.value(quotedContent);
|
||||
}
|
||||
editor.focus();
|
||||
editor.moveCursorToEnd();
|
||||
}
|
||||
|
||||
export function initRepoIssueCommentEdit() {
|
||||
// Edit issue or comment content
|
||||
$(document).on('click', '.edit-content', onEditContent);
|
||||
|
||||
// Quote reply
|
||||
$(document).on('click', '.quote-reply', async function (event) {
|
||||
event.preventDefault();
|
||||
const target = this.getAttribute('data-target');
|
||||
const quote = document.querySelector(`#${target}`).textContent.replace(/\n/g, '\n> ');
|
||||
const content = `> ${quote}\n\n`;
|
||||
|
||||
let editor;
|
||||
if (this.classList.contains('quote-reply-diff')) {
|
||||
const replyBtn = this.closest('.comment-code-cloud').querySelector('button.comment-form-reply');
|
||||
editor = await handleReply(replyBtn);
|
||||
} else {
|
||||
// for normal issue/comment page
|
||||
editor = getComboMarkdownEditor($('#comment-form .combo-markdown-editor'));
|
||||
}
|
||||
if (editor) {
|
||||
if (editor.value()) {
|
||||
editor.value(`${editor.value()}\n\n${content}`);
|
||||
} else {
|
||||
editor.value(content);
|
||||
}
|
||||
editor.focus();
|
||||
editor.moveCursorToEnd();
|
||||
}
|
||||
document.addEventListener('click', (e) => {
|
||||
tryOnEditContent(e); // Edit issue or comment content
|
||||
tryOnQuoteReply(e); // Quote reply to the comment editor
|
||||
});
|
||||
}
|
||||
|
|
24
web_src/js/markup/html2markdown.test.ts
Normal file
24
web_src/js/markup/html2markdown.test.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import {convertHtmlToMarkdown} from './html2markdown.ts';
|
||||
import {createElementFromHTML} from '../utils/dom.ts';
|
||||
|
||||
const h = createElementFromHTML;
|
||||
|
||||
test('convertHtmlToMarkdown', () => {
|
||||
expect(convertHtmlToMarkdown(h(`<h1>h</h1>`))).toBe('# h');
|
||||
expect(convertHtmlToMarkdown(h(`<strong>txt</strong>`))).toBe('**txt**');
|
||||
expect(convertHtmlToMarkdown(h(`<em>txt</em>`))).toBe('_txt_');
|
||||
expect(convertHtmlToMarkdown(h(`<del>txt</del>`))).toBe('~~txt~~');
|
||||
|
||||
expect(convertHtmlToMarkdown(h(`<a href="link">txt</a>`))).toBe('[txt](link)');
|
||||
expect(convertHtmlToMarkdown(h(`<a href="https://link">https://link</a>`))).toBe('https://link');
|
||||
|
||||
expect(convertHtmlToMarkdown(h(`<img src="link">`))).toBe('![image](link)');
|
||||
expect(convertHtmlToMarkdown(h(`<img src="link" alt="name">`))).toBe('![name](link)');
|
||||
expect(convertHtmlToMarkdown(h(`<img src="link" width="1" height="1">`))).toBe('<img alt="image" width="1" height="1" src="link">');
|
||||
|
||||
expect(convertHtmlToMarkdown(h(`<p>txt</p>`))).toBe('txt\n');
|
||||
expect(convertHtmlToMarkdown(h(`<blockquote>a\nb</blockquote>`))).toBe('> a\n> b\n');
|
||||
|
||||
expect(convertHtmlToMarkdown(h(`<ol><li>a<ul><li>b</li></ul></li></ol>`))).toBe('1. a\n * b\n\n');
|
||||
expect(convertHtmlToMarkdown(h(`<ol><li><input checked>a</li></ol>`))).toBe('1. [x] a\n');
|
||||
});
|
119
web_src/js/markup/html2markdown.ts
Normal file
119
web_src/js/markup/html2markdown.ts
Normal file
|
@ -0,0 +1,119 @@
|
|||
import {htmlEscape} from 'escape-goat';
|
||||
|
||||
type Processors = {
|
||||
[tagName: string]: (el: HTMLElement) => string | HTMLElement | void;
|
||||
}
|
||||
|
||||
type ProcessorContext = {
|
||||
elementIsFirst: boolean;
|
||||
elementIsLast: boolean;
|
||||
listNestingLevel: number;
|
||||
}
|
||||
|
||||
function prepareProcessors(ctx:ProcessorContext): Processors {
|
||||
const processors = {
|
||||
H1(el) {
|
||||
const level = parseInt(el.tagName.slice(1));
|
||||
el.textContent = `${'#'.repeat(level)} ${el.textContent.trim()}`;
|
||||
},
|
||||
STRONG(el) {
|
||||
return `**${el.textContent}**`;
|
||||
},
|
||||
EM(el) {
|
||||
return `_${el.textContent}_`;
|
||||
},
|
||||
DEL(el) {
|
||||
return `~~${el.textContent}~~`;
|
||||
},
|
||||
|
||||
A(el) {
|
||||
const text = el.textContent || 'link';
|
||||
const href = el.getAttribute('href');
|
||||
if (/^https?:/.test(text) && text === href) {
|
||||
return text;
|
||||
}
|
||||
return href ? `[${text}](${href})` : text;
|
||||
},
|
||||
IMG(el) {
|
||||
const alt = el.getAttribute('alt') || 'image';
|
||||
const src = el.getAttribute('src');
|
||||
const widthAttr = el.hasAttribute('width') ? ` width="${htmlEscape(el.getAttribute('width') || '')}"` : '';
|
||||
const heightAttr = el.hasAttribute('height') ? ` height="${htmlEscape(el.getAttribute('height') || '')}"` : '';
|
||||
if (widthAttr || heightAttr) {
|
||||
return `<img alt="${htmlEscape(alt)}"${widthAttr}${heightAttr} src="${htmlEscape(src)}">`;
|
||||
}
|
||||
return `![${alt}](${src})`;
|
||||
},
|
||||
|
||||
P(el) {
|
||||
el.textContent = `${el.textContent}\n`;
|
||||
},
|
||||
BLOCKQUOTE(el) {
|
||||
el.textContent = `${el.textContent.replace(/^/mg, '> ')}\n`;
|
||||
},
|
||||
|
||||
OL(el) {
|
||||
const preNewLine = ctx.listNestingLevel ? '\n' : '';
|
||||
el.textContent = `${preNewLine}${el.textContent}\n`;
|
||||
},
|
||||
LI(el) {
|
||||
const parent = el.parentNode;
|
||||
const bullet = parent.tagName === 'OL' ? `1. ` : '* ';
|
||||
const nestingIdentLevel = Math.max(0, ctx.listNestingLevel - 1);
|
||||
el.textContent = `${' '.repeat(nestingIdentLevel * 4)}${bullet}${el.textContent}${ctx.elementIsLast ? '' : '\n'}`;
|
||||
return el;
|
||||
},
|
||||
INPUT(el) {
|
||||
return el.checked ? '[x] ' : '[ ] ';
|
||||
},
|
||||
|
||||
CODE(el) {
|
||||
const text = el.textContent;
|
||||
if (el.parentNode && el.parentNode.tagName === 'PRE') {
|
||||
el.textContent = `\`\`\`\n${text}\n\`\`\`\n`;
|
||||
return el;
|
||||
}
|
||||
if (text.includes('`')) {
|
||||
return `\`\` ${text} \`\``;
|
||||
}
|
||||
return `\`${text}\``;
|
||||
},
|
||||
};
|
||||
processors['UL'] = processors.OL;
|
||||
for (let level = 2; level <= 6; level++) {
|
||||
processors[`H${level}`] = processors.H1;
|
||||
}
|
||||
return processors;
|
||||
}
|
||||
|
||||
function processElement(ctx :ProcessorContext, processors: Processors, el: HTMLElement) {
|
||||
if (el.hasAttribute('data-markdown-generated-content')) return el.textContent;
|
||||
if (el.tagName === 'A' && el.children.length === 1 && el.children[0].tagName === 'IMG') {
|
||||
return processElement(ctx, processors, el.children[0] as HTMLElement);
|
||||
}
|
||||
|
||||
const isListContainer = el.tagName === 'OL' || el.tagName === 'UL';
|
||||
if (isListContainer) ctx.listNestingLevel++;
|
||||
for (let i = 0; i < el.children.length; i++) {
|
||||
ctx.elementIsFirst = i === 0;
|
||||
ctx.elementIsLast = i === el.children.length - 1;
|
||||
processElement(ctx, processors, el.children[i] as HTMLElement);
|
||||
}
|
||||
if (isListContainer) ctx.listNestingLevel--;
|
||||
|
||||
if (processors[el.tagName]) {
|
||||
const ret = processors[el.tagName](el);
|
||||
if (ret && ret !== el) {
|
||||
el.replaceWith(typeof ret === 'string' ? document.createTextNode(ret) : ret);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function convertHtmlToMarkdown(el: HTMLElement): string {
|
||||
const div = document.createElement('div');
|
||||
div.append(el);
|
||||
const ctx = {} as ProcessorContext;
|
||||
ctx.listNestingLevel = 0;
|
||||
processElement(ctx, prepareProcessors(ctx), el);
|
||||
return div.textContent;
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import {createElementFromAttrs, createElementFromHTML} from './dom.ts';
|
||||
import {createElementFromAttrs, createElementFromHTML, querySingleVisibleElem} from './dom.ts';
|
||||
|
||||
test('createElementFromHTML', () => {
|
||||
expect(createElementFromHTML('<a>foo<span>bar</span></a>').outerHTML).toEqual('<a>foo<span>bar</span></a>');
|
||||
|
@ -16,3 +16,12 @@ test('createElementFromAttrs', () => {
|
|||
}, 'txt', createElementFromHTML('<span>inner</span>'));
|
||||
expect(el.outerHTML).toEqual('<button id="the-id" class="cls-1 cls-2" disabled="" tabindex="0" data-foo="the-data">txt<span>inner</span></button>');
|
||||
});
|
||||
|
||||
test('querySingleVisibleElem', () => {
|
||||
let el = createElementFromHTML('<div><span>foo</span></div>');
|
||||
expect(querySingleVisibleElem(el, 'span').textContent).toEqual('foo');
|
||||
el = createElementFromHTML('<div><span style="display: none;">foo</span><span>bar</span></div>');
|
||||
expect(querySingleVisibleElem(el, 'span').textContent).toEqual('bar');
|
||||
el = createElementFromHTML('<div><span>foo</span><span>bar</span></div>');
|
||||
expect(() => querySingleVisibleElem(el, 'span')).toThrowError('Expected exactly one visible element');
|
||||
});
|
||||
|
|
|
@ -269,8 +269,8 @@ export function initSubmitEventPolyfill() {
|
|||
*/
|
||||
export function isElemVisible(element: HTMLElement): boolean {
|
||||
if (!element) return false;
|
||||
|
||||
return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
|
||||
// checking element.style.display is not necessary for browsers, but it is required by some tests with happy-dom because happy-dom doesn't really do layout
|
||||
return Boolean((element.offsetWidth || element.offsetHeight || element.getClientRects().length) && element.style.display !== 'none');
|
||||
}
|
||||
|
||||
// replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this
|
||||
|
@ -330,3 +330,10 @@ export function animateOnce(el: Element, animationClassName: string): Promise<vo
|
|||
el.classList.add(animationClassName);
|
||||
});
|
||||
}
|
||||
|
||||
export function querySingleVisibleElem<T extends HTMLElement>(parent: Element, selector: string): T | null {
|
||||
const elems = parent.querySelectorAll<HTMLElement>(selector);
|
||||
const candidates = Array.from(elems).filter(isElemVisible);
|
||||
if (candidates.length > 1) throw new Error(`Expected exactly one visible element matching selector "${selector}", but found ${candidates.length}`);
|
||||
return candidates.length ? candidates[0] as T : null;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue