mirror of
https://github.com/go-gitea/gitea.git
synced 2026-02-07 09:49:41 +09:00
Refactor template render (#36438)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
195
modules/templates/mail.go
Normal file
195
modules/templates/mail.go
Normal 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
|
||||
}
|
||||
@@ -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
modules/templates/page.go
Normal file
98
modules/templates/page.go
Normal 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)
|
||||
}
|
||||
|
||||
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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user