From da19f04a6f64f01469edca013d9a0aad3b2133cd Mon Sep 17 00:00:00 2001 From: a1012112796 <1012112796@qq.com> Date: Wed, 12 Feb 2025 12:30:02 +0800 Subject: [PATCH 1/2] add creating new issue by email support Signed-off-by: a1012112796 <1012112796@qq.com> --- models/user/setting.go | 32 ++++++++ models/user/setting_keys.go | 10 +++ options/locale/locale_en-US.ini | 7 ++ routers/web/repo/issue_list.go | 32 ++++++++ routers/web/repo/issue_list_test.go | 45 +++++++++++ routers/web/user/setting/repo.go | 37 +++++++++ routers/web/web.go | 2 + services/mailer/incoming/incoming.go | 2 + services/mailer/incoming/incoming_handler.go | 84 +++++++++++++++++++- services/mailer/incoming/mailto_new_issue.go | 38 +++++++++ services/mailer/incoming/payload/payload.go | 61 ++++++++++++++ services/mailer/mail.go | 4 +- services/mailer/token/token.go | 15 ++-- templates/repo/issue/list.tmpl | 3 + templates/repo/issue/mailto_module.tmpl | 24 ++++++ tests/integration/incoming_email_test.go | 4 +- web_src/js/features/repo-issue-list.ts | 26 ++++++ 17 files changed, 414 insertions(+), 12 deletions(-) create mode 100644 routers/web/repo/issue_list_test.go create mode 100644 routers/web/user/setting/repo.go create mode 100644 services/mailer/incoming/mailto_new_issue.go create mode 100644 templates/repo/issue/mailto_module.tmpl diff --git a/models/user/setting.go b/models/user/setting.go index b4af0e5ccd684..7cd524f94f79f 100644 --- a/models/user/setting.go +++ b/models/user/setting.go @@ -210,3 +210,35 @@ func upsertUserSettingValue(ctx context.Context, userID int64, key, value string return err }) } + +type RepositoryRandsType string + +const ( + RepositoryRandsTypeNewIssue RepositoryRandsType = "new_issue" +) + +func CreatRandsForRepository(ctx context.Context, userID, repoID int64, event RepositoryRandsType) (string, error) { + rand, err := GetUserSalt() + if err != nil { + return rand, err + } + + return rand, SetUserSetting(ctx, userID, SettingsKeyUserRandsForRepo(repoID, string(event)), rand) +} + +func GetRandsForRepository(ctx context.Context, userID, repoID int64, event RepositoryRandsType) (string, error) { + return GetSetting(ctx, userID, SettingsKeyUserRandsForRepo(repoID, string(event))) +} + +func (u *User) GetOrCreateRandsForRepository(ctx context.Context, repoID int64, event RepositoryRandsType) (string, error) { + rand, err := GetRandsForRepository(ctx, u.ID, repoID, event) + if err != nil && !IsErrUserSettingIsNotExist(err) { + return "", err + } + + if len(rand) == 0 || err != nil { + rand, err = CreatRandsForRepository(ctx, u.ID, repoID, event) + } + + return rand, err +} diff --git a/models/user/setting_keys.go b/models/user/setting_keys.go index 3149aae18ba68..1b9c1593e94f4 100644 --- a/models/user/setting_keys.go +++ b/models/user/setting_keys.go @@ -3,6 +3,8 @@ package user +import "fmt" + const ( // SettingsKeyHiddenCommentTypes is the setting key for hidden comment types SettingsKeyHiddenCommentTypes = "issue.hidden_comment_types" @@ -19,3 +21,11 @@ const ( // SignupUserAgent is the user agent that the user signed up with SignupUserAgent = "signup.user_agent" ) + +func SettingsKeyUserRands(key string) string { + return "rands." + key +} + +func SettingsKeyUserRandsForRepo(repoID int64, key string) string { + return SettingsKeyUserRands(fmt.Sprintf("repo.%d.%s", repoID, key)) +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index bce64a81a3d9d..9c341cd4b6c5b 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1808,6 +1808,13 @@ issues.content_history.delete_from_history_confirm = Delete from history? issues.content_history.options = Options issues.reference_link = Reference: %s +issues.mailto_modal.title = Create new issue by email +issues.mailto_modal.desc_1 = You can create a new issue inside this project by sending an email to the following email address: +issues.mailto_modal.desc_2 = The subject will be used as the title of the new issue, and the message will be the description. +issues.mailto_modal.desc_3 = `This is a private email address generated just for you. Anyone who has it can create issues as if they were you. If that happens, reset this token.` +issues.mailto_modal.mailto_link = Email a new issue to this repository +issues.mailto_modal.send_mail = send mail + compare.compare_base = base compare.compare_head = compare diff --git a/routers/web/repo/issue_list.go b/routers/web/repo/issue_list.go index 2f615a100e3d0..96e2283c080ef 100644 --- a/routers/web/repo/issue_list.go +++ b/routers/web/repo/issue_list.go @@ -27,6 +27,7 @@ import ( "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" issue_service "code.gitea.io/gitea/services/issue" + "code.gitea.io/gitea/services/mailer/incoming" pull_service "code.gitea.io/gitea/services/pull" ) @@ -780,5 +781,36 @@ func Issues(ctx *context.Context) { ctx.Data["CanWriteIssuesOrPulls"] = ctx.Repo.CanWriteIssuesOrPulls(isPullList) + if !isPullList { + err := renderMailToIssue(ctx) + if err != nil { + ctx.ServerError("renderMailToIssue", err) + return + } + } + ctx.HTML(http.StatusOK, tplIssues) } + +func renderMailToIssue(ctx *context.Context) error { + if !setting.IncomingEmail.Enabled { + return nil + } + + if !ctx.IsSigned { + return nil + } + + token, mailToAddress, err := incoming.GenerateMailToRepoURL(ctx, ctx.Doer, ctx.Repo.Repository, user_model.RepositoryRandsTypeNewIssue) + if err != nil { + return err + } + + ctx.Data["MailToIssueEnabled"] = true + ctx.Data["MailToIssueAddress"] = mailToAddress + ctx.Data["MailToIssueLink"] = fmt.Sprintf("mailto:%s", mailToAddress) + ctx.Data["MailToIssueToken"] = token + ctx.Data["MailToIssueTokenResetUrl"] = fmt.Sprintf("%s/user/settings/repo_mailto_rands_reset/%d", setting.AppSubURL, ctx.Repo.Repository.ID) + + return nil +} diff --git a/routers/web/repo/issue_list_test.go b/routers/web/repo/issue_list_test.go new file mode 100644 index 0000000000000..eb9410df9f783 --- /dev/null +++ b/routers/web/repo/issue_list_test.go @@ -0,0 +1,45 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + 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/services/context" + "code.gitea.io/gitea/services/contexttest" + "code.gitea.io/gitea/services/mailer/token" + + "github.com/stretchr/testify/assert" +) + +func TestRenderMailToIssue(t *testing.T) { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1") + + ctx.IsSigned = true + ctx.Doer = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + ctx.Repo = &context.Repository{ + Repository: unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}), + } + + setting.IncomingEmail.Enabled = true + setting.IncomingEmail.ReplyToAddress = "test%{token}@gitea.io" + setting.IncomingEmail.TokenPlaceholder = "%{token}" + + err := renderMailToIssue(ctx) + assert.NoError(t, err) + + key, ok := ctx.Data["MailToIssueToken"].(string) + assert.True(t, ok) + + handlerType, user, _, err := token.ExtractToken(ctx, key) + assert.NoError(t, err) + assert.EqualValues(t, token.NewIssueHandlerType, handlerType) + assert.EqualValues(t, ctx.Doer.ID, user.ID) +} diff --git a/routers/web/user/setting/repo.go b/routers/web/user/setting/repo.go new file mode 100644 index 0000000000000..95d09ac32f7ed --- /dev/null +++ b/routers/web/user/setting/repo.go @@ -0,0 +1,37 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + "strconv" + + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/mailer/incoming" +) + +func ResetRepoMailToRands(ctx *context.Context) { + repoID, _ := strconv.ParseInt(ctx.PathParam("repo_id"), 10, 64) + repo, err := repo_model.GetRepositoryByID(ctx, repoID) + if err != nil { + ctx.ServerError("GetRepositoryByID", err) + return + } + + _, err = user_model.CreatRandsForRepository(ctx, ctx.Doer.ID, repo.ID, user_model.RepositoryRandsTypeNewIssue) + if err != nil { + ctx.ServerError("CreatRandsForRepository", err) + return + } + + _, url, err := incoming.GenerateMailToRepoURL(ctx, ctx.Doer, repo, user_model.RepositoryRandsTypeNewIssue) + if err != nil { + ctx.ServerError("GenerateMailToRepoURL", err) + return + } + + ctx.JSON(http.StatusOK, map[string]string{"url": url}) +} diff --git a/routers/web/web.go b/routers/web/web.go index f5bd6a92979db..25ddaf1356747 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -683,6 +683,8 @@ func registerRoutes(m *web.Router) { m.Get("", user_setting.BlockedUsers) m.Post("", web.Bind(forms.BlockUserForm{}), user_setting.BlockedUsersPost) }) + + m.Post("/repo_mailto_rands_reset/{repo_id}", user_setting.ResetRepoMailToRands) }, reqSignIn, ctxDataSet("PageIsUserSettings", true, "EnablePackages", setting.Packages.Enabled)) m.Group("/user", func() { diff --git a/services/mailer/incoming/incoming.go b/services/mailer/incoming/incoming.go index eade0cf271bf8..8e15618781460 100644 --- a/services/mailer/incoming/incoming.go +++ b/services/mailer/incoming/incoming.go @@ -255,6 +255,7 @@ loop: } content := getContentFromMailReader(env) + content.Subject = env.GetHeader("Subject") if err := handler.Handle(ctx, content, user, payload); err != nil { return fmt.Errorf("could not handle message: %w", err) @@ -350,6 +351,7 @@ func searchTokenInAddresses(addresses []*net_mail.Address) string { type MailContent struct { Content string Attachments []*Attachment + Subject string } type Attachment struct { diff --git a/services/mailer/incoming/incoming_handler.go b/services/mailer/incoming/incoming_handler.go index 38a234eac1fd2..ce4024dacfc61 100644 --- a/services/mailer/incoming/incoming_handler.go +++ b/services/mailer/incoming/incoming_handler.go @@ -6,11 +6,13 @@ package incoming import ( "bytes" "context" + "errors" "fmt" issues_model "code.gitea.io/gitea/models/issues" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -28,8 +30,10 @@ type MailHandler interface { } var handlers = map[token.HandlerType]MailHandler{ - token.ReplyHandlerType: &ReplyHandler{}, - token.UnsubscribeHandlerType: &UnsubscribeHandler{}, + token.ReplyHandlerType: &ReplyHandler{}, + token.UnsubscribeHandlerType: &UnsubscribeHandler{}, + token.NewIssueHandlerType: &NewIssueHandler{}, + token.NewPullRequestHandlerType: &NewPullRequest{}, } // ReplyHandler handles incoming emails to create a reply from them @@ -178,3 +182,79 @@ func (h *UnsubscribeHandler) Handle(ctx context.Context, _ *MailContent, doer *u return fmt.Errorf("unsupported unsubscribe reference: %v", ref) } + +// NewIssueHandler handles new issues +type NewIssueHandler struct{} + +func (h *NewIssueHandler) Handle(ctx context.Context, content *MailContent, doer *user_model.User, payload []byte) error { + if doer == nil { + return util.NewInvalidArgumentErrorf("doer can't be nil") + } + + ref, err := incoming_payload.GetReferenceFromPayload(ctx, payload) + if err != nil { + return err + } + + var repo *repo_model.Repository + + switch r := ref.(type) { + case *repo_model.Repository: + repo = r + default: + return util.NewInvalidArgumentErrorf("unsupported reply reference: %v", ref) + } + + if util.IsEmptyString(content.Subject) { + return nil + } + + perm, err := access_model.GetUserRepoPermission(ctx, repo, doer) + if err != nil { + return err + } + if !perm.CanRead(unit.TypeIssues) { + return nil + } + + attachmentIDs := make([]string, 0, len(content.Attachments)) + if setting.Attachment.Enabled { + for _, attachment := range content.Attachments { + a, err := attachment_service.UploadAttachment(ctx, bytes.NewReader(attachment.Content), setting.Attachment.AllowedTypes, int64(len(attachment.Content)), &repo_model.Attachment{ + Name: attachment.Name, + UploaderID: doer.ID, + RepoID: repo.ID, + }) + if err != nil { + if upload.IsErrFileTypeForbidden(err) { + log.Info("NewIssueHandler: Skipping disallowed attachment type: %s", attachment.Name) + continue + } + return err + } + attachmentIDs = append(attachmentIDs, a.UUID) + } + } + + issue := &issues_model.Issue{ + RepoID: repo.ID, + Repo: repo, + Title: content.Subject, + PosterID: doer.ID, + Poster: doer, + Content: content.Content, + } + + if err := issue_service.NewIssue(ctx, repo, issue, []int64{}, attachmentIDs, []int64{}, 0); err != nil { + log.Warn("NewIssueHandler: Failed to create issue: %v", err) + } + + return nil +} + +// NewPullRequest handles new pull requests +type NewPullRequest struct{} + +func (h *NewPullRequest) Handle(ctx context.Context, _ *MailContent, doer *user_model.User, payload []byte) error { + return errors.New("not implemented") +} diff --git a/services/mailer/incoming/mailto_new_issue.go b/services/mailer/incoming/mailto_new_issue.go new file mode 100644 index 0000000000000..e42d5159d2e58 --- /dev/null +++ b/services/mailer/incoming/mailto_new_issue.go @@ -0,0 +1,38 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package incoming + +import ( + "context" + "strings" + + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload" + "code.gitea.io/gitea/services/mailer/token" +) + +func GenerateMailToRepoURL(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, event user_model.RepositoryRandsType) (string, string, error) { + _, err := doer.GetOrCreateRandsForRepository(ctx, repo.ID, event) + if err != nil { + return "", "", err + } + + payload, err := incoming_payload.CreateReferencePayload(&incoming_payload.ReferenceRepository{ + RepositoryID: repo.ID, + ActionType: incoming_payload.ReferenceRepositoryActionTypeNewIssue, + }) + if err != nil { + return "", "", err + } + + token, err := token.CreateToken(ctx, token.NewIssueHandlerType, doer, payload) + if err != nil { + return "", "", err + } + + mailToAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1) + return token, mailToAddress, nil +} diff --git a/services/mailer/incoming/payload/payload.go b/services/mailer/incoming/payload/payload.go index 00ada7826bdaf..3e4825e938b4b 100644 --- a/services/mailer/incoming/payload/payload.go +++ b/services/mailer/incoming/payload/payload.go @@ -7,6 +7,8 @@ import ( "context" issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/util" ) @@ -17,8 +19,22 @@ type payloadReferenceType byte const ( payloadReferenceIssue payloadReferenceType = iota payloadReferenceComment + payloadReferenceNewIssue + payloadReferenceNewPullRequest ) +type ReferenceRepositoryActionType int64 + +const ( + ReferenceRepositoryActionTypeNewIssue ReferenceRepositoryActionType = iota + ReferenceRepositoryActionTypeNewPullRequest +) + +type ReferenceRepository struct { + RepositoryID int64 + ActionType ReferenceRepositoryActionType +} + // CreateReferencePayload creates data which GetReferenceFromPayload resolves to the reference again. func CreateReferencePayload(reference any) ([]byte, error) { var refType payloadReferenceType @@ -31,6 +47,17 @@ func CreateReferencePayload(reference any) ([]byte, error) { case *issues_model.Comment: refType = payloadReferenceComment refID = r.ID + case *ReferenceRepository: + switch r.ActionType { + case ReferenceRepositoryActionTypeNewIssue: + refType = payloadReferenceNewIssue + refID = r.RepositoryID + case ReferenceRepositoryActionTypeNewPullRequest: + refType = payloadReferenceNewPullRequest + refID = r.RepositoryID + default: + return nil, util.NewInvalidArgumentErrorf("unsupported repository reference action type: %d", r.ActionType) + } default: return nil, util.NewInvalidArgumentErrorf("unsupported reference type: %T", r) } @@ -64,7 +91,41 @@ func GetReferenceFromPayload(ctx context.Context, payload []byte) (any, error) { return issues_model.GetIssueByID(ctx, id) case payloadReferenceComment: return issues_model.GetCommentByID(ctx, id) + case payloadReferenceNewIssue: + return repo_model.GetRepositoryByID(ctx, id) + case payloadReferenceNewPullRequest: + return repo_model.GetRepositoryByID(ctx, id) default: return nil, util.NewInvalidArgumentErrorf("unsupported reference type: %T", ref) } } + +func GetRandsFromPayload(ctx context.Context, doer *user_model.User, payload []byte) []byte { + if len(payload) < 1 { + return []byte{} + } + + if payload[0] != replyPayloadVersion1 { + return []byte{} + } + + var ref payloadReferenceType + var id int64 + if err := util.UnpackData(payload[1:], &ref, &id); err != nil { + return []byte{} + } + + switch ref { + case payloadReferenceIssue: + return []byte(doer.Rands) + case payloadReferenceComment: + return []byte(doer.Rands) + case payloadReferenceNewIssue: + rands, _ := user_model.GetRandsForRepository(ctx, doer.ID, id, user_model.RepositoryRandsTypeNewIssue) + return []byte(rands) + case payloadReferenceNewPullRequest: + return []byte{} + default: + return []byte{} + } +} diff --git a/services/mailer/mail.go b/services/mailer/mail.go index 52e19bde6f261..955f6f90c1600 100644 --- a/services/mailer/mail.go +++ b/services/mailer/mail.go @@ -325,7 +325,7 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient if setting.IncomingEmail.Enabled { if replyPayload != nil { - token, err := token.CreateToken(token.ReplyHandlerType, recipient, replyPayload) + token, err := token.CreateToken(ctx, token.ReplyHandlerType, recipient, replyPayload) if err != nil { log.Error("CreateToken failed: %v", err) } else { @@ -337,7 +337,7 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient } } - token, err := token.CreateToken(token.UnsubscribeHandlerType, recipient, unsubscribePayload) + token, err := token.CreateToken(ctx, token.UnsubscribeHandlerType, recipient, unsubscribePayload) if err != nil { log.Error("CreateToken failed: %v", err) } else { diff --git a/services/mailer/token/token.go b/services/mailer/token/token.go index 8a5a762d6b5fd..e97c3868286fb 100644 --- a/services/mailer/token/token.go +++ b/services/mailer/token/token.go @@ -13,6 +13,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/util" + incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload" ) // A token is a verifiable container describing an action. @@ -34,6 +35,8 @@ const ( UnknownHandlerType HandlerType = iota ReplyHandlerType UnsubscribeHandlerType + NewIssueHandlerType + NewPullRequestHandlerType ) var encodingWithoutPadding = base32.StdEncoding.WithPadding(base32.NoPadding) @@ -51,7 +54,7 @@ func (err *ErrToken) Unwrap() error { } // CreateToken creates a token for the action/user tuple -func CreateToken(ht HandlerType, user *user_model.User, data []byte) (string, error) { +func CreateToken(ctx context.Context, ht HandlerType, user *user_model.User, data []byte) (string, error) { payload, err := util.PackData( time.Now().AddDate(tokenLifetimeInYears, 0, 0).Unix(), ht, @@ -63,7 +66,7 @@ func CreateToken(ht HandlerType, user *user_model.User, data []byte) (string, er packagedData, err := util.PackData( user.ID, - generateHmac([]byte(user.Rands), payload), + generateHmac(incoming_payload.GetRandsFromPayload(ctx, user, data), payload), payload, ) if err != nil { @@ -100,10 +103,6 @@ func ExtractToken(ctx context.Context, token string) (HandlerType, *user_model.U return UnknownHandlerType, nil, nil, err } - if !crypto_hmac.Equal(hmac, generateHmac([]byte(user.Rands), payload)) { - return UnknownHandlerType, nil, nil, &ErrToken{"verification failed"} - } - var expiresUnix int64 var handlerType HandlerType var innerPayload []byte @@ -111,6 +110,10 @@ func ExtractToken(ctx context.Context, token string) (HandlerType, *user_model.U return UnknownHandlerType, nil, nil, err } + if !crypto_hmac.Equal(hmac, generateHmac(incoming_payload.GetRandsFromPayload(ctx, user, innerPayload), payload)) { + return UnknownHandlerType, nil, nil, &ErrToken{"verification failed"} + } + if time.Unix(expiresUnix, 0).Before(time.Now()) { return UnknownHandlerType, nil, nil, &ErrToken{"token expired"} } diff --git a/templates/repo/issue/list.tmpl b/templates/repo/issue/list.tmpl index 53d0eca171fee..e09e17d7cdb95 100644 --- a/templates/repo/issue/list.tmpl +++ b/templates/repo/issue/list.tmpl @@ -50,6 +50,9 @@ {{template "shared/issuelist" dict "." . "listType" "repo"}} + {{if and .PageIsIssueList .MailToIssueEnabled}} + {{template "repo/issue/mailto_module" dict "." .}} + {{end}} {{template "base/footer" .}} diff --git a/templates/repo/issue/mailto_module.tmpl b/templates/repo/issue/mailto_module.tmpl new file mode 100644 index 0000000000000..77ea535286005 --- /dev/null +++ b/templates/repo/issue/mailto_module.tmpl @@ -0,0 +1,24 @@ +