diff --git a/cmd/embedded.go b/cmd/embedded.go
index 30fc7103d838b..ffdc3d6a6364f 100644
--- a/cmd/embedded.go
+++ b/cmd/embedded.go
@@ -123,7 +123,7 @@ func initEmbeddedExtractor(c *cli.Context) error {
sections["public"] = §ion{Path: "public", Names: public.AssetNames, IsDir: public.AssetIsDir, Asset: public.Asset}
sections["options"] = §ion{Path: "options", Names: options.AssetNames, IsDir: options.AssetIsDir, Asset: options.Asset}
- sections["templates"] = §ion{Path: "templates", Names: templates.AssetNames, IsDir: templates.AssetIsDir, Asset: templates.Asset}
+ sections["templates"] = §ion{Path: "templates", Names: templates.BuiltinAssetNames, IsDir: templates.BuiltinAssetIsDir, Asset: templates.BuiltinAsset}
for _, sec := range sections {
assets = append(assets, buildAssetList(sec, pats, c)...)
diff --git a/cmd/web.go b/cmd/web.go
index 43bb0ada911e7..b53e867c8e38e 100644
--- a/cmd/web.go
+++ b/cmd/web.go
@@ -126,8 +126,10 @@ func runWeb(ctx *cli.Context) error {
return err
}
}
- c := install.Routes()
+ installCtx, cancel := context.WithCancel(graceful.GetManager().HammerContext())
+ c := install.Routes(installCtx)
err := listen(c, false)
+ cancel()
if err != nil {
log.Critical("Unable to open listener for installer. Is Gitea already running?")
graceful.GetManager().DoGracefulShutdown()
@@ -174,7 +176,7 @@ func runWeb(ctx *cli.Context) error {
}
// Set up Chi routes
- c := routers.NormalRoutes()
+ c := routers.NormalRoutes(graceful.GetManager().HammerContext())
err := listen(c, true)
<-graceful.GetManager().Done()
log.Info("PID: %d Gitea Web Finished", os.Getpid())
diff --git a/contrib/pr/checkout.go b/contrib/pr/checkout.go
index f6d29f3c5b574..36487b2f84ee5 100644
--- a/contrib/pr/checkout.go
+++ b/contrib/pr/checkout.go
@@ -27,6 +27,7 @@ import (
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"
gitea_git "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/external"
repo_module "code.gitea.io/gitea/modules/repository"
@@ -118,7 +119,7 @@ func runPR() {
// routers.GlobalInit()
external.RegisterRenderers()
markup.Init()
- c := routers.NormalRoutes()
+ c := routers.NormalRoutes(graceful.GetManager().HammerContext())
log.Printf("[PR] Ready for testing !\n")
log.Printf("[PR] Login with user1, user2, user3, ... with pass: password\n")
diff --git a/go.mod b/go.mod
index 8e0003d6ecb02..478d93c894df3 100644
--- a/go.mod
+++ b/go.mod
@@ -27,6 +27,7 @@ require (
github.com/emirpasic/gods v1.18.1
github.com/ethantkoenig/rupture v1.0.1
github.com/felixge/fgprof v0.9.2
+ github.com/fsnotify/fsnotify v1.5.4
github.com/gliderlabs/ssh v0.3.4
github.com/go-ap/activitypub v0.0.0-20220615144428-48208c70483b
github.com/go-ap/jsonld v0.0.0-20220615144122-1d862b15410d
@@ -160,7 +161,6 @@ require (
github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect
github.com/felixge/httpsnoop v1.0.2 // indirect
github.com/form3tech-oss/jwt-go v3.2.3+incompatible // indirect
- github.com/fsnotify/fsnotify v1.5.4 // indirect
github.com/fullstorydev/grpcurl v1.8.1 // indirect
github.com/fxamacker/cbor/v2 v2.4.0 // indirect
github.com/go-ap/errors v0.0.0-20220615144307-e8bc4a40ae9f // indirect
diff --git a/integrations/api_activitypub_person_test.go b/integrations/api_activitypub_person_test.go
index e19da40864e92..3dc2fda3f4768 100644
--- a/integrations/api_activitypub_person_test.go
+++ b/integrations/api_activitypub_person_test.go
@@ -23,10 +23,10 @@ import (
func TestActivityPubPerson(t *testing.T) {
setting.Federation.Enabled = true
- c = routers.NormalRoutes()
+ c = routers.NormalRoutes(context.TODO())
defer func() {
setting.Federation.Enabled = false
- c = routers.NormalRoutes()
+ c = routers.NormalRoutes(context.TODO())
}()
onGiteaRun(t, func(*testing.T, *url.URL) {
@@ -60,10 +60,10 @@ func TestActivityPubPerson(t *testing.T) {
func TestActivityPubMissingPerson(t *testing.T) {
setting.Federation.Enabled = true
- c = routers.NormalRoutes()
+ c = routers.NormalRoutes(context.Background())
defer func() {
setting.Federation.Enabled = false
- c = routers.NormalRoutes()
+ c = routers.NormalRoutes(context.Background())
}()
onGiteaRun(t, func(*testing.T, *url.URL) {
@@ -75,10 +75,10 @@ func TestActivityPubMissingPerson(t *testing.T) {
func TestActivityPubPersonInbox(t *testing.T) {
setting.Federation.Enabled = true
- c = routers.NormalRoutes()
+ c = routers.NormalRoutes(context.Background())
defer func() {
setting.Federation.Enabled = false
- c = routers.NormalRoutes()
+ c = routers.NormalRoutes(context.Background())
}()
srv := httptest.NewServer(c)
diff --git a/integrations/api_nodeinfo_test.go b/integrations/api_nodeinfo_test.go
index cf9ff4da1b532..bbb79120784e2 100644
--- a/integrations/api_nodeinfo_test.go
+++ b/integrations/api_nodeinfo_test.go
@@ -5,6 +5,7 @@
package integrations
import (
+ "context"
"net/http"
"net/url"
"testing"
@@ -18,10 +19,10 @@ import (
func TestNodeinfo(t *testing.T) {
setting.Federation.Enabled = true
- c = routers.NormalRoutes()
+ c = routers.NormalRoutes(context.TODO())
defer func() {
setting.Federation.Enabled = false
- c = routers.NormalRoutes()
+ c = routers.NormalRoutes(context.TODO())
}()
onGiteaRun(t, func(*testing.T, *url.URL) {
diff --git a/integrations/create_no_session_test.go b/integrations/create_no_session_test.go
index 49234c1e9599c..017fe1d356ed5 100644
--- a/integrations/create_no_session_test.go
+++ b/integrations/create_no_session_test.go
@@ -5,6 +5,7 @@
package integrations
import (
+ "context"
"net/http"
"net/http/httptest"
"os"
@@ -57,7 +58,7 @@ func TestSessionFileCreation(t *testing.T) {
oldSessionConfig := setting.SessionConfig.ProviderConfig
defer func() {
setting.SessionConfig.ProviderConfig = oldSessionConfig
- c = routers.NormalRoutes()
+ c = routers.NormalRoutes(context.TODO())
}()
var config session.Options
@@ -82,7 +83,7 @@ func TestSessionFileCreation(t *testing.T) {
setting.SessionConfig.ProviderConfig = string(newConfigBytes)
- c = routers.NormalRoutes()
+ c = routers.NormalRoutes(context.TODO())
t.Run("NoSessionOnViewIssue", func(t *testing.T) {
defer PrintCurrentTest(t)()
diff --git a/integrations/integration_test.go b/integrations/integration_test.go
index 8a43de7c45fa9..c3da53396585f 100644
--- a/integrations/integration_test.go
+++ b/integrations/integration_test.go
@@ -89,7 +89,7 @@ func TestMain(m *testing.M) {
defer cancel()
initIntegrationTest()
- c = routers.NormalRoutes()
+ c = routers.NormalRoutes(context.TODO())
// integration test settings...
if setting.Cfg != nil {
diff --git a/modules/context/context.go b/modules/context/context.go
index 68f8a1b408c1f..d988dee3fbc80 100644
--- a/modules/context/context.go
+++ b/modules/context/context.go
@@ -673,8 +673,8 @@ func Auth(authMethod auth.Method) func(*Context) {
}
// Contexter initializes a classic context for a request.
-func Contexter() func(next http.Handler) http.Handler {
- rnd := templates.HTMLRenderer()
+func Contexter(ctx context.Context) func(next http.Handler) http.Handler {
+ _, rnd := templates.HTMLRenderer(ctx)
csrfOpts := getCsrfOpts()
if !setting.IsProd {
CsrfTokenRegenerationInterval = 5 * time.Second // in dev, re-generate the tokens more aggressively for debug purpose
diff --git a/modules/context/package.go b/modules/context/package.go
index 4c52907dc529c..28210a6d6ddd9 100644
--- a/modules/context/package.go
+++ b/modules/context/package.go
@@ -5,6 +5,7 @@
package context
import (
+ gocontext "context"
"fmt"
"net/http"
@@ -13,6 +14,7 @@ import (
"code.gitea.io/gitea/models/perm"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/templates"
)
// Package contains owner, access mode and optional the package descriptor
@@ -101,12 +103,14 @@ func packageAssignment(ctx *Context, errCb func(int, string, interface{})) {
}
// PackageContexter initializes a package context for a request.
-func PackageContexter() func(next http.Handler) http.Handler {
+func PackageContexter(ctx gocontext.Context) func(next http.Handler) http.Handler {
+ _, rnd := templates.HTMLRenderer(ctx)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
ctx := Context{
- Resp: NewResponse(resp),
- Data: map[string]interface{}{},
+ Resp: NewResponse(resp),
+ Data: map[string]interface{}{},
+ Render: rnd,
}
defer ctx.Close()
diff --git a/modules/options/base.go b/modules/options/base.go
new file mode 100644
index 0000000000000..685202cef9a71
--- /dev/null
+++ b/modules/options/base.go
@@ -0,0 +1,34 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package options
+
+import (
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+)
+
+func walkAssetDir(root string, callback func(path string, name string, d fs.DirEntry, err error) error) error {
+ if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+ name := path[len(root):]
+ if len(name) > 0 && name[0] == '/' {
+ name = name[1:]
+ }
+ if err != nil {
+ if os.IsNotExist(err) {
+ return callback(path, name, d, err)
+ }
+ return err
+ }
+ if d.Name() == ".DS_Store" && d.IsDir() { // Because Macs...
+ return fs.SkipDir
+ }
+ return callback(path, name, d, err)
+ }); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("unable to get files for assets in %s: %w", root, err)
+ }
+ return nil
+}
diff --git a/modules/options/dynamic.go b/modules/options/dynamic.go
index 5fea337e4203b..37622b1e30d9e 100644
--- a/modules/options/dynamic.go
+++ b/modules/options/dynamic.go
@@ -8,8 +8,10 @@ package options
import (
"fmt"
+ "io/fs"
"os"
"path"
+ "path/filepath"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
@@ -45,7 +47,7 @@ func Dir(name string) ([]string, error) {
isDir, err = util.IsDir(staticDir)
if err != nil {
- return []string{}, fmt.Errorf("Unabe to check if static directory %s is a directory. %v", staticDir, err)
+ return []string{}, fmt.Errorf("unable to check if static directory %s is a directory. %v", staticDir, err)
}
if isDir {
files, err := util.StatDir(staticDir, true)
@@ -64,6 +66,18 @@ func Locale(name string) ([]byte, error) {
return fileFromDir(path.Join("locale", name))
}
+// WalkLocales reads the content of a specific locale from static or custom path.
+func WalkLocales(callback func(path string, name string, d fs.DirEntry, err error) error) error {
+ if err := walkAssetDir(filepath.Join(setting.StaticRootPath, "options", "locale"), callback); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("failed to walk locales. Error: %w", err)
+ }
+
+ if err := walkAssetDir(filepath.Join(setting.CustomPath, "options", "locale"), callback); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("failed to walk locales. Error: %w", err)
+ }
+ return nil
+}
+
// Readme reads the content of a specific readme from static or custom path.
func Readme(name string) ([]byte, error) {
return fileFromDir(path.Join("readme", name))
diff --git a/modules/options/static.go b/modules/options/static.go
index 6cad88cb61bbb..b6a1ee8d3b72d 100644
--- a/modules/options/static.go
+++ b/modules/options/static.go
@@ -9,8 +9,10 @@ package options
import (
"fmt"
"io"
+ "io/fs"
"os"
"path"
+ "path/filepath"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
@@ -74,6 +76,14 @@ func Locale(name string) ([]byte, error) {
return fileFromDir(path.Join("locale", name))
}
+// WalkLocales reads the content of a specific locale from static or custom path.
+func WalkLocales(callback func(path string, name string, d fs.DirEntry, err error) error) error {
+ if err := walkAssetDir(filepath.Join(setting.CustomPath, "options", "locale"), callback); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("failed to walk locales. Error: %w", err)
+ }
+ return nil
+}
+
// Readme reads the content of a specific readme from bindata or custom path.
func Readme(name string) ([]byte, error) {
return fileFromDir(path.Join("readme", name))
diff --git a/modules/templates/base.go b/modules/templates/base.go
index 282019f826c1d..0c9d6da3cf09e 100644
--- a/modules/templates/base.go
+++ b/modules/templates/base.go
@@ -5,15 +5,16 @@
package templates
import (
+ "fmt"
+ "io/fs"
"os"
+ "path/filepath"
"strings"
"time"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
-
- "github.com/unrolled/render"
)
// Vars represents variables to be render in golang templates
@@ -46,8 +47,16 @@ func BaseVars() Vars {
}
}
-func getDirAssetNames(dir string) []string {
+func getDirTemplateAssetNames(dir string) []string {
+ return getDirAssetNames(dir, false)
+}
+
+func getDirAssetNames(dir string, mailer bool) []string {
var tmpls []string
+
+ if mailer {
+ dir += filepath.Join(dir, "mail")
+ }
f, err := os.Stat(dir)
if err != nil {
if os.IsNotExist(err) {
@@ -66,8 +75,13 @@ func getDirAssetNames(dir string) []string {
log.Warn("Failed to read %s templates dir. %v", dir, err)
return tmpls
}
+
+ prefix := "templates/"
+ if mailer {
+ prefix += "mail/"
+ }
for _, filePath := range files {
- if strings.HasPrefix(filePath, "mail/") {
+ if !mailer && strings.HasPrefix(filePath, "mail/") {
continue
}
@@ -75,20 +89,36 @@ func getDirAssetNames(dir string) []string {
continue
}
- tmpls = append(tmpls, "templates/"+filePath)
+ tmpls = append(tmpls, prefix+filePath)
}
return tmpls
}
-// HTMLRenderer returns a render.
-func HTMLRenderer() *render.Render {
- return render.New(render.Options{
- Extensions: []string{".tmpl"},
- Directory: "templates",
- Funcs: NewFuncMap(),
- Asset: GetAsset,
- AssetNames: GetAssetNames,
- IsDevelopment: !setting.IsProd,
- DisableHTTPErrorRendering: true,
- })
+func walkAssetDir(root string, skipMail bool, callback func(path string, name string, d fs.DirEntry, err error) error) error {
+ mailRoot := filepath.Join(root, "mail")
+ if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+ name := path[len(root):]
+ if len(name) > 0 && name[0] == '/' {
+ name = name[1:]
+ }
+ if err != nil {
+ if os.IsNotExist(err) {
+ return callback(path, name, d, err)
+ }
+ return err
+ }
+ if skipMail && path == mailRoot && d.IsDir() {
+ return fs.SkipDir
+ }
+ if d.Name() == ".DS_Store" && d.IsDir() { // Because Macs...
+ return fs.SkipDir
+ }
+ if strings.HasSuffix(d.Name(), ".tmpl") || d.IsDir() {
+ return callback(path, name, d, err)
+ }
+ return nil
+ }); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("unable to get files for template assets in %s: %w", root, err)
+ }
+ return nil
}
diff --git a/modules/templates/dynamic.go b/modules/templates/dynamic.go
index de6968c314a08..4896580f6249f 100644
--- a/modules/templates/dynamic.go
+++ b/modules/templates/dynamic.go
@@ -8,15 +8,12 @@ package templates
import (
"html/template"
+ "io/fs"
"os"
- "path"
"path/filepath"
- "strings"
texttmpl "text/template"
- "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
- "code.gitea.io/gitea/modules/util"
)
var (
@@ -36,77 +33,42 @@ func GetAsset(name string) ([]byte, error) {
return os.ReadFile(filepath.Join(setting.StaticRootPath, name))
}
-// GetAssetNames returns assets list
-func GetAssetNames() []string {
- tmpls := getDirAssetNames(filepath.Join(setting.CustomPath, "templates"))
- tmpls2 := getDirAssetNames(filepath.Join(setting.StaticRootPath, "templates"))
- return append(tmpls, tmpls2...)
-}
-
-// Mailer provides the templates required for sending notification mails.
-func Mailer() (*texttmpl.Template, *template.Template) {
- for _, funcs := range NewTextFuncMap() {
- subjectTemplates.Funcs(funcs)
+// walkTemplateFiles calls a callback for each template asset
+func walkTemplateFiles(callback func(path, name string, d fs.DirEntry, err error) error) error {
+ if err := walkAssetDir(filepath.Join(setting.CustomPath, "templates"), true, callback); err != nil && !os.IsNotExist(err) {
+ return err
}
- for _, funcs := range NewFuncMap() {
- bodyTemplates.Funcs(funcs)
+ if err := walkAssetDir(filepath.Join(setting.StaticRootPath, "templates"), true, callback); err != nil && !os.IsNotExist(err) {
+ return err
}
+ return nil
+}
- staticDir := path.Join(setting.StaticRootPath, "templates", "mail")
-
- isDir, err := util.IsDir(staticDir)
- if err != nil {
- log.Warn("Unable to check if templates dir %s is a directory. Error: %v", staticDir, err)
- }
- if isDir {
- files, err := util.StatDir(staticDir)
-
- if err != nil {
- log.Warn("Failed to read %s templates dir. %v", staticDir, err)
- } else {
- for _, filePath := range files {
- if !strings.HasSuffix(filePath, ".tmpl") {
- continue
- }
-
- content, err := os.ReadFile(path.Join(staticDir, filePath))
- if err != nil {
- log.Warn("Failed to read static %s template. %v", filePath, err)
- continue
- }
+// GetTemplateAssetNames returns list of template names
+func GetTemplateAssetNames() []string {
+ tmpls := getDirTemplateAssetNames(filepath.Join(setting.CustomPath, "templates"))
+ tmpls2 := getDirTemplateAssetNames(filepath.Join(setting.StaticRootPath, "templates"))
+ return append(tmpls, tmpls2...)
+}
- buildSubjectBodyTemplate(subjectTemplates, bodyTemplates, strings.TrimSuffix(filePath, ".tmpl"), content)
- }
- }
+func walkMailerTemplates(callback func(path, name string, d fs.DirEntry, err error) error) error {
+ if err := walkAssetDir(filepath.Join(setting.StaticRootPath, "templates", "mail"), false, callback); err != nil && !os.IsNotExist(err) {
+ return err
}
-
- customDir := path.Join(setting.CustomPath, "templates", "mail")
-
- isDir, err = util.IsDir(customDir)
- if err != nil {
- log.Warn("Unable to check if templates dir %s is a directory. Error: %v", customDir, err)
+ if err := walkAssetDir(filepath.Join(setting.CustomPath, "templates", "mail"), false, callback); err != nil && !os.IsNotExist(err) {
+ return err
}
- if isDir {
- files, err := util.StatDir(customDir)
-
- if err != nil {
- log.Warn("Failed to read %s templates dir. %v", customDir, err)
- } else {
- for _, filePath := range files {
- if !strings.HasSuffix(filePath, ".tmpl") {
- continue
- }
-
- content, err := os.ReadFile(path.Join(customDir, filePath))
- if err != nil {
- log.Warn("Failed to read custom %s template. %v", filePath, err)
- continue
- }
+ return nil
+}
- buildSubjectBodyTemplate(subjectTemplates, bodyTemplates, strings.TrimSuffix(filePath, ".tmpl"), content)
- }
- }
- }
+// BuiltinAsset will read the provided asset from the embedded assets
+// (This always returns os.ErrNotExist)
+func BuiltinAsset(name string) ([]byte, error) {
+ return nil, os.ErrNotExist
+}
- return subjectTemplates, bodyTemplates
+// BuiltinAssetNames returns the names of the embedded assets
+// (This always returns nil)
+func BuiltinAssetNames() []string {
+ return nil
}
diff --git a/modules/templates/htmlrenderer.go b/modules/templates/htmlrenderer.go
new file mode 100644
index 0000000000000..618f2835c894e
--- /dev/null
+++ b/modules/templates/htmlrenderer.go
@@ -0,0 +1,51 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package templates
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/watcher"
+ "github.com/unrolled/render"
+)
+
+var rendererKey interface{} = "templatesHtmlRendereer"
+
+// HTMLRenderer returns the current html renderer for the context or creates and stores one within the context for future use
+func HTMLRenderer(ctx context.Context) (context.Context, *render.Render) {
+ rendererInterface := ctx.Value(rendererKey)
+ if rendererInterface != nil {
+ renderer, ok := rendererInterface.(*render.Render)
+ if ok && renderer != nil {
+ return ctx, renderer
+ }
+ }
+
+ if setting.IsProd {
+ log.Log(1, log.DEBUG, "Creating static HTML Renderer")
+ } else {
+ log.Log(1, log.DEBUG, "Creating auto-reloading HTML Renderer")
+ }
+
+ renderer := render.New(render.Options{
+ Extensions: []string{".tmpl"},
+ Directory: "templates",
+ Funcs: NewFuncMap(),
+ Asset: GetAsset,
+ AssetNames: GetTemplateAssetNames,
+ UseMutexLock: !setting.IsProd,
+ IsDevelopment: false,
+ DisableHTTPErrorRendering: true,
+ })
+ if !setting.IsProd {
+ watcher.CreateWatcher(ctx, "HTML Templates", &watcher.CreateWatcherOpts{
+ PathsCallback: walkTemplateFiles,
+ BetweenCallback: renderer.CompileTemplates,
+ })
+ }
+ return context.WithValue(ctx, rendererKey, renderer), renderer
+}
diff --git a/modules/templates/mailer.go b/modules/templates/mailer.go
new file mode 100644
index 0000000000000..e8696dc8e8c87
--- /dev/null
+++ b/modules/templates/mailer.go
@@ -0,0 +1,98 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package templates
+
+import (
+ "context"
+ "html/template"
+ "io/fs"
+ "os"
+ "strings"
+ texttmpl "text/template"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/watcher"
+)
+
+// Mailer provides the templates required for sending notification mails.
+func Mailer(ctx context.Context) (*texttmpl.Template, *template.Template) {
+ for _, funcs := range NewTextFuncMap() {
+ subjectTemplates.Funcs(funcs)
+ }
+ for _, funcs := range NewFuncMap() {
+ bodyTemplates.Funcs(funcs)
+ }
+
+ refreshTemplates := func() {
+ for _, assetPath := range BuiltinAssetNames() {
+ if !strings.HasPrefix(assetPath, "mail/") {
+ continue
+ }
+
+ if !strings.HasSuffix(assetPath, ".tmpl") {
+ continue
+ }
+
+ content, err := BuiltinAsset(assetPath)
+ if err != nil {
+ log.Warn("Failed to read embedded %s template. %v", assetPath, err)
+ continue
+ }
+
+ assetName := strings.TrimPrefix(
+ strings.TrimSuffix(
+ assetPath,
+ ".tmpl",
+ ),
+ "mail/",
+ )
+
+ log.Trace("Adding built-in mailer template for %s", assetName)
+ buildSubjectBodyTemplate(subjectTemplates,
+ bodyTemplates,
+ assetName,
+ content)
+ }
+
+ if err := walkMailerTemplates(func(path, name string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+
+ content, err := os.ReadFile(path)
+ if err != nil {
+ log.Warn("Failed to read custom %s template. %v", path, err)
+ return nil
+ }
+
+ assetName := strings.TrimSuffix(name, ".tmpl")
+ log.Trace("Adding mailer template for %s from %q", assetName, path)
+ buildSubjectBodyTemplate(subjectTemplates,
+ bodyTemplates,
+ assetName,
+ content)
+ return nil
+ }); err != nil && !os.IsNotExist(err) {
+ log.Warn("Error whilst walking mailer templates directories. %v", err)
+ }
+ }
+
+ refreshTemplates()
+
+ if !setting.IsProd {
+ // Now subjectTemplates and bodyTemplates are both synchronized
+ // thus it is safe to call refresh from a different goroutine
+ watcher.CreateWatcher(ctx, "Mailer Templates", &watcher.CreateWatcherOpts{
+ PathsCallback: walkMailerTemplates,
+ BetweenCallback: refreshTemplates,
+ })
+ }
+
+ return subjectTemplates, bodyTemplates
+}
diff --git a/modules/templates/static.go b/modules/templates/static.go
index 351e48b4daa9a..3265bd9cfcbc4 100644
--- a/modules/templates/static.go
+++ b/modules/templates/static.go
@@ -9,6 +9,7 @@ package templates
import (
"html/template"
"io"
+ "io/fs"
"os"
"path"
"path/filepath"
@@ -16,10 +17,8 @@ import (
texttmpl "text/template"
"time"
- "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"
- "code.gitea.io/gitea/modules/util"
)
var (
@@ -40,95 +39,42 @@ func GetAsset(name string) ([]byte, error) {
} else if err == nil {
return bs, nil
}
- return Asset(strings.TrimPrefix(name, "templates/"))
+ return BuiltinAsset(strings.TrimPrefix(name, "templates/"))
}
-// GetAssetNames only for chi
-func GetAssetNames() []string {
+// GetFiles calls a callback for each template asset
+func walkTemplateFiles(callback func(path, name string, d fs.DirEntry, err error) error) error {
+ if err := walkAssetDir(filepath.Join(setting.CustomPath, "templates"), true, callback); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ return nil
+}
+
+// GetTemplateAssetNames only for chi
+func GetTemplateAssetNames() []string {
realFS := Assets.(vfsgen۰FS)
tmpls := make([]string, 0, len(realFS))
for k := range realFS {
+ if strings.HasPrefix(k, "/mail/") {
+ continue
+ }
tmpls = append(tmpls, "templates/"+k[1:])
}
customDir := path.Join(setting.CustomPath, "templates")
- customTmpls := getDirAssetNames(customDir)
+ customTmpls := getDirTemplateAssetNames(customDir)
return append(tmpls, customTmpls...)
}
-// Mailer provides the templates required for sending notification mails.
-func Mailer() (*texttmpl.Template, *template.Template) {
- for _, funcs := range NewTextFuncMap() {
- subjectTemplates.Funcs(funcs)
- }
- for _, funcs := range NewFuncMap() {
- bodyTemplates.Funcs(funcs)
+func walkMailerTemplates(callback func(path, name string, d fs.DirEntry, err error) error) error {
+ if err := walkAssetDir(filepath.Join(setting.CustomPath, "templates", "mail"), false, callback); err != nil && !os.IsNotExist(err) {
+ return err
}
-
- for _, assetPath := range AssetNames() {
- if !strings.HasPrefix(assetPath, "mail/") {
- continue
- }
-
- if !strings.HasSuffix(assetPath, ".tmpl") {
- continue
- }
-
- content, err := Asset(assetPath)
- if err != nil {
- log.Warn("Failed to read embedded %s template. %v", assetPath, err)
- continue
- }
-
- buildSubjectBodyTemplate(subjectTemplates,
- bodyTemplates,
- strings.TrimPrefix(
- strings.TrimSuffix(
- assetPath,
- ".tmpl",
- ),
- "mail/",
- ),
- content)
- }
-
- customDir := path.Join(setting.CustomPath, "templates", "mail")
- isDir, err := util.IsDir(customDir)
- if err != nil {
- log.Warn("Failed to check if custom directory %s is a directory. %v", err)
- }
- if isDir {
- files, err := util.StatDir(customDir)
-
- if err != nil {
- log.Warn("Failed to read %s templates dir. %v", customDir, err)
- } else {
- for _, filePath := range files {
- if !strings.HasSuffix(filePath, ".tmpl") {
- continue
- }
-
- content, err := os.ReadFile(path.Join(customDir, filePath))
- if err != nil {
- log.Warn("Failed to read custom %s template. %v", filePath, err)
- continue
- }
-
- buildSubjectBodyTemplate(subjectTemplates,
- bodyTemplates,
- strings.TrimSuffix(
- filePath,
- ".tmpl",
- ),
- content)
- }
- }
- }
-
- return subjectTemplates, bodyTemplates
+ return nil
}
-func Asset(name string) ([]byte, error) {
+// BuiltinAsset reads the provided asset from the builtin embedded assets
+func BuiltinAsset(name string) ([]byte, error) {
f, err := Assets.Open("/" + name)
if err != nil {
return nil, err
@@ -137,7 +83,8 @@ func Asset(name string) ([]byte, error) {
return io.ReadAll(f)
}
-func AssetNames() []string {
+// BuiltinAssetNames returns the names of the built-in embedded assets
+func BuiltinAssetNames() []string {
realFS := Assets.(vfsgen۰FS)
results := make([]string, 0, len(realFS))
for k := range realFS {
@@ -146,7 +93,8 @@ func AssetNames() []string {
return results
}
-func AssetIsDir(name string) (bool, error) {
+// BuiltinAssetIsDir returns if a provided asset is a directory
+func BuiltinAssetIsDir(name string) (bool, error) {
if f, err := Assets.Open("/" + name); err != nil {
return false, err
} else {
diff --git a/modules/timeutil/since_test.go b/modules/timeutil/since_test.go
index 8bdb9d7546a39..dac014ee0531b 100644
--- a/modules/timeutil/since_test.go
+++ b/modules/timeutil/since_test.go
@@ -5,6 +5,7 @@
package timeutil
import (
+ "context"
"fmt"
"os"
"testing"
@@ -31,7 +32,7 @@ func TestMain(m *testing.M) {
setting.Names = []string{"english"}
setting.Langs = []string{"en-US"}
// setup
- translation.InitLocales()
+ translation.InitLocales(context.Background())
BaseDate = time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC)
// run the tests
diff --git a/modules/translation/i18n/errors.go b/modules/translation/i18n/errors.go
new file mode 100644
index 0000000000000..b485badd1d2b9
--- /dev/null
+++ b/modules/translation/i18n/errors.go
@@ -0,0 +1,12 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package i18n
+
+import "errors"
+
+var (
+ ErrLocaleAlreadyExist = errors.New("lang already exists")
+ ErrUncertainArguments = errors.New("arguments to i18n should not contain uncertain slices")
+)
diff --git a/modules/translation/i18n/format.go b/modules/translation/i18n/format.go
new file mode 100644
index 0000000000000..3fb9e6d6d05fa
--- /dev/null
+++ b/modules/translation/i18n/format.go
@@ -0,0 +1,42 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package i18n
+
+import (
+ "fmt"
+ "reflect"
+)
+
+// Format formats provided arguments for a given translated message
+func Format(format string, args ...interface{}) (msg string, err error) {
+ if len(args) == 0 {
+ return format, nil
+ }
+
+ fmtArgs := make([]interface{}, 0, len(args))
+ for _, arg := range args {
+ val := reflect.ValueOf(arg)
+ if val.Kind() == reflect.Slice {
+ // Previously, we would accept Tr(lang, key, a, [b, c], d, [e, f]) as Sprintf(msg, a, b, c, d, e, f)
+ // but this is an unstable behavior.
+ //
+ // So we restrict the accepted arguments to either:
+ //
+ // 1. Tr(lang, key, [slice-items]) as Sprintf(msg, items...)
+ // 2. Tr(lang, key, args...) as Sprintf(msg, args...)
+ if len(args) == 1 {
+ for i := 0; i < val.Len(); i++ {
+ fmtArgs = append(fmtArgs, val.Index(i).Interface())
+ }
+ } else {
+ err = ErrUncertainArguments
+ break
+ }
+ } else {
+ fmtArgs = append(fmtArgs, arg)
+ }
+ }
+ return fmt.Sprintf(format, fmtArgs...), err
+}
diff --git a/modules/translation/i18n/i18n.go b/modules/translation/i18n/i18n.go
index acce5f19fb0dc..23b4e23c76446 100644
--- a/modules/translation/i18n/i18n.go
+++ b/modules/translation/i18n/i18n.go
@@ -5,203 +5,48 @@
package i18n
import (
- "errors"
- "fmt"
- "os"
- "reflect"
- "sync"
- "time"
-
- "code.gitea.io/gitea/modules/log"
- "code.gitea.io/gitea/modules/setting"
-
- "gopkg.in/ini.v1"
-)
-
-var (
- ErrLocaleAlreadyExist = errors.New("lang already exists")
-
- DefaultLocales = NewLocaleStore(true)
+ "io"
)
-type locale struct {
- store *LocaleStore
- langName string
- textMap map[int]string // the map key (idx) is generated by store's textIdxMap
+var DefaultLocales = NewLocaleStore()
- sourceFileName string
- sourceFileInfo os.FileInfo
- lastReloadCheckTime time.Time
+type Locale interface {
+ // Tr translates a given key and arguments for a language
+ Tr(trKey string, trArgs ...interface{}) string
+ // Has reports if a locale has a translation for a given key
+ Has(trKey string) bool
}
-type LocaleStore struct {
- reloadMu *sync.Mutex // for non-prod(dev), use a mutex for live-reload. for prod, no mutex, no live-reload.
-
- langNames []string
- langDescs []string
+// LocaleStore provides the functions common to all locale stores
+type LocaleStore interface {
+ io.Closer
- localeMap map[string]*locale
- textIdxMap map[string]int
-
- defaultLang string
-}
-
-func NewLocaleStore(isProd bool) *LocaleStore {
- ls := &LocaleStore{localeMap: make(map[string]*locale), textIdxMap: make(map[string]int)}
- if !isProd {
- ls.reloadMu = &sync.Mutex{}
- }
- return ls
-}
-
-// AddLocaleByIni adds locale by ini into the store
-// if source is a string, then the file is loaded. in dev mode, the file can be live-reloaded
-// if source is a []byte, then the content is used
-func (ls *LocaleStore) AddLocaleByIni(langName, langDesc string, source interface{}) error {
- if _, ok := ls.localeMap[langName]; ok {
- return ErrLocaleAlreadyExist
- }
-
- lc := &locale{store: ls, langName: langName}
- if fileName, ok := source.(string); ok {
- lc.sourceFileName = fileName
- lc.sourceFileInfo, _ = os.Stat(fileName) // live-reload only works for regular files. the error can be ignored
- }
-
- ls.langNames = append(ls.langNames, langName)
- ls.langDescs = append(ls.langDescs, langDesc)
- ls.localeMap[lc.langName] = lc
-
- return ls.reloadLocaleByIni(langName, source)
-}
-
-func (ls *LocaleStore) reloadLocaleByIni(langName string, source interface{}) error {
- iniFile, err := ini.LoadSources(ini.LoadOptions{
- IgnoreInlineComment: true,
- UnescapeValueCommentSymbols: true,
- }, source)
- if err != nil {
- return fmt.Errorf("unable to load ini: %w", err)
- }
- iniFile.BlockMode = false
-
- lc := ls.localeMap[langName]
- lc.textMap = make(map[int]string)
- for _, section := range iniFile.Sections() {
- for _, key := range section.Keys() {
- var trKey string
- if section.Name() == "" || section.Name() == "DEFAULT" {
- trKey = key.Name()
- } else {
- trKey = section.Name() + "." + key.Name()
- }
- textIdx, ok := ls.textIdxMap[trKey]
- if !ok {
- textIdx = len(ls.textIdxMap)
- ls.textIdxMap[trKey] = textIdx
- }
- lc.textMap[textIdx] = key.Value()
- }
- }
- iniFile = nil
- return nil
-}
-
-func (ls *LocaleStore) HasLang(langName string) bool {
- _, ok := ls.localeMap[langName]
- return ok
-}
-
-func (ls *LocaleStore) ListLangNameDesc() (names, desc []string) {
- return ls.langNames, ls.langDescs
-}
-
-// SetDefaultLang sets default language as a fallback
-func (ls *LocaleStore) SetDefaultLang(lang string) {
- ls.defaultLang = lang
-}
-
-// Tr translates content to target language. fall back to default language.
-func (ls *LocaleStore) Tr(lang, trKey string, trArgs ...interface{}) string {
- l, ok := ls.localeMap[lang]
- if !ok {
- l, ok = ls.localeMap[ls.defaultLang]
- }
- if ok {
- return l.Tr(trKey, trArgs...)
- }
- return trKey
-}
-
-// Tr translates content to locale language. fall back to default language.
-func (l *locale) Tr(trKey string, trArgs ...interface{}) string {
- if l.store.reloadMu != nil {
- l.store.reloadMu.Lock()
- defer l.store.reloadMu.Unlock()
- now := time.Now()
- if now.Sub(l.lastReloadCheckTime) >= time.Second && l.sourceFileInfo != nil && l.sourceFileName != "" {
- l.lastReloadCheckTime = now
- if sourceFileInfo, err := os.Stat(l.sourceFileName); err == nil && !sourceFileInfo.ModTime().Equal(l.sourceFileInfo.ModTime()) {
- if err = l.store.reloadLocaleByIni(l.langName, l.sourceFileName); err == nil {
- l.sourceFileInfo = sourceFileInfo
- } else {
- log.Error("unable to live-reload the locale file %q, err: %v", l.sourceFileName, err)
- }
- }
- }
- }
- msg, _ := l.tryTr(trKey, trArgs...)
- return msg
-}
-
-func (l *locale) tryTr(trKey string, trArgs ...interface{}) (msg string, found bool) {
- trMsg := trKey
- textIdx, ok := l.store.textIdxMap[trKey]
- if ok {
- if msg, found = l.textMap[textIdx]; found {
- trMsg = msg // use current translation
- } else if l.langName != l.store.defaultLang {
- if def, ok := l.store.localeMap[l.store.defaultLang]; ok {
- return def.tryTr(trKey, trArgs...)
- }
- } else if !setting.IsProd {
- log.Error("missing i18n translation key: %q", trKey)
- }
- }
-
- if len(trArgs) > 0 {
- fmtArgs := make([]interface{}, 0, len(trArgs))
- for _, arg := range trArgs {
- val := reflect.ValueOf(arg)
- if val.Kind() == reflect.Slice {
- // before, it can accept Tr(lang, key, a, [b, c], d, [e, f]) as Sprintf(msg, a, b, c, d, e, f), it's an unstable behavior
- // now, we restrict the strange behavior and only support:
- // 1. Tr(lang, key, [slice-items]) as Sprintf(msg, items...)
- // 2. Tr(lang, key, args...) as Sprintf(msg, args...)
- if len(trArgs) == 1 {
- for i := 0; i < val.Len(); i++ {
- fmtArgs = append(fmtArgs, val.Index(i).Interface())
- }
- } else {
- log.Error("the args for i18n shouldn't contain uncertain slices, key=%q, args=%v", trKey, trArgs)
- break
- }
- } else {
- fmtArgs = append(fmtArgs, arg)
- }
- }
- return fmt.Sprintf(trMsg, fmtArgs...), found
- }
- return trMsg, found
+ // Tr translates a given key and arguments for a language
+ Tr(lang, trKey string, trArgs ...interface{}) string
+ // Has reports if a locale has a translation for a given key
+ Has(lang, trKey string) bool
+ // SetDefaultLang sets the default language to fall back to
+ SetDefaultLang(lang string)
+ // ListLangNameDesc provides paired slices of language names to descriptors
+ ListLangNameDesc() (names, desc []string)
+ // Locale return the locale for the provided language or the default language if not found
+ Locale(langName string) (Locale, bool)
+ // HasLang returns whether a given language is present in the store
+ HasLang(langName string) bool
+ // AddLocaleByIni adds a new language to the store
+ AddLocaleByIni(langName, langDesc string, source interface{}) error
}
// ResetDefaultLocales resets the current default locales
// NOTE: this is not synchronized
-func ResetDefaultLocales(isProd bool) {
- DefaultLocales = NewLocaleStore(isProd)
+func ResetDefaultLocales() {
+ if DefaultLocales != nil {
+ _ = DefaultLocales.Close()
+ }
+ DefaultLocales = NewLocaleStore()
}
-// Tr use default locales to translate content to target language.
-func Tr(lang, trKey string, trArgs ...interface{}) string {
- return DefaultLocales.Tr(lang, trKey, trArgs...)
+// GetLocales returns the locale from the default locales
+func GetLocale(lang string) (Locale, bool) {
+ return DefaultLocales.Locale(lang)
}
diff --git a/modules/translation/i18n/i18n_test.go b/modules/translation/i18n/i18n_test.go
index 32f7585b322e0..7940e59c940a3 100644
--- a/modules/translation/i18n/i18n_test.go
+++ b/modules/translation/i18n/i18n_test.go
@@ -27,36 +27,34 @@ fmt = %[2]s %[1]s
sub = Changed Sub String
`)
- for _, isProd := range []bool{true, false} {
- ls := NewLocaleStore(isProd)
- assert.NoError(t, ls.AddLocaleByIni("lang1", "Lang1", testData1))
- assert.NoError(t, ls.AddLocaleByIni("lang2", "Lang2", testData2))
- ls.SetDefaultLang("lang1")
+ ls := NewLocaleStore()
+ assert.NoError(t, ls.AddLocaleByIni("lang1", "Lang1", testData1))
+ assert.NoError(t, ls.AddLocaleByIni("lang2", "Lang2", testData2))
+ ls.SetDefaultLang("lang1")
- result := ls.Tr("lang1", "fmt", "a", "b")
- assert.Equal(t, "a b", result)
+ result := ls.Tr("lang1", "fmt", "a", "b")
+ assert.Equal(t, "a b", result)
- result = ls.Tr("lang2", "fmt", "a", "b")
- assert.Equal(t, "b a", result)
+ result = ls.Tr("lang2", "fmt", "a", "b")
+ assert.Equal(t, "b a", result)
- result = ls.Tr("lang1", "section.sub")
- assert.Equal(t, "Sub String", result)
+ result = ls.Tr("lang1", "section.sub")
+ assert.Equal(t, "Sub String", result)
- result = ls.Tr("lang2", "section.sub")
- assert.Equal(t, "Changed Sub String", result)
+ result = ls.Tr("lang2", "section.sub")
+ assert.Equal(t, "Changed Sub String", result)
- result = ls.Tr("", ".dot.name")
- assert.Equal(t, "Dot Name", result)
+ result = ls.Tr("", ".dot.name")
+ assert.Equal(t, "Dot Name", result)
- result = ls.Tr("lang2", "section.mixed")
- assert.Equal(t, `test value; more text`, result)
+ result = ls.Tr("lang2", "section.mixed")
+ assert.Equal(t, `test value; more text`, result)
- langs, descs := ls.ListLangNameDesc()
- assert.Equal(t, []string{"lang1", "lang2"}, langs)
- assert.Equal(t, []string{"Lang1", "Lang2"}, descs)
+ langs, descs := ls.ListLangNameDesc()
+ assert.Equal(t, []string{"lang1", "lang2"}, langs)
+ assert.Equal(t, []string{"Lang1", "Lang2"}, descs)
- result, found := ls.localeMap["lang1"].tryTr("no-such")
- assert.Equal(t, "no-such", result)
- assert.False(t, found)
- }
+ found := ls.Has("lang1", "no-such")
+ assert.False(t, found)
+ assert.NoError(t, ls.Close())
}
diff --git a/modules/translation/i18n/localestore.go b/modules/translation/i18n/localestore.go
new file mode 100644
index 0000000000000..4388d2c76dd7b
--- /dev/null
+++ b/modules/translation/i18n/localestore.go
@@ -0,0 +1,161 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package i18n
+
+import (
+ "fmt"
+
+ "code.gitea.io/gitea/modules/log"
+ "gopkg.in/ini.v1"
+)
+
+// This file implements the static LocaleStore that will not watch for changes
+
+type locale struct {
+ store *localeStore
+ langName string
+ idxToMsgMap map[int]string // the map idx is generated by store's trKeyToIdxMap
+}
+
+type localeStore struct {
+ // After initializing has finished, these fields are read-only.
+ langNames []string
+ langDescs []string
+
+ localeMap map[string]*locale
+ trKeyToIdxMap map[string]int
+
+ defaultLang string
+}
+
+// NewLocaleStore creates a static locale store
+func NewLocaleStore() LocaleStore {
+ return &localeStore{localeMap: make(map[string]*locale), trKeyToIdxMap: make(map[string]int)}
+}
+
+// AddLocaleByIni adds locale by ini into the store
+// if source is a string, then the file is loaded
+// if source is a []byte, then the content is used
+func (store *localeStore) AddLocaleByIni(langName, langDesc string, source interface{}) error {
+ if _, ok := store.localeMap[langName]; ok {
+ return ErrLocaleAlreadyExist
+ }
+
+ store.langNames = append(store.langNames, langName)
+ store.langDescs = append(store.langDescs, langDesc)
+
+ l := &locale{store: store, langName: langName, idxToMsgMap: make(map[int]string)}
+ store.localeMap[l.langName] = l
+
+ iniFile, err := ini.LoadSources(ini.LoadOptions{
+ IgnoreInlineComment: true,
+ UnescapeValueCommentSymbols: true,
+ }, source)
+ if err != nil {
+ return fmt.Errorf("unable to load ini: %w", err)
+ }
+ iniFile.BlockMode = false
+
+ for _, section := range iniFile.Sections() {
+ for _, key := range section.Keys() {
+ var trKey string
+ if section.Name() == "" || section.Name() == "DEFAULT" {
+ trKey = key.Name()
+ } else {
+ trKey = section.Name() + "." + key.Name()
+ }
+ idx, ok := store.trKeyToIdxMap[trKey]
+ if !ok {
+ idx = len(store.trKeyToIdxMap)
+ store.trKeyToIdxMap[trKey] = idx
+ }
+ l.idxToMsgMap[idx] = key.Value()
+ }
+ }
+ iniFile = nil
+
+ return nil
+}
+
+func (store *localeStore) HasLang(langName string) bool {
+ _, ok := store.localeMap[langName]
+ return ok
+}
+
+func (store *localeStore) ListLangNameDesc() (names, desc []string) {
+ return store.langNames, store.langDescs
+}
+
+// SetDefaultLang sets default language as a fallback
+func (store *localeStore) SetDefaultLang(lang string) {
+ store.defaultLang = lang
+}
+
+// Tr translates content to target language. fall back to default language.
+func (store *localeStore) Tr(lang, trKey string, trArgs ...interface{}) string {
+ l, _ := store.Locale(lang)
+
+ if l != nil {
+ return l.Tr(trKey, trArgs...)
+ }
+ return trKey
+}
+
+// Has returns whether the given language has a translation for the provided key
+func (store *localeStore) Has(lang, trKey string) bool {
+ l, _ := store.Locale(lang)
+
+ if l != nil {
+ return false
+ }
+ return l.Has(trKey)
+}
+
+// Locale returns the locale for the lang or the default language
+func (store *localeStore) Locale(lang string) (l Locale, found bool) {
+ l, found = store.localeMap[lang]
+ if !found {
+ l = store.localeMap[store.defaultLang]
+ }
+ return l, found
+}
+
+// Close implements io.Closer
+func (store *localeStore) Close() error {
+ return nil
+}
+
+// Tr translates content to locale language. fall back to default language.
+func (l *locale) Tr(trKey string, trArgs ...interface{}) string {
+ format := trKey
+
+ idx, ok := l.store.trKeyToIdxMap[trKey]
+ if ok {
+ if msg, ok := l.idxToMsgMap[idx]; ok {
+ format = msg // use the found translation
+ } else if def, ok := l.store.localeMap[l.store.defaultLang]; ok {
+ // try to use default locale's translation
+ if msg, ok := def.idxToMsgMap[idx]; ok {
+ format = msg
+ }
+ }
+ }
+
+ msg, err := Format(format, trArgs...)
+ if err != nil {
+ log.Error("Error whilst formatting %q in %s: %v", trKey, l.langName, err)
+ }
+ return msg
+}
+
+// Has returns whether a key is present in this locale or not
+func (l *locale) Has(trKey string) bool {
+ idx, ok := l.store.trKeyToIdxMap[trKey]
+ if !ok {
+ return false
+ }
+ _, ok = l.idxToMsgMap[idx]
+ return ok
+}
diff --git a/modules/translation/translation.go b/modules/translation/translation.go
index fcc101d963435..e40a9357faefe 100644
--- a/modules/translation/translation.go
+++ b/modules/translation/translation.go
@@ -5,15 +5,16 @@
package translation
import (
- "path"
+ "context"
"sort"
"strings"
+ "sync"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/options"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/translation/i18n"
- "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/watcher"
"golang.org/x/text/language"
)
@@ -31,6 +32,7 @@ type LangType struct {
}
var (
+ lock *sync.RWMutex
matcher language.Matcher
allLangs []*LangType
allLangMap map[string]*LangType
@@ -43,58 +45,53 @@ func AllLangs() []*LangType {
}
// InitLocales loads the locales
-func InitLocales() {
- i18n.ResetDefaultLocales(setting.IsProd)
- localeNames, err := options.Dir("locale")
- if err != nil {
- log.Fatal("Failed to list locale files: %v", err)
+func InitLocales(ctx context.Context) {
+ if lock != nil {
+ lock.Lock()
+ defer lock.Unlock()
+ } else if !setting.IsProd && lock == nil {
+ lock = &sync.RWMutex{}
}
- localFiles := make(map[string]interface{}, len(localeNames))
- for _, name := range localeNames {
- if options.IsDynamic() {
- // Try to check if CustomPath has the file, otherwise fallback to StaticRootPath
- value := path.Join(setting.CustomPath, "options/locale", name)
-
- isFile, err := util.IsFile(value)
- if err != nil {
- log.Fatal("Failed to load %s locale file. %v", name, err)
- }
+ refreshLocales := func() {
+ i18n.ResetDefaultLocales()
+ localeNames, err := options.Dir("locale")
+ if err != nil {
+ log.Fatal("Failed to list locale files: %v", err)
+ }
- if isFile {
- localFiles[name] = value
- } else {
- localFiles[name] = path.Join(setting.StaticRootPath, "options/locale", name)
- }
- } else {
+ localFiles := make(map[string]interface{}, len(localeNames))
+ for _, name := range localeNames {
localFiles[name], err = options.Locale(name)
if err != nil {
log.Fatal("Failed to load %s locale file. %v", name, err)
}
}
- }
- supportedTags = make([]language.Tag, len(setting.Langs))
- for i, lang := range setting.Langs {
- supportedTags[i] = language.Raw.Make(lang)
- }
+ supportedTags = make([]language.Tag, len(setting.Langs))
+ for i, lang := range setting.Langs {
+ supportedTags[i] = language.Raw.Make(lang)
+ }
- matcher = language.NewMatcher(supportedTags)
- for i := range setting.Names {
- key := "locale_" + setting.Langs[i] + ".ini"
+ matcher = language.NewMatcher(supportedTags)
+ for i := range setting.Names {
+ key := "locale_" + setting.Langs[i] + ".ini"
- if err = i18n.DefaultLocales.AddLocaleByIni(setting.Langs[i], setting.Names[i], localFiles[key]); err != nil {
- log.Error("Failed to set messages to %s: %v", setting.Langs[i], err)
+ if err = i18n.DefaultLocales.AddLocaleByIni(setting.Langs[i], setting.Names[i], localFiles[key]); err != nil {
+ log.Error("Failed to set messages to %s: %v", setting.Langs[i], err)
+ }
}
- }
- if len(setting.Langs) != 0 {
- defaultLangName := setting.Langs[0]
- if defaultLangName != "en-US" {
- log.Info("Use the first locale (%s) in LANGS setting option as default", defaultLangName)
+ if len(setting.Langs) != 0 {
+ defaultLangName := setting.Langs[0]
+ if defaultLangName != "en-US" {
+ log.Info("Use the first locale (%s) in LANGS setting option as default", defaultLangName)
+ }
+ i18n.DefaultLocales.SetDefaultLang(defaultLangName)
}
- i18n.DefaultLocales.SetDefaultLang(defaultLangName)
}
+ refreshLocales()
+
langs, descs := i18n.DefaultLocales.ListLangNameDesc()
allLangs = make([]*LangType, 0, len(langs))
allLangMap = map[string]*LangType{}
@@ -108,6 +105,17 @@ func InitLocales() {
sort.Slice(allLangs, func(i, j int) bool {
return strings.ToLower(allLangs[i].Name) < strings.ToLower(allLangs[j].Name)
})
+
+ if !setting.IsProd {
+ watcher.CreateWatcher(ctx, "Locales", &watcher.CreateWatcherOpts{
+ PathsCallback: options.WalkLocales,
+ BetweenCallback: func() {
+ lock.Lock()
+ defer lock.Unlock()
+ refreshLocales()
+ },
+ })
+ }
}
// Match matches accept languages
@@ -118,16 +126,24 @@ func Match(tags ...language.Tag) language.Tag {
// locale represents the information of localization.
type locale struct {
+ i18n.Locale
Lang, LangName string // these fields are used directly in templates: .i18n.Lang
}
// NewLocale return a locale
func NewLocale(lang string) Locale {
+ if lock != nil {
+ lock.RLock()
+ defer lock.RUnlock()
+ }
+
langName := "unknown"
if l, ok := allLangMap[lang]; ok {
langName = l.Name
}
+ i18nLocale, _ := i18n.GetLocale(lang)
return &locale{
+ Locale: i18nLocale,
Lang: lang,
LangName: langName,
}
@@ -137,11 +153,6 @@ func (l *locale) Language() string {
return l.Lang
}
-// Tr translates content to target language.
-func (l *locale) Tr(format string, args ...interface{}) string {
- return i18n.Tr(l.Lang, format, args...)
-}
-
// Language specific rules for translating plural texts
var trNLangRules = map[string]func(int64) int{
// the default rule is "en-US" if a language isn't listed here
diff --git a/modules/watcher/watcher.go b/modules/watcher/watcher.go
new file mode 100644
index 0000000000000..8029ce1ab96cc
--- /dev/null
+++ b/modules/watcher/watcher.go
@@ -0,0 +1,104 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package watcher
+
+import (
+ "context"
+ "io/fs"
+ "os"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/process"
+ "github.com/fsnotify/fsnotify"
+)
+
+type CreateWatcherOpts struct {
+ PathsCallback func(func(path, name string, d fs.DirEntry, err error) error) error
+ BeforeCallback func()
+ BetweenCallback func()
+ AfterCallback func()
+}
+
+func CreateWatcher(ctx context.Context, desc string, opts *CreateWatcherOpts) {
+ go run(ctx, desc, opts)
+}
+
+func run(ctx context.Context, desc string, opts *CreateWatcherOpts) {
+ if opts.BeforeCallback != nil {
+ opts.BeforeCallback()
+ }
+ if opts.AfterCallback != nil {
+ defer opts.AfterCallback()
+ }
+ ctx, _, finished := process.GetManager().AddTypedContext(ctx, "Watcher: "+desc, process.SystemProcessType, true)
+ defer finished()
+
+ log.Trace("Watcher loop starting for %s", desc)
+ defer log.Trace("Watcher loop ended for %s", desc)
+
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ log.Error("Unable to create watcher for %s: %v", desc, err)
+ return
+ }
+ if err := opts.PathsCallback(func(path, _ string, _ fs.DirEntry, err error) error {
+ if err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ log.Trace("Watcher: %s watching %q", desc, path)
+ _ = watcher.Add(path)
+ return nil
+ }); err != nil {
+ log.Error("Unable to create watcher for %s: %v", desc, err)
+ _ = watcher.Close()
+ return
+ }
+
+ // Note we don't call the BetweenCallback here
+
+ for {
+ select {
+ case event, ok := <-watcher.Events:
+ if !ok {
+ _ = watcher.Close()
+ return
+ }
+ log.Debug("Watched file for %s had event: %v", desc, event)
+ case err, ok := <-watcher.Errors:
+ if !ok {
+ _ = watcher.Close()
+ return
+ }
+ log.Error("Error whilst watching files for %s: %v", desc, err)
+ case <-ctx.Done():
+ _ = watcher.Close()
+ return
+ }
+
+ // Recreate the watcher - only call the BetweenCallback after the new watcher is set-up
+ _ = watcher.Close()
+ watcher, err = fsnotify.NewWatcher()
+ if err != nil {
+ log.Error("Unable to create watcher for %s: %v", desc, err)
+ return
+ }
+ if err := opts.PathsCallback(func(path, _ string, _ fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ _ = watcher.Add(path)
+ return nil
+ }); err != nil {
+ log.Error("Unable to create watcher for %s: %v", desc, err)
+ _ = watcher.Close()
+ return
+ }
+
+ // Inform our BetweenCallback that there has been an event
+ if opts.BetweenCallback != nil {
+ opts.BetweenCallback()
+ }
+ }
+}
diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go
index b5fdc739d7c10..c4efae8bd2b84 100644
--- a/routers/api/packages/api.go
+++ b/routers/api/packages/api.go
@@ -5,6 +5,7 @@
package packages
import (
+ gocontext "context"
"net/http"
"regexp"
"strings"
@@ -37,10 +38,10 @@ func reqPackageAccess(accessMode perm.AccessMode) func(ctx *context.Context) {
}
}
-func Routes() *web.Route {
+func Routes(ctx gocontext.Context) *web.Route {
r := web.NewRoute()
- r.Use(context.PackageContexter())
+ r.Use(context.PackageContexter(ctx))
authMethods := []auth.Method{
&auth.OAuth2{},
@@ -237,10 +238,10 @@ func Routes() *web.Route {
return r
}
-func ContainerRoutes() *web.Route {
+func ContainerRoutes(ctx gocontext.Context) *web.Route {
r := web.NewRoute()
- r.Use(context.PackageContexter())
+ r.Use(context.PackageContexter(ctx))
authMethods := []auth.Method{
&auth.Basic{},
diff --git a/routers/api/packages/pypi/pypi.go b/routers/api/packages/pypi/pypi.go
index 9209c4edd5501..848fd9a148475 100644
--- a/routers/api/packages/pypi/pypi.go
+++ b/routers/api/packages/pypi/pypi.go
@@ -16,7 +16,6 @@ import (
packages_module "code.gitea.io/gitea/modules/packages"
pypi_module "code.gitea.io/gitea/modules/packages/pypi"
"code.gitea.io/gitea/modules/setting"
- "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/validation"
"code.gitea.io/gitea/routers/api/packages/helper"
packages_service "code.gitea.io/gitea/services/packages"
@@ -58,7 +57,6 @@ func PackageMetadata(ctx *context.Context) {
ctx.Data["RegistryURL"] = setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/pypi"
ctx.Data["PackageDescriptor"] = pds[0]
ctx.Data["PackageDescriptors"] = pds
- ctx.Render = templates.HTMLRenderer()
ctx.HTML(http.StatusOK, "api/packages/pypi/simple")
}
diff --git a/routers/api/v1/misc/markdown_test.go b/routers/api/v1/misc/markdown_test.go
index 9beb88be16846..7809fa5cc72a0 100644
--- a/routers/api/v1/misc/markdown_test.go
+++ b/routers/api/v1/misc/markdown_test.go
@@ -29,7 +29,7 @@ const (
)
func createContext(req *http.Request) (*context.Context, *httptest.ResponseRecorder) {
- rnd := templates.HTMLRenderer()
+ _, rnd := templates.HTMLRenderer(req.Context())
resp := httptest.NewRecorder()
c := &context.Context{
Req: req,
diff --git a/routers/init.go b/routers/init.go
index 2898c446072f1..9eb6d14d3822a 100644
--- a/routers/init.go
+++ b/routers/init.go
@@ -29,6 +29,7 @@ import (
"code.gitea.io/gitea/modules/ssh"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/svg"
+ "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
@@ -114,7 +115,7 @@ func GlobalInitInstalled(ctx context.Context) {
log.Info("Run Mode: %s", util.ToTitleCase(setting.RunMode))
// Setup i18n
- translation.InitLocales()
+ translation.InitLocales(ctx)
setting.NewServices()
mustInit(storage.Init)
@@ -171,18 +172,19 @@ func GlobalInitInstalled(ctx context.Context) {
}
// NormalRoutes represents non install routes
-func NormalRoutes() *web.Route {
+func NormalRoutes(ctx context.Context) *web.Route {
+ ctx, _ = templates.HTMLRenderer(ctx)
r := web.NewRoute()
for _, middle := range common.Middlewares() {
r.Use(middle)
}
- r.Mount("/", web_routers.Routes())
+ r.Mount("/", web_routers.Routes(ctx))
r.Mount("/api/v1", apiv1.Routes())
r.Mount("/api/internal", private.Routes())
if setting.Packages.Enabled {
- r.Mount("/api/packages", packages_router.Routes())
- r.Mount("/v2", packages_router.ContainerRoutes())
+ r.Mount("/api/packages", packages_router.Routes(ctx))
+ r.Mount("/v2", packages_router.ContainerRoutes(ctx))
}
return r
}
diff --git a/routers/install/install.go b/routers/install/install.go
index 27c3509fdec51..7483d14d255a3 100644
--- a/routers/install/install.go
+++ b/routers/install/install.go
@@ -6,6 +6,7 @@
package install
import (
+ goctx "context"
"fmt"
"net/http"
"os"
@@ -51,39 +52,41 @@ func getSupportedDbTypeNames() (dbTypeNames []map[string]string) {
}
// Init prepare for rendering installation page
-func Init(next http.Handler) http.Handler {
- rnd := templates.HTMLRenderer()
+func Init(ctx goctx.Context) func(next http.Handler) http.Handler {
+ _, rnd := templates.HTMLRenderer(ctx)
dbTypeNames := getSupportedDbTypeNames()
- return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
- if setting.InstallLock {
- resp.Header().Add("Refresh", "1; url="+setting.AppURL+"user/login")
- _ = rnd.HTML(resp, http.StatusOK, string(tplPostInstall), nil)
- return
- }
- locale := middleware.Locale(resp, req)
- startTime := time.Now()
- ctx := context.Context{
- Resp: context.NewResponse(resp),
- Flash: &middleware.Flash{},
- Locale: locale,
- Render: rnd,
- Session: session.GetSession(req),
- Data: map[string]interface{}{
- "locale": locale,
- "Title": locale.Tr("install.install"),
- "PageIsInstall": true,
- "DbTypeNames": dbTypeNames,
- "AllLangs": translation.AllLangs(),
- "PageStartTime": startTime,
-
- "PasswordHashAlgorithms": user_model.AvailableHashAlgorithms,
- },
- }
- defer ctx.Close()
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
+ if setting.InstallLock {
+ resp.Header().Add("Refresh", "1; url="+setting.AppURL+"user/login")
+ _ = rnd.HTML(resp, http.StatusOK, string(tplPostInstall), nil)
+ return
+ }
+ locale := middleware.Locale(resp, req)
+ startTime := time.Now()
+ ctx := context.Context{
+ Resp: context.NewResponse(resp),
+ Flash: &middleware.Flash{},
+ Locale: locale,
+ Render: rnd,
+ Session: session.GetSession(req),
+ Data: map[string]interface{}{
+ "locale": locale,
+ "Title": locale.Tr("install.install"),
+ "PageIsInstall": true,
+ "DbTypeNames": dbTypeNames,
+ "AllLangs": translation.AllLangs(),
+ "PageStartTime": startTime,
+
+ "PasswordHashAlgorithms": user_model.AvailableHashAlgorithms,
+ },
+ }
+ defer ctx.Close()
- ctx.Req = context.WithContext(req, &ctx)
- next.ServeHTTP(resp, ctx.Req)
- })
+ ctx.Req = context.WithContext(req, &ctx)
+ next.ServeHTTP(resp, ctx.Req)
+ })
+ }
}
// Install render installation page
diff --git a/routers/install/routes.go b/routers/install/routes.go
index 32829ede9e26f..682fa2bfb5360 100644
--- a/routers/install/routes.go
+++ b/routers/install/routes.go
@@ -5,6 +5,7 @@
package install
import (
+ goctx "context"
"fmt"
"net/http"
"path"
@@ -28,8 +29,8 @@ func (d *dataStore) GetData() map[string]interface{} {
return *d
}
-func installRecovery() func(next http.Handler) http.Handler {
- rnd := templates.HTMLRenderer()
+func installRecovery(ctx goctx.Context) func(next http.Handler) http.Handler {
+ _, rnd := templates.HTMLRenderer(ctx)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
defer func() {
@@ -80,7 +81,7 @@ func installRecovery() func(next http.Handler) http.Handler {
}
// Routes registers the install routes
-func Routes() *web.Route {
+func Routes(ctx goctx.Context) *web.Route {
r := web.NewRoute()
for _, middle := range common.Middlewares() {
r.Use(middle)
@@ -103,7 +104,7 @@ func Routes() *web.Route {
Domain: setting.SessionConfig.Domain,
}))
- r.Use(installRecovery())
+ r.Use(installRecovery(ctx))
r.Use(Init)
r.Get("/", Install)
r.Post("/", web.Bind(forms.InstallForm{}), SubmitInstall)
diff --git a/routers/install/routes_test.go b/routers/install/routes_test.go
index 29003c3841beb..e69d2d15dfafe 100644
--- a/routers/install/routes_test.go
+++ b/routers/install/routes_test.go
@@ -5,13 +5,16 @@
package install
import (
+ "context"
"testing"
"github.com/stretchr/testify/assert"
)
func TestRoutes(t *testing.T) {
- routes := Routes()
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ routes := Routes(ctx)
assert.NotNil(t, routes)
assert.EqualValues(t, "/", routes.R.Routes()[0].Pattern)
assert.Nil(t, routes.R.Routes()[0].SubRoutes)
diff --git a/routers/install/setting.go b/routers/install/setting.go
index cf0a01ce31f57..c4912f1124f8a 100644
--- a/routers/install/setting.go
+++ b/routers/install/setting.go
@@ -24,7 +24,7 @@ func PreloadSettings(ctx context.Context) bool {
log.Info("Log path: %s", setting.LogRootPath)
log.Info("Configuration file: %s", setting.CustomConf)
log.Info("Prepare to run install page")
- translation.InitLocales()
+ translation.InitLocales(ctx)
if setting.EnableSQLite3 {
log.Info("SQLite3 is supported")
}
diff --git a/routers/web/base.go b/routers/web/base.go
index c7ade55a61f6f..2dacedb21be6b 100644
--- a/routers/web/base.go
+++ b/routers/web/base.go
@@ -5,6 +5,7 @@
package web
import (
+ goctx "context"
"errors"
"fmt"
"io"
@@ -123,8 +124,8 @@ func (d *dataStore) GetData() map[string]interface{} {
// Recovery returns a middleware that recovers from any panics and writes a 500 and a log if so.
// This error will be created with the gitea 500 page.
-func Recovery() func(next http.Handler) http.Handler {
- rnd := templates.HTMLRenderer()
+func Recovery(ctx goctx.Context) func(next http.Handler) http.Handler {
+ _, rnd := templates.HTMLRenderer(ctx)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
defer func() {
diff --git a/routers/web/web.go b/routers/web/web.go
index 1b6dd03bc8a84..f10935b7151ca 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -98,7 +98,7 @@ func buildAuthGroup() *auth_service.Group {
}
// Routes returns all web routes
-func Routes() *web.Route {
+func Routes(ctx gocontext.Context) *web.Route {
routes := web.NewRoute()
routes.Use(web.WrapWithPrefix(public.AssetsURLPathPrefix, public.AssetsHandlerFunc(&public.Options{
@@ -120,7 +120,9 @@ func Routes() *web.Route {
})
routes.Use(sessioner)
- routes.Use(Recovery())
+ ctx, _ = templates.HTMLRenderer(ctx)
+
+ routes.Use(Recovery(ctx))
// We use r.Route here over r.Use because this prevents requests that are not for avatars having to go through this additional handler
routes.Route("/avatars/*", "GET, HEAD", storageHandler(setting.Avatar.Storage, "avatars", storage.Avatars))
@@ -151,7 +153,7 @@ func Routes() *web.Route {
common = append(common, h)
}
- mailer.InitMailRender(templates.Mailer())
+ mailer.InitMailRender(templates.Mailer(ctx))
if setting.Service.EnableCaptcha {
// The captcha http.Handler should only fire on /captcha/* so we can just mount this on that url
@@ -195,7 +197,7 @@ func Routes() *web.Route {
routes.Get("/api/healthz", healthcheck.Check)
// Removed: toolbox.Toolboxer middleware will provide debug information which seems unnecessary
- common = append(common, context.Contexter())
+ common = append(common, context.Contexter(ctx))
group := buildAuthGroup()
if err := group.Init(); err != nil {