Merge branch 'main' into pacman-packages

pull/31037/head
wxiaoguang 1 week ago committed by GitHub
commit e07ddcb5d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      models/activities/action.go
  2. 1
      models/migrations/migrations.go
  3. 52
      models/migrations/v1_23/v308.go
  4. 3
      models/perm/access_mode.go
  5. 5
      modules/indexer/code/bleve/bleve.go
  6. 13
      modules/indexer/code/elasticsearch/elasticsearch.go
  7. 49
      modules/indexer/code/indexer_test.go
  8. 9
      modules/indexer/internal/bleve/util.go
  9. 8
      modules/indexer/internal/bleve/util_test.go
  10. 5
      modules/markup/html.go
  11. 2
      modules/markup/html_codepreview_test.go
  12. 12
      modules/markup/html_internal_test.go
  13. 16
      modules/markup/html_test.go
  14. 12
      modules/markup/markdown/markdown_test.go
  15. 1
      modules/markup/sanitizer_default.go
  16. 48
      modules/repository/collaborator.go
  17. 280
      modules/repository/collaborator_test.go
  18. 143
      modules/repository/create.go
  19. 1
      modules/repository/main_test.go
  20. 16
      modules/templates/util_render_test.go
  21. 2
      routers/api/v1/api.go
  22. 6
      routers/api/v1/misc/markup_test.go
  23. 25
      routers/api/v1/repo/collaborators.go
  24. 13
      routers/api/v1/repo/issue_attachment.go
  25. 13
      routers/api/v1/repo/issue_comment_attachment.go
  26. 13
      routers/api/v1/repo/release_attachment.go
  27. 5
      routers/web/repo/setting/collaboration.go
  28. 9
      services/attachment/attachment.go
  29. 21
      services/context/upload/upload.go
  30. 1
      services/feed/action_test.go
  31. 1
      services/issue/main_test.go
  32. 1
      services/mailer/main_test.go
  33. 2
      services/repository/adopt.go
  34. 49
      services/repository/collaboration.go
  35. 16
      services/repository/collaboration_test.go
  36. 141
      services/repository/create.go
  37. 2
      services/repository/fork.go
  38. 2
      services/repository/generate.go
  39. 6
      services/repository/transfer.go
  40. 2
      templates/repo/diff/comments.tmpl
  41. 2
      templates/repo/issue/view_content.tmpl
  42. 4
      templates/repo/issue/view_content/comments.tmpl
  43. 18
      templates/repo/issue/view_content/context_menu.tmpl
  44. 2
      templates/repo/issue/view_content/conversation.tmpl
  45. 11
      templates/swagger/v1_json.tmpl
  46. 5
      tests/gitea-repositories-meta/org42/search-by-path.git/description
  47. 2
      tests/gitea-repositories-meta/org42/search-by-path.git/info/refs
  48. BIN
      tests/gitea-repositories-meta/org42/search-by-path.git/objects/info/commit-graph
  49. 2
      tests/gitea-repositories-meta/org42/search-by-path.git/objects/info/packs
  50. BIN
      tests/gitea-repositories-meta/org42/search-by-path.git/objects/pack/pack-393dc29256bc27cb2ec73898507df710be7a3cf5.bitmap
  51. BIN
      tests/gitea-repositories-meta/org42/search-by-path.git/objects/pack/pack-393dc29256bc27cb2ec73898507df710be7a3cf5.idx
  52. BIN
      tests/gitea-repositories-meta/org42/search-by-path.git/objects/pack/pack-393dc29256bc27cb2ec73898507df710be7a3cf5.rev
  53. BIN
      tests/gitea-repositories-meta/org42/search-by-path.git/objects/pack/pack-a7bef76cf6e2b46bc816936ab69306fb10aea571.bitmap
  54. BIN
      tests/gitea-repositories-meta/org42/search-by-path.git/objects/pack/pack-a7bef76cf6e2b46bc816936ab69306fb10aea571.idx
  55. BIN
      tests/gitea-repositories-meta/org42/search-by-path.git/objects/pack/pack-a7bef76cf6e2b46bc816936ab69306fb10aea571.pack
  56. BIN
      tests/gitea-repositories-meta/org42/search-by-path.git/objects/pack/pack-a7bef76cf6e2b46bc816936ab69306fb10aea571.rev
  57. 2
      tests/gitea-repositories-meta/org42/search-by-path.git/packed-refs
  58. 23
      tests/integration/api_comment_attachment_test.go
  59. 22
      tests/integration/api_issue_attachment_test.go
  60. 40
      tests/integration/api_releases_attachment_test.go
  61. 8
      web_src/js/features/comp/QuickSubmit.ts
  62. 76
      web_src/js/features/repo-issue-edit.ts
  63. 24
      web_src/js/markup/html2markdown.test.ts
  64. 119
      web_src/js/markup/html2markdown.ts
  65. 11
      web_src/js/utils/dom.test.ts
  66. 11
      web_src/js/utils/dom.ts

@ -171,7 +171,10 @@ func (a *Action) TableIndices() []*schemas.Index {
cudIndex := schemas.NewIndex("c_u_d", schemas.IndexType) cudIndex := schemas.NewIndex("c_u_d", schemas.IndexType)
cudIndex.AddColumn("created_unix", "user_id", "is_deleted") 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 return indices
} }

