mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-10-24 01:37:50 +08:00
Plugin config forms can now ask the plugin while they are filled in:
- x-options {name, dependsOn, search} loads a field's choices from
/v1/options/{name}. POST /api/v1/plugins/:id/options merges the form's
values with the stored secrets (system, tenant or instance scope) so a
redacted token still works. Go and Python SDKs, OpenAPI and conformance
cover the endpoint.
- x-oauth turns a string field into an account connection. WeKnora runs
the authorization code flow (PKCE, client credentials from the plugin's
system config), keeps the tokens sealed in plugin_oauth_connections and
the field holds "oauth:<id>". The invoker swaps references for fresh
access tokens at call time, refreshing near expiry. Callback:
/api/v1/plugin-oauth/callback, one-time states in Redis or memory.
- SchemaForm gains an options widget (dependsOn reload, remote search)
and an OAuth connect widget; connector, web search, workspace and
platform config forms provide the plugin behind them.
Host plugins are capped per runtime.resources on Linux: RLIMIT_DATA for
memory (WEKNORA_PLUGIN_MEMORY_DEFAULT when undeclared) and, with a
delegated cgroup v2 (WEKNORA_PLUGIN_CGROUP), memory.max and cpu.max.
367 lines
13 KiB
Go
367 lines
13 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
stderrors "errors"
|
|
"html/template"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/Tencent/WeKnora/internal/errors"
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|
"github.com/Tencent/WeKnora/internal/plugin/activate"
|
|
"github.com/Tencent/WeKnora/internal/plugin/configschema"
|
|
"github.com/Tencent/WeKnora/internal/plugin/install"
|
|
"github.com/Tencent/WeKnora/internal/plugin/manifest"
|
|
pluginoauth "github.com/Tencent/WeKnora/internal/plugin/oauth"
|
|
"github.com/Tencent/WeKnora/internal/plugin/registry"
|
|
"github.com/Tencent/WeKnora/internal/plugin/tenancy"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|
"github.com/Tencent/WeKnora/pluginsdk/pluginapi"
|
|
)
|
|
|
|
// PluginFormsHandler serves what plugin forms need from the plugin while
|
|
// they are being filled: dynamic choices (x-options) and OAuth connections
|
|
// (x-oauth).
|
|
type PluginFormsHandler struct {
|
|
registry *registry.Registry
|
|
tenancy *tenancy.Service
|
|
invoker *activate.Invoker
|
|
plugins interfaces.PluginRepository
|
|
dataSources interfaces.DataSourceRepository
|
|
webSearch interfaces.WebSearchProviderRepository
|
|
oauth *pluginoauth.Service
|
|
}
|
|
|
|
// NewPluginFormsHandler creates the handler.
|
|
func NewPluginFormsHandler(
|
|
reg *registry.Registry, t *tenancy.Service, iv *activate.Invoker, plugins interfaces.PluginRepository,
|
|
dataSources interfaces.DataSourceRepository, webSearch interfaces.WebSearchProviderRepository,
|
|
oauth *pluginoauth.Service,
|
|
) *PluginFormsHandler {
|
|
return &PluginFormsHandler{
|
|
registry: reg, tenancy: t, invoker: iv, plugins: plugins, dataSources: dataSources, webSearch: webSearch,
|
|
oauth: oauth,
|
|
}
|
|
}
|
|
|
|
// PluginOptionsRequest asks a plugin for a field's choices.
|
|
type PluginOptionsRequest struct {
|
|
Name string `json:"name" binding:"required"`
|
|
Field string `json:"field" binding:"required"`
|
|
// Scope is system, tenant or instance.
|
|
Scope string `json:"scope" binding:"required"`
|
|
// Contribution is "<point>/<id>" of the instance ("connectors/jira").
|
|
Contribution string `json:"contribution"`
|
|
// InstanceID is the instance being edited; its stored secrets fill in
|
|
// the fields the form did not change.
|
|
InstanceID string `json:"instanceId"`
|
|
// Values are what the form holds now, in the scope's shape.
|
|
Values map[string]any `json:"values"`
|
|
Query string `json:"query"`
|
|
}
|
|
|
|
// Options godoc
|
|
// @Summary 插件表单的动态选项
|
|
// @Description 用表单当前的值(未修改的密钥用已存的值补齐)向插件取某个字段的可选项(x-options)
|
|
// @Tags Plugins
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "插件 ID"
|
|
// @Param request body PluginOptionsRequest true "请求"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Security Bearer
|
|
// @Router /plugins/{id}/options [post]
|
|
func (h *PluginFormsHandler) Options(c *gin.Context) {
|
|
limitJSONBody(c, 1<<20)
|
|
var req PluginOptionsRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
_ = c.Error(errors.NewBadRequestError("name, field and scope are required"))
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
m, ok := h.codePlugin(c, c.Param("id"), req.Scope)
|
|
if !ok {
|
|
return
|
|
}
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
values := req.Values
|
|
if values == nil {
|
|
values = map[string]any{}
|
|
}
|
|
var override activate.ConfigOverride
|
|
var instance map[string]any
|
|
switch req.Scope {
|
|
case pluginapi.OptionsScopeSystem:
|
|
stored, _, err := install.OpenSystemConfig(ctx, h.plugins, m)
|
|
if err != nil {
|
|
h.fail(c, err)
|
|
return
|
|
}
|
|
override.System = mergeForm(m.Config.SystemSchema, stored, values)
|
|
case pluginapi.OptionsScopeTenant:
|
|
stored, _, err := h.tenancy.OpenConfig(ctx, tenantID, m.ID)
|
|
if err != nil {
|
|
h.fail(c, err)
|
|
return
|
|
}
|
|
override.Tenant = mergeForm(m.Config.TenantSchema, stored, values)
|
|
case pluginapi.OptionsScopeInstance:
|
|
var err error
|
|
if instance, err = h.instanceValues(c, tenantID, req, values); err != nil {
|
|
h.fail(c, err)
|
|
return
|
|
}
|
|
default:
|
|
_ = c.Error(errors.NewBadRequestError("scope must be system, tenant or instance"))
|
|
return
|
|
}
|
|
in := pluginapi.OptionsInput{Field: req.Field, Scope: req.Scope, Contribution: req.Contribution, Query: req.Query}
|
|
var out pluginapi.OptionsOutput
|
|
err := h.invoker.CallOverriding(ctx, m, pluginapi.OptionsPath(req.Name), instance, override, in, &out)
|
|
if err != nil {
|
|
h.fail(c, err)
|
|
return
|
|
}
|
|
if out.Options == nil {
|
|
out.Options = []pluginapi.Option{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
|
|
}
|
|
|
|
// codePlugin finds a plugin with code the caller may ask for this scope.
|
|
func (h *PluginFormsHandler) codePlugin(c *gin.Context, id, scope string) (*manifest.Manifest, bool) {
|
|
ctx := c.Request.Context()
|
|
m, ok := h.registry.Plugin(id)
|
|
if !ok || (m.Runtime.Type != manifest.RuntimeHost && m.Runtime.Type != manifest.RuntimeRemote) {
|
|
_ = c.Error(errors.NewNotFoundError("no such plugin with code"))
|
|
return nil, false
|
|
}
|
|
if scope == pluginapi.OptionsScopeSystem {
|
|
if !types.IsSystemAdminFromContext(ctx) {
|
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Forbidden: system administrators only"})
|
|
return nil, false
|
|
}
|
|
return m, true
|
|
}
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
if on, err := h.tenancy.PluginEnabled(ctx, tenantID, id); err != nil || !on {
|
|
_ = c.Error(errors.NewNotFoundError("the plugin is not enabled in this workspace"))
|
|
return nil, false
|
|
}
|
|
return m, true
|
|
}
|
|
|
|
// mergeForm fills the fields a form left redacted ("***") or empty with the
|
|
// stored values.
|
|
func mergeForm(rawSchema json.RawMessage, stored, values map[string]any) map[string]any {
|
|
if len(rawSchema) == 0 {
|
|
return values
|
|
}
|
|
schema, err := configschema.Parse(rawSchema)
|
|
if err != nil {
|
|
return values
|
|
}
|
|
return configschema.Merge(schema, stored, values)
|
|
}
|
|
|
|
// instanceValues is the instance the form holds, with the stored instance's
|
|
// secrets filled in where the form has none (forms never receive secrets).
|
|
func (h *PluginFormsHandler) instanceValues(
|
|
c *gin.Context, tenantID uint64, req PluginOptionsRequest, values map[string]any,
|
|
) (map[string]any, error) {
|
|
point, _, _ := strings.Cut(req.Contribution, "/")
|
|
switch manifest.Point(point) {
|
|
case manifest.PointConnectors:
|
|
creds, _ := values["credentials"].(map[string]any)
|
|
if creds == nil {
|
|
creds = map[string]any{}
|
|
}
|
|
if req.InstanceID != "" {
|
|
ds, err := h.dataSources.FindByID(c.Request.Context(), req.InstanceID)
|
|
if err != nil || ds == nil || ds.TenantID != tenantID {
|
|
return nil, errInstanceNotFound
|
|
}
|
|
cfg, err := ds.ParseConfig()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for k, v := range cfg.Credentials {
|
|
if s, _ := creds[k].(string); s == "" || s == types.RedactedSecretPlaceholder {
|
|
creds[k] = v
|
|
}
|
|
}
|
|
}
|
|
out := map[string]any{
|
|
"credentials": creds,
|
|
"settings": values["settings"],
|
|
"resourceIds": values["resourceIds"],
|
|
}
|
|
if out["settings"] == nil {
|
|
out["settings"] = map[string]any{}
|
|
}
|
|
return out, nil
|
|
case manifest.PointWebSearch:
|
|
out := map[string]any{}
|
|
for k, v := range values {
|
|
out[k] = v
|
|
}
|
|
if key, _ := out["api_key"].(string); req.InstanceID != "" &&
|
|
(key == "" || key == types.RedactedSecretPlaceholder) {
|
|
p, err := h.webSearch.GetByID(c.Request.Context(), tenantID, req.InstanceID)
|
|
if err != nil || p == nil {
|
|
return nil, errInstanceNotFound
|
|
}
|
|
out["api_key"] = p.Parameters.APIKey
|
|
}
|
|
return out, nil
|
|
default:
|
|
return values, nil
|
|
}
|
|
}
|
|
|
|
var errInstanceNotFound = stderrors.New("instance not found")
|
|
|
|
// fail maps a plugin form failure: the plugin's own field problems go back
|
|
// to the form as they are.
|
|
func (h *PluginFormsHandler) fail(c *gin.Context, err error) {
|
|
if stderrors.Is(err, errInstanceNotFound) {
|
|
_ = c.Error(errors.NewNotFoundError("instance not found"))
|
|
return
|
|
}
|
|
if pe, ok := pluginapi.AsError(err); ok {
|
|
switch pe.Code {
|
|
case pluginapi.CodeInvalidConfig, pluginapi.CodeUnauthorized, pluginapi.CodeBadRequest:
|
|
body := gin.H{"success": false, "error": gin.H{"code": string(pe.Code), "message": pe.Message}}
|
|
if pe.Details != nil && len(pe.Details.Fields) > 0 {
|
|
body["error"].(gin.H)["fields"] = pe.Details.Fields
|
|
}
|
|
c.JSON(http.StatusBadRequest, body)
|
|
return
|
|
case pluginapi.CodeNotFound:
|
|
_ = c.Error(errors.NewNotFoundError(pe.Message))
|
|
return
|
|
}
|
|
}
|
|
logger.Warnf(c.Request.Context(), "[plugin] form request to %s failed: %v", c.Param("id"), err)
|
|
c.JSON(http.StatusBadGateway, gin.H{"success": false, "error": gin.H{"message": err.Error()}})
|
|
}
|
|
|
|
// PluginOAuthStartRequest starts connecting an x-oauth field.
|
|
type PluginOAuthStartRequest struct {
|
|
Scope string `json:"scope" binding:"required"`
|
|
Contribution string `json:"contribution"`
|
|
Field string `json:"field" binding:"required"`
|
|
}
|
|
|
|
// OAuthStart godoc
|
|
// @Summary 开始插件字段的 OAuth 授权
|
|
// @Description 为 x-oauth 字段生成授权地址;授权完成后回调页把连接 ID 交回表单,令牌只保存在服务端
|
|
// @Tags Plugins
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "插件 ID"
|
|
// @Param request body PluginOAuthStartRequest true "字段"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Security Bearer
|
|
// @Router /plugins/{id}/oauth/start [post]
|
|
func (h *PluginFormsHandler) OAuthStart(c *gin.Context) {
|
|
var req PluginOAuthStartRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
_ = c.Error(errors.NewBadRequestError("scope and field are required"))
|
|
return
|
|
}
|
|
m, ok := h.codePlugin(c, c.Param("id"), req.Scope)
|
|
if !ok {
|
|
return
|
|
}
|
|
target := pluginoauth.Target{
|
|
PluginID: m.ID, Scope: req.Scope, Contribution: req.Contribution, Field: req.Field,
|
|
}
|
|
if req.Scope != pluginapi.OptionsScopeSystem {
|
|
target.TenantID = c.GetUint64(types.TenantIDContextKey.String())
|
|
}
|
|
userID, _ := types.UserIDFromContext(c.Request.Context())
|
|
origin, base := appOrigin(c)
|
|
url, state, err := h.oauth.Start(c.Request.Context(), target, userID, origin, base)
|
|
if err != nil {
|
|
_ = c.Error(errors.NewBadRequestError(err.Error()))
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
|
|
"authorizeUrl": url, "state": state, "redirectUri": base + pluginoauth.CallbackPath,
|
|
}})
|
|
}
|
|
|
|
// appOrigin is the app's origin (where the callback page reports back) and
|
|
// the base URL authorization servers send the browser to: APP_EXTERNAL_URL
|
|
// when set, else the origin the browser called from.
|
|
func appOrigin(c *gin.Context) (origin, base string) {
|
|
if ext := strings.TrimSuffix(strings.TrimSpace(os.Getenv("APP_EXTERNAL_URL")), "/"); ext != "" {
|
|
if u, err := url.Parse(ext); err == nil && u.Host != "" {
|
|
return u.Scheme + "://" + u.Host, ext
|
|
}
|
|
}
|
|
if o := c.GetHeader("Origin"); strings.HasPrefix(o, "http://") || strings.HasPrefix(o, "https://") {
|
|
return o, o
|
|
}
|
|
scheme := "http"
|
|
if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
|
|
scheme = "https"
|
|
}
|
|
host := c.Request.Host
|
|
if fh := c.GetHeader("X-Forwarded-Host"); fh != "" {
|
|
host = fh
|
|
}
|
|
o := scheme + "://" + host
|
|
return o, o
|
|
}
|
|
|
|
var oauthDoneTemplate = template.Must(template.New("oauth").Parse(`<!doctype html>
|
|
<html><head><meta charset="utf-8"><title>WeKnora</title></head>
|
|
<body style="font:14px system-ui,sans-serif;padding:24px">
|
|
<p id="msg">{{.Message}}</p>
|
|
<script>
|
|
(function () {
|
|
var msg = {
|
|
type: 'weknora-plugin-oauth', state: {{.State}}, ok: {{.OK}},
|
|
connection: {{.Connection}}, error: {{.Error}}
|
|
};
|
|
if (window.opener && {{.Origin}}) {
|
|
window.opener.postMessage(msg, {{.Origin}});
|
|
window.close();
|
|
}
|
|
})();
|
|
</script>
|
|
</body></html>`))
|
|
|
|
// OAuthCallback is where authorization servers send the browser back. It
|
|
// needs no login (the state names the pending authorization) and answers
|
|
// with a page that hands the result to the form that opened it.
|
|
func (h *PluginFormsHandler) OAuthCallback(c *gin.Context) {
|
|
res := h.oauth.Complete(c.Request.Context(), c.Query("state"), c.Query("code"), c.Query("error"))
|
|
data := map[string]any{
|
|
"State": res.State, "Origin": res.Origin, "OK": res.Err == nil, "Connection": "", "Error": "",
|
|
"Message": "Connected. You can close this window.",
|
|
}
|
|
if res.Err != nil {
|
|
data["Error"], data["Message"] = res.Err.Error(), res.Err.Error()
|
|
} else {
|
|
data["Connection"] = configschema.OAuthRefPrefix + res.ConnectionID
|
|
}
|
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
|
c.Header("Cache-Control", "no-store")
|
|
c.Header("Referrer-Policy", "no-referrer")
|
|
status := http.StatusOK
|
|
if res.Err != nil {
|
|
status = http.StatusBadRequest
|
|
}
|
|
c.Status(status)
|
|
_ = oauthDoneTemplate.Execute(c.Writer, data)
|
|
}
|