From 9de659437e783b3877de1af15985ae019f3029ce Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sat, 24 Jan 2026 13:11:49 +0800 Subject: [PATCH] Refactor template render (#36438) --- cmd/web.go | 11 +- modules/assetfs/layered.go | 50 ++--- modules/public/public.go | 2 +- modules/templates/base.go | 23 --- modules/templates/helper.go | 2 - modules/templates/htmlrenderer.go | 109 ++-------- modules/templates/mail.go | 195 ++++++++++++++++++ modules/templates/mailer.go | 117 ----------- modules/templates/page.go | 98 +++++++++ modules/templates/scopedtmpl/scopedtmpl.go | 4 + modules/templates/vars/vars.go | 25 +-- routers/common/errpage.go | 51 +++-- routers/common/errpage_test.go | 33 +-- routers/common/qos.go | 24 +-- routers/common/qos_test.go | 28 --- routers/init.go | 2 - routers/install/install.go | 32 +-- routers/private/manager.go | 2 +- routers/web/devtest/mail_preview.go | 12 +- routers/web/repo/pull_review_test.go | 2 +- routers/web/user/home_test.go | 2 +- routers/web/web.go | 3 - services/context/context.go | 21 +- services/context/package.go | 2 +- services/mailer/mail.go | 7 +- services/mailer/mail_issue_common.go | 11 +- services/mailer/mail_release_test.go | 19 +- services/mailer/mail_test.go | 33 ++- services/mailer/mailer.go | 2 +- .../markup/renderhelper_codepreview_test.go | 6 +- .../renderhelper_issueicontitle_test.go | 6 +- 31 files changed, 475 insertions(+), 459 deletions(-) create mode 100644 modules/templates/mail.go delete mode 100644 modules/templates/mailer.go create mode 100644 modules/templates/page.go diff --git a/cmd/web.go b/cmd/web.go index 1998ee7668..61cfb87130 100644 --- a/cmd/web.go +++ b/cmd/web.go @@ -22,6 +22,7 @@ import ( "code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers" "code.gitea.io/gitea/routers/install" @@ -249,12 +250,6 @@ func servePprof() { } func runWeb(ctx context.Context, cmd *cli.Command) error { - defer func() { - if panicked := recover(); panicked != nil { - log.Fatal("PANIC: %v\n%s", panicked, log.Stack(2)) - } - }() - if subCmdName, valid := isValidDefaultSubCommand(cmd); !valid { return fmt.Errorf("unknown command: %s", subCmdName) } @@ -274,6 +269,10 @@ func runWeb(ctx context.Context, cmd *cli.Command) error { createPIDFile(cmd.String("pid")) } + // init the HTML renderer and load templates, if error happens, it will report the error immediately and exit with error log + // in dev mode, it won't exit, but watch the template files for changes + _ = templates.PageRenderer() + if !setting.InstallLock { if err := serveInstall(cmd); err != nil { return err diff --git a/modules/assetfs/layered.go b/modules/assetfs/layered.go index ce55475bd9..41e4ca7376 100644 --- a/modules/assetfs/layered.go +++ b/modules/assetfs/layered.go @@ -6,9 +6,7 @@ package assetfs import ( "context" "fmt" - "io" "io/fs" - "net/http" "os" "path/filepath" "sort" @@ -25,7 +23,7 @@ import ( // Layer represents a layer in a layered asset file-system. It has a name and works like http.FileSystem type Layer struct { name string - fs http.FileSystem + fs fs.FS localPath string } @@ -34,7 +32,7 @@ func (l *Layer) Name() string { } // Open opens the named file. The caller is responsible for closing the file. -func (l *Layer) Open(name string) (http.File, error) { +func (l *Layer) Open(name string) (fs.File, error) { return l.fs.Open(name) } @@ -48,12 +46,12 @@ func Local(name, base string, sub ...string) *Layer { panic(fmt.Sprintf("Unable to get absolute path for %q: %v", base, err)) } root := util.FilePathJoinAbs(base, sub...) - return &Layer{name: name, fs: http.Dir(root), localPath: root} + return &Layer{name: name, fs: os.DirFS(root), localPath: root} } // Bindata returns a new Layer with the given name, it serves files from the given bindata asset. func Bindata(name string, fs fs.FS) *Layer { - return &Layer{name: name, fs: http.FS(fs)} + return &Layer{name: name, fs: fs} } // LayeredFS is a layered asset file-system. It works like http.FileSystem, but it can have multiple layers. @@ -69,7 +67,7 @@ func Layered(layers ...*Layer) *LayeredFS { } // Open opens the named file. The caller is responsible for closing the file. -func (l *LayeredFS) Open(name string) (http.File, error) { +func (l *LayeredFS) Open(name string) (fs.File, error) { for _, layer := range l.layers { f, err := layer.Open(name) if err == nil || !os.IsNotExist(err) { @@ -89,40 +87,34 @@ func (l *LayeredFS) ReadFile(elems ...string) ([]byte, error) { func (l *LayeredFS) ReadLayeredFile(elems ...string) ([]byte, string, error) { name := util.PathJoinRel(elems...) for _, layer := range l.layers { - f, err := layer.Open(name) + bs, err := fs.ReadFile(layer, name) if os.IsNotExist(err) { continue } else if err != nil { return nil, layer.name, err } - bs, err := io.ReadAll(f) - _ = f.Close() return bs, layer.name, err } return nil, "", fs.ErrNotExist } -func shouldInclude(info fs.FileInfo, fileMode ...bool) bool { - if util.IsCommonHiddenFileName(info.Name()) { +func shouldInclude(dirEntry fs.DirEntry, fileMode ...bool) bool { + if util.IsCommonHiddenFileName(dirEntry.Name()) { return false } if len(fileMode) == 0 { return true } else if len(fileMode) == 1 { - return fileMode[0] == !info.Mode().IsDir() + return fileMode[0] == !dirEntry.IsDir() } panic("too many arguments for fileMode in shouldInclude") } -func readDir(layer *Layer, name string) ([]fs.FileInfo, error) { - f, err := layer.Open(name) - if os.IsNotExist(err) { +func readDirOptional(layer *Layer, name string) (entries []fs.DirEntry, err error) { + if entries, err = fs.ReadDir(layer, name); os.IsNotExist(err) { return nil, nil - } else if err != nil { - return nil, err } - defer f.Close() - return f.Readdir(-1) + return entries, err } // ListFiles lists files/directories in the given directory. The fileMode controls the returned files. @@ -133,13 +125,13 @@ func readDir(layer *Layer, name string) ([]fs.FileInfo, error) { func (l *LayeredFS) ListFiles(name string, fileMode ...bool) ([]string, error) { fileSet := make(container.Set[string]) for _, layer := range l.layers { - infos, err := readDir(layer, name) + entries, err := readDirOptional(layer, name) if err != nil { return nil, err } - for _, info := range infos { - if shouldInclude(info, fileMode...) { - fileSet.Add(info.Name()) + for _, entry := range entries { + if shouldInclude(entry, fileMode...) { + fileSet.Add(entry.Name()) } } } @@ -163,16 +155,16 @@ func listAllFiles(layers []*Layer, name string, fileMode ...bool) ([]string, err var list func(dir string) error list = func(dir string) error { for _, layer := range layers { - infos, err := readDir(layer, dir) + entries, err := readDirOptional(layer, dir) if err != nil { return err } - for _, info := range infos { - path := util.PathJoinRelX(dir, info.Name()) - if shouldInclude(info, fileMode...) { + for _, entry := range entries { + path := util.PathJoinRelX(dir, entry.Name()) + if shouldInclude(entry, fileMode...) { fileSet.Add(path) } - if info.IsDir() { + if entry.IsDir() { if err = list(path); err != nil { return err } diff --git a/modules/public/public.go b/modules/public/public.go index a7eace1538..3a5a76637e 100644 --- a/modules/public/public.go +++ b/modules/public/public.go @@ -36,7 +36,7 @@ func FileHandlerFunc() http.HandlerFunc { resp.WriteHeader(http.StatusMethodNotAllowed) return } - handleRequest(resp, req, assetFS, req.URL.Path) + handleRequest(resp, req, http.FS(assetFS), req.URL.Path) } } diff --git a/modules/templates/base.go b/modules/templates/base.go index 2c2f35bbed..c8697cc7ef 100644 --- a/modules/templates/base.go +++ b/modules/templates/base.go @@ -4,9 +4,6 @@ package templates import ( - "slices" - "strings" - "code.gitea.io/gitea/modules/assetfs" "code.gitea.io/gitea/modules/setting" ) @@ -18,23 +15,3 @@ func AssetFS() *assetfs.LayeredFS { func CustomAssets() *assetfs.Layer { return assetfs.Local("custom", setting.CustomPath, "templates") } - -func ListWebTemplateAssetNames(assets *assetfs.LayeredFS) ([]string, error) { - files, err := assets.ListAllFiles(".", true) - if err != nil { - return nil, err - } - return slices.DeleteFunc(files, func(file string) bool { - return strings.HasPrefix(file, "mail/") || !strings.HasSuffix(file, ".tmpl") - }), nil -} - -func ListMailTemplateAssetNames(assets *assetfs.LayeredFS) ([]string, error) { - files, err := assets.ListAllFiles(".", true) - if err != nil { - return nil, err - } - return slices.DeleteFunc(files, func(file string) bool { - return !strings.HasPrefix(file, "mail/") || !strings.HasSuffix(file, ".tmpl") - }), nil -} diff --git a/modules/templates/helper.go b/modules/templates/helper.go index a7aa321811..11c52bd5a7 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -25,8 +25,6 @@ import ( // NewFuncMap returns functions for injecting to templates func NewFuncMap() template.FuncMap { return map[string]any{ - "ctx": func() any { return nil }, // template context function - "DumpVar": dumpVar, "NIL": func() any { return nil }, diff --git a/modules/templates/htmlrenderer.go b/modules/templates/htmlrenderer.go index 8073a6e5f5..59b95cdd80 100644 --- a/modules/templates/htmlrenderer.go +++ b/modules/templates/htmlrenderer.go @@ -6,21 +6,18 @@ package templates import ( "bufio" "bytes" - "context" "errors" "fmt" + "html/template" "io" - "net/http" "path/filepath" "regexp" "strconv" "strings" - "sync" "sync/atomic" texttemplate "text/template" "code.gitea.io/gitea/modules/assetfs" - "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates/scopedtmpl" @@ -31,58 +28,27 @@ type TemplateExecutor scopedtmpl.TemplateExecutor type TplName string -type HTMLRender struct { +type tmplRender struct { templates atomic.Pointer[scopedtmpl.ScopedTemplate] + + collectTemplateNames func() ([]string, error) + readTemplateContent func(name string) ([]byte, error) } -var ( - htmlRender *HTMLRender - htmlRenderOnce sync.Once -) - -var ErrTemplateNotInitialized = errors.New("template system is not initialized, check your log for errors") - -func (h *HTMLRender) HTML(w io.Writer, status int, tplName TplName, data any, ctx context.Context) error { //nolint:revive // we don't use ctx, only pass it to the template executor - name := string(tplName) - if respWriter, ok := w.(http.ResponseWriter); ok { - if respWriter.Header().Get("Content-Type") == "" { - respWriter.Header().Set("Content-Type", "text/html; charset=utf-8") - } - respWriter.WriteHeader(status) - } - t, err := h.TemplateLookup(name, ctx) - if err != nil { - return texttemplate.ExecError{Name: name, Err: err} - } - return t.Execute(w, data) +func (h *tmplRender) Templates() *scopedtmpl.ScopedTemplate { + return h.templates.Load() } -func (h *HTMLRender) TemplateLookup(name string, ctx context.Context) (TemplateExecutor, error) { //nolint:revive // we don't use ctx, only pass it to the template executor - tmpls := h.templates.Load() - if tmpls == nil { - return nil, ErrTemplateNotInitialized - } - m := NewFuncMap() - m["ctx"] = func() any { return ctx } - return tmpls.Executor(name, m) -} - -func (h *HTMLRender) CompileTemplates() error { - assets := AssetFS() - extSuffix := ".tmpl" +func (h *tmplRender) recompileTemplates(dummyFuncMap template.FuncMap) error { tmpls := scopedtmpl.NewScopedTemplate() - tmpls.Funcs(NewFuncMap()) - files, err := ListWebTemplateAssetNames(assets) + tmpls.Funcs(dummyFuncMap) + names, err := h.collectTemplateNames() if err != nil { - return nil + return err } - for _, file := range files { - if !strings.HasSuffix(file, extSuffix) { - continue - } - name := strings.TrimSuffix(file, extSuffix) + for _, name := range names { tmpl := tmpls.New(filepath.ToSlash(name)) - buf, err := assets.ReadFile(file) + buf, err := h.readTemplateContent(name) if err != nil { return err } @@ -95,55 +61,20 @@ func (h *HTMLRender) CompileTemplates() error { return nil } -// HTMLRenderer init once and returns the globally shared html renderer -func HTMLRenderer() *HTMLRender { - htmlRenderOnce.Do(initHTMLRenderer) - return htmlRender +func ReloadAllTemplates() error { + return errors.Join(PageRendererReload(), MailRendererReload()) } -func ReloadHTMLTemplates() error { - log.Trace("Reloading HTML templates") - if err := htmlRender.CompileTemplates(); err != nil { - log.Error("Template error: %v\n%s", err, log.Stack(2)) - return err - } - return nil -} - -func initHTMLRenderer() { - rendererType := "static" - if !setting.IsProd { - rendererType = "auto-reloading" - } - log.Debug("Creating %s HTML Renderer", rendererType) - - htmlRender = &HTMLRender{} - if err := htmlRender.CompileTemplates(); err != nil { - p := &templateErrorPrettier{assets: AssetFS()} - wrapTmplErrMsg(p.handleFuncNotDefinedError(err)) - wrapTmplErrMsg(p.handleUnexpectedOperandError(err)) - wrapTmplErrMsg(p.handleExpectedEndError(err)) - wrapTmplErrMsg(p.handleGenericTemplateError(err)) - wrapTmplErrMsg(fmt.Sprintf("CompileTemplates error: %v", err)) - } - - if !setting.IsProd { - go AssetFS().WatchLocalChanges(graceful.GetManager().ShutdownContext(), func() { - _ = ReloadHTMLTemplates() - }) - } -} - -func wrapTmplErrMsg(msg string) { - if msg == "" { +func processStartupTemplateError(err error) { + if err == nil { return } - if setting.IsProd { + if setting.IsProd || setting.IsInTesting { // in prod mode, Gitea must have correct templates to run - log.Fatal("Gitea can't run with template errors: %s", msg) + log.Fatal("Gitea can't run with template errors: %v", err) } // in dev mode, do not need to really exit, because the template errors could be fixed by developer soon and the templates get reloaded - log.Error("There are template errors but Gitea continues to run in dev mode: %s", msg) + log.Error("There are template errors but Gitea continues to run in dev mode: %v", err) } type templateErrorPrettier struct { diff --git a/modules/templates/mail.go b/modules/templates/mail.go new file mode 100644 index 0000000000..ca13626468 --- /dev/null +++ b/modules/templates/mail.go @@ -0,0 +1,195 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package templates + +import ( + "html/template" + "io" + "regexp" + "slices" + "strings" + "sync" + texttmpl "text/template" + + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" +) + +type MailRender struct { + TemplateNames []string + BodyTemplates struct { + HasTemplate func(name string) bool + ExecuteTemplate func(w io.Writer, name string, data any) error + } + + // FIXME: MAIL-TEMPLATE-SUBJECT: only "issue" related messages support using subject from templates + // It is an incomplete implementation from "Use templates for issue e-mail subject and body" https://github.com/go-gitea/gitea/pull/8329 + SubjectTemplates *texttmpl.Template + + tmplRenderer *tmplRender + + mockedBodyTemplates map[string]*template.Template +} + +// mailSubjectTextFuncMap returns functions for injecting to text templates, it's only used for mail subject +func mailSubjectTextFuncMap() texttmpl.FuncMap { + return texttmpl.FuncMap{ + "dict": dict, + "Eval": evalTokens, + + "EllipsisString": util.EllipsisDisplayString, + "AppName": func() string { + return setting.AppName + }, + "AppDomain": func() string { // documented in mail-templates.md + return setting.Domain + }, + } +} + +var mailSubjectSplit = regexp.MustCompile(`(?m)^-{3,}\s*$`) + +func newMailRenderer() (*MailRender, error) { + subjectTemplates := texttmpl.New("") + subjectTemplates.Funcs(mailSubjectTextFuncMap()) + + renderer := &MailRender{ + SubjectTemplates: subjectTemplates, + } + + assetFS := AssetFS() + + renderer.tmplRenderer = &tmplRender{ + collectTemplateNames: func() ([]string, error) { + names, err := assetFS.ListAllFiles(".", true) + if err != nil { + return nil, err + } + names = slices.DeleteFunc(names, func(file string) bool { + return !strings.HasPrefix(file, "mail/") || !strings.HasSuffix(file, ".tmpl") + }) + for i, name := range names { + names[i] = strings.TrimSuffix(strings.TrimPrefix(name, "mail/"), ".tmpl") + } + renderer.TemplateNames = names + return names, nil + }, + readTemplateContent: func(name string) ([]byte, error) { + content, err := assetFS.ReadFile("mail/" + name + ".tmpl") + if err != nil { + return nil, err + } + var subjectContent []byte + bodyContent := content + loc := mailSubjectSplit.FindIndex(content) + if loc != nil { + subjectContent, bodyContent = content[0:loc[0]], content[loc[1]:] + } + _, err = renderer.SubjectTemplates.New(name).Parse(string(subjectContent)) + if err != nil { + return nil, err + } + return bodyContent, nil + }, + } + + renderer.BodyTemplates.HasTemplate = func(name string) bool { + if renderer.mockedBodyTemplates[name] != nil { + return true + } + return renderer.tmplRenderer.Templates().HasTemplate(name) + } + + staticFuncMap := NewFuncMap() + renderer.BodyTemplates.ExecuteTemplate = func(w io.Writer, name string, data any) error { + if t, ok := renderer.mockedBodyTemplates[name]; ok { + return t.Execute(w, data) + } + t, err := renderer.tmplRenderer.Templates().Executor(name, staticFuncMap) + if err != nil { + return err + } + return t.Execute(w, data) + } + + err := renderer.tmplRenderer.recompileTemplates(staticFuncMap) + if err != nil { + return nil, err + } + return renderer, nil +} + +func (r *MailRender) MockTemplate(name, subject, body string) func() { + if r.mockedBodyTemplates == nil { + r.mockedBodyTemplates = make(map[string]*template.Template) + } + oldSubject := r.SubjectTemplates + r.SubjectTemplates, _ = r.SubjectTemplates.Clone() + texttmpl.Must(r.SubjectTemplates.New(name).Parse(subject)) + + oldBody, hasOldBody := r.mockedBodyTemplates[name] + mockFuncMap := NewFuncMap() + r.mockedBodyTemplates[name] = template.Must(template.New(name).Funcs(mockFuncMap).Parse(body)) + return func() { + r.SubjectTemplates = oldSubject + if hasOldBody { + r.mockedBodyTemplates[name] = oldBody + } else { + delete(r.mockedBodyTemplates, name) + } + } +} + +var ( + globalMailRenderer *MailRender + globalMailRendererMu sync.RWMutex +) + +func MailRendererReload() error { + globalMailRendererMu.Lock() + defer globalMailRendererMu.Unlock() + r, err := newMailRenderer() + if err != nil { + return err + } + globalMailRenderer = r + return nil +} + +func MailRenderer() *MailRender { + globalMailRendererMu.RLock() + r := globalMailRenderer + globalMailRendererMu.RUnlock() + if r != nil { + return r + } + + globalMailRendererMu.Lock() + defer globalMailRendererMu.Unlock() + if globalMailRenderer != nil { + return globalMailRenderer + } + + var err error + globalMailRenderer, err = newMailRenderer() + if err != nil { + log.Fatal("Failed to initialize mail renderer: %v", err) + } + + if !setting.IsProd { + go AssetFS().WatchLocalChanges(graceful.GetManager().ShutdownContext(), func() { + globalMailRendererMu.Lock() + defer globalMailRendererMu.Unlock() + r, err := newMailRenderer() + if err != nil { + log.Error("Mail template error: %v", err) + return + } + globalMailRenderer = r + }) + } + return globalMailRenderer +} diff --git a/modules/templates/mailer.go b/modules/templates/mailer.go deleted file mode 100644 index c43b760777..0000000000 --- a/modules/templates/mailer.go +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package templates - -import ( - "context" - "fmt" - "html/template" - "regexp" - "strings" - "sync/atomic" - texttmpl "text/template" - - "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" -) - -type MailTemplates struct { - TemplateNames []string - BodyTemplates *template.Template - SubjectTemplates *texttmpl.Template -} - -var mailSubjectSplit = regexp.MustCompile(`(?m)^-{3,}\s*$`) - -// mailSubjectTextFuncMap returns functions for injecting to text templates, it's only used for mail subject -func mailSubjectTextFuncMap() texttmpl.FuncMap { - return texttmpl.FuncMap{ - "dict": dict, - "Eval": evalTokens, - - "EllipsisString": util.EllipsisDisplayString, - "AppName": func() string { - return setting.AppName - }, - "AppDomain": func() string { // documented in mail-templates.md - return setting.Domain - }, - } -} - -func buildSubjectBodyTemplate(stpl *texttmpl.Template, btpl *template.Template, name string, content []byte) error { - // Split template into subject and body - var subjectContent []byte - bodyContent := content - loc := mailSubjectSplit.FindIndex(content) - if loc != nil { - subjectContent = content[0:loc[0]] - bodyContent = content[loc[1]:] - } - if _, err := stpl.New(name).Parse(string(subjectContent)); err != nil { - return fmt.Errorf("failed to parse template [%s/subject]: %w", name, err) - } - if _, err := btpl.New(name).Parse(string(bodyContent)); err != nil { - return fmt.Errorf("failed to parse template [%s/body]: %w", name, err) - } - return nil -} - -// LoadMailTemplates provides the templates required for sending notification mails. -func LoadMailTemplates(ctx context.Context, loadedTemplates *atomic.Pointer[MailTemplates]) { - assetFS := AssetFS() - refreshTemplates := func(firstRun bool) { - var templateNames []string - subjectTemplates := texttmpl.New("") - bodyTemplates := template.New("") - - subjectTemplates.Funcs(mailSubjectTextFuncMap()) - bodyTemplates.Funcs(NewFuncMap()) - - if !firstRun { - log.Trace("Reloading mail templates") - } - assetPaths, err := ListMailTemplateAssetNames(assetFS) - if err != nil { - log.Error("Failed to list mail templates: %v", err) - return - } - - for _, assetPath := range assetPaths { - content, layerName, err := assetFS.ReadLayeredFile(assetPath) - if err != nil { - log.Warn("Failed to read mail template %s by %s: %v", assetPath, layerName, err) - continue - } - tmplName := strings.TrimPrefix(strings.TrimSuffix(assetPath, ".tmpl"), "mail/") - if firstRun { - log.Trace("Adding mail template %s: %s by %s", tmplName, assetPath, layerName) - } - templateNames = append(templateNames, tmplName) - if err = buildSubjectBodyTemplate(subjectTemplates, bodyTemplates, tmplName, content); err != nil { - if firstRun { - log.Fatal("Failed to parse mail template, err: %v", err) - } - log.Error("Failed to parse mail template, err: %v", err) - } - } - loaded := &MailTemplates{ - TemplateNames: templateNames, - BodyTemplates: bodyTemplates, - SubjectTemplates: subjectTemplates, - } - loadedTemplates.Store(loaded) - } - - refreshTemplates(true) - - if !setting.IsProd { - // Now subjectTemplates and bodyTemplates are both synchronized - // thus it is safe to call refresh from a different goroutine - go assetFS.WatchLocalChanges(ctx, func() { - refreshTemplates(false) - }) - } -} diff --git a/modules/templates/page.go b/modules/templates/page.go new file mode 100644 index 0000000000..8f6c82fc4b --- /dev/null +++ b/modules/templates/page.go @@ -0,0 +1,98 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package templates + +import ( + "context" + "html/template" + "io" + "net/http" + "slices" + "strings" + "sync" + texttemplate "text/template" + + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" +) + +type pageRenderer struct { + tmplRenderer *tmplRender +} + +func (r *pageRenderer) funcMap(ctx context.Context) template.FuncMap { + pageFuncMap := NewFuncMap() + pageFuncMap["ctx"] = func() any { return ctx } + return pageFuncMap +} + +func (r *pageRenderer) funcMapDummy() template.FuncMap { + dummyFuncMap := NewFuncMap() + dummyFuncMap["ctx"] = func() any { return nil } // for template compilation only, no context available + return dummyFuncMap +} + +func (r *pageRenderer) TemplateLookup(tmpl string, templateCtx context.Context) (TemplateExecutor, error) { //nolint:revive // we don't use ctx, only pass it to the template executor + return r.tmplRenderer.Templates().Executor(tmpl, r.funcMap(templateCtx)) +} + +func (r *pageRenderer) HTML(w io.Writer, status int, tplName TplName, data any, templateCtx context.Context) error { //nolint:revive // we don't use ctx, only pass it to the template executor + name := string(tplName) + if respWriter, ok := w.(http.ResponseWriter); ok { + if respWriter.Header().Get("Content-Type") == "" { + respWriter.Header().Set("Content-Type", "text/html; charset=utf-8") + } + respWriter.WriteHeader(status) + } + t, err := r.TemplateLookup(name, templateCtx) + if err != nil { + return texttemplate.ExecError{Name: name, Err: err} + } + return t.Execute(w, data) +} + +var PageRenderer = sync.OnceValue(func() *pageRenderer { + rendererType := util.Iif(setting.IsProd, "static", "auto-reloading") + log.Debug("Creating %s HTML Renderer", rendererType) + + assetFS := AssetFS() + tr := &tmplRender{ + collectTemplateNames: func() ([]string, error) { + names, err := assetFS.ListAllFiles(".", true) + if err != nil { + return nil, err + } + names = slices.DeleteFunc(names, func(file string) bool { + return strings.HasPrefix(file, "mail/") || !strings.HasSuffix(file, ".tmpl") + }) + for i, file := range names { + names[i] = strings.TrimSuffix(file, ".tmpl") + } + return names, nil + }, + readTemplateContent: func(name string) ([]byte, error) { + return assetFS.ReadFile(name + ".tmpl") + }, + } + + pr := &pageRenderer{tmplRenderer: tr} + if err := tr.recompileTemplates(pr.funcMapDummy()); err != nil { + processStartupTemplateError(err) + } + + if !setting.IsProd { + go AssetFS().WatchLocalChanges(graceful.GetManager().ShutdownContext(), func() { + if err := tr.recompileTemplates(pr.funcMapDummy()); err != nil { + log.Error("Template error: %v\n%s", err, log.Stack(2)) + } + }) + } + return pr +}) + +func PageRendererReload() error { + return PageRenderer().tmplRenderer.recompileTemplates(PageRenderer().funcMapDummy()) +} diff --git a/modules/templates/scopedtmpl/scopedtmpl.go b/modules/templates/scopedtmpl/scopedtmpl.go index 34e8b9ad70..de066124b9 100644 --- a/modules/templates/scopedtmpl/scopedtmpl.go +++ b/modules/templates/scopedtmpl/scopedtmpl.go @@ -61,6 +61,10 @@ func (t *ScopedTemplate) Freeze() { t.all.Funcs(m) } +func (t *ScopedTemplate) HasTemplate(name string) bool { + return t.all.Lookup(name) != nil +} + func (t *ScopedTemplate) Executor(name string, funcMap template.FuncMap) (TemplateExecutor, error) { t.scopedMu.RLock() scopedTmplSet, ok := t.scopedTemplateSets[name] diff --git a/modules/templates/vars/vars.go b/modules/templates/vars/vars.go index 500078d4b8..60d11ea609 100644 --- a/modules/templates/vars/vars.go +++ b/modules/templates/vars/vars.go @@ -10,25 +10,6 @@ import ( "unicode/utf8" ) -// ErrWrongSyntax represents a wrong syntax with a template -type ErrWrongSyntax struct { - Template string -} - -func (err ErrWrongSyntax) Error() string { - return "wrong syntax found in " + err.Template -} - -// ErrVarMissing represents an error that no matched variable -type ErrVarMissing struct { - Template string - Var string -} - -func (err ErrVarMissing) Error() string { - return fmt.Sprintf("the variable %s is missing for %s", err.Var, err.Template) -} - // Expand replaces all variables like {var} by `vars` map, it always returns the expanded string regardless of errors // if error occurs, the error part doesn't change and is returned as it is. func Expand(template string, vars map[string]string) (string, error) { @@ -66,14 +47,14 @@ func Expand(template string, vars map[string]string) (string, error) { posBegin = posEnd if part == "{}" || part[len(part)-1] != '}' { // treat "{}" or "{..." as error - err = ErrWrongSyntax{Template: template} + err = fmt.Errorf("wrong syntax found in %s", template) buf.WriteString(part) } else { // now we get a valid key "{...}" key := part[1 : len(part)-1] keyFirst, _ := utf8.DecodeRuneInString(key) if unicode.IsSpace(keyFirst) || unicode.IsPunct(keyFirst) || unicode.IsControl(keyFirst) { - // the if key doesn't start with a letter, then we do not treat it as a var now + // if the key doesn't start with a letter, then we do not treat it as a var now buf.WriteString(part) } else { // look up in the map @@ -82,7 +63,7 @@ func Expand(template string, vars map[string]string) (string, error) { } else { // write the non-existing var as it is buf.WriteString(part) - err = ErrVarMissing{Template: template, Var: key} + err = fmt.Errorf("the variable %s is missing for %s", key, template) } } } diff --git a/routers/common/errpage.go b/routers/common/errpage.go index 4caef92d14..4d24914bd2 100644 --- a/routers/common/errpage.go +++ b/routers/common/errpage.go @@ -4,8 +4,11 @@ package common import ( + "bytes" "fmt" + "io" "net/http" + "strings" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/httpcache" @@ -19,6 +22,36 @@ import ( const tplStatus500 templates.TplName = "status/500" +func renderServerErrorPage(w http.ResponseWriter, req *http.Request, respCode int, tmpl templates.TplName, ctxData map[string]any, plainMsg string) { + acceptsHTML := false + for _, part := range req.Header["Accept"] { + if strings.Contains(part, "text/html") { + acceptsHTML = true + break + } + } + + httpcache.SetCacheControlInHeader(w.Header(), &httpcache.CacheControlOptions{NoTransform: true}) + w.Header().Set(`X-Frame-Options`, setting.CORSConfig.XFrameOptions) + + tmplCtx := context.NewTemplateContext(req.Context(), req) + tmplCtx["Locale"] = middleware.Locale(w, req) + + w.WriteHeader(respCode) + + outBuf := &bytes.Buffer{} + if acceptsHTML { + err := templates.PageRenderer().HTML(outBuf, respCode, tmpl, ctxData, tmplCtx) + if err != nil { + _, _ = w.Write([]byte("Internal server error but failed to render error page template, please collect error logs and report to Gitea issue tracker")) + return + } + } else { + outBuf.WriteString(plainMsg) + } + _, _ = io.Copy(w, outBuf) +} + // RenderPanicErrorPage renders a 500 page, and it never panics func RenderPanicErrorPage(w http.ResponseWriter, req *http.Request, err any) { combinedErr := fmt.Sprintf("%v\n%s", err, log.Stack(2)) @@ -32,24 +65,14 @@ func RenderPanicErrorPage(w http.ResponseWriter, req *http.Request, err any) { routing.UpdatePanicError(req.Context(), err) - httpcache.SetCacheControlInHeader(w.Header(), &httpcache.CacheControlOptions{NoTransform: true}) - w.Header().Set(`X-Frame-Options`, setting.CORSConfig.XFrameOptions) - - tmplCtx := context.NewTemplateContext(req.Context(), req) - tmplCtx["Locale"] = middleware.Locale(w, req) + plainMsg := "Internal Server Error" ctxData := middleware.GetContextData(req.Context()) - // This recovery handler could be called without Gitea's web context, so we shouldn't touch that context too much. // Otherwise, the 500-page may cause new panics, eg: cache.GetContextWithData, it makes the developer&users couldn't find the original panic. user, _ := ctxData[middleware.ContextDataKeySignedUser].(*user_model.User) if !setting.IsProd || (user != nil && user.IsAdmin) { - ctxData["ErrorMsg"] = "PANIC: " + combinedErr - } - - err = templates.HTMLRenderer().HTML(w, http.StatusInternalServerError, tplStatus500, ctxData, tmplCtx) - if err != nil { - log.Error("Error occurs again when rendering error page: %v", err) - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte("Internal server error, please collect error logs and report to Gitea issue tracker")) + plainMsg = "PANIC: " + combinedErr + ctxData["ErrorMsg"] = plainMsg } + renderServerErrorPage(w, req, http.StatusInternalServerError, tplStatus500, ctxData, plainMsg) } diff --git a/routers/common/errpage_test.go b/routers/common/errpage_test.go index 33aa6bb339..c50d45c296 100644 --- a/routers/common/errpage_test.go +++ b/routers/common/errpage_test.go @@ -18,19 +18,28 @@ import ( ) func TestRenderPanicErrorPage(t *testing.T) { - w := httptest.NewRecorder() - req := &http.Request{URL: &url.URL{}} - req = req.WithContext(reqctx.NewRequestContextForTest(t.Context())) - RenderPanicErrorPage(w, req, errors.New("fake panic error (for test only)")) - respContent := w.Body.String() - assert.Contains(t, respContent, `class="page-content status-page-500"`) - assert.Contains(t, respContent, ``) - assert.Contains(t, respContent, `lang="en-US"`) // make sure the locale work + t.Run("HTML", func(t *testing.T) { + w := httptest.NewRecorder() + req := &http.Request{URL: &url.URL{}, Header: http.Header{"Accept": []string{"text/html"}}} + req = req.WithContext(reqctx.NewRequestContextForTest(t.Context())) + RenderPanicErrorPage(w, req, errors.New("fake panic error (for test only)")) + respContent := w.Body.String() + assert.Contains(t, respContent, `class="page-content status-page-500"`) + assert.Contains(t, respContent, ``) + assert.Contains(t, respContent, `lang="en-US"`) // make sure the locale work - // the 500 page doesn't have normal pages footer, it makes it easier to distinguish a normal page and a failed page. - // especially when a sub-template causes page error, the HTTP response code is still 200, - // the different "footer" is the only way to know whether a page is fully rendered without error. - assert.False(t, test.IsNormalPageCompleted(respContent)) + // the 500 page doesn't have normal pages footer, it makes it easier to distinguish a normal page and a failed page. + // especially when a sub-template causes page error, the HTTP response code is still 200, + // the different "footer" is the only way to know whether a page is fully rendered without error. + assert.False(t, test.IsNormalPageCompleted(respContent)) + }) + t.Run("Plain", func(t *testing.T) { + w := httptest.NewRecorder() + req := &http.Request{URL: &url.URL{}} + req = req.WithContext(reqctx.NewRequestContextForTest(t.Context())) + renderServiceUnavailable(w, req) + assert.Equal(t, "Service Unavailable", w.Body.String()) + }) } func TestMain(m *testing.M) { diff --git a/routers/common/qos.go b/routers/common/qos.go index 0670ea0b4c..96f23b64fe 100644 --- a/routers/common/qos.go +++ b/routers/common/qos.go @@ -14,7 +14,6 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/web/middleware" - giteacontext "code.gitea.io/gitea/services/context" "github.com/bohde/codel" "github.com/go-chi/chi/v5" @@ -119,27 +118,6 @@ func requestPriority(ctx context.Context) Priority { // renderServiceUnavailable will render an HTTP 503 Service // Unavailable page, providing HTML if the client accepts it. func renderServiceUnavailable(w http.ResponseWriter, req *http.Request) { - acceptsHTML := false - for _, part := range req.Header["Accept"] { - if strings.Contains(part, "text/html") { - acceptsHTML = true - break - } - } - - // If the client doesn't accept HTML, then render a plain text response - if !acceptsHTML { - http.Error(w, "503 Service Unavailable", http.StatusServiceUnavailable) - return - } - - tmplCtx := giteacontext.NewTemplateContext(req.Context(), req) - tmplCtx["Locale"] = middleware.Locale(w, req) ctxData := middleware.GetContextData(req.Context()) - err := templates.HTMLRenderer().HTML(w, http.StatusServiceUnavailable, tplStatus503, ctxData, tmplCtx) - if err != nil { - log.Error("Error occurs again when rendering service unavailable page: %v", err) - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte("Internal server error, please collect error logs and report to Gitea issue tracker")) - } + renderServerErrorPage(w, req, http.StatusServiceUnavailable, tplStatus503, ctxData, "Service Unavailable") } diff --git a/routers/common/qos_test.go b/routers/common/qos_test.go index 850a5f51db..17dc9cb30c 100644 --- a/routers/common/qos_test.go +++ b/routers/common/qos_test.go @@ -4,7 +4,6 @@ package common import ( - "net/http" "testing" user_model "code.gitea.io/gitea/models/user" @@ -62,30 +61,3 @@ func TestRequestPriority(t *testing.T) { }) } } - -func TestRenderServiceUnavailable(t *testing.T) { - t.Run("HTML", func(t *testing.T) { - ctx, resp := contexttest.MockContext(t, "") - ctx.Req.Header.Set("Accept", "text/html") - - renderServiceUnavailable(resp, ctx.Req) - assert.Equal(t, http.StatusServiceUnavailable, resp.Code) - assert.Contains(t, resp.Header().Get("Content-Type"), "text/html") - - body := resp.Body.String() - assert.Contains(t, body, `lang="en-US"`) - assert.Contains(t, body, "503 Service Unavailable") - }) - - t.Run("plain", func(t *testing.T) { - ctx, resp := contexttest.MockContext(t, "") - ctx.Req.Header.Set("Accept", "text/plain") - - renderServiceUnavailable(resp, ctx.Req) - assert.Equal(t, http.StatusServiceUnavailable, resp.Code) - assert.Contains(t, resp.Header().Get("Content-Type"), "text/plain") - - body := resp.Body.String() - assert.Contains(t, body, "503 Service Unavailable") - }) -} diff --git a/routers/init.go b/routers/init.go index 859b00ebb2..3af5f9f510 100644 --- a/routers/init.go +++ b/routers/init.go @@ -24,7 +24,6 @@ import ( "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/svg" "code.gitea.io/gitea/modules/system" - "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" @@ -182,7 +181,6 @@ func InitWebInstalled(ctx context.Context) { // NormalRoutes represents non install routes func NormalRoutes() *web.Router { - _ = templates.HTMLRenderer() r := web.NewRouter() r.Use(common.ProtocolMiddlewares()...) diff --git a/routers/install/install.go b/routers/install/install.go index c5acf968bd..399128b6ed 100644 --- a/routers/install/install.go +++ b/routers/install/install.go @@ -24,11 +24,9 @@ import ( "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" - "code.gitea.io/gitea/modules/reqctx" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/timeutil" - "code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/user" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web/middleware" @@ -37,8 +35,6 @@ import ( "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/versioned_migration" - - "gitea.com/go-chi/session" ) const ( @@ -55,29 +51,13 @@ func getSupportedDbTypeNames() (dbTypeNames []map[string]string) { return dbTypeNames } -// installContexter prepare for rendering installation page func installContexter() func(next http.Handler) http.Handler { - rnd := templates.HTMLRenderer() - dbTypeNames := getSupportedDbTypeNames() - envConfigKeys := setting.CollectEnvConfigKeys() - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { - base := context.NewBaseContext(resp, req) - ctx := context.NewWebContext(base, rnd, session.GetSession(req)) - ctx.Data.MergeFrom(middleware.CommonTemplateContextData()) - ctx.Data.MergeFrom(reqctx.ContextData{ - "Title": ctx.Locale.Tr("install.install"), - "PageIsInstall": true, - "DbTypeNames": dbTypeNames, - "EnvConfigKeys": envConfigKeys, - "CustomConfFile": setting.CustomConf, - "AllLangs": translation.AllLangs(), - - "PasswordHashAlgorithms": hash.RecommendedHashAlgorithms, - }) - next.ServeHTTP(resp, ctx.Req) - }) - } + return context.ContexterInstallPage(map[string]any{ + "DbTypeNames": getSupportedDbTypeNames(), + "EnvConfigKeys": setting.CollectEnvConfigKeys(), + "CustomConfFile": setting.CustomConf, + "PasswordHashAlgorithms": hash.RecommendedHashAlgorithms, + }) } // Install render installation page diff --git a/routers/private/manager.go b/routers/private/manager.go index 00e52d6511..b84919d180 100644 --- a/routers/private/manager.go +++ b/routers/private/manager.go @@ -21,7 +21,7 @@ import ( // ReloadTemplates reloads all the templates func ReloadTemplates(ctx *context.PrivateContext) { - err := templates.ReloadHTMLTemplates() + err := templates.ReloadAllTemplates() if err != nil { ctx.JSON(http.StatusInternalServerError, private.Response{ UserMsg: fmt.Sprintf("Template error: %v", err), diff --git a/routers/web/devtest/mail_preview.go b/routers/web/devtest/mail_preview.go index d6bade15d7..7b1787d52b 100644 --- a/routers/web/devtest/mail_preview.go +++ b/routers/web/devtest/mail_preview.go @@ -8,6 +8,7 @@ import ( "strings" "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/mailer" @@ -34,17 +35,18 @@ func MailPreviewRender(ctx *context.Context) { func prepareMailPreviewRender(ctx *context.Context, tmplName string) { tmplSubject := mailer.LoadedTemplates().SubjectTemplates.Lookup(tmplName) - if tmplSubject == nil { - ctx.Data["RenderMailSubject"] = "default subject" - } else { + // FIXME: MAIL-TEMPLATE-SUBJECT: only "issue" related messages support using subject from templates + subject := "(default subject)" + if tmplSubject != nil { var buf strings.Builder err := tmplSubject.Execute(&buf, nil) if err != nil { - ctx.Data["RenderMailSubject"] = err.Error() + subject = "ERROR: " + err.Error() } else { - ctx.Data["RenderMailSubject"] = buf.String() + subject = util.IfZero(buf.String(), subject) } } + ctx.Data["RenderMailSubject"] = subject ctx.Data["RenderMailTemplateName"] = tmplName } diff --git a/routers/web/repo/pull_review_test.go b/routers/web/repo/pull_review_test.go index 42223c1d9c..1b28cad5b6 100644 --- a/routers/web/repo/pull_review_test.go +++ b/routers/web/repo/pull_review_test.go @@ -30,7 +30,7 @@ func TestRenderConversation(t *testing.T) { run := func(name string, cb func(t *testing.T, ctx *context.Context, resp *httptest.ResponseRecorder)) { t.Run(name, func(t *testing.T) { - ctx, resp := contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()}) + ctx, resp := contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.PageRenderer()}) contexttest.LoadUser(t, ctx, pr.Issue.PosterID) contexttest.LoadRepo(t, ctx, pr.BaseRepoID) contexttest.LoadGitRepo(t, ctx) diff --git a/routers/web/user/home_test.go b/routers/web/user/home_test.go index c5b9e16c1e..5f3646769e 100644 --- a/routers/web/user/home_test.go +++ b/routers/web/user/home_test.go @@ -116,7 +116,7 @@ func TestMilestonesForSpecificRepo(t *testing.T) { } func TestDashboardPagination(t *testing.T) { - ctx, _ := contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()}) + ctx, _ := contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.PageRenderer()}) page := context.NewPagination(10, 3, 1, 3) setting.AppSubURL = "/SubPath" diff --git a/routers/web/web.go b/routers/web/web.go index 64137876e0..c37add30d5 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -18,7 +18,6 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/structs" - "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/validation" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web/middleware" @@ -232,8 +231,6 @@ func Routes() *web.Router { routes.Methods("GET, HEAD", "/apple-touch-icon-precomposed.png", misc.StaticRedirect("/assets/img/apple-touch-icon.png")) routes.Methods("GET, HEAD", "/favicon.ico", misc.StaticRedirect("/assets/img/favicon.png")) - _ = templates.HTMLRenderer() - var mid []any if setting.EnableGzip { diff --git a/services/context/context.go b/services/context/context.go index e12a97eeef..b19941cb8d 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -17,6 +17,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/httpcache" + "code.gitea.io/gitea/modules/reqctx" "code.gitea.io/gitea/modules/session" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" @@ -137,9 +138,27 @@ func NewWebContext(base *Base, render Render, session session.Store) *Context { return ctx } +func ContexterInstallPage(data map[string]any) func(next http.Handler) http.Handler { + rnd := templates.PageRenderer() + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + base := NewBaseContext(resp, req) + ctx := NewWebContext(base, rnd, session.GetContextSession(req)) + ctx.Data.MergeFrom(middleware.CommonTemplateContextData()) + ctx.Data.MergeFrom(reqctx.ContextData{ + "Title": ctx.Locale.Tr("install.install"), + "PageIsInstall": true, + "AllLangs": translation.AllLangs(), + }) + ctx.Data.MergeFrom(data) + next.ServeHTTP(resp, ctx.Req) + }) + } +} + // Contexter initializes a classic context for a request. func Contexter() func(next http.Handler) http.Handler { - rnd := templates.HTMLRenderer() + rnd := templates.PageRenderer() return func(next http.Handler) http.Handler { return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { base := NewBaseContext(resp, req) diff --git a/services/context/package.go b/services/context/package.go index 8b722932b1..0e9210515b 100644 --- a/services/context/package.go +++ b/services/context/package.go @@ -150,7 +150,7 @@ func determineAccessMode(ctx *Base, pkg *Package, doer *user_model.User) (perm.A // PackageContexter initializes a package context for a request. func PackageContexter() func(next http.Handler) http.Handler { - renderer := templates.HTMLRenderer() + renderer := templates.PageRenderer() return func(next http.Handler) http.Handler { return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { base := NewBaseContext(resp, req) diff --git a/services/mailer/mail.go b/services/mailer/mail.go index d81b6d10af..8f831f89ad 100644 --- a/services/mailer/mail.go +++ b/services/mailer/mail.go @@ -15,7 +15,6 @@ import ( "mime" "regexp" "strings" - "sync/atomic" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" @@ -32,12 +31,10 @@ import ( const mailMaxSubjectRunes = 256 // There's no actual limit for subject in RFC 5322 -var loadedTemplates atomic.Pointer[templates.MailTemplates] - var subjectRemoveSpaces = regexp.MustCompile(`[\s]+`) -func LoadedTemplates() *templates.MailTemplates { - return loadedTemplates.Load() +func LoadedTemplates() *templates.MailRender { + return templates.MailRenderer() } // SendTestMail sends a test mail diff --git a/services/mailer/mail_issue_common.go b/services/mailer/mail_issue_common.go index 9aa3a95b3d..994df6707a 100644 --- a/services/mailer/mail_issue_common.go +++ b/services/mailer/mail_issue_common.go @@ -21,6 +21,7 @@ import ( "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/modules/util" 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" @@ -122,9 +123,7 @@ func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang var mailSubject bytes.Buffer if err := LoadedTemplates().SubjectTemplates.ExecuteTemplate(&mailSubject, tplName, mailMeta); err == nil { subject = sanitizeSubject(mailSubject.String()) - if subject == "" { - subject = fallback - } + subject = util.IfZero(subject, fallback) } else { log.Error("ExecuteTemplate [%s]: %v", tplName+"/subject", err) } @@ -261,14 +260,14 @@ func actionToTemplate(issue *issues_model.Issue, actionType activities_model.Act } template = "repo/" + typeName + "/" + name - ok := LoadedTemplates().BodyTemplates.Lookup(template) != nil + ok := LoadedTemplates().BodyTemplates.HasTemplate(template) if !ok && typeName != "issue" { template = "repo/issue/" + name - ok = LoadedTemplates().BodyTemplates.Lookup(template) != nil + ok = LoadedTemplates().BodyTemplates.HasTemplate(template) } if !ok { template = "repo/" + typeName + "/default" - ok = LoadedTemplates().BodyTemplates.Lookup(template) != nil + ok = LoadedTemplates().BodyTemplates.HasTemplate(template) } if !ok { template = "repo/issue/default" diff --git a/services/mailer/mail_release_test.go b/services/mailer/mail_release_test.go index d078bdde74..6fc8587f98 100644 --- a/services/mailer/mail_release_test.go +++ b/services/mailer/mail_release_test.go @@ -10,6 +10,7 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" sender_service "code.gitea.io/gitea/services/mailer/sender" "github.com/stretchr/testify/assert" @@ -19,18 +20,10 @@ import ( func TestMailNewReleaseFiltersUnauthorizedWatchers(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - origMailService := setting.MailService - origDomain := setting.Domain - origAppName := setting.AppName - origAppURL := setting.AppURL - origTemplates := LoadedTemplates() - defer func() { - setting.MailService = origMailService - setting.Domain = origDomain - setting.AppName = origAppName - setting.AppURL = origAppURL - loadedTemplates.Store(origTemplates) - }() + defer test.MockVariableValue(&setting.MailService)() + defer test.MockVariableValue(&setting.Domain)() + defer test.MockVariableValue(&setting.AppName)() + defer test.MockVariableValue(&setting.AppURL)() setting.MailService = &setting.Mailer{ From: "Gitea", @@ -39,7 +32,7 @@ func TestMailNewReleaseFiltersUnauthorizedWatchers(t *testing.T) { setting.Domain = "example.com" setting.AppName = "Gitea" setting.AppURL = "https://example.com/" - prepareMailTemplates(string(tplNewReleaseMail), "{{.Subject}}", "

{{.Release.TagName}}

") + defer mockMailTemplates(string(tplNewReleaseMail), "{{.Subject}}", "

{{.Release.TagName}}

")() repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) require.True(t, repo.IsPrivate) diff --git a/services/mailer/mail_test.go b/services/mailer/mail_test.go index 6ae7acf10b..caa072725a 100644 --- a/services/mailer/mail_test.go +++ b/services/mailer/mail_test.go @@ -96,11 +96,8 @@ func prepareMailerBase64Test(t *testing.T) (doer *user_model.User, repo *repo_mo return user, repo, issue, att1, att2 } -func prepareMailTemplates(name, subjectTmpl, bodyTmpl string) { - loadedTemplates.Store(&templates.MailTemplates{ - SubjectTemplates: texttmpl.Must(texttmpl.New(name).Parse(subjectTmpl)), - BodyTemplates: template.Must(template.New(name).Parse(bodyTmpl)), - }) +func mockMailTemplates(name, subjectTmpl, bodyTmpl string) func() { + return templates.MailRenderer().MockTemplate(name, subjectTmpl, bodyTmpl) } func TestComposeIssueComment(t *testing.T) { @@ -112,10 +109,8 @@ func TestComposeIssueComment(t *testing.T) { }, }) - setting.IncomingEmail.Enabled = true - defer func() { setting.IncomingEmail.Enabled = false }() - - prepareMailTemplates("repo/issue/comment", subjectTpl, bodyTpl) + defer test.MockVariableValue(&setting.IncomingEmail.Enabled, true)() + defer mockMailTemplates("repo/issue/comment", subjectTpl, bodyTpl)() recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}, {Name: "Test2", Email: "test2@gitea.com"}} msgs, err := composeIssueCommentMessages(t.Context(), &mailComment{ @@ -160,7 +155,7 @@ func TestComposeIssueComment(t *testing.T) { func TestMailMentionsComment(t *testing.T) { doer, _, issue, comment := prepareMailerTest(t) comment.Poster = doer - prepareMailTemplates("repo/issue/comment", subjectTpl, bodyTpl) + defer mockMailTemplates("repo/issue/comment", subjectTpl, bodyTpl)() mails := 0 defer test.MockVariableValue(&SendAsync, func(msgs ...*sender_service.Message) { @@ -175,7 +170,7 @@ func TestMailMentionsComment(t *testing.T) { func TestComposeIssueMessage(t *testing.T) { doer, _, issue, _ := prepareMailerTest(t) - prepareMailTemplates("repo/issue/new", subjectTpl, bodyTpl) + defer mockMailTemplates("repo/issue/new", subjectTpl, bodyTpl)() recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}, {Name: "Test2", Email: "test2@gitea.com"}} msgs, err := composeIssueCommentMessages(t.Context(), &mailComment{ Issue: issue, Doer: doer, ActionType: activities_model.ActionCreateIssue, @@ -204,14 +199,10 @@ func TestTemplateSelection(t *testing.T) { doer, repo, issue, comment := prepareMailerTest(t) recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}} - prepareMailTemplates("repo/issue/default", "repo/issue/default/subject", "repo/issue/default/body") - - texttmpl.Must(LoadedTemplates().SubjectTemplates.New("repo/issue/new").Parse("repo/issue/new/subject")) - texttmpl.Must(LoadedTemplates().SubjectTemplates.New("repo/pull/comment").Parse("repo/pull/comment/subject")) - texttmpl.Must(LoadedTemplates().SubjectTemplates.New("repo/issue/close").Parse("")) // Must default to a fallback subject - template.Must(LoadedTemplates().BodyTemplates.New("repo/issue/new").Parse("repo/issue/new/body")) - template.Must(LoadedTemplates().BodyTemplates.New("repo/pull/comment").Parse("repo/pull/comment/body")) - template.Must(LoadedTemplates().BodyTemplates.New("repo/issue/close").Parse("repo/issue/close/body")) + defer mockMailTemplates("repo/issue/default", "repo/issue/default/subject", "repo/issue/default/body")() + defer mockMailTemplates("repo/issue/new", "repo/issue/new/subject", "repo/issue/new/body")() + defer mockMailTemplates("repo/pull/comment", "repo/pull/comment/subject", "repo/pull/comment/body")() + defer mockMailTemplates("repo/issue/close", "", "repo/issue/close/body")() // Must default to a fallback subject expect := func(t *testing.T, msg *sender_service.Message, expSubject, expBody string) { subject := msg.ToMessage().GetGenHeader("Subject") @@ -256,7 +247,7 @@ func TestTemplateServices(t *testing.T) { expect := func(t *testing.T, issue *issues_model.Issue, comment *issues_model.Comment, doer *user_model.User, actionType activities_model.ActionType, fromMention bool, tplSubject, tplBody, expSubject, expBody string, ) { - prepareMailTemplates("repo/issue/default", tplSubject, tplBody) + defer mockMailTemplates("repo/issue/default", tplSubject, tplBody)() recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}} msg := testComposeIssueCommentMessage(t, &mailComment{ Issue: issue, Doer: doer, ActionType: actionType, @@ -523,7 +514,7 @@ func TestEmbedBase64Images(t *testing.T) { att2ImgBase64 := fmt.Sprintf(``, att2Base64) t.Run("ComposeMessage", func(t *testing.T) { - prepareMailTemplates("repo/issue/new", subjectTpl, bodyTpl) + defer mockMailTemplates("repo/issue/new", subjectTpl, bodyTpl)() issue.Content = fmt.Sprintf(`MSG-BEFORE MSG-AFTER`, att1.UUID) require.NoError(t, issues_model.UpdateIssueCols(t.Context(), issue, "content")) diff --git a/services/mailer/mailer.go b/services/mailer/mailer.go index db00aac4f1..05dd5d8588 100644 --- a/services/mailer/mailer.go +++ b/services/mailer/mailer.go @@ -43,7 +43,7 @@ func NewContext(ctx context.Context) { sender = &sender_service.SMTPSender{} } - templates.LoadMailTemplates(ctx, &loadedTemplates) + _ = templates.MailRenderer() mailQueue = queue.CreateSimpleQueue(graceful.GetManager().ShutdownContext(), "mail", func(items ...*sender_service.Message) []*sender_service.Message { for _, msg := range items { diff --git a/services/markup/renderhelper_codepreview_test.go b/services/markup/renderhelper_codepreview_test.go index 6665f0d009..c84845e7ea 100644 --- a/services/markup/renderhelper_codepreview_test.go +++ b/services/markup/renderhelper_codepreview_test.go @@ -18,7 +18,7 @@ import ( func TestRenderHelperCodePreview(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - ctx, _ := contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()}) + ctx, _ := contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.PageRenderer()}) htm, err := renderRepoFileCodePreview(ctx, markup.RenderCodePreviewOptions{ FullURL: "http://full", OwnerName: "user2", @@ -46,7 +46,7 @@ func TestRenderHelperCodePreview(t *testing.T) { `, string(htm)) - ctx, _ = contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()}) + ctx, _ = contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.PageRenderer()}) htm, err = renderRepoFileCodePreview(ctx, markup.RenderCodePreviewOptions{ FullURL: "http://full", OwnerName: "user2", @@ -70,7 +70,7 @@ func TestRenderHelperCodePreview(t *testing.T) { `, string(htm)) - ctx, _ = contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()}) + ctx, _ = contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.PageRenderer()}) _, err = renderRepoFileCodePreview(ctx, markup.RenderCodePreviewOptions{ FullURL: "http://full", OwnerName: "user15", diff --git a/services/markup/renderhelper_issueicontitle_test.go b/services/markup/renderhelper_issueicontitle_test.go index adce8401e0..25907f4b77 100644 --- a/services/markup/renderhelper_issueicontitle_test.go +++ b/services/markup/renderhelper_issueicontitle_test.go @@ -19,7 +19,7 @@ import ( func TestRenderHelperIssueIconTitle(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - ctx, _ := contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()}) + ctx, _ := contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.PageRenderer()}) ctx.Repo.Repository = unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 1}) htm, err := renderRepoIssueIconTitle(ctx, markup.RenderIssueIconTitleOptions{ LinkHref: "/link", @@ -28,7 +28,7 @@ func TestRenderHelperIssueIconTitle(t *testing.T) { assert.NoError(t, err) assert.Equal(t, `octicon-issue-opened(16/text green) issue1 (#1)`, string(htm)) - ctx, _ = contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()}) + ctx, _ = contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.PageRenderer()}) htm, err = renderRepoIssueIconTitle(ctx, markup.RenderIssueIconTitleOptions{ OwnerName: "user2", RepoName: "repo1", @@ -38,7 +38,7 @@ func TestRenderHelperIssueIconTitle(t *testing.T) { assert.NoError(t, err) assert.Equal(t, `octicon-issue-opened(16/text green) issue1 (user2/repo1#1)`, string(htm)) - ctx, _ = contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()}) + ctx, _ = contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.PageRenderer()}) _, err = renderRepoIssueIconTitle(ctx, markup.RenderIssueIconTitleOptions{ OwnerName: "user2", RepoName: "repo2",