@ -365,6 +365,7 @@ func prepareMigrationTasks() []*migration {
newMigration(305, "Add Repository Licenses", v1_23.AddRepositoryLicenses), newMigration(305, "Add Repository Licenses", v1_23.AddRepositoryLicenses),
newMigration(306, "Add BlockAdminMergeOverride to ProtectedBranch", v1_23.AddBlockAdminMergeOverrideBranchProtection), 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(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 return preparedMigrations
} }

@ -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) 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/camelcase"
"github.com/blevesearch/bleve/v2/analysis/token/lowercase" "github.com/blevesearch/bleve/v2/analysis/token/lowercase"
"github.com/blevesearch/bleve/v2/analysis/token/unicodenorm" "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/analysis/tokenizer/unicode"
"github.com/blevesearch/bleve/v2/mapping" "github.com/blevesearch/bleve/v2/mapping"
"github.com/blevesearch/bleve/v2/search/query" "github.com/blevesearch/bleve/v2/search/query"
@ -69,7 +70,7 @@ const (
filenameIndexerAnalyzer = "filenameIndexerAnalyzer" filenameIndexerAnalyzer = "filenameIndexerAnalyzer"
filenameIndexerTokenizer = "filenameIndexerTokenizer" filenameIndexerTokenizer = "filenameIndexerTokenizer"
repoIndexerDocType = "repoIndexerDocType" repoIndexerDocType = "repoIndexerDocType"
repoIndexerLatestVersion = 7 repoIndexerLatestVersion = 8
) )
// generateBleveIndexMapping generates a bleve index mapping for the repo indexer // 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{ } else if err := mapping.AddCustomAnalyzer(repoIndexerAnalyzer, map[string]any{
"type": analyzer_custom.Name, "type": analyzer_custom.Name,
"char_filters": []string{}, "char_filters": []string{},
"tokenizer": unicode.Name, "tokenizer": letter.Name,
"token_filters": []string{unicodeNormalizeName, camelcase.Name, lowercase.Name}, "token_filters": []string{unicodeNormalizeName, camelcase.Name, lowercase.Name},
}); err != nil { }); err != nil {
return nil, err return nil, err

@ -30,7 +30,7 @@ import (
) )
const ( const (
esRepoIndexerLatestVersion = 2 esRepoIndexerLatestVersion = 3
// multi-match-types, currently only 2 types are used // 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 // Reference: https://www.elastic.co/guide/en/elasticsearch/reference/7.0/query-dsl-multi-match-query.html#multi-match-types
esMultiMatchTypeBestFields = "best_fields" esMultiMatchTypeBestFields = "best_fields"
@ -60,6 +60,10 @@ const (
"settings": { "settings": {
"analysis": { "analysis": {
"analyzer": { "analyzer": {
"content_analyzer": {
"tokenizer": "content_tokenizer",
"filter" : ["lowercase"]
},
"filename_path_analyzer": { "filename_path_analyzer": {
"tokenizer": "path_tokenizer" "tokenizer": "path_tokenizer"
}, },
@ -68,6 +72,10 @@ const (
} }
}, },
"tokenizer": { "tokenizer": {
"content_tokenizer": {
"type": "simple_pattern_split",
"pattern": "[^a-zA-Z0-9]"
},
"path_tokenizer": { "path_tokenizer": {
"type": "path_hierarchy", "type": "path_hierarchy",
"delimiter": "/" "delimiter": "/"
@ -104,7 +112,8 @@ const (
"content": { "content": {
"type": "text", "type": "text",
"term_vector": "with_positions_offsets", "term_vector": "with_positions_offsets",
"index": true "index": true,
"analyzer": "content_analyzer"
}, },
"commit_id": { "commit_id": {
"type": "keyword", "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 { for _, kw := range keywords {

@ -6,12 +6,13 @@ package bleve
import ( import (
"errors" "errors"
"os" "os"
"unicode"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"github.com/blevesearch/bleve/v2" "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/blevesearch/bleve/v2/index/upsidedown"
"github.com/ethantkoenig/rupture" "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. // 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. // Given a phrasse, its shortest word determines its fuzziness. If a phrase uses CJK (eg: `갃갃갃` `啊啊啊`), the fuzziness is zero.
func GuessFuzzinessByKeyword(s string) int { func GuessFuzzinessByKeyword(s string) int {
tokenizer := unicode.NewUnicodeTokenizer() tokenizer := unicode_tokenizer.NewUnicodeTokenizer()
tokens := tokenizer.Tokenize([]byte(s)) tokens := tokenizer.Tokenize([]byte(s))
if len(tokens) > 0 { 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 // 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 // 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. // 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 { for _, r := range s {
if r >= 128 { if r >= 128 || !unicode.IsLetter(r) {
return 0 return 0
} }
} }

@ -35,6 +35,14 @@ func TestBleveGuessFuzzinessByKeyword(t *testing.T) {
Input: "갃갃갃", Input: "갃갃갃",
Fuzziness: 0, Fuzziness: 0,
}, },
{
Input: "repo1",
Fuzziness: 0,
},
{
Input: "avocado.md",
Fuzziness: 0,
},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {

@ -442,7 +442,10 @@ func createLink(href, content, class string) *html.Node {
a := &html.Node{ a := &html.Node{
Type: html.ElementNode, Type: html.ElementNode,
Data: atom.A.String(), Data: atom.A.String(),
Attr: []html.Attribute{{Key: "href", Val: href}}, Attr: []html.Attribute{
{Key: "href", Val: href},
{Key: "data-markdown-generated-content"},
},
} }
if class != "" { if class != "" {

@ -30,5 +30,5 @@ func TestRenderCodePreview(t *testing.T) {
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer)) 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://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 // link an HTML link
func link(href, class, contents string) string { func link(href, class, contents string) string {
if class != "" { extra := ` data-markdown-generated-content=""`
class = " class=\"" + class + "\"" extra += util.Iif(class != "", ` class="`+class+`"`, "")
} return fmt.Sprintf(`<a href="%s"%s>%s</a>`, href, extra, contents)
return fmt.Sprintf("<a href=\"%s\"%s>%s</a>", href, class, contents)
} }
var numericMetas = map[string]string{ var numericMetas = map[string]string{
@ -353,7 +351,9 @@ func TestRender_FullIssueURLs(t *testing.T) {
Metas: localMetas, Metas: localMetas,
}, []processor{fullIssuePatternProcessor}, strings.NewReader(input), &result) }, []processor{fullIssuePatternProcessor}, strings.NewReader(input), &result)
assert.NoError(t, err) 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", 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") "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, Metas: localMetas,
}, input) }, input)
assert.NoError(t, err) 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( test(
@ -156,7 +158,9 @@ func TestRender_links(t *testing.T) {
}, },
}, input) }, input)
assert.NoError(t, err) 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 oldCustomURLSchemes := setting.Markdown.CustomURLSchemes
@ -267,7 +271,9 @@ func TestRender_email(t *testing.T) {
}, },
}, input) }, input)
assert.NoError(t, err) 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 // Text that should be turned into email link
@ -616,7 +622,9 @@ func TestPostProcess_RenderDocument(t *testing.T) {
Metas: localMetas, Metas: localMetas,
}, strings.NewReader(input), &res) }, strings.NewReader(input), &res)
assert.NoError(t, err) 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. // Issue index shouldn't be post processing in a document.

@ -311,7 +311,8 @@ func TestTotal_RenderWiki(t *testing.T) {
IsWiki: true, IsWiki: true,
}, sameCases[i]) }, sameCases[i])
assert.NoError(t, err) 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{ testCases := []string{
@ -336,7 +337,8 @@ func TestTotal_RenderWiki(t *testing.T) {
IsWiki: true, IsWiki: true,
}, testCases[i]) }, testCases[i])
assert.NoError(t, err) 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, Metas: localMetas,
}, sameCases[i]) }, sameCases[i])
assert.NoError(t, err) 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{} testCases := []string{}
@ -996,7 +999,8 @@ space</p>
for i, c := range cases { for i, c := range cases {
result, err := markdown.RenderString(&markup.RenderContext{Ctx: context.Background(), Links: c.Links, IsWiki: c.IsWiki}, input) 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.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", "start", "summary", "tabindex", "target",
"title", "type", "usemap", "valign", "value", "title", "type", "usemap", "valign", "value",
"vspace", "width", "itemprop", "vspace", "width", "itemprop",
"data-markdown-generated-content",
} }
generalSafeElements := []string{ 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" "path/filepath"
"strings" "strings"
"code.gitea.io/gitea/models"
activities_model "code.gitea.io/gitea/models/activities" activities_model "code.gitea.io/gitea/models/activities"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git" git_model "code.gitea.io/gitea/models/git"
"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm"
access_model "code.gitea.io/gitea/models/perm/access" access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/models/webhook"
issue_indexer "code.gitea.io/gitea/modules/indexer/issues" issue_indexer "code.gitea.io/gitea/modules/indexer/issues"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util" "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 const notRegularFileMode = os.ModeSymlink | os.ModeNamedPipe | os.ModeSocket | os.ModeDevice | os.ModeCharDevice | os.ModeIrregular
// getDirectorySize returns the disk consumption for a given path // 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/unittest"
_ "code.gitea.io/gitea/models"
_ "code.gitea.io/gitea/models/actions" _ "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="/mention-user" class="mention">@mention-user</a> test
<a href="/user13/repo11/issues/123" class="ref-issue">#123</a> <a href="/user13/repo11/issues/123" class="ref-issue">#123</a>
space` space`
actual := strings.ReplaceAll(string(newTestRenderUtils().RenderCommitBody(testInput(), testMetas)), ` data-markdown-generated-content=""`, "")
assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitBody(testInput(), testMetas)) assert.EqualValues(t, expected, actual)
} }
func TestRenderCommitMessage(t *testing.T) { 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)) assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitMessage(testInput(), testMetas))
} }
func TestRenderCommitMessageLinkSubject(t *testing.T) { func TestRenderCommitMessageLinkSubject(t *testing.T) {
expected := `<a href="https://example.com/link" class="default-link muted">space </a><a href="/mention-user" 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)) assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", testMetas))
} }
@ -168,7 +168,8 @@ mail@domain.com
space<SPACE><SPACE> space<SPACE><SPACE>
` `
expected = strings.ReplaceAll(expected, "<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) { func TestRenderMarkdownToHtml(t *testing.T) {
@ -193,7 +194,8 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
#123 #123
space</p> 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) { func TestRenderLabels(t *testing.T) {
@ -211,5 +213,5 @@ func TestRenderLabels(t *testing.T) {
func TestUserMention(t *testing.T) { func TestUserMention(t *testing.T) {
rendered := newTestRenderUtils().MarkdownToHtml("@no-such-user @mention-user @mention-user") 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.Get("", reqAnyRepoReader(), repo.ListCollaborators)
m.Group("/{collaborator}", func() { m.Group("/{collaborator}", func() {
m.Combo("").Get(reqAnyRepoReader(), repo.IsCollaborator). m.Combo("").Get(reqAnyRepoReader(), repo.IsCollaborator).
Put(reqAdmin(), bind(api.AddCollaboratorOption{}), repo.AddCollaborator). Put(reqAdmin(), bind(api.AddCollaboratorOption{}), repo.AddOrUpdateCollaborator).
Delete(reqAdmin(), repo.DeleteCollaborator) Delete(reqAdmin(), repo.DeleteCollaborator)
m.Get("/permission", repo.GetRepoPermissions) 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") ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markup")
web.SetForm(ctx, &options) web.SetForm(ctx, &options)
Markup(ctx) 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) assert.Equal(t, expectedCode, resp.Code)
resp.Body.Reset() 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") ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown")
web.SetForm(ctx, &options) web.SetForm(ctx, &options)
Markdown(ctx) 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) assert.Equal(t, responseCode, resp.Code)
resp.Body.Reset() resp.Body.Reset()
} }

@ -12,7 +12,6 @@ import (
access_model "code.gitea.io/gitea/models/perm/access" access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
repo_module "code.gitea.io/gitea/modules/repository"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/api/v1/utils" "code.gitea.io/gitea/routers/api/v1/utils"
@ -123,11 +122,11 @@ func IsCollaborator(ctx *context.APIContext) {
} }
} }
// AddCollaborator add a collaborator to a repository // AddOrUpdateCollaborator add or update a collaborator to a repository
func AddCollaborator(ctx *context.APIContext) { func AddOrUpdateCollaborator(ctx *context.APIContext) {
// swagger:operation PUT /repos/{owner}/{repo}/collaborators/{collaborator} repository repoAddCollaborator // 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: // produces:
// - application/json // - application/json
// parameters: // parameters:
@ -177,21 +176,19 @@ func AddCollaborator(ctx *context.APIContext) {
return return
} }
if err := repo_module.AddCollaborator(ctx, ctx.Repo.Repository, collaborator); err != nil { p := perm.AccessModeWrite
if form.Permission != nil {
p = perm.ParseAccessMode(*form.Permission)
}
if err := repo_service.AddOrUpdateCollaborator(ctx, ctx.Repo.Repository, collaborator, p); err != nil {
if errors.Is(err, user_model.ErrBlockedUser) { if errors.Is(err, user_model.ErrBlockedUser) {
ctx.Error(http.StatusForbidden, "AddCollaborator", err) ctx.Error(http.StatusForbidden, "AddOrUpdateCollaborator", err)
} else { } else {
ctx.Error(http.StatusInternalServerError, "AddCollaborator", err) ctx.Error(http.StatusInternalServerError, "AddOrUpdateCollaborator", err)
}
return
} }
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 return
} }
}
ctx.Status(http.StatusNoContent) ctx.Status(http.StatusNoContent)
} }

@ -12,7 +12,7 @@ import (
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web" "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"
"code.gitea.io/gitea/services/context/upload" "code.gitea.io/gitea/services/context/upload"
"code.gitea.io/gitea/services/convert" "code.gitea.io/gitea/services/convert"
@ -181,7 +181,7 @@ func CreateIssueAttachment(ctx *context.APIContext) {
filename = query 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, Name: filename,
UploaderID: ctx.Doer.ID, UploaderID: ctx.Doer.ID,
RepoID: ctx.Repo.Repository.ID, RepoID: ctx.Repo.Repository.ID,
@ -247,6 +247,8 @@ func EditIssueAttachment(ctx *context.APIContext) {
// "$ref": "#/responses/Attachment" // "$ref": "#/responses/Attachment"
// "404": // "404":
// "$ref": "#/responses/error" // "$ref": "#/responses/error"
// "422":
// "$ref": "#/responses/validationError"
// "423": // "423":
// "$ref": "#/responses/repoArchivedError" // "$ref": "#/responses/repoArchivedError"
@ -261,8 +263,13 @@ func EditIssueAttachment(ctx *context.APIContext) {
attachment.Name = form.Name 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) ctx.Error(http.StatusInternalServerError, "UpdateAttachment", err)
return
} }
ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attachment)) ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attachment))

@ -14,7 +14,7 @@ import (
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web" "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"
"code.gitea.io/gitea/services/context/upload" "code.gitea.io/gitea/services/context/upload"
"code.gitea.io/gitea/services/convert" "code.gitea.io/gitea/services/convert"
@ -189,7 +189,7 @@ func CreateIssueCommentAttachment(ctx *context.APIContext) {
filename = query 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, Name: filename,
UploaderID: ctx.Doer.ID, UploaderID: ctx.Doer.ID,
RepoID: ctx.Repo.Repository.ID, RepoID: ctx.Repo.Repository.ID,
@ -263,6 +263,8 @@ func EditIssueCommentAttachment(ctx *context.APIContext) {
// "$ref": "#/responses/Attachment" // "$ref": "#/responses/Attachment"
// "404": // "404":
// "$ref": "#/responses/error" // "$ref": "#/responses/error"
// "422":
// "$ref": "#/responses/validationError"
// "423": // "423":
// "$ref": "#/responses/repoArchivedError" // "$ref": "#/responses/repoArchivedError"
attach := getIssueCommentAttachmentSafeWrite(ctx) attach := getIssueCommentAttachmentSafeWrite(ctx)
@ -275,8 +277,13 @@ func EditIssueCommentAttachment(ctx *context.APIContext) {
attach.Name = form.Name 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) ctx.Error(http.StatusInternalServerError, "UpdateAttachment", attach)
return
} }
ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach)) ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach))
} }

@ -13,7 +13,7 @@ import (
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web" "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"
"code.gitea.io/gitea/services/context/upload" "code.gitea.io/gitea/services/context/upload"
"code.gitea.io/gitea/services/convert" "code.gitea.io/gitea/services/convert"
@ -234,7 +234,7 @@ func CreateReleaseAttachment(ctx *context.APIContext) {
} }
// Create a new attachment and save the file // 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, Name: filename,
UploaderID: ctx.Doer.ID, UploaderID: ctx.Doer.ID,
RepoID: ctx.Repo.Repository.ID, RepoID: ctx.Repo.Repository.ID,
@ -291,6 +291,8 @@ func EditReleaseAttachment(ctx *context.APIContext) {
// responses: // responses:
// "201": // "201":
// "$ref": "#/responses/Attachment" // "$ref": "#/responses/Attachment"
// "422":
// "$ref": "#/responses/validationError"
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
@ -322,8 +324,13 @@ func EditReleaseAttachment(ctx *context.APIContext) {
attach.Name = form.Name 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) ctx.Error(http.StatusInternalServerError, "UpdateAttachment", attach)
return
} }
ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach)) ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach))
} }

@ -14,7 +14,6 @@ import (
unit_model "code.gitea.io/gitea/models/unit" unit_model "code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
repo_module "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/mailer" "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) { if errors.Is(err, user_model.ErrBlockedUser) {
ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator.blocked_user")) ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator.blocked_user"))
ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration")
} else { } else {
ctx.ServerError("AddCollaborator", err) ctx.ServerError("AddOrUpdateCollaborator", err)
} }
return 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) 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 { 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]+/\*$`) 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 { func Verify(buf []byte, fileName, allowedTypesStr string) error {
allowedTypesStr = strings.ReplaceAll(allowedTypesStr, "|", ",") // compat for old config format 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} return ErrFileTypeForbidden{Type: fullMimeType}
} }
extension := strings.ToLower(path.Ext(fileName)) 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 // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Unique_file_type_specifiers
for _, allowEntry := range allowedTypes { for _, allowEntry := range allowedTypes {
if allowEntry == "*/*" { if allowEntry == "*/*" {
return nil // everything allowed return nil // everything allowed
} else if strings.HasPrefix(allowEntry, ".") && allowEntry == extension { }
if strings.HasPrefix(allowEntry, ".") && allowEntry == extension {
return nil // extension is allowed 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 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/* return nil // wildcard match, e.g. image/*
} }
} }
if !isBufEmpty {
log.Info("Attachment with type %s blocked from upload", fullMimeType) log.Info("Attachment with type %s blocked from upload", fullMimeType)
}
return ErrFileTypeForbidden{Type: fullMimeType} return ErrFileTypeForbidden{Type: fullMimeType}
} }

