* 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>
223 lines
6.7 KiB
Go
223 lines
6.7 KiB
Go
package routes
|
|
|
|
import (
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/pocketbase/dbx"
|
|
"github.com/pocketbase/pocketbase/apis"
|
|
"github.com/pocketbase/pocketbase/core"
|
|
|
|
"pocketbase/pluginsystem"
|
|
)
|
|
|
|
type pluginOAuthStartRequest struct {
|
|
PluginID string `json:"pluginId"`
|
|
InstanceID string `json:"instanceId"`
|
|
AuthContext string `json:"authContext,omitempty"`
|
|
RedirectURI string `json:"redirectUri"`
|
|
}
|
|
|
|
type pluginOAuthCallbackRequest struct {
|
|
InstanceID string `json:"instanceId"`
|
|
Code string `json:"code"`
|
|
State string `json:"state"`
|
|
}
|
|
|
|
type pluginOAuthRevokeRequest struct {
|
|
InstanceID string `json:"instanceId"`
|
|
}
|
|
|
|
func PluginSystemOAuthStart(e *core.RequestEvent) error {
|
|
if e.Auth == nil {
|
|
return apis.NewUnauthorizedError("authentication required", nil)
|
|
}
|
|
|
|
var data pluginOAuthStartRequest
|
|
if err := e.BindBody(&data); err != nil {
|
|
return apis.NewBadRequestError("failed to read request data", err)
|
|
}
|
|
if data.PluginID == "" || data.RedirectURI == "" {
|
|
return apis.NewBadRequestError("pluginId and redirectUri are required", nil)
|
|
}
|
|
if err := pluginsystem.ValidateOAuthRedirectURI(data.RedirectURI); err != nil {
|
|
return apis.NewBadRequestError("redirectUri is not allowed", err)
|
|
}
|
|
|
|
plugin, err := localPlugin(e.App, data.PluginID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
contextName, authContext, err := pluginsystem.OAuthContext(plugin, data.AuthContext)
|
|
if err != nil {
|
|
return apis.NewBadRequestError("plugin has no oauth auth context", err)
|
|
}
|
|
|
|
instance, err := pluginAuthInstance(e.App, e.Auth.Id, data.PluginID, data.InstanceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
auth, err := decryptedInstanceAuth(instance)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
clientID := pluginsystem.StringFromAny(auth["clientId"])
|
|
if clientID == "" {
|
|
return apis.NewBadRequestError("oauth clientId is required", nil)
|
|
}
|
|
|
|
state := pluginsystem.NewOAuthState(32)
|
|
auth[pluginsystem.AuthFieldOAuthContext] = contextName
|
|
auth[pluginsystem.AuthFieldOAuthState] = state
|
|
auth[pluginsystem.AuthFieldOAuthRedirectURI] = data.RedirectURI
|
|
|
|
values := url.Values{}
|
|
values.Set("response_type", "code")
|
|
values.Set("client_id", clientID)
|
|
values.Set("redirect_uri", data.RedirectURI)
|
|
values.Set("state", state)
|
|
if len(authContext.Scopes) > 0 {
|
|
separator := authContext.ScopeSeparator
|
|
if separator == "" {
|
|
separator = " "
|
|
}
|
|
values.Set("scope", strings.Join(authContext.Scopes, separator))
|
|
}
|
|
for key, value := range authContext.AuthorizationParams {
|
|
values.Set(key, value)
|
|
}
|
|
if authContext.PKCE {
|
|
verifier := pluginsystem.NewOAuthCodeVerifier(64)
|
|
auth[pluginsystem.AuthFieldOAuthCodeVerifier] = verifier
|
|
values.Set("code_challenge_method", "S256")
|
|
values.Set("code_challenge", pluginsystem.PKCEChallenge(verifier))
|
|
}
|
|
|
|
instance.Set("auth", auth)
|
|
instance.Set("status", "needs_auth")
|
|
if err := e.App.Save(instance); err != nil {
|
|
return err
|
|
}
|
|
|
|
authURL, err := url.Parse(authContext.AuthorizationURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
query := authURL.Query()
|
|
for key, value := range values {
|
|
query[key] = value
|
|
}
|
|
authURL.RawQuery = query.Encode()
|
|
|
|
return e.JSON(http.StatusOK, map[string]any{
|
|
"url": authURL.String(),
|
|
"state": state,
|
|
"instanceId": instance.Id,
|
|
})
|
|
}
|
|
|
|
func PluginSystemOAuthCallback(e *core.RequestEvent) error {
|
|
if e.Auth == nil {
|
|
return apis.NewUnauthorizedError("authentication required", nil)
|
|
}
|
|
|
|
var data pluginOAuthCallbackRequest
|
|
if err := e.BindBody(&data); err != nil {
|
|
return apis.NewBadRequestError("failed to read request data", err)
|
|
}
|
|
if data.InstanceID == "" || data.Code == "" || data.State == "" {
|
|
return apis.NewBadRequestError("instanceId, code and state are required", nil)
|
|
}
|
|
|
|
instance, err := e.App.FindRecordById("plugin_instances", data.InstanceID)
|
|
if err != nil || instance.GetString("user") != e.Auth.Id {
|
|
return apis.NewNotFoundError("plugin instance not found", nil)
|
|
}
|
|
plugin, err := localPlugin(e.App, instance.GetString("plugin_id"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
auth, err := decryptedInstanceAuth(instance)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if data.State != pluginsystem.StringFromAny(auth[pluginsystem.AuthFieldOAuthState]) {
|
|
return apis.NewBadRequestError("invalid oauth state", nil)
|
|
}
|
|
contextName := pluginsystem.StringFromAny(auth[pluginsystem.AuthFieldOAuthContext])
|
|
_, authContext, err := pluginsystem.OAuthContext(plugin, contextName)
|
|
if err != nil {
|
|
return apis.NewBadRequestError("plugin has no oauth auth context", err)
|
|
}
|
|
|
|
token, err := pluginsystem.ExchangeOAuthToken(e.Request.Context(), plugin.Manifest, authContext, auth, map[string]string{
|
|
"grant_type": "authorization_code",
|
|
"code": data.Code,
|
|
"redirect_uri": pluginsystem.StringFromAny(auth[pluginsystem.AuthFieldOAuthRedirectURI]),
|
|
"code_verifier": pluginsystem.StringFromAny(
|
|
auth[pluginsystem.AuthFieldOAuthCodeVerifier],
|
|
),
|
|
})
|
|
if err != nil {
|
|
return apis.NewBadRequestError("oauth token exchange failed", err)
|
|
}
|
|
pluginsystem.StoreOAuthToken(auth, contextName, token)
|
|
for _, field := range pluginsystem.InternalOAuthTransientFields() {
|
|
delete(auth, field)
|
|
}
|
|
|
|
instance.Set("auth", auth)
|
|
instance.Set("status", "configured")
|
|
instance.Set("last_error", map[string]any{})
|
|
if err := e.App.Save(instance); err != nil {
|
|
return err
|
|
}
|
|
|
|
return e.JSON(http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
func PluginSystemOAuthRevoke(e *core.RequestEvent) error {
|
|
if e.Auth == nil {
|
|
return apis.NewUnauthorizedError("authentication required", nil)
|
|
}
|
|
|
|
var data pluginOAuthRevokeRequest
|
|
if err := e.BindBody(&data); err != nil {
|
|
return apis.NewBadRequestError("failed to read request data", err)
|
|
}
|
|
if data.InstanceID == "" {
|
|
return apis.NewBadRequestError("instanceId is required", nil)
|
|
}
|
|
instance, err := e.App.FindRecordById("plugin_instances", data.InstanceID)
|
|
if err != nil || instance.GetString("user") != e.Auth.Id {
|
|
return apis.NewNotFoundError("plugin instance not found", nil)
|
|
}
|
|
auth, err := decryptedInstanceAuth(instance)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pluginsystem.ClearOAuthToken(auth)
|
|
instance.Set("auth", auth)
|
|
instance.Set("status", "needs_auth")
|
|
if err := e.App.Save(instance); err != nil {
|
|
return err
|
|
}
|
|
return e.JSON(http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
func pluginAuthInstance(app core.App, userID string, pluginID string, instanceID string) (*core.Record, error) {
|
|
if instanceID != "" {
|
|
instance, err := app.FindRecordById("plugin_instances", instanceID)
|
|
if err != nil || instance.GetString("user") != userID || instance.GetString("plugin_id") != pluginID {
|
|
return nil, apis.NewNotFoundError("plugin instance not found", nil)
|
|
}
|
|
return instance, nil
|
|
}
|
|
return app.FindFirstRecordByFilter(
|
|
"plugin_instances",
|
|
"user={:user} && plugin_id={:plugin_id}",
|
|
dbx.Params{"user": userID, "plugin_id": pluginID},
|
|
)
|
|
}
|