* 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>
272 lines
6.7 KiB
Go
272 lines
6.7 KiB
Go
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
|
|
}
|