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 {