diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 899209874f7cc..57c66dddbba55 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -1767,6 +1767,12 @@ LEVEL = Info ;; ;; convert \r\n to \n for Sendmail ;SENDMAIL_CONVERT_CRLF = true +;; +;; convert links of attached images to inline images. Only for images hosted in this gitea instance. +;BASE64_EMBED_IMAGES = false +;; +;; The maximum size of sum of all images in a single email. Default is 9.5MB +;BASE64_EMBED_IMAGES_MAX_SIZE_PER_EMAIL = 9961472 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/models/fixtures/attachment.yml b/models/fixtures/attachment.yml index 7882d8bff2089..f6b5393b4dc65 100644 --- a/models/fixtures/attachment.yml +++ b/models/fixtures/attachment.yml @@ -153,3 +153,16 @@ download_count: 0 size: 0 created_unix: 946684800 + +- + id: 13 + uuid: 1b267670-1793-4cd0-abc1-449269b7cff9 + repo_id: 1 + issue_id: 23 + release_id: 0 + uploader_id: 0 + comment_id: 2 + name: gitea.png + download_count: 0 + size: 1458 + created_unix: 946684800 diff --git a/models/fixtures/issue.yml b/models/fixtures/issue.yml index ca5b1c6cd1df5..c829c755296ed 100644 --- a/models/fixtures/issue.yml +++ b/models/fixtures/issue.yml @@ -372,3 +372,20 @@ created_unix: 1707270422 updated_unix: 1707270422 is_locked: false + +- + id: 23 + repo_id: 1 + index: 6 + poster_id: 1 + original_author_id: 0 + name: issue23 + content: 'content including this image: gitea.png with some more content behind it' + milestone_id: 0 + priority: 0 + is_closed: false + is_pull: false + num_comments: 0 + created_unix: 946684801 + updated_unix: 978307201 + is_locked: false diff --git a/models/fixtures/issue_index.yml b/models/fixtures/issue_index.yml index 5aabc08e388c5..c1e0b546a4013 100644 --- a/models/fixtures/issue_index.yml +++ b/models/fixtures/issue_index.yml @@ -1,6 +1,6 @@ - group_id: 1 - max_index: 5 + max_index: 6 - group_id: 2 diff --git a/models/fixtures/repository.yml b/models/fixtures/repository.yml index 552a78cbd2773..47f9cb8a5d012 100644 --- a/models/fixtures/repository.yml +++ b/models/fixtures/repository.yml @@ -9,7 +9,7 @@ num_watches: 4 num_stars: 0 num_forks: 0 - num_issues: 2 + num_issues: 3 num_closed_issues: 1 num_pulls: 3 num_closed_pulls: 0 diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go index dbbb1e417901f..77c387d0e0d40 100644 --- a/models/issues/issue_test.go +++ b/models/issues/issue_test.go @@ -57,7 +57,7 @@ func Test_GetIssueIDsByRepoID(t *testing.T) { ids, err := issues_model.GetIssueIDsByRepoID(db.DefaultContext, 1) assert.NoError(t, err) - assert.Len(t, ids, 5) + assert.Len(t, ids, 6) } func TestIssueAPIURL(t *testing.T) { @@ -170,7 +170,7 @@ func TestIssues(t *testing.T) { PageSize: 4, }, }, - []int64{1, 2, 3, 5}, + []int64{1, 23, 2, 3}, }, { issues_model.IssuesOptions{ @@ -249,11 +249,11 @@ func TestIssue_InsertIssue(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) // there are 5 issues and max index is 5 on repository 1, so this one should 6 - issue := testInsertIssue(t, "my issue1", "special issue's comments?", 6) + issue := testInsertIssue(t, "my issue1", "special issue's comments?", 7) _, err := db.DeleteByID[issues_model.Issue](db.DefaultContext, issue.ID) assert.NoError(t, err) - issue = testInsertIssue(t, `my issue2, this is my son's love \n \r \ `, "special issue's '' comments?", 7) + issue = testInsertIssue(t, `my issue2, this is my son's love \n \r \ `, "special issue's '' comments?", 8) _, err = db.DeleteByID[issues_model.Issue](db.DefaultContext, issue.ID) assert.NoError(t, err) } @@ -380,7 +380,7 @@ func TestCountIssues(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) count, err := issues_model.CountIssues(db.DefaultContext, &issues_model.IssuesOptions{}) assert.NoError(t, err) - assert.EqualValues(t, 22, count) + assert.EqualValues(t, 23, count) } func TestIssueLoadAttributes(t *testing.T) { diff --git a/models/issues/issue_user_test.go b/models/issues/issue_user_test.go index 7c21aa15eef6a..fef599a36641a 100644 --- a/models/issues/issue_user_test.go +++ b/models/issues/issue_user_test.go @@ -22,7 +22,7 @@ func Test_NewIssueUsers(t *testing.T) { newIssue := &issues_model.Issue{ RepoID: repo.ID, PosterID: 4, - Index: 6, + Index: 7, Title: "newTestIssueTitle", Content: "newTestIssueContent", } diff --git a/models/repo/attachment_test.go b/models/repo/attachment_test.go index c059ffd39a91e..48313aa382235 100644 --- a/models/repo/attachment_test.go +++ b/models/repo/attachment_test.go @@ -51,7 +51,7 @@ func TestDeleteAttachments(t *testing.T) { count, err = repo_model.DeleteAttachmentsByComment(db.DefaultContext, 2, false) assert.NoError(t, err) - assert.Equal(t, 2, count) + assert.Equal(t, 3, count) err = repo_model.DeleteAttachment(db.DefaultContext, &repo_model.Attachment{ID: 8}, false) assert.NoError(t, err) diff --git a/models/unittest/testdb.go b/models/unittest/testdb.go index 7a9ca9698d0e9..f1ce2bd7d58ba 100644 --- a/models/unittest/testdb.go +++ b/models/unittest/testdb.go @@ -49,6 +49,7 @@ func InitSettings() { setting.CustomConf = filepath.Join(setting.CustomPath, "conf/app-unittest-tmp.ini") _ = os.Remove(setting.CustomConf) } + log.Info("CustomConf: %s", setting.CustomConf) setting.InitCfgProvider(setting.CustomConf) setting.LoadCommonSettings() @@ -72,6 +73,7 @@ type TestOptions struct { // MainTest a reusable TestMain(..) function for unit tests that need to use a // test database. Creates the test database, and sets necessary settings. func MainTest(m *testing.M, testOptsArg ...*TestOptions) { + log.Info("MainTest Alive: %v\n", testOptsArg) testOpts := util.OptionalArg(testOptsArg, &TestOptions{}) giteaRoot = test.SetupGiteaRoot() setting.CustomPath = filepath.Join(giteaRoot, "custom") @@ -101,12 +103,14 @@ func MainTest(m *testing.M, testOptsArg ...*TestOptions) { if err != nil { fatalTestError("TempDir: %v\n", err) } + log.Info("Appdata Findme: %s", appDataPath) setting.AppDataPath = appDataPath setting.AppWorkPath = giteaRoot setting.StaticRootPath = giteaRoot setting.GravatarSource = "https://secure.gravatar.com/avatar/" setting.Attachment.Storage.Path = filepath.Join(setting.AppDataPath, "attachments") + log.Info("Attachment Path: %s", setting.Attachment.Storage.Path) setting.LFS.Storage.Path = filepath.Join(setting.AppDataPath, "lfs") diff --git a/modules/indexer/issues/indexer_test.go b/modules/indexer/issues/indexer_test.go index 8043d33eebb88..736936012eb80 100644 --- a/modules/indexer/issues/indexer_test.go +++ b/modules/indexer/issues/indexer_test.go @@ -57,7 +57,7 @@ func searchIssueWithKeyword(t *testing.T) { Keyword: "issue2", RepoIDs: []int64{1}, }, - []int64{2}, + []int64{2, 23}, }, { SearchOptions{ @@ -106,7 +106,7 @@ func searchIssueByIndex(t *testing.T) { Keyword: "2", RepoIDs: []int64{1, 2, 3, 32}, }, - []int64{17, 12, 7, 2}, + []int64{17, 12, 7, 2, 23}, }, { SearchOptions{ @@ -133,7 +133,7 @@ func searchIssueInRepo(t *testing.T) { SearchOptions{ RepoIDs: []int64{1}, }, - []int64{11, 5, 3, 2, 1}, + []int64{11, 5, 3, 2, 23, 1}, }, { SearchOptions{ @@ -177,7 +177,7 @@ func searchIssueByID(t *testing.T) { opts: SearchOptions{ PosterID: optional.Some(int64(1)), }, - expectedIDs: []int64{11, 6, 3, 2, 1}, + expectedIDs: []int64{11, 6, 3, 2, 23, 1}, }, { opts: SearchOptions{ @@ -188,7 +188,7 @@ func searchIssueByID(t *testing.T) { { // NOTE: This tests no assignees filtering and also ToSearchOptions() to ensure it will set AssigneeID to 0 when it is passed as -1. opts: *ToSearchOptions("", &issues.IssuesOptions{AssigneeID: optional.Some(db.NoConditionID)}), - expectedIDs: []int64{22, 21, 16, 15, 14, 13, 12, 11, 20, 5, 19, 18, 10, 7, 4, 9, 8, 3, 2}, + expectedIDs: []int64{22, 21, 16, 15, 14, 13, 12, 11, 20, 5, 19, 18, 10, 7, 4, 9, 8, 3, 2, 23}, }, { opts: SearchOptions{ @@ -212,7 +212,7 @@ func searchIssueByID(t *testing.T) { opts: SearchOptions{ SubscriberID: optional.Some(int64(1)), }, - expectedIDs: []int64{11, 6, 5, 3, 2, 1}, + expectedIDs: []int64{11, 6, 5, 3, 2, 23, 1}, }, { // issue 20 request user 15 and team 5 which user 15 belongs to @@ -247,7 +247,7 @@ func searchIssueIsPull(t *testing.T) { SearchOptions{ IsPull: optional.Some(false), }, - []int64{17, 16, 15, 14, 13, 6, 5, 18, 10, 7, 4, 1}, + []int64{17, 16, 15, 14, 13, 6, 5, 18, 10, 7, 4, 23, 1}, }, { SearchOptions{ @@ -272,7 +272,7 @@ func searchIssueIsClosed(t *testing.T) { SearchOptions{ IsClosed: optional.Some(false), }, - []int64{22, 21, 17, 16, 15, 14, 13, 12, 11, 20, 6, 19, 18, 10, 7, 9, 8, 3, 2, 1}, + []int64{22, 21, 17, 16, 15, 14, 13, 12, 11, 20, 6, 19, 18, 10, 7, 9, 8, 3, 2, 23, 1}, }, { SearchOptions{ @@ -297,7 +297,7 @@ func searchIssueIsArchived(t *testing.T) { SearchOptions{ IsArchived: optional.Some(false), }, - []int64{22, 21, 17, 16, 15, 13, 12, 11, 20, 6, 5, 19, 18, 10, 7, 4, 9, 8, 3, 2, 1}, + []int64{22, 21, 17, 16, 15, 13, 12, 11, 20, 6, 5, 19, 18, 10, 7, 4, 9, 8, 3, 2, 23, 1}, }, { SearchOptions{ @@ -359,7 +359,7 @@ func searchIssueByLabelID(t *testing.T) { SearchOptions{ ExcludedLabelIDs: []int64{1}, }, - []int64{22, 21, 17, 16, 15, 14, 13, 12, 11, 20, 6, 5, 19, 18, 10, 7, 4, 9, 8, 3}, + []int64{22, 21, 17, 16, 15, 14, 13, 12, 11, 20, 6, 5, 19, 18, 10, 7, 4, 9, 8, 3, 23}, }, } for _, test := range tests { @@ -378,7 +378,7 @@ func searchIssueByTime(t *testing.T) { SearchOptions{ UpdatedAfterUnix: optional.Some(int64(0)), }, - []int64{22, 21, 17, 16, 15, 14, 13, 12, 11, 20, 6, 5, 19, 18, 10, 7, 4, 9, 8, 3, 2, 1}, + []int64{22, 21, 17, 16, 15, 14, 13, 12, 11, 20, 6, 5, 19, 18, 10, 7, 4, 9, 8, 3, 2, 23, 1}, }, } for _, test := range tests { @@ -397,7 +397,7 @@ func searchIssueWithOrder(t *testing.T) { SearchOptions{ SortBy: internal.SortByCreatedAsc, }, - []int64{1, 2, 3, 8, 9, 4, 7, 10, 18, 19, 5, 6, 20, 11, 12, 13, 14, 15, 16, 17, 21, 22}, + []int64{1, 23, 2, 3, 8, 9, 4, 7, 10, 18, 19, 5, 6, 20, 11, 12, 13, 14, 15, 16, 17, 21, 22}, }, } for _, test := range tests { @@ -451,7 +451,7 @@ func searchIssueWithPaginator(t *testing.T) { }, }, []int64{22, 21, 17, 16, 15}, - 22, + 23, }, } for _, test := range tests { diff --git a/modules/setting/mailer.go b/modules/setting/mailer.go index 4c3dff6850947..a850ee3c0c0c3 100644 --- a/modules/setting/mailer.go +++ b/modules/setting/mailer.go @@ -13,21 +13,23 @@ import ( "code.gitea.io/gitea/modules/log" - shellquote "github.com/kballard/go-shellquote" + "github.com/kballard/go-shellquote" ) // Mailer represents mail service. type Mailer struct { // Mailer - Name string `ini:"NAME"` - From string `ini:"FROM"` - EnvelopeFrom string `ini:"ENVELOPE_FROM"` - OverrideEnvelopeFrom bool `ini:"-"` - FromName string `ini:"-"` - FromEmail string `ini:"-"` - SendAsPlainText bool `ini:"SEND_AS_PLAIN_TEXT"` - SubjectPrefix string `ini:"SUBJECT_PREFIX"` - OverrideHeader map[string][]string `ini:"-"` + Name string `ini:"NAME"` + From string `ini:"FROM"` + EnvelopeFrom string `ini:"ENVELOPE_FROM"` + OverrideEnvelopeFrom bool `ini:"-"` + FromName string `ini:"-"` + FromEmail string `ini:"-"` + SendAsPlainText bool `ini:"SEND_AS_PLAIN_TEXT"` + SubjectPrefix string `ini:"SUBJECT_PREFIX"` + OverrideHeader map[string][]string `ini:"-"` + Base64EmbedImages bool `ini:"BASE64_EMBED_IMAGES"` + Base64EmbedImagesMaxSizePerEmail int64 `ini:"BASE64_EMBED_IMAGES_MAX_SIZE_PER_EMAIL"` // SMTP sender Protocol string `ini:"PROTOCOL"` @@ -150,6 +152,8 @@ func loadMailerFrom(rootCfg ConfigProvider) { sec.Key("SENDMAIL_TIMEOUT").MustDuration(5 * time.Minute) sec.Key("SENDMAIL_CONVERT_CRLF").MustBool(true) sec.Key("FROM").MustString(sec.Key("USER").String()) + sec.Key("BASE64_EMBED_IMAGES").MustBool(false) + sec.Key("BASE64_EMBED_IMAGES_MAX_SIZE_PER_EMAIL").MustInt64(9.5 * 1024 * 1024) // Now map the values on to the MailService MailService = &Mailer{} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 20da796b58d3d..c76cc6906dea2 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -66,6 +66,7 @@ func PrepareAppDataPath() error { // For quickstart, the parent directories should be created automatically for first startup (eg: a flag or a check of INSTALL_LOCK). // Now we can take the first step to do correctly (using Mkdir) in other packages, and prepare the AppDataPath here, then make a refactor in future. + fmt.Printf("AppDataPath: %s\n", AppDataPath) st, err := os.Stat(AppDataPath) if os.IsNotExist(err) { err = os.MkdirAll(AppDataPath, os.ModePerm) diff --git a/modules/storage/azureblob.go b/modules/storage/azureblob.go index 837afd0ba62b4..d6c880e6432a4 100644 --- a/modules/storage/azureblob.go +++ b/modules/storage/azureblob.go @@ -179,6 +179,7 @@ func (a *AzureBlobStorage) Open(path string) (Object, error) { // Save saves a file to azure blob storage func (a *AzureBlobStorage) Save(path string, r io.Reader, size int64) (int64, error) { + log.Info("AzureBlobStorage.Save(%s, %d)\n", path, size) rd := util.NewCountingReader(r) _, err := a.client.UploadStream( a.ctx, diff --git a/modules/storage/helper.go b/modules/storage/helper.go index 9e6cceb537da7..97b7c7eb3f1f9 100644 --- a/modules/storage/helper.go +++ b/modules/storage/helper.go @@ -4,6 +4,7 @@ package storage import ( + "code.gitea.io/gitea/modules/log" "fmt" "io" "net/url" @@ -19,6 +20,7 @@ func (s discardStorage) Open(_ string) (Object, error) { } func (s discardStorage) Save(_ string, _ io.Reader, _ int64) (int64, error) { + log.Info("DiscardStorage.Save(%s)\n", s) return 0, fmt.Errorf("%s", s) } diff --git a/modules/storage/local.go b/modules/storage/local.go index 00c7f668aa2c3..71a6f136bc054 100644 --- a/modules/storage/local.go +++ b/modules/storage/local.go @@ -60,6 +60,7 @@ func (l *LocalStorage) Open(path string) (Object, error) { // Save a file func (l *LocalStorage) Save(path string, r io.Reader, size int64) (int64, error) { + log.Info("LocalStorage.Save(%s, %d) local\n", path, size) p := l.buildLocalPath(path) if err := os.MkdirAll(filepath.Dir(p), os.ModePerm); err != nil { return 0, err diff --git a/modules/storage/minio.go b/modules/storage/minio.go index 6b92be61fb7d2..5013d015ba2e8 100644 --- a/modules/storage/minio.go +++ b/modules/storage/minio.go @@ -207,6 +207,7 @@ func (m *MinioStorage) Open(path string) (Object, error) { // Save saves a file to minio func (m *MinioStorage) Save(path string, r io.Reader, size int64) (int64, error) { + log.Info("MinioStorage.Save(%s, %d)\n", path, size) uploadInfo, err := m.client.PutObject( m.ctx, m.bucket, diff --git a/modules/storage/storage.go b/modules/storage/storage.go index b0529941e7da4..4d4e6046b4621 100644 --- a/modules/storage/storage.go +++ b/modules/storage/storage.go @@ -169,12 +169,21 @@ func initAvatars() (err error) { } func initAttachments() (err error) { + log.Info("initAttachments") if !setting.Attachment.Enabled { Attachments = discardStorage("Attachment isn't enabled") return nil } log.Info("Initialising Attachment storage with type: %s", setting.Attachment.Storage.Type) Attachments, err = NewStorage(setting.Attachment.Storage.Type, setting.Attachment.Storage) + log.Info("storage FINDME: %v\n", setting.Attachment.Storage.Path) + // check if directory exists and print the result human readable + if _, err := os.Stat(setting.Attachment.Storage.Path); os.IsNotExist(err) { + log.Info("Attachment storage path does not exist: %s\n", setting.Attachment.Storage.Path) + } else { + log.Info("Attachment storage path exists: %s\n", setting.Attachment.Storage.Path) + } + return err } diff --git a/modules/test/utils.go b/modules/test/utils.go index ec4c9763881f8..ba3ddcf44d6e5 100644 --- a/modules/test/utils.go +++ b/modules/test/utils.go @@ -4,6 +4,8 @@ package test import ( + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/util" "fmt" "net/http" "net/http/httptest" @@ -11,9 +13,6 @@ import ( "path/filepath" "runtime" "strings" - - "code.gitea.io/gitea/modules/json" - "code.gitea.io/gitea/modules/util" ) // RedirectURL returns the redirect URL of a http response. @@ -50,11 +49,13 @@ func MockVariableValue[T any](p *T, v ...T) (reset func()) { // SetupGiteaRoot Sets GITEA_ROOT if it is not already set and returns the value func SetupGiteaRoot() string { giteaRoot := os.Getenv("GITEA_ROOT") + fmt.Printf("GITEA_ROOT1: %s\n", giteaRoot) if giteaRoot != "" { return giteaRoot } _, filename, _, _ := runtime.Caller(0) giteaRoot = filepath.Dir(filepath.Dir(filepath.Dir(filename))) + fmt.Printf("GITEA_ROOT2: %s\n", giteaRoot) fixturesDir := filepath.Join(giteaRoot, "models", "fixtures") if exist, _ := util.IsDir(fixturesDir); !exist { panic(fmt.Sprintf("fixtures directory not found: %s", fixturesDir)) diff --git a/services/issue/issue_test.go b/services/issue/issue_test.go index 8806cec0e7c5d..d7c850e6eb071 100644 --- a/services/issue/issue_test.go +++ b/services/issue/issue_test.go @@ -37,7 +37,7 @@ func TestIssue_DeleteIssue(t *testing.T) { issueIDs, err := issues_model.GetIssueIDsByRepoID(db.DefaultContext, 1) assert.NoError(t, err) - assert.Len(t, issueIDs, 5) + assert.Len(t, issueIDs, 6) issue := &issues_model.Issue{ RepoID: 1, @@ -48,7 +48,7 @@ func TestIssue_DeleteIssue(t *testing.T) { assert.NoError(t, err) issueIDs, err = issues_model.GetIssueIDsByRepoID(db.DefaultContext, 1) assert.NoError(t, err) - assert.Len(t, issueIDs, 4) + assert.Len(t, issueIDs, 5) // check attachment removal attachments, err := repo_model.GetAttachmentsByIssueID(db.DefaultContext, 4) diff --git a/services/issue/suggestion_test.go b/services/issue/suggestion_test.go index 84cfd520ac40a..84771694c4626 100644 --- a/services/issue/suggestion_test.go +++ b/services/issue/suggestion_test.go @@ -26,7 +26,7 @@ func Test_Suggestion(t *testing.T) { }{ { keyword: "", - expectedIndexes: []int64{5, 1, 4, 2, 3}, + expectedIndexes: []int64{5, 6, 1, 4, 2}, }, { keyword: "1", @@ -34,7 +34,7 @@ func Test_Suggestion(t *testing.T) { }, { keyword: "issue", - expectedIndexes: []int64{4, 1, 2, 3}, + expectedIndexes: []int64{6, 4, 1, 2, 3}, }, { keyword: "pull", diff --git a/services/mailer/mail.go b/services/mailer/mail.go index 52e19bde6f261..8bf991da13d87 100644 --- a/services/mailer/mail.go +++ b/services/mailer/mail.go @@ -7,9 +7,12 @@ package mailer import ( "bytes" "context" + "encoding/base64" "fmt" "html/template" + "io" "mime" + "net/http" "regexp" "strconv" "strings" @@ -18,19 +21,24 @@ import ( activities_model "code.gitea.io/gitea/models/activities" issues_model "code.gitea.io/gitea/models/issues" + access_model "code.gitea.io/gitea/models/perm/access" "code.gitea.io/gitea/models/renderhelper" 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/modules/emoji" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/translation" incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload" sender_service "code.gitea.io/gitea/services/mailer/sender" "code.gitea.io/gitea/services/mailer/token" + + "golang.org/x/net/html" ) const ( @@ -228,6 +236,15 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient return nil, err } + if setting.MailService.Base64EmbedImages { + bodyStr := string(body) + bodyStr, err = Base64InlineImages(bodyStr, ctx) + if err != nil { + return nil, err + } + body = template.HTML(bodyStr) + } + actType, actName, tplName := actionToTemplate(ctx.Issue, ctx.ActionType, commentType, reviewType) if actName != "new" { @@ -359,6 +376,110 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient return msgs, nil } +func Base64InlineImages(body string, ctx *mailCommentContext) (string, error) { + doc, err := html.Parse(strings.NewReader(body)) + if err != nil { + log.Error("Failed to parse HTML body: %v", err) + return "", err + } + + var totalEmbeddedImagesSize int64 + + var processNode func(*html.Node) + processNode = func(n *html.Node) { + if n.Type == html.ElementNode { + if n.Data == "img" { + for i, attr := range n.Attr { + if attr.Key == "src" { + attachmentPath := attr.Val + dataURI, err := AttachmentSrcToBase64DataURI(attachmentPath, ctx, &totalEmbeddedImagesSize) + if err != nil { + log.Trace("attachmentSrcToDataURI not possible: %v", err) // Not an error, just skip. This is probably an image from outside the gitea instance. + continue + } + log.Trace("Old value of src attribute: %s, new value (first 100 characters): %s", attr.Val, dataURI[:100]) + n.Attr[i].Val = dataURI + break + } + } + } + } + + for c := n.FirstChild; c != nil; c = c.NextSibling { + processNode(c) + } + } + + processNode(doc) + + var buf bytes.Buffer + err = html.Render(&buf, doc) + if err != nil { + log.Error("Failed to render modified HTML: %v", err) + return "", err + } + return buf.String(), nil +} + +func AttachmentSrcToBase64DataURI(attachmentPath string, ctx *mailCommentContext, totalEmbeddedImagesSize *int64) (string, error) { + if !strings.HasPrefix(attachmentPath, setting.AppURL) { // external image + return "", fmt.Errorf("external image") + } + parts := strings.Split(attachmentPath, "/attachments/") + if len(parts) <= 1 { + return "", fmt.Errorf("invalid attachment path: %s", attachmentPath) + } + + attachmentUUID := parts[len(parts)-1] + attachment, err := repo_model.GetAttachmentByUUID(ctx, attachmentUUID) + if err != nil { + return "", err + } + + // "Doer" is theoretically not the correct permission check (as Doer created the action on which to send), but as this is batch processed the receipants can't be accessed. + // Therefore we check the Doer, with which we counter leaking information as a Doer brute force attack on attachments would be possible. + perm, err := access_model.GetUserRepoPermission(ctx, ctx.Issue.Repo, ctx.Doer) + if err != nil { + return "", err + } + if !perm.CanRead(unit.TypeIssues) { + return "", fmt.Errorf("no permission") + } + + fr, err := storage.Attachments.Open(attachment.RelativePath()) + if err != nil { + return "", err + } + defer fr.Close() + + maxSize := setting.MailService.Base64EmbedImagesMaxSizePerEmail // at maximum read the whole available combined email size, to prevent maliciously large file reads + + lr := &io.LimitedReader{R: fr, N: maxSize + 1} + content, err := io.ReadAll(lr) + if err != nil { + return "", err + } + if len(content) > int(maxSize) { + return "", fmt.Errorf("file size exceeds the embedded image max limit \\(%d bytes\\)", maxSize) + } + + if *totalEmbeddedImagesSize+int64(len(content)) > setting.MailService.Base64EmbedImagesMaxSizePerEmail { + return "", fmt.Errorf("total embedded images exceed max limit: %d > %d", *totalEmbeddedImagesSize+int64(len(content)), setting.MailService.Base64EmbedImagesMaxSizePerEmail) + } + *totalEmbeddedImagesSize += int64(len(content)) + + mimeType := http.DetectContentType(content) + + if !strings.HasPrefix(mimeType, "image/") { + return "", fmt.Errorf("not an image") + } + + encoded := base64.StdEncoding.EncodeToString(content) + dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, encoded) + + return dataURI, nil +} + func generateMessageIDForIssue(issue *issues_model.Issue, comment *issues_model.Comment, actionType activities_model.ActionType) string { var path string if issue.IsPull { diff --git a/services/mailer/mail_test.go b/services/mailer/mail_test.go index 8298ac4a34281..e1eac1b17c5d8 100644 --- a/services/mailer/mail_test.go +++ b/services/mailer/mail_test.go @@ -5,25 +5,28 @@ package mailer import ( "bytes" - "context" - "fmt" - "html/template" - "io" - "mime/quotedprintable" - "regexp" - "strings" - "testing" - texttmpl "text/template" - activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" 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/log" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" sender_service "code.gitea.io/gitea/services/mailer/sender" + "context" + "fmt" + "html/template" + "io" + "mime/quotedprintable" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + texttmpl "text/template" "github.com/stretchr/testify/assert" ) @@ -59,6 +62,7 @@ func prepareMailerTest(t *testing.T) (doer *user_model.User, repo *repo_model.Re setting.MailService = &mailService setting.Domain = "localhost" + setting.AppURL = "https://try.gitea.io/" doer = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, Owner: doer}) @@ -450,3 +454,193 @@ func TestFromDisplayName(t *testing.T) { assert.EqualValues(t, "Mister X (by Code IT on [code.it])", fromDisplayName(&user_model.User{FullName: "Mister X", Name: "tmp"})) }) } + +func WalkDirectory(dirPath string, level int) { + entries, err := os.ReadDir(dirPath) + if err != nil { + log.Info("Error reading directory %s: %v", dirPath, err) + return + } + + // Calculate indentation for the current level. + indent := strings.Repeat(" ", level) + + for _, entry := range entries { + // Print the current entry name with indentation. + log.Info("%s- %s\n", indent, entry.Name()) + + // If this is a directory, recurse into it. + if entry.IsDir() { + subDir := filepath.Join(dirPath, entry.Name()) + WalkDirectory(subDir, level+1) + } + } +} + +func PrepareAttachmentsStorage(t testing.TB) { // same as in test_utils.go + // prepare attachments directory and files + log.Info("delete attachments: %s\n", storage.Attachments) + // wait 30s for the attachments to be deleted + // <-time.After(30 * time.Second) + assert.NoError(t, storage.Clean(storage.Attachments)) + + s, err := storage.NewStorage(setting.LocalStorageType, &setting.Storage{ + Path: filepath.Join(filepath.Dir(setting.AppPath), "tests", "testdata", "data", "attachments"), + }) + + // print all directory contents of: filepath.Join(filepath.Dir(setting.AppPath), "tests", "testdata", "data", "attachments") + targetPath := filepath.Join(filepath.Dir(setting.AppPath), "tests", "testdata", "data", "attachments") + log.Info("Listing: %s\n", targetPath) + + WalkDirectory(targetPath, 0) + + assert.NoError(t, err) + assert.NoError(t, s.IterateObjects("", func(p string, obj storage.Object) error { + _, err = storage.Copy(storage.Attachments, p, s, p) + log.Info("copying %s to %s\n", p, storage.Attachments) + return err + })) + log.Info("Attachments are: %s\n", storage.Attachments) + // <-time.After(30 * time.Second) + +} + +func TestEmbedBase64ImagesInEmail(t *testing.T) { + // Fake context setup + + assert.NoError(t, unittest.LoadFixtures()) + assert.NoError(t, unittest.PrepareTestDatabase()) + PrepareAttachmentsStorage(t) + + doer, repo, _, _ := prepareMailerTest(t) + + attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 13, IssueID: 23, RepoID: repo.ID}) + + assert.Equal(t, attachment.IssueID, int64(23)) + + // load attachment content + + fr, err := storage.Attachments.Open(attachment.RelativePath()) + assert.NoError(t, err) + defer fr.Close() + maxSize := setting.MailService.Base64EmbedImagesMaxSizePerEmail + lr := &io.LimitedReader{R: fr, N: maxSize + 1} + content, err := io.ReadAll(lr) + assert.NoError(t, err) + + //assert content length is above 0 + assert.Greater(t, len(content), 0) + + setting.MailService.Base64EmbedImages = true + setting.MailService.Base64EmbedImagesMaxSizePerEmail = 10 * 1024 * 1024 + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 23, Repo: repo, Poster: doer}) + assert.NoError(t, issue.LoadRepo(db.DefaultContext)) + + subjectTemplates = texttmpl.Must(texttmpl.New("issue/new").Parse(subjectTpl)) + bodyTemplates = template.Must(template.New("issue/new").Parse(bodyTpl)) + + recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}} + msgs, err := composeIssueCommentMessages(&mailCommentContext{ + Context: context.TODO(), // TODO: use a correct context + Issue: issue, Doer: doer, ActionType: activities_model.ActionCreateIssue, + Content: strings.ReplaceAll(issue.Content, `src="`, `src="`+setting.AppURL), + }, "en-US", recipients, false, "issue create") + + mailBody := msgs[0].Body + re := regexp.MustCompile(`(?s)(.*?)`) + matches := re.FindStringSubmatch(mailBody) + if len(matches) > 1 { + mailBody = matches[1] + } + // check if the mail body was correctly generated + assert.NoError(t, err) + assert.Contains(t, mailBody, "content including this image") + + // check if an image was embedded + assert.Contains(t, mailBody, "data:image/png;base64,") + + // check if the image was embedded only once + assert.Equal(t, 1, strings.Count(mailBody, "data:image/png;base64,")) + + img2InternalBase64 := "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAAAxAQMAAAB3d7wRAAAABlBMVEVgmyF6qkqITHmkAAAAAXRSTlMBN+Ho8AAAAJhJREFUKM+V0DsOwyAQBNCxXLjkCFwk0t7McDQfhS4tpQuEzWc/iaUU2eo1zC4DUMWYF3DxVKzGTXjBGb2RsjJEo6ZhN1Zj+cEgi/9hBQl3YflkkIsbo5IO5glKTuhPpavM3Hp4C7WdjEWYrL5GMkp/R+s4GPlh/CZn4MEwv9aHHiyD3ujm5X22eaMyDa5yAm+O0B1TPa1l3W2qZWMg+KgtAAAAAElFTkSuQmCC" + + // check if the image was embedded correctly + assert.Contains(t, mailBody, img2InternalBase64) +} + +//func TestEmbedBase64Images(t *testing.T) { +// assert.NoError(t, unittest.LoadFixtures()) +// assert.NoError(t, unittest.PrepareTestDatabase()) +// PrepareAttachmentsStorage(t) +// +// user, repo, _, _ := prepareMailerTest(t) +// setting.MailService.Base64EmbedImages = true +// setting.MailService.Base64EmbedImagesMaxSizePerEmail = 10 * 1024 * 1024 +// +// issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 23, Repo: repo, Poster: user}) +// +// attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 13, IssueID: issue.ID, RepoID: repo.ID}) +// ctx0 := context.Background() +// +// ctx := &mailCommentContext{Context: ctx0 /* TODO: use a correct context */, Issue: issue, Doer: user} +// +// img1ExternalURL := "https://via.placeholder.com/10" +// img1ExternalImg := "" +// +// img2InternalURL := setting.AppURL + repo.Owner.Name + "/" + repo.Name + "/attachments/" + attachment.UUID +// img2InternalImg := "" +// img2InternalBase64 := "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAAAxAQMAAAB3d7wRAAAABlBMVEVgmyF6qkqITHmkAAAAAXRSTlMBN+Ho8AAAAJhJREFUKM+V0DsOwyAQBNCxXLjkCFwk0t7McDQfhS4tpQuEzWc/iaUU2eo1zC4DUMWYF3DxVKzGTXjBGb2RsjJEo6ZhN1Zj+cEgi/9hBQl3YflkkIsbo5IO5glKTuhPpavM3Hp4C7WdjEWYrL5GMkp/R+s4GPlh/CZn4MEwv9aHHiyD3ujm5X22eaMyDa5yAm+O0B1TPa1l3W2qZWMg+KgtAAAAAElFTkSuQmCC" +// img2InternalBase64Img := "" +// +// // 1st Test: convert internal image to base64 +// t.Run("replaceSpecifiedBase64ImagesInternal", func(t *testing.T) { +// totalEmbeddedImagesSize := int64(0) +// +// resultImg1Internal, err := AttachmentSrcToBase64DataURI(img2InternalURL, ctx, &totalEmbeddedImagesSize) +// assert.NoError(t, err) +// assert.Equal(t, img2InternalBase64, resultImg1Internal) // replace cause internal image +// }) +// +// // 2nd Test: convert external image to base64 -> abort cause external image +// t.Run("replaceSpecifiedBase64ImagesExternal", func(t *testing.T) { +// totalEmbeddedImagesSize := int64(0) +// +// resultImg1External, err := AttachmentSrcToBase64DataURI(img1ExternalURL, ctx, &totalEmbeddedImagesSize) +// assert.Error(t, err) +// assert.Equal(t, "", resultImg1External) // don't replace cause external image +// }) +// +// // 3rd Test: generate email body with 1 internal and 1 external image, expect the result to have the internal image replaced with base64 data and the external not replaced +// t.Run("generateEmailBody", func(t *testing.T) { +// mailBody := "

Test1

" + img1ExternalImg + "

Test2

" + img2InternalImg + "

Test3

" +// expectedMailBody := "

Test1

" + img1ExternalImg + "

Test2

" + img2InternalBase64Img + "

Test3

" +// resultMailBody, err := Base64InlineImages(mailBody, ctx) +// +// assert.NoError(t, err) +// assert.Equal(t, expectedMailBody, resultMailBody) +// }) +// +// // 4th Test, generate email body with 2 internal images, but set Mailer.Base64EmbedImagesMaxSizePerEmail to the size of the first image (+1), expect the first image to be replaced and the second not +// t.Run("generateEmailBodyWithMaxSize", func(t *testing.T) { +// setting.MailService.Base64EmbedImagesMaxSizePerEmail = int64(len(img2InternalBase64) + 1) +// +// mailBody := "

Test1

" + img2InternalImg + "

Test2

" + img2InternalImg + "

Test3

" +// expectedMailBody := "

Test1

" + img2InternalBase64Img + "

Test2

" + img2InternalImg + "

Test3

" +// resultMailBody, err := Base64InlineImages(mailBody, ctx) +// +// assert.NoError(t, err) +// assert.Equal(t, expectedMailBody, resultMailBody) +// }) +// +// // 5th Test, generate email body with 3 internal images, but set Mailer.Base64EmbedImagesMaxSizePerEmail to the size of all 3 images (+1), expect all images to be replaced +// t.Run("generateEmailBodyWith3Images", func(t *testing.T) { +// setting.MailService.Base64EmbedImagesMaxSizePerEmail = int64(len(img2InternalBase64)*3 + 1) +// +// mailBody := "

Test1

" + img2InternalImg + "

Test2

" + img2InternalImg + "

Test3

" + img2InternalImg + "" +// expectedMailBody := "

Test1

" + img2InternalBase64Img + "

Test2

" + img2InternalBase64Img + "

Test3

" + img2InternalBase64Img + "" +// resultMailBody, err := Base64InlineImages(mailBody, ctx) +// +// assert.NoError(t, err) +// assert.Equal(t, expectedMailBody, resultMailBody) +// }) +//} diff --git a/tests/integration/api_issue_test.go b/tests/integration/api_issue_test.go index d8394a33d96f4..8b81b7196f43c 100644 --- a/tests/integration/api_issue_test.go +++ b/tests/integration/api_issue_test.go @@ -287,7 +287,7 @@ func TestAPISearchIssues(t *testing.T) { req = NewRequest(t, "GET", link.String()).AddTokenAuth(publicOnlyToken) resp = MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &apiIssues) - assert.Len(t, apiIssues, 15) // 15 public issues + assert.Len(t, apiIssues, 16) // 16 public issues since := "2000-01-01T00:50:01+00:00" // 946687801 before := time.Unix(999307200, 0).Format(time.RFC3339) @@ -297,7 +297,7 @@ func TestAPISearchIssues(t *testing.T) { req = NewRequest(t, "GET", link.String()).AddTokenAuth(token) resp = MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &apiIssues) - assert.Len(t, apiIssues, 11) + assert.Len(t, apiIssues, 12) query.Del("since") query.Del("before") @@ -313,7 +313,7 @@ func TestAPISearchIssues(t *testing.T) { req = NewRequest(t, "GET", link.String()).AddTokenAuth(token) resp = MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &apiIssues) - assert.EqualValues(t, "22", resp.Header().Get("X-Total-Count")) + assert.EqualValues(t, "23", resp.Header().Get("X-Total-Count")) assert.Len(t, apiIssues, 20) query.Add("limit", "10") @@ -321,7 +321,7 @@ func TestAPISearchIssues(t *testing.T) { req = NewRequest(t, "GET", link.String()).AddTokenAuth(token) resp = MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &apiIssues) - assert.EqualValues(t, "22", resp.Header().Get("X-Total-Count")) + assert.EqualValues(t, "23", resp.Header().Get("X-Total-Count")) assert.Len(t, apiIssues, 10) query = url.Values{"assigned": {"true"}, "state": {"all"}} @@ -350,7 +350,7 @@ func TestAPISearchIssues(t *testing.T) { req = NewRequest(t, "GET", link.String()).AddTokenAuth(token) resp = MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &apiIssues) - assert.Len(t, apiIssues, 8) + assert.Len(t, apiIssues, 9) query = url.Values{"owner": {"org3"}} // organization link.RawQuery = query.Encode() diff --git a/tests/integration/api_nodeinfo_test.go b/tests/integration/api_nodeinfo_test.go index 75f8dbb4ba3aa..c6b4663b4a4d9 100644 --- a/tests/integration/api_nodeinfo_test.go +++ b/tests/integration/api_nodeinfo_test.go @@ -33,7 +33,7 @@ func TestNodeinfo(t *testing.T) { assert.True(t, nodeinfo.OpenRegistrations) assert.Equal(t, "gitea", nodeinfo.Software.Name) assert.Equal(t, 29, nodeinfo.Usage.Users.Total) - assert.Equal(t, 22, nodeinfo.Usage.LocalPosts) + assert.Equal(t, 23, nodeinfo.Usage.LocalPosts) assert.Equal(t, 3, nodeinfo.Usage.LocalComments) }) } diff --git a/tests/integration/api_repo_test.go b/tests/integration/api_repo_test.go index 22f26d87d4332..5180e3d17ee57 100644 --- a/tests/integration/api_repo_test.go +++ b/tests/integration/api_repo_test.go @@ -268,7 +268,7 @@ func TestAPIViewRepo(t *testing.T) { assert.EqualValues(t, 1, repo.ID) assert.EqualValues(t, "repo1", repo.Name) assert.EqualValues(t, 2, repo.Releases) - assert.EqualValues(t, 1, repo.OpenIssues) + assert.EqualValues(t, 2, repo.OpenIssues) assert.EqualValues(t, 3, repo.OpenPulls) req = NewRequest(t, "GET", "/api/v1/repos/user12/repo10") diff --git a/tests/integration/issue_test.go b/tests/integration/issue_test.go index bd0cedd300baf..d66e78c9ae4c6 100644 --- a/tests/integration/issue_test.go +++ b/tests/integration/issue_test.go @@ -495,7 +495,7 @@ func TestSearchIssues(t *testing.T) { req = NewRequest(t, "GET", link.String()) resp = session.MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &apiIssues) - assert.Len(t, apiIssues, 11) + assert.Len(t, apiIssues, 12) query.Del("since") query.Del("before") @@ -511,7 +511,7 @@ func TestSearchIssues(t *testing.T) { req = NewRequest(t, "GET", link.String()) resp = session.MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &apiIssues) - assert.EqualValues(t, "22", resp.Header().Get("X-Total-Count")) + assert.EqualValues(t, "23", resp.Header().Get("X-Total-Count")) assert.Len(t, apiIssues, 20) query.Add("limit", "5") @@ -519,7 +519,7 @@ func TestSearchIssues(t *testing.T) { req = NewRequest(t, "GET", link.String()) resp = session.MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &apiIssues) - assert.EqualValues(t, "22", resp.Header().Get("X-Total-Count")) + assert.EqualValues(t, "23", resp.Header().Get("X-Total-Count")) assert.Len(t, apiIssues, 5) query = url.Values{"assigned": {"true"}, "state": {"all"}} @@ -548,7 +548,7 @@ func TestSearchIssues(t *testing.T) { req = NewRequest(t, "GET", link.String()) resp = session.MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &apiIssues) - assert.Len(t, apiIssues, 8) + assert.Len(t, apiIssues, 9) query = url.Values{"owner": {"org3"}} // organization link.RawQuery = query.Encode() diff --git a/tests/integration/pull_merge_test.go b/tests/integration/pull_merge_test.go index 169df8618e89d..6128b8d8a72d4 100644 --- a/tests/integration/pull_merge_test.go +++ b/tests/integration/pull_merge_test.go @@ -924,7 +924,7 @@ func TestPullAutoMergeAfterCommitStatusSucceedAndApprovalForAgitFlow(t *testing. Run(&git.RunOpts{Dir: dstPath, Stderr: stderrBuf}) assert.NoError(t, err) - assert.Contains(t, stderrBuf.String(), setting.AppURL+"user2/repo1/pulls/6") + assert.Contains(t, stderrBuf.String(), setting.AppURL+"user2/repo1/pulls/7") baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user2", Name: "repo1"}) pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ @@ -1044,7 +1044,7 @@ func TestPullNonMergeForAdminWithBranchProtection(t *testing.T) { token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) - mergeReq := NewRequestWithValues(t, "POST", "/api/v1/repos/user2/repo1/pulls/6/merge", map[string]string{ + mergeReq := NewRequestWithValues(t, "POST", "/api/v1/repos/user2/repo1/pulls/7/merge", map[string]string{ "_csrf": csrf, "head_commit_id": "", "merge_when_checks_succeed": "false", diff --git a/tests/test_utils.go b/tests/test_utils.go index 96eb5731b4ac1..484dd08a979a9 100644 --- a/tests/test_utils.go +++ b/tests/test_utils.go @@ -38,7 +38,9 @@ func InitTest(requireGitea bool) { // setting.UI.Notification.EventSourceUpdateTime = time.Second setting.AppWorkPath = giteaRoot + log.Info("AppWorkPath: %s", setting.AppWorkPath) setting.CustomPath = filepath.Join(setting.AppWorkPath, "custom") + log.Info("CustomPath: %s", setting.CustomPath) if requireGitea { giteaBinary := "gitea" if setting.IsWindows { @@ -66,6 +68,8 @@ func InitTest(requireGitea bool) { } else { setting.CustomConf = giteaConf } + log.Info("GiteaConf: %s", setting.CustomConf) + log.Info("CustomConf: %s", setting.CustomConf) unittest.InitSettings() setting.Repository.DefaultBranch = "master" // many test code still assume that default branch is called "master" diff --git a/tests/testdata/data/attachments/1/b/1b267670-1793-4cd0-abc1-449269b7cff9 b/tests/testdata/data/attachments/1/b/1b267670-1793-4cd0-abc1-449269b7cff9 new file mode 100644 index 0000000000000..8fc22b562f026 Binary files /dev/null and b/tests/testdata/data/attachments/1/b/1b267670-1793-4cd0-abc1-449269b7cff9 differ