* feat: add plugin system * fix db docker build * fix hammerhead readme, add strava subscription news to docs * fixes and sdk improvements * fix: reduce Meilisearch load, debounce federation sync (#1012) * optimize meili trail index * several fixes --------- Co-authored-by: Flomp <Flomp@users.noreply.github.com> * Bump svelte from 5.55.5 to 5.56.0 in /docs (#1032) Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 5.55.5 to 5.56.0. - [Release notes](https://github.com/sveltejs/svelte/releases) - [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md) - [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.56.0/packages/svelte) --- updated-dependencies: - dependency-name: svelte dependency-version: 5.56.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Flomp <Flomp@users.noreply.github.com> * Release v0.19.2 (#1035) * chore: release v0.19.2 * add changelog --------- Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com> Co-authored-by: Christian Beutel <> * speed up plugin sync and several small fixes * concepts for security improvements and process stability * improve concept * security concept implemented * remove insecure TLS * worker concept implemented * fixes and cleanup * fixes * docu * mermaid, namings * WASM plugin host improvements, plugin logging * fix db migration * Improve plugin config and category mapping UI * fixes * further fixes * remove manual test sync * fix db migration and strava mapping * type added, UI improvements * fix plugin card toggle clickable area * optimize synch status card layout * plugin type 'trails' instead of 'integration' * session auth validation in UI * fix komoot date and waypoints * improve category mapping * fix send to hammerhead: trail name * plugin setup error handling improved * fix review findings * re-mapping added * rename remote_category --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Flomp <Flomp@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com>
420 lines
12 KiB
Go
420 lines
12 KiB
Go
package pluginsystem
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"hash/fnv"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/pocketbase/dbx"
|
|
"github.com/pocketbase/pocketbase/core"
|
|
)
|
|
|
|
// Manager coordinates local plugin discovery with the installed_plugins cache.
|
|
// It is intentionally small: request hot paths should read cached manifests,
|
|
// while list/cron entrypoints refresh the cache from data/plugins first.
|
|
type Manager struct {
|
|
App core.App
|
|
Dir string
|
|
}
|
|
|
|
// PluginInfo is the UI-facing view of an installed plugin. It combines the
|
|
// static manifest with runtime availability and embedded icon data.
|
|
type PluginInfo struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Name string `json:"name"`
|
|
DisplayName string `json:"displayName,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
Icon string `json:"icon,omitempty"`
|
|
IconDark string `json:"iconDark,omitempty"`
|
|
Version string `json:"version"`
|
|
Runtime string `json:"runtime"`
|
|
Path string `json:"path"`
|
|
Capabilities []string `json:"capabilities"`
|
|
Status string `json:"status"`
|
|
Error string `json:"error,omitempty"`
|
|
Manifest Manifest `json:"manifest"`
|
|
}
|
|
|
|
// NewManager creates a manager for the configured plugin directory. Tests can
|
|
// pass a custom dir; production callers use the resolved runtime plugin
|
|
// directory.
|
|
func NewManager(app core.App, dir string) *Manager {
|
|
if dir == "" {
|
|
dir = PluginDir()
|
|
}
|
|
return &Manager{App: app, Dir: dir}
|
|
}
|
|
|
|
// ListLocalPlugins returns installed plugins in the shape consumed by the
|
|
// settings UI. It reads from installed_plugins first so listing does not need to
|
|
// parse every manifest from disk after the cache has been refreshed.
|
|
func (m *Manager) ListLocalPlugins(context.Context) ([]PluginInfo, error) {
|
|
plugins, err := LoadInstalledPlugins(m.App, m.Dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
infos := make([]PluginInfo, 0, len(plugins))
|
|
infoByPath := map[string]int{}
|
|
for _, plugin := range plugins {
|
|
status := "available"
|
|
record, _ := m.App.FindFirstRecordByFilter(
|
|
"installed_plugins",
|
|
"plugin_id={:plugin_id}",
|
|
dbx.Params{"plugin_id": plugin.Manifest.ID},
|
|
)
|
|
if record != nil && record.GetString("status") != "" {
|
|
status = record.GetString("status")
|
|
}
|
|
errorMessage := ""
|
|
if record != nil {
|
|
errorMessage = record.GetString("error")
|
|
}
|
|
icon, iconDark := pluginIcons(plugin)
|
|
infos = append(infos, PluginInfo{
|
|
ID: plugin.Manifest.ID,
|
|
Type: plugin.Manifest.Type,
|
|
Name: plugin.Manifest.Name,
|
|
DisplayName: stringMetadata(plugin.Manifest.Metadata, "displayName"),
|
|
Description: plugin.Manifest.Description,
|
|
Icon: icon,
|
|
IconDark: iconDark,
|
|
Version: plugin.Manifest.Version,
|
|
Runtime: plugin.Manifest.Runtime.Type,
|
|
Path: plugin.Dir,
|
|
Capabilities: capabilityNames(plugin.Manifest.Capabilities),
|
|
Status: status,
|
|
Error: errorMessage,
|
|
Manifest: plugin.Manifest,
|
|
})
|
|
infoByPath[filepath.Clean(plugin.Dir)] = len(infos) - 1
|
|
}
|
|
|
|
_, issues, err := DiscoverLocalPlugins(m.Dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, issue := range issues {
|
|
if index, ok := infoByPath[filepath.Clean(issue.Dir)]; ok {
|
|
infos[index].Status = "error"
|
|
infos[index].Error = issue.Error
|
|
continue
|
|
}
|
|
infos = append(infos, PluginInfo{
|
|
ID: issue.ID,
|
|
Type: PluginTypeTrails,
|
|
Name: issue.Name,
|
|
Path: issue.Dir,
|
|
Status: "error",
|
|
Error: issue.Error,
|
|
Runtime: RuntimeWASM,
|
|
Manifest: Manifest{
|
|
ID: issue.ID,
|
|
Type: PluginTypeTrails,
|
|
Name: issue.Name,
|
|
Runtime: RuntimeManifest{
|
|
Type: RuntimeWASM,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
return infos, nil
|
|
}
|
|
|
|
// pluginIcons embeds optional light/dark icon files from the plugin bundle as
|
|
// data URLs so the frontend does not need direct filesystem access.
|
|
func pluginIcons(plugin LocalPlugin) (string, string) {
|
|
icons, _ := plugin.Manifest.Metadata["icons"].(map[string]any)
|
|
return pluginIcon(plugin.Dir, stringMetadata(icons, "light")), pluginIcon(plugin.Dir, stringMetadata(icons, "dark"))
|
|
}
|
|
|
|
func stringMetadata(values map[string]any, key string) string {
|
|
value, _ := values[key].(string)
|
|
return value
|
|
}
|
|
|
|
func pluginIcon(pluginDir string, iconPath string) string {
|
|
iconPath = strings.TrimSpace(iconPath)
|
|
if iconPath == "" {
|
|
return ""
|
|
}
|
|
cleanPath := filepath.Clean(iconPath)
|
|
if filepath.IsAbs(cleanPath) || cleanPath == ".." || strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) {
|
|
return ""
|
|
}
|
|
fullPath := filepath.Join(pluginDir, cleanPath)
|
|
data, err := os.ReadFile(fullPath)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
contentType := "image/svg+xml"
|
|
switch strings.ToLower(filepath.Ext(fullPath)) {
|
|
case ".png":
|
|
contentType = "image/png"
|
|
case ".jpg", ".jpeg":
|
|
contentType = "image/jpeg"
|
|
case ".webp":
|
|
contentType = "image/webp"
|
|
}
|
|
return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(data)
|
|
}
|
|
|
|
// SyncInstalledPlugins scans the runtime plugin directory and upserts
|
|
// installed_plugins records.
|
|
// This keeps the manifest snapshot available even when later code paths should
|
|
// avoid repeated disk IO.
|
|
func (m *Manager) SyncInstalledPlugins(ctx context.Context) error {
|
|
plugins, issues, err := DiscoverLocalPlugins(m.Dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
collection, err := m.App.FindCollectionByNameOrId("installed_plugins")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
activePaths := activePluginPaths(plugins, issues)
|
|
if err := m.deleteStaleInstalledPlugins(ctx, activePaths); err != nil {
|
|
return err
|
|
}
|
|
for _, issue := range issues {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
m.App.Logger().Warn("plugin setup error", "plugin", issue.ID, "path", issue.Dir, "error", issue.Error)
|
|
if err := m.savePluginIssue(collection, issue); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, plugin := range plugins {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
record, err := m.findPluginRecord(collection, plugin)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
record.Set("plugin_id", plugin.Manifest.ID)
|
|
record.Set("name", plugin.Manifest.Name)
|
|
record.Set("type", plugin.Manifest.Type)
|
|
record.Set("version", plugin.Manifest.Version)
|
|
record.Set("runtime", plugin.Manifest.Runtime.Type)
|
|
record.Set("path", plugin.Dir)
|
|
record.Set("status", "available")
|
|
record.Set("error", "")
|
|
manifestJSON, err := marshalManifest(plugin.Manifest)
|
|
if err != nil {
|
|
return fmt.Errorf("encode installed plugin %s manifest: %w", plugin.Manifest.ID, err)
|
|
}
|
|
record.Set("manifest", manifestJSON)
|
|
record.Set("config", mergeDefaultConfig(defaultConfig(plugin.Manifest), JSONMapFromRecord(record, "config")))
|
|
if err := m.App.Save(record); err != nil {
|
|
return fmt.Errorf("save installed plugin %s: %w", plugin.Manifest.ID, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func activePluginPaths(plugins []LocalPlugin, issues []LocalPluginIssue) map[string]bool {
|
|
paths := make(map[string]bool, len(plugins)+len(issues))
|
|
for _, plugin := range plugins {
|
|
if plugin.Dir != "" {
|
|
paths[filepath.Clean(plugin.Dir)] = true
|
|
}
|
|
}
|
|
for _, issue := range issues {
|
|
if issue.Dir != "" {
|
|
paths[filepath.Clean(issue.Dir)] = true
|
|
}
|
|
}
|
|
return paths
|
|
}
|
|
|
|
func (m *Manager) deleteStaleInstalledPlugins(ctx context.Context, activePaths map[string]bool) error {
|
|
records, err := m.App.FindRecordsByFilter("installed_plugins", "", "", -1, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, record := range records {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
path := strings.TrimSpace(record.GetString("path"))
|
|
if path != "" && activePaths[filepath.Clean(path)] {
|
|
continue
|
|
}
|
|
if err := m.App.Delete(record); err != nil {
|
|
return fmt.Errorf("delete stale installed plugin %s: %w", record.GetString("plugin_id"), err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) findPluginRecord(collection *core.Collection, plugin LocalPlugin) (*core.Record, error) {
|
|
recordByID, _ := m.App.FindFirstRecordByFilter(
|
|
"installed_plugins",
|
|
"plugin_id={:plugin_id}",
|
|
dbx.Params{"plugin_id": plugin.Manifest.ID},
|
|
)
|
|
var recordByPath *core.Record
|
|
if plugin.Dir != "" {
|
|
recordByPath, _ = m.App.FindFirstRecordByFilter(
|
|
"installed_plugins",
|
|
"path={:path}",
|
|
dbx.Params{"path": plugin.Dir},
|
|
)
|
|
}
|
|
if recordByID != nil && recordByPath != nil && recordByID.Id != recordByPath.Id {
|
|
if err := m.App.Delete(recordByPath); err != nil {
|
|
return nil, fmt.Errorf("delete superseded installed plugin %s: %w", recordByPath.GetString("plugin_id"), err)
|
|
}
|
|
}
|
|
if recordByID != nil {
|
|
return recordByID, nil
|
|
}
|
|
if recordByPath != nil {
|
|
return recordByPath, nil
|
|
}
|
|
return core.NewRecord(collection), nil
|
|
}
|
|
|
|
func (m *Manager) savePluginIssue(collection *core.Collection, issue LocalPluginIssue) error {
|
|
recordID := pluginIssueRecordID(issue)
|
|
record, _ := m.App.FindFirstRecordByFilter(
|
|
"installed_plugins",
|
|
"plugin_id={:plugin_id}",
|
|
dbx.Params{"plugin_id": recordID},
|
|
)
|
|
if record == nil && issue.Dir != "" {
|
|
record, _ = m.App.FindFirstRecordByFilter(
|
|
"installed_plugins",
|
|
"path={:path}",
|
|
dbx.Params{"path": issue.Dir},
|
|
)
|
|
}
|
|
if record == nil {
|
|
record = core.NewRecord(collection)
|
|
record.Set("plugin_id", recordID)
|
|
}
|
|
record.Set("name", issue.Name)
|
|
record.Set("type", PluginTypeTrails)
|
|
record.Set("version", "unknown")
|
|
record.Set("runtime", RuntimeWASM)
|
|
record.Set("path", issue.Dir)
|
|
record.Set("manifest", map[string]any{
|
|
"id": record.GetString("plugin_id"),
|
|
"type": PluginTypeTrails,
|
|
"name": issue.Name,
|
|
})
|
|
record.Set("status", "error")
|
|
record.Set("error", issue.Error)
|
|
if err := m.App.Save(record); err != nil {
|
|
return fmt.Errorf("save plugin setup error %s: %w", issue.ID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func pluginIssueRecordID(issue LocalPluginIssue) string {
|
|
originalID := strings.TrimSpace(issue.ID)
|
|
id := strings.ToLower(originalID)
|
|
var builder strings.Builder
|
|
for _, r := range id {
|
|
switch {
|
|
case r >= 'a' && r <= 'z':
|
|
builder.WriteRune(r)
|
|
case r >= '0' && r <= '9':
|
|
builder.WriteRune(r)
|
|
case r == '_' || r == '-':
|
|
builder.WriteRune(r)
|
|
default:
|
|
builder.WriteRune('-')
|
|
}
|
|
}
|
|
result := strings.Trim(builder.String(), "-_")
|
|
if result == "" {
|
|
result = "plugin-setup-error"
|
|
}
|
|
if originalID != result || !pluginIDPattern.MatchString(result) {
|
|
result = strings.Trim(result, "-_")
|
|
if result == "" {
|
|
result = "plugin-setup-error"
|
|
}
|
|
result = result + "-" + pluginIssueHash(issue)
|
|
}
|
|
if len(result) > 128 {
|
|
hash := pluginIssueHash(issue)
|
|
prefixLength := 128 - len(hash) - 1
|
|
result = strings.Trim(result[:prefixLength], "-_") + "-" + hash
|
|
}
|
|
if pluginIDPattern.MatchString(result) {
|
|
return result
|
|
}
|
|
return "plugin-setup-error-" + pluginIssueHash(issue)
|
|
}
|
|
|
|
func pluginIssueHash(issue LocalPluginIssue) string {
|
|
hash := fnv.New32a()
|
|
_, _ = hash.Write([]byte(issue.Dir))
|
|
_, _ = hash.Write([]byte{0})
|
|
_, _ = hash.Write([]byte(issue.ID))
|
|
return fmt.Sprintf("%08x", hash.Sum32())
|
|
}
|
|
|
|
func marshalManifest(manifest Manifest) (map[string]any, error) {
|
|
data, err := json.Marshal(manifest)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result map[string]any
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func defaultConfig(manifest Manifest) map[string]any {
|
|
hostConfig, _ := CloneJSONValue(manifest.HostConfig).(map[string]any)
|
|
if hostConfig == nil {
|
|
hostConfig = map[string]any{}
|
|
}
|
|
config := map[string]any{
|
|
"host": hostConfig,
|
|
}
|
|
pluginConfig := map[string]any{}
|
|
for _, field := range manifest.ConfigSchema {
|
|
if field.Key == "" || field.Default == nil {
|
|
continue
|
|
}
|
|
pluginConfig[field.Key] = CloneJSONValue(field.Default)
|
|
}
|
|
config["plugin"] = pluginConfig
|
|
return config
|
|
}
|
|
|
|
func mergeDefaultConfig(defaults map[string]any, current map[string]any) map[string]any {
|
|
if len(defaults) == 0 {
|
|
return current
|
|
}
|
|
merged := CloneJSONMap(defaults)
|
|
MergePluginConfig(merged, current)
|
|
return merged
|
|
}
|
|
|
|
func MergePluginConfig(dst map[string]any, src map[string]any) {
|
|
DeepMergeConfigWithReplaceKeys(dst, src, map[string]bool{
|
|
"categoryMapping": true,
|
|
})
|
|
}
|
|
|
|
func capabilityNames(capabilities []CapabilityManifest) []string {
|
|
names := make([]string, 0, len(capabilities))
|
|
for _, capability := range capabilities {
|
|
names = append(names, capability.Name+"."+capability.Version)
|
|
}
|
|
return names
|
|
}
|