Refactor template render (#36438)

This commit is contained in:
wxiaoguang
2026-01-24 13:11:49 +08:00
committed by GitHub
parent 47717d4435
commit 9de659437e
31 changed files with 475 additions and 459 deletions
+5 -6
View File
@@ -22,6 +22,7 @@ import (
"code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/process"
"code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/public"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/routers" "code.gitea.io/gitea/routers"
"code.gitea.io/gitea/routers/install" "code.gitea.io/gitea/routers/install"
@@ -249,12 +250,6 @@ func servePprof() {
} }
func runWeb(ctx context.Context, cmd *cli.Command) error { 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 { if subCmdName, valid := isValidDefaultSubCommand(cmd); !valid {
return fmt.Errorf("unknown command: %s", subCmdName) return fmt.Errorf("unknown command: %s", subCmdName)
} }
@@ -274,6 +269,10 @@ func runWeb(ctx context.Context, cmd *cli.Command) error {
createPIDFile(cmd.String("pid")) 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 !setting.InstallLock {
if err := serveInstall(cmd); err != nil { if err := serveInstall(cmd); err != nil {
return err return err
+21 -29
View File
@@ -6,9 +6,7 @@ package assetfs
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"io/fs" "io/fs"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"sort" "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 // Layer represents a layer in a layered asset file-system. It has a name and works like http.FileSystem
type Layer struct { type Layer struct {
name string name string
fs http.FileSystem fs fs.FS
localPath string localPath string
} }
@@ -34,7 +32,7 @@ func (l *Layer) Name() string {
} }
// Open opens the named file. The caller is responsible for closing the file. // 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) 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)) panic(fmt.Sprintf("Unable to get absolute path for %q: %v", base, err))
} }
root := util.FilePathJoinAbs(base, sub...) 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. // 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 { 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. // 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. // 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 { for _, layer := range l.layers {
f, err := layer.Open(name) f, err := layer.Open(name)
if err == nil || !os.IsNotExist(err) { 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) { func (l *LayeredFS) ReadLayeredFile(elems ...string) ([]byte, string, error) {
name := util.PathJoinRel(elems...) name := util.PathJoinRel(elems...)
for _, layer := range l.layers { for _, layer := range l.layers {
f, err := layer.Open(name) bs, err := fs.ReadFile(layer, name)
if os.IsNotExist(err) { if os.IsNotExist(err) {
continue continue
} else if err != nil { } else if err != nil {
return nil, layer.name, err return nil, layer.name, err
} }
bs, err := io.ReadAll(f)
_ = f.Close()
return bs, layer.name, err return bs, layer.name, err
} }
return nil, "", fs.ErrNotExist return nil, "", fs.ErrNotExist
} }
func shouldInclude(info fs.FileInfo, fileMode ...bool) bool { func shouldInclude(dirEntry fs.DirEntry, fileMode ...bool) bool {
if util.IsCommonHiddenFileName(info.Name()) { if util.IsCommonHiddenFileName(dirEntry.Name()) {
return false return false
} }
if len(fileMode) == 0 { if len(fileMode) == 0 {
return true return true
} else if len(fileMode) == 1 { } else if len(fileMode) == 1 {
return fileMode[0] == !info.Mode().IsDir() return fileMode[0] == !dirEntry.IsDir()
} }
panic("too many arguments for fileMode in shouldInclude") panic("too many arguments for fileMode in shouldInclude")
} }
func readDir(layer *Layer, name string) ([]fs.FileInfo, error) { func readDirOptional(layer *Layer, name string) (entries []fs.DirEntry, err error) {
f, err := layer.Open(name) if entries, err = fs.ReadDir(layer, name); os.IsNotExist(err) {
if os.IsNotExist(err) {
return nil, nil return nil, nil
} else if err != nil {
return nil, err
} }
defer f.Close() return entries, err
return f.Readdir(-1)
} }
// ListFiles lists files/directories in the given directory. The fileMode controls the returned files. // 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) { func (l *LayeredFS) ListFiles(name string, fileMode ...bool) ([]string, error) {
fileSet := make(container.Set[string]) fileSet := make(container.Set[string])
for _, layer := range l.layers { for _, layer := range l.layers {
infos, err := readDir(layer, name) entries, err := readDirOptional(layer, name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
for _, info := range infos { for _, entry := range entries {
if shouldInclude(info, fileMode...) { if shouldInclude(entry, fileMode...) {
fileSet.Add(info.Name()) fileSet.Add(entry.Name())
} }
} }
} }
@@ -163,16 +155,16 @@ func listAllFiles(layers []*Layer, name string, fileMode ...bool) ([]string, err
var list func(dir string) error var list func(dir string) error
list = func(dir string) error { list = func(dir string) error {
for _, layer := range layers { for _, layer := range layers {
infos, err := readDir(layer, dir) entries, err := readDirOptional(layer, dir)
if err != nil { if err != nil {
return err return err
} }
for _, info := range infos { for _, entry := range entries {
path := util.PathJoinRelX(dir, info.Name()) path := util.PathJoinRelX(dir, entry.Name())
if shouldInclude(info, fileMode...) { if shouldInclude(entry, fileMode...) {
fileSet.Add(path) fileSet.Add(path)
} }
if info.IsDir() { if entry.IsDir() {
if err = list(path); err != nil { if err = list(path); err != nil {
return err return err
} }
+1 -1
View File
@@ -36,7 +36,7 @@ func FileHandlerFunc() http.HandlerFunc {
resp.WriteHeader(http.StatusMethodNotAllowed) resp.WriteHeader(http.StatusMethodNotAllowed)
return return
} }
handleRequest(resp, req, assetFS, req.URL.Path) handleRequest(resp, req, http.FS(assetFS), req.URL.Path)
} }
} }
-23
View File
@@ -4,9 +4,6 @@
package templates package templates
import ( import (
"slices"
"strings"
"code.gitea.io/gitea/modules/assetfs" "code.gitea.io/gitea/modules/assetfs"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
) )
@@ -18,23 +15,3 @@ func AssetFS() *assetfs.LayeredFS {
func CustomAssets() *assetfs.Layer { func CustomAssets() *assetfs.Layer {
return assetfs.Local("custom", setting.CustomPath, "templates") 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
}
-2
View File
@@ -25,8 +25,6 @@ import (
// NewFuncMap returns functions for injecting to templates // NewFuncMap returns functions for injecting to templates
func NewFuncMap() template.FuncMap { func NewFuncMap() template.FuncMap {
return map[string]any{ return map[string]any{
"ctx": func() any { return nil }, // template context function
"DumpVar": dumpVar, "DumpVar": dumpVar,
"NIL": func() any { return nil }, "NIL": func() any { return nil },
+20 -89
View File
@@ -6,21 +6,18 @@ package templates
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"context"
"errors" "errors"
"fmt" "fmt"
"html/template"
"io" "io"
"net/http"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
texttemplate "text/template" texttemplate "text/template"
"code.gitea.io/gitea/modules/assetfs" "code.gitea.io/gitea/modules/assetfs"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates/scopedtmpl" "code.gitea.io/gitea/modules/templates/scopedtmpl"
@@ -31,58 +28,27 @@ type TemplateExecutor scopedtmpl.TemplateExecutor
type TplName string type TplName string
type HTMLRender struct { type tmplRender struct {
templates atomic.Pointer[scopedtmpl.ScopedTemplate] templates atomic.Pointer[scopedtmpl.ScopedTemplate]
collectTemplateNames func() ([]string, error)
readTemplateContent func(name string) ([]byte, error)
} }
var ( func (h *tmplRender) Templates() *scopedtmpl.ScopedTemplate {
htmlRender *HTMLRender return h.templates.Load()
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 *HTMLRender) TemplateLookup(name string, ctx context.Context) (TemplateExecutor, error) { //nolint:revive // we don't use ctx, only pass it to the template executor func (h *tmplRender) recompileTemplates(dummyFuncMap template.FuncMap) error {
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"
tmpls := scopedtmpl.NewScopedTemplate() tmpls := scopedtmpl.NewScopedTemplate()
tmpls.Funcs(NewFuncMap()) tmpls.Funcs(dummyFuncMap)
files, err := ListWebTemplateAssetNames(assets) names, err := h.collectTemplateNames()
if err != nil { if err != nil {
return nil return err
} }
for _, file := range files { for _, name := range names {
if !strings.HasSuffix(file, extSuffix) {
continue
}
name := strings.TrimSuffix(file, extSuffix)
tmpl := tmpls.New(filepath.ToSlash(name)) tmpl := tmpls.New(filepath.ToSlash(name))
buf, err := assets.ReadFile(file) buf, err := h.readTemplateContent(name)
if err != nil { if err != nil {
return err return err
} }
@@ -95,55 +61,20 @@ func (h *HTMLRender) CompileTemplates() error {
return nil return nil
} }
// HTMLRenderer init once and returns the globally shared html renderer func ReloadAllTemplates() error {
func HTMLRenderer() *HTMLRender { return errors.Join(PageRendererReload(), MailRendererReload())
htmlRenderOnce.Do(initHTMLRenderer)
return htmlRender
} }
func ReloadHTMLTemplates() error { func processStartupTemplateError(err error) {
log.Trace("Reloading HTML templates") if err == nil {
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 == "" {
return return
} }
if setting.IsProd { if setting.IsProd || setting.IsInTesting {
// in prod mode, Gitea must have correct templates to run // 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 // 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 { type templateErrorPrettier struct {
+195
View File
@@ -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
}
-117
View File
@@ -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)
})
}
}
+98
View File
@@ -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())
}
@@ -61,6 +61,10 @@ func (t *ScopedTemplate) Freeze() {
t.all.Funcs(m) 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) { func (t *ScopedTemplate) Executor(name string, funcMap template.FuncMap) (TemplateExecutor, error) {
t.scopedMu.RLock() t.scopedMu.RLock()
scopedTmplSet, ok := t.scopedTemplateSets[name] scopedTmplSet, ok := t.scopedTemplateSets[name]
+3 -22
View File
@@ -10,25 +10,6 @@ import (
"unicode/utf8" "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 // 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. // 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) { 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 posBegin = posEnd
if part == "{}" || part[len(part)-1] != '}' { if part == "{}" || part[len(part)-1] != '}' {
// treat "{}" or "{..." as error // treat "{}" or "{..." as error
err = ErrWrongSyntax{Template: template} err = fmt.Errorf("wrong syntax found in %s", template)
buf.WriteString(part) buf.WriteString(part)
} else { } else {
// now we get a valid key "{...}" // now we get a valid key "{...}"
key := part[1 : len(part)-1] key := part[1 : len(part)-1]
keyFirst, _ := utf8.DecodeRuneInString(key) keyFirst, _ := utf8.DecodeRuneInString(key)
if unicode.IsSpace(keyFirst) || unicode.IsPunct(keyFirst) || unicode.IsControl(keyFirst) { 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) buf.WriteString(part)
} else { } else {
// look up in the map // look up in the map
@@ -82,7 +63,7 @@ func Expand(template string, vars map[string]string) (string, error) {
} else { } else {
// write the non-existing var as it is // write the non-existing var as it is
buf.WriteString(part) buf.WriteString(part)
err = ErrVarMissing{Template: template, Var: key} err = fmt.Errorf("the variable %s is missing for %s", key, template)
} }
} }
} }
+37 -14
View File
@@ -4,8 +4,11 @@
package common package common
import ( import (
"bytes"
"fmt" "fmt"
"io"
"net/http" "net/http"
"strings"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/httpcache" "code.gitea.io/gitea/modules/httpcache"
@@ -19,6 +22,36 @@ import (
const tplStatus500 templates.TplName = "status/500" 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 // RenderPanicErrorPage renders a 500 page, and it never panics
func RenderPanicErrorPage(w http.ResponseWriter, req *http.Request, err any) { func RenderPanicErrorPage(w http.ResponseWriter, req *http.Request, err any) {
combinedErr := fmt.Sprintf("%v\n%s", err, log.Stack(2)) 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) routing.UpdatePanicError(req.Context(), err)
httpcache.SetCacheControlInHeader(w.Header(), &httpcache.CacheControlOptions{NoTransform: true}) plainMsg := "Internal Server Error"
w.Header().Set(`X-Frame-Options`, setting.CORSConfig.XFrameOptions)
tmplCtx := context.NewTemplateContext(req.Context(), req)
tmplCtx["Locale"] = middleware.Locale(w, req)
ctxData := middleware.GetContextData(req.Context()) 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. // 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. // 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) user, _ := ctxData[middleware.ContextDataKeySignedUser].(*user_model.User)
if !setting.IsProd || (user != nil && user.IsAdmin) { if !setting.IsProd || (user != nil && user.IsAdmin) {
ctxData["ErrorMsg"] = "PANIC: " + combinedErr plainMsg = "PANIC: " + combinedErr
} ctxData["ErrorMsg"] = plainMsg
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"))
} }
renderServerErrorPage(w, req, http.StatusInternalServerError, tplStatus500, ctxData, plainMsg)
} }
+10 -1
View File
@@ -18,8 +18,9 @@ import (
) )
func TestRenderPanicErrorPage(t *testing.T) { func TestRenderPanicErrorPage(t *testing.T) {
t.Run("HTML", func(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
req := &http.Request{URL: &url.URL{}} req := &http.Request{URL: &url.URL{}, Header: http.Header{"Accept": []string{"text/html"}}}
req = req.WithContext(reqctx.NewRequestContextForTest(t.Context())) req = req.WithContext(reqctx.NewRequestContextForTest(t.Context()))
RenderPanicErrorPage(w, req, errors.New("fake panic error (for test only)")) RenderPanicErrorPage(w, req, errors.New("fake panic error (for test only)"))
respContent := w.Body.String() respContent := w.Body.String()
@@ -31,6 +32,14 @@ func TestRenderPanicErrorPage(t *testing.T) {
// especially when a sub-template causes page error, the HTTP response code is still 200, // 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. // the different "footer" is the only way to know whether a page is fully rendered without error.
assert.False(t, test.IsNormalPageCompleted(respContent)) 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) { func TestMain(m *testing.M) {
+1 -23
View File
@@ -14,7 +14,6 @@ import (
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/modules/web/middleware"
giteacontext "code.gitea.io/gitea/services/context"
"github.com/bohde/codel" "github.com/bohde/codel"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -119,27 +118,6 @@ func requestPriority(ctx context.Context) Priority {
// renderServiceUnavailable will render an HTTP 503 Service // renderServiceUnavailable will render an HTTP 503 Service
// Unavailable page, providing HTML if the client accepts it. // Unavailable page, providing HTML if the client accepts it.
func renderServiceUnavailable(w http.ResponseWriter, req *http.Request) { 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()) ctxData := middleware.GetContextData(req.Context())
err := templates.HTMLRenderer().HTML(w, http.StatusServiceUnavailable, tplStatus503, ctxData, tmplCtx) renderServerErrorPage(w, req, http.StatusServiceUnavailable, tplStatus503, ctxData, "Service Unavailable")
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"))
}
} }
-28
View File
@@ -4,7 +4,6 @@
package common package common
import ( import (
"net/http"
"testing" "testing"
user_model "code.gitea.io/gitea/models/user" 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")
})
}
-2
View File
@@ -24,7 +24,6 @@ import (
"code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/svg" "code.gitea.io/gitea/modules/svg"
"code.gitea.io/gitea/modules/system" "code.gitea.io/gitea/modules/system"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
@@ -182,7 +181,6 @@ func InitWebInstalled(ctx context.Context) {
// NormalRoutes represents non install routes // NormalRoutes represents non install routes
func NormalRoutes() *web.Router { func NormalRoutes() *web.Router {
_ = templates.HTMLRenderer()
r := web.NewRouter() r := web.NewRouter()
r.Use(common.ProtocolMiddlewares()...) r.Use(common.ProtocolMiddlewares()...)
+3 -23
View File
@@ -24,11 +24,9 @@ import (
"code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/reqctx"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/user" "code.gitea.io/gitea/modules/user"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/modules/web/middleware"
@@ -37,8 +35,6 @@ import (
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/forms"
"code.gitea.io/gitea/services/versioned_migration" "code.gitea.io/gitea/services/versioned_migration"
"gitea.com/go-chi/session"
) )
const ( const (
@@ -55,29 +51,13 @@ func getSupportedDbTypeNames() (dbTypeNames []map[string]string) {
return dbTypeNames return dbTypeNames
} }
// installContexter prepare for rendering installation page
func installContexter() func(next http.Handler) http.Handler { func installContexter() func(next http.Handler) http.Handler {
rnd := templates.HTMLRenderer() return context.ContexterInstallPage(map[string]any{
dbTypeNames := getSupportedDbTypeNames() "DbTypeNames": getSupportedDbTypeNames(),
envConfigKeys := setting.CollectEnvConfigKeys() "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, "CustomConfFile": setting.CustomConf,
"AllLangs": translation.AllLangs(),
"PasswordHashAlgorithms": hash.RecommendedHashAlgorithms, "PasswordHashAlgorithms": hash.RecommendedHashAlgorithms,
}) })
next.ServeHTTP(resp, ctx.Req)
})
}
} }
// Install render installation page // Install render installation page
+1 -1
View File
@@ -21,7 +21,7 @@ import (
// ReloadTemplates reloads all the templates // ReloadTemplates reloads all the templates
func ReloadTemplates(ctx *context.PrivateContext) { func ReloadTemplates(ctx *context.PrivateContext) {
err := templates.ReloadHTMLTemplates() err := templates.ReloadAllTemplates()
if err != nil { if err != nil {
ctx.JSON(http.StatusInternalServerError, private.Response{ ctx.JSON(http.StatusInternalServerError, private.Response{
UserMsg: fmt.Sprintf("Template error: %v", err), UserMsg: fmt.Sprintf("Template error: %v", err),
+7 -5
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/mailer" "code.gitea.io/gitea/services/mailer"
@@ -34,17 +35,18 @@ func MailPreviewRender(ctx *context.Context) {
func prepareMailPreviewRender(ctx *context.Context, tmplName string) { func prepareMailPreviewRender(ctx *context.Context, tmplName string) {
tmplSubject := mailer.LoadedTemplates().SubjectTemplates.Lookup(tmplName) tmplSubject := mailer.LoadedTemplates().SubjectTemplates.Lookup(tmplName)
if tmplSubject == nil { // FIXME: MAIL-TEMPLATE-SUBJECT: only "issue" related messages support using subject from templates
ctx.Data["RenderMailSubject"] = "default subject" subject := "(default subject)"
} else { if tmplSubject != nil {
var buf strings.Builder var buf strings.Builder
err := tmplSubject.Execute(&buf, nil) err := tmplSubject.Execute(&buf, nil)
if err != nil { if err != nil {
ctx.Data["RenderMailSubject"] = err.Error() subject = "ERROR: " + err.Error()
} else { } else {
ctx.Data["RenderMailSubject"] = buf.String() subject = util.IfZero(buf.String(), subject)
} }
} }
ctx.Data["RenderMailSubject"] = subject
ctx.Data["RenderMailTemplateName"] = tmplName ctx.Data["RenderMailTemplateName"] = tmplName
} }
+1 -1
View File
@@ -30,7 +30,7 @@ func TestRenderConversation(t *testing.T) {
run := func(name string, cb func(t *testing.T, ctx *context.Context, resp *httptest.ResponseRecorder)) { run := func(name string, cb func(t *testing.T, ctx *context.Context, resp *httptest.ResponseRecorder)) {
t.Run(name, func(t *testing.T) { 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.LoadUser(t, ctx, pr.Issue.PosterID)
contexttest.LoadRepo(t, ctx, pr.BaseRepoID) contexttest.LoadRepo(t, ctx, pr.BaseRepoID)
contexttest.LoadGitRepo(t, ctx) contexttest.LoadGitRepo(t, ctx)
+1 -1
View File
@@ -116,7 +116,7 @@ func TestMilestonesForSpecificRepo(t *testing.T) {
} }
func TestDashboardPagination(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) page := context.NewPagination(10, 3, 1, 3)
setting.AppSubURL = "/SubPath" setting.AppSubURL = "/SubPath"
-3
View File
@@ -18,7 +18,6 @@ import (
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/validation" "code.gitea.io/gitea/modules/validation"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/modules/web/middleware" "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", "/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")) routes.Methods("GET, HEAD", "/favicon.ico", misc.StaticRedirect("/assets/img/favicon.png"))
_ = templates.HTMLRenderer()
var mid []any var mid []any
if setting.EnableGzip { if setting.EnableGzip {
+20 -1
View File
@@ -17,6 +17,7 @@ import (
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/cache"
"code.gitea.io/gitea/modules/httpcache" "code.gitea.io/gitea/modules/httpcache"
"code.gitea.io/gitea/modules/reqctx"
"code.gitea.io/gitea/modules/session" "code.gitea.io/gitea/modules/session"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates"
@@ -137,9 +138,27 @@ func NewWebContext(base *Base, render Render, session session.Store) *Context {
return ctx 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. // Contexter initializes a classic context for a request.
func Contexter() func(next http.Handler) http.Handler { func Contexter() func(next http.Handler) http.Handler {
rnd := templates.HTMLRenderer() rnd := templates.PageRenderer()
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
base := NewBaseContext(resp, req) base := NewBaseContext(resp, req)
+1 -1
View File
@@ -150,7 +150,7 @@ func determineAccessMode(ctx *Base, pkg *Package, doer *user_model.User) (perm.A
// PackageContexter initializes a package context for a request. // PackageContexter initializes a package context for a request.
func PackageContexter() func(next http.Handler) http.Handler { func PackageContexter() func(next http.Handler) http.Handler {
renderer := templates.HTMLRenderer() renderer := templates.PageRenderer()
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
base := NewBaseContext(resp, req) base := NewBaseContext(resp, req)
+2 -5
View File
@@ -15,7 +15,6 @@ import (
"mime" "mime"
"regexp" "regexp"
"strings" "strings"
"sync/atomic"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
@@ -32,12 +31,10 @@ import (
const mailMaxSubjectRunes = 256 // There's no actual limit for subject in RFC 5322 const mailMaxSubjectRunes = 256 // There's no actual limit for subject in RFC 5322
var loadedTemplates atomic.Pointer[templates.MailTemplates]
var subjectRemoveSpaces = regexp.MustCompile(`[\s]+`) var subjectRemoveSpaces = regexp.MustCompile(`[\s]+`)
func LoadedTemplates() *templates.MailTemplates { func LoadedTemplates() *templates.MailRender {
return loadedTemplates.Load() return templates.MailRenderer()
} }
// SendTestMail sends a test mail // SendTestMail sends a test mail
+5 -6
View File
@@ -21,6 +21,7 @@ import (
"code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/util"
incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload" incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload"
sender_service "code.gitea.io/gitea/services/mailer/sender" sender_service "code.gitea.io/gitea/services/mailer/sender"
"code.gitea.io/gitea/services/mailer/token" "code.gitea.io/gitea/services/mailer/token"
@@ -122,9 +123,7 @@ func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang
var mailSubject bytes.Buffer var mailSubject bytes.Buffer
if err := LoadedTemplates().SubjectTemplates.ExecuteTemplate(&mailSubject, tplName, mailMeta); err == nil { if err := LoadedTemplates().SubjectTemplates.ExecuteTemplate(&mailSubject, tplName, mailMeta); err == nil {
subject = sanitizeSubject(mailSubject.String()) subject = sanitizeSubject(mailSubject.String())
if subject == "" { subject = util.IfZero(subject, fallback)
subject = fallback
}
} else { } else {
log.Error("ExecuteTemplate [%s]: %v", tplName+"/subject", err) 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 template = "repo/" + typeName + "/" + name
ok := LoadedTemplates().BodyTemplates.Lookup(template) != nil ok := LoadedTemplates().BodyTemplates.HasTemplate(template)
if !ok && typeName != "issue" { if !ok && typeName != "issue" {
template = "repo/issue/" + name template = "repo/issue/" + name
ok = LoadedTemplates().BodyTemplates.Lookup(template) != nil ok = LoadedTemplates().BodyTemplates.HasTemplate(template)
} }
if !ok { if !ok {
template = "repo/" + typeName + "/default" template = "repo/" + typeName + "/default"
ok = LoadedTemplates().BodyTemplates.Lookup(template) != nil ok = LoadedTemplates().BodyTemplates.HasTemplate(template)
} }
if !ok { if !ok {
template = "repo/issue/default" template = "repo/issue/default"
+6 -13
View File
@@ -10,6 +10,7 @@ import (
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
sender_service "code.gitea.io/gitea/services/mailer/sender" sender_service "code.gitea.io/gitea/services/mailer/sender"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -19,18 +20,10 @@ import (
func TestMailNewReleaseFiltersUnauthorizedWatchers(t *testing.T) { func TestMailNewReleaseFiltersUnauthorizedWatchers(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
origMailService := setting.MailService defer test.MockVariableValue(&setting.MailService)()
origDomain := setting.Domain defer test.MockVariableValue(&setting.Domain)()
origAppName := setting.AppName defer test.MockVariableValue(&setting.AppName)()
origAppURL := setting.AppURL defer test.MockVariableValue(&setting.AppURL)()
origTemplates := LoadedTemplates()
defer func() {
setting.MailService = origMailService
setting.Domain = origDomain
setting.AppName = origAppName
setting.AppURL = origAppURL
loadedTemplates.Store(origTemplates)
}()
setting.MailService = &setting.Mailer{ setting.MailService = &setting.Mailer{
From: "Gitea", From: "Gitea",
@@ -39,7 +32,7 @@ func TestMailNewReleaseFiltersUnauthorizedWatchers(t *testing.T) {
setting.Domain = "example.com" setting.Domain = "example.com"
setting.AppName = "Gitea" setting.AppName = "Gitea"
setting.AppURL = "https://example.com/" setting.AppURL = "https://example.com/"
prepareMailTemplates(string(tplNewReleaseMail), "{{.Subject}}", "<p>{{.Release.TagName}}</p>") defer mockMailTemplates(string(tplNewReleaseMail), "{{.Subject}}", "<p>{{.Release.TagName}}</p>")()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
require.True(t, repo.IsPrivate) require.True(t, repo.IsPrivate)
+12 -21
View File
@@ -96,11 +96,8 @@ func prepareMailerBase64Test(t *testing.T) (doer *user_model.User, repo *repo_mo
return user, repo, issue, att1, att2 return user, repo, issue, att1, att2
} }
func prepareMailTemplates(name, subjectTmpl, bodyTmpl string) { func mockMailTemplates(name, subjectTmpl, bodyTmpl string) func() {
loadedTemplates.Store(&templates.MailTemplates{ return templates.MailRenderer().MockTemplate(name, subjectTmpl, bodyTmpl)
SubjectTemplates: texttmpl.Must(texttmpl.New(name).Parse(subjectTmpl)),
BodyTemplates: template.Must(template.New(name).Parse(bodyTmpl)),
})
} }
func TestComposeIssueComment(t *testing.T) { func TestComposeIssueComment(t *testing.T) {
@@ -112,10 +109,8 @@ func TestComposeIssueComment(t *testing.T) {
}, },
}) })
setting.IncomingEmail.Enabled = true defer test.MockVariableValue(&setting.IncomingEmail.Enabled, true)()
defer func() { setting.IncomingEmail.Enabled = false }() defer mockMailTemplates("repo/issue/comment", subjectTpl, bodyTpl)()
prepareMailTemplates("repo/issue/comment", subjectTpl, bodyTpl)
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}, {Name: "Test2", Email: "test2@gitea.com"}} recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}, {Name: "Test2", Email: "test2@gitea.com"}}
msgs, err := composeIssueCommentMessages(t.Context(), &mailComment{ msgs, err := composeIssueCommentMessages(t.Context(), &mailComment{
@@ -160,7 +155,7 @@ func TestComposeIssueComment(t *testing.T) {
func TestMailMentionsComment(t *testing.T) { func TestMailMentionsComment(t *testing.T) {
doer, _, issue, comment := prepareMailerTest(t) doer, _, issue, comment := prepareMailerTest(t)
comment.Poster = doer comment.Poster = doer
prepareMailTemplates("repo/issue/comment", subjectTpl, bodyTpl) defer mockMailTemplates("repo/issue/comment", subjectTpl, bodyTpl)()
mails := 0 mails := 0
defer test.MockVariableValue(&SendAsync, func(msgs ...*sender_service.Message) { defer test.MockVariableValue(&SendAsync, func(msgs ...*sender_service.Message) {
@@ -175,7 +170,7 @@ func TestMailMentionsComment(t *testing.T) {
func TestComposeIssueMessage(t *testing.T) { func TestComposeIssueMessage(t *testing.T) {
doer, _, issue, _ := prepareMailerTest(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"}} recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}, {Name: "Test2", Email: "test2@gitea.com"}}
msgs, err := composeIssueCommentMessages(t.Context(), &mailComment{ msgs, err := composeIssueCommentMessages(t.Context(), &mailComment{
Issue: issue, Doer: doer, ActionType: activities_model.ActionCreateIssue, Issue: issue, Doer: doer, ActionType: activities_model.ActionCreateIssue,
@@ -204,14 +199,10 @@ func TestTemplateSelection(t *testing.T) {
doer, repo, issue, comment := prepareMailerTest(t) doer, repo, issue, comment := prepareMailerTest(t)
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}} recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}}
prepareMailTemplates("repo/issue/default", "repo/issue/default/subject", "repo/issue/default/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")()
texttmpl.Must(LoadedTemplates().SubjectTemplates.New("repo/issue/new").Parse("repo/issue/new/subject")) defer mockMailTemplates("repo/pull/comment", "repo/pull/comment/subject", "repo/pull/comment/body")()
texttmpl.Must(LoadedTemplates().SubjectTemplates.New("repo/pull/comment").Parse("repo/pull/comment/subject")) defer mockMailTemplates("repo/issue/close", "", "repo/issue/close/body")() // Must default to a fallback 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"))
expect := func(t *testing.T, msg *sender_service.Message, expSubject, expBody string) { expect := func(t *testing.T, msg *sender_service.Message, expSubject, expBody string) {
subject := msg.ToMessage().GetGenHeader("Subject") 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, 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, 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"}} recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}}
msg := testComposeIssueCommentMessage(t, &mailComment{ msg := testComposeIssueCommentMessage(t, &mailComment{
Issue: issue, Doer: doer, ActionType: actionType, Issue: issue, Doer: doer, ActionType: actionType,
@@ -523,7 +514,7 @@ func TestEmbedBase64Images(t *testing.T) {
att2ImgBase64 := fmt.Sprintf(`<img src="%s"/>`, att2Base64) att2ImgBase64 := fmt.Sprintf(`<img src="%s"/>`, att2Base64)
t.Run("ComposeMessage", func(t *testing.T) { 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 <image src="attachments/%s"> MSG-AFTER`, att1.UUID) issue.Content = fmt.Sprintf(`MSG-BEFORE <image src="attachments/%s"> MSG-AFTER`, att1.UUID)
require.NoError(t, issues_model.UpdateIssueCols(t.Context(), issue, "content")) require.NoError(t, issues_model.UpdateIssueCols(t.Context(), issue, "content"))
+1 -1
View File
@@ -43,7 +43,7 @@ func NewContext(ctx context.Context) {
sender = &sender_service.SMTPSender{} 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 { mailQueue = queue.CreateSimpleQueue(graceful.GetManager().ShutdownContext(), "mail", func(items ...*sender_service.Message) []*sender_service.Message {
for _, msg := range items { for _, msg := range items {
@@ -18,7 +18,7 @@ import (
func TestRenderHelperCodePreview(t *testing.T) { func TestRenderHelperCodePreview(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) 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{ htm, err := renderRepoFileCodePreview(ctx, markup.RenderCodePreviewOptions{
FullURL: "http://full", FullURL: "http://full",
OwnerName: "user2", OwnerName: "user2",
@@ -46,7 +46,7 @@ func TestRenderHelperCodePreview(t *testing.T) {
</div> </div>
`, string(htm)) `, 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{ htm, err = renderRepoFileCodePreview(ctx, markup.RenderCodePreviewOptions{
FullURL: "http://full", FullURL: "http://full",
OwnerName: "user2", OwnerName: "user2",
@@ -70,7 +70,7 @@ func TestRenderHelperCodePreview(t *testing.T) {
</div> </div>
`, string(htm)) `, 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{ _, err = renderRepoFileCodePreview(ctx, markup.RenderCodePreviewOptions{
FullURL: "http://full", FullURL: "http://full",
OwnerName: "user15", OwnerName: "user15",
@@ -19,7 +19,7 @@ import (
func TestRenderHelperIssueIconTitle(t *testing.T) { func TestRenderHelperIssueIconTitle(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) 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}) ctx.Repo.Repository = unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 1})
htm, err := renderRepoIssueIconTitle(ctx, markup.RenderIssueIconTitleOptions{ htm, err := renderRepoIssueIconTitle(ctx, markup.RenderIssueIconTitleOptions{
LinkHref: "/link", LinkHref: "/link",
@@ -28,7 +28,7 @@ func TestRenderHelperIssueIconTitle(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, `<a href="/link"><span>octicon-issue-opened(16/text green)</span> issue1 (#1)</a>`, string(htm)) assert.Equal(t, `<a href="/link"><span>octicon-issue-opened(16/text green)</span> issue1 (#1)</a>`, 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{ htm, err = renderRepoIssueIconTitle(ctx, markup.RenderIssueIconTitleOptions{
OwnerName: "user2", OwnerName: "user2",
RepoName: "repo1", RepoName: "repo1",
@@ -38,7 +38,7 @@ func TestRenderHelperIssueIconTitle(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, `<a href="/link"><span>octicon-issue-opened(16/text green)</span> issue1 (user2/repo1#1)</a>`, string(htm)) assert.Equal(t, `<a href="/link"><span>octicon-issue-opened(16/text green)</span> issue1 (user2/repo1#1)</a>`, 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{ _, err = renderRepoIssueIconTitle(ctx, markup.RenderIssueIconTitleOptions{
OwnerName: "user2", OwnerName: "user2",
RepoName: "repo2", RepoName: "repo2",