0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-03 13:34:11 +02:00

Merge branch 'main' into feat-32257-add-comments-unchanged-lines-and-show

This commit is contained in:
Rajesh Jonnalagadda 2025-03-03 11:19:37 +05:30 committed by GitHub
commit 38b0d576ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 831 additions and 713 deletions

View File

@ -337,3 +337,17 @@ func FixRunnersWithoutBelongingRepo(ctx context.Context) (int64, error) {
}
return res.RowsAffected()
}
func CountWrongRepoLevelRunners(ctx context.Context) (int64, error) {
var result int64
_, err := db.GetEngine(ctx).SQL("SELECT count(`id`) FROM `action_runner` WHERE `repo_id` > 0 AND `owner_id` > 0").Get(&result)
return result, err
}
func UpdateWrongRepoLevelRunners(ctx context.Context) (int64, error) {
result, err := db.GetEngine(ctx).Exec("UPDATE `action_runner` SET `owner_id` = 0 WHERE `repo_id` > 0 AND `owner_id` > 0")
if err != nil {
return 0, err
}
return result.RowsAffected()
}

View File

@ -147,3 +147,17 @@ func GetVariablesOfRun(ctx context.Context, run *ActionRun) (map[string]string,
return variables, nil
}
func CountWrongRepoLevelVariables(ctx context.Context) (int64, error) {
var result int64
_, err := db.GetEngine(ctx).SQL("SELECT count(`id`) FROM `action_variable` WHERE `repo_id` > 0 AND `owner_id` > 0").Get(&result)
return result, err
}
func UpdateWrongRepoLevelVariables(ctx context.Context) (int64, error) {
result, err := db.GetEngine(ctx).Exec("UPDATE `action_variable` SET `owner_id` = 0 WHERE `repo_id` > 0 AND `owner_id` > 0")
if err != nil {
return 0, err
}
return result.RowsAffected()
}

View File

