feat: add plugin system (#1034)
* 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>
This commit is contained in:
@@ -1,146 +0,0 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"pocketbase/util"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func ListIntegrationHandler() func(e *core.RecordsListRequestEvent) error {
|
||||
return func(e *core.RecordsListRequestEvent) error {
|
||||
if e.HasSuperuserAuth() {
|
||||
return e.Next()
|
||||
}
|
||||
for _, r := range e.Records {
|
||||
|
||||
err := censorIntegrationSecrets(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func CreateIntegrationHandler() func(e *core.RecordEvent) error {
|
||||
return func(e *core.RecordEvent) error {
|
||||
err := encryptIntegrationSecrets(e.App, e.Record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func CreateUpdateIntegrationSuccessHandler() func(e *core.RecordEvent) error {
|
||||
return func(e *core.RecordEvent) error {
|
||||
err := censorIntegrationSecrets(e.Record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateIntegrationHandler() func(e *core.RecordEvent) error {
|
||||
return func(e *core.RecordEvent) error {
|
||||
err := encryptIntegrationSecrets(e.App, e.Record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func censorIntegrationSecrets(r *core.Record) error {
|
||||
secrets := map[string][]string{
|
||||
"strava": {"clientSecret", "refreshToken", "accessToken", "expiresAt"},
|
||||
"komoot": {"password"},
|
||||
"hammerhead": {"password"},
|
||||
}
|
||||
for key, secretKeys := range secrets {
|
||||
if integrationString := r.GetString(key); integrationString != "" {
|
||||
var integration map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(integrationString), &integration); err != nil {
|
||||
return err
|
||||
}
|
||||
if integration == nil {
|
||||
continue
|
||||
}
|
||||
for _, secretKey := range secretKeys {
|
||||
integration[secretKey] = ""
|
||||
}
|
||||
b, err := json.Marshal(integration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Set(key, string(b))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encryptIntegrationSecrets(app core.App, r *core.Record) error {
|
||||
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
|
||||
if len(encryptionKey) == 0 {
|
||||
return apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil)
|
||||
}
|
||||
|
||||
secrets := map[string][]string{
|
||||
"strava": {"clientSecret", "refreshToken", "accessToken", "expiresAt"},
|
||||
"komoot": {"password"},
|
||||
"hammerhead": {"password"},
|
||||
}
|
||||
|
||||
original, _ := app.FindRecordById("integrations", r.Id)
|
||||
|
||||
for key, secretKeys := range secrets {
|
||||
if integrationString := r.GetString(key); integrationString != "" {
|
||||
var integration map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(integrationString), &integration); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, secretKey := range secretKeys {
|
||||
// If the secret is already encrypted, we don't re-encrypt it.
|
||||
// TODO: This is a bit of a hack, we should handle this in a more robust way (e.g.
|
||||
// storing flag on the record or prefixing encrypted strings with enc: or smilar).
|
||||
// Doing that would also potentially allow us to support key rotation in the future.
|
||||
if secret, ok := integration[secretKey].(string); ok && len(secret) > 0 && !util.CanDecryptSecret(secret) {
|
||||
encryptedSecret, err := security.Encrypt([]byte(secret), encryptionKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
integration[secretKey] = encryptedSecret
|
||||
} else if original != nil {
|
||||
|
||||
originalString := original.GetString(key)
|
||||
var originalIntegration map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(originalString), &originalIntegration); err != nil {
|
||||
return err
|
||||
}
|
||||
if integration == nil {
|
||||
continue
|
||||
}
|
||||
integration[secretKey] = originalIntegration[secretKey]
|
||||
}
|
||||
}
|
||||
|
||||
b, err := json.Marshal(integration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Set(key, string(b))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
271
db/hooks/plugin_instances.go
Normal file
271
db/hooks/plugin_instances.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"pocketbase/util"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
|
||||
"pocketbase/pluginsystem"
|
||||
)
|
||||
|
||||
// ListPluginInstanceHandler censors auth values before plugin instances leave
|
||||
// the API. The database keeps encrypted secrets, but normal users never receive
|
||||
// the encrypted payload either.
|
||||
func ListPluginInstanceHandler() func(e *core.RecordsListRequestEvent) error {
|
||||
return func(e *core.RecordsListRequestEvent) error {
|
||||
if e.HasSuperuserAuth() {
|
||||
return e.Next()
|
||||
}
|
||||
for _, r := range e.Records {
|
||||
censorPluginInstanceAuth(e.App, r)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// ViewPluginInstanceHandler applies the same auth censoring for single-record
|
||||
// reads that ListPluginInstanceHandler applies for list reads.
|
||||
func ViewPluginInstanceHandler() func(e *core.RecordRequestEvent) error {
|
||||
return func(e *core.RecordRequestEvent) error {
|
||||
if e.HasSuperuserAuth() {
|
||||
return e.Next()
|
||||
}
|
||||
censorPluginInstanceAuth(e.App, e.Record)
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// CreatePluginInstanceHandler normalizes initial status and encrypts submitted
|
||||
// auth fields before a plugin instance is persisted.
|
||||
func CreatePluginInstanceHandler() func(e *core.RecordEvent) error {
|
||||
return func(e *core.RecordEvent) error {
|
||||
ensurePluginInstanceStatus(e.Record)
|
||||
mergePluginInstanceDefaultConfig(e.App, e.Record)
|
||||
if err := encryptPluginInstanceAuth(e.App, e.Record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// CreateUpdatePluginInstanceSuccessHandler censors auth values in the response
|
||||
// body after PocketBase has stored the encrypted values.
|
||||
func CreateUpdatePluginInstanceSuccessHandler() func(e *core.RecordEvent) error {
|
||||
return func(e *core.RecordEvent) error {
|
||||
censorPluginInstanceAuth(e.App, e.Record)
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// UpdatePluginInstanceHandler re-applies status defaults and encrypts any
|
||||
// changed auth fields before the update is persisted.
|
||||
func UpdatePluginInstanceHandler() func(e *core.RecordEvent) error {
|
||||
return func(e *core.RecordEvent) error {
|
||||
ensurePluginInstanceStatus(e.Record)
|
||||
mergePluginInstanceDefaultConfig(e.App, e.Record)
|
||||
if err := encryptPluginInstanceAuth(e.App, e.Record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func mergePluginInstanceDefaultConfig(app core.App, r *core.Record) {
|
||||
defaults := installedPluginDefaultConfig(app, r.GetString("plugin_id"))
|
||||
if len(defaults) == 0 {
|
||||
return
|
||||
}
|
||||
merged := pluginsystem.CloneJSONMap(defaults)
|
||||
pluginsystem.MergePluginConfig(merged, pluginsystem.JSONMapFromRecord(r, "config"))
|
||||
r.Set("config", merged)
|
||||
}
|
||||
|
||||
func installedPluginDefaultConfig(app core.App, pluginID string) map[string]any {
|
||||
if pluginID == "" {
|
||||
return map[string]any{}
|
||||
}
|
||||
record, _ := app.FindFirstRecordByFilter(
|
||||
"installed_plugins",
|
||||
"plugin_id={:plugin_id}",
|
||||
dbx.Params{"plugin_id": pluginID},
|
||||
)
|
||||
if record == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return pluginsystem.JSONMapFromRecord(record, "config")
|
||||
}
|
||||
|
||||
func censorPluginInstanceAuth(app core.App, r *core.Record) {
|
||||
if authString := r.GetString("auth"); authString != "" {
|
||||
var auth map[string]any
|
||||
if err := json.Unmarshal([]byte(authString), &auth); err != nil {
|
||||
r.Set("auth", "{}")
|
||||
return
|
||||
}
|
||||
|
||||
secretFields := pluginInstanceSecretFields(app, r.GetString("plugin_id"))
|
||||
encryptAll := len(secretFields) == 0
|
||||
for key := range auth {
|
||||
if encryptAll || secretFields[key] {
|
||||
auth[key] = ""
|
||||
}
|
||||
}
|
||||
|
||||
b, err := json.Marshal(auth)
|
||||
if err != nil {
|
||||
r.Set("auth", "{}")
|
||||
return
|
||||
}
|
||||
r.Set("auth", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func ensurePluginInstanceStatus(r *core.Record) {
|
||||
if r.GetString("status") != "" {
|
||||
return
|
||||
}
|
||||
if r.GetString("auth") == "" {
|
||||
r.Set("status", "needs_auth")
|
||||
return
|
||||
}
|
||||
if r.GetBool("enabled") {
|
||||
r.Set("status", "configured")
|
||||
return
|
||||
}
|
||||
r.Set("status", "disabled")
|
||||
}
|
||||
|
||||
func encryptPluginInstanceAuth(app core.App, r *core.Record) error {
|
||||
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
|
||||
if len(encryptionKey) == 0 {
|
||||
return apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil)
|
||||
}
|
||||
|
||||
authString := r.GetString("auth")
|
||||
if authString == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var auth map[string]any
|
||||
if err := json.Unmarshal([]byte(authString), &auth); err != nil {
|
||||
return err
|
||||
}
|
||||
if auth == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var originalAuth map[string]any
|
||||
if original, _ := app.FindRecordById("plugin_instances", r.Id); original != nil {
|
||||
originalString := original.GetString("auth")
|
||||
if originalString != "" {
|
||||
_ = json.Unmarshal([]byte(originalString), &originalAuth)
|
||||
}
|
||||
}
|
||||
|
||||
secretFields := pluginInstanceSecretFields(app, r.GetString("plugin_id"))
|
||||
encryptAll := len(secretFields) == 0
|
||||
if originalAuth != nil {
|
||||
for key, value := range originalAuth {
|
||||
if _, ok := auth[key]; ok {
|
||||
continue
|
||||
}
|
||||
if encryptAll || secretFields[key] {
|
||||
auth[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for key, value := range auth {
|
||||
secret, ok := value.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if secret == "" {
|
||||
if originalAuth != nil {
|
||||
if restored, ok := originalAuth[key].(string); ok && restored != "" {
|
||||
secret = restored
|
||||
}
|
||||
}
|
||||
if secret == "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !encryptAll && !secretFields[key] {
|
||||
auth[key] = secret
|
||||
continue
|
||||
}
|
||||
if util.CanDecryptSecret(secret) {
|
||||
auth[key] = secret
|
||||
continue
|
||||
}
|
||||
encryptedSecret, err := security.Encrypt([]byte(secret), encryptionKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
auth[key] = encryptedSecret
|
||||
}
|
||||
|
||||
b, err := json.Marshal(auth)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Set("auth", string(b))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func pluginInstanceSecretFields(app core.App, pluginID string) map[string]bool {
|
||||
manifest, ok := pluginInstancePluginManifest(app, pluginID)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
fields := map[string]bool{}
|
||||
for _, field := range pluginsystem.InternalAuthSecretFields() {
|
||||
fields[field] = true
|
||||
}
|
||||
for _, context := range manifest.Auth.Contexts {
|
||||
if context.SecretField != "" {
|
||||
fields[context.SecretField] = true
|
||||
}
|
||||
for _, field := range context.SecretFields {
|
||||
fields[field] = true
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func pluginInstancePluginManifest(app core.App, pluginID string) (pluginsystem.Manifest, bool) {
|
||||
record, _ := app.FindFirstRecordByFilter(
|
||||
"installed_plugins",
|
||||
"plugin_id={:plugin_id}",
|
||||
dbx.Params{"plugin_id": pluginID},
|
||||
)
|
||||
if record != nil {
|
||||
var manifest pluginsystem.Manifest
|
||||
if err := record.UnmarshalJSONField("manifest", &manifest); err == nil && manifest.ID != "" {
|
||||
return manifest, true
|
||||
}
|
||||
}
|
||||
|
||||
plugins, err := pluginsystem.LoadLocalPlugins("")
|
||||
if err != nil {
|
||||
return pluginsystem.Manifest{}, false
|
||||
}
|
||||
for _, plugin := range plugins {
|
||||
if plugin.Manifest.ID == pluginID {
|
||||
return plugin.Manifest, true
|
||||
}
|
||||
}
|
||||
return pluginsystem.Manifest{}, false
|
||||
}
|
||||
Reference in New Issue
Block a user