feat: load declared plugin locale catalogs

This commit is contained in:
2026-07-11 02:02:47 +08:00
parent 15f1f413d0
commit 8b1151e03a
4 changed files with 242 additions and 24 deletions
+70 -7
View File
@@ -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) {
+59
View File
@@ -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"})