@ -25,7 +25,7 @@ func (err ErrKeyUnableVerify) Error() string {
}
// ErrKeyIsPrivate is returned when the provided key is a private key not a public key
var ErrKeyIsPrivate = util.NewSilentWrapErrorf(util.ErrInvalidArgument, "the provided key is a private key")
var ErrKeyIsPrivate = util.ErrorWrap(util.ErrInvalidArgument, "the provided key is a private key")
// ErrKeyNotExist represents a "KeyNotExist" kind of error.
type ErrKeyNotExist struct {

View File

@ -77,7 +77,7 @@ func (err ErrNameCharsNotAllowed) Unwrap() error {
func IsUsableName(reservedNames, reservedPatterns []string, name string) error {
name = strings.TrimSpace(strings.ToLower(name))
if utf8.RuneCountInString(name) == 0 {
return util.SilentWrap{Message: "name is empty", Err: util.ErrInvalidArgument}
return util.NewInvalidArgumentErrorf("name is empty")
}
for i := range reservedNames {

View File

@ -61,7 +61,7 @@ func listPullRequestStatement(ctx context.Context, baseRepoID int64, opts *PullR
}
// GetUnmergedPullRequestsByHeadInfo returns all pull requests that are open and has not been merged
func GetUnmergedPullRequestsByHeadInfo(ctx context.Context, repoID int64, branch string) ([]*PullRequest, error) {
func GetUnmergedPullRequestsByHeadInfo(ctx context.Context, repoID int64, branch string) (PullRequestList, error) {
prs := make([]*PullRequest, 0, 2)
sess := db.GetEngine(ctx).
Join("INNER", "issue", "issue.id = pull_request.issue_id").
@ -116,7 +116,7 @@ func HasUnmergedPullRequestsByHeadInfo(ctx context.Context, repoID int64, branch
// GetUnmergedPullRequestsByBaseInfo returns all pull requests that are open and has not been merged
// by given base information (repo and branch).
func GetUnmergedPullRequestsByBaseInfo(ctx context.Context, repoID int64, branch string) ([]*PullRequest, error) {
func GetUnmergedPullRequestsByBaseInfo(ctx context.Context, repoID int64, branch string) (PullRequestList, error) {
prs := make([]*PullRequest, 0, 2)
return prs, db.GetEngine(ctx).
Where("base_repo_id=? AND base_branch=? AND has_merged=? AND issue.is_closed=?",

View File

@ -16,11 +16,11 @@ import (
func TestPullRequestList_LoadAttributes(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
prs := []*issues_model.PullRequest{
prs := issues_model.PullRequestList{
unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1}),
unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}),
}
assert.NoError(t, issues_model.PullRequestList(prs).LoadAttributes(db.DefaultContext))
assert.NoError(t, prs.LoadAttributes(db.DefaultContext))
for _, pr := range prs {
assert.NotNil(t, pr.Issue)
assert.Equal(t, pr.IssueID, pr.Issue.ID)
@ -32,11 +32,11 @@ func TestPullRequestList_LoadAttributes(t *testing.T) {
func TestPullRequestList_LoadReviewCommentsCounts(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
prs := []*issues_model.PullRequest{
prs := issues_model.PullRequestList{
unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1}),
unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}),
}
reviewComments, err := issues_model.PullRequestList(prs).LoadReviewCommentsCounts(db.DefaultContext)
reviewComments, err := prs.LoadReviewCommentsCounts(db.DefaultContext)
assert.NoError(t, err)
assert.Len(t, reviewComments, 2)
for _, pr := range prs {
@ -47,11 +47,11 @@ func TestPullRequestList_LoadReviewCommentsCounts(t *testing.T) {
func TestPullRequestList_LoadReviews(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
prs := []*issues_model.PullRequest{
prs := issues_model.PullRequestList{
unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1}),
unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}),
}
reviewList, err := issues_model.PullRequestList(prs).LoadReviews(db.DefaultContext)
reviewList, err := prs.LoadReviews(db.DefaultContext)
assert.NoError(t, err)
// 1, 7, 8, 9, 10, 22
assert.Len(t, reviewList, 6)

View File

@ -374,6 +374,7 @@ func prepareMigrationTasks() []*migration {
// Gitea 1.23.0-rc0 ends at migration ID number 311 (database version 312)
newMigration(312, "Add DeleteBranchAfterMerge to AutoMerge", v1_24.AddDeleteBranchAfterMergeForAutoMerge),
newMigration(313, "Move PinOrder from issue table to a new table issue_pin", v1_24.MovePinOrderToTableIssuePin),
newMigration(314, "Update OwnerID as zero for repository level action tables", v1_24.UpdateOwnerIDOfRepoLevelActionsTables),
}
return preparedMigrations
}

View File

@ -0,0 +1,19 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_24 //nolint
import (
"xorm.io/xorm"
)
func UpdateOwnerIDOfRepoLevelActionsTables(x *xorm.Engine) error {
if _, err := x.Exec("UPDATE `action_runner` SET `owner_id` = 0 WHERE `repo_id` > 0 AND `owner_id` > 0"); err != nil {
return err
}
if _, err := x.Exec("UPDATE `action_variable` SET `owner_id` = 0 WHERE `repo_id` > 0 AND `owner_id` > 0"); err != nil {
return err
}
_, err := x.Exec("UPDATE `secret` SET `owner_id` = 0 WHERE `repo_id` > 0 AND `owner_id` > 0")
return err
}

View File

@ -50,15 +50,15 @@ func (archiver *RepoArchiver) RelativePath() string {
func repoArchiverForRelativePath(relativePath string) (*RepoArchiver, error) {
parts := strings.SplitN(relativePath, "/", 3)
if len(parts) != 3 {
return nil, util.SilentWrap{Message: fmt.Sprintf("invalid storage path: %s", relativePath), Err: util.ErrInvalidArgument}
return nil, util.NewInvalidArgumentErrorf("invalid storage path: must have 3 parts")
}
repoID, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return nil, util.SilentWrap{Message: fmt.Sprintf("invalid storage path: %s", relativePath), Err: util.ErrInvalidArgument}
return nil, util.NewInvalidArgumentErrorf("invalid storage path: invalid repo id")
}
commitID, archiveType := git.SplitArchiveNameType(parts[2])
if archiveType == git.ArchiveUnknown {
return nil, util.SilentWrap{Message: fmt.Sprintf("invalid storage path: %s", relativePath), Err: util.ErrInvalidArgument}
return nil, util.NewInvalidArgumentErrorf("invalid storage path: invalid archive type")
}
return &RepoArchiver{RepoID: repoID, CommitID: commitID, Type: archiveType}, nil
}

View File

@ -165,3 +165,17 @@ func GetSecretsOfTask(ctx context.Context, task *actions_model.ActionTask) (map[
return secrets, nil
}
func CountWrongRepoLevelSecrets(ctx context.Context) (int64, error) {
var result int64
_, err := db.GetEngine(ctx).SQL("SELECT count(`id`) FROM `secret` WHERE `repo_id` > 0 AND `owner_id` > 0").Get(&result)
return result, err
}
func UpdateWrongRepoLevelSecrets(ctx context.Context) (int64, error) {
result, err := db.GetEngine(ctx).Exec("UPDATE `secret` SET `owner_id` = 0 WHERE `repo_id` > 0 AND `owner_id` > 0")
if err != nil {
return 0, err
}
return result.RowsAffected()
}

View File

@ -34,7 +34,7 @@ func SetMustChangePassword(ctx context.Context, all, mustChangePassword bool, in
if !all {
include = sliceTrimSpaceDropEmpty(include)
if len(include) == 0 {
return 0, util.NewSilentWrapErrorf(util.ErrInvalidArgument, "no users to include provided")
return 0, util.ErrorWrap(util.ErrInvalidArgument, "no users to include provided")
}
cond = cond.And(builder.In("lower_name", include))

View File

@ -62,6 +62,10 @@ func TestSanitizer(t *testing.T) {
`<a href="javascript:alert('xss')">bad</a>`, `bad`,
`<a href="vbscript:no">bad</a>`, `bad`,
`<a href="data:1234">bad</a>`, `bad`,
// Some classes and attributes are used by the frontend framework and will execute JS code, so make sure they are removed
`<div class="link-action" data-attr-class="foo" data-url="xxx">txt</div>`, `<div data-attr-class="foo">txt</div>`,
`<div class="form-fetch-action" data-markdown-generated-content="bar" data-global-init="a" data-global-click="b">txt</div>`, `<div data-markdown-generated-content="bar">txt</div>`,
}
for i := 0; i < len(testCases); i += 2 {

View File

@ -17,9 +17,9 @@ import (
)
var (
ErrInvalidStructure = util.SilentWrap{Message: "package structure is invalid", Err: util.ErrInvalidArgument}
ErrInvalidName = util.SilentWrap{Message: "package name is invalid", Err: util.ErrInvalidArgument}
ErrInvalidVersion = util.SilentWrap{Message: "package version is invalid", Err: util.ErrInvalidArgument}
ErrInvalidStructure = util.NewInvalidArgumentErrorf("package structure is invalid")
ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid")
ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
)
const (

View File

@ -1,13 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package i18n
import (
"code.gitea.io/gitea/modules/util"
)
var (
ErrLocaleAlreadyExist = util.SilentWrap{Message: "lang already exists", Err: util.ErrAlreadyExist}
ErrUncertainArguments = util.SilentWrap{Message: "arguments to i18n should not contain uncertain slices", Err: util.ErrInvalidArgument}
)

View File

@ -4,6 +4,7 @@
package i18n
import (
"errors"
"fmt"
"reflect"
)
@ -30,7 +31,7 @@ func Format(format string, args ...any) (msg string, err error) {
fmtArgs = append(fmtArgs, val.Index(i).Interface())
}
} else {
err = ErrUncertainArguments
err = errors.New("arguments to i18n should not contain uncertain slices")
break
}
} else {

View File

@ -4,6 +4,7 @@
package i18n
import (
"errors"
"fmt"
"html/template"
"slices"
@ -41,7 +42,7 @@ func NewLocaleStore() LocaleStore {
// AddLocaleByIni adds locale by ini into the store
func (store *localeStore) AddLocaleByIni(langName, langDesc string, source, moreSource []byte) error {
if _, ok := store.localeMap[langName]; ok {
return ErrLocaleAlreadyExist
return errors.New("lang has already been added")
}
store.langNames = append(store.langNames, langName)

View File

@ -22,74 +22,74 @@ var (
ErrUnprocessableContent = errors.New("unprocessable content")
)
// SilentWrap provides a simple wrapper for a wrapped error where the wrapped error message plays no part in the error message
// errorWrapper provides a simple wrapper for a wrapped error where the wrapped error message plays no part in the error message
// Especially useful for "untyped" errors created with "errors.New(…)" that can be classified as 'invalid argument', 'permission denied', 'exists already', or 'does not exist'
type SilentWrap struct {
type errorWrapper struct {
Message string
Err error
}
// Error returns the message
func (w SilentWrap) Error() string {
func (w errorWrapper) Error() string {
return w.Message
}
// Unwrap returns the underlying error
func (w SilentWrap) Unwrap() error {
func (w errorWrapper) Unwrap() error {
return w.Err
}
type LocaleWrap struct {
type LocaleWrapper struct {
err error
TrKey string
TrArgs []any
}
// Error returns the message
func (w LocaleWrap) Error() string {
func (w LocaleWrapper) Error() string {
return w.err.Error()
}
// Unwrap returns the underlying error
func (w LocaleWrap) Unwrap() error {
func (w LocaleWrapper) Unwrap() error {
return w.err
}
// NewSilentWrapErrorf returns an error that formats as the given text but unwraps as the provided error
func NewSilentWrapErrorf(unwrap error, message string, args ...any) error {
// ErrorWrap returns an error that formats as the given text but unwraps as the provided error
func ErrorWrap(unwrap error, message string, args ...any) error {
if len(args) == 0 {
return SilentWrap{Message: message, Err: unwrap}
return errorWrapper{Message: message, Err: unwrap}
}
return SilentWrap{Message: fmt.Sprintf(message, args...), Err: unwrap}
return errorWrapper{Message: fmt.Sprintf(message, args...), Err: unwrap}
}
// NewInvalidArgumentErrorf returns an error that formats as the given text but unwraps as an ErrInvalidArgument
func NewInvalidArgumentErrorf(message string, args ...any) error {
return NewSilentWrapErrorf(ErrInvalidArgument, message, args...)
return ErrorWrap(ErrInvalidArgument, message, args...)
}
// NewPermissionDeniedErrorf returns an error that formats as the given text but unwraps as an ErrPermissionDenied
func NewPermissionDeniedErrorf(message string, args ...any) error {
return NewSilentWrapErrorf(ErrPermissionDenied, message, args...)
return ErrorWrap(ErrPermissionDenied, message, args...)
}
// NewAlreadyExistErrorf returns an error that formats as the given text but unwraps as an ErrAlreadyExist
func NewAlreadyExistErrorf(message string, args ...any) error {
return NewSilentWrapErrorf(ErrAlreadyExist, message, args...)
return ErrorWrap(ErrAlreadyExist, message, args...)
}
// NewNotExistErrorf returns an error that formats as the given text but unwraps as an ErrNotExist
func NewNotExistErrorf(message string, args ...any) error {
return NewSilentWrapErrorf(ErrNotExist, message, args...)
return ErrorWrap(ErrNotExist, message, args...)
}
// ErrWrapLocale wraps an err with a translation key and arguments
func ErrWrapLocale(err error, trKey string, trArgs ...any) error {
return LocaleWrap{err: err, TrKey: trKey, TrArgs: trArgs}
// ErrorWrapLocale wraps an err with a translation key and arguments
func ErrorWrapLocale(err error, trKey string, trArgs ...any) error {
return LocaleWrapper{err: err, TrKey: trKey, TrArgs: trArgs}
}
func ErrAsLocale(err error) *LocaleWrap {
var e LocaleWrap
func ErrorAsLocale(err error) *LocaleWrapper {
var e LocaleWrapper
if errors.As(err, &e) {
return &e
}

View File

@ -789,7 +789,7 @@ func Run(ctx *context_module.Context) {
return nil
})
if err != nil {
if errLocale := util.ErrAsLocale(err); errLocale != nil {
if errLocale := util.ErrorAsLocale(err); errLocale != nil {
ctx.Flash.Error(ctx.Tr(errLocale.TrKey, errLocale.TrArgs...))
ctx.Redirect(redirectURL)
} else {

View File

@ -141,14 +141,14 @@ func GetActionWorkflow(ctx *context.APIContext, workflowID string) (*api.ActionW
func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, workflowID, ref string, processInputs func(model *model.WorkflowDispatch, inputs map[string]any) error) error {
if workflowID == "" {
return util.ErrWrapLocale(
return util.ErrorWrapLocale(
util.NewNotExistErrorf("workflowID is empty"),
"actions.workflow.not_found", workflowID,
)
}
if ref == "" {
return util.ErrWrapLocale(
return util.ErrorWrapLocale(
util.NewNotExistErrorf("ref is empty"),
"form.target_ref_not_exist", ref,
)
@ -158,7 +158,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions)
cfg := cfgUnit.ActionsConfig()
if cfg.IsWorkflowDisabled(workflowID) {
return util.ErrWrapLocale(
return util.ErrorWrapLocale(
util.NewPermissionDeniedErrorf("workflow is disabled"),
"actions.workflow.disabled",
)
@ -177,7 +177,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
runTargetCommit, err = gitRepo.GetBranchCommit(ref)
}
if err != nil {
return util.ErrWrapLocale(
return util.ErrorWrapLocale(
util.NewNotExistErrorf("ref %q doesn't exist", ref),
"form.target_ref_not_exist", ref,
)
@ -208,7 +208,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
}
if len(workflows) == 0 {
return util.ErrWrapLocale(
return util.ErrorWrapLocale(
util.NewNotExistErrorf("workflow %q doesn't exist", workflowID),
"actions.workflow.not_found", workflowID,
)

View File

@ -12,6 +12,7 @@ import (
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/migrations"
repo_model "code.gitea.io/gitea/models/repo"
secret_model "code.gitea.io/gitea/models/secret"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
)
@ -164,6 +165,24 @@ func prepareDBConsistencyChecks() []consistencyCheck {
Fixer: repo_model.DeleteOrphanedTopics,
FixedMessage: "Removed",
},
{
Name: "Repository level Runners with non-zero owner_id",
Counter: actions_model.CountWrongRepoLevelRunners,
Fixer: actions_model.UpdateWrongRepoLevelRunners,
FixedMessage: "Corrected",
},
{
Name: "Repository level Variables with non-zero owner_id",
Counter: actions_model.CountWrongRepoLevelVariables,
Fixer: actions_model.UpdateWrongRepoLevelVariables,
FixedMessage: "Corrected",
},
{
Name: "Repository level Secrets with non-zero owner_id",
Counter: secret_model.CountWrongRepoLevelSecrets,
Fixer: secret_model.UpdateWrongRepoLevelSecrets,
FixedMessage: "Corrected",
},
}
// TODO: function to recalc all counters

View File

@ -6,46 +6,19 @@ package mailer
import (
"bytes"
"context"
"fmt"
"html/template"
"mime"
"regexp"
"strconv"
"strings"
texttmpl "text/template"
"time"
activities_model "code.gitea.io/gitea/models/activities"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/renderhelper"
repo_model "code.gitea.io/gitea/models/repo"
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/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"
)
const (
mailAuthActivate templates.TplName = "auth/activate"
mailAuthActivateEmail templates.TplName = "auth/activate_email"
mailAuthResetPassword templates.TplName = "auth/reset_passwd"
mailAuthRegisterNotify templates.TplName = "auth/register_notify"
mailNotifyCollaborator templates.TplName = "notify/collaborator"
mailRepoTransferNotify templates.TplName = "notify/repo_transfer"
// There's no actual limit for subject in RFC 5322
mailMaxSubjectRunes = 256
)
const mailMaxSubjectRunes = 256 // There's no actual limit for subject in RFC 5322
var (
bodyTemplates *template.Template
@ -62,367 +35,6 @@ func SendTestMail(email string) error {
return sender_service.Send(sender, sender_service.NewMessage(email, "Gitea Test Email!", "Gitea Test Email!"))
}
// sendUserMail sends a mail to the user
func sendUserMail(language string, u *user_model.User, tpl templates.TplName, code, subject, info string) {
locale := translation.NewLocale(language)
data := map[string]any{
"locale": locale,
"DisplayName": u.DisplayName(),
"ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale),
"ResetPwdCodeLives": timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, locale),
"Code": code,
"Language": locale.Language(),
}
var content bytes.Buffer
if err := bodyTemplates.ExecuteTemplate(&content, string(tpl), data); err != nil {
log.Error("Template: %v", err)
return
}
msg := sender_service.NewMessage(u.EmailTo(), subject, content.String())
msg.Info = fmt.Sprintf("UID: %d, %s", u.ID, info)
SendAsync(msg)
}
// SendActivateAccountMail sends an activation mail to the user (new user registration)
func SendActivateAccountMail(locale translation.Locale, u *user_model.User) {
if setting.MailService == nil {
// No mail service configured
return
}
opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount}
sendUserMail(locale.Language(), u, mailAuthActivate, user_model.GenerateUserTimeLimitCode(opts, u), locale.TrString("mail.activate_account"), "activate account")
}
// SendResetPasswordMail sends a password reset mail to the user
func SendResetPasswordMail(u *user_model.User) {
if setting.MailService == nil {
// No mail service configured
return
}
locale := translation.NewLocale(u.Language)
opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeResetPassword}
sendUserMail(u.Language, u, mailAuthResetPassword, user_model.GenerateUserTimeLimitCode(opts, u), locale.TrString("mail.reset_password"), "recover account")
}
// SendActivateEmailMail sends confirmation email to confirm new email address
func SendActivateEmailMail(u *user_model.User, email string) {
if setting.MailService == nil {
// No mail service configured
return
}
locale := translation.NewLocale(u.Language)
opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateEmail, NewEmail: email}
data := map[string]any{
"locale": locale,
"DisplayName": u.DisplayName(),
"ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale),
"Code": user_model.GenerateUserTimeLimitCode(opts, u),
"Email": email,
"Language": locale.Language(),
}
var content bytes.Buffer
if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil {
log.Error("Template: %v", err)
return
}
msg := sender_service.NewMessage(email, locale.TrString("mail.activate_email"), content.String())
msg.Info = fmt.Sprintf("UID: %d, activate email", u.ID)
SendAsync(msg)
}
// SendRegisterNotifyMail triggers a notify e-mail by admin created a account.
func SendRegisterNotifyMail(u *user_model.User) {
if setting.MailService == nil || !u.IsActive {
// No mail service configured OR user is inactive
return
}
locale := translation.NewLocale(u.Language)
data := map[string]any{
"locale": locale,
"DisplayName": u.DisplayName(),
"Username": u.Name,
"Language": locale.Language(),
}
var content bytes.Buffer
if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthRegisterNotify), data); err != nil {
log.Error("Template: %v", err)
return
}
msg := sender_service.NewMessage(u.EmailTo(), locale.TrString("mail.register_notify", setting.AppName), content.String())
msg.Info = fmt.Sprintf("UID: %d, registration notify", u.ID)
SendAsync(msg)
}
// SendCollaboratorMail sends mail notification to new collaborator.
func SendCollaboratorMail(u, doer *user_model.User, repo *repo_model.Repository) {
if setting.MailService == nil || !u.IsActive {
// No mail service configured OR the user is inactive
return
}
locale := translation.NewLocale(u.Language)
repoName := repo.FullName()
subject := locale.TrString("mail.repo.collaborator.added.subject", doer.DisplayName(), repoName)
data := map[string]any{
"locale": locale,
"Subject": subject,
"RepoName": repoName,
"Link": repo.HTMLURL(),
"Language": locale.Language(),
}
var content bytes.Buffer
if err := bodyTemplates.ExecuteTemplate(&content, string(mailNotifyCollaborator), data); err != nil {
log.Error("Template: %v", err)
return
}
msg := sender_service.NewMessage(u.EmailTo(), subject, content.String())
msg.Info = fmt.Sprintf("UID: %d, add collaborator", u.ID)
SendAsync(msg)
}
func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipients []*user_model.User, fromMention bool, info string) ([]*sender_service.Message, error) {
var (
subject string
link string
prefix string
// Fall back subject for bad templates, make sure subject is never empty
fallback string
reviewComments []*issues_model.Comment
)
commentType := issues_model.CommentTypeComment
if ctx.Comment != nil {
commentType = ctx.Comment.Type
link = ctx.Issue.HTMLURL() + "#" + ctx.Comment.HashTag()
} else {
link = ctx.Issue.HTMLURL()
}
reviewType := issues_model.ReviewTypeComment
if ctx.Comment != nil && ctx.Comment.Review != nil {
reviewType = ctx.Comment.Review.Type
}
// This is the body of the new issue or comment, not the mail body
rctx := renderhelper.NewRenderContextRepoComment(ctx.Context, ctx.Issue.Repo).WithUseAbsoluteLink(true)
body, err := markdown.RenderString(rctx,
ctx.Content)
if err != nil {
return nil, err
}
actType, actName, tplName := actionToTemplate(ctx.Issue, ctx.ActionType, commentType, reviewType)
if actName != "new" {
prefix = "Re: "
}
fallback = prefix + fallbackMailSubject(ctx.Issue)
if ctx.Comment != nil && ctx.Comment.Review != nil {
reviewComments = make([]*issues_model.Comment, 0, 10)
for _, lines := range ctx.Comment.Review.CodeComments {
for _, comments := range lines {
reviewComments = append(reviewComments, comments...)
}
}
}
locale := translation.NewLocale(lang)
mailMeta := map[string]any{
"locale": locale,
"FallbackSubject": fallback,
"Body": body,
"Link": link,
"Issue": ctx.Issue,
"Comment": ctx.Comment,
"IsPull": ctx.Issue.IsPull,
"User": ctx.Issue.Repo.MustOwner(ctx),
"Repo": ctx.Issue.Repo.FullName(),
"Doer": ctx.Doer,
"IsMention": fromMention,
"SubjectPrefix": prefix,
"ActionType": actType,
"ActionName": actName,
"ReviewComments": reviewComments,
"Language": locale.Language(),
"CanReply": setting.IncomingEmail.Enabled && commentType != issues_model.CommentTypePullRequestPush,
}
var mailSubject bytes.Buffer
if err := subjectTemplates.ExecuteTemplate(&mailSubject, tplName, mailMeta); err == nil {
subject = sanitizeSubject(mailSubject.String())
if subject == "" {
subject = fallback
}
} else {
log.Error("ExecuteTemplate [%s]: %v", tplName+"/subject", err)
}
subject = emoji.ReplaceAliases(subject)
mailMeta["Subject"] = subject
var mailBody bytes.Buffer
if err := bodyTemplates.ExecuteTemplate(&mailBody, tplName, mailMeta); err != nil {
log.Error("ExecuteTemplate [%s]: %v", tplName+"/body", err)
}
// Make sure to compose independent messages to avoid leaking user emails
msgID := generateMessageIDForIssue(ctx.Issue, ctx.Comment, ctx.ActionType)
reference := generateMessageIDForIssue(ctx.Issue, nil, activities_model.ActionType(0))
var replyPayload []byte
if ctx.Comment != nil {
if ctx.Comment.Type.HasMailReplySupport() {
replyPayload, err = incoming_payload.CreateReferencePayload(ctx.Comment)
}
} else {
replyPayload, err = incoming_payload.CreateReferencePayload(ctx.Issue)
}
if err != nil {
return nil, err
}
unsubscribePayload, err := incoming_payload.CreateReferencePayload(ctx.Issue)
if err != nil {
return nil, err
}
msgs := make([]*sender_service.Message, 0, len(recipients))
for _, recipient := range recipients {
msg := sender_service.NewMessageFrom(
recipient.Email,
fromDisplayName(ctx.Doer),
setting.MailService.FromEmail,
subject,
mailBody.String(),
)
msg.Info = fmt.Sprintf("Subject: %s, %s", subject, info)
msg.SetHeader("Message-ID", msgID)
msg.SetHeader("In-Reply-To", reference)
references := []string{reference}
listUnsubscribe := []string{"<" + ctx.Issue.HTMLURL() + ">"}
if setting.IncomingEmail.Enabled {
if replyPayload != nil {
token, err := token.CreateToken(token.ReplyHandlerType, recipient, replyPayload)
if err != nil {
log.Error("CreateToken failed: %v", err)
} else {
replyAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1)
msg.ReplyTo = replyAddress
msg.SetHeader("List-Post", fmt.Sprintf("<mailto:%s>", replyAddress))
references = append(references, fmt.Sprintf("<reply-%s@%s>", token, setting.Domain))
}
}
token, err := token.CreateToken(token.UnsubscribeHandlerType, recipient, unsubscribePayload)
if err != nil {
log.Error("CreateToken failed: %v", err)
} else {
unsubAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1)
listUnsubscribe = append(listUnsubscribe, "<mailto:"+unsubAddress+">")
}
}
msg.SetHeader("References", references...)
msg.SetHeader("List-Unsubscribe", listUnsubscribe...)
for key, value := range generateAdditionalHeaders(ctx, actType, recipient) {
msg.SetHeader(key, value)
}
msgs = append(msgs, msg)
}
return msgs, nil
}
func generateMessageIDForIssue(issue *issues_model.Issue, comment *issues_model.Comment, actionType activities_model.ActionType) string {
var path string
if issue.IsPull {
path = "pulls"
} else {
path = "issues"
}
var extra string
if comment != nil {
extra = fmt.Sprintf("/comment/%d", comment.ID)
} else {
switch actionType {
case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest:
extra = fmt.Sprintf("/close/%d", time.Now().UnixNano()/1e6)
case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest:
extra = fmt.Sprintf("/reopen/%d", time.Now().UnixNano()/1e6)
case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest:
extra = fmt.Sprintf("/merge/%d", time.Now().UnixNano()/1e6)
case activities_model.ActionPullRequestReadyForReview:
extra = fmt.Sprintf("/ready/%d", time.Now().UnixNano()/1e6)
}
}
return fmt.Sprintf("<%s/%s/%d%s@%s>", issue.Repo.FullName(), path, issue.Index, extra, setting.Domain)
}
func generateMessageIDForRelease(release *repo_model.Release) string {
return fmt.Sprintf("<%s/releases/%d@%s>", release.Repo.FullName(), release.ID, setting.Domain)
}
func generateAdditionalHeaders(ctx *mailCommentContext, reason string, recipient *user_model.User) map[string]string {
repo := ctx.Issue.Repo
return map[string]string{
// https://datatracker.ietf.org/doc/html/rfc2919
"List-ID": fmt.Sprintf("%s <%s.%s.%s>", repo.FullName(), repo.Name, repo.OwnerName, setting.Domain),
// https://datatracker.ietf.org/doc/html/rfc2369
"List-Archive": fmt.Sprintf("<%s>", repo.HTMLURL()),
"X-Mailer": "Gitea",
"X-Gitea-Reason": reason,
"X-Gitea-Sender": ctx.Doer.Name,
"X-Gitea-Recipient": recipient.Name,
"X-Gitea-Recipient-Address": recipient.Email,
"X-Gitea-Repository": repo.Name,
"X-Gitea-Repository-Path": repo.FullName(),
"X-Gitea-Repository-Link": repo.HTMLURL(),
"X-Gitea-Issue-ID": strconv.FormatInt(ctx.Issue.Index, 10),
"X-Gitea-Issue-Link": ctx.Issue.HTMLURL(),
"X-GitHub-Reason": reason,
"X-GitHub-Sender": ctx.Doer.Name,
"X-GitHub-Recipient": recipient.Name,
"X-GitHub-Recipient-Address": recipient.Email,
"X-GitLab-NotificationReason": reason,
"X-GitLab-Project": repo.Name,
"X-GitLab-Project-Path": repo.FullName(),
"X-GitLab-Issue-IID": strconv.FormatInt(ctx.Issue.Index, 10),
}
}
func sanitizeSubject(subject string) string {
runes := []rune(strings.TrimSpace(subjectRemoveSpaces.ReplaceAllLiteralString(subject, " ")))
if len(runes) > mailMaxSubjectRunes {
@ -432,107 +44,6 @@ func sanitizeSubject(subject string) string {
return mime.QEncoding.Encode("utf-8", string(runes))
}
// SendIssueAssignedMail composes and sends issue assigned email
func SendIssueAssignedMail(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, content string, comment *issues_model.Comment, recipients []*user_model.User) error {
if setting.MailService == nil {
// No mail service configured
return nil
}
if err := issue.LoadRepo(ctx); err != nil {
log.Error("Unable to load repo [%d] for issue #%d [%d]. Error: %v", issue.RepoID, issue.Index, issue.ID, err)
return err
}
langMap := make(map[string][]*user_model.User)
for _, user := range recipients {
if !user.IsActive {
// don't send emails to inactive users
continue
}
langMap[user.Language] = append(langMap[user.Language], user)
}
for lang, tos := range langMap {
msgs, err := composeIssueCommentMessages(&mailCommentContext{
Context: ctx,
Issue: issue,
Doer: doer,
ActionType: activities_model.ActionType(0),
Content: content,
Comment: comment,
}, lang, tos, false, "issue assigned")
if err != nil {
return err
}
SendAsync(msgs...)
}
return nil
}
// actionToTemplate returns the type and name of the action facing the user
// (slightly different from activities_model.ActionType) and the name of the template to use (based on availability)
func actionToTemplate(issue *issues_model.Issue, actionType activities_model.ActionType,
commentType issues_model.CommentType, reviewType issues_model.ReviewType,
) (typeName, name, template string) {
if issue.IsPull {
typeName = "pull"
} else {
typeName = "issue"
}
switch actionType {
case activities_model.ActionCreateIssue, activities_model.ActionCreatePullRequest:
name = "new"
case activities_model.ActionCommentIssue, activities_model.ActionCommentPull:
name = "comment"
case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest:
name = "close"
case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest:
name = "reopen"
case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest:
name = "merge"
case activities_model.ActionPullReviewDismissed:
name = "review_dismissed"
case activities_model.ActionPullRequestReadyForReview:
name = "ready_for_review"
default:
switch commentType {
case issues_model.CommentTypeReview:
switch reviewType {
case issues_model.ReviewTypeApprove:
name = "approve"
case issues_model.ReviewTypeReject:
name = "reject"
default:
name = "review"
}
case issues_model.CommentTypeCode:
name = "code"
case issues_model.CommentTypeAssignees:
name = "assigned"
case issues_model.CommentTypePullRequestPush:
name = "push"
default:
name = "default"
}
}
template = typeName + "/" + name
ok := bodyTemplates.Lookup(template) != nil
if !ok && typeName != "issue" {
template = "issue/" + name
ok = bodyTemplates.Lookup(template) != nil
}
if !ok {
template = typeName + "/default"
ok = bodyTemplates.Lookup(template) != nil
}
if !ok {
template = "issue/default"
}
return typeName, name, template
}
func fromDisplayName(u *user_model.User) string {
if setting.MailService.FromDisplayNameFormatTemplate != nil {
var ctx bytes.Buffer

View File

@ -18,24 +18,7 @@ import (
"code.gitea.io/gitea/modules/setting"
)
func fallbackMailSubject(issue *issues_model.Issue) string {
return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.FullName(), issue.Title, issue.Index)
}
type mailCommentContext struct {
context.Context
Issue *issues_model.Issue
Doer *user_model.User
ActionType activities_model.ActionType
Content string
Comment *issues_model.Comment
ForceDoerNotification bool
}
const (
// MailBatchSize set the batch size used in mailIssueCommentBatch
MailBatchSize = 100
)
const MailBatchSize = 100 // batch size used in mailIssueCommentBatch
// mailIssueCommentToParticipants can be used for both new issue creation and comment.
// This function sends two list of emails:
@ -199,3 +182,41 @@ func MailParticipants(ctx context.Context, issue *issues_model.Issue, doer *user
}
return nil
}
// SendIssueAssignedMail composes and sends issue assigned email
func SendIssueAssignedMail(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, content string, comment *issues_model.Comment, recipients []*user_model.User) error {
if setting.MailService == nil {
// No mail service configured
return nil
}
if err := issue.LoadRepo(ctx); err != nil {
log.Error("Unable to load repo [%d] for issue #%d [%d]. Error: %v", issue.RepoID, issue.Index, issue.ID, err)
return err
}
langMap := make(map[string][]*user_model.User)
for _, user := range recipients {
if !user.IsActive {
// don't send emails to inactive users
continue
}
langMap[user.Language] = append(langMap[user.Language], user)
}
for lang, tos := range langMap {
msgs, err := composeIssueCommentMessages(&mailCommentContext{
Context: ctx,
Issue: issue,
Doer: doer,
ActionType: activities_model.ActionType(0),
Content: content,
Comment: comment,
}, lang, tos, false, "issue assigned")
if err != nil {
return err
}
SendAsync(msgs...)
}
return nil
}

View File

@ -0,0 +1,325 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package mailer
import (
"bytes"
"context"
"fmt"
"strconv"
"strings"
"time"
activities_model "code.gitea.io/gitea/models/activities"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/renderhelper"
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/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"
)
func fallbackMailSubject(issue *issues_model.Issue) string {
return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.FullName(), issue.Title, issue.Index)
}
type mailCommentContext struct {
context.Context
Issue *issues_model.Issue
Doer *user_model.User
ActionType activities_model.ActionType
Content string
Comment *issues_model.Comment
ForceDoerNotification bool
}
func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipients []*user_model.User, fromMention bool, info string) ([]*sender_service.Message, error) {
var (
subject string
link string
prefix string
// Fall back subject for bad templates, make sure subject is never empty
fallback string
reviewComments []*issues_model.Comment
)
commentType := issues_model.CommentTypeComment
if ctx.Comment != nil {
commentType = ctx.Comment.Type
link = ctx.Issue.HTMLURL() + "#" + ctx.Comment.HashTag()
} else {
link = ctx.Issue.HTMLURL()
}
reviewType := issues_model.ReviewTypeComment
if ctx.Comment != nil && ctx.Comment.Review != nil {
reviewType = ctx.Comment.Review.Type
}
// This is the body of the new issue or comment, not the mail body
rctx := renderhelper.NewRenderContextRepoComment(ctx.Context, ctx.Issue.Repo).WithUseAbsoluteLink(true)
body, err := markdown.RenderString(rctx,
ctx.Content)
if err != nil {
return nil, err
}
actType, actName, tplName := actionToTemplate(ctx.Issue, ctx.ActionType, commentType, reviewType)
if actName != "new" {
prefix = "Re: "
}
fallback = prefix + fallbackMailSubject(ctx.Issue)
if ctx.Comment != nil && ctx.Comment.Review != nil {
reviewComments = make([]*issues_model.Comment, 0, 10)
for _, lines := range ctx.Comment.Review.CodeComments {
for _, comments := range lines {
reviewComments = append(reviewComments, comments...)
}
}
}
locale := translation.NewLocale(lang)
mailMeta := map[string]any{
"locale": locale,
"FallbackSubject": fallback,
"Body": body,
"Link": link,
"Issue": ctx.Issue,
"Comment": ctx.Comment,
"IsPull": ctx.Issue.IsPull,
"User": ctx.Issue.Repo.MustOwner(ctx),
"Repo": ctx.Issue.Repo.FullName(),
"Doer": ctx.Doer,
"IsMention": fromMention,
"SubjectPrefix": prefix,
"ActionType": actType,
"ActionName": actName,
"ReviewComments": reviewComments,
"Language": locale.Language(),
"CanReply": setting.IncomingEmail.Enabled && commentType != issues_model.CommentTypePullRequestPush,
}
var mailSubject bytes.Buffer
if err := subjectTemplates.ExecuteTemplate(&mailSubject, tplName, mailMeta); err == nil {
subject = sanitizeSubject(mailSubject.String())
if subject == "" {
subject = fallback
}
} else {
log.Error("ExecuteTemplate [%s]: %v", tplName+"/subject", err)
}
subject = emoji.ReplaceAliases(subject)
mailMeta["Subject"] = subject
var mailBody bytes.Buffer
if err := bodyTemplates.ExecuteTemplate(&mailBody, tplName, mailMeta); err != nil {
log.Error("ExecuteTemplate [%s]: %v", tplName+"/body", err)
}
// Make sure to compose independent messages to avoid leaking user emails
msgID := generateMessageIDForIssue(ctx.Issue, ctx.Comment, ctx.ActionType)
reference := generateMessageIDForIssue(ctx.Issue, nil, activities_model.ActionType(0))
var replyPayload []byte
if ctx.Comment != nil {
if ctx.Comment.Type.HasMailReplySupport() {
replyPayload, err = incoming_payload.CreateReferencePayload(ctx.Comment)
}
} else {
replyPayload, err = incoming_payload.CreateReferencePayload(ctx.Issue)
}
if err != nil {
return nil, err
}
unsubscribePayload, err := incoming_payload.CreateReferencePayload(ctx.Issue)
if err != nil {
return nil, err
}
msgs := make([]*sender_service.Message, 0, len(recipients))
for _, recipient := range recipients {
msg := sender_service.NewMessageFrom(
recipient.Email,
fromDisplayName(ctx.Doer),
setting.MailService.FromEmail,
subject,
mailBody.String(),
)
msg.Info = fmt.Sprintf("Subject: %s, %s", subject, info)
msg.SetHeader("Message-ID", msgID)
msg.SetHeader("In-Reply-To", reference)
references := []string{reference}
listUnsubscribe := []string{"<" + ctx.Issue.HTMLURL() + ">"}
if setting.IncomingEmail.Enabled {
if replyPayload != nil {
token, err := token.CreateToken(token.ReplyHandlerType, recipient, replyPayload)
if err != nil {
log.Error("CreateToken failed: %v", err)
} else {
replyAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1)
msg.ReplyTo = replyAddress
msg.SetHeader("List-Post", fmt.Sprintf("<mailto:%s>", replyAddress))
references = append(references, fmt.Sprintf("<reply-%s@%s>", token, setting.Domain))
}
}
token, err := token.CreateToken(token.UnsubscribeHandlerType, recipient, unsubscribePayload)
if err != nil {
log.Error("CreateToken failed: %v", err)
} else {
unsubAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1)
listUnsubscribe = append(listUnsubscribe, "<mailto:"+unsubAddress+">")
}
}
msg.SetHeader("References", references...)
msg.SetHeader("List-Unsubscribe", listUnsubscribe...)
for key, value := range generateAdditionalHeaders(ctx, actType, recipient) {
msg.SetHeader(key, value)
}
msgs = append(msgs, msg)
}
return msgs, nil
}
// actionToTemplate returns the type and name of the action facing the user
// (slightly different from activities_model.ActionType) and the name of the template to use (based on availability)
func actionToTemplate(issue *issues_model.Issue, actionType activities_model.ActionType,
commentType issues_model.CommentType, reviewType issues_model.ReviewType,
) (typeName, name, template string) {
if issue.IsPull {
typeName = "pull"
} else {
typeName = "issue"
}
switch actionType {
case activities_model.ActionCreateIssue, activities_model.ActionCreatePullRequest:
name = "new"
case activities_model.ActionCommentIssue, activities_model.ActionCommentPull:
name = "comment"
case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest:
name = "close"
case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest:
name = "reopen"
case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest:
name = "merge"
case activities_model.ActionPullReviewDismissed:
name = "review_dismissed"
case activities_model.ActionPullRequestReadyForReview:
name = "ready_for_review"
default:
switch commentType {
case issues_model.CommentTypeReview:
switch reviewType {
case issues_model.ReviewTypeApprove:
name = "approve"
case issues_model.ReviewTypeReject:
name = "reject"
default:
name = "review"
}
case issues_model.CommentTypeCode:
name = "code"
case issues_model.CommentTypeAssignees:
name = "assigned"
case issues_model.CommentTypePullRequestPush:
name = "push"
default:
name = "default"
}
}
template = typeName + "/" + name
ok := bodyTemplates.Lookup(template) != nil
if !ok && typeName != "issue" {
template = "issue/" + name
ok = bodyTemplates.Lookup(template) != nil
}
if !ok {
template = typeName + "/default"
ok = bodyTemplates.Lookup(template) != nil
}
if !ok {
template = "issue/default"
}
return typeName, name, template
}
func generateMessageIDForIssue(issue *issues_model.Issue, comment *issues_model.Comment, actionType activities_model.ActionType) string {
var path string
if issue.IsPull {
path = "pulls"
} else {
path = "issues"
}
var extra string
if comment != nil {
extra = fmt.Sprintf("/comment/%d", comment.ID)
} else {
switch actionType {
case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest:
extra = fmt.Sprintf("/close/%d", time.Now().UnixNano()/1e6)
case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest:
extra = fmt.Sprintf("/reopen/%d", time.Now().UnixNano()/1e6)
case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest:
extra = fmt.Sprintf("/merge/%d", time.Now().UnixNano()/1e6)
case activities_model.ActionPullRequestReadyForReview:
extra = fmt.Sprintf("/ready/%d", time.Now().UnixNano()/1e6)
}
}
return fmt.Sprintf("<%s/%s/%d%s@%s>", issue.Repo.FullName(), path, issue.Index, extra, setting.Domain)
}
func generateAdditionalHeaders(ctx *mailCommentContext, reason string, recipient *user_model.User) map[string]string {
repo := ctx.Issue.Repo
return map[string]string{
// https://datatracker.ietf.org/doc/html/rfc2919
"List-ID": fmt.Sprintf("%s <%s.%s.%s>", repo.FullName(), repo.Name, repo.OwnerName, setting.Domain),
// https://datatracker.ietf.org/doc/html/rfc2369
"List-Archive": fmt.Sprintf("<%s>", repo.HTMLURL()),
"X-Mailer": "Gitea",
"X-Gitea-Reason": reason,
"X-Gitea-Sender": ctx.Doer.Name,
"X-Gitea-Recipient": recipient.Name,
"X-Gitea-Recipient-Address": recipient.Email,
"X-Gitea-Repository": repo.Name,
"X-Gitea-Repository-Path": repo.FullName(),
"X-Gitea-Repository-Link": repo.HTMLURL(),
"X-Gitea-Issue-ID": strconv.FormatInt(ctx.Issue.Index, 10),
"X-Gitea-Issue-Link": ctx.Issue.HTMLURL(),
"X-GitHub-Reason": reason,
"X-GitHub-Sender": ctx.Doer.Name,
"X-GitHub-Recipient": recipient.Name,
"X-GitHub-Recipient-Address": recipient.Email,
"X-GitLab-NotificationReason": reason,
"X-GitLab-Project": repo.Name,
"X-GitLab-Project-Path": repo.FullName(),
"X-GitLab-Issue-IID": strconv.FormatInt(ctx.Issue.Index, 10),
}
}

View File

@ -6,6 +6,7 @@ package mailer
import (
"bytes"
"context"
"fmt"
"code.gitea.io/gitea/models/renderhelper"
repo_model "code.gitea.io/gitea/models/repo"
@ -18,9 +19,11 @@ import (
sender_service "code.gitea.io/gitea/services/mailer/sender"
)
const (
tplNewReleaseMail templates.TplName = "release"
)
const tplNewReleaseMail templates.TplName = "release"
func generateMessageIDForRelease(release *repo_model.Release) string {
return fmt.Sprintf("<%s/releases/%d@%s>", release.Repo.FullName(), release.ID, setting.Domain)
}
// MailNewRelease send new release notify to all repo watchers.
func MailNewRelease(ctx context.Context, rel *repo_model.Release) {

View File

@ -12,10 +12,13 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/translation"
sender_service "code.gitea.io/gitea/services/mailer/sender"
)
const mailRepoTransferNotify templates.TplName = "notify/repo_transfer"
// SendRepoTransferNotifyMail triggers a notification e-mail when a pending repository transfer was created
func SendRepoTransferNotifyMail(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) error {
if setting.MailService == nil {

View File

@ -18,9 +18,7 @@ import (
sender_service "code.gitea.io/gitea/services/mailer/sender"
)
const (
tplTeamInviteMail templates.TplName = "team_invite"
)
const tplTeamInviteMail templates.TplName = "team_invite"
// MailTeamInvite sends team invites
func MailTeamInvite(ctx context.Context, inviter *user_model.User, team *org_model.Team, invite *org_model.TeamInvite) error {

View File

@ -0,0 +1,161 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package mailer
import (
"bytes"
"fmt"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/translation"
sender_service "code.gitea.io/gitea/services/mailer/sender"
)
const (
mailAuthActivate templates.TplName = "auth/activate"
mailAuthActivateEmail templates.TplName = "auth/activate_email"
mailAuthResetPassword templates.TplName = "auth/reset_passwd"
mailAuthRegisterNotify templates.TplName = "auth/register_notify"
mailNotifyCollaborator templates.TplName = "notify/collaborator"
)
// sendUserMail sends a mail to the user
func sendUserMail(language string, u *user_model.User, tpl templates.TplName, code, subject, info string) {
locale := translation.NewLocale(language)
data := map[string]any{
"locale": locale,
"DisplayName": u.DisplayName(),
"ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale),
"ResetPwdCodeLives": timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, locale),
"Code": code,
"Language": locale.Language(),
}
var content bytes.Buffer
if err := bodyTemplates.ExecuteTemplate(&content, string(tpl), data); err != nil {
log.Error("Template: %v", err)
return
}
msg := sender_service.NewMessage(u.EmailTo(), subject, content.String())
msg.Info = fmt.Sprintf("UID: %d, %s", u.ID, info)
SendAsync(msg)
}
// SendActivateAccountMail sends an activation mail to the user (new user registration)
func SendActivateAccountMail(locale translation.Locale, u *user_model.User) {
if setting.MailService == nil {
// No mail service configured
return
}
opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount}
sendUserMail(locale.Language(), u, mailAuthActivate, user_model.GenerateUserTimeLimitCode(opts, u), locale.TrString("mail.activate_account"), "activate account")
}
// SendResetPasswordMail sends a password reset mail to the user
func SendResetPasswordMail(u *user_model.User) {
if setting.MailService == nil {
// No mail service configured
return
}
locale := translation.NewLocale(u.Language)
opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeResetPassword}
sendUserMail(u.Language, u, mailAuthResetPassword, user_model.GenerateUserTimeLimitCode(opts, u), locale.TrString("mail.reset_password"), "recover account")
}
// SendActivateEmailMail sends confirmation email to confirm new email address
func SendActivateEmailMail(u *user_model.User, email string) {
if setting.MailService == nil {
// No mail service configured
return
}
locale := translation.NewLocale(u.Language)
opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateEmail, NewEmail: email}
data := map[string]any{
"locale": locale,
"DisplayName": u.DisplayName(),
"ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale),
"Code": user_model.GenerateUserTimeLimitCode(opts, u),
"Email": email,
"Language": locale.Language(),
}
var content bytes.Buffer
if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil {
log.Error("Template: %v", err)
return
}
msg := sender_service.NewMessage(email, locale.TrString("mail.activate_email"), content.String())
msg.Info = fmt.Sprintf("UID: %d, activate email", u.ID)
SendAsync(msg)
}
// SendRegisterNotifyMail triggers a notify e-mail by admin created a account.
func SendRegisterNotifyMail(u *user_model.User) {
if setting.MailService == nil || !u.IsActive {
// No mail service configured OR user is inactive
return
}
locale := translation.NewLocale(u.Language)
data := map[string]any{
"locale": locale,
"DisplayName": u.DisplayName(),
"Username": u.Name,
"Language": locale.Language(),
}
var content bytes.Buffer
if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthRegisterNotify), data); err != nil {
log.Error("Template: %v", err)
return
}
msg := sender_service.NewMessage(u.EmailTo(), locale.TrString("mail.register_notify", setting.AppName), content.String())
msg.Info = fmt.Sprintf("UID: %d, registration notify", u.ID)
SendAsync(msg)
}
// SendCollaboratorMail sends mail notification to new collaborator.
func SendCollaboratorMail(u, doer *user_model.User, repo *repo_model.Repository) {
if setting.MailService == nil || !u.IsActive {
// No mail service configured OR the user is inactive
return
}
locale := translation.NewLocale(u.Language)
repoName := repo.FullName()
subject := locale.TrString("mail.repo.collaborator.added.subject", doer.DisplayName(), repoName)
data := map[string]any{
"locale": locale,
"Subject": subject,
"RepoName": repoName,
"Link": repo.HTMLURL(),
"Language": locale.Language(),
}
var content bytes.Buffer
if err := bodyTemplates.ExecuteTemplate(&content, string(mailNotifyCollaborator), data); err != nil {
log.Error("Template: %v", err)
return
}
msg := sender_service.NewMessage(u.EmailTo(), subject, content.String())
msg.Info = fmt.Sprintf("UID: %d, add collaborator", u.ID)
SendAsync(msg)
}

View File

@ -407,11 +407,10 @@ func AddTestPullRequestTask(doer *user_model.User, repoID int64, branch string,
}
if isSync {
requests := issues_model.PullRequestList(prs)
if err = requests.LoadAttributes(ctx); err != nil {
if err = prs.LoadAttributes(ctx); err != nil {
log.Error("PullRequestList.LoadAttributes: %v", err)
}
if invalidationErr := checkForInvalidation(ctx, requests, repoID, doer, branch); invalidationErr != nil {
if invalidationErr := checkForInvalidation(ctx, prs, repoID, doer, branch); invalidationErr != nil {
log.Error("checkForInvalidation: %v", invalidationErr)
}
if err == nil {
@ -645,7 +644,7 @@ func retargetBranchPulls(ctx context.Context, doer *user_model.User, repoID int6
return err
}
if err := issues_model.PullRequestList(prs).LoadAttributes(ctx); err != nil {
if err := prs.LoadAttributes(ctx); err != nil {
return err
}
@ -672,11 +671,11 @@ func AdjustPullsCausedByBranchDeleted(ctx context.Context, doer *user_model.User
return err
}
if err := issues_model.PullRequestList(prs).LoadAttributes(ctx); err != nil {
if err := prs.LoadAttributes(ctx); err != nil {
return err
}
issues_model.PullRequestList(prs).SetHeadRepo(repo)
if err := issues_model.PullRequestList(prs).LoadRepositories(ctx); err != nil {
prs.SetHeadRepo(repo)
if err := prs.LoadRepositories(ctx); err != nil {
return err
}
@ -707,11 +706,11 @@ func AdjustPullsCausedByBranchDeleted(ctx context.Context, doer *user_model.User
return err
}
if err := issues_model.PullRequestList(prs).LoadAttributes(ctx); err != nil {
if err := prs.LoadAttributes(ctx); err != nil {
return err
}
issues_model.PullRequestList(prs).SetBaseRepo(repo)
if err := issues_model.PullRequestList(prs).LoadRepositories(ctx); err != nil {
prs.SetBaseRepo(repo)
if err := prs.LoadRepositories(ctx); err != nil {
return err
}
@ -744,7 +743,7 @@ func CloseRepoBranchesPulls(ctx context.Context, doer *user_model.User, repo *re
return err
}
if err = issues_model.PullRequestList(prs).LoadAttributes(ctx); err != nil {
if err = prs.LoadAttributes(ctx); err != nil {
return err
}

View File

@ -296,10 +296,7 @@ func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repo
}
for _, attach := range attachments {
if attach.ReleaseID != rel.ID {
return util.SilentWrap{
Message: "delete attachment of release permission denied",
Err: util.ErrPermissionDenied,
}
return util.NewPermissionDeniedErrorf("delete attachment of release permission denied")
}
deletedUUIDs.Add(attach.UUID)
}
@ -321,10 +318,7 @@ func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repo
}
for _, attach := range attachments {
if attach.ReleaseID != rel.ID {
return util.SilentWrap{
Message: "update attachment of release permission denied",
Err: util.ErrPermissionDenied,
}
return util.NewPermissionDeniedErrorf("update attachment of release permission denied")
}
}

View File

@ -1,20 +1,20 @@
{{if .Flash.ErrorMsg}}
{{- if .Flash.ErrorMsg -}}
<div class="ui negative message flash-message flash-error">
<p>{{.Flash.ErrorMsg | SanitizeHTML}}</p>
</div>
{{end}}
{{if .Flash.SuccessMsg}}
{{- end -}}
{{- if .Flash.SuccessMsg -}}
<div class="ui positive message flash-message flash-success">
<p>{{.Flash.SuccessMsg | SanitizeHTML}}</p>
</div>
{{end}}
{{if .Flash.InfoMsg}}
{{- end -}}
{{- if .Flash.InfoMsg -}}
<div class="ui info message flash-message flash-info">
<p>{{.Flash.InfoMsg | SanitizeHTML}}</p>
</div>
{{end}}
{{if .Flash.WarningMsg}}
{{- end -}}
{{- if .Flash.WarningMsg -}}
<div class="ui warning message flash-message flash-warning">
<p>{{.Flash.WarningMsg | SanitizeHTML}}</p>
</div>
{{end}}
{{- end -}}

View File

@ -1,6 +1,4 @@
{{if .Flash}}
{{template "base/alert" .}}
{{end}}
<form class="issue-content ui comment form form-fetch-action" id="new-issue" action="{{.Link}}" method="post">
{{.CsrfTokenHtml}}
<div class="issue-content-left">
@ -9,7 +7,10 @@
{{ctx.AvatarUtils.Avatar .SignedUser 40}}
<div class="ui segment content tw-my-0">
<div class="field">
<input name="title" class="js-autofocus-end" id="issue_title" placeholder="{{ctx.Locale.Tr "repo.milestones.title"}}" value="{{if .TitleQuery}}{{.TitleQuery}}{{else if .IssueTemplateTitle}}{{.IssueTemplateTitle}}{{else}}{{.title}}{{end}}" required maxlength="255" autocomplete="off">
<input name="title" data-global-init="initInputAutoFocusEnd" id="issue_title" required maxlength="255" autocomplete="off"
placeholder="{{ctx.Locale.Tr "repo.milestones.title"}}"
value="{{if .TitleQuery}}{{.TitleQuery}}{{else if .IssueTemplateTitle}}{{.IssueTemplateTitle}}{{else}}{{.title}}{{end}}"
>
{{if .PageIsComparePull}}
<div class="title_wip_desc" data-wip-prefixes="{{JsonUtils.EncodeToString .PullRequestWorkInProgressPrefixes}}">{{ctx.Locale.Tr "repo.pulls.title_wip_desc" (index .PullRequestWorkInProgressPrefixes 0)}}</div>
{{end}}

View File

@ -1,6 +0,0 @@
export function initAutoFocusEnd() {
for (const el of document.querySelectorAll<HTMLInputElement>('.js-autofocus-end')) {
el.focus(); // expects only one such element on one page. If there are many, then the last one gets the focus.
el.setSelectionRange(el.value.length, el.value.length);
}
}

View File

@ -2,7 +2,7 @@ import {GET} from '../modules/fetch.ts';
import {showGlobalErrorMessage} from '../bootstrap.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {queryElems} from '../utils/dom.ts';
import {observeAddedElement} from '../modules/observer.ts';
import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
const {appUrl} = window.config;
@ -30,7 +30,7 @@ export function initFootLanguageMenu() {
export function initGlobalDropdown() {
// do not init "custom" dropdowns, "custom" dropdowns are managed by their own code.
observeAddedElement('.ui.dropdown:not(.custom)', (el) => {
registerGlobalSelectorFunc('.ui.dropdown:not(.custom)', (el) => {
const $dropdown = fomanticQuery(el);
if ($dropdown.data('module-dropdown')) return; // do not re-init if other code has already initialized it.
@ -80,6 +80,25 @@ export function initGlobalTabularMenu() {
fomanticQuery('.ui.menu.tabular:not(.custom) .item').tab({autoTabActivation: false});
}
// for performance considerations, it only uses performant syntax
function attachInputDirAuto(el: Partial<HTMLInputElement | HTMLTextAreaElement>) {
if (el.type !== 'hidden' &&
el.type !== 'checkbox' &&
el.type !== 'radio' &&
el.type !== 'range' &&
el.type !== 'color') {
el.dir = 'auto';
}
}
export function initGlobalInput() {
registerGlobalSelectorFunc('input, textarea', attachInputDirAuto);
registerGlobalInitFunc('initInputAutoFocusEnd', (el: HTMLInputElement) => {
el.focus(); // expects only one such element on one page. If there are many, then the last one gets the focus.
el.setSelectionRange(el.value.length, el.value.length);
});
}
/**
* Too many users set their ROOT_URL to wrong value, and it causes a lot of problems:
* * Cross-origin API request without correct cookie

View File

@ -12,6 +12,8 @@ import {invertFileFolding} from './file-fold.ts';
import {parseDom} from '../utils.ts';
import {observeAddedElement} from '../modules/observer.ts';
import {initGlobalDropdown} from './common-page.ts';
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
const {i18n} = window.config;
function initRepoDiffFileBox(el: HTMLElement) {
@ -260,7 +262,7 @@ export function initRepoDiffView() {
initExpandAndCollapseFilesButton();
initRepoDiffHashChangeListener();
observeAddedElement('#diff-file-boxes .diff-file-box', initRepoDiffFileBox);
registerGlobalSelectorFunc('#diff-file-boxes .diff-file-box', initRepoDiffFileBox);
addDelegatedEventListener(document, 'click', '.fold-file', (el) => {
invertFileFolding(el.closest('.file-content'), el);
});

View File

@ -11,7 +11,6 @@ import {initImageDiff} from './features/imagediff.ts';
import {initRepoMigration} from './features/repo-migration.ts';
import {initRepoProject} from './features/repo-projects.ts';
import {initTableSort} from './features/tablesort.ts';
import {initAutoFocusEnd} from './features/autofocus-end.ts';
import {initAdminUserListSearchForm} from './features/admin/users.ts';
import {initAdminConfigs} from './features/admin/config.ts';
import {initMarkupAnchors} from './markup/anchors.ts';
@ -62,62 +61,23 @@ import {initRepoContributors} from './features/contributors.ts';
import {initRepoCodeFrequency} from './features/code-frequency.ts';
import {initRepoRecentCommits} from './features/recent-commits.ts';
import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.ts';
import {initAddedElementObserver} from './modules/observer.ts';
import {initGlobalSelectorObserver} from './modules/observer.ts';
import {initRepositorySearch} from './features/repo-search.ts';
import {initColorPickers} from './features/colorpicker.ts';
import {initAdminSelfCheck} from './features/admin/selfcheck.ts';
import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts';
import {initGlobalFetchAction} from './features/common-fetch-action.ts';
import {
initFootLanguageMenu,
initGlobalDropdown,
initGlobalTabularMenu,
initHeadNavbarContentToggle,
} from './features/common-page.ts';
import {
initGlobalButtonClickOnEnter,
initGlobalButtons,
initGlobalDeleteButton,
} from './features/common-button.ts';
import {
initGlobalComboMarkdownEditor,
initGlobalEnterQuickSubmit,
initGlobalFormDirtyLeaveConfirm,
} from './features/common-form.ts';
import {initFootLanguageMenu, initGlobalDropdown, initGlobalInput, initGlobalTabularMenu, initHeadNavbarContentToggle} from './features/common-page.ts';
import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} from './features/common-button.ts';
import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
import {callInitFunctions} from './modules/init.ts';
initGiteaFomantic();
initAddedElementObserver();
initSubmitEventPolyfill();
function callInitFunctions(functions: (() => any)[]) {
// Start performance trace by accessing a URL by "https://localhost/?_ui_performance_trace=1" or "https://localhost/?key=value&_ui_performance_trace=1"
// It is a quick check, no side effect so no need to do slow URL parsing.
const initStart = performance.now();
if (window.location.search.includes('_ui_performance_trace=1')) {
let results: {name: string, dur: number}[] = [];
for (const func of functions) {
const start = performance.now();
func();
results.push({name: func.name, dur: performance.now() - start});
}
results = results.sort((a, b) => b.dur - a.dur);
for (let i = 0; i < 20 && i < results.length; i++) {
// eslint-disable-next-line no-console
console.log(`performance trace: ${results[i].name} ${results[i].dur.toFixed(3)}`);
}
} else {
for (const func of functions) {
func();
}
}
const initDur = performance.now() - initStart;
if (initDur > 500) {
console.error(`slow init functions took ${initDur.toFixed(3)}ms`);
}
}
onDomReady(() => {
callInitFunctions([
const initStartTime = performance.now();
const initPerformanceTracer = callInitFunctions([
initGlobalDropdown,
initGlobalTabularMenu,
initGlobalFetchAction,
@ -129,6 +89,7 @@ onDomReady(() => {
initGlobalFormDirtyLeaveConfirm,
initGlobalComboMarkdownEditor,
initGlobalDeleteButton,
initGlobalInput,
initCommonOrganization,
initCommonIssueListQuickGoto,
@ -150,7 +111,6 @@ onDomReady(() => {
initSshKeyFormParser,
initStopwatch,
initTableSort,
initAutoFocusEnd,
initFindFileInRepo,
initCopyContent,
@ -212,4 +172,13 @@ onDomReady(() => {
initOAuth2SettingsDisableCheckbox,
]);
// it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions.
initGlobalSelectorObserver(initPerformanceTracer);
if (initPerformanceTracer) initPerformanceTracer.printResults();
const initDur = performance.now() - initStartTime;
if (initDur > 500) {
console.error(`slow init functions took ${initDur.toFixed(3)}ms`);
}
});

View File

@ -0,0 +1,26 @@
export class InitPerformanceTracer {
results: {name: string, dur: number}[] = [];
recordCall(name: string, func: ()=>void) {
const start = performance.now();
func();
this.results.push({name, dur: performance.now() - start});
}
printResults() {
this.results = this.results.sort((a, b) => b.dur - a.dur);
for (let i = 0; i < 20 && i < this.results.length; i++) {
console.info(`performance trace: ${this.results[i].name} ${this.results[i].dur.toFixed(3)}`);
}
}
}
export function callInitFunctions(functions: (() => any)[]): InitPerformanceTracer | null {
// Start performance trace by accessing a URL by "https://localhost/?_ui_performance_trace=1" or "https://localhost/?key=value&_ui_performance_trace=1"
// It is a quick check, no side effect so no need to do slow URL parsing.
const perfTracer = !window.location.search.includes('_ui_performance_trace=1') ? null : new InitPerformanceTracer();
if (perfTracer) {
for (const func of functions) perfTracer.recordCall(func.name, func);
} else {
for (const func of functions) func();
}
return perfTracer;
}

View File

@ -1,52 +1,73 @@
import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
import type {Promisable} from 'type-fest';
import type {InitPerformanceTracer} from './init.ts';
type DirElement = HTMLInputElement | HTMLTextAreaElement;
let globalSelectorObserverInited = false;
// for performance considerations, it only uses performant syntax
function attachDirAuto(el: Partial<DirElement>) {
if (el.type !== 'hidden' &&
el.type !== 'checkbox' &&
el.type !== 'radio' &&
el.type !== 'range' &&
el.type !== 'color') {
el.dir = 'auto';
}
}
type SelectorHandler = {selector: string, handler: (el: HTMLElement) => void};
const selectorHandlers: SelectorHandler[] = [];
type GlobalInitFunc<T extends HTMLElement> = (el: T) => void | Promise<void>;
const globalInitFuncs: Record<string, GlobalInitFunc<HTMLElement>> = {};
function attachGlobalInit(el: HTMLElement) {
const initFunc = el.getAttribute('data-global-init');
const func = globalInitFuncs[initFunc];
if (!func) throw new Error(`Global init function "${initFunc}" not found`);
func(el);
}
type GlobalEventFunc<T extends HTMLElement, E extends Event> = (el: T, e: E) => (void | Promise<void>);
type GlobalEventFunc<T extends HTMLElement, E extends Event> = (el: T, e: E) => Promisable<void>;
const globalEventFuncs: Record<string, GlobalEventFunc<HTMLElement, Event>> = {};
type GlobalInitFunc<T extends HTMLElement> = (el: T) => Promisable<void>;
const globalInitFuncs: Record<string, GlobalInitFunc<HTMLElement>> = {};
// It handles the global events for all `<div data-global-click="onSomeElemClick"></div>` elements.
export function registerGlobalEventFunc<T extends HTMLElement, E extends Event>(event: string, name: string, func: GlobalEventFunc<T, E>) {
globalEventFuncs[`${event}:${name}`] = func as any;
globalEventFuncs[`${event}:${name}`] = func as GlobalEventFunc<HTMLElement, Event>;
}
type SelectorHandler = {
selector: string,
handler: (el: HTMLElement) => void,
};
const selectorHandlers: SelectorHandler[] = [
{selector: 'input, textarea', handler: attachDirAuto},
{selector: '[data-global-init]', handler: attachGlobalInit},
];
export function observeAddedElement(selector: string, handler: (el: HTMLElement) => void) {
// It handles the global init functions by a selector, for example:
// > registerGlobalSelectorObserver('.ui.dropdown:not(.custom)', (el) => { initDropdown(el, ...) });
export function registerGlobalSelectorFunc(selector: string, handler: (el: HTMLElement) => void) {
selectorHandlers.push({selector, handler});
const docNodes = document.querySelectorAll<HTMLElement>(selector);
for (const el of docNodes) {
// Then initAddedElementObserver will call this handler for all existing elements after all handlers are added.
// This approach makes the init stage only need to do one "querySelectorAll".
if (!globalSelectorObserverInited) return;
for (const el of document.querySelectorAll<HTMLElement>(selector)) {
handler(el);
}
}
export function initAddedElementObserver(): void {
// It handles the global init functions for all `<div data-global-int="initSomeElem"></div>` elements.
export function registerGlobalInitFunc<T extends HTMLElement>(name: string, handler: GlobalInitFunc<T>) {
globalInitFuncs[name] = handler as GlobalInitFunc<HTMLElement>;
// The "global init" functions are managed internally and called by callGlobalInitFunc
// They must be ready before initGlobalSelectorObserver is called.
if (globalSelectorObserverInited) throw new Error('registerGlobalInitFunc() must be called before initGlobalSelectorObserver()');
}
function callGlobalInitFunc(el: HTMLElement) {
const initFunc = el.getAttribute('data-global-init');
const func = globalInitFuncs[initFunc];
if (!func) throw new Error(`Global init function "${initFunc}" not found`);
type GiteaGlobalInitElement = Partial<HTMLElement> & {_giteaGlobalInited: boolean};
if ((el as GiteaGlobalInitElement)._giteaGlobalInited) throw new Error(`Global init function "${initFunc}" already executed`);
(el as GiteaGlobalInitElement)._giteaGlobalInited = true;
func(el);
}
function attachGlobalEvents() {
// add global "[data-global-click]" event handler
document.addEventListener('click', (e) => {
const elem = (e.target as HTMLElement).closest<HTMLElement>('[data-global-click]');
if (!elem) return;
const funcName = elem.getAttribute('data-global-click');
const func = globalEventFuncs[`click:${funcName}`];
if (!func) throw new Error(`Global event function "click:${funcName}" not found`);
func(elem, e);
});
}
export function initGlobalSelectorObserver(perfTracer?: InitPerformanceTracer): void {
if (globalSelectorObserverInited) throw new Error('initGlobalSelectorObserver() already called');
globalSelectorObserverInited = true;
attachGlobalEvents();
selectorHandlers.push({selector: '[data-global-init]', handler: callGlobalInitFunc});
const observer = new MutationObserver((mutationList) => {
const len = mutationList.length;
for (let i = 0; i < len; i++) {
@ -60,30 +81,27 @@ export function initAddedElementObserver(): void {
if (addedNode.matches(selector)) {
handler(addedNode);
}
const children = addedNode.querySelectorAll<HTMLElement>(selector);
for (const el of children) {
for (const el of addedNode.querySelectorAll<HTMLElement>(selector)) {
handler(el);
}
}
}
}
});
for (const {selector, handler} of selectorHandlers) {
const docNodes = document.querySelectorAll<HTMLElement>(selector);
for (const el of docNodes) {
handler(el);
if (perfTracer) {
for (const {selector, handler} of selectorHandlers) {
perfTracer.recordCall(`initGlobalSelectorObserver ${selector}`, () => {
for (const el of document.querySelectorAll<HTMLElement>(selector)) {
handler(el);
}
});
}
} else {
for (const {selector, handler} of selectorHandlers) {
for (const el of document.querySelectorAll<HTMLElement>(selector)) {
handler(el);
}
}
}
observer.observe(document, {subtree: true, childList: true});
document.addEventListener('click', (e) => {
const elem = (e.target as HTMLElement).closest<HTMLElement>('[data-global-click]');
if (!elem) return;
const funcName = elem.getAttribute('data-global-click');
const func = globalEventFuncs[`click:${funcName}`];
if (!func) throw new Error(`Global event function "click:${funcName}" not found`);
func(elem, e);
});
}

View File

@ -355,7 +355,7 @@ export function querySingleVisibleElem<T extends HTMLElement>(parent: Element, s
return candidates.length ? candidates[0] as T : null;
}
export function addDelegatedEventListener<T extends HTMLElement, E extends Event>(parent: Node, type: string, selector: string, listener: (elem: T, e: E) => void | Promise<any>, options?: boolean | AddEventListenerOptions) {
export function addDelegatedEventListener<T extends HTMLElement, E extends Event>(parent: Node, type: string, selector: string, listener: (elem: T, e: E) => Promisable<void>, options?: boolean | AddEventListenerOptions) {
parent.addEventListener(type, (e: Event) => {
const elem = (e.target as HTMLElement).closest(selector);
if (!elem) return;