feat: load declared plugin locale catalogs
This commit is contained in:
+70
-7
@@ -2080,18 +2080,81 @@ func (a *App) GetPluginFrontendInfo(pluginID string) map[string]interface{} {
|
||||
return map[string]interface{}{"status": "no-frontend"}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"pluginId": p.Manifest.ID,
|
||||
"name": p.Manifest.Name,
|
||||
"icon": p.Manifest.Icon,
|
||||
"version": p.Manifest.Version,
|
||||
"entry": p.Manifest.Frontend.Entry,
|
||||
"style": p.Manifest.Frontend.Style,
|
||||
"rootPath": p.RootPath,
|
||||
"pluginId": p.Manifest.ID,
|
||||
"name": p.Manifest.Name,
|
||||
"icon": p.Manifest.Icon,
|
||||
"version": p.Manifest.Version,
|
||||
"entry": p.Manifest.Frontend.Entry,
|
||||
"style": p.Manifest.Frontend.Style,
|
||||
"localization": p.Manifest.Localization,
|
||||
"rootPath": p.RootPath,
|
||||
}
|
||||
}
|
||||
return map[string]interface{}{"status": "not-found"}
|
||||
}
|
||||
|
||||
// GetPluginLocalization reads a locale catalog declared by the plugin manifest.
|
||||
func (a *App) GetPluginLocalization(pluginID, locale string) (map[string]string, string) {
|
||||
var selected *plugin.Plugin
|
||||
for i := range a.plugins {
|
||||
if a.plugins[i].Manifest.ID == pluginID {
|
||||
selected = &a.plugins[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if selected == nil {
|
||||
return nil, "plugin not found"
|
||||
}
|
||||
localization := selected.Manifest.Localization
|
||||
if localization == nil {
|
||||
return nil, "plugin does not declare localization"
|
||||
}
|
||||
catalogPath, ok := localization.Locales[locale]
|
||||
if !ok {
|
||||
return nil, fmt.Sprintf("locale %q is not declared", locale)
|
||||
}
|
||||
if err := plugin.ValidateLocalizationPath(catalogPath); err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
|
||||
absRoot, err := filepath.Abs(selected.RootPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Sprintf("resolve plugin root: %v", err)
|
||||
}
|
||||
absPath, err := filepath.Abs(filepath.Join(absRoot, filepath.FromSlash(catalogPath)))
|
||||
if err != nil {
|
||||
return nil, fmt.Sprintf("resolve catalog path: %v", err)
|
||||
}
|
||||
if !pathInsideRoot(absRoot, absPath) {
|
||||
return nil, "catalog path escapes plugin root"
|
||||
}
|
||||
resolvedPath, err := filepath.EvalSymlinks(absPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Sprintf("failed to resolve catalog: %v", err)
|
||||
}
|
||||
if !pathInsideRoot(absRoot, resolvedPath) {
|
||||
return nil, "catalog path escapes plugin root"
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(resolvedPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Sprintf("failed to read catalog: %v", err)
|
||||
}
|
||||
catalog := map[string]string{}
|
||||
if err := json.Unmarshal(data, &catalog); err != nil {
|
||||
return nil, fmt.Sprintf("failed to parse catalog: %v", err)
|
||||
}
|
||||
return catalog, ""
|
||||
}
|
||||
|
||||
func pathInsideRoot(root, candidate string) bool {
|
||||
relative, err := filepath.Rel(root, candidate)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
|
||||
}
|
||||
|
||||
// GetPluginAssetContent reads a frontend asset file from a plugin directory.
|
||||
// Security: validates that the assetPath is relative and does not escape the plugin root.
|
||||
func (a *App) GetPluginAssetContent(pluginID, assetPath string) (string, string) {
|
||||
|
||||
@@ -343,6 +343,65 @@ func TestGetPluginAssetContent_NonexistentFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPluginLocalizationReadsDeclaredCatalog(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "locales"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "locales", "ru.json"), []byte(`{"greeting":"Привет","count":"Всего: {count}"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := newTestApp(root)
|
||||
app.plugins[0].Manifest.Localization = &plugin.LocalizationConfig{
|
||||
DefaultLocale: "en",
|
||||
Locales: map[string]string{
|
||||
"en": "locales/en.json",
|
||||
"ru": "locales/ru.json",
|
||||
},
|
||||
}
|
||||
|
||||
catalog, errStr := app.GetPluginLocalization("test.plugin", "ru")
|
||||
if errStr != "" {
|
||||
t.Fatalf("GetPluginLocalization: %s", errStr)
|
||||
}
|
||||
if catalog["greeting"] != "Привет" || catalog["count"] != "Всего: {count}" {
|
||||
t.Fatalf("catalog = %#v", catalog)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPluginLocalizationRejectsInvalidCatalogs(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "locales"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "locales", "bad.json"), []byte(`{"valid":"text","invalid":42}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := newTestApp(root)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config *plugin.LocalizationConfig
|
||||
locale string
|
||||
want string
|
||||
}{
|
||||
{"missing declaration", nil, "ru", "does not declare"},
|
||||
{"unknown locale", &plugin.LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": "locales/en.json"}}, "ru", "not declared"},
|
||||
{"traversal", &plugin.LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": "../outside.json"}}, "en", "traversal"},
|
||||
{"backslash", &plugin.LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": `locales\bad.json`}}, "en", "backslash"},
|
||||
{"non string value", &plugin.LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": "locales/bad.json"}}, "en", "parse"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
app.plugins[0].Manifest.Localization = tc.config
|
||||
_, errStr := app.GetPluginLocalization("test.plugin", tc.locale)
|
||||
if !strings.Contains(errStr, tc.want) {
|
||||
t.Fatalf("error = %q, want substring %q", errStr, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesBridgeReadWriteListMoveTrash(t *testing.T) {
|
||||
app, root := newFilesTestApp(t, []string{"files.read", "files.write", "files.delete"})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user