@ -13,6 +13,7 @@ import (
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
_ "code.gitea.io/gitea/models"
_ "code.gitea.io/gitea/models/actions" _ "code.gitea.io/gitea/models/actions"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"

@ -8,6 +8,7 @@ import (
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
_ "code.gitea.io/gitea/models"
_ "code.gitea.io/gitea/models/actions" _ "code.gitea.io/gitea/models/actions"
) )

@ -8,6 +8,7 @@ import (
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
_ "code.gitea.io/gitea/models"
_ "code.gitea.io/gitea/models/actions" _ "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 return err
} }

@ -9,11 +9,60 @@ import (
"code.gitea.io/gitea/models" "code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/perm"
access_model "code.gitea.io/gitea/models/perm/access" access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"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. // DeleteCollaboration removes collaboration relation between the user and repository.
func DeleteCollaboration(ctx context.Context, repo *repo_model.Repository, collaborator *user_model.User) (err error) { func DeleteCollaboration(ctx context.Context, repo *repo_model.Repository, collaborator *user_model.User) (err error) {
collaboration := &repo_model.Collaboration{ collaboration := &repo_model.Collaboration{

@ -7,6 +7,7 @@ import (
"testing" "testing"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/perm"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
@ -14,6 +15,21 @@ import (
"github.com/stretchr/testify/assert" "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) { func TestRepository_DeleteCollaboration(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())

@ -12,9 +12,15 @@ import (
"strings" "strings"
"time" "time"
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/db" "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" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/models/webhook"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log" "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 var rollbackRepo *repo_model.Repository
if err := db.WithTx(ctx, func(ctx context.Context) error { 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 return err
} }
@ -335,3 +341,136 @@ func CreateRepositoryDirectly(ctx context.Context, doer, u *user_model.User, opt
return repo, nil 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 { 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 return err
} }

@ -343,7 +343,7 @@ func generateRepository(ctx context.Context, doer, owner *user_model.User, templ
ObjectFormatName: templateRepo.ObjectFormatName, 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 return nil, err
} }

@ -20,7 +20,6 @@ import (
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/globallock" "code.gitea.io/gitea/modules/globallock"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
repo_module "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
notify_service "code.gitea.io/gitea/services/notify" notify_service "code.gitea.io/gitea/services/notify"
) )
@ -419,10 +418,7 @@ func StartRepositoryTransfer(ctx context.Context, doer, newOwner *user_model.Use
return err return err
} }
if !hasAccess { if !hasAccess {
if err := repo_module.AddCollaborator(ctx, repo, newOwner); err != nil { if err := AddOrUpdateCollaborator(ctx, repo, newOwner, perm.AccessModeRead); err != nil {
return err
}
if err := repo_model.ChangeCollaborationAccessMode(ctx, repo, newOwner.ID, perm.AccessModeRead); err != nil {
return err return err
} }
} }

@ -49,7 +49,7 @@
{{end}} {{end}}
{{end}} {{end}}
{{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.root.RepoLink .ID)}} {{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> </div>
<div class="ui attached segment comment-body"> <div class="ui attached segment comment-body">

@ -48,7 +48,7 @@
{{if not $.Repository.IsArchived}} {{if not $.Repository.IsArchived}}
{{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/issues/%d/reactions" $.RepoLink .Issue.Index)}} {{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/issues/%d/reactions" $.RepoLink .Issue.Index)}}
{{end}} {{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> </div>
<div class="ui attached segment comment-body" role="article"> <div class="ui attached segment comment-body" role="article">

@ -55,7 +55,7 @@
{{if not $.Repository.IsArchived}} {{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/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID)}}
{{end}} {{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> </div>
<div class="ui attached segment comment-body" role="article"> <div class="ui attached segment comment-body" role="article">
@ -430,7 +430,7 @@
{{template "repo/issue/view_content/show_role" dict "ShowRole" .ShowRole}} {{template "repo/issue/view_content/show_role" dict "ShowRole" .ShowRole}}
{{if not $.Repository.IsArchived}} {{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/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}} {{end}}
</div> </div>
</div> </div>

@ -5,29 +5,29 @@
<div class="menu"> <div class="menu">
{{$referenceUrl := ""}} {{$referenceUrl := ""}}
{{if .issue}} {{if .issue}}
{{$referenceUrl = printf "%s#%s" .ctxData.Issue.Link .item.HashTag}} {{$referenceUrl = printf "%s#%s" ctx.RootData.Issue.Link .item.HashTag}}
{{else}} {{else}}
{{$referenceUrl = printf "%s/files#%s" .ctxData.Issue.Link .item.HashTag}} {{$referenceUrl = printf "%s/files#%s" ctx.RootData.Issue.Link .item.HashTag}}
{{end}} {{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> <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}} {{$needDivider := false}}
{{if not .ctxData.Repository.IsArchived}} {{if not ctx.RootData.Repository.IsArchived}}
{{$needDivider = true}} {{$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> <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}} {{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> <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}} {{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="divider"></div>
<div class="item context js-aria-clickable edit-content">{{ctx.Locale.Tr "repo.issues.context.edit"}}</div> <div class="item context js-aria-clickable edit-content">{{ctx.Locale.Tr "repo.issues.context.edit"}}</div>
{{if .delete}} {{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}} {{end}}
{{end}} {{end}}
{{$canUserBlock := call .ctxData.CanBlockUser .ctxData.SignedUser .item.Poster}} {{$canUserBlock := call ctx.RootData.CanBlockUser ctx.RootData.SignedUser .item.Poster}}
{{$canOrgBlock := and .ctxData.Repository.Owner.IsOrganization (call .ctxData.CanBlockUser .ctxData.Repository.Owner .item.Poster)}} {{$canOrgBlock := and ctx.RootData.Repository.Owner.IsOrganization (call ctx.RootData.CanBlockUser ctx.RootData.Repository.Owner .item.Poster)}}
{{if or $canOrgBlock $canUserBlock}} {{if or $canOrgBlock $canUserBlock}}
{{if $needDivider}} {{if $needDivider}}
<div class="divider"></div> <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> <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}} {{end}}
{{if $canOrgBlock}} {{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}} {{end}}
{{end}} {{end}}

@ -83,7 +83,7 @@
{{template "repo/issue/view_content/show_role" dict "ShowRole" .ShowRole}} {{template "repo/issue/view_content/show_role" dict "ShowRole" .ShowRole}}
{{if not $.Repository.IsArchived}} {{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/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}} {{end}}
</div> </div>
</div> </div>

@ -5095,7 +5095,7 @@
"tags": [ "tags": [
"repository" "repository"
], ],
"summary": "Add a collaborator to a repository", "summary": "Add or Update a collaborator to a repository",
"operationId": "repoAddCollaborator", "operationId": "repoAddCollaborator",
"parameters": [ "parameters": [
{ {
@ -7706,6 +7706,9 @@
"404": { "404": {
"$ref": "#/responses/error" "$ref": "#/responses/error"
}, },
"422": {
"$ref": "#/responses/validationError"
},
"423": { "423": {
"$ref": "#/responses/repoArchivedError" "$ref": "#/responses/repoArchivedError"
} }
@ -8328,6 +8331,9 @@
"404": { "404": {
"$ref": "#/responses/error" "$ref": "#/responses/error"
}, },
"422": {
"$ref": "#/responses/validationError"
},
"423": { "423": {
"$ref": "#/responses/repoArchivedError" "$ref": "#/responses/repoArchivedError"
} }
@ -13474,6 +13480,9 @@
}, },
"404": { "404": {
"$ref": "#/responses/notFound" "$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 ├── avocado.md
├── cucumber.md ├── cucumber.md
├── ham.md ├── ham.md
└── potato ├── potato
└── ham.md | └── ham.md
└── example-file.js

@ -3,7 +3,7 @@
65f1bf27bc3bf70f64657658635e66094edbcb4d refs/heads/develop 65f1bf27bc3bf70f64657658635e66094edbcb4d refs/heads/develop
65f1bf27bc3bf70f64657658635e66094edbcb4d refs/heads/feature/1 65f1bf27bc3bf70f64657658635e66094edbcb4d refs/heads/feature/1
78fb907e3a3309eae4fe8fef030874cebbf1cd5e refs/heads/home-md-img-check 78fb907e3a3309eae4fe8fef030874cebbf1cd5e refs/heads/home-md-img-check
3731fe53b763859aaf83e703ee731f6b9447ff1e refs/heads/master 9f894b61946fd2f7b8b9d8e370e4d62f915522f5 refs/heads/master
62fb502a7172d4453f0322a2cc85bddffa57f07a refs/heads/pr-to-update 62fb502a7172d4453f0322a2cc85bddffa57f07a refs/heads/pr-to-update
4649299398e4d39a5c09eb4f534df6f1e1eb87cc refs/heads/sub-home-md-img-check 4649299398e4d39a5c09eb4f534df6f1e1eb87cc refs/heads/sub-home-md-img-check
3fa2f829675543ecfc16b2891aebe8bf0608a8f4 refs/notes/commits 3fa2f829675543ecfc16b2891aebe8bf0608a8f4 refs/notes/commits

@ -1,2 +1,2 @@
P pack-393dc29256bc27cb2ec73898507df710be7a3cf5.pack P pack-a7bef76cf6e2b46bc816936ab69306fb10aea571.pack

@ -4,7 +4,7 @@
65f1bf27bc3bf70f64657658635e66094edbcb4d refs/heads/develop 65f1bf27bc3bf70f64657658635e66094edbcb4d refs/heads/develop
65f1bf27bc3bf70f64657658635e66094edbcb4d refs/heads/feature/1 65f1bf27bc3bf70f64657658635e66094edbcb4d refs/heads/feature/1
78fb907e3a3309eae4fe8fef030874cebbf1cd5e refs/heads/home-md-img-check 78fb907e3a3309eae4fe8fef030874cebbf1cd5e refs/heads/home-md-img-check
3731fe53b763859aaf83e703ee731f6b9447ff1e refs/heads/master 9f894b61946fd2f7b8b9d8e370e4d62f915522f5 refs/heads/master
62fb502a7172d4453f0322a2cc85bddffa57f07a refs/heads/pr-to-update 62fb502a7172d4453f0322a2cc85bddffa57f07a refs/heads/pr-to-update
4649299398e4d39a5c09eb4f534df6f1e1eb87cc refs/heads/sub-home-md-img-check 4649299398e4d39a5c09eb4f534df6f1e1eb87cc refs/heads/sub-home-md-img-check
3fa2f829675543ecfc16b2891aebe8bf0608a8f4 refs/notes/commits 3fa2f829675543ecfc16b2891aebe8bf0608a8f4 refs/notes/commits

@ -151,7 +151,7 @@ func TestAPICreateCommentAttachmentWithUnallowedFile(t *testing.T) {
func TestAPIEditCommentAttachment(t *testing.T) { func TestAPIEditCommentAttachment(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
const newAttachmentName = "newAttachmentName" const newAttachmentName = "newAttachmentName.txt"
attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 6}) attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 6})
comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: attachment.CommentID}) 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}) 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) { func TestAPIDeleteCommentAttachment(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()

@ -126,7 +126,7 @@ func TestAPICreateIssueAttachmentWithUnallowedFile(t *testing.T) {
func TestAPIEditIssueAttachment(t *testing.T) { func TestAPIEditIssueAttachment(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
const newAttachmentName = "newAttachmentName" const newAttachmentName = "hello_world.txt"
attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 1}) attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 1})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: attachment.RepoID}) 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}) 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) { func TestAPIDeleteIssueAttachment(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()

@ -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) { export function handleGlobalEnterQuickSubmit(target) {
let form = target.closest('form'); let form = target.closest('form');
if (form) { if (form) {
@ -12,7 +14,11 @@ export function handleGlobalEnterQuickSubmit(target) {
} }
form = target.closest('.ui.form'); form = target.closest('.ui.form');
if (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 true;
} }
return false; return false;

@ -1,17 +1,19 @@
import $ from 'jquery';
import {handleReply} from './repo-issue.ts'; import {handleReply} from './repo-issue.ts';
import {getComboMarkdownEditor, initComboMarkdownEditor, ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts'; import {getComboMarkdownEditor, initComboMarkdownEditor, ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
import {POST} from '../modules/fetch.ts'; import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.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 {attachRefIssueContextPopup} from './contextpopup.ts';
import {initCommentContent, initMarkupContent} from '../markup/content.ts'; import {initCommentContent, initMarkupContent} from '../markup/content.ts';
import {triggerUploadStateChanged} from './comp/EditorUpload.ts'; import {triggerUploadStateChanged} from './comp/EditorUpload.ts';
import {convertHtmlToMarkdown} from '../markup/html2markdown.ts';
async function onEditContent(event) { async function tryOnEditContent(e) {
event.preventDefault(); 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 editContentZone = segment.querySelector('.edit-content-zone');
const renderContent = segment.querySelector('.render-content'); const renderContent = segment.querySelector('.render-content');
const rawContent = segment.querySelector('.raw-content'); const rawContent = segment.querySelector('.raw-content');
@ -77,20 +79,22 @@ async function onEditContent(event) {
} }
}; };
// Show write/preview tab and copy raw content as needed
showElem(editContentZone);
hideElem(renderContent);
comboMarkdownEditor = getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor')); comboMarkdownEditor = getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'));
if (!comboMarkdownEditor) { if (!comboMarkdownEditor) {
editContentZone.innerHTML = document.querySelector('#issue-comment-editor-template').innerHTML; editContentZone.innerHTML = document.querySelector('#issue-comment-editor-template').innerHTML;
const saveButton = editContentZone.querySelector('.ui.primary.button'); const saveButton = querySingleVisibleElem<HTMLButtonElement>(editContentZone, '.ui.primary.button');
const cancelButton = querySingleVisibleElem<HTMLButtonElement>(editContentZone, '.ui.cancel.button');
comboMarkdownEditor = await initComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor')); comboMarkdownEditor = await initComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'));
const syncUiState = () => saveButton.disabled = comboMarkdownEditor.isUploading(); const syncUiState = () => saveButton.disabled = comboMarkdownEditor.isUploading();
comboMarkdownEditor.container.addEventListener(ComboMarkdownEditor.EventUploadStateChanged, syncUiState); comboMarkdownEditor.container.addEventListener(ComboMarkdownEditor.EventUploadStateChanged, syncUiState);
editContentZone.querySelector('.ui.cancel.button').addEventListener('click', cancelAndReset); cancelButton.addEventListener('click', cancelAndReset);
saveButton.addEventListener('click', saveAndRefresh); saveButton.addEventListener('click', saveAndRefresh);
} }
// Show write/preview tab and copy raw content as needed
showElem(editContentZone);
hideElem(renderContent);
// FIXME: ideally here should reload content and attachment list from backend for existing editor, to avoid losing data // FIXME: ideally here should reload content and attachment list from backend for existing editor, to avoid losing data
if (!comboMarkdownEditor.value()) { if (!comboMarkdownEditor.value()) {
comboMarkdownEditor.value(rawContent.textContent); comboMarkdownEditor.value(rawContent.textContent);
@ -100,33 +104,53 @@ async function onEditContent(event) {
triggerUploadStateChanged(comboMarkdownEditor.container); triggerUploadStateChanged(comboMarkdownEditor.container);
} }
export function initRepoIssueCommentEdit() { function extractSelectedMarkdown(container: HTMLElement) {
// Edit issue or comment content const selection = window.getSelection();
$(document).on('click', '.edit-content', onEditContent); if (!selection.rangeCount) return '';
const range = selection.getRangeAt(0);
if (!container.contains(range.commonAncestorContainer)) return '';
// Quote reply // todo: if commonAncestorContainer parent has "[data-markdown-original-content]" attribute, use the parent's markdown content
$(document).on('click', '.quote-reply', async function (event) { // otherwise, use the selected HTML content and respect all "[data-markdown-original-content]/[data-markdown-generated-content]" attributes
event.preventDefault(); const contents = selection.getRangeAt(0).cloneContents();
const target = this.getAttribute('data-target'); const el = document.createElement('div');
const quote = document.querySelector(`#${target}`).textContent.replace(/\n/g, '\n> '); el.append(contents);
const content = `> ${quote}\n\n`; 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; let editor;
if (this.classList.contains('quote-reply-diff')) { if (clickTarget.classList.contains('quote-reply-diff')) {
const replyBtn = this.closest('.comment-code-cloud').querySelector('button.comment-form-reply'); const replyBtn = clickTarget.closest('.comment-code-cloud').querySelector('button.comment-form-reply');
editor = await handleReply(replyBtn); editor = await handleReply(replyBtn);
} else { } else {
// for normal issue/comment page // for normal issue/comment page
editor = getComboMarkdownEditor($('#comment-form .combo-markdown-editor')); editor = getComboMarkdownEditor(document.querySelector('#comment-form .combo-markdown-editor'));
} }
if (editor) {
if (editor.value()) { if (editor.value()) {
editor.value(`${editor.value()}\n\n${content}`); editor.value(`${editor.value()}\n\n${quotedContent}`);
} else { } else {
editor.value(content); editor.value(quotedContent);
} }
editor.focus(); editor.focus();
editor.moveCursorToEnd(); editor.moveCursorToEnd();
} }
export function initRepoIssueCommentEdit() {
document.addEventListener('click', (e) => {
tryOnEditContent(e); // Edit issue or comment content
tryOnQuoteReply(e); // Quote reply to the comment editor
}); });
} }

@ -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');
});

@ -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', () => { test('createElementFromHTML', () => {
expect(createElementFromHTML('<a>foo<span>bar</span></a>').outerHTML).toEqual('<a>foo<span>bar</span></a>'); 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>')); }, '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>'); 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 { export function isElemVisible(element: HTMLElement): boolean {
if (!element) return false; if (!element) return false;
// 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); 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 // 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); 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…
Cancel
Save