forked from casdoor/casdoor
Compare commits
99 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25d8595e66 | ||
|
|
3aafa91937 | ||
|
|
0077839549 | ||
|
|
e1ee2ddee8 | ||
|
|
b93be2d3e2 | ||
|
|
77b56a2e40 | ||
|
|
c0591f316e | ||
|
|
6749d46561 | ||
|
|
a4a50f182b | ||
|
|
221d10a172 | ||
|
|
5c051ba03d | ||
|
|
c16f4d2fb5 | ||
|
|
fe185f880c | ||
|
|
b3bed1992b | ||
|
|
be38d178fd | ||
|
|
3eb164e149 | ||
|
|
6c3cd8a74b | ||
|
|
c5ab4eec59 | ||
|
|
e8170884d7 | ||
|
|
729b21e8ae | ||
|
|
bed67a1ff2 | ||
|
|
df5f5def31 | ||
|
|
76c56e9b2d | ||
|
|
f46e229d5b | ||
|
|
112be9714b | ||
|
|
9d85362a24 | ||
|
|
37e2f13d99 | ||
|
|
f35398ea5c | ||
|
|
5a5470d5a3 | ||
|
|
948fc017e1 | ||
|
|
c63184fc67 | ||
|
|
f5f4032b3b | ||
|
|
7006041fa9 | ||
|
|
d7bc2bf052 | ||
|
|
29eeb03f85 | ||
|
|
14b4b557f9 | ||
|
|
49d35ac161 | ||
|
|
5ed9158368 | ||
|
|
2bb728ad7d | ||
|
|
f4665df477 | ||
|
|
12bbecb69d | ||
|
|
a5079cd0c5 | ||
|
|
e361044f86 | ||
|
|
91cdf56636 | ||
|
|
10daed237e | ||
|
|
315a6bb040 | ||
|
|
cef6b85389 | ||
|
|
14a802f2c5 | ||
|
|
40d1f63cd6 | ||
|
|
85c91c50d3 | ||
|
|
0e5f810f2f | ||
|
|
e9c2ec0d6c | ||
|
|
2a8ac578da | ||
|
|
31ce1512df | ||
|
|
bac824cb4f | ||
|
|
1637ca1dfb | ||
|
|
c7ad2052c9 | ||
|
|
117bf608ea | ||
|
|
13e0af4b0a | ||
|
|
e8a0b268dc | ||
|
|
2762390c32 | ||
|
|
a69c4454ca | ||
|
|
c76d0d17ed | ||
|
|
e10706cb6d | ||
|
|
d92b856868 | ||
|
|
d14674e60e | ||
|
|
284dde292a | ||
|
|
ea56cfec2b | ||
|
|
82d7f241bb | ||
|
|
56ac5cd221 | ||
|
|
203a61cfef | ||
|
|
b9500a27d9 | ||
|
|
c979a05c25 | ||
|
|
1e7a2d8dad | ||
|
|
f6a3fb9455 | ||
|
|
9030a06792 | ||
|
|
fffb26deb9 | ||
|
|
fab57364db | ||
|
|
e73cfe8b40 | ||
|
|
facc1ec203 | ||
|
|
6cb9978475 | ||
|
|
f75cee76ae | ||
|
|
c92e553e9b | ||
|
|
a824fc0f3c | ||
|
|
98dea3a15a | ||
|
|
c0d3fdf812 | ||
|
|
1c60a4ddfa | ||
|
|
ac43fb9cac | ||
|
|
2f7e6c1cc2 | ||
|
|
28b76cce76 | ||
|
|
319896267e | ||
|
|
a3698024bc | ||
|
|
8ffca95c59 | ||
|
|
4f68432349 | ||
|
|
17a52da2b8 | ||
|
|
5140053083 | ||
|
|
9b86530763 | ||
|
|
84f289ddc4 | ||
|
|
23cdb279e6 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,6 +20,7 @@ bin/
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/settings.json
|
||||
.claude
|
||||
|
||||
tmp/
|
||||
tmpFiles/
|
||||
|
||||
@@ -69,6 +69,7 @@ p, *, *, GET, /api/get-resources, *, *
|
||||
p, *, *, GET, /api/get-records, *, *
|
||||
p, *, *, GET, /api/get-product, *, *
|
||||
p, *, *, GET, /api/get-products, *, *
|
||||
p, *, *, POST, /api/buy-product, *, *
|
||||
p, *, *, GET, /api/get-order, *, *
|
||||
p, *, *, GET, /api/get-orders, *, *
|
||||
p, *, *, GET, /api/get-user-orders, *, *
|
||||
@@ -86,12 +87,18 @@ p, *, *, GET, /api/get-captcha, *, *
|
||||
p, *, *, POST, /api/verify-captcha, *, *
|
||||
p, *, *, POST, /api/verify-code, *, *
|
||||
p, *, *, POST, /api/v1/traces, *, *
|
||||
p, *, *, POST, /api/v1/metrics, *, *
|
||||
p, *, *, POST, /api/v1/logs, *, *
|
||||
p, *, *, POST, /api/reset-email-or-phone, *, *
|
||||
p, *, *, POST, /api/upload-resource, *, *
|
||||
p, *, *, GET, /.well-known/openid-configuration, *, *
|
||||
p, *, *, GET, /.well-known/oauth-authorization-server, *, *
|
||||
p, *, *, GET, /.well-known/oauth-protected-resource, *, *
|
||||
p, *, *, GET, /.well-known/webfinger, *, *
|
||||
p, *, *, *, /.well-known/jwks, *, *
|
||||
p, *, *, GET, /.well-known/:application/openid-configuration, *, *
|
||||
p, *, *, GET, /.well-known/:application/oauth-authorization-server, *, *
|
||||
p, *, *, GET, /.well-known/:application/oauth-protected-resource, *, *
|
||||
p, *, *, GET, /.well-known/:application/webfinger, *, *
|
||||
p, *, *, *, /.well-known/:application/jwks, *, *
|
||||
p, *, *, GET, /api/get-saml-login, *, *
|
||||
@@ -172,7 +179,7 @@ func IsAllowed(subOwner string, subName string, method string, urlPath string, o
|
||||
return true
|
||||
}
|
||||
|
||||
if user.IsAdmin && (subOwner == objOwner || (objOwner == "admin")) {
|
||||
if user.IsAdmin && subOwner == objOwner {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,6 +374,10 @@ func (c *ApiController) Logout() {
|
||||
return
|
||||
}
|
||||
|
||||
// Retrieve application and token before clearing the session
|
||||
application := c.GetSessionApplication()
|
||||
sessionToken := c.GetSessionToken()
|
||||
|
||||
c.ClearUserSession()
|
||||
c.ClearTokenSession()
|
||||
|
||||
@@ -382,7 +386,9 @@ func (c *ApiController) Logout() {
|
||||
return
|
||||
}
|
||||
|
||||
application := c.GetSessionApplication()
|
||||
// Propagate logout to external Custom OAuth2 providers
|
||||
object.InvokeCustomProviderLogout(application, sessionToken)
|
||||
|
||||
if application == nil || application.Name == "app-built-in" || application.HomepageUrl == "" {
|
||||
c.ResponseOk(user)
|
||||
return
|
||||
@@ -427,6 +433,9 @@ func (c *ApiController) Logout() {
|
||||
return
|
||||
}
|
||||
|
||||
// Propagate logout to external Custom OAuth2 providers
|
||||
object.InvokeCustomProviderLogout(application, accessToken)
|
||||
|
||||
if redirectUri == "" {
|
||||
c.ResponseOk()
|
||||
return
|
||||
@@ -469,6 +478,10 @@ func (c *ApiController) SsoLogout() {
|
||||
logoutAll := c.Ctx.Input.Query("logoutAll")
|
||||
logoutAllSessions := logoutAll == "" || logoutAll == "true" || logoutAll == "1"
|
||||
|
||||
// Retrieve application and token before clearing the session
|
||||
ssoApplication := c.GetSessionApplication()
|
||||
ssoSessionToken := c.GetSessionToken()
|
||||
|
||||
c.ClearUserSession()
|
||||
c.ClearTokenSession()
|
||||
owner, username, err := util.GetOwnerAndNameFromIdWithError(user)
|
||||
@@ -548,6 +561,9 @@ func (c *ApiController) SsoLogout() {
|
||||
}
|
||||
}
|
||||
|
||||
// Propagate logout to external Custom OAuth2 providers
|
||||
object.InvokeCustomProviderLogout(ssoApplication, ssoSessionToken)
|
||||
|
||||
c.ResponseOk()
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ import (
|
||||
"github.com/casdoor/casdoor/object"
|
||||
"github.com/casdoor/casdoor/proxy"
|
||||
"github.com/casdoor/casdoor/util"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
@@ -938,14 +937,7 @@ func (c *ApiController) Login() {
|
||||
}
|
||||
|
||||
if tmpUser != nil {
|
||||
var uid uuid.UUID
|
||||
uid, err = uuid.NewRandom()
|
||||
if err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
uidStr := strings.Split(uid.String(), "-")
|
||||
uidStr := strings.Split(util.GenerateUUID(), "-")
|
||||
userInfo.Username = fmt.Sprintf("%s_%s", userInfo.Username, uidStr[1])
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,9 @@ func (c *ApiController) Enforce() {
|
||||
return
|
||||
}
|
||||
|
||||
var request []string
|
||||
// Accept both plain string arrays (["alice","data1","read"]) and mixed arrays
|
||||
// with JSON objects ([{"DivisionGuid":"x"}, "resource", "read"]) for ABAC support.
|
||||
var request []interface{}
|
||||
err := json.Unmarshal(c.Ctx.Input.RequestBody, &request)
|
||||
if err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
@@ -74,8 +76,8 @@ func (c *ApiController) Enforce() {
|
||||
res := []bool{}
|
||||
keyRes := []string{}
|
||||
|
||||
// type transformation
|
||||
interfaceRequest := util.StringToInterfaceArray(request)
|
||||
// Convert elements: JSON-object strings and maps become anonymous structs for ABAC.
|
||||
interfaceRequest := util.InterfaceToEnforceArray(request)
|
||||
|
||||
enforceResult, err := enforcer.Enforce(interfaceRequest...)
|
||||
if err != nil {
|
||||
@@ -197,7 +199,8 @@ func (c *ApiController) BatchEnforce() {
|
||||
return
|
||||
}
|
||||
|
||||
var requests [][]string
|
||||
// Accept both string arrays and mixed arrays with JSON objects for ABAC support.
|
||||
var requests [][]interface{}
|
||||
err := json.Unmarshal(c.Ctx.Input.RequestBody, &requests)
|
||||
if err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
@@ -214,8 +217,8 @@ func (c *ApiController) BatchEnforce() {
|
||||
res := [][]bool{}
|
||||
keyRes := []string{}
|
||||
|
||||
// type transformation
|
||||
interfaceRequests := util.StringToInterfaceArray2d(requests)
|
||||
// Convert elements: JSON-object strings and maps become anonymous structs for ABAC.
|
||||
interfaceRequests := util.InterfaceToEnforceArray2d(requests)
|
||||
|
||||
enforceResult, err := enforcer.BatchEnforce(interfaceRequests)
|
||||
if err != nil {
|
||||
|
||||
@@ -88,6 +88,25 @@ func (c *ApiController) GetEntry() {
|
||||
c.ResponseOk(entry)
|
||||
}
|
||||
|
||||
// GetOpenClawSessionGraph
|
||||
// @Title GetOpenClawSessionGraph
|
||||
// @Tag Entry API
|
||||
// @Description get OpenClaw session graph
|
||||
// @Param id query string true "The id ( owner/name ) of the entry"
|
||||
// @Success 200 {object} object.OpenClawSessionGraph The Response object
|
||||
// @router /get-openclaw-session-graph [get]
|
||||
func (c *ApiController) GetOpenClawSessionGraph() {
|
||||
id := c.Ctx.Input.Query("id")
|
||||
|
||||
graph, err := object.GetOpenClawSessionGraph(id)
|
||||
if err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.ResponseOk(graph)
|
||||
}
|
||||
|
||||
// UpdateEntry
|
||||
// @Title UpdateEntry
|
||||
// @Tag Entry API
|
||||
|
||||
@@ -15,63 +15,134 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/casdoor/casdoor/object"
|
||||
collogspb "go.opentelemetry.io/proto/otlp/collector/logs/v1"
|
||||
colmetricspb "go.opentelemetry.io/proto/otlp/collector/metrics/v1"
|
||||
coltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/casdoor/casdoor/util"
|
||||
)
|
||||
|
||||
// AddOtlpEntry
|
||||
// @Title AddTrace
|
||||
// @Title AddOtlpTrace
|
||||
// @Tag OTLP API
|
||||
// @Description receive otlp trace protobuf
|
||||
// @Success 200 {object} string
|
||||
// @router /api/v1/traces [post]
|
||||
func (c *ApiController) AddTrace() {
|
||||
if !strings.HasPrefix(c.Ctx.Input.Header("Content-Type"), "application/x-protobuf") {
|
||||
c.Ctx.Output.SetStatus(415)
|
||||
c.Ctx.Output.Body([]byte("unsupported content type"))
|
||||
func (c *ApiController) AddOtlpTrace() {
|
||||
body := readProtobufBody(c.Ctx)
|
||||
if body == nil {
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
provider, status, err := resolveOpenClawProvider(c.Ctx)
|
||||
if err != nil {
|
||||
c.Ctx.Output.SetStatus(400)
|
||||
c.Ctx.Output.Body([]byte("read body failed"))
|
||||
responseOtlpError(c.Ctx, status, body, "%s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var req coltracepb.ExportTraceServiceRequest
|
||||
|
||||
if err := proto.Unmarshal(body, &req); err != nil {
|
||||
c.Ctx.Output.SetStatus(400)
|
||||
c.Ctx.Output.Body([]byte(fmt.Sprintf("bad protobuf: %v", err)))
|
||||
responseOtlpError(c.Ctx, 400, body, "bad protobuf: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
message, err := protojson.Marshal(&req)
|
||||
message, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(&req)
|
||||
if err != nil {
|
||||
c.Ctx.Output.SetStatus(500)
|
||||
c.Ctx.Output.Body([]byte(fmt.Sprintf("marshal trace failed: %v", err)))
|
||||
responseOtlpError(c.Ctx, 500, body, "marshal trace failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
entry := object.NewTraceEntry(message)
|
||||
|
||||
if _, err := object.AddEntry(entry); err != nil {
|
||||
c.Ctx.Output.SetStatus(500)
|
||||
c.Ctx.Output.Body([]byte(fmt.Sprintf("save trace failed: %v", err)))
|
||||
clientIp := util.GetClientIpFromRequest(c.Ctx.Request)
|
||||
userAgent := c.Ctx.Request.Header.Get("User-Agent")
|
||||
if err := provider.AddTrace(message, clientIp, userAgent); err != nil {
|
||||
responseOtlpError(c.Ctx, 500, body, "save trace failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := &coltracepb.ExportTraceServiceResponse{}
|
||||
respBytes, _ := proto.Marshal(resp)
|
||||
|
||||
resp, _ := proto.Marshal(&coltracepb.ExportTraceServiceResponse{})
|
||||
c.Ctx.Output.Header("Content-Type", "application/x-protobuf")
|
||||
c.Ctx.Output.SetStatus(200)
|
||||
c.Ctx.Output.Body(respBytes)
|
||||
c.Ctx.Output.Body(resp)
|
||||
}
|
||||
|
||||
// @Title AddOtlpMetrics
|
||||
// @Tag OTLP API
|
||||
// @Description receive otlp metrics protobuf
|
||||
// @Success 200 {object} string
|
||||
// @router /api/v1/metrics [post]
|
||||
func (c *ApiController) AddOtlpMetrics() {
|
||||
body := readProtobufBody(c.Ctx)
|
||||
if body == nil {
|
||||
return
|
||||
}
|
||||
provider, status, err := resolveOpenClawProvider(c.Ctx)
|
||||
if err != nil {
|
||||
responseOtlpError(c.Ctx, status, body, "%s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var req colmetricspb.ExportMetricsServiceRequest
|
||||
if err := proto.Unmarshal(body, &req); err != nil {
|
||||
responseOtlpError(c.Ctx, 400, body, "bad protobuf: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
message, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(&req)
|
||||
if err != nil {
|
||||
responseOtlpError(c.Ctx, 500, body, "marshal metrics failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
clientIp := util.GetClientIpFromRequest(c.Ctx.Request)
|
||||
userAgent := c.Ctx.Request.Header.Get("User-Agent")
|
||||
if err := provider.AddMetrics(message, clientIp, userAgent); err != nil {
|
||||
responseOtlpError(c.Ctx, 500, body, "save metrics failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
resp, _ := proto.Marshal(&colmetricspb.ExportMetricsServiceResponse{})
|
||||
c.Ctx.Output.Header("Content-Type", "application/x-protobuf")
|
||||
c.Ctx.Output.SetStatus(200)
|
||||
c.Ctx.Output.Body(resp)
|
||||
}
|
||||
|
||||
// @Title AddOtlpLogs
|
||||
// @Tag OTLP API
|
||||
// @Description receive otlp logs protobuf
|
||||
// @Success 200 {object} string
|
||||
// @router /api/v1/logs [post]
|
||||
func (c *ApiController) AddOtlpLogs() {
|
||||
body := readProtobufBody(c.Ctx)
|
||||
if body == nil {
|
||||
return
|
||||
}
|
||||
provider, status, err := resolveOpenClawProvider(c.Ctx)
|
||||
if err != nil {
|
||||
responseOtlpError(c.Ctx, status, body, "%s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var req collogspb.ExportLogsServiceRequest
|
||||
if err := proto.Unmarshal(body, &req); err != nil {
|
||||
responseOtlpError(c.Ctx, 400, body, "bad protobuf: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
message, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(&req)
|
||||
if err != nil {
|
||||
responseOtlpError(c.Ctx, 500, body, "marshal logs failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
clientIp := util.GetClientIpFromRequest(c.Ctx.Request)
|
||||
userAgent := c.Ctx.Request.Header.Get("User-Agent")
|
||||
if err := provider.AddLogs(message, clientIp, userAgent); err != nil {
|
||||
responseOtlpError(c.Ctx, 500, body, "save logs failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
resp, _ := proto.Marshal(&collogspb.ExportLogsServiceResponse{})
|
||||
c.Ctx.Output.Header("Content-Type", "application/x-protobuf")
|
||||
c.Ctx.Output.SetStatus(200)
|
||||
c.Ctx.Output.Body(resp)
|
||||
}
|
||||
|
||||
78
controllers/entry_util.go
Normal file
78
controllers/entry_util.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/beego/beego/v2/server/web/context"
|
||||
"github.com/casdoor/casdoor/log"
|
||||
"github.com/casdoor/casdoor/object"
|
||||
"github.com/casdoor/casdoor/util"
|
||||
)
|
||||
|
||||
func responseOtlpError(ctx *context.Context, status int, body []byte, format string, args ...interface{}) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
req := ctx.Request
|
||||
bodyInfo := "(no body)"
|
||||
if len(body) > 0 {
|
||||
bodyInfo = fmt.Sprintf("%d bytes: %q", len(body), truncate(body, 256))
|
||||
}
|
||||
fmt.Printf("responseOtlpError: [%d] %s | %s %s | remoteAddr=%s | Content-Type=%s | User-Agent=%s | body=%s\n",
|
||||
status, msg,
|
||||
req.Method, req.URL.Path,
|
||||
req.RemoteAddr,
|
||||
req.Header.Get("Content-Type"),
|
||||
req.Header.Get("User-Agent"),
|
||||
bodyInfo,
|
||||
)
|
||||
ctx.Output.SetStatus(status)
|
||||
ctx.Output.Body([]byte(msg))
|
||||
}
|
||||
|
||||
func truncate(b []byte, max int) []byte {
|
||||
if len(b) <= max {
|
||||
return b
|
||||
}
|
||||
return b[:max]
|
||||
}
|
||||
|
||||
func resolveOpenClawProvider(ctx *context.Context) (*log.OpenClawProvider, int, error) {
|
||||
clientIP := util.GetClientIpFromRequest(ctx.Request)
|
||||
provider, err := object.GetOpenClawProviderByIP(clientIP)
|
||||
if err != nil {
|
||||
return nil, 500, fmt.Errorf("provider lookup failed: %w", err)
|
||||
}
|
||||
if provider == nil {
|
||||
return nil, 403, fmt.Errorf("forbidden: no OpenClaw provider configured for IP %s", clientIP)
|
||||
}
|
||||
return provider, 0, nil
|
||||
}
|
||||
|
||||
func readProtobufBody(ctx *context.Context) []byte {
|
||||
if !strings.HasPrefix(ctx.Input.Header("Content-Type"), "application/x-protobuf") {
|
||||
preview, _ := io.ReadAll(io.LimitReader(ctx.Request.Body, 256))
|
||||
responseOtlpError(ctx, 415, preview, "unsupported content type")
|
||||
return nil
|
||||
}
|
||||
body, err := io.ReadAll(ctx.Request.Body)
|
||||
if err != nil {
|
||||
responseOtlpError(ctx, 400, nil, "read body failed")
|
||||
return nil
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -32,7 +32,7 @@ import (
|
||||
// @Param owner path string true "The owner name of the server"
|
||||
// @Param name path string true "The name of the server"
|
||||
// @Success 200 {object} mcp.McpResponse The Response object
|
||||
// @router /server/:owner/:name [post]
|
||||
// @router /server/:owner/:name [get,post]
|
||||
func (c *ApiController) ProxyServer() {
|
||||
owner := c.Ctx.Input.Param(":owner")
|
||||
name := c.Ctx.Input.Param(":name")
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
|
||||
"github.com/casdoor/casdoor/object"
|
||||
"github.com/casdoor/casdoor/util"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// MfaSetupInitiate
|
||||
@@ -77,7 +76,7 @@ func (c *ApiController) MfaSetupInitiate() {
|
||||
return
|
||||
}
|
||||
|
||||
recoveryCode := uuid.NewString()
|
||||
recoveryCode := util.GenerateUUID()
|
||||
mfaProps.RecoveryCodes = []string{recoveryCode}
|
||||
mfaProps.MfaRememberInHours = organization.MfaRememberInHours
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/beego/beego/v2/core/utils/pagination"
|
||||
"github.com/casdoor/casdoor/object"
|
||||
@@ -149,3 +151,78 @@ func (c *ApiController) DeleteProduct() {
|
||||
c.Data["json"] = wrapActionResponse(object.DeleteProduct(&product))
|
||||
c.ServeJSON()
|
||||
}
|
||||
|
||||
// BuyProduct
|
||||
// @Title BuyProduct (Deprecated)
|
||||
// @Tag Product API
|
||||
// @Description buy product using the deprecated compatibility endpoint, prefer place-order plus pay-order for new integrations
|
||||
// @Param id query string true "The id ( owner/name ) of the product"
|
||||
// @Param providerName query string true "The name of the provider"
|
||||
// @Param pricingName query string false "The name of the pricing (for subscription)"
|
||||
// @Param planName query string false "The name of the plan (for subscription)"
|
||||
// @Param userName query string false "The username to buy product for (admin only)"
|
||||
// @Param paymentEnv query string false "The payment environment"
|
||||
// @Param customPrice query number false "Custom price for recharge products"
|
||||
// @Success 200 {object} controllers.Response The Response object
|
||||
// @router /buy-product [post]
|
||||
func (c *ApiController) BuyProduct() {
|
||||
id := c.Ctx.Input.Query("id")
|
||||
host := c.Ctx.Request.Host
|
||||
providerName := c.Ctx.Input.Query("providerName")
|
||||
paymentEnv := c.Ctx.Input.Query("paymentEnv")
|
||||
customPriceStr := c.Ctx.Input.Query("customPrice")
|
||||
if customPriceStr == "" {
|
||||
customPriceStr = "0"
|
||||
}
|
||||
|
||||
customPrice, err := strconv.ParseFloat(customPriceStr, 64)
|
||||
if err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
pricingName := c.Ctx.Input.Query("pricingName")
|
||||
planName := c.Ctx.Input.Query("planName")
|
||||
paidUserName := c.Ctx.Input.Query("userName")
|
||||
|
||||
owner, _, err := util.GetOwnerAndNameFromIdWithError(id)
|
||||
if err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var userId string
|
||||
if paidUserName != "" {
|
||||
userId = util.GetId(owner, paidUserName)
|
||||
if userId != c.GetSessionUsername() && !c.IsAdmin() && userId != c.GetPaidUsername() {
|
||||
c.ResponseError(c.T("general:Only admin user can specify user"))
|
||||
return
|
||||
}
|
||||
|
||||
c.SetSession("paidUsername", "")
|
||||
} else {
|
||||
userId = c.GetSessionUsername()
|
||||
}
|
||||
if userId == "" {
|
||||
c.ResponseError(c.T("general:Please login first"))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := object.GetUser(userId)
|
||||
if err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
c.ResponseError(fmt.Sprintf(c.T("general:The user: %s doesn't exist"), userId))
|
||||
return
|
||||
}
|
||||
|
||||
payment, attachInfo, err := object.BuyProduct(id, user, providerName, pricingName, planName, host, paymentEnv, customPrice, c.GetAcceptLanguage())
|
||||
if err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.ResponseOk(payment, attachInfo)
|
||||
}
|
||||
|
||||
@@ -110,6 +110,30 @@ func (c *ApiController) UpdateServer() {
|
||||
c.ServeJSON()
|
||||
}
|
||||
|
||||
// SyncMcpTool
|
||||
// @Title SyncMcpTool
|
||||
// @Tag Server API
|
||||
// @Description sync MCP tools for a server and return sync errors directly
|
||||
// @Param id query string true "The id ( owner/name ) of the server"
|
||||
// @Param isCleared query bool false "Whether to clear all tools instead of syncing"
|
||||
// @Param body body object.Server true "The details of the server"
|
||||
// @Success 200 {object} controllers.Response The Response object
|
||||
// @router /sync-mcp-tool [post]
|
||||
func (c *ApiController) SyncMcpTool() {
|
||||
id := c.Ctx.Input.Query("id")
|
||||
isCleared := c.Ctx.Input.Query("isCleared") == "1"
|
||||
|
||||
var server object.Server
|
||||
err := json.Unmarshal(c.Ctx.Input.RequestBody, &server)
|
||||
if err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.Data["json"] = wrapActionResponse(object.SyncMcpTool(id, &server, isCleared))
|
||||
c.ServeJSON()
|
||||
}
|
||||
|
||||
// AddServer
|
||||
// @Title AddServer
|
||||
// @Tag Server API
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const onlineServerListUrl = "https://remotemcplist.com/api/servers.json"
|
||||
const onlineServerListUrl = "https://mcp.casdoor.org/registry.json"
|
||||
|
||||
// GetOnlineServers
|
||||
// @Title GetOnlineServers
|
||||
|
||||
170
controllers/server_sync.go
Normal file
170
controllers/server_sync.go
Normal file
@@ -0,0 +1,170 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/casdoor/casdoor/mcp"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSyncTimeoutMs = 1200
|
||||
defaultSyncMaxConcurrency = 32
|
||||
maxSyncHosts = 1024
|
||||
)
|
||||
|
||||
var (
|
||||
defaultSyncPorts = []int{3000, 8080, 80}
|
||||
defaultSyncPaths = []string{"/", "/mcp", "/sse", "/mcp/sse"}
|
||||
)
|
||||
|
||||
type SyncInnerServersRequest struct {
|
||||
CIDR []string `json:"cidr"`
|
||||
Scheme string `json:"scheme"`
|
||||
Ports []string `json:"ports"`
|
||||
Paths []string `json:"paths"`
|
||||
TimeoutMs int `json:"timeoutMs"`
|
||||
MaxConcurrency int `json:"maxConcurrency"`
|
||||
}
|
||||
|
||||
type SyncInnerServersResult struct {
|
||||
CIDR []string `json:"cidr"`
|
||||
ScannedHosts int `json:"scannedHosts"`
|
||||
OnlineHosts []string `json:"onlineHosts"`
|
||||
Servers []*mcp.InnerMcpServer `json:"servers"`
|
||||
}
|
||||
|
||||
// SyncIntranetServers
|
||||
// @Title SyncIntranetServers
|
||||
// @Tag Server API
|
||||
// @Description scan intranet IP/CIDR targets and detect MCP servers by probing common ports and paths
|
||||
// @Param body body controllers.SyncInnerServersRequest true "Intranet MCP server scan request"
|
||||
// @Success 200 {object} controllers.Response The Response object
|
||||
// @router /sync-intranet-servers [post]
|
||||
func (c *ApiController) SyncIntranetServers() {
|
||||
_, ok := c.RequireAdmin()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req SyncInnerServersRequest
|
||||
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &req); err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
for i := range req.CIDR {
|
||||
req.CIDR[i] = strings.TrimSpace(req.CIDR[i])
|
||||
}
|
||||
if len(req.CIDR) == 0 {
|
||||
c.ResponseError("scan target (CIDR/IP) is required")
|
||||
return
|
||||
}
|
||||
|
||||
hosts, err := mcp.ParseScanTargets(req.CIDR, maxSyncHosts)
|
||||
if err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
timeout := mcp.SanitizeTimeout(req.TimeoutMs, defaultSyncTimeoutMs, 10000)
|
||||
concurrency := mcp.SanitizeConcurrency(req.MaxConcurrency, defaultSyncMaxConcurrency, 256)
|
||||
ports := mcp.SanitizePorts(req.Ports, defaultSyncPorts)
|
||||
paths := mcp.SanitizePaths(req.Paths, defaultSyncPaths)
|
||||
scheme := mcp.SanitizeScheme(req.Scheme)
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
onlineHostSet := map[string]struct{}{}
|
||||
serverMap := map[string]*mcp.InnerMcpServer{}
|
||||
mutex := sync.Mutex{}
|
||||
waitGroup := sync.WaitGroup{}
|
||||
sem := make(chan struct{}, concurrency)
|
||||
|
||||
for _, host := range hosts {
|
||||
host := host.String()
|
||||
waitGroup.Add(1)
|
||||
go func() {
|
||||
defer waitGroup.Done()
|
||||
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
defer func() { <-sem }()
|
||||
|
||||
isOnline, servers := mcp.ProbeHost(ctx, client, scheme, host, ports, paths, timeout)
|
||||
if !isOnline {
|
||||
return
|
||||
}
|
||||
|
||||
mutex.Lock()
|
||||
onlineHostSet[host] = struct{}{}
|
||||
for _, server := range servers {
|
||||
serverMap[server.Url] = server
|
||||
}
|
||||
mutex.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
waitGroup.Wait()
|
||||
|
||||
onlineHosts := make([]string, 0, len(onlineHostSet))
|
||||
for host := range onlineHostSet {
|
||||
onlineHosts = append(onlineHosts, host)
|
||||
}
|
||||
slices.Sort(onlineHosts)
|
||||
|
||||
servers := make([]*mcp.InnerMcpServer, 0, len(serverMap))
|
||||
for _, server := range serverMap {
|
||||
servers = append(servers, server)
|
||||
}
|
||||
slices.SortFunc(servers, func(a, b *mcp.InnerMcpServer) int {
|
||||
if a.Url < b.Url {
|
||||
return -1
|
||||
}
|
||||
if a.Url > b.Url {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
c.ResponseOk(&SyncInnerServersResult{
|
||||
CIDR: req.CIDR,
|
||||
ScannedHosts: len(hosts),
|
||||
OnlineHosts: onlineHosts,
|
||||
Servers: servers,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *ApiController) SyncInnerServers() {
|
||||
c.SyncIntranetServers()
|
||||
}
|
||||
@@ -250,6 +250,9 @@ func (c *ApiController) GetOAuthToken() {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract DPoP proof header (RFC 9449). Empty string when DPoP is not used.
|
||||
dpopProof := c.Ctx.Request.Header.Get("DPoP")
|
||||
|
||||
host := c.Ctx.Request.Host
|
||||
if deviceCode != "" {
|
||||
deviceAuthCache, ok := object.DeviceAuthMap.Load(deviceCode)
|
||||
@@ -291,7 +294,7 @@ func (c *ApiController) GetOAuthToken() {
|
||||
username = deviceAuthCacheCast.UserName
|
||||
}
|
||||
|
||||
token, err := object.GetOAuthToken(grantType, clientId, clientSecret, code, verifier, scope, nonce, username, password, host, refreshToken, tag, avatar, c.GetAcceptLanguage(), subjectToken, subjectTokenType, assertion, clientAssertion, clientAssertionType, audience, resource)
|
||||
token, err := object.GetOAuthToken(grantType, clientId, clientSecret, code, verifier, scope, nonce, username, password, host, refreshToken, tag, avatar, c.GetAcceptLanguage(), subjectToken, subjectTokenType, assertion, clientAssertion, clientAssertionType, audience, resource, dpopProof)
|
||||
if err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
return
|
||||
@@ -340,7 +343,8 @@ func (c *ApiController) RefreshToken() {
|
||||
return
|
||||
}
|
||||
|
||||
refreshToken2, err := object.RefreshToken(application, grantType, refreshToken, scope, clientId, clientSecret, host)
|
||||
dpopProof := c.Ctx.Request.Header.Get("DPoP")
|
||||
refreshToken2, err := object.RefreshToken(application, grantType, refreshToken, scope, clientId, clientSecret, host, dpopProof)
|
||||
if err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
return
|
||||
@@ -556,6 +560,11 @@ func (c *ApiController) IntrospectToken() {
|
||||
|
||||
introspectionResponse.TokenType = token.TokenType
|
||||
introspectionResponse.ClientId = application.ClientId
|
||||
|
||||
// Expose DPoP key binding in the introspection response (RFC 9449 §8).
|
||||
if token.DPoPJkt != "" {
|
||||
introspectionResponse.Cnf = &object.DPoPConfirmation{JKT: token.DPoPJkt}
|
||||
}
|
||||
}
|
||||
|
||||
c.Data["json"] = introspectionResponse
|
||||
|
||||
@@ -252,6 +252,10 @@ func (c *ApiController) SendVerificationCode() {
|
||||
return
|
||||
}
|
||||
|
||||
if vform.CaptchaToken != "" {
|
||||
enableCaptcha = true
|
||||
}
|
||||
|
||||
// Only verify CAPTCHA if it should be enabled
|
||||
if enableCaptcha {
|
||||
captchaProvider, err := object.GetCaptchaProviderByApplication(vform.ApplicationId, "false", c.GetAcceptLanguage())
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/casdoor/casdoor/util"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -141,7 +141,7 @@ func (a *AzureACSEmailProvider) Send(fromAddress string, fromName string, toAddr
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("repeatability-request-id", uuid.New().String())
|
||||
req.Header.Set("repeatability-request-id", util.GenerateUUID())
|
||||
req.Header.Set("repeatability-first-sent", time.Now().UTC().Format(http.TimeFormat))
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
63
log/agent_openclaw.go
Normal file
63
log/agent_openclaw.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package log
|
||||
|
||||
import "fmt"
|
||||
|
||||
// OtlpAdder persists a single OTLP record into the backing store.
|
||||
// Parameters: entryType ("trace"/"metrics"/"log"), message (JSON payload),
|
||||
// clientIp and userAgent from the originating HTTP request.
|
||||
// The unique entry name is generated by the implementation.
|
||||
type OtlpAdder func(entryType, message, clientIp, userAgent string) error
|
||||
|
||||
// OpenClawProvider receives OpenTelemetry data pushed by an OpenClaw agent over
|
||||
// HTTP and persists each record as an Entry row via the OtlpAdder supplied at
|
||||
// construction time. It is passive (push-based via HTTP): Start/Stop are no-ops
|
||||
// and Write is not applicable.
|
||||
type OpenClawProvider struct {
|
||||
providerName string
|
||||
addOtlpEntry OtlpAdder
|
||||
}
|
||||
|
||||
// NewOpenClawProvider creates an OpenClawProvider backed by addOtlpEntry.
|
||||
func NewOpenClawProvider(providerName string, addOtlpEntry OtlpAdder) *OpenClawProvider {
|
||||
return &OpenClawProvider{providerName: providerName, addOtlpEntry: addOtlpEntry}
|
||||
}
|
||||
|
||||
// Write is not applicable for an HTTP-push provider and always returns an error.
|
||||
func (p *OpenClawProvider) Write(_, _ string) error {
|
||||
return fmt.Errorf("OpenClawProvider receives data over HTTP and does not accept Write calls")
|
||||
}
|
||||
|
||||
// Start is a no-op; OpenClawProvider is passive and has no background goroutine.
|
||||
func (p *OpenClawProvider) Start(_ EntryAdder, _ func(error)) error { return nil }
|
||||
|
||||
// Stop is a no-op.
|
||||
func (p *OpenClawProvider) Stop() error { return nil }
|
||||
|
||||
// AddTrace persists an OTLP trace payload (already serialised to JSON).
|
||||
func (p *OpenClawProvider) AddTrace(message []byte, clientIp, userAgent string) error {
|
||||
return p.addOtlpEntry("trace", string(message), clientIp, userAgent)
|
||||
}
|
||||
|
||||
// AddMetrics persists an OTLP metrics payload (already serialised to JSON).
|
||||
func (p *OpenClawProvider) AddMetrics(message []byte, clientIp, userAgent string) error {
|
||||
return p.addOtlpEntry("metrics", string(message), clientIp, userAgent)
|
||||
}
|
||||
|
||||
// AddLogs persists an OTLP logs payload (already serialised to JSON).
|
||||
func (p *OpenClawProvider) AddLogs(message []byte, clientIp, userAgent string) error {
|
||||
return p.addOtlpEntry("log", string(message), clientIp, userAgent)
|
||||
}
|
||||
47
log/casdoor_permission.go
Normal file
47
log/casdoor_permission.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package log
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PermissionLogProvider records Casbin authorization decisions as Entry rows.
|
||||
// It is push-based: callers supply log lines via Write, which are immediately
|
||||
// persisted through the injected EntryAdder. Start and Stop are no-ops.
|
||||
type PermissionLogProvider struct {
|
||||
providerName string
|
||||
addEntry EntryAdder
|
||||
}
|
||||
|
||||
// NewPermissionLogProvider creates a PermissionLogProvider backed by addEntry.
|
||||
func NewPermissionLogProvider(providerName string, addEntry EntryAdder) *PermissionLogProvider {
|
||||
return &PermissionLogProvider{providerName: providerName, addEntry: addEntry}
|
||||
}
|
||||
|
||||
// Write stores one permission-log entry.
|
||||
// severity follows syslog conventions (e.g. info, warning, err).
|
||||
func (p *PermissionLogProvider) Write(severity string, message string) error {
|
||||
createdTime := time.Now().UTC().Format(time.RFC3339)
|
||||
return p.addEntry("built-in", createdTime, p.providerName, fmt.Sprintf("[%s] %s", severity, message))
|
||||
}
|
||||
|
||||
// Start is a no-op for PermissionLogProvider; it received its EntryAdder at
|
||||
// construction time and does not require background collection.
|
||||
func (p *PermissionLogProvider) Start(_ EntryAdder, _ func(error)) error { return nil }
|
||||
|
||||
// Stop is a no-op for PermissionLogProvider.
|
||||
func (p *PermissionLogProvider) Stop() error { return nil }
|
||||
75
log/provider.go
Normal file
75
log/provider.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package log
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/thanhpk/randstr"
|
||||
)
|
||||
|
||||
// GenerateEntryName returns a cryptographically random 32-character hex string
|
||||
// suitable for use as an Entry.Name primary key.
|
||||
func GenerateEntryName() string {
|
||||
return randstr.Hex(16)
|
||||
}
|
||||
|
||||
// EntryAdder persists a collected log entry into the backing store.
|
||||
// Parameters map to the Entry table columns: owner, createdTime (RFC3339),
|
||||
// provider (the log provider name), and message. The unique entry name is
|
||||
// generated by the implementation, so callers do not need to supply one.
|
||||
// Defined here so it is shared by all LogProvider implementations without
|
||||
// creating import cycles with the object package.
|
||||
type EntryAdder func(owner, createdTime, provider, message string) error
|
||||
|
||||
// LogProvider is the common interface for all log providers.
|
||||
//
|
||||
// Push-based providers (e.g. PermissionLogProvider) receive individual log
|
||||
// lines through Write and persist them immediately. Start and Stop are no-ops
|
||||
// for these providers.
|
||||
//
|
||||
// Pull-based providers (e.g. SystemLogProvider) actively collect logs from an
|
||||
// external source. Start begins a background collection goroutine that calls
|
||||
// addEntry for every new record; Stop halts collection. Write returns an error
|
||||
// for these providers as they are not designed to accept external input.
|
||||
type LogProvider interface {
|
||||
// Write records a single log line. Used by push-based providers.
|
||||
Write(severity string, message string) error
|
||||
// Start begins background log collection with the given EntryAdder.
|
||||
// For push-based providers this is a no-op (they received addEntry at
|
||||
// construction time). onError is called from the background goroutine
|
||||
// when collection stops with a fatal error; it may be nil.
|
||||
Start(addEntry EntryAdder, onError func(error)) error
|
||||
// Stop halts background collection and releases any OS resources.
|
||||
Stop() error
|
||||
}
|
||||
|
||||
// GetLogProvider returns a concrete log provider for the given type and connection settings.
|
||||
// The title parameter is used as the OS log tag for System Log.
|
||||
// Types that are not yet implemented return a non-nil error.
|
||||
func GetLogProvider(typ string, _ string, _ int, title string) (LogProvider, error) {
|
||||
switch typ {
|
||||
case "System Log":
|
||||
tag := title
|
||||
if tag == "" {
|
||||
tag = "casdoor"
|
||||
}
|
||||
return NewSystemLogProvider(tag)
|
||||
case "SELinux Log":
|
||||
return NewSELinuxLogProvider()
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported log provider type: %s", typ)
|
||||
}
|
||||
}
|
||||
66
log/selinux_log.go
Normal file
66
log/selinux_log.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package log
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// SELinuxLogProvider collects SELinux audit events (AVC denials and related
|
||||
// records) from the local system and stores each record as an Entry row via
|
||||
// the EntryAdder supplied to Start.
|
||||
//
|
||||
// It is pull-based: Write is not applicable and returns an error.
|
||||
// Start launches the background collector; Stop cancels it.
|
||||
// On platforms where SELinux is not supported, Start returns an error.
|
||||
type SELinuxLogProvider struct {
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewSELinuxLogProvider creates a SELinuxLogProvider.
|
||||
// Call Start to begin collection.
|
||||
func NewSELinuxLogProvider() (*SELinuxLogProvider, error) {
|
||||
return &SELinuxLogProvider{}, nil
|
||||
}
|
||||
|
||||
// Write is not applicable for a pull-based collector and always returns an error.
|
||||
func (s *SELinuxLogProvider) Write(severity string, message string) error {
|
||||
return fmt.Errorf("SELinuxLogProvider is a log collector and does not accept Write calls")
|
||||
}
|
||||
|
||||
// Start launches a background goroutine that reads new SELinux audit records
|
||||
// and persists each one by calling addEntry. Returns immediately; collection
|
||||
// runs until Stop is called. If the goroutine encounters a fatal error,
|
||||
// onError is called with that error (onError may be nil).
|
||||
func (s *SELinuxLogProvider) Start(addEntry EntryAdder, onError func(error)) error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.cancel = cancel
|
||||
go func() {
|
||||
if err := collectSELinuxLogs(ctx, addEntry); err != nil && onError != nil {
|
||||
onError(err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop cancels background collection. It is safe to call multiple times.
|
||||
func (s *SELinuxLogProvider) Stop() error {
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
s.cancel = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
140
log/selinux_log_linux.go
Normal file
140
log/selinux_log_linux.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build linux
|
||||
|
||||
package log
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const auditLogPath = "/var/log/audit/audit.log"
|
||||
|
||||
// selinuxAuditTypes is the set of audit record types that are SELinux-related.
|
||||
var selinuxAuditTypes = map[string]bool{
|
||||
"AVC": true,
|
||||
"USER_AVC": true,
|
||||
"SELINUX_ERR": true,
|
||||
"MAC_POLICY_LOAD": true,
|
||||
"MAC_STATUS": true,
|
||||
}
|
||||
|
||||
// auditTimestampRe matches the msg=audit(seconds.millis:serial) field.
|
||||
var auditTimestampRe = regexp.MustCompile(`msg=audit\((\d+)\.\d+:\d+\)`)
|
||||
|
||||
// CheckSELinuxAvailable returns nil if SELinux is active and the audit log is
|
||||
// readable on this system. Returns a descriptive error otherwise.
|
||||
func CheckSELinuxAvailable() error {
|
||||
if _, err := os.Stat("/sys/fs/selinux/enforce"); os.IsNotExist(err) {
|
||||
return fmt.Errorf("SELinux is not available or not mounted on this system")
|
||||
}
|
||||
if _, err := os.Stat(auditLogPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("SELinux audit log not found at %s (is auditd running?)", auditLogPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectSELinuxLogs tails /var/log/audit/audit.log and persists each
|
||||
// SELinux-related audit record via addEntry until ctx is cancelled.
|
||||
func collectSELinuxLogs(ctx context.Context, addEntry EntryAdder) error {
|
||||
if err := CheckSELinuxAvailable(); err != nil {
|
||||
return fmt.Errorf("SELinuxLogProvider: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "tail", "-f", "-n", "0", auditLogPath)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("SELinuxLogProvider: failed to open audit log pipe: %w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("SELinuxLogProvider: failed to start tail: %w", err)
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
for scanner.Scan() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
|
||||
line := scanner.Text()
|
||||
if !isSELinuxAuditLine(line) {
|
||||
continue
|
||||
}
|
||||
|
||||
severity := selinuxSeverity(line)
|
||||
createdTime := parseAuditTimestamp(line)
|
||||
if err := addEntry("built-in", createdTime, "",
|
||||
fmt.Sprintf("[%s] %s", severity, line)); err != nil {
|
||||
return fmt.Errorf("SELinuxLogProvider: failed to persist audit entry: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("SELinuxLogProvider: audit log read error: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isSELinuxAuditLine reports whether the audit log line is an SELinux record.
|
||||
func isSELinuxAuditLine(line string) bool {
|
||||
// Audit lines start with "type=<TYPE> "
|
||||
const prefix = "type="
|
||||
if !strings.HasPrefix(line, prefix) {
|
||||
return false
|
||||
}
|
||||
end := strings.IndexByte(line[len(prefix):], ' ')
|
||||
var typ string
|
||||
if end < 0 {
|
||||
typ = line[len(prefix):]
|
||||
} else {
|
||||
typ = line[len(prefix) : len(prefix)+end]
|
||||
}
|
||||
return selinuxAuditTypes[typ]
|
||||
}
|
||||
|
||||
// selinuxSeverity maps SELinux audit record types to a syslog severity name.
|
||||
func selinuxSeverity(line string) string {
|
||||
if strings.HasPrefix(line, "type=AVC") || strings.HasPrefix(line, "type=USER_AVC") || strings.HasPrefix(line, "type=SELINUX_ERR") {
|
||||
return "warning"
|
||||
}
|
||||
return "info"
|
||||
}
|
||||
|
||||
// parseAuditTimestamp extracts the Unix timestamp from an audit log line and
|
||||
// returns it as an RFC3339 string. Falls back to the current time on failure.
|
||||
func parseAuditTimestamp(line string) string {
|
||||
m := auditTimestampRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
sec, err := strconv.ParseInt(m[1], 10, 64)
|
||||
if err != nil {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
return time.Unix(sec, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
33
log/selinux_log_unsupported.go
Normal file
33
log/selinux_log_unsupported.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build !linux
|
||||
|
||||
package log
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// CheckSELinuxAvailable always returns an error on non-Linux platforms.
|
||||
func CheckSELinuxAvailable() error {
|
||||
return fmt.Errorf("SELinux is not supported on %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
// collectSELinuxLogs is a no-op on non-Linux platforms.
|
||||
func collectSELinuxLogs(_ context.Context, _ EntryAdder) error {
|
||||
return CheckSELinuxAvailable()
|
||||
}
|
||||
79
log/system_log.go
Normal file
79
log/system_log.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package log
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// platformCollector is an OS-specific log reader.
|
||||
// Implementations are in system_log_unix.go and system_log_windows.go.
|
||||
type platformCollector interface {
|
||||
// collect blocks and streams new OS log records to addEntry until ctx is
|
||||
// cancelled or a fatal error occurs. It must return promptly when
|
||||
// ctx.Done() is closed. A non-nil error means collection stopped
|
||||
// unexpectedly and should be reported to the operator.
|
||||
collect(ctx context.Context, addEntry EntryAdder) error
|
||||
}
|
||||
|
||||
// SystemLogProvider collects log records from the operating-system's native
|
||||
// logging facility (journald/syslog on Linux/Unix, Event Log on Windows) and
|
||||
// stores each record as an Entry row via the EntryAdder supplied to Start.
|
||||
//
|
||||
// It is pull-based: Write is not applicable and returns an error.
|
||||
// Start launches the background collector; Stop cancels it.
|
||||
type SystemLogProvider struct {
|
||||
tag string
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewSystemLogProvider creates a SystemLogProvider that will identify itself
|
||||
// with the given tag when collecting OS log records.
|
||||
// Call Start to begin collection.
|
||||
func NewSystemLogProvider(tag string) (*SystemLogProvider, error) {
|
||||
return &SystemLogProvider{tag: tag}, nil
|
||||
}
|
||||
|
||||
// Write is not applicable for a pull-based collector and always returns an
|
||||
// error. Callers in the permission-log path should skip System Log providers.
|
||||
func (s *SystemLogProvider) Write(severity string, message string) error {
|
||||
return fmt.Errorf("SystemLogProvider is a log collector and does not accept Write calls")
|
||||
}
|
||||
|
||||
// Start launches a background goroutine that reads new OS log records and
|
||||
// persists each one by calling addEntry. It returns immediately; collection
|
||||
// runs until Stop is called. If the goroutine encounters a fatal error,
|
||||
// onError is called with that error (onError may be nil).
|
||||
func (s *SystemLogProvider) Start(addEntry EntryAdder, onError func(error)) error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.cancel = cancel
|
||||
collector := newPlatformCollector(s.tag)
|
||||
go func() {
|
||||
if err := collector.collect(ctx, addEntry); err != nil && onError != nil {
|
||||
onError(err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop cancels background collection. It is safe to call multiple times.
|
||||
func (s *SystemLogProvider) Stop() error {
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
s.cancel = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
121
log/system_log_unix.go
Normal file
121
log/system_log_unix.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package log
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type unixCollector struct {
|
||||
tag string
|
||||
}
|
||||
|
||||
func newPlatformCollector(tag string) platformCollector {
|
||||
return &unixCollector{tag: tag}
|
||||
}
|
||||
|
||||
// collect streams new journald records to addEntry until ctx is cancelled or
|
||||
// a fatal error occurs. It runs `journalctl -n 0 -f --output=json` so only
|
||||
// records that arrive after Start is called are collected (no backfill).
|
||||
// Returns nil when ctx is cancelled normally; returns a non-nil error if the
|
||||
// process could not be started or the output pipe broke unexpectedly.
|
||||
func (u *unixCollector) collect(ctx context.Context, addEntry EntryAdder) error {
|
||||
cmd := exec.CommandContext(ctx, "journalctl", "-n", "0", "-f", "--output=json")
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("SystemLogProvider: failed to open journalctl stdout pipe: %w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("SystemLogProvider: failed to start journalctl: %w", err)
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
// journald JSON lines can be large; use a 1 MB buffer.
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
|
||||
var fields map[string]interface{}
|
||||
if err := json.Unmarshal(scanner.Bytes(), &fields); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
severity := journalSeverity(fields)
|
||||
message := journalMessage(fields)
|
||||
createdTime := journalTimestamp(fields)
|
||||
if err := addEntry("built-in", createdTime, u.tag,
|
||||
fmt.Sprintf("[%s] %s", severity, message)); err != nil {
|
||||
return fmt.Errorf("SystemLogProvider: failed to persist journal entry: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
// A cancelled context causes the pipe to close; treat that as normal exit.
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("SystemLogProvider: journalctl output error: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// journalSeverity maps the journald PRIORITY field to a syslog severity name.
|
||||
// PRIORITY values: 0=emerg 1=alert 2=crit 3=err 4=warning 5=notice 6=info 7=debug
|
||||
func journalSeverity(fields map[string]interface{}) string {
|
||||
mapping := map[string]string{
|
||||
"0": "emerg", "1": "alert", "2": "crit", "3": "err",
|
||||
"4": "warning", "5": "notice", "6": "info", "7": "debug",
|
||||
}
|
||||
if p, ok := fields["PRIORITY"].(string); ok {
|
||||
if s, ok2 := mapping[p]; ok2 {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return "info"
|
||||
}
|
||||
|
||||
// journalMessage extracts the human-readable message from journald JSON.
|
||||
func journalMessage(fields map[string]interface{}) string {
|
||||
if msg, ok := fields["MESSAGE"].(string); ok {
|
||||
return msg
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// journalTimestamp converts the journald __REALTIME_TIMESTAMP (microseconds
|
||||
// since Unix epoch) to an RFC3339 string.
|
||||
func journalTimestamp(fields map[string]interface{}) string {
|
||||
if ts, ok := fields["__REALTIME_TIMESTAMP"].(string); ok {
|
||||
usec, err := strconv.ParseInt(ts, 10, 64)
|
||||
if err == nil {
|
||||
t := time.Unix(usec/1_000_000, (usec%1_000_000)*1_000).UTC()
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
180
log/system_log_windows.go
Normal file
180
log/system_log_windows.go
Normal file
@@ -0,0 +1,180 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build windows
|
||||
|
||||
package log
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Windows Event Log channels to collect from.
|
||||
var eventLogChannels = []string{"System", "Application"}
|
||||
|
||||
type windowsCollector struct {
|
||||
tag string
|
||||
}
|
||||
|
||||
func newPlatformCollector(tag string) platformCollector {
|
||||
return &windowsCollector{tag: tag}
|
||||
}
|
||||
|
||||
// collect polls Windows Event Log channels every 5 seconds via wevtutil.exe
|
||||
// and persists new records to addEntry. Only events that arrive after Start
|
||||
// is called are collected; historical events are not backfilled.
|
||||
// Returns nil when ctx is cancelled normally.
|
||||
func (w *windowsCollector) collect(ctx context.Context, addEntry EntryAdder) error {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
lastCheck := time.Now().UTC()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case tick := <-ticker.C:
|
||||
for _, channel := range eventLogChannels {
|
||||
if err := w.queryChannel(ctx, channel, lastCheck, addEntry); err != nil {
|
||||
return fmt.Errorf("SystemLogProvider: error querying channel %s: %w", channel, err)
|
||||
}
|
||||
}
|
||||
lastCheck = tick.UTC()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// queryChannel runs wevtutil.exe to fetch events from channel that were
|
||||
// created after since, then stores each event via addEntry.
|
||||
// Returns a non-nil error if the wevtutil command fails or XML parsing fails.
|
||||
func (w *windowsCollector) queryChannel(ctx context.Context, channel string, since time.Time, addEntry EntryAdder) error {
|
||||
sinceStr := since.Format("2006-01-02T15:04:05.000Z")
|
||||
query := fmt.Sprintf("*[System[TimeCreated[@SystemTime>='%s']]]", sinceStr)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "wevtutil.exe", "qe", channel,
|
||||
"/f:RenderedXml", "/rd:false",
|
||||
fmt.Sprintf("/q:%s", query),
|
||||
)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
// A cancelled context is a normal shutdown, not an error.
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("wevtutil.exe failed for channel %s: %w", channel, err)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return w.parseAndPersistEvents(out, channel, addEntry)
|
||||
}
|
||||
|
||||
// parseAndPersistEvents decodes wevtutil XML output and persists each Event
|
||||
// record via addEntry. wevtutil outputs one <Event> element per record;
|
||||
// the output is wrapped in a synthetic <Events> root so the decoder can
|
||||
// handle multiple records in one pass. Token()+DecodeElement() is used to
|
||||
// skip the wrapper element without triggering an XMLName mismatch error.
|
||||
func (w *windowsCollector) parseAndPersistEvents(out []byte, channel string, addEntry EntryAdder) error {
|
||||
wrapped := "<Events>" + string(out) + "</Events>"
|
||||
decoder := xml.NewDecoder(strings.NewReader(wrapped))
|
||||
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return fmt.Errorf("SystemLogProvider: failed to parse event XML (channel=%s): %w", channel, err)
|
||||
}
|
||||
se, ok := token.(xml.StartElement)
|
||||
if !ok || se.Name.Local != "Event" {
|
||||
continue
|
||||
}
|
||||
|
||||
var event winEvent
|
||||
if err := decoder.DecodeElement(&event, &se); err != nil {
|
||||
return fmt.Errorf("SystemLogProvider: failed to decode event XML (channel=%s): %w", channel, err)
|
||||
}
|
||||
|
||||
severity := winEventSeverity(event.System.Level)
|
||||
message := strings.TrimSpace(event.RenderingInfo.Message)
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("EventID=%d Source=%s", event.System.EventID, event.System.Provider.Name)
|
||||
}
|
||||
createdTime := winEventTimestamp(event.System.TimeCreated.SystemTime)
|
||||
if err := addEntry("built-in", createdTime, w.tag,
|
||||
fmt.Sprintf("[%s] [%s] %s", severity, channel, message)); err != nil {
|
||||
return fmt.Errorf("SystemLogProvider: failed to persist event (channel=%s EventID=%d): %w",
|
||||
channel, event.System.EventID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// winEvent represents the subset of the Windows Event XML schema that we need.
|
||||
type winEvent struct {
|
||||
XMLName xml.Name `xml:"Event"`
|
||||
System struct {
|
||||
Provider struct {
|
||||
Name string `xml:"Name,attr"`
|
||||
} `xml:"Provider"`
|
||||
EventID int `xml:"EventID"`
|
||||
Level int `xml:"Level"`
|
||||
TimeCreated struct {
|
||||
SystemTime string `xml:"SystemTime,attr"`
|
||||
} `xml:"TimeCreated"`
|
||||
} `xml:"System"`
|
||||
RenderingInfo struct {
|
||||
Message string `xml:"Message"`
|
||||
} `xml:"RenderingInfo"`
|
||||
}
|
||||
|
||||
// winEventSeverity maps Windows Event Log Level values to syslog severity names.
|
||||
// Level: 1=Critical 2=Error 3=Warning 4=Information 5=Verbose
|
||||
func winEventSeverity(level int) string {
|
||||
switch level {
|
||||
case 1:
|
||||
return "crit"
|
||||
case 2:
|
||||
return "err"
|
||||
case 3:
|
||||
return "warning"
|
||||
case 5:
|
||||
return "debug"
|
||||
default: // 4=Information and anything else
|
||||
return "info"
|
||||
}
|
||||
}
|
||||
|
||||
// winEventTimestamp parses a Windows Event SystemTime attribute string to RFC3339.
|
||||
func winEventTimestamp(s string) string {
|
||||
// SystemTime is in the form "2024-01-15T10:30:00.000000000Z"
|
||||
t, err := time.Parse(time.RFC3339Nano, s)
|
||||
if err != nil {
|
||||
// Try without nanoseconds
|
||||
t, err = time.Parse("2006-01-02T15:04:05.000000000Z", s)
|
||||
if err != nil {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package logprovider
|
||||
|
||||
import "fmt"
|
||||
|
||||
// LogProvider sends application log lines to an external logging backend.
|
||||
// Severity uses syslog-style names (e.g. emerg, alert, crit, err, warning, notice, info, debug).
|
||||
type LogProvider interface {
|
||||
Write(severity string, message string) error
|
||||
}
|
||||
|
||||
// GetLogProvider returns a concrete log provider for the given type and connection settings.
|
||||
// Types that are not yet implemented return a non-nil error.
|
||||
func GetLogProvider(typ string, _ string, _ int, _ string) (LogProvider, error) {
|
||||
switch typ {
|
||||
case "Linux Syslog":
|
||||
return nil, fmt.Errorf("Linux Syslog log provider is not implemented yet")
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported log provider type: %s", typ)
|
||||
}
|
||||
}
|
||||
2
main.go
2
main.go
@@ -66,6 +66,7 @@ func main() {
|
||||
}
|
||||
|
||||
object.InitDefaultStorageProvider()
|
||||
object.InitLogProviders()
|
||||
object.InitLdapAutoSynchronizer()
|
||||
proxy.InitHttpClient()
|
||||
authz.InitApi()
|
||||
@@ -89,6 +90,7 @@ func main() {
|
||||
web.SetStaticPath("/swagger", "swagger")
|
||||
web.SetStaticPath("/files", "files")
|
||||
// https://studygolang.com/articles/2303
|
||||
web.InsertFilter("*", web.BeforeStatic, routers.RequestBodyFilter)
|
||||
web.InsertFilter("*", web.BeforeRouter, routers.StaticFilter)
|
||||
web.InsertFilter("*", web.BeforeRouter, routers.AutoSigninFilter)
|
||||
web.InsertFilter("*", web.BeforeRouter, routers.CorsFilter)
|
||||
|
||||
324
mcp/util.go
324
mcp/util.go
@@ -16,6 +16,12 @@ package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/casdoor/casdoor/util"
|
||||
@@ -23,6 +29,13 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type InnerMcpServer struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Path string `json:"path"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
func GetServerTools(owner, name, url, token string) ([]*mcpsdk.Tool, error) {
|
||||
var session *mcpsdk.ClientSession
|
||||
var err error
|
||||
@@ -30,11 +43,21 @@ func GetServerTools(owner, name, url, token string) ([]*mcpsdk.Tool, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute*10)
|
||||
defer cancel()
|
||||
client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: util.GetId(owner, name), Version: "1.0.0"}, nil)
|
||||
if token != "" {
|
||||
httpClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}))
|
||||
session, err = client.Connect(ctx, &mcpsdk.StreamableClientTransport{Endpoint: url, HTTPClient: httpClient}, nil)
|
||||
|
||||
if strings.HasSuffix(url, "sse") {
|
||||
if token != "" {
|
||||
httpClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}))
|
||||
session, err = client.Connect(ctx, &mcpsdk.StreamableClientTransport{Endpoint: url, HTTPClient: httpClient}, nil)
|
||||
} else {
|
||||
session, err = client.Connect(ctx, &mcpsdk.StreamableClientTransport{Endpoint: url}, nil)
|
||||
}
|
||||
} else {
|
||||
session, err = client.Connect(ctx, &mcpsdk.StreamableClientTransport{Endpoint: url}, nil)
|
||||
if token != "" {
|
||||
httpClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}))
|
||||
session, err = client.Connect(ctx, &mcpsdk.StreamableClientTransport{Endpoint: url, HTTPClient: httpClient}, nil)
|
||||
} else {
|
||||
session, err = client.Connect(ctx, &mcpsdk.StreamableClientTransport{Endpoint: url}, nil)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -49,3 +72,296 @@ func GetServerTools(owner, name, url, token string) ([]*mcpsdk.Tool, error) {
|
||||
|
||||
return toolResult.Tools, nil
|
||||
}
|
||||
|
||||
func SanitizeScheme(scheme string) string {
|
||||
scheme = strings.ToLower(strings.TrimSpace(scheme))
|
||||
if scheme == "https" {
|
||||
return "https"
|
||||
}
|
||||
return "http"
|
||||
}
|
||||
|
||||
func SanitizeTimeout(timeoutMs int, defaultTimeoutMs int, maxTimeoutMs int) time.Duration {
|
||||
if timeoutMs <= 0 {
|
||||
timeoutMs = defaultTimeoutMs
|
||||
}
|
||||
if timeoutMs > maxTimeoutMs {
|
||||
timeoutMs = maxTimeoutMs
|
||||
}
|
||||
return time.Duration(timeoutMs) * time.Millisecond
|
||||
}
|
||||
|
||||
func SanitizeConcurrency(maxConcurrency int, defaultConcurrency int, maxAllowed int) int {
|
||||
if maxConcurrency <= 0 {
|
||||
maxConcurrency = defaultConcurrency
|
||||
}
|
||||
if maxConcurrency > maxAllowed {
|
||||
maxConcurrency = maxAllowed
|
||||
}
|
||||
return maxConcurrency
|
||||
}
|
||||
|
||||
func SanitizePorts(portInputs []string, defaultPorts []int) []int {
|
||||
if len(portInputs) == 0 {
|
||||
return append([]int{}, defaultPorts...)
|
||||
}
|
||||
|
||||
portSet := map[int]struct{}{}
|
||||
result := make([]int, 0, len(portInputs))
|
||||
for _, portInput := range portInputs {
|
||||
portInput = strings.TrimSpace(portInput)
|
||||
if portInput == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(portInput, "-") {
|
||||
parts := strings.SplitN(portInput, "-", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
start, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
end, err := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if start > end {
|
||||
continue
|
||||
}
|
||||
|
||||
if start < 1 {
|
||||
start = 1
|
||||
}
|
||||
if end > 65535 {
|
||||
end = 65535
|
||||
}
|
||||
if start > end {
|
||||
continue
|
||||
}
|
||||
|
||||
for port := start; port <= end; port++ {
|
||||
if _, ok := portSet[port]; ok {
|
||||
continue
|
||||
}
|
||||
portSet[port] = struct{}{}
|
||||
result = append(result, port)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(portInput)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if port <= 0 || port > 65535 {
|
||||
continue
|
||||
}
|
||||
if _, ok := portSet[port]; ok {
|
||||
continue
|
||||
}
|
||||
portSet[port] = struct{}{}
|
||||
result = append(result, port)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return append([]int{}, defaultPorts...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func SanitizePaths(paths []string, defaultPaths []string) []string {
|
||||
if len(paths) == 0 {
|
||||
return append([]string{}, defaultPaths...)
|
||||
}
|
||||
|
||||
pathSet := map[string]struct{}{}
|
||||
result := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
if _, ok := pathSet[path]; ok {
|
||||
continue
|
||||
}
|
||||
pathSet[path] = struct{}{}
|
||||
result = append(result, path)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return append([]string{}, defaultPaths...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func ParseScanTargets(targets []string, maxHosts int) ([]net.IP, error) {
|
||||
hostSet := map[uint32]struct{}{}
|
||||
hosts := make([]net.IP, 0)
|
||||
|
||||
addHost := func(ipv4 net.IP) error {
|
||||
value := binary.BigEndian.Uint32(ipv4)
|
||||
if _, ok := hostSet[value]; ok {
|
||||
return nil
|
||||
}
|
||||
if len(hosts) >= maxHosts {
|
||||
return fmt.Errorf("scan targets exceed max %d hosts", maxHosts)
|
||||
}
|
||||
hostSet[value] = struct{}{}
|
||||
host := make(net.IP, net.IPv4len)
|
||||
copy(host, ipv4)
|
||||
hosts = append(hosts, host)
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, target := range targets {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(target); ip != nil {
|
||||
ipv4 := ip.To4()
|
||||
if ipv4 == nil {
|
||||
return nil, fmt.Errorf("only IPv4 is supported: %s", target)
|
||||
}
|
||||
if !util.IsIntranetIp(ipv4.String()) {
|
||||
return nil, fmt.Errorf("target must be intranet: %s", target)
|
||||
}
|
||||
if err := addHost(ipv4); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
cidrHosts, err := ParseCIDRHosts(target, maxHosts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, host := range cidrHosts {
|
||||
if !util.IsIntranetIp(host.String()) {
|
||||
return nil, fmt.Errorf("target must be intranet: %s", target)
|
||||
}
|
||||
if err = addHost(host.To4()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(hosts) == 0 {
|
||||
return nil, fmt.Errorf("cidr is required")
|
||||
}
|
||||
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
func ParseCIDRHosts(cidr string, maxHosts int) ([]net.IP, error) {
|
||||
baseIp, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ipv4 := baseIp.To4()
|
||||
if ipv4 == nil {
|
||||
return nil, fmt.Errorf("only IPv4 CIDR is supported")
|
||||
}
|
||||
if !util.IsIntranetIp(ipv4.String()) {
|
||||
return nil, fmt.Errorf("cidr must be intranet: %s", cidr)
|
||||
}
|
||||
|
||||
ones, bits := ipNet.Mask.Size()
|
||||
hostBits := bits - ones
|
||||
if hostBits < 0 {
|
||||
return nil, fmt.Errorf("invalid cidr mask: %s", cidr)
|
||||
}
|
||||
|
||||
if hostBits >= 63 {
|
||||
return nil, fmt.Errorf("cidr range is too large")
|
||||
}
|
||||
total := uint64(1) << hostBits
|
||||
if total > uint64(maxHosts)+2 {
|
||||
return nil, fmt.Errorf("cidr range is too large, max %d hosts", maxHosts)
|
||||
}
|
||||
|
||||
totalInt := int(total)
|
||||
start := binary.BigEndian.Uint32(ipv4.Mask(ipNet.Mask))
|
||||
end := start + uint32(total) - 1
|
||||
hosts := make([]net.IP, 0, totalInt)
|
||||
for value := start; value <= end; value++ {
|
||||
if total > 2 && (value == start || value == end) {
|
||||
continue
|
||||
}
|
||||
|
||||
candidate := make(net.IP, net.IPv4len)
|
||||
binary.BigEndian.PutUint32(candidate, value)
|
||||
if ipNet.Contains(candidate) {
|
||||
hosts = append(hosts, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
if len(hosts) == 0 {
|
||||
return nil, fmt.Errorf("cidr has no usable hosts: %s", cidr)
|
||||
}
|
||||
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
func ProbeHost(ctx context.Context, client *http.Client, scheme, host string, ports []int, paths []string, timeout time.Duration) (bool, []*InnerMcpServer) {
|
||||
if !util.IsIntranetIp(host) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{Timeout: timeout}
|
||||
isOnline := false
|
||||
var servers []*InnerMcpServer
|
||||
|
||||
for _, port := range ports {
|
||||
address := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
conn, err := dialer.DialContext(ctx, "tcp", address)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = conn.Close()
|
||||
isOnline = true
|
||||
|
||||
for _, path := range paths {
|
||||
server, ok := probeMcpInitialize(ctx, client, scheme, host, port, path)
|
||||
if ok {
|
||||
servers = append(servers, server)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return isOnline, servers
|
||||
}
|
||||
|
||||
func probeMcpInitialize(ctx context.Context, client *http.Client, scheme, host string, port int, path string) (*InnerMcpServer, bool) {
|
||||
fullUrl := fmt.Sprintf("%s://%s%s", scheme, net.JoinHostPort(host, strconv.Itoa(port)), path)
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, fullUrl, nil)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return &InnerMcpServer{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Path: path,
|
||||
Url: fullUrl,
|
||||
}, true
|
||||
}
|
||||
|
||||
@@ -161,6 +161,9 @@ func (adapter *Adapter) InitAdapter() error {
|
||||
}
|
||||
} else {
|
||||
driverName = adapter.DatabaseType
|
||||
if driverName == "sqlite3" {
|
||||
driverName = "sqlite"
|
||||
}
|
||||
switch driverName {
|
||||
case "mssql":
|
||||
dataSourceName = fmt.Sprintf("sqlserver://%s:%s@%s:%d?database=%s", adapter.User,
|
||||
@@ -174,7 +177,7 @@ func (adapter *Adapter) InitAdapter() error {
|
||||
case "CockroachDB":
|
||||
dataSourceName = fmt.Sprintf("user=%s password=%s host=%s port=%d sslmode=disable dbname=%s serial_normalization=virtual_sequence",
|
||||
adapter.User, adapter.Password, adapter.Host, adapter.Port, adapter.Database)
|
||||
case "sqlite3":
|
||||
case "sqlite":
|
||||
dataSourceName = fmt.Sprintf("file:%s", adapter.Host)
|
||||
default:
|
||||
return fmt.Errorf("unsupported database type: %s", adapter.DatabaseType)
|
||||
|
||||
@@ -64,6 +64,53 @@ func (a *SafeAdapter) RemovePolicies(sec string, ptype string, rules [][]string)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *SafeAdapter) UpdatePolicy(sec string, ptype string, oldRule []string, newRule []string) error {
|
||||
oldLine := a.buildCasbinRule(ptype, oldRule)
|
||||
newLine := a.buildCasbinRule(ptype, newRule)
|
||||
|
||||
session := a.engine.NewSession()
|
||||
defer session.Close()
|
||||
|
||||
if a.tableName != "" {
|
||||
session = session.Table(a.tableName)
|
||||
}
|
||||
|
||||
_, err := session.
|
||||
Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ? AND v3 = ? AND v4 = ? AND v5 = ?",
|
||||
oldLine.Ptype, oldLine.V0, oldLine.V1, oldLine.V2, oldLine.V3, oldLine.V4, oldLine.V5).
|
||||
MustCols("ptype", "v0", "v1", "v2", "v3", "v4", "v5").
|
||||
Update(newLine)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *SafeAdapter) UpdatePolicies(sec string, ptype string, oldRules [][]string, newRules [][]string) error {
|
||||
_, err := a.engine.Transaction(func(tx *xorm.Session) (interface{}, error) {
|
||||
for i, oldRule := range oldRules {
|
||||
oldLine := a.buildCasbinRule(ptype, oldRule)
|
||||
newLine := a.buildCasbinRule(ptype, newRules[i])
|
||||
|
||||
var session *xorm.Session
|
||||
if a.tableName != "" {
|
||||
session = tx.Table(a.tableName)
|
||||
} else {
|
||||
session = tx
|
||||
}
|
||||
|
||||
_, err := session.
|
||||
Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ? AND v3 = ? AND v4 = ? AND v5 = ?",
|
||||
oldLine.Ptype, oldLine.V0, oldLine.V1, oldLine.V2, oldLine.V3, oldLine.V4, oldLine.V5).
|
||||
MustCols("ptype", "v0", "v1", "v2", "v3", "v4", "v5").
|
||||
Update(newLine)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *SafeAdapter) buildCasbinRule(ptype string, rule []string) *xormadapter.CasbinRule {
|
||||
line := xormadapter.CasbinRule{Ptype: ptype}
|
||||
|
||||
|
||||
@@ -17,8 +17,6 @@ package object
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/casdoor/casdoor/i18n"
|
||||
"github.com/casdoor/casdoor/util"
|
||||
@@ -96,6 +94,7 @@ type Application struct {
|
||||
HeaderHtml string `xorm:"mediumtext" json:"headerHtml"`
|
||||
EnablePassword bool `json:"enablePassword"`
|
||||
EnableSignUp bool `json:"enableSignUp"`
|
||||
EnableGuestSignin bool `json:"enableGuestSignin"`
|
||||
DisableSignin bool `json:"disableSignin"`
|
||||
EnableSigninSession bool `json:"enableSigninSession"`
|
||||
EnableAutoSignin bool `json:"enableAutoSignin"`
|
||||
@@ -221,192 +220,6 @@ func GetPaginationOrganizationApplications(owner, organization string, offset, l
|
||||
return applications, nil
|
||||
}
|
||||
|
||||
func getProviderMap(owner string) (m map[string]*Provider, err error) {
|
||||
providers, err := GetProviders(owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m = map[string]*Provider{}
|
||||
for _, provider := range providers {
|
||||
m[provider.Name] = GetMaskedProvider(provider, true)
|
||||
}
|
||||
|
||||
return m, err
|
||||
}
|
||||
|
||||
func extendApplicationWithProviders(application *Application) (err error) {
|
||||
m, err := getProviderMap(application.Organization)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, providerItem := range application.Providers {
|
||||
if provider, ok := m[providerItem.Name]; ok {
|
||||
providerItem.Provider = provider
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func extendApplicationWithOrg(application *Application) (err error) {
|
||||
organization, err := getOrganization(application.Owner, application.Organization)
|
||||
application.OrganizationObj = organization
|
||||
return
|
||||
}
|
||||
|
||||
func extendApplicationWithSigninItems(application *Application) (err error) {
|
||||
if len(application.SigninItems) == 0 {
|
||||
signinItem := &SigninItem{
|
||||
Name: "Back button",
|
||||
Visible: true,
|
||||
CustomCss: ".back-button {\n top: 65px;\n left: 15px;\n position: absolute;\n}\n.back-inner-button{}",
|
||||
Placeholder: "",
|
||||
Rule: "None",
|
||||
}
|
||||
application.SigninItems = append(application.SigninItems, signinItem)
|
||||
signinItem = &SigninItem{
|
||||
Name: "Languages",
|
||||
Visible: true,
|
||||
CustomCss: ".login-languages {\n top: 55px;\n right: 5px;\n position: absolute;\n}",
|
||||
Placeholder: "",
|
||||
Rule: "None",
|
||||
}
|
||||
application.SigninItems = append(application.SigninItems, signinItem)
|
||||
signinItem = &SigninItem{
|
||||
Name: "Logo",
|
||||
Visible: true,
|
||||
CustomCss: ".login-logo-box {}",
|
||||
Placeholder: "",
|
||||
Rule: "None",
|
||||
}
|
||||
application.SigninItems = append(application.SigninItems, signinItem)
|
||||
signinItem = &SigninItem{
|
||||
Name: "Signin methods",
|
||||
Visible: true,
|
||||
CustomCss: ".signin-methods {}",
|
||||
Placeholder: "",
|
||||
Rule: "None",
|
||||
}
|
||||
application.SigninItems = append(application.SigninItems, signinItem)
|
||||
signinItem = &SigninItem{
|
||||
Name: "Username",
|
||||
Visible: true,
|
||||
CustomCss: ".login-username {}\n.login-username-input{}",
|
||||
Placeholder: "",
|
||||
Rule: "None",
|
||||
}
|
||||
application.SigninItems = append(application.SigninItems, signinItem)
|
||||
signinItem = &SigninItem{
|
||||
Name: "Password",
|
||||
Visible: true,
|
||||
CustomCss: ".login-password {}\n.login-password-input{}",
|
||||
Placeholder: "",
|
||||
Rule: "None",
|
||||
}
|
||||
application.SigninItems = append(application.SigninItems, signinItem)
|
||||
signinItem = &SigninItem{
|
||||
Name: "Verification code",
|
||||
Visible: true,
|
||||
CustomCss: ".verification-code {}\n.verification-code-input{}",
|
||||
Placeholder: "",
|
||||
Rule: "None",
|
||||
}
|
||||
application.SigninItems = append(application.SigninItems, signinItem)
|
||||
signinItem = &SigninItem{
|
||||
Name: "Agreement",
|
||||
Visible: true,
|
||||
CustomCss: ".login-agreement {}",
|
||||
Placeholder: "",
|
||||
Rule: "None",
|
||||
}
|
||||
application.SigninItems = append(application.SigninItems, signinItem)
|
||||
signinItem = &SigninItem{
|
||||
Name: "Forgot password?",
|
||||
Visible: true,
|
||||
CustomCss: ".login-forget-password {\n display: inline-flex;\n justify-content: space-between;\n width: 320px;\n margin-bottom: 25px;\n}",
|
||||
Placeholder: "",
|
||||
Rule: "None",
|
||||
}
|
||||
application.SigninItems = append(application.SigninItems, signinItem)
|
||||
signinItem = &SigninItem{
|
||||
Name: "Login button",
|
||||
Visible: true,
|
||||
CustomCss: ".login-button-box {\n margin-bottom: 5px;\n}\n.login-button {\n width: 100%;\n}",
|
||||
Placeholder: "",
|
||||
Rule: "None",
|
||||
}
|
||||
application.SigninItems = append(application.SigninItems, signinItem)
|
||||
signinItem = &SigninItem{
|
||||
Name: "Signup link",
|
||||
Visible: true,
|
||||
CustomCss: ".login-signup-link {\n margin-bottom: 24px;\n display: flex;\n justify-content: end;\n}",
|
||||
Placeholder: "",
|
||||
Rule: "None",
|
||||
}
|
||||
application.SigninItems = append(application.SigninItems, signinItem)
|
||||
signinItem = &SigninItem{
|
||||
Name: "Providers",
|
||||
Visible: true,
|
||||
CustomCss: ".provider-img {\n width: 30px;\n margin: 5px;\n}\n.provider-big-img {\n margin-bottom: 10px;\n}",
|
||||
Placeholder: "",
|
||||
Rule: "None",
|
||||
}
|
||||
application.SigninItems = append(application.SigninItems, signinItem)
|
||||
}
|
||||
for idx, item := range application.SigninItems {
|
||||
if item.Label != "" && item.CustomCss == "" {
|
||||
application.SigninItems[idx].CustomCss = item.Label
|
||||
application.SigninItems[idx].Label = ""
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func extendApplicationWithSigninMethods(application *Application) (err error) {
|
||||
if len(application.SigninMethods) == 0 {
|
||||
if application.EnablePassword {
|
||||
signinMethod := &SigninMethod{Name: "Password", DisplayName: "Password", Rule: "All"}
|
||||
application.SigninMethods = append(application.SigninMethods, signinMethod)
|
||||
}
|
||||
if application.EnableCodeSignin {
|
||||
signinMethod := &SigninMethod{Name: "Verification code", DisplayName: "Verification code", Rule: "All"}
|
||||
application.SigninMethods = append(application.SigninMethods, signinMethod)
|
||||
}
|
||||
if application.EnableWebAuthn {
|
||||
signinMethod := &SigninMethod{Name: "WebAuthn", DisplayName: "WebAuthn", Rule: "None"}
|
||||
application.SigninMethods = append(application.SigninMethods, signinMethod)
|
||||
}
|
||||
|
||||
signinMethod := &SigninMethod{Name: "Face ID", DisplayName: "Face ID", Rule: "None"}
|
||||
application.SigninMethods = append(application.SigninMethods, signinMethod)
|
||||
}
|
||||
|
||||
if len(application.SigninMethods) == 0 {
|
||||
signinMethod := &SigninMethod{Name: "Password", DisplayName: "Password", Rule: "All"}
|
||||
application.SigninMethods = append(application.SigninMethods, signinMethod)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func extendApplicationWithSignupItems(application *Application) (err error) {
|
||||
if len(application.SignupItems) == 0 {
|
||||
application.SignupItems = []*SignupItem{
|
||||
{Name: "ID", Visible: false, Required: true, Prompted: false, Rule: "Random"},
|
||||
{Name: "Username", Visible: true, Required: true, Prompted: false, Rule: "None"},
|
||||
{Name: "Display name", Visible: true, Required: true, Prompted: false, Rule: "None"},
|
||||
{Name: "Password", Visible: true, Required: true, Prompted: false, Rule: "None"},
|
||||
{Name: "Confirm password", Visible: true, Required: true, Prompted: false, Rule: "None"},
|
||||
{Name: "Email", Visible: true, Required: true, Prompted: false, Rule: "None"},
|
||||
{Name: "Phone", Visible: true, Required: true, Prompted: false, Rule: "None"},
|
||||
{Name: "Agreement", Visible: true, Required: true, Prompted: false, Rule: "None"},
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func getApplication(owner string, name string) (*Application, error) {
|
||||
if owner == "" || name == "" {
|
||||
return nil, nil
|
||||
@@ -559,155 +372,6 @@ func GetApplication(id string) (*Application, error) {
|
||||
return getApplication(owner, name)
|
||||
}
|
||||
|
||||
func GetMaskedApplication(application *Application, userId string) *Application {
|
||||
if application == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if application.TokenFields == nil {
|
||||
application.TokenFields = []string{}
|
||||
}
|
||||
|
||||
if application.FailedSigninLimit == 0 {
|
||||
application.FailedSigninLimit = DefaultFailedSigninLimit
|
||||
}
|
||||
if application.FailedSigninFrozenTime == 0 {
|
||||
application.FailedSigninFrozenTime = DefaultFailedSigninFrozenTime
|
||||
}
|
||||
|
||||
isOrgUser := false
|
||||
if userId != "" {
|
||||
if isUserIdGlobalAdmin(userId) {
|
||||
return application
|
||||
}
|
||||
|
||||
user, err := GetUser(userId)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if user != nil {
|
||||
if user.IsApplicationAdmin(application) {
|
||||
return application
|
||||
}
|
||||
|
||||
if user.Owner == application.Organization {
|
||||
isOrgUser = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
application.ClientSecret = "***"
|
||||
application.Cert = "***"
|
||||
application.EnablePassword = false
|
||||
application.EnableSigninSession = false
|
||||
application.EnableCodeSignin = false
|
||||
application.EnableSamlCompress = false
|
||||
application.EnableSamlC14n10 = false
|
||||
application.EnableSamlPostBinding = false
|
||||
application.DisableSamlAttributes = false
|
||||
application.EnableWebAuthn = false
|
||||
application.EnableLinkWithEmail = false
|
||||
application.SamlReplyUrl = "***"
|
||||
|
||||
providerItems := []*ProviderItem{}
|
||||
for _, providerItem := range application.Providers {
|
||||
if providerItem.Provider != nil && (providerItem.Provider.Category == "OAuth" || providerItem.Provider.Category == "Web3" || providerItem.Provider.Category == "Captcha" || providerItem.Provider.Category == "SAML" || providerItem.Provider.Category == "Face ID") {
|
||||
providerItems = append(providerItems, providerItem)
|
||||
}
|
||||
}
|
||||
application.Providers = providerItems
|
||||
|
||||
application.GrantTypes = []string{}
|
||||
application.RedirectUris = []string{}
|
||||
application.TokenFormat = "***"
|
||||
application.TokenFields = []string{}
|
||||
application.ExpireInHours = -1
|
||||
application.RefreshExpireInHours = -1
|
||||
application.FailedSigninLimit = -1
|
||||
application.FailedSigninFrozenTime = -1
|
||||
|
||||
if application.OrganizationObj != nil {
|
||||
application.OrganizationObj.MasterPassword = "***"
|
||||
application.OrganizationObj.DefaultPassword = "***"
|
||||
application.OrganizationObj.MasterVerificationCode = "***"
|
||||
application.OrganizationObj.PasswordType = "***"
|
||||
application.OrganizationObj.PasswordSalt = "***"
|
||||
application.OrganizationObj.InitScore = -1
|
||||
application.OrganizationObj.EnableSoftDeletion = false
|
||||
|
||||
if !isOrgUser {
|
||||
application.OrganizationObj.MfaItems = nil
|
||||
if !application.OrganizationObj.IsProfilePublic {
|
||||
application.OrganizationObj.AccountItems = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return application
|
||||
}
|
||||
|
||||
func GetMaskedApplications(applications []*Application, userId string) []*Application {
|
||||
if isUserIdGlobalAdmin(userId) {
|
||||
return applications
|
||||
}
|
||||
|
||||
for _, application := range applications {
|
||||
application = GetMaskedApplication(application, userId)
|
||||
}
|
||||
return applications
|
||||
}
|
||||
|
||||
func GetAllowedApplications(applications []*Application, userId string, lang string) ([]*Application, error) {
|
||||
if userId == "" {
|
||||
return nil, errors.New(i18n.Translate(lang, "auth:Unauthorized operation"))
|
||||
}
|
||||
|
||||
if isUserIdGlobalAdmin(userId) {
|
||||
return applications, nil
|
||||
}
|
||||
|
||||
user, err := GetUser(userId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, errors.New(i18n.Translate(lang, "auth:Unauthorized operation"))
|
||||
}
|
||||
|
||||
if user.IsAdmin {
|
||||
return applications, nil
|
||||
}
|
||||
|
||||
res := []*Application{}
|
||||
for _, application := range applications {
|
||||
var allowed bool
|
||||
allowed, err = CheckLoginPermission(userId, application)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if allowed {
|
||||
res = append(res, application)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func checkMultipleCaptchaProviders(application *Application, lang string) error {
|
||||
var captchaProviders []string
|
||||
for _, providerItem := range application.Providers {
|
||||
if providerItem.Provider != nil && providerItem.Provider.Category == "Captcha" {
|
||||
captchaProviders = append(captchaProviders, providerItem.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(captchaProviders) > 1 {
|
||||
return fmt.Errorf(i18n.Translate(lang, "general:Multiple captcha providers are not allowed in the same application: %s"), strings.Join(captchaProviders, ", "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func UpdateApplication(id string, application *Application, isGlobalAdmin bool, lang string) (bool, error) {
|
||||
owner, name, err := util.GetOwnerAndNameFromIdWithError(id)
|
||||
if err != nil {
|
||||
@@ -844,205 +508,3 @@ func DeleteApplication(application *Application) (bool, error) {
|
||||
|
||||
return deleteApplication(application)
|
||||
}
|
||||
|
||||
func (application *Application) GetId() string {
|
||||
return fmt.Sprintf("%s/%s", application.Owner, application.Name)
|
||||
}
|
||||
|
||||
func (application *Application) IsRedirectUriValid(redirectUri string) bool {
|
||||
isValid, err := util.IsValidOrigin(redirectUri)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if isValid {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, targetUri := range application.RedirectUris {
|
||||
if targetUri == "" {
|
||||
continue
|
||||
}
|
||||
targetUriRegex := regexp.MustCompile(targetUri)
|
||||
if targetUriRegex.MatchString(redirectUri) || strings.Contains(redirectUri, targetUri) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (application *Application) IsPasswordEnabled() bool {
|
||||
if len(application.SigninMethods) == 0 {
|
||||
return application.EnablePassword
|
||||
} else {
|
||||
for _, signinMethod := range application.SigninMethods {
|
||||
if signinMethod.Name == "Password" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (application *Application) IsPasswordWithLdapEnabled() bool {
|
||||
if len(application.SigninMethods) == 0 {
|
||||
return application.EnablePassword
|
||||
} else {
|
||||
for _, signinMethod := range application.SigninMethods {
|
||||
if signinMethod.Name == "Password" && signinMethod.Rule == "All" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (application *Application) IsCodeSigninViaEmailEnabled() bool {
|
||||
if len(application.SigninMethods) == 0 {
|
||||
return application.EnableCodeSignin
|
||||
} else {
|
||||
for _, signinMethod := range application.SigninMethods {
|
||||
if signinMethod.Name == "Verification code" && signinMethod.Rule != "Phone only" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (application *Application) IsCodeSigninViaSmsEnabled() bool {
|
||||
if len(application.SigninMethods) == 0 {
|
||||
return application.EnableCodeSignin
|
||||
} else {
|
||||
for _, signinMethod := range application.SigninMethods {
|
||||
if signinMethod.Name == "Verification code" && signinMethod.Rule != "Email only" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (application *Application) IsLdapEnabled() bool {
|
||||
if len(application.SigninMethods) > 0 {
|
||||
for _, signinMethod := range application.SigninMethods {
|
||||
if signinMethod.Name == "LDAP" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (application *Application) IsFaceIdEnabled() bool {
|
||||
if len(application.SigninMethods) > 0 {
|
||||
for _, signinMethod := range application.SigninMethods {
|
||||
if signinMethod.Name == "Face ID" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func IsOriginAllowed(origin string) (bool, error) {
|
||||
applications, err := GetApplications("")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, application := range applications {
|
||||
if application.IsRedirectUriValid(origin) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func getApplicationMap(organization string) (map[string]*Application, error) {
|
||||
applicationMap := make(map[string]*Application)
|
||||
applications, err := GetOrganizationApplications("admin", organization)
|
||||
if err != nil {
|
||||
return applicationMap, err
|
||||
}
|
||||
|
||||
for _, application := range applications {
|
||||
applicationMap[application.Name] = application
|
||||
}
|
||||
|
||||
return applicationMap, nil
|
||||
}
|
||||
|
||||
func ExtendManagedAccountsWithUser(user *User) (*User, error) {
|
||||
if user.ManagedAccounts == nil || len(user.ManagedAccounts) == 0 {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
applicationMap, err := getApplicationMap(user.Owner)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
||||
var managedAccounts []ManagedAccount
|
||||
for _, managedAccount := range user.ManagedAccounts {
|
||||
application := applicationMap[managedAccount.Application]
|
||||
if application != nil {
|
||||
managedAccount.SigninUrl = application.SigninUrl
|
||||
managedAccounts = append(managedAccounts, managedAccount)
|
||||
}
|
||||
}
|
||||
user.ManagedAccounts = managedAccounts
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func applicationChangeTrigger(oldName string, newName string) error {
|
||||
session := ormer.Engine.NewSession()
|
||||
defer session.Close()
|
||||
|
||||
err := session.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
organization := new(Organization)
|
||||
organization.DefaultApplication = newName
|
||||
_, err = session.Where("default_application=?", oldName).Update(organization)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := new(User)
|
||||
user.SignupApplication = newName
|
||||
_, err = session.Where("signup_application=?", oldName).Update(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resource := new(Resource)
|
||||
resource.Application = newName
|
||||
_, err = session.Where("application=?", oldName).Update(resource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var permissions []*Permission
|
||||
err = ormer.Engine.Find(&permissions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := 0; i < len(permissions); i++ {
|
||||
permissionResoureces := permissions[i].Resources
|
||||
for j := 0; j < len(permissionResoureces); j++ {
|
||||
if permissionResoureces[j] == oldName {
|
||||
permissionResoureces[j] = newName
|
||||
}
|
||||
}
|
||||
permissions[i].Resources = permissionResoureces
|
||||
_, err = session.Where("owner=?", permissions[i].Owner).Where("name=?", permissions[i].Name).Update(permissions[i])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return session.Commit()
|
||||
}
|
||||
|
||||
615
object/application_util.go
Normal file
615
object/application_util.go
Normal file
@@ -0,0 +1,615 @@
|
||||
// Copyright 2021 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package object
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/casdoor/casdoor/i18n"
|
||||
"github.com/casdoor/casdoor/util"
|
||||
)
|
||||
|
||||
func getProviderMap(owner string) (m map[string]*Provider, err error) {
|
||||
providers, err := GetProviders(owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m = map[string]*Provider{}
|
||||
for _, provider := range providers {
|
||||
m[provider.Name] = GetMaskedProvider(provider, true)
|
||||
}
|
||||
|
||||
return m, err
|
||||
}
|
||||
|
||||
func extendApplicationWithProviders(application *Application) (err error) {
|
||||
m, err := getProviderMap(application.Organization)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, providerItem := range application.Providers {
|
||||
if provider, ok := m[providerItem.Name]; ok {
|
||||
providerItem.Provider = provider
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func extendApplicationWithOrg(application *Application) (err error) {
|
||||
organization, err := getOrganization(application.Owner, application.Organization)
|
||||
application.OrganizationObj = organization
|
||||
return
|
||||
}
|
||||
|
||||
func extendApplicationWithSigninItems(application *Application) (err error) {
|
||||
if len(application.SigninItems) == 0 {
|
||||
signinItem := &SigninItem{
|
||||
Name: "Back button",
|
||||
Visible: true,
|
||||
CustomCss: ".back-button {\n top: 65px;\n left: 15px;\n position: absolute;\n}\n.back-inner-button{}",
|
||||
Placeholder: "",
|
||||
Rule: "None",
|
||||
}
|
||||
application.SigninItems = append(application.SigninItems, signinItem)
|
||||
signinItem = &SigninItem{
|
||||
Name: "Languages",
|
||||
Visible: true,
|
||||
CustomCss: ".login-languages {\n top: 55px;\n right: 5px;\n position: absolute;\n}",
|
||||
Placeholder: "",
|
||||
Rule: "None",
|
||||
}
|
||||
application.SigninItems = append(application.SigninItems, signinItem)
|
||||
signinItem = &SigninItem{
|
||||
Name: "Logo",
|
||||
Visible: true,
|
||||
CustomCss: ".login-logo-box {}",
|
||||
Placeholder: "",
|
||||
Rule: "None",
|
||||
}
|
||||
application.SigninItems = append(application.SigninItems, signinItem)
|
||||
signinItem = &SigninItem{
|
||||
Name: "Signin methods",
|
||||
Visible: true,
|
||||
CustomCss: ".signin-methods {}",
|
||||
Placeholder: "",
|
||||
Rule: "None",
|
||||
}
|
||||
application.SigninItems = append(application.SigninItems, signinItem)
|
||||
signinItem = &SigninItem{
|
||||
Name: "Username",
|
||||
Visible: true,
|
||||
CustomCss: ".login-username {}\n.login-username-input{}",
|
||||
Placeholder: "",
|
||||
Rule: "None",
|
||||
}
|
||||
application.SigninItems = append(application.SigninItems, signinItem)
|
||||
signinItem = &SigninItem{
|
||||
Name: "Password",
|
||||
Visible: true,
|
||||
CustomCss: ".login-password {}\n.login-password-input{}",
|
||||
Placeholder: "",
|
||||
Rule: "None",
|
||||
}
|
||||
application.SigninItems = append(application.SigninItems, signinItem)
|
||||
signinItem = &SigninItem{
|
||||
Name: "Verification code",
|
||||
Visible: true,
|
||||
CustomCss: ".verification-code {}\n.verification-code-input{}",
|
||||
Placeholder: "",
|
||||
Rule: "None",
|
||||
}
|
||||
application.SigninItems = append(application.SigninItems, signinItem)
|
||||
signinItem = &SigninItem{
|
||||
Name: "Agreement",
|
||||
Visible: true,
|
||||
CustomCss: ".login-agreement {}",
|
||||
Placeholder: "",
|
||||
Rule: "None",
|
||||
}
|
||||
application.SigninItems = append(application.SigninItems, signinItem)
|
||||
signinItem = &SigninItem{
|
||||
Name: "Forgot password?",
|
||||
Visible: true,
|
||||
CustomCss: ".login-forget-password {\n display: inline-flex;\n justify-content: space-between;\n width: 320px;\n margin-bottom: 25px;\n}",
|
||||
Placeholder: "",
|
||||
Rule: "None",
|
||||
}
|
||||
application.SigninItems = append(application.SigninItems, signinItem)
|
||||
signinItem = &SigninItem{
|
||||
Name: "Login button",
|
||||
Visible: true,
|
||||
CustomCss: ".login-button-box {\n margin-bottom: 5px;\n}\n.login-button {\n width: 100%;\n}",
|
||||
Placeholder: "",
|
||||
Rule: "None",
|
||||
}
|
||||
application.SigninItems = append(application.SigninItems, signinItem)
|
||||
signinItem = &SigninItem{
|
||||
Name: "Signup link",
|
||||
Visible: true,
|
||||
CustomCss: ".login-signup-link {\n margin-bottom: 24px;\n display: flex;\n justify-content: end;\n}",
|
||||
Placeholder: "",
|
||||
Rule: "None",
|
||||
}
|
||||
application.SigninItems = append(application.SigninItems, signinItem)
|
||||
signinItem = &SigninItem{
|
||||
Name: "Providers",
|
||||
Visible: true,
|
||||
CustomCss: ".provider-img {\n width: 30px;\n margin: 5px;\n}\n.provider-big-img {\n margin-bottom: 10px;\n}",
|
||||
Placeholder: "",
|
||||
Rule: "None",
|
||||
}
|
||||
application.SigninItems = append(application.SigninItems, signinItem)
|
||||
}
|
||||
for idx, item := range application.SigninItems {
|
||||
if item.Label != "" && item.CustomCss == "" {
|
||||
application.SigninItems[idx].CustomCss = item.Label
|
||||
application.SigninItems[idx].Label = ""
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func extendApplicationWithSigninMethods(application *Application) (err error) {
|
||||
if len(application.SigninMethods) == 0 {
|
||||
if application.EnablePassword {
|
||||
signinMethod := &SigninMethod{Name: "Password", DisplayName: "Password", Rule: "All"}
|
||||
application.SigninMethods = append(application.SigninMethods, signinMethod)
|
||||
}
|
||||
if application.EnableCodeSignin {
|
||||
signinMethod := &SigninMethod{Name: "Verification code", DisplayName: "Verification code", Rule: "All"}
|
||||
application.SigninMethods = append(application.SigninMethods, signinMethod)
|
||||
}
|
||||
if application.EnableWebAuthn {
|
||||
signinMethod := &SigninMethod{Name: "WebAuthn", DisplayName: "WebAuthn", Rule: "None"}
|
||||
application.SigninMethods = append(application.SigninMethods, signinMethod)
|
||||
}
|
||||
|
||||
signinMethod := &SigninMethod{Name: "Face ID", DisplayName: "Face ID", Rule: "None"}
|
||||
application.SigninMethods = append(application.SigninMethods, signinMethod)
|
||||
}
|
||||
|
||||
if len(application.SigninMethods) == 0 {
|
||||
signinMethod := &SigninMethod{Name: "Password", DisplayName: "Password", Rule: "All"}
|
||||
application.SigninMethods = append(application.SigninMethods, signinMethod)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func extendApplicationWithSignupItems(application *Application) (err error) {
|
||||
if len(application.SignupItems) == 0 {
|
||||
application.SignupItems = []*SignupItem{
|
||||
{Name: "ID", Visible: false, Required: true, Prompted: false, Rule: "Random"},
|
||||
{Name: "Username", Visible: true, Required: true, Prompted: false, Rule: "None"},
|
||||
{Name: "Display name", Visible: true, Required: true, Prompted: false, Rule: "None"},
|
||||
{Name: "Password", Visible: true, Required: true, Prompted: false, Rule: "None"},
|
||||
{Name: "Confirm password", Visible: true, Required: true, Prompted: false, Rule: "None"},
|
||||
{Name: "Email", Visible: true, Required: true, Prompted: false, Rule: "None"},
|
||||
{Name: "Phone", Visible: true, Required: true, Prompted: false, Rule: "None"},
|
||||
{Name: "Agreement", Visible: true, Required: true, Prompted: false, Rule: "None"},
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func GetMaskedApplication(application *Application, userId string) *Application {
|
||||
if application == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if application.TokenFields == nil {
|
||||
application.TokenFields = []string{}
|
||||
}
|
||||
|
||||
if application.FailedSigninLimit == 0 {
|
||||
application.FailedSigninLimit = DefaultFailedSigninLimit
|
||||
}
|
||||
if application.FailedSigninFrozenTime == 0 {
|
||||
application.FailedSigninFrozenTime = DefaultFailedSigninFrozenTime
|
||||
}
|
||||
|
||||
isOrgUser := false
|
||||
if userId != "" {
|
||||
if isUserIdGlobalAdmin(userId) {
|
||||
return application
|
||||
}
|
||||
|
||||
user, err := GetUser(userId)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if user != nil {
|
||||
if user.IsApplicationAdmin(application) {
|
||||
return application
|
||||
}
|
||||
|
||||
if user.Owner == application.Organization {
|
||||
isOrgUser = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
application.ClientSecret = "***"
|
||||
application.Cert = "***"
|
||||
application.EnablePassword = false
|
||||
application.EnableSigninSession = false
|
||||
application.EnableCodeSignin = false
|
||||
application.EnableSamlCompress = false
|
||||
application.EnableSamlC14n10 = false
|
||||
application.EnableSamlPostBinding = false
|
||||
application.DisableSamlAttributes = false
|
||||
application.EnableWebAuthn = false
|
||||
application.EnableLinkWithEmail = false
|
||||
application.SamlReplyUrl = "***"
|
||||
|
||||
providerItems := []*ProviderItem{}
|
||||
for _, providerItem := range application.Providers {
|
||||
if providerItem.Provider != nil && (providerItem.Provider.Category == "OAuth" || providerItem.Provider.Category == "Web3" || providerItem.Provider.Category == "Captcha" || providerItem.Provider.Category == "SAML" || providerItem.Provider.Category == "Face ID") {
|
||||
providerItems = append(providerItems, providerItem)
|
||||
}
|
||||
}
|
||||
application.Providers = providerItems
|
||||
|
||||
application.GrantTypes = []string{}
|
||||
application.RedirectUris = []string{}
|
||||
application.TokenFormat = "***"
|
||||
application.TokenFields = []string{}
|
||||
application.ExpireInHours = -1
|
||||
application.RefreshExpireInHours = -1
|
||||
application.FailedSigninLimit = -1
|
||||
application.FailedSigninFrozenTime = -1
|
||||
|
||||
if application.OrganizationObj != nil {
|
||||
application.OrganizationObj.MasterPassword = "***"
|
||||
application.OrganizationObj.DefaultPassword = "***"
|
||||
application.OrganizationObj.MasterVerificationCode = "***"
|
||||
application.OrganizationObj.PasswordType = "***"
|
||||
application.OrganizationObj.PasswordSalt = "***"
|
||||
application.OrganizationObj.InitScore = -1
|
||||
application.OrganizationObj.EnableSoftDeletion = false
|
||||
|
||||
if !isOrgUser {
|
||||
application.OrganizationObj.MfaItems = nil
|
||||
if !application.OrganizationObj.IsProfilePublic {
|
||||
application.OrganizationObj.AccountItems = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return application
|
||||
}
|
||||
|
||||
func GetMaskedApplications(applications []*Application, userId string) []*Application {
|
||||
if isUserIdGlobalAdmin(userId) {
|
||||
return applications
|
||||
}
|
||||
|
||||
for _, application := range applications {
|
||||
application = GetMaskedApplication(application, userId)
|
||||
}
|
||||
return applications
|
||||
}
|
||||
|
||||
func GetAllowedApplications(applications []*Application, userId string, lang string) ([]*Application, error) {
|
||||
if userId == "" {
|
||||
return nil, errors.New(i18n.Translate(lang, "auth:Unauthorized operation"))
|
||||
}
|
||||
|
||||
if isUserIdGlobalAdmin(userId) {
|
||||
return applications, nil
|
||||
}
|
||||
|
||||
user, err := GetUser(userId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, errors.New(i18n.Translate(lang, "auth:Unauthorized operation"))
|
||||
}
|
||||
|
||||
if user.IsAdmin {
|
||||
return applications, nil
|
||||
}
|
||||
|
||||
res := []*Application{}
|
||||
for _, application := range applications {
|
||||
var allowed bool
|
||||
allowed, err = CheckLoginPermission(userId, application)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if allowed {
|
||||
res = append(res, application)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func checkMultipleCaptchaProviders(application *Application, lang string) error {
|
||||
var captchaProviders []string
|
||||
for _, providerItem := range application.Providers {
|
||||
if providerItem.Provider != nil && providerItem.Provider.Category == "Captcha" {
|
||||
captchaProviders = append(captchaProviders, providerItem.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(captchaProviders) > 1 {
|
||||
return fmt.Errorf(i18n.Translate(lang, "general:Multiple captcha providers are not allowed in the same application: %s"), strings.Join(captchaProviders, ", "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (application *Application) GetId() string {
|
||||
return fmt.Sprintf("%s/%s", application.Owner, application.Name)
|
||||
}
|
||||
|
||||
func (application *Application) IsRedirectUriValid(redirectUri string) bool {
|
||||
isValid, err := util.IsValidOrigin(redirectUri)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if isValid {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, targetUri := range application.RedirectUris {
|
||||
if redirectUriMatchesPattern(redirectUri, targetUri) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func redirectUriMatchesPattern(redirectUri, targetUri string) bool {
|
||||
if targetUri == "" {
|
||||
return false
|
||||
}
|
||||
if redirectUri == targetUri {
|
||||
return true
|
||||
}
|
||||
|
||||
redirectUriObj, err := url.Parse(redirectUri)
|
||||
if err != nil || redirectUriObj.Host == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
targetUriObj, err := url.Parse(targetUri)
|
||||
if err == nil && targetUriObj.Host != "" {
|
||||
return redirectUriMatchesTarget(redirectUriObj, targetUriObj)
|
||||
}
|
||||
|
||||
withScheme, parseErr := url.Parse("https://" + targetUri)
|
||||
if parseErr == nil && withScheme.Host != "" {
|
||||
redirectHost := redirectUriObj.Hostname()
|
||||
targetHost := withScheme.Hostname()
|
||||
var hostMatches bool
|
||||
if strings.HasPrefix(targetHost, ".") {
|
||||
hostMatches = strings.HasSuffix(redirectHost, targetHost)
|
||||
} else {
|
||||
hostMatches = redirectHost == targetHost || strings.HasSuffix(redirectHost, "."+targetHost)
|
||||
}
|
||||
schemeOk := redirectUriObj.Scheme == "http" || redirectUriObj.Scheme == "https"
|
||||
pathMatches := withScheme.Path == "" || withScheme.Path == "/" || redirectUriObj.Path == withScheme.Path
|
||||
return schemeOk && hostMatches && pathMatches
|
||||
}
|
||||
|
||||
anchoredPattern := "^(?:" + targetUri + ")$"
|
||||
targetUriRegex, err := regexp.Compile(anchoredPattern)
|
||||
return err == nil && targetUriRegex.MatchString(redirectUri)
|
||||
}
|
||||
|
||||
func redirectUriMatchesTarget(redirectUri, targetUri *url.URL) bool {
|
||||
if redirectUri.Scheme != targetUri.Scheme {
|
||||
return false
|
||||
}
|
||||
if redirectUri.Port() != targetUri.Port() {
|
||||
return false
|
||||
}
|
||||
redirectHost := redirectUri.Hostname()
|
||||
targetHost := targetUri.Hostname()
|
||||
if redirectHost != targetHost && !strings.HasSuffix(redirectHost, "."+targetHost) {
|
||||
return false
|
||||
}
|
||||
if redirectUri.Path != targetUri.Path {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (application *Application) IsPasswordEnabled() bool {
|
||||
if len(application.SigninMethods) == 0 {
|
||||
return application.EnablePassword
|
||||
} else {
|
||||
for _, signinMethod := range application.SigninMethods {
|
||||
if signinMethod.Name == "Password" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (application *Application) IsPasswordWithLdapEnabled() bool {
|
||||
if len(application.SigninMethods) == 0 {
|
||||
return application.EnablePassword
|
||||
} else {
|
||||
for _, signinMethod := range application.SigninMethods {
|
||||
if signinMethod.Name == "Password" && signinMethod.Rule == "All" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (application *Application) IsCodeSigninViaEmailEnabled() bool {
|
||||
if len(application.SigninMethods) == 0 {
|
||||
return application.EnableCodeSignin
|
||||
} else {
|
||||
for _, signinMethod := range application.SigninMethods {
|
||||
if signinMethod.Name == "Verification code" && signinMethod.Rule != "Phone only" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (application *Application) IsCodeSigninViaSmsEnabled() bool {
|
||||
if len(application.SigninMethods) == 0 {
|
||||
return application.EnableCodeSignin
|
||||
} else {
|
||||
for _, signinMethod := range application.SigninMethods {
|
||||
if signinMethod.Name == "Verification code" && signinMethod.Rule != "Email only" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (application *Application) IsLdapEnabled() bool {
|
||||
if len(application.SigninMethods) > 0 {
|
||||
for _, signinMethod := range application.SigninMethods {
|
||||
if signinMethod.Name == "LDAP" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (application *Application) IsFaceIdEnabled() bool {
|
||||
if len(application.SigninMethods) > 0 {
|
||||
for _, signinMethod := range application.SigninMethods {
|
||||
if signinMethod.Name == "Face ID" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func IsOriginAllowed(origin string) (bool, error) {
|
||||
applications, err := GetApplications("")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, application := range applications {
|
||||
if application.IsRedirectUriValid(origin) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func getApplicationMap(organization string) (map[string]*Application, error) {
|
||||
applicationMap := make(map[string]*Application)
|
||||
applications, err := GetOrganizationApplications("admin", organization)
|
||||
if err != nil {
|
||||
return applicationMap, err
|
||||
}
|
||||
|
||||
for _, application := range applications {
|
||||
applicationMap[application.Name] = application
|
||||
}
|
||||
|
||||
return applicationMap, nil
|
||||
}
|
||||
|
||||
func ExtendManagedAccountsWithUser(user *User) (*User, error) {
|
||||
if user.ManagedAccounts == nil || len(user.ManagedAccounts) == 0 {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
applicationMap, err := getApplicationMap(user.Owner)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
||||
var managedAccounts []ManagedAccount
|
||||
for _, managedAccount := range user.ManagedAccounts {
|
||||
application := applicationMap[managedAccount.Application]
|
||||
if application != nil {
|
||||
managedAccount.SigninUrl = application.SigninUrl
|
||||
managedAccounts = append(managedAccounts, managedAccount)
|
||||
}
|
||||
}
|
||||
user.ManagedAccounts = managedAccounts
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func applicationChangeTrigger(oldName string, newName string) error {
|
||||
session := ormer.Engine.NewSession()
|
||||
defer session.Close()
|
||||
|
||||
err := session.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
organization := new(Organization)
|
||||
organization.DefaultApplication = newName
|
||||
_, err = session.Where("default_application=?", oldName).Update(organization)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := new(User)
|
||||
user.SignupApplication = newName
|
||||
_, err = session.Where("signup_application=?", oldName).Update(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resource := new(Resource)
|
||||
resource.Application = newName
|
||||
_, err = session.Where("application=?", oldName).Update(resource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var permissions []*Permission
|
||||
err = ormer.Engine.Find(&permissions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := 0; i < len(permissions); i++ {
|
||||
permissionResoureces := permissions[i].Resources
|
||||
for j := 0; j < len(permissionResoureces); j++ {
|
||||
if permissionResoureces[j] == oldName {
|
||||
permissionResoureces[j] = newName
|
||||
}
|
||||
}
|
||||
permissions[i].Resources = permissionResoureces
|
||||
_, err = session.Where("owner=?", permissions[i].Owner).Where("name=?", permissions[i].Name).Update(permissions[i])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return session.Commit()
|
||||
}
|
||||
79
object/application_util_test.go
Normal file
79
object/application_util_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package object
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRedirectUriMatchesPattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
redirectUri string
|
||||
targetUri string
|
||||
want bool
|
||||
}{
|
||||
// Exact match
|
||||
{"https://login.example.com/callback", "https://login.example.com/callback", true},
|
||||
|
||||
// Full URL pattern: exact host
|
||||
{"https://login.example.com/callback", "https://login.example.com/callback", true},
|
||||
{"https://login.example.com/other", "https://login.example.com/callback", false},
|
||||
|
||||
// Full URL pattern: subdomain of configured host
|
||||
{"https://def.abc.com/callback", "abc.com", true},
|
||||
{"https://def.abc.com/callback", ".abc.com", true},
|
||||
{"https://def.abc.com/callback", ".abc.com/", true},
|
||||
{"https://deep.app.example.com/callback", "https://example.com/callback", true},
|
||||
|
||||
// Full URL pattern: unrelated host must not match
|
||||
{"https://evil.com/callback", "https://example.com/callback", false},
|
||||
// Suffix collision: evilexample.com must not match example.com
|
||||
{"https://evilexample.com/callback", "https://example.com/callback", false},
|
||||
|
||||
// Full URL pattern: scheme mismatch
|
||||
{"http://app.example.com/callback", "https://example.com/callback", false},
|
||||
|
||||
// Full URL pattern: path mismatch
|
||||
{"https://app.example.com/other", "https://example.com/callback", false},
|
||||
|
||||
// Scheme-less pattern: exact host
|
||||
{"https://login.example.com/callback", "login.example.com/callback", true},
|
||||
{"http://login.example.com/callback", "login.example.com/callback", true},
|
||||
|
||||
// Scheme-less pattern: subdomain of configured host
|
||||
{"https://app.login.example.com/callback", "login.example.com/callback", true},
|
||||
|
||||
// Scheme-less pattern: unrelated host must not match
|
||||
{"https://evil.com/callback", "login.example.com/callback", false},
|
||||
|
||||
// Scheme-less pattern: query-string injection must not match
|
||||
{"https://evil.com/?r=https://login.example.com/callback", "login.example.com/callback", false},
|
||||
{"https://evil.com/page?redirect=https://login.example.com/callback", "login.example.com/callback", false},
|
||||
|
||||
// Scheme-less pattern: path mismatch
|
||||
{"https://login.example.com/other", "login.example.com/callback", false},
|
||||
|
||||
// Scheme-less pattern: non-http scheme must not match
|
||||
{"ftp://login.example.com/callback", "login.example.com/callback", false},
|
||||
|
||||
// Empty target
|
||||
{"https://login.example.com/callback", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := redirectUriMatchesPattern(tt.redirectUri, tt.targetUri)
|
||||
if got != tt.want {
|
||||
t.Errorf("redirectUriMatchesPattern(%q, %q) = %v, want %v", tt.redirectUri, tt.targetUri, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -587,7 +587,12 @@ func CheckLoginPermission(userId string, application *Application) (bool, error)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
permissions, err := GetPermissions(application.Organization)
|
||||
permissionOrganization := application.Organization
|
||||
if application.IsShared {
|
||||
permissionOrganization = owner
|
||||
}
|
||||
|
||||
permissions, err := GetPermissions(permissionOrganization)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -28,24 +28,13 @@ type Entry struct {
|
||||
UpdatedTime string `xorm:"varchar(100)" json:"updatedTime"`
|
||||
DisplayName string `xorm:"varchar(100)" json:"displayName"`
|
||||
|
||||
Url string `xorm:"varchar(500)" json:"url"`
|
||||
Token string `xorm:"varchar(500)" json:"token"`
|
||||
Provider string `xorm:"varchar(100)" json:"provider"`
|
||||
Application string `xorm:"varchar(100)" json:"application"`
|
||||
Message string `xorm:"mediumtext" json:"message"`
|
||||
}
|
||||
|
||||
func NewTraceEntry(message []byte) *Entry {
|
||||
currentTime := util.GetCurrentTime()
|
||||
traceId := fmt.Sprintf("trace_%s_%s", util.GenerateSimpleTimeId(), util.GetRandomName())
|
||||
|
||||
return &Entry{
|
||||
Owner: CasdoorOrganization,
|
||||
Name: traceId,
|
||||
CreatedTime: currentTime,
|
||||
UpdatedTime: currentTime,
|
||||
DisplayName: traceId,
|
||||
Message: string(message),
|
||||
}
|
||||
Type string `xorm:"varchar(100)" json:"type"`
|
||||
ClientIp string `xorm:"varchar(100)" json:"clientIp"`
|
||||
UserAgent string `xorm:"varchar(500)" json:"userAgent"`
|
||||
Message string `xorm:"mediumtext" json:"message"`
|
||||
}
|
||||
|
||||
func GetEntries(owner string) ([]*Entry, error) {
|
||||
|
||||
@@ -318,10 +318,12 @@ func GetGroupUserCount(groupId string, field, value string) (int64, error) {
|
||||
return int64(len(names)), nil
|
||||
} else {
|
||||
tableNamePrefix := conf.GetConfigString("tableNamePrefix")
|
||||
return ormer.Engine.Table(tableNamePrefix+"user").
|
||||
Where("owner = ?", owner).In("name", names).
|
||||
And(fmt.Sprintf("user.%s like ?", util.CamelToSnakeCase(field)), "%"+value+"%").
|
||||
Count()
|
||||
session := ormer.Engine.Table(tableNamePrefix+"user").
|
||||
Where("owner = ?", owner).In("name", names)
|
||||
if util.FilterField(field) {
|
||||
session = session.And(fmt.Sprintf("user.%s like ?", util.CamelToSnakeCase(field)), "%"+value+"%")
|
||||
}
|
||||
return session.Count()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,7 +347,7 @@ func GetPaginationGroupUsers(groupId string, offset, limit int, field, value, so
|
||||
session.Limit(limit, offset)
|
||||
}
|
||||
|
||||
if field != "" && value != "" {
|
||||
if field != "" && value != "" && util.FilterField(field) {
|
||||
session = session.And(fmt.Sprintf("%s.%s like ?", prefixedUserTable, util.CamelToSnakeCase(field)), "%"+value+"%")
|
||||
}
|
||||
|
||||
|
||||
@@ -878,12 +878,12 @@ func (ldap *Ldap) buildAuthFilterString(user *User) string {
|
||||
}
|
||||
|
||||
if len(ldap.FilterFields) == 0 {
|
||||
return fmt.Sprintf("(&%s(uid=%s))", baseFilter, user.Name)
|
||||
return fmt.Sprintf("(&%s(uid=%s))", baseFilter, goldap.EscapeFilter(user.Name))
|
||||
}
|
||||
|
||||
filter := fmt.Sprintf("(&%s(|", baseFilter)
|
||||
for _, field := range ldap.FilterFields {
|
||||
filter = fmt.Sprintf("%s(%s=%s)", filter, field, user.getFieldFromLdapAttribute(field))
|
||||
filter = fmt.Sprintf("%s(%s=%s)", filter, field, goldap.EscapeFilter(user.getFieldFromLdapAttribute(field)))
|
||||
}
|
||||
filter = fmt.Sprintf("%s))", filter)
|
||||
|
||||
|
||||
184
object/log_provider.go
Normal file
184
object/log_provider.go
Normal file
@@ -0,0 +1,184 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package object
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/casdoor/casdoor/log"
|
||||
)
|
||||
|
||||
var (
|
||||
runningCollectors = map[string]log.LogProvider{} // providerGetId() -> LogProvider
|
||||
runningCollectorsMu sync.Mutex
|
||||
)
|
||||
|
||||
// InitLogProviders scans all globally-configured Log providers and starts
|
||||
// background collection for pull-based providers (e.g. System Log, SELinux Log)
|
||||
// and registers passive providers (e.g. OpenClaw).
|
||||
// It is called once from main() after the database is ready.
|
||||
func InitLogProviders() {
|
||||
providers, err := GetGlobalProviders()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, p := range providers {
|
||||
if p.Category != "Log" {
|
||||
continue
|
||||
}
|
||||
if p.State == "Disabled" {
|
||||
continue
|
||||
}
|
||||
switch p.Type {
|
||||
case "System Log", "SELinux Log":
|
||||
startLogCollector(p)
|
||||
case "Agent":
|
||||
if p.SubType == "OpenClaw" {
|
||||
startOpenClawProvider(p)
|
||||
startOpenClawTranscriptSync(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopCollector(id string) {
|
||||
runningCollectorsMu.Lock()
|
||||
defer runningCollectorsMu.Unlock()
|
||||
|
||||
if existing, ok := runningCollectors[id]; ok {
|
||||
_ = existing.Stop()
|
||||
delete(runningCollectors, id)
|
||||
}
|
||||
}
|
||||
|
||||
// startLogCollector starts a pull-based log collector (System Log / SELinux Log)
|
||||
// for the given provider. If a collector for the same provider is already
|
||||
// running it is stopped first.
|
||||
func startLogCollector(provider *Provider) {
|
||||
id := provider.GetId()
|
||||
stopCollector(id)
|
||||
|
||||
lp, err := log.GetLogProvider(provider.Type, provider.Host, provider.Port, provider.Title)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
providerName := provider.Name
|
||||
addEntry := func(owner, createdTime, _ string, message string) error {
|
||||
name := log.GenerateEntryName()
|
||||
entry := &Entry{
|
||||
Owner: owner,
|
||||
Name: name,
|
||||
CreatedTime: createdTime,
|
||||
UpdatedTime: createdTime,
|
||||
DisplayName: name,
|
||||
Provider: providerName,
|
||||
Message: message,
|
||||
}
|
||||
_, err := AddEntry(entry)
|
||||
return err
|
||||
}
|
||||
|
||||
onError := func(err error) {
|
||||
fmt.Printf("InitLogProviders: collector for provider %s stopped with error: %v\n", providerName, err)
|
||||
}
|
||||
if err := lp.Start(addEntry, onError); err != nil {
|
||||
fmt.Printf("InitLogProviders: failed to start collector for provider %s: %v\n", providerName, err)
|
||||
return
|
||||
}
|
||||
|
||||
runningCollectorsMu.Lock()
|
||||
defer runningCollectorsMu.Unlock()
|
||||
runningCollectors[id] = lp
|
||||
}
|
||||
|
||||
// startOpenClawProvider registers an OpenClaw provider in runningCollectors so
|
||||
// that incoming OTLP requests can be routed to it by IP.
|
||||
func startOpenClawProvider(provider *Provider) {
|
||||
id := provider.GetId()
|
||||
stopCollector(id)
|
||||
|
||||
lp, err := GetLogProviderFromProvider(provider)
|
||||
if err != nil {
|
||||
fmt.Printf("InitLogProviders: failed to create OpenClaw provider %s: %v\n", provider.Name, err)
|
||||
return
|
||||
}
|
||||
|
||||
runningCollectorsMu.Lock()
|
||||
defer runningCollectorsMu.Unlock()
|
||||
runningCollectors[id] = lp
|
||||
}
|
||||
|
||||
func refreshLogProviderRuntime(oldID string, provider *Provider) {
|
||||
if provider == nil {
|
||||
if oldID != "" {
|
||||
stopLogProviderRuntime(oldID)
|
||||
}
|
||||
return
|
||||
}
|
||||
if oldID != "" {
|
||||
stopLogProviderRuntime(oldID)
|
||||
}
|
||||
if provider.Category != "Log" {
|
||||
return
|
||||
}
|
||||
if provider.State == "Disabled" {
|
||||
return
|
||||
}
|
||||
|
||||
switch provider.Type {
|
||||
case "System Log", "SELinux Log":
|
||||
startLogCollector(provider)
|
||||
case "Agent":
|
||||
if provider.SubType == "OpenClaw" {
|
||||
startOpenClawProvider(provider)
|
||||
startOpenClawTranscriptSync(provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopLogProviderRuntime(providerID string) {
|
||||
if providerID == "" {
|
||||
return
|
||||
}
|
||||
stopCollector(providerID)
|
||||
stopOpenClawTranscriptSync(providerID)
|
||||
}
|
||||
|
||||
// GetOpenClawProviderByIP returns the running OpenClawProvider whose Host field
|
||||
// matches clientIP, or whose Host is empty (meaning any IP is allowed).
|
||||
// Returns nil if no matching provider is registered.
|
||||
func GetOpenClawProviderByIP(clientIP string) (*log.OpenClawProvider, error) {
|
||||
providers := []*Provider{}
|
||||
err := ormer.Engine.Where("category = ? AND type = ? AND sub_type = ? AND (state = ? OR state = ?)", "Log", "Agent", "OpenClaw", "Enabled", "").Find(&providers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
runningCollectorsMu.Lock()
|
||||
defer runningCollectorsMu.Unlock()
|
||||
|
||||
for _, p := range providers {
|
||||
if p.Host == "" || p.Host == clientIP {
|
||||
if lp, ok := runningCollectors[p.GetId()]; ok {
|
||||
if ocp, ok := lp.(*log.OpenClawProvider); ok {
|
||||
return ocp, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/casdoor/casdoor/notification"
|
||||
"github.com/google/uuid"
|
||||
"github.com/casdoor/casdoor/util"
|
||||
)
|
||||
|
||||
type PushMfa struct {
|
||||
@@ -111,7 +111,7 @@ func (mfa *PushMfa) sendPushNotification(title string, message string) error {
|
||||
// Generate a unique challenge ID for this notification
|
||||
// Note: In a full implementation, this would be stored in a cache/database
|
||||
// to validate callbacks from the mobile app
|
||||
mfa.challengeId = uuid.NewString()
|
||||
mfa.challengeId = util.GenerateUUID()
|
||||
mfa.challengeExp = time.Now().Add(5 * time.Minute) // Challenge expires in 5 minutes
|
||||
|
||||
// Get the notification provider
|
||||
|
||||
804
object/openclaw_session_graph.go
Normal file
804
object/openclaw_session_graph.go
Normal file
@@ -0,0 +1,804 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package object
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/casdoor/casdoor/util"
|
||||
)
|
||||
|
||||
type OpenClawSessionGraph struct {
|
||||
Nodes []*OpenClawSessionGraphNode `json:"nodes"`
|
||||
Edges []*OpenClawSessionGraphEdge `json:"edges"`
|
||||
Stats OpenClawSessionGraphStats `json:"stats"`
|
||||
}
|
||||
|
||||
type OpenClawSessionGraphNode struct {
|
||||
ID string `json:"id"`
|
||||
ParentID string `json:"parentId,omitempty"`
|
||||
OriginalParentID string `json:"originalParentId,omitempty"`
|
||||
EntryID string `json:"entryId,omitempty"`
|
||||
ToolCallID string `json:"toolCallId,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Summary string `json:"summary"`
|
||||
Tool string `json:"tool,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
OK *bool `json:"ok,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
IsAnchor bool `json:"isAnchor"`
|
||||
}
|
||||
|
||||
type OpenClawSessionGraphEdge struct {
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
|
||||
type OpenClawSessionGraphStats struct {
|
||||
TotalNodes int `json:"totalNodes"`
|
||||
TaskCount int `json:"taskCount"`
|
||||
ToolCallCount int `json:"toolCallCount"`
|
||||
ToolResultCount int `json:"toolResultCount"`
|
||||
FinalCount int `json:"finalCount"`
|
||||
FailedCount int `json:"failedCount"`
|
||||
}
|
||||
|
||||
type openClawSessionGraphBuilder struct {
|
||||
graph *OpenClawSessionGraph
|
||||
nodes map[string]*OpenClawSessionGraphNode
|
||||
}
|
||||
|
||||
type openClawSessionGraphRecord struct {
|
||||
Entry *Entry
|
||||
Payload openClawBehaviorPayload
|
||||
}
|
||||
|
||||
type openClawAssistantStepGroup struct {
|
||||
ParentID string
|
||||
Timestamp string
|
||||
ToolNames []string
|
||||
Text string
|
||||
}
|
||||
|
||||
func GetOpenClawSessionGraph(id string) (*OpenClawSessionGraph, error) {
|
||||
entry, err := GetEntry(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if entry == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.TrimSpace(entry.Type) != "session" {
|
||||
return nil, fmt.Errorf("entry %s is not an OpenClaw session entry", id)
|
||||
}
|
||||
|
||||
provider, err := GetProvider(util.GetId(entry.Owner, entry.Provider))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if provider != nil && !isOpenClawLogProvider(provider) {
|
||||
return nil, fmt.Errorf("entry %s is not an OpenClaw session entry", id)
|
||||
}
|
||||
|
||||
anchorPayload, err := parseOpenClawSessionGraphPayload(entry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse anchor entry %s: %w", entry.Name, err)
|
||||
}
|
||||
|
||||
records, err := collectOpenClawSessionGraphRecords(entry, anchorPayload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load OpenClaw session entries from database: %w", err)
|
||||
}
|
||||
|
||||
return buildOpenClawSessionGraphFromEntries(anchorPayload, entry.Name, records), nil
|
||||
}
|
||||
|
||||
func parseOpenClawSessionGraphPayload(entry *Entry) (openClawBehaviorPayload, error) {
|
||||
if entry == nil {
|
||||
return openClawBehaviorPayload{}, fmt.Errorf("entry is nil")
|
||||
}
|
||||
|
||||
message := strings.TrimSpace(entry.Message)
|
||||
if message == "" {
|
||||
return openClawBehaviorPayload{}, fmt.Errorf("message is empty")
|
||||
}
|
||||
|
||||
var payload openClawBehaviorPayload
|
||||
if err := json.Unmarshal([]byte(message), &payload); err != nil {
|
||||
return openClawBehaviorPayload{}, err
|
||||
}
|
||||
|
||||
payload.SessionID = strings.TrimSpace(payload.SessionID)
|
||||
payload.EntryID = strings.TrimSpace(payload.EntryID)
|
||||
payload.ToolCallID = strings.TrimSpace(payload.ToolCallID)
|
||||
payload.ParentID = strings.TrimSpace(payload.ParentID)
|
||||
payload.Kind = strings.TrimSpace(payload.Kind)
|
||||
payload.Summary = strings.TrimSpace(payload.Summary)
|
||||
payload.Tool = strings.TrimSpace(payload.Tool)
|
||||
payload.Query = strings.TrimSpace(payload.Query)
|
||||
payload.URL = strings.TrimSpace(payload.URL)
|
||||
payload.Path = strings.TrimSpace(payload.Path)
|
||||
payload.Error = strings.TrimSpace(payload.Error)
|
||||
payload.AssistantText = strings.TrimSpace(payload.AssistantText)
|
||||
payload.Text = strings.TrimSpace(payload.Text)
|
||||
payload.Timestamp = strings.TrimSpace(firstNonEmpty(payload.Timestamp, entry.CreatedTime))
|
||||
|
||||
if payload.SessionID == "" {
|
||||
return openClawBehaviorPayload{}, fmt.Errorf("sessionId is empty")
|
||||
}
|
||||
if payload.EntryID == "" {
|
||||
return openClawBehaviorPayload{}, fmt.Errorf("entryId is empty")
|
||||
}
|
||||
if payload.Kind == "" {
|
||||
return openClawBehaviorPayload{}, fmt.Errorf("kind is empty")
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func collectOpenClawSessionGraphRecords(anchorEntry *Entry, anchorPayload openClawBehaviorPayload) ([]openClawSessionGraphRecord, error) {
|
||||
if anchorEntry == nil {
|
||||
return nil, fmt.Errorf("anchor entry is nil")
|
||||
}
|
||||
|
||||
entries := []*Entry{}
|
||||
query := ormer.Engine.Where("owner = ? and type = ?", anchorEntry.Owner, "session")
|
||||
if providerName := strings.TrimSpace(anchorEntry.Provider); providerName != "" {
|
||||
query = query.And("provider = ?", providerName)
|
||||
}
|
||||
|
||||
if err := query.
|
||||
Asc("created_time").
|
||||
Asc("name").
|
||||
Find(&entries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filterOpenClawSessionGraphRecords(anchorEntry, anchorPayload, entries), nil
|
||||
}
|
||||
|
||||
func filterOpenClawSessionGraphRecords(anchorEntry *Entry, anchorPayload openClawBehaviorPayload, entries []*Entry) []openClawSessionGraphRecord {
|
||||
targetSessionID := strings.TrimSpace(anchorPayload.SessionID)
|
||||
records := make([]openClawSessionGraphRecord, 0, len(entries)+1)
|
||||
hasAnchor := false
|
||||
for _, candidate := range entries {
|
||||
if candidate == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
payload, err := parseOpenClawSessionGraphPayload(candidate)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if payload.SessionID != targetSessionID {
|
||||
continue
|
||||
}
|
||||
|
||||
records = append(records, openClawSessionGraphRecord{
|
||||
Entry: candidate,
|
||||
Payload: payload,
|
||||
})
|
||||
if candidate.Owner == anchorEntry.Owner && candidate.Name == anchorEntry.Name {
|
||||
hasAnchor = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasAnchor && anchorEntry != nil {
|
||||
records = append(records, openClawSessionGraphRecord{
|
||||
Entry: anchorEntry,
|
||||
Payload: anchorPayload,
|
||||
})
|
||||
}
|
||||
|
||||
sort.SliceStable(records, func(i, j int) bool {
|
||||
leftPayload := records[i].Payload
|
||||
rightPayload := records[j].Payload
|
||||
leftTimestamp := strings.TrimSpace(firstNonEmpty(leftPayload.Timestamp, records[i].Entry.CreatedTime))
|
||||
rightTimestamp := strings.TrimSpace(firstNonEmpty(rightPayload.Timestamp, records[j].Entry.CreatedTime))
|
||||
if timestampOrder := compareOpenClawGraphTimestamps(leftTimestamp, rightTimestamp); timestampOrder != 0 {
|
||||
return timestampOrder < 0
|
||||
}
|
||||
return records[i].Entry.Name < records[j].Entry.Name
|
||||
})
|
||||
|
||||
return records
|
||||
}
|
||||
|
||||
func buildOpenClawSessionGraphFromEntries(anchorPayload openClawBehaviorPayload, anchorEntryName string, records []openClawSessionGraphRecord) *OpenClawSessionGraph {
|
||||
builder := newOpenClawSessionGraphBuilder()
|
||||
nodeIDsByEntryName := map[string][]string{}
|
||||
assistantGroups := map[string]*openClawAssistantStepGroup{}
|
||||
toolCallNodesByAssistant := map[string][]*OpenClawSessionGraphNode{}
|
||||
toolCallNodeIDByToolCallID := map[string]string{}
|
||||
allToolCallNodes := []*OpenClawSessionGraphNode{}
|
||||
toolResultRecords := []openClawSessionGraphRecord{}
|
||||
|
||||
for _, record := range records {
|
||||
payload := record.Payload
|
||||
switch payload.Kind {
|
||||
case "task":
|
||||
builder.addNode(&OpenClawSessionGraphNode{
|
||||
ID: payload.EntryID,
|
||||
ParentID: payload.ParentID,
|
||||
EntryID: payload.EntryID,
|
||||
Kind: "task",
|
||||
Timestamp: payload.Timestamp,
|
||||
Summary: payload.Summary,
|
||||
Text: payload.Text,
|
||||
})
|
||||
appendGraphNodeEntryName(nodeIDsByEntryName, record.Entry, payload.EntryID)
|
||||
case "tool_call":
|
||||
nodeID := buildStoredToolCallNodeID(record.Entry, payload)
|
||||
builder.addNode(&OpenClawSessionGraphNode{
|
||||
ID: nodeID,
|
||||
ParentID: payload.EntryID,
|
||||
EntryID: payload.EntryID,
|
||||
ToolCallID: payload.ToolCallID,
|
||||
Kind: "tool_call",
|
||||
Timestamp: payload.Timestamp,
|
||||
Summary: payload.Summary,
|
||||
Tool: payload.Tool,
|
||||
Query: payload.Query,
|
||||
URL: payload.URL,
|
||||
Path: payload.Path,
|
||||
Text: payload.Text,
|
||||
})
|
||||
storedNode := builder.nodes[nodeID]
|
||||
appendGraphNodeEntryName(nodeIDsByEntryName, record.Entry, nodeID)
|
||||
if storedNode != nil {
|
||||
toolCallNodesByAssistant[payload.EntryID] = append(toolCallNodesByAssistant[payload.EntryID], storedNode)
|
||||
allToolCallNodes = append(allToolCallNodes, storedNode)
|
||||
}
|
||||
if payload.ToolCallID != "" && toolCallNodeIDByToolCallID[payload.ToolCallID] == "" {
|
||||
toolCallNodeIDByToolCallID[payload.ToolCallID] = nodeID
|
||||
}
|
||||
|
||||
group := assistantGroups[payload.EntryID]
|
||||
if group == nil {
|
||||
group = &openClawAssistantStepGroup{
|
||||
ParentID: payload.ParentID,
|
||||
Timestamp: payload.Timestamp,
|
||||
}
|
||||
assistantGroups[payload.EntryID] = group
|
||||
}
|
||||
group.ParentID = firstNonEmpty(group.ParentID, payload.ParentID)
|
||||
group.Timestamp = chooseEarlierTimestamp(group.Timestamp, payload.Timestamp)
|
||||
group.ToolNames = append(group.ToolNames, payload.Tool)
|
||||
group.Text = firstNonEmpty(group.Text, payload.AssistantText)
|
||||
case "tool_result":
|
||||
toolResultRecords = append(toolResultRecords, record)
|
||||
case "final":
|
||||
builder.addNode(&OpenClawSessionGraphNode{
|
||||
ID: payload.EntryID,
|
||||
ParentID: payload.ParentID,
|
||||
EntryID: payload.EntryID,
|
||||
Kind: "final",
|
||||
Timestamp: payload.Timestamp,
|
||||
Summary: payload.Summary,
|
||||
Text: payload.Text,
|
||||
})
|
||||
appendGraphNodeEntryName(nodeIDsByEntryName, record.Entry, payload.EntryID)
|
||||
}
|
||||
}
|
||||
|
||||
assistantIDs := make([]string, 0, len(assistantGroups))
|
||||
for entryID := range assistantGroups {
|
||||
assistantIDs = append(assistantIDs, entryID)
|
||||
}
|
||||
sort.Strings(assistantIDs)
|
||||
|
||||
for _, assistantID := range assistantIDs {
|
||||
group := assistantGroups[assistantID]
|
||||
builder.addNode(&OpenClawSessionGraphNode{
|
||||
ID: assistantID,
|
||||
ParentID: strings.TrimSpace(group.ParentID),
|
||||
EntryID: assistantID,
|
||||
Kind: "assistant_step",
|
||||
Timestamp: strings.TrimSpace(group.Timestamp),
|
||||
Summary: buildAssistantStepSummary(group.ToolNames),
|
||||
Text: strings.TrimSpace(group.Text),
|
||||
})
|
||||
}
|
||||
|
||||
for _, record := range toolResultRecords {
|
||||
payload := record.Payload
|
||||
parentID := strings.TrimSpace(payload.ParentID)
|
||||
originalParentID := ""
|
||||
|
||||
if payload.ToolCallID != "" {
|
||||
if matchedNodeID := strings.TrimSpace(toolCallNodeIDByToolCallID[payload.ToolCallID]); matchedNodeID != "" {
|
||||
originalParentID = parentID
|
||||
parentID = matchedNodeID
|
||||
}
|
||||
}
|
||||
|
||||
if parentID == strings.TrimSpace(payload.ParentID) {
|
||||
if matchedNodeID := matchToolResultToolCallNodeID(payload, toolCallNodesByAssistant[payload.ParentID], allToolCallNodes); matchedNodeID != "" && matchedNodeID != parentID {
|
||||
originalParentID = parentID
|
||||
parentID = matchedNodeID
|
||||
}
|
||||
}
|
||||
|
||||
builder.addNode(&OpenClawSessionGraphNode{
|
||||
ID: payload.EntryID,
|
||||
ParentID: parentID,
|
||||
OriginalParentID: originalParentID,
|
||||
EntryID: payload.EntryID,
|
||||
ToolCallID: payload.ToolCallID,
|
||||
Kind: "tool_result",
|
||||
Timestamp: payload.Timestamp,
|
||||
Summary: payload.Summary,
|
||||
Tool: payload.Tool,
|
||||
Query: payload.Query,
|
||||
URL: payload.URL,
|
||||
Path: payload.Path,
|
||||
OK: cloneBoolPointer(payload.OK),
|
||||
Error: payload.Error,
|
||||
Text: payload.Text,
|
||||
})
|
||||
appendGraphNodeEntryName(nodeIDsByEntryName, record.Entry, payload.EntryID)
|
||||
}
|
||||
|
||||
markStoredGraphAnchor(builder, anchorPayload, anchorEntryName, nodeIDsByEntryName)
|
||||
return builder.finalize()
|
||||
}
|
||||
|
||||
func appendGraphNodeEntryName(index map[string][]string, entry *Entry, nodeID string) {
|
||||
if index == nil || entry == nil {
|
||||
return
|
||||
}
|
||||
|
||||
entryName := strings.TrimSpace(entry.Name)
|
||||
nodeID = strings.TrimSpace(nodeID)
|
||||
if entryName == "" || nodeID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
for _, existingNodeID := range index[entryName] {
|
||||
if existingNodeID == nodeID {
|
||||
return
|
||||
}
|
||||
}
|
||||
index[entryName] = append(index[entryName], nodeID)
|
||||
}
|
||||
|
||||
func matchToolResultToolCallNodeID(payload openClawBehaviorPayload, assistantToolCalls []*OpenClawSessionGraphNode, allToolCalls []*OpenClawSessionGraphNode) string {
|
||||
if matchedNodeID := chooseMatchingToolCallNodeID(payload, assistantToolCalls); matchedNodeID != "" {
|
||||
return matchedNodeID
|
||||
}
|
||||
|
||||
if len(assistantToolCalls) != len(allToolCalls) {
|
||||
return chooseMatchingToolCallNodeID(payload, allToolCalls)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func chooseMatchingToolCallNodeID(payload openClawBehaviorPayload, candidates []*OpenClawSessionGraphNode) string {
|
||||
filtered := make([]*OpenClawSessionGraphNode, 0, len(candidates))
|
||||
seenNodeIDs := map[string]struct{}{}
|
||||
for _, candidate := range candidates {
|
||||
if candidate == nil || candidate.Kind != "tool_call" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenNodeIDs[candidate.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seenNodeIDs[candidate.ID] = struct{}{}
|
||||
filtered = append(filtered, candidate)
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(filtered) == 1 {
|
||||
return filtered[0].ID
|
||||
}
|
||||
|
||||
filtered = refineToolCallCandidates(filtered, payload.Query, func(node *OpenClawSessionGraphNode) string { return node.Query })
|
||||
if len(filtered) == 1 {
|
||||
return filtered[0].ID
|
||||
}
|
||||
|
||||
filtered = refineToolCallCandidates(filtered, payload.URL, func(node *OpenClawSessionGraphNode) string { return node.URL })
|
||||
if len(filtered) == 1 {
|
||||
return filtered[0].ID
|
||||
}
|
||||
|
||||
filtered = refineToolCallCandidates(filtered, payload.Path, func(node *OpenClawSessionGraphNode) string { return node.Path })
|
||||
if len(filtered) == 1 {
|
||||
return filtered[0].ID
|
||||
}
|
||||
|
||||
filtered = refineToolCallCandidates(filtered, payload.Tool, func(node *OpenClawSessionGraphNode) string { return node.Tool })
|
||||
if len(filtered) == 1 {
|
||||
return filtered[0].ID
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func refineToolCallCandidates(candidates []*OpenClawSessionGraphNode, expected string, selector func(node *OpenClawSessionGraphNode) string) []*OpenClawSessionGraphNode {
|
||||
expected = strings.TrimSpace(expected)
|
||||
if expected == "" {
|
||||
return candidates
|
||||
}
|
||||
|
||||
filtered := make([]*OpenClawSessionGraphNode, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
if strings.TrimSpace(selector(candidate)) == expected {
|
||||
filtered = append(filtered, candidate)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return candidates
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func markStoredGraphAnchor(builder *openClawSessionGraphBuilder, anchorPayload openClawBehaviorPayload, anchorEntryName string, nodeIDsByEntryName map[string][]string) {
|
||||
anchorNodeID := ""
|
||||
|
||||
if nodeIDs := nodeIDsByEntryName[strings.TrimSpace(anchorEntryName)]; len(nodeIDs) == 1 {
|
||||
anchorNodeID = nodeIDs[0]
|
||||
}
|
||||
|
||||
if anchorNodeID == "" {
|
||||
switch anchorPayload.Kind {
|
||||
case "tool_call":
|
||||
candidates := []string{}
|
||||
for _, node := range builder.nodes {
|
||||
if !toolCallPayloadMatchesNode(anchorPayload, node) {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, node.ID)
|
||||
}
|
||||
|
||||
switch len(candidates) {
|
||||
case 1:
|
||||
anchorNodeID = candidates[0]
|
||||
default:
|
||||
anchorNodeID = anchorPayload.EntryID
|
||||
}
|
||||
default:
|
||||
if node := builder.nodes[anchorPayload.EntryID]; node != nil && node.Kind == anchorPayload.Kind {
|
||||
anchorNodeID = node.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if anchorNode := builder.nodes[anchorNodeID]; anchorNode != nil {
|
||||
anchorNode.IsAnchor = true
|
||||
}
|
||||
}
|
||||
|
||||
func buildStoredToolCallNodeID(entry *Entry, payload openClawBehaviorPayload) string {
|
||||
if payload.ToolCallID != "" {
|
||||
return fmt.Sprintf("tool_call:%s", payload.ToolCallID)
|
||||
}
|
||||
if entry != nil && strings.TrimSpace(entry.Name) != "" {
|
||||
return fmt.Sprintf("tool_call_row:%s", strings.TrimSpace(entry.Name))
|
||||
}
|
||||
return fmt.Sprintf("tool_call:%s", strings.TrimSpace(payload.EntryID))
|
||||
}
|
||||
|
||||
func newOpenClawSessionGraphBuilder() *openClawSessionGraphBuilder {
|
||||
return &openClawSessionGraphBuilder{
|
||||
graph: &OpenClawSessionGraph{
|
||||
Nodes: []*OpenClawSessionGraphNode{},
|
||||
Edges: []*OpenClawSessionGraphEdge{},
|
||||
},
|
||||
nodes: map[string]*OpenClawSessionGraphNode{},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *openClawSessionGraphBuilder) addNode(node *OpenClawSessionGraphNode) {
|
||||
if b == nil || node == nil {
|
||||
return
|
||||
}
|
||||
|
||||
node.ID = strings.TrimSpace(node.ID)
|
||||
if node.ID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if existing := b.nodes[node.ID]; existing != nil {
|
||||
mergeOpenClawGraphNode(existing, node)
|
||||
return
|
||||
}
|
||||
|
||||
cloned := *node
|
||||
cloned.ParentID = strings.TrimSpace(cloned.ParentID)
|
||||
cloned.OriginalParentID = strings.TrimSpace(cloned.OriginalParentID)
|
||||
cloned.EntryID = strings.TrimSpace(cloned.EntryID)
|
||||
cloned.ToolCallID = strings.TrimSpace(cloned.ToolCallID)
|
||||
cloned.Kind = strings.TrimSpace(cloned.Kind)
|
||||
cloned.Timestamp = strings.TrimSpace(cloned.Timestamp)
|
||||
cloned.Summary = strings.TrimSpace(cloned.Summary)
|
||||
cloned.Tool = strings.TrimSpace(cloned.Tool)
|
||||
cloned.Query = strings.TrimSpace(cloned.Query)
|
||||
cloned.URL = strings.TrimSpace(cloned.URL)
|
||||
cloned.Path = strings.TrimSpace(cloned.Path)
|
||||
cloned.Error = strings.TrimSpace(cloned.Error)
|
||||
cloned.Text = strings.TrimSpace(cloned.Text)
|
||||
cloned.OK = cloneBoolPointer(cloned.OK)
|
||||
b.nodes[cloned.ID] = &cloned
|
||||
}
|
||||
|
||||
func (b *openClawSessionGraphBuilder) finalize() *OpenClawSessionGraph {
|
||||
if b == nil || b.graph == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
nodeIDs := make([]string, 0, len(b.nodes))
|
||||
for id := range b.nodes {
|
||||
nodeIDs = append(nodeIDs, id)
|
||||
}
|
||||
sort.Slice(nodeIDs, func(i, j int) bool {
|
||||
left := b.nodes[nodeIDs[i]]
|
||||
right := b.nodes[nodeIDs[j]]
|
||||
return compareGraphNodes(left, right) < 0
|
||||
})
|
||||
|
||||
b.graph.Nodes = make([]*OpenClawSessionGraphNode, 0, len(nodeIDs))
|
||||
b.graph.Stats = OpenClawSessionGraphStats{}
|
||||
for _, id := range nodeIDs {
|
||||
node := b.nodes[id]
|
||||
b.graph.Nodes = append(b.graph.Nodes, node)
|
||||
updateOpenClawSessionGraphStats(&b.graph.Stats, node)
|
||||
}
|
||||
|
||||
edgeKeys := map[string]struct{}{}
|
||||
b.graph.Edges = []*OpenClawSessionGraphEdge{}
|
||||
for _, node := range b.graph.Nodes {
|
||||
if node.ParentID == "" || b.nodes[node.ParentID] == nil {
|
||||
continue
|
||||
}
|
||||
key := fmt.Sprintf("%s->%s", node.ParentID, node.ID)
|
||||
if _, ok := edgeKeys[key]; ok {
|
||||
continue
|
||||
}
|
||||
edgeKeys[key] = struct{}{}
|
||||
b.graph.Edges = append(b.graph.Edges, &OpenClawSessionGraphEdge{
|
||||
Source: node.ParentID,
|
||||
Target: node.ID,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(b.graph.Edges, func(i, j int) bool {
|
||||
left := b.graph.Edges[i]
|
||||
right := b.graph.Edges[j]
|
||||
if left.Source != right.Source {
|
||||
return left.Source < right.Source
|
||||
}
|
||||
return left.Target < right.Target
|
||||
})
|
||||
|
||||
return b.graph
|
||||
}
|
||||
|
||||
func mergeOpenClawGraphNode(current, next *OpenClawSessionGraphNode) {
|
||||
if current == nil || next == nil {
|
||||
return
|
||||
}
|
||||
|
||||
current.ParentID = firstNonEmpty(current.ParentID, next.ParentID)
|
||||
current.OriginalParentID = firstNonEmpty(current.OriginalParentID, next.OriginalParentID)
|
||||
current.EntryID = firstNonEmpty(current.EntryID, next.EntryID)
|
||||
current.ToolCallID = firstNonEmpty(current.ToolCallID, next.ToolCallID)
|
||||
current.Kind = firstNonEmpty(current.Kind, next.Kind)
|
||||
current.Timestamp = chooseEarlierTimestamp(current.Timestamp, next.Timestamp)
|
||||
current.Summary = firstNonEmpty(current.Summary, next.Summary)
|
||||
current.Tool = firstNonEmpty(current.Tool, next.Tool)
|
||||
current.Query = firstNonEmpty(current.Query, next.Query)
|
||||
current.URL = firstNonEmpty(current.URL, next.URL)
|
||||
current.Path = firstNonEmpty(current.Path, next.Path)
|
||||
current.Error = firstNonEmpty(current.Error, next.Error)
|
||||
current.Text = firstNonEmpty(current.Text, next.Text)
|
||||
current.OK = mergeBoolPointers(current.OK, next.OK)
|
||||
current.IsAnchor = current.IsAnchor || next.IsAnchor
|
||||
}
|
||||
|
||||
func updateOpenClawSessionGraphStats(stats *OpenClawSessionGraphStats, node *OpenClawSessionGraphNode) {
|
||||
if stats == nil || node == nil {
|
||||
return
|
||||
}
|
||||
|
||||
stats.TotalNodes++
|
||||
switch node.Kind {
|
||||
case "task":
|
||||
stats.TaskCount++
|
||||
case "tool_call":
|
||||
stats.ToolCallCount++
|
||||
case "tool_result":
|
||||
stats.ToolResultCount++
|
||||
if node.OK != nil && !*node.OK {
|
||||
stats.FailedCount++
|
||||
}
|
||||
case "final":
|
||||
stats.FinalCount++
|
||||
}
|
||||
}
|
||||
|
||||
func buildAssistantStepSummary(toolNames []string) string {
|
||||
deduped := []string{}
|
||||
seen := map[string]struct{}{}
|
||||
for _, toolName := range toolNames {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[toolName]; ok {
|
||||
continue
|
||||
}
|
||||
seen[toolName] = struct{}{}
|
||||
deduped = append(deduped, toolName)
|
||||
}
|
||||
|
||||
if len(toolNames) == 0 {
|
||||
return "assistant step"
|
||||
}
|
||||
if len(deduped) == 0 {
|
||||
return fmt.Sprintf("%d tool calls", len(toolNames))
|
||||
}
|
||||
if len(deduped) <= 3 {
|
||||
return fmt.Sprintf("%d tool calls: %s", len(toolNames), strings.Join(deduped, ", "))
|
||||
}
|
||||
return fmt.Sprintf("%d tool calls: %s, ...", len(toolNames), strings.Join(deduped[:3], ", "))
|
||||
}
|
||||
|
||||
func toolCallPayloadMatchesNode(payload openClawBehaviorPayload, node *OpenClawSessionGraphNode) bool {
|
||||
if node == nil || node.Kind != "tool_call" {
|
||||
return false
|
||||
}
|
||||
|
||||
if payload.ToolCallID != "" {
|
||||
return strings.TrimSpace(node.ToolCallID) == strings.TrimSpace(payload.ToolCallID)
|
||||
}
|
||||
if strings.TrimSpace(node.EntryID) != strings.TrimSpace(payload.EntryID) {
|
||||
return false
|
||||
}
|
||||
|
||||
fields := []struct {
|
||||
payload string
|
||||
node string
|
||||
}{
|
||||
{payload.Tool, node.Tool},
|
||||
{payload.Query, node.Query},
|
||||
{payload.URL, node.URL},
|
||||
{payload.Path, node.Path},
|
||||
{payload.Text, node.Text},
|
||||
}
|
||||
|
||||
matchedField := false
|
||||
for _, field := range fields {
|
||||
left := strings.TrimSpace(field.payload)
|
||||
if left == "" {
|
||||
continue
|
||||
}
|
||||
matchedField = true
|
||||
if left != strings.TrimSpace(field.node) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return matchedField
|
||||
}
|
||||
|
||||
func compareGraphNodes(left, right *OpenClawSessionGraphNode) int {
|
||||
leftTimestamp := ""
|
||||
rightTimestamp := ""
|
||||
leftID := ""
|
||||
rightID := ""
|
||||
if left != nil {
|
||||
leftTimestamp = left.Timestamp
|
||||
leftID = left.ID
|
||||
}
|
||||
if right != nil {
|
||||
rightTimestamp = right.Timestamp
|
||||
rightID = right.ID
|
||||
}
|
||||
if timestampOrder := compareOpenClawGraphTimestamps(leftTimestamp, rightTimestamp); timestampOrder != 0 {
|
||||
return timestampOrder
|
||||
}
|
||||
if leftID < rightID {
|
||||
return -1
|
||||
}
|
||||
if leftID > rightID {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func chooseEarlierTimestamp(current, next string) string {
|
||||
current = strings.TrimSpace(current)
|
||||
next = strings.TrimSpace(next)
|
||||
if current == "" {
|
||||
return next
|
||||
}
|
||||
if next == "" {
|
||||
return current
|
||||
}
|
||||
if compareOpenClawGraphTimestamps(next, current) < 0 {
|
||||
return next
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func compareOpenClawGraphTimestamps(left, right string) int {
|
||||
left = strings.TrimSpace(left)
|
||||
right = strings.TrimSpace(right)
|
||||
|
||||
leftUnixNano, leftOK := parseOpenClawGraphTimestamp(left)
|
||||
rightUnixNano, rightOK := parseOpenClawGraphTimestamp(right)
|
||||
if leftOK && rightOK {
|
||||
if leftUnixNano < rightUnixNano {
|
||||
return -1
|
||||
}
|
||||
if leftUnixNano > rightUnixNano {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
if left < right {
|
||||
return -1
|
||||
}
|
||||
if left > right {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseOpenClawGraphTimestamp(timestamp string) (_ int64, ok bool) {
|
||||
timestamp = strings.TrimSpace(timestamp)
|
||||
if timestamp == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
ok = false
|
||||
}
|
||||
}()
|
||||
|
||||
return util.String2Time(timestamp).UnixNano(), true
|
||||
}
|
||||
|
||||
func mergeBoolPointers(current, next *bool) *bool {
|
||||
if next == nil {
|
||||
return current
|
||||
}
|
||||
if current == nil {
|
||||
return cloneBoolPointer(next)
|
||||
}
|
||||
value := *current && *next
|
||||
return &value
|
||||
}
|
||||
|
||||
func cloneBoolPointer(value *bool) *bool {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *value
|
||||
return &cloned
|
||||
}
|
||||
733
object/openclaw_transcript_sync.go
Normal file
733
object/openclaw_transcript_sync.go
Normal file
@@ -0,0 +1,733 @@
|
||||
package object
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/casdoor/casdoor/util"
|
||||
)
|
||||
|
||||
const openClawTranscriptSyncInterval = 10 * time.Second
|
||||
|
||||
var (
|
||||
openClawTranscriptWorkers = map[string]*openClawTranscriptSyncWorker{}
|
||||
openClawTranscriptWorkersMu sync.Mutex
|
||||
|
||||
writeSuccessPathPattern = regexp.MustCompile(`(?i)successfully wrote \d+ bytes to (.+)$`)
|
||||
)
|
||||
|
||||
type openClawTranscriptSyncWorker struct {
|
||||
provider *Provider
|
||||
stopCh chan struct{}
|
||||
doneCh chan struct{}
|
||||
fileStates map[string]openClawTranscriptFileState
|
||||
}
|
||||
|
||||
type openClawTranscriptFileState struct {
|
||||
ModTimeUnixNano int64
|
||||
Size int64
|
||||
}
|
||||
|
||||
type openClawTranscriptEntry struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
ParentID string `json:"parentId"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Message *openClawMessage `json:"message"`
|
||||
Details map[string]interface{} `json:"details"`
|
||||
}
|
||||
|
||||
type openClawMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
StopReason string `json:"stopReason"`
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
ToolName string `json:"toolName"`
|
||||
IsError bool `json:"isError"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
type openClawContentItem struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Text string `json:"text"`
|
||||
Arguments map[string]interface{} `json:"arguments"`
|
||||
}
|
||||
|
||||
type openClawBehaviorPayload struct {
|
||||
Summary string `json:"summary"`
|
||||
Kind string `json:"kind"`
|
||||
SessionID string `json:"sessionId"`
|
||||
EntryID string `json:"entryId"`
|
||||
ToolCallID string `json:"toolCallId,omitempty"`
|
||||
ParentID string `json:"parentId,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Tool string `json:"tool,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
OK *bool `json:"ok,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
AssistantText string `json:"assistantText,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
type openClawToolContext struct {
|
||||
Tool string
|
||||
Query string
|
||||
URL string
|
||||
Path string
|
||||
Command string
|
||||
}
|
||||
|
||||
func startOpenClawTranscriptSync(provider *Provider) {
|
||||
if provider == nil || provider.Category != "Log" || provider.Type != "Agent" || provider.SubType != "OpenClaw" {
|
||||
return
|
||||
}
|
||||
|
||||
id := provider.GetId()
|
||||
stopOpenClawTranscriptSync(id)
|
||||
|
||||
worker := &openClawTranscriptSyncWorker{
|
||||
provider: provider,
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
fileStates: map[string]openClawTranscriptFileState{},
|
||||
}
|
||||
|
||||
openClawTranscriptWorkersMu.Lock()
|
||||
openClawTranscriptWorkers[id] = worker
|
||||
openClawTranscriptWorkersMu.Unlock()
|
||||
|
||||
go worker.run()
|
||||
}
|
||||
|
||||
func stopOpenClawTranscriptSync(providerID string) {
|
||||
openClawTranscriptWorkersMu.Lock()
|
||||
worker, ok := openClawTranscriptWorkers[providerID]
|
||||
if ok {
|
||||
delete(openClawTranscriptWorkers, providerID)
|
||||
}
|
||||
openClawTranscriptWorkersMu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
close(worker.stopCh)
|
||||
<-worker.doneCh
|
||||
}
|
||||
|
||||
func (w *openClawTranscriptSyncWorker) run() {
|
||||
defer close(w.doneCh)
|
||||
|
||||
w.syncOnce()
|
||||
|
||||
ticker := time.NewTicker(openClawTranscriptSyncInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-w.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.syncOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *openClawTranscriptSyncWorker) syncOnce() {
|
||||
if w.isStopping() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := w.scanTranscriptDir(); err != nil {
|
||||
fmt.Printf("OpenClaw transcript sync failed for provider %s: %v\n", w.provider.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *openClawTranscriptSyncWorker) isStopping() bool {
|
||||
select {
|
||||
case <-w.stopCh:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (w *openClawTranscriptSyncWorker) scanTranscriptDir() error {
|
||||
rootDir, err := resolveOpenClawTranscriptDir(w.provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(rootDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
seenPaths := map[string]struct{}{}
|
||||
for _, entry := range entries {
|
||||
if w.isStopping() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
if !strings.HasSuffix(name, ".jsonl") || strings.Contains(name, ".reset.") || name == "sessions.json" {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(rootDir, name)
|
||||
seenPaths[path] = struct{}{}
|
||||
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nextState := openClawTranscriptFileState{
|
||||
ModTimeUnixNano: info.ModTime().UnixNano(),
|
||||
Size: info.Size(),
|
||||
}
|
||||
if w.shouldSkipTranscriptFile(path, nextState) {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := w.scanTranscriptFile(path); err != nil {
|
||||
return err
|
||||
}
|
||||
w.fileStates[path] = nextState
|
||||
}
|
||||
|
||||
for path := range w.fileStates {
|
||||
if w.isStopping() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, ok := seenPaths[path]; !ok {
|
||||
delete(w.fileStates, path)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *openClawTranscriptSyncWorker) shouldSkipTranscriptFile(path string, nextState openClawTranscriptFileState) bool {
|
||||
currentState, ok := w.fileStates[path]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return currentState.ModTimeUnixNano == nextState.ModTimeUnixNano && currentState.Size == nextState.Size
|
||||
}
|
||||
|
||||
func resolveOpenClawTranscriptDir(provider *Provider) (string, error) {
|
||||
if provider == nil {
|
||||
return "", fmt.Errorf("provider is nil")
|
||||
}
|
||||
|
||||
if endpoint := strings.TrimSpace(provider.Endpoint); endpoint != "" {
|
||||
return expandOpenClawPath(endpoint)
|
||||
}
|
||||
|
||||
stateDir, err := resolveOpenClawStateDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
agentID := strings.TrimSpace(provider.Title)
|
||||
if agentID == "" {
|
||||
agentID = "main"
|
||||
}
|
||||
|
||||
return filepath.Join(stateDir, "agents", agentID, "sessions"), nil
|
||||
}
|
||||
|
||||
func fillOpenClawProviderDefaults(provider *Provider) error {
|
||||
if !isOpenClawLogProvider(provider) {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(provider.Title) == "" {
|
||||
provider.Title = "main"
|
||||
}
|
||||
if strings.TrimSpace(provider.Endpoint) != "" {
|
||||
resolved, err := expandOpenClawPath(provider.Endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
provider.Endpoint = resolved
|
||||
return nil
|
||||
}
|
||||
|
||||
transcriptDir, err := resolveOpenClawTranscriptDir(provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
provider.Endpoint = transcriptDir
|
||||
return nil
|
||||
}
|
||||
|
||||
func isOpenClawLogProvider(provider *Provider) bool {
|
||||
return provider != nil && provider.Category == "Log" && provider.Type == "Agent" && provider.SubType == "OpenClaw"
|
||||
}
|
||||
|
||||
func resolveOpenClawStateDir() (string, error) {
|
||||
if override := strings.TrimSpace(os.Getenv("OPENCLAW_STATE_DIR")); override != "" {
|
||||
return expandOpenClawPath(override)
|
||||
}
|
||||
|
||||
homeDir, err := resolveOpenClawHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if profile := strings.TrimSpace(os.Getenv("OPENCLAW_PROFILE")); profile != "" && !strings.EqualFold(profile, "default") {
|
||||
return filepath.Join(homeDir, ".openclaw-"+profile), nil
|
||||
}
|
||||
|
||||
return filepath.Join(homeDir, ".openclaw"), nil
|
||||
}
|
||||
|
||||
func resolveOpenClawHomeDir() (string, error) {
|
||||
if explicitHome := strings.TrimSpace(os.Getenv("OPENCLAW_HOME")); explicitHome != "" {
|
||||
return expandOpenClawPath(explicitHome)
|
||||
}
|
||||
|
||||
return resolveSystemHomeDir()
|
||||
}
|
||||
|
||||
func resolveSystemHomeDir() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Clean(homeDir), nil
|
||||
}
|
||||
|
||||
func expandOpenClawPath(input string) (string, error) {
|
||||
trimmed := strings.TrimSpace(input)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if trimmed == "~" || strings.HasPrefix(trimmed, "~/") || strings.HasPrefix(trimmed, "~\\") {
|
||||
homeDir, err := resolveSystemHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
suffix := strings.TrimPrefix(strings.TrimPrefix(trimmed, "~"), string(filepath.Separator))
|
||||
suffix = strings.TrimPrefix(strings.TrimPrefix(suffix, "/"), "\\")
|
||||
if suffix == "" {
|
||||
return filepath.Clean(homeDir), nil
|
||||
}
|
||||
return filepath.Clean(filepath.Join(homeDir, suffix)), nil
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" && len(trimmed) >= 2 && trimmed[0] == '%' {
|
||||
if index := strings.Index(trimmed[1:], "%"); index >= 0 {
|
||||
end := index + 1
|
||||
envKey := trimmed[1:end]
|
||||
if envValue := strings.TrimSpace(os.Getenv(envKey)); envValue != "" {
|
||||
replaced := envValue + trimmed[end+1:]
|
||||
return filepath.Clean(replaced), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filepath.Clean(trimmed), nil
|
||||
}
|
||||
|
||||
func (w *openClawTranscriptSyncWorker) scanTranscriptFile(path string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
sessionID := strings.TrimSuffix(filepath.Base(path), ".jsonl")
|
||||
toolContexts := map[string]openClawToolContext{}
|
||||
reader := bufio.NewReader(file)
|
||||
|
||||
for {
|
||||
if w.isStopping() {
|
||||
return nil
|
||||
}
|
||||
|
||||
lineBytes, readErr := reader.ReadBytes('\n')
|
||||
if readErr != nil && readErr != io.EOF {
|
||||
return readErr
|
||||
}
|
||||
|
||||
line := strings.TrimSpace(string(lineBytes))
|
||||
if line == "" {
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
var entry openClawTranscriptEntry
|
||||
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
results := buildOpenClawTranscriptEntries(w.provider, sessionID, entry, toolContexts)
|
||||
for _, result := range results {
|
||||
if result == nil {
|
||||
continue
|
||||
}
|
||||
if err := addOpenClawTranscriptEntry(result); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildOpenClawTranscriptEntries(provider *Provider, sessionID string, entry openClawTranscriptEntry, toolContexts map[string]openClawToolContext) []*Entry {
|
||||
if entry.Type != "message" || entry.Message == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
message := entry.Message
|
||||
switch message.Role {
|
||||
case "user":
|
||||
text := normalizeUserText(extractMessageText(message.Content))
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
if isHeartbeatText(text) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []*Entry{newOpenClawTranscriptEntry(provider, sessionID, "task", entry.ID, openClawBehaviorPayload{
|
||||
Summary: truncateText(fmt.Sprintf("task: %s", text), 100),
|
||||
Kind: "task",
|
||||
SessionID: sessionID,
|
||||
EntryID: entry.ID,
|
||||
ParentID: entry.ParentID,
|
||||
Timestamp: normalizeOpenClawTimestamp(entry.Timestamp, message.Timestamp),
|
||||
Text: truncateText(text, 2000),
|
||||
})}
|
||||
case "assistant":
|
||||
items := parseContentItems(message.Content)
|
||||
assistantText := truncateText(extractMessageText(message.Content), 2000)
|
||||
toolEntries := []*Entry{}
|
||||
storedAssistantText := false
|
||||
for _, item := range items {
|
||||
if item.Type != "toolCall" {
|
||||
continue
|
||||
}
|
||||
context := extractOpenClawToolContext(item)
|
||||
toolContexts[item.ID] = context
|
||||
payload := openClawBehaviorPayload{
|
||||
Summary: truncateText(buildToolCallSummary(context), 100),
|
||||
Kind: "tool_call",
|
||||
SessionID: sessionID,
|
||||
EntryID: entry.ID,
|
||||
ToolCallID: item.ID,
|
||||
ParentID: entry.ParentID,
|
||||
Timestamp: normalizeOpenClawTimestamp(entry.Timestamp, message.Timestamp),
|
||||
Tool: context.Tool,
|
||||
Query: context.Query,
|
||||
URL: context.URL,
|
||||
Path: context.Path,
|
||||
Text: truncateText(context.Command, 500),
|
||||
}
|
||||
if !storedAssistantText {
|
||||
// Avoid duplicating the same assistant text on every tool-call row.
|
||||
payload.AssistantText = assistantText
|
||||
storedAssistantText = true
|
||||
}
|
||||
identity := fmt.Sprintf("%s/%s", entry.ID, item.ID)
|
||||
toolEntries = append(toolEntries, newOpenClawTranscriptEntry(provider, sessionID, "tool_call", identity, payload))
|
||||
}
|
||||
if len(toolEntries) > 0 {
|
||||
return toolEntries
|
||||
}
|
||||
|
||||
if message.StopReason != "stop" {
|
||||
return nil
|
||||
}
|
||||
text := extractMessageText(message.Content)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return []*Entry{newOpenClawTranscriptEntry(provider, sessionID, "final", entry.ID, openClawBehaviorPayload{
|
||||
Summary: truncateText(fmt.Sprintf("final: %s", text), 100),
|
||||
Kind: "final",
|
||||
SessionID: sessionID,
|
||||
EntryID: entry.ID,
|
||||
ParentID: entry.ParentID,
|
||||
Timestamp: normalizeOpenClawTimestamp(entry.Timestamp, message.Timestamp),
|
||||
Text: truncateText(text, 2000),
|
||||
})}
|
||||
case "toolResult":
|
||||
payload, ok := buildToolResultPayload(sessionID, entry, toolContexts[message.ToolCallID])
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return []*Entry{newOpenClawTranscriptEntry(provider, sessionID, "tool_result", entry.ID, payload)}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildToolResultPayload(sessionID string, entry openClawTranscriptEntry, toolContext openClawToolContext) (openClawBehaviorPayload, bool) {
|
||||
message := entry.Message
|
||||
if message == nil {
|
||||
return openClawBehaviorPayload{}, false
|
||||
}
|
||||
|
||||
okValue, errorText := resolveToolResultStatus(entry)
|
||||
text := summarizeToolResultText(extractMessageText(message.Content), okValue)
|
||||
toolName := firstNonEmpty(toolContext.Tool, message.ToolName)
|
||||
if toolName == "" && text == "" && errorText == "" {
|
||||
return openClawBehaviorPayload{}, false
|
||||
}
|
||||
|
||||
return openClawBehaviorPayload{
|
||||
Summary: truncateText(buildToolResultSummary(toolName, toolContext, okValue, errorText, text), 100),
|
||||
Kind: "tool_result",
|
||||
SessionID: sessionID,
|
||||
EntryID: entry.ID,
|
||||
ToolCallID: message.ToolCallID,
|
||||
ParentID: entry.ParentID,
|
||||
Timestamp: normalizeOpenClawTimestamp(entry.Timestamp, message.Timestamp),
|
||||
Tool: toolName,
|
||||
Query: toolContext.Query,
|
||||
URL: toolContext.URL,
|
||||
Path: firstNonEmpty(toolContext.Path, extractWriteSuccessPath(text)),
|
||||
OK: &okValue,
|
||||
Error: truncateText(errorText, 500),
|
||||
Text: truncateText(text, 2000),
|
||||
}, true
|
||||
}
|
||||
|
||||
func newOpenClawTranscriptEntry(provider *Provider, sessionID string, entryKind string, identity string, payload openClawBehaviorPayload) *Entry {
|
||||
body, _ := json.Marshal(payload)
|
||||
nameSource := fmt.Sprintf("%s|%s|%s", provider.Name, sessionID, identity)
|
||||
createdTime := payload.Timestamp
|
||||
if strings.TrimSpace(createdTime) == "" {
|
||||
createdTime = util.GetCurrentTime()
|
||||
}
|
||||
|
||||
return &Entry{
|
||||
Owner: CasdoorOrganization,
|
||||
Name: fmt.Sprintf("oc_%s", util.GetMd5Hash(nameSource)),
|
||||
CreatedTime: createdTime,
|
||||
UpdatedTime: createdTime,
|
||||
DisplayName: truncateText(payload.Summary, 100),
|
||||
Provider: provider.Name,
|
||||
Type: "session",
|
||||
Message: string(body),
|
||||
}
|
||||
}
|
||||
|
||||
func addOpenClawTranscriptEntry(entry *Entry) error {
|
||||
if entry == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := AddEntry(entry)
|
||||
if err == nil || isDuplicateEntryError(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func isDuplicateEntryError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "duplicate") || strings.Contains(msg, "unique constraint") || strings.Contains(msg, "already exists")
|
||||
}
|
||||
|
||||
func normalizeOpenClawTimestamp(raw string, fallbackMillis int64) string {
|
||||
if trimmed := strings.TrimSpace(raw); trimmed != "" {
|
||||
if _, err := time.Parse(time.RFC3339, trimmed); err == nil {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
if fallbackMillis > 0 {
|
||||
return time.UnixMilli(fallbackMillis).UTC().Format(time.RFC3339)
|
||||
}
|
||||
return util.GetCurrentTime()
|
||||
}
|
||||
|
||||
func parseContentItems(raw json.RawMessage) []openClawContentItem {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var text string
|
||||
if err := json.Unmarshal(raw, &text); err == nil {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil
|
||||
}
|
||||
return []openClawContentItem{{Type: "text", Text: text}}
|
||||
}
|
||||
|
||||
var items []openClawContentItem
|
||||
if err := json.Unmarshal(raw, &items); err == nil {
|
||||
return items
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractMessageText(raw json.RawMessage) string {
|
||||
items := parseContentItems(raw)
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := []string{}
|
||||
for _, item := range items {
|
||||
if item.Type == "text" && strings.TrimSpace(item.Text) != "" {
|
||||
parts = append(parts, strings.TrimSpace(item.Text))
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(parts, "\n\n"))
|
||||
}
|
||||
|
||||
func normalizeUserText(text string) string {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if !strings.HasPrefix(trimmed, "Sender (untrusted metadata):") {
|
||||
return trimmed
|
||||
}
|
||||
if index := strings.LastIndex(trimmed, "\n\n"); index >= 0 && index+2 < len(trimmed) {
|
||||
return strings.TrimSpace(trimmed[index+2:])
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func extractOpenClawToolContext(item openClawContentItem) openClawToolContext {
|
||||
context := openClawToolContext{Tool: item.Name}
|
||||
if item.Arguments == nil {
|
||||
return context
|
||||
}
|
||||
context.Query = stringifyOpenClawArg(item.Arguments["query"])
|
||||
context.URL = stringifyOpenClawArg(item.Arguments["url"])
|
||||
context.Path = stringifyOpenClawArg(item.Arguments["path"])
|
||||
context.Command = stringifyOpenClawArg(item.Arguments["command"])
|
||||
return context
|
||||
}
|
||||
|
||||
func stringifyOpenClawArg(value interface{}) string {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func buildToolCallSummary(context openClawToolContext) string {
|
||||
target := firstNonEmpty(context.Query, context.URL, context.Path, context.Command)
|
||||
if target == "" {
|
||||
return fmt.Sprintf("%s called", context.Tool)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", context.Tool, target)
|
||||
}
|
||||
|
||||
func resolveToolResultStatus(entry openClawTranscriptEntry) (bool, string) {
|
||||
if entry.Message != nil && entry.Message.IsError {
|
||||
return false, stringifyOpenClawArg(entry.Details["error"])
|
||||
}
|
||||
if status, ok := entry.Details["status"].(string); ok && strings.EqualFold(status, "error") {
|
||||
return false, stringifyOpenClawArg(entry.Details["error"])
|
||||
}
|
||||
|
||||
text := strings.TrimSpace(extractMessageText(entry.Message.Content))
|
||||
if strings.HasPrefix(text, "{") {
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(text), &payload); err == nil {
|
||||
if status, ok := payload["status"].(string); ok && strings.EqualFold(status, "error") {
|
||||
return false, stringifyOpenClawArg(payload["error"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true, stringifyOpenClawArg(entry.Details["error"])
|
||||
}
|
||||
|
||||
func summarizeToolResultText(text string, okValue bool) string {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if okValue {
|
||||
if path := extractWriteSuccessPath(trimmed); path != "" {
|
||||
return fmt.Sprintf("Successfully wrote %s", path)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func extractWriteSuccessPath(text string) string {
|
||||
matches := writeSuccessPathPattern.FindStringSubmatch(strings.TrimSpace(text))
|
||||
if len(matches) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(matches[1])
|
||||
}
|
||||
|
||||
func buildToolResultSummary(tool string, context openClawToolContext, okValue bool, errorText string, text string) string {
|
||||
target := firstNonEmpty(context.Query, context.URL, context.Path, context.Command, extractWriteSuccessPath(text))
|
||||
status := "ok"
|
||||
details := target
|
||||
if !okValue {
|
||||
status = "failed"
|
||||
details = firstNonEmpty(errorText, target)
|
||||
}
|
||||
if details == "" {
|
||||
return fmt.Sprintf("%s %s", tool, status)
|
||||
}
|
||||
return fmt.Sprintf("%s %s: %s", tool, status, details)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isHeartbeatText(text string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(text), "Read HEARTBEAT.md")
|
||||
}
|
||||
|
||||
func truncateText(text string, max int) string {
|
||||
if max <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(strings.TrimSpace(text))
|
||||
if len(runes) <= max {
|
||||
return string(runes)
|
||||
}
|
||||
return string(runes[:max-1]) + "…"
|
||||
}
|
||||
@@ -254,7 +254,11 @@ func (a *Ormer) open() error {
|
||||
dataSourceName = a.dataSourceName
|
||||
}
|
||||
|
||||
engine, err := xorm.NewEngine(a.driverName, dataSourceName)
|
||||
driverName := a.driverName
|
||||
if driverName == "sqlite3" {
|
||||
driverName = "sqlite"
|
||||
}
|
||||
engine, err := xorm.NewEngine(driverName, dataSourceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -278,7 +282,11 @@ func (a *Ormer) openFromDb(db *sql.DB) error {
|
||||
|
||||
xormDb := core.FromDB(db)
|
||||
|
||||
engine, err := xorm.NewEngineWithDB(a.driverName, dataSourceName, xormDb)
|
||||
driverName := a.driverName
|
||||
if driverName == "sqlite3" {
|
||||
driverName = "sqlite"
|
||||
}
|
||||
engine, err := xorm.NewEngineWithDB(driverName, dataSourceName, xormDb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -120,18 +120,6 @@ func checkPermissionValid(permission *Permission) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
groupingPolicies, err := getGroupingPolicies(permission)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(groupingPolicies) > 0 {
|
||||
_, err = enforcer.AddGroupingPolicies(groupingPolicies)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -171,11 +159,6 @@ func UpdatePermission(id string, permission *Permission) (bool, error) {
|
||||
}
|
||||
|
||||
if affected != 0 {
|
||||
err = removeGroupingPolicies(oldPermission)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
err = removePolicies(oldPermission)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -191,11 +174,6 @@ func UpdatePermission(id string, permission *Permission) (bool, error) {
|
||||
// }
|
||||
// }
|
||||
|
||||
err = addGroupingPolicies(permission)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
err = addPolicies(permission)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -212,11 +190,6 @@ func AddPermission(permission *Permission) (bool, error) {
|
||||
}
|
||||
|
||||
if affected != 0 {
|
||||
err = addGroupingPolicies(permission)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
err = addPolicies(permission)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -241,11 +214,6 @@ func AddPermissions(permissions []*Permission) (bool, error) {
|
||||
for _, permission := range permissions {
|
||||
// add using for loop
|
||||
if affected != 0 {
|
||||
err = addGroupingPolicies(permission)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
err = addPolicies(permission)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -302,11 +270,6 @@ func DeletePermission(permission *Permission) (bool, error) {
|
||||
}
|
||||
|
||||
if affected {
|
||||
err = removeGroupingPolicies(permission)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
err = removePolicies(permission)
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
||||
@@ -52,11 +52,9 @@ func getPermissionEnforcer(p *Permission, permissionIDs ...string) (*casbin.Enfo
|
||||
}
|
||||
|
||||
policyFilter := xormadapter.Filter{
|
||||
V5: policyFilterV5,
|
||||
}
|
||||
|
||||
if !HasRoleDefinition(enforcer.GetModel()) {
|
||||
policyFilter.Ptype = []string{"p"}
|
||||
// Permission enforcers only persist p rules. Legacy g rows are rebuilt from roles at runtime.
|
||||
Ptype: []string{"p"},
|
||||
V5: policyFilterV5,
|
||||
}
|
||||
|
||||
err = enforcer.LoadFilteredPolicy(policyFilter)
|
||||
@@ -64,6 +62,12 @@ func getPermissionEnforcer(p *Permission, permissionIDs ...string) (*casbin.Enfo
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// we can rebuild group policies in memory
|
||||
err = loadRuntimeGroupingPolicies(enforcer, p, permissionIDs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return enforcer, nil
|
||||
}
|
||||
|
||||
@@ -141,13 +145,47 @@ func getPolicies(permission *Permission) [][]string {
|
||||
return policies
|
||||
}
|
||||
|
||||
func getRolesInRole(roleId string, visited map[string]struct{}) ([]*Role, error) {
|
||||
type permissionRoleResolver struct {
|
||||
rolesByOwner map[string][]*Role
|
||||
roleByID map[string]*Role
|
||||
}
|
||||
|
||||
func newPermissionRoleResolver() *permissionRoleResolver {
|
||||
return &permissionRoleResolver{
|
||||
rolesByOwner: map[string][]*Role{},
|
||||
roleByID: map[string]*Role{},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *permissionRoleResolver) getRoles(owner string) ([]*Role, error) {
|
||||
if roles, ok := r.rolesByOwner[owner]; ok {
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
roles, err := GetRoles(owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r.rolesByOwner[owner] = roles
|
||||
for _, role := range roles {
|
||||
r.roleByID[role.GetId()] = role
|
||||
}
|
||||
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
func (r *permissionRoleResolver) getRolesInRole(permissionOwner string, roleId string, visited map[string]struct{}) ([]*Role, error) {
|
||||
if roleId == "*" {
|
||||
roleId = util.GetId(permissionOwner, "*")
|
||||
}
|
||||
|
||||
roleOwner, roleName, err := util.GetOwnerAndNameFromIdWithError(roleId)
|
||||
if err != nil {
|
||||
return []*Role{}, err
|
||||
}
|
||||
if roleName == "*" {
|
||||
roles, err := GetRoles(roleOwner)
|
||||
roles, err := r.getRoles(roleOwner)
|
||||
if err != nil {
|
||||
return []*Role{}, err
|
||||
}
|
||||
@@ -155,11 +193,13 @@ func getRolesInRole(roleId string, visited map[string]struct{}) ([]*Role, error)
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
role, err := GetRole(roleId)
|
||||
_, err = r.getRoles(roleOwner)
|
||||
if err != nil {
|
||||
return []*Role{}, err
|
||||
}
|
||||
|
||||
role := r.roleByID[roleId]
|
||||
|
||||
if role == nil {
|
||||
return []*Role{}, nil
|
||||
}
|
||||
@@ -168,55 +208,94 @@ func getRolesInRole(roleId string, visited map[string]struct{}) ([]*Role, error)
|
||||
roles := []*Role{role}
|
||||
for _, subRole := range role.Roles {
|
||||
if _, ok := visited[subRole]; !ok {
|
||||
r, err := getRolesInRole(subRole, visited)
|
||||
subRoles, err := r.getRolesInRole(roleOwner, subRole, visited)
|
||||
if err != nil {
|
||||
return []*Role{}, err
|
||||
}
|
||||
|
||||
roles = append(roles, r...)
|
||||
roles = append(roles, subRoles...)
|
||||
}
|
||||
}
|
||||
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
func getGroupingPolicies(permission *Permission) ([][]string, error) {
|
||||
var groupingPolicies [][]string
|
||||
func getPermissionEnforcerTargets(permission *Permission, permissionIDs ...string) ([]*Permission, error) {
|
||||
if len(permissionIDs) == 0 {
|
||||
return []*Permission{permission}, nil
|
||||
}
|
||||
|
||||
domainExist := len(permission.Domains) > 0
|
||||
permissionId := permission.GetId()
|
||||
|
||||
for _, roleId := range permission.Roles {
|
||||
visited := map[string]struct{}{}
|
||||
|
||||
if roleId == "*" {
|
||||
roleId = util.GetId(permission.Owner, "*")
|
||||
permissions := make([]*Permission, 0, len(permissionIDs))
|
||||
visited := map[string]struct{}{}
|
||||
for _, permissionID := range permissionIDs {
|
||||
if _, ok := visited[permissionID]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
rolesInRole, err := getRolesInRole(roleId, visited)
|
||||
targetPermission, err := GetPermission(permissionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if targetPermission == nil {
|
||||
return nil, fmt.Errorf("the permission: %s doesn't exist", permissionID)
|
||||
}
|
||||
|
||||
for _, role := range rolesInRole {
|
||||
roleId = role.GetId()
|
||||
for _, subUser := range role.Users {
|
||||
if domainExist {
|
||||
for _, domain := range permission.Domains {
|
||||
groupingPolicies = append(groupingPolicies, []string{subUser, roleId, domain, "", "", permissionId})
|
||||
}
|
||||
} else {
|
||||
groupingPolicies = append(groupingPolicies, []string{subUser, roleId, "", "", "", permissionId})
|
||||
}
|
||||
permissions = append(permissions, targetPermission)
|
||||
visited[permissionID] = struct{}{}
|
||||
}
|
||||
|
||||
return permissions, nil
|
||||
}
|
||||
|
||||
func newRuntimeGroupingPolicy(sub string, roleId string, domain string) []string {
|
||||
return []string{sub, roleId, domain, "", "", ""}
|
||||
}
|
||||
|
||||
func appendRuntimeGroupingPolicy(groupingPolicies *[][]string, visited map[string]struct{}, rule []string) {
|
||||
// we can't use []string as key, so use null character
|
||||
key := strings.Join(rule, "\x00")
|
||||
if _, ok := visited[key]; ok {
|
||||
return
|
||||
}
|
||||
|
||||
*groupingPolicies = append(*groupingPolicies, rule)
|
||||
visited[key] = struct{}{}
|
||||
}
|
||||
|
||||
func getRuntimeGroupingPolicies(permissions []*Permission) ([][]string, error) {
|
||||
var groupingPolicies [][]string
|
||||
visitedPolicies := map[string]struct{}{}
|
||||
roleResolver := newPermissionRoleResolver()
|
||||
|
||||
for _, permission := range permissions {
|
||||
domainExist := len(permission.Domains) > 0
|
||||
for _, roleId := range permission.Roles {
|
||||
visited := map[string]struct{}{}
|
||||
rolesInRole, err := roleResolver.getRolesInRole(permission.Owner, roleId, visited)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, subRole := range role.Roles {
|
||||
if domainExist {
|
||||
for _, domain := range permission.Domains {
|
||||
groupingPolicies = append(groupingPolicies, []string{subRole, roleId, domain, "", "", permissionId})
|
||||
for _, role := range rolesInRole {
|
||||
currentRoleID := role.GetId()
|
||||
for _, subUser := range role.Users {
|
||||
if domainExist {
|
||||
for _, domain := range permission.Domains {
|
||||
appendRuntimeGroupingPolicy(&groupingPolicies, visitedPolicies, newRuntimeGroupingPolicy(subUser, currentRoleID, domain))
|
||||
}
|
||||
} else {
|
||||
appendRuntimeGroupingPolicy(&groupingPolicies, visitedPolicies, newRuntimeGroupingPolicy(subUser, currentRoleID, ""))
|
||||
}
|
||||
}
|
||||
|
||||
for _, subRole := range role.Roles {
|
||||
if domainExist {
|
||||
for _, domain := range permission.Domains {
|
||||
appendRuntimeGroupingPolicy(&groupingPolicies, visitedPolicies, newRuntimeGroupingPolicy(subRole, currentRoleID, domain))
|
||||
}
|
||||
} else {
|
||||
appendRuntimeGroupingPolicy(&groupingPolicies, visitedPolicies, newRuntimeGroupingPolicy(subRole, currentRoleID, ""))
|
||||
}
|
||||
} else {
|
||||
groupingPolicies = append(groupingPolicies, []string{subRole, roleId, "", "", "", permissionId})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,6 +304,35 @@ func getGroupingPolicies(permission *Permission) ([][]string, error) {
|
||||
return groupingPolicies, nil
|
||||
}
|
||||
|
||||
func loadRuntimeGroupingPolicies(enforcer *casbin.Enforcer, permission *Permission, permissionIDs ...string) error {
|
||||
if !HasRoleDefinition(enforcer.GetModel()) {
|
||||
return nil
|
||||
}
|
||||
|
||||
targetPermissions, err := getPermissionEnforcerTargets(permission, permissionIDs...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
groupingPolicies, err := getRuntimeGroupingPolicies(targetPermissions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(groupingPolicies) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
enforcer.EnableAutoSave(false)
|
||||
defer enforcer.EnableAutoSave(true)
|
||||
_, err = enforcer.AddGroupingPolicies(groupingPolicies)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func addPolicies(permission *Permission) error {
|
||||
enforcer, err := getPermissionEnforcer(permission)
|
||||
if err != nil {
|
||||
@@ -249,68 +357,27 @@ func removePolicies(permission *Permission) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func addGroupingPolicies(permission *Permission) error {
|
||||
enforcer, err := getPermissionEnforcer(permission)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
groupingPolicies, err := getGroupingPolicies(permission)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(groupingPolicies) > 0 {
|
||||
_, err = enforcer.AddGroupingPolicies(groupingPolicies)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeGroupingPolicies(permission *Permission) error {
|
||||
enforcer, err := getPermissionEnforcer(permission)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
groupingPolicies, err := getGroupingPolicies(permission)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(groupingPolicies) > 0 {
|
||||
_, err = enforcer.RemoveGroupingPolicies(groupingPolicies)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Enforce(permission *Permission, request []string, permissionIds ...string) (bool, error) {
|
||||
func Enforce(permission *Permission, request []interface{}, permissionIds ...string) (bool, error) {
|
||||
enforcer, err := getPermissionEnforcer(permission, permissionIds...)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// type transformation
|
||||
interfaceRequest := util.StringToInterfaceArray(request)
|
||||
// Convert each element: JSON-object strings and maps become anonymous structs
|
||||
// so Casbin can evaluate ABAC rules with dot-notation (e.g. r.sub.DivisionGuid).
|
||||
interfaceRequest := util.InterfaceToEnforceArray(request)
|
||||
|
||||
return enforcer.Enforce(interfaceRequest...)
|
||||
}
|
||||
|
||||
func BatchEnforce(permission *Permission, requests [][]string, permissionIds ...string) ([]bool, error) {
|
||||
func BatchEnforce(permission *Permission, requests [][]interface{}, permissionIds ...string) ([]bool, error) {
|
||||
enforcer, err := getPermissionEnforcer(permission, permissionIds...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// type transformation
|
||||
interfaceRequests := util.StringToInterfaceArray2d(requests)
|
||||
// Convert each element in every row for ABAC support.
|
||||
interfaceRequests := util.InterfaceToEnforceArray2d(requests)
|
||||
|
||||
return enforcer.BatchEnforce(interfaceRequests)
|
||||
}
|
||||
|
||||
314
object/permission_rbac_dedup_test.go
Normal file
314
object/permission_rbac_dedup_test.go
Normal file
@@ -0,0 +1,314 @@
|
||||
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build !skipCi
|
||||
|
||||
package object
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/casdoor/casdoor/util"
|
||||
)
|
||||
|
||||
type permissionRuleRecord struct {
|
||||
Id int64 `xorm:"pk autoincr"`
|
||||
Ptype string `xorm:"varchar(100) index not null default ''"`
|
||||
V0 string `xorm:"varchar(100) index not null default ''"`
|
||||
V1 string `xorm:"varchar(100) index not null default ''"`
|
||||
V2 string `xorm:"varchar(100) index not null default ''"`
|
||||
V3 string `xorm:"varchar(100) index not null default ''"`
|
||||
V4 string `xorm:"varchar(100) index not null default ''"`
|
||||
V5 string `xorm:"varchar(100) index not null default ''"`
|
||||
}
|
||||
|
||||
func (permissionRuleRecord) TableName() string {
|
||||
return "permission_rule"
|
||||
}
|
||||
|
||||
var permissionRbacTestInit sync.Once
|
||||
|
||||
func initPermissionRbacTestDb(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
permissionRbacTestInit.Do(func() {
|
||||
oldCreateDatabase := createDatabase
|
||||
createDatabase = false
|
||||
InitConfig()
|
||||
createDatabase = oldCreateDatabase
|
||||
})
|
||||
}
|
||||
|
||||
func newPermissionRbacTestOwner(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
initPermissionRbacTestDb(t)
|
||||
|
||||
owner := "rbac-dedup-" + util.GenerateId()
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, err := ormer.Engine.Where("v5 like ?", owner+"/%").Delete(&permissionRuleRecord{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to delete permission rules for owner %s: %v", owner, err)
|
||||
}
|
||||
|
||||
_, err = ormer.Engine.Where("owner = ?", owner).Delete(&Permission{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to delete permissions for owner %s: %v", owner, err)
|
||||
}
|
||||
|
||||
_, err = ormer.Engine.Where("owner = ?", owner).Delete(&Role{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to delete roles for owner %s: %v", owner, err)
|
||||
}
|
||||
})
|
||||
|
||||
return owner
|
||||
}
|
||||
|
||||
func newTestPermission(owner string, name string, roleIDs ...string) *Permission {
|
||||
return &Permission{
|
||||
Owner: owner,
|
||||
Name: name,
|
||||
Roles: roleIDs,
|
||||
Resources: []string{"data1"},
|
||||
Actions: []string{"read"},
|
||||
Effect: "Allow",
|
||||
}
|
||||
}
|
||||
|
||||
func getPermissionRulesByPermissionID(t *testing.T, permissionID string) []permissionRuleRecord {
|
||||
t.Helper()
|
||||
|
||||
rules := make([]permissionRuleRecord, 0)
|
||||
err := ormer.Engine.Where("v5 = ?", permissionID).Asc("id").Find(&rules)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query permission rules for %s: %v", permissionID, err)
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
func TestPermissionRuntimeGroupingIgnoresPersistedG(t *testing.T) {
|
||||
owner := newPermissionRbacTestOwner(t)
|
||||
|
||||
role := &Role{
|
||||
Owner: owner,
|
||||
Name: "reader",
|
||||
Users: []string{owner + "/alice"},
|
||||
}
|
||||
affected, err := AddRole(role)
|
||||
if err != nil {
|
||||
t.Fatalf("AddRole() error: %v", err)
|
||||
}
|
||||
if !affected {
|
||||
t.Fatalf("expected AddRole to affect rows")
|
||||
}
|
||||
|
||||
permission := newTestPermission(owner, "perm-reader", role.GetId())
|
||||
affected, err = AddPermission(permission)
|
||||
if err != nil {
|
||||
t.Fatalf("AddPermission() error: %v", err)
|
||||
}
|
||||
if !affected {
|
||||
t.Fatalf("expected AddPermission to affect rows")
|
||||
}
|
||||
|
||||
rules := getPermissionRulesByPermissionID(t, permission.GetId())
|
||||
if len(rules) != 1 || rules[0].Ptype != "p" {
|
||||
t.Fatalf("expected exactly one persisted p rule, got %+v", rules)
|
||||
}
|
||||
|
||||
allowed, err := Enforce(permission, []string{owner + "/alice", "data1", "read"})
|
||||
if err != nil {
|
||||
t.Fatalf("Enforce() for alice error: %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Fatalf("expected alice to be allowed")
|
||||
}
|
||||
|
||||
_, err = ormer.Engine.Insert(&permissionRuleRecord{
|
||||
Ptype: "g",
|
||||
V0: owner + "/mallory",
|
||||
V1: role.GetId(),
|
||||
V5: permission.GetId(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert legacy g rule: %v", err)
|
||||
}
|
||||
|
||||
allowed, err = Enforce(permission, []string{owner + "/mallory", "data1", "read"})
|
||||
if err != nil {
|
||||
t.Fatalf("Enforce() for mallory error: %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Fatalf("expected legacy persisted g rule to be ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRoleUsesRuntimeGroupingAndOnlyRenameRewritesP(t *testing.T) {
|
||||
owner := newPermissionRbacTestOwner(t)
|
||||
|
||||
role := &Role{
|
||||
Owner: owner,
|
||||
Name: "reader-old",
|
||||
Users: []string{owner + "/alice"},
|
||||
}
|
||||
affected, err := AddRole(role)
|
||||
if err != nil {
|
||||
t.Fatalf("AddRole() error: %v", err)
|
||||
}
|
||||
if !affected {
|
||||
t.Fatalf("expected AddRole to affect rows")
|
||||
}
|
||||
|
||||
permission := newTestPermission(owner, "perm-reader", role.GetId())
|
||||
affected, err = AddPermission(permission)
|
||||
if err != nil {
|
||||
t.Fatalf("AddPermission() error: %v", err)
|
||||
}
|
||||
if !affected {
|
||||
t.Fatalf("expected AddPermission to affect rows")
|
||||
}
|
||||
|
||||
rulesBefore := getPermissionRulesByPermissionID(t, permission.GetId())
|
||||
|
||||
updatedRole := *role
|
||||
updatedRole.Users = []string{owner + "/bob"}
|
||||
affected, err = UpdateRole(role.GetId(), &updatedRole)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateRole() for membership change error: %v", err)
|
||||
}
|
||||
if !affected {
|
||||
t.Fatalf("expected UpdateRole membership change to affect rows")
|
||||
}
|
||||
|
||||
rulesAfterMembershipChange := getPermissionRulesByPermissionID(t, permission.GetId())
|
||||
if fmt.Sprintf("%#v", rulesBefore) != fmt.Sprintf("%#v", rulesAfterMembershipChange) {
|
||||
t.Fatalf("expected membership change to keep persisted permission rules unchanged")
|
||||
}
|
||||
|
||||
allowed, err := Enforce(permission, []string{owner + "/alice", "data1", "read"})
|
||||
if err != nil {
|
||||
t.Fatalf("Enforce() for alice after membership change error: %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Fatalf("expected alice to lose permission after membership change")
|
||||
}
|
||||
|
||||
allowed, err = Enforce(permission, []string{owner + "/bob", "data1", "read"})
|
||||
if err != nil {
|
||||
t.Fatalf("Enforce() for bob after membership change error: %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Fatalf("expected bob to gain permission after membership change")
|
||||
}
|
||||
|
||||
renamedRole := updatedRole
|
||||
renamedRole.Name = "reader-new"
|
||||
affected, err = UpdateRole(updatedRole.GetId(), &renamedRole)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateRole() for rename error: %v", err)
|
||||
}
|
||||
if !affected {
|
||||
t.Fatalf("expected UpdateRole rename to affect rows")
|
||||
}
|
||||
|
||||
updatedPermission, err := GetPermission(permission.GetId())
|
||||
if err != nil {
|
||||
t.Fatalf("GetPermission() error: %v", err)
|
||||
}
|
||||
if len(updatedPermission.Roles) != 1 || updatedPermission.Roles[0] != renamedRole.GetId() {
|
||||
t.Fatalf("expected permission role reference to be renamed")
|
||||
}
|
||||
|
||||
rulesAfterRename := getPermissionRulesByPermissionID(t, permission.GetId())
|
||||
if len(rulesAfterRename) != 1 || rulesAfterRename[0].Ptype != "p" || rulesAfterRename[0].V0 != renamedRole.GetId() {
|
||||
t.Fatalf("expected rename to rebuild persisted p rule with new role id, got %+v", rulesAfterRename)
|
||||
}
|
||||
|
||||
allowed, err = Enforce(updatedPermission, []string{owner + "/bob", "data1", "read"})
|
||||
if err != nil {
|
||||
t.Fatalf("Enforce() for bob after rename error: %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Fatalf("expected bob to stay allowed after role rename")
|
||||
}
|
||||
}
|
||||
|
||||
// issue 5346
|
||||
func TestPermissionEnforcerDeduplicatesRuntimeGroupingPoliciesAcross1000Permissions(t *testing.T) {
|
||||
owner := newPermissionRbacTestOwner(t)
|
||||
|
||||
const (
|
||||
permissionCount = 1000
|
||||
userCount = 1000
|
||||
)
|
||||
|
||||
users := make([]string, 0, userCount)
|
||||
for i := range userCount {
|
||||
users = append(users, fmt.Sprintf("%s/user-%04d", owner, i))
|
||||
}
|
||||
|
||||
role := &Role{
|
||||
Owner: owner,
|
||||
Name: "shared-role",
|
||||
Users: users,
|
||||
}
|
||||
affected, err := AddRole(role)
|
||||
if err != nil {
|
||||
t.Fatalf("AddRole() error: %v", err)
|
||||
}
|
||||
if !affected {
|
||||
t.Fatalf("expected AddRole to affect rows")
|
||||
}
|
||||
|
||||
permissions := make([]*Permission, 0, permissionCount)
|
||||
permissionIDs := make([]string, 0, permissionCount)
|
||||
for i := 0; i < permissionCount; i++ {
|
||||
permission := newTestPermission(owner, fmt.Sprintf("perm-%04d", i), role.GetId())
|
||||
permissions = append(permissions, permission)
|
||||
permissionIDs = append(permissionIDs, permission.GetId())
|
||||
}
|
||||
|
||||
affected, err = AddPermissions(permissions)
|
||||
if err != nil {
|
||||
t.Fatalf("AddPermissions() error: %v", err)
|
||||
}
|
||||
if !affected {
|
||||
t.Fatalf("expected AddPermissions to affect rows")
|
||||
}
|
||||
|
||||
enforcer, err := getPermissionEnforcer(permissions[0], permissionIDs...)
|
||||
if err != nil {
|
||||
t.Fatalf("getPermissionEnforcer() error: %v", err)
|
||||
}
|
||||
|
||||
if len(enforcer.GetPolicy()) != permissionCount {
|
||||
t.Fatalf("expected %d p rules in merged enforcer, got %d", permissionCount, len(enforcer.GetPolicy()))
|
||||
}
|
||||
if len(enforcer.GetGroupingPolicy()) != userCount {
|
||||
t.Fatalf("expected deduplicated runtime g rules to stay at %d, got %d", userCount, len(enforcer.GetGroupingPolicy()))
|
||||
}
|
||||
|
||||
allowed, err := enforcer.Enforce(users[userCount-1], "data1", "read")
|
||||
if err != nil {
|
||||
t.Fatalf("Enforce() in 1000x1000 scenario error: %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Fatalf("expected last user to be allowed in 1000x1000 scenario")
|
||||
}
|
||||
}
|
||||
@@ -257,6 +257,26 @@ func (product *Product) getProvider(providerName string) (*Provider, error) {
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func BuyProduct(id string, user *User, providerName, pricingName, planName, host, paymentEnv string, customPrice float64, lang string) (payment *Payment, attachInfo map[string]interface{}, err error) {
|
||||
owner, productName, err := util.GetOwnerAndNameFromIdWithError(id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
order, err := PlaceOrder(owner, []ProductInfo{{
|
||||
Name: productName,
|
||||
Price: customPrice,
|
||||
Quantity: 1,
|
||||
PricingName: pricingName,
|
||||
PlanName: planName,
|
||||
}}, user)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return PayOrder(providerName, host, paymentEnv, order, lang)
|
||||
}
|
||||
|
||||
func ExtendProductWithProviders(product *Product) error {
|
||||
if product == nil {
|
||||
return nil
|
||||
|
||||
@@ -17,6 +17,8 @@ package object
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -24,7 +26,7 @@ import (
|
||||
"github.com/casdoor/casdoor/i18n"
|
||||
"github.com/casdoor/casdoor/idp"
|
||||
"github.com/casdoor/casdoor/idv"
|
||||
"github.com/casdoor/casdoor/logprovider"
|
||||
"github.com/casdoor/casdoor/log"
|
||||
"github.com/casdoor/casdoor/pp"
|
||||
"github.com/casdoor/casdoor/util"
|
||||
"github.com/xorm-io/core"
|
||||
@@ -48,6 +50,7 @@ type Provider struct {
|
||||
CustomAuthUrl string `xorm:"varchar(200)" json:"customAuthUrl"`
|
||||
CustomTokenUrl string `xorm:"varchar(200)" json:"customTokenUrl"`
|
||||
CustomUserInfoUrl string `xorm:"varchar(200)" json:"customUserInfoUrl"`
|
||||
CustomLogoutUrl string `xorm:"varchar(200)" json:"customLogoutUrl"`
|
||||
CustomLogo string `xorm:"varchar(200)" json:"customLogo"`
|
||||
Scopes string `xorm:"varchar(100)" json:"scopes"`
|
||||
UserMapping map[string]string `xorm:"varchar(500)" json:"userMapping"`
|
||||
@@ -81,6 +84,8 @@ type Provider struct {
|
||||
ProviderUrl string `xorm:"varchar(200)" json:"providerUrl"`
|
||||
EnableProxy bool `json:"enableProxy"`
|
||||
EnablePkce bool `json:"enablePkce"`
|
||||
|
||||
State string `xorm:"varchar(100)" json:"state"`
|
||||
}
|
||||
|
||||
func GetMaskedProvider(provider *Provider, isMaskEnabled bool) *Provider {
|
||||
@@ -232,6 +237,10 @@ func UpdateProvider(id string, provider *Provider) (bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := fillOpenClawProviderDefaults(provider); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if name != provider.Name {
|
||||
err := providerChangeTrigger(name, provider.Name)
|
||||
if err != nil {
|
||||
@@ -257,6 +266,10 @@ func UpdateProvider(id string, provider *Provider) (bool, error) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if affected != 0 {
|
||||
refreshLogProviderRuntime(util.GetId(owner, name), provider)
|
||||
}
|
||||
|
||||
return affected != 0, nil
|
||||
}
|
||||
|
||||
@@ -273,11 +286,19 @@ func AddProvider(provider *Provider) (bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := fillOpenClawProviderDefaults(provider); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
affected, err := ormer.Engine.Insert(provider)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if affected != 0 {
|
||||
refreshLogProviderRuntime("", provider)
|
||||
}
|
||||
|
||||
return affected != 0, nil
|
||||
}
|
||||
|
||||
@@ -287,6 +308,10 @@ func DeleteProvider(provider *Provider) (bool, error) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if affected != 0 {
|
||||
stopLogProviderRuntime(provider.GetId())
|
||||
}
|
||||
|
||||
return affected != 0, nil
|
||||
}
|
||||
|
||||
@@ -612,9 +637,95 @@ func GetIdvProviderFromProvider(provider *Provider) idv.IdvProvider {
|
||||
return idv.GetIdvProvider(provider.Type, provider.ClientId, provider.ClientSecret, provider.Endpoint)
|
||||
}
|
||||
|
||||
func GetLogProviderFromProvider(provider *Provider) (logprovider.LogProvider, error) {
|
||||
func GetLogProviderFromProvider(provider *Provider) (log.LogProvider, error) {
|
||||
if provider.Category != "Log" {
|
||||
return nil, fmt.Errorf("provider %s category is not Log", provider.Name)
|
||||
}
|
||||
return logprovider.GetLogProvider(provider.Type, provider.Host, provider.Port, provider.Title)
|
||||
|
||||
if provider.Type == "Casdoor Permission Log" {
|
||||
return log.NewPermissionLogProvider(provider.Name, func(owner, createdTime, providerName, message string) error {
|
||||
name := log.GenerateEntryName()
|
||||
entry := &Entry{
|
||||
Owner: owner,
|
||||
Name: name,
|
||||
CreatedTime: createdTime,
|
||||
UpdatedTime: createdTime,
|
||||
DisplayName: name,
|
||||
Provider: providerName,
|
||||
Application: CasdoorApplication,
|
||||
Message: message,
|
||||
}
|
||||
_, err := AddEntry(entry)
|
||||
return err
|
||||
}), nil
|
||||
}
|
||||
|
||||
if provider.Type == "Agent" && provider.SubType == "OpenClaw" {
|
||||
providerName := provider.Name
|
||||
return log.NewOpenClawProvider(providerName, func(entryType, message, clientIp, userAgent string) error {
|
||||
// Bypass: metrics entries are temporarily not persisted to the database.
|
||||
if entryType == "metrics" {
|
||||
return nil
|
||||
}
|
||||
|
||||
name := log.GenerateEntryName()
|
||||
currentTime := util.GetCurrentTime()
|
||||
entry := &Entry{
|
||||
Owner: CasdoorOrganization,
|
||||
Name: name,
|
||||
CreatedTime: currentTime,
|
||||
UpdatedTime: currentTime,
|
||||
DisplayName: name,
|
||||
Provider: providerName,
|
||||
Type: entryType,
|
||||
ClientIp: clientIp,
|
||||
UserAgent: userAgent,
|
||||
Message: message,
|
||||
}
|
||||
_, err := AddEntry(entry)
|
||||
return err
|
||||
}), nil
|
||||
}
|
||||
|
||||
return log.GetLogProvider(provider.Type, provider.Host, provider.Port, provider.Title)
|
||||
}
|
||||
|
||||
// InvokeCustomProviderLogout iterates through the application's Custom OAuth2 providers
|
||||
// and calls their logout endpoint (if configured) to terminate the upstream session.
|
||||
func InvokeCustomProviderLogout(application *Application, accessToken string) {
|
||||
if application == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, providerItem := range application.Providers {
|
||||
provider := providerItem.Provider
|
||||
if provider == nil || provider.Category != "OAuth" || !strings.HasPrefix(provider.Type, "Custom") {
|
||||
continue
|
||||
}
|
||||
if provider.CustomLogoutUrl == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
go callProviderLogoutUrl(provider, accessToken)
|
||||
}
|
||||
}
|
||||
|
||||
// callProviderLogoutUrl sends a logout/token-revocation request to the provider's logout URL.
|
||||
// Supports RFC 7009 token revocation and Keycloak-style end_session endpoints.
|
||||
func callProviderLogoutUrl(provider *Provider, accessToken string) {
|
||||
params := url.Values{}
|
||||
params.Set("token", accessToken)
|
||||
params.Set("client_id", provider.ClientId)
|
||||
params.Set("client_secret", provider.ClientSecret)
|
||||
|
||||
resp, err := http.PostForm(provider.CustomLogoutUrl, params)
|
||||
if err != nil {
|
||||
util.LogWarning(nil, "InvokeCustomProviderLogout: failed to call logout URL %s for provider %s: %v", provider.CustomLogoutUrl, provider.Name, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
util.LogWarning(nil, "InvokeCustomProviderLogout: logout URL %s returned status %d for provider %s", provider.CustomLogoutUrl, resp.StatusCode, provider.Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,40 +98,23 @@ func UpdateRole(id string, role *Role) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
visited := map[string]struct{}{}
|
||||
|
||||
permissions, err := GetPermissionsByRole(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, permission := range permissions {
|
||||
removeGroupingPolicies(permission)
|
||||
removePolicies(permission)
|
||||
visited[permission.GetId()] = struct{}{}
|
||||
}
|
||||
|
||||
ancestorRoles, err := GetAncestorRoles(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, r := range ancestorRoles {
|
||||
permissions, err := GetPermissionsByRole(r.GetId())
|
||||
renameRole := name != role.Name
|
||||
oldPermissions := []*Permission{}
|
||||
if renameRole {
|
||||
oldPermissions, err = GetPermissionsByRole(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, permission := range permissions {
|
||||
permissionId := permission.GetId()
|
||||
if _, ok := visited[permissionId]; !ok {
|
||||
removeGroupingPolicies(permission)
|
||||
visited[permissionId] = struct{}{}
|
||||
for _, permission := range oldPermissions {
|
||||
err = removePolicies(permission)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if name != role.Name {
|
||||
if renameRole {
|
||||
err := roleChangeTrigger(name, role.Name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -143,47 +126,16 @@ func UpdateRole(id string, role *Role) (bool, error) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
visited = map[string]struct{}{}
|
||||
newRoleID := role.GetId()
|
||||
permissions, err = GetPermissionsByRole(newRoleID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, permission := range permissions {
|
||||
err = addGroupingPolicies(permission)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
err = addPolicies(permission)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
visited[permission.GetId()] = struct{}{}
|
||||
}
|
||||
|
||||
ancestorRoles, err = GetAncestorRoles(newRoleID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, r := range ancestorRoles {
|
||||
permissions, err := GetPermissionsByRole(r.GetId())
|
||||
if renameRole && affected != 0 {
|
||||
permissions, err := GetPermissionsByRole(role.GetId())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, permission := range permissions {
|
||||
permissionId := permission.GetId()
|
||||
if _, ok := visited[permissionId]; !ok {
|
||||
err = addGroupingPolicies(permission)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
visited[permissionId] = struct{}{}
|
||||
err = addPolicies(permission)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/beevik/etree"
|
||||
"github.com/casdoor/casdoor/util"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
saml "github.com/russellhaering/gosaml2"
|
||||
dsig "github.com/russellhaering/goxmldsig"
|
||||
)
|
||||
@@ -50,7 +50,7 @@ func NewSamlResponse(application *Application, user *User, host string, certific
|
||||
samlResponse.CreateAttr("xmlns:saml", "urn:oasis:names:tc:SAML:2.0:assertion")
|
||||
samlResponse.CreateAttr("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance")
|
||||
samlResponse.CreateAttr("xmlns:xs", "http://www.w3.org/2001/XMLSchema")
|
||||
arId := uuid.New()
|
||||
arId := util.GenerateUUID()
|
||||
|
||||
samlResponse.CreateAttr("ID", fmt.Sprintf("_%s", arId))
|
||||
samlResponse.CreateAttr("Version", "2.0")
|
||||
@@ -65,7 +65,7 @@ func NewSamlResponse(application *Application, user *User, host string, certific
|
||||
assertion.CreateAttr("xmlns:saml", "urn:oasis:names:tc:SAML:2.0:assertion")
|
||||
assertion.CreateAttr("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance")
|
||||
assertion.CreateAttr("xmlns:xs", "http://www.w3.org/2001/XMLSchema")
|
||||
assertion.CreateAttr("ID", fmt.Sprintf("_%s", uuid.New()))
|
||||
assertion.CreateAttr("ID", fmt.Sprintf("_%s", util.GenerateUUID()))
|
||||
assertion.CreateAttr("Version", "2.0")
|
||||
assertion.CreateAttr("IssueInstant", now)
|
||||
assertion.CreateElement("saml:Issuer").SetText(host)
|
||||
@@ -100,7 +100,7 @@ func NewSamlResponse(application *Application, user *User, host string, certific
|
||||
}
|
||||
authnStatement := assertion.CreateElement("saml:AuthnStatement")
|
||||
authnStatement.CreateAttr("AuthnInstant", now)
|
||||
authnStatement.CreateAttr("SessionIndex", fmt.Sprintf("_%s", uuid.New()))
|
||||
authnStatement.CreateAttr("SessionIndex", fmt.Sprintf("_%s", util.GenerateUUID()))
|
||||
authnStatement.CreateAttr("SessionNotOnOrAfter", expireTime)
|
||||
authnStatement.CreateElement("saml:AuthnContext").CreateElement("saml:AuthnContextClassRef").SetText("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport")
|
||||
|
||||
@@ -385,7 +385,7 @@ func GetSamlResponse(application *Application, user *User, samlRequest string, h
|
||||
}
|
||||
|
||||
if application.EnableSamlC14n10 {
|
||||
ctx.Canonicalizer = dsig.MakeC14N10ExclusiveCanonicalizerWithPrefixList("")
|
||||
ctx.Canonicalizer = dsig.MakeC14N10ExclusiveCanonicalizerWithPrefixList("xs")
|
||||
}
|
||||
|
||||
// signedXML, err := ctx.SignEnvelopedLimix(samlResponse)
|
||||
@@ -460,7 +460,7 @@ func NewSamlResponse11(application *Application, user *User, requestID string, h
|
||||
samlResponse.CreateAttr("MajorVersion", "1")
|
||||
samlResponse.CreateAttr("MinorVersion", "1")
|
||||
|
||||
responseID := uuid.New()
|
||||
responseID := util.GenerateUUID()
|
||||
samlResponse.CreateAttr("ResponseID", fmt.Sprintf("_%s", responseID))
|
||||
samlResponse.CreateAttr("InResponseTo", requestID)
|
||||
|
||||
@@ -476,7 +476,7 @@ func NewSamlResponse11(application *Application, user *User, requestID string, h
|
||||
assertion.CreateAttr("xmlns:saml", "urn:oasis:names:tc:SAML:1.0:assertion")
|
||||
assertion.CreateAttr("MajorVersion", "1")
|
||||
assertion.CreateAttr("MinorVersion", "1")
|
||||
assertion.CreateAttr("AssertionID", uuid.New().String())
|
||||
assertion.CreateAttr("AssertionID", util.GenerateUUID())
|
||||
assertion.CreateAttr("Issuer", host)
|
||||
assertion.CreateAttr("IssueInstant", now)
|
||||
|
||||
|
||||
@@ -21,13 +21,10 @@ import (
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/casdoor/casdoor/idp"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
|
||||
"github.com/casdoor/casdoor/i18n"
|
||||
"github.com/casdoor/casdoor/idp"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
saml2 "github.com/russellhaering/gosaml2"
|
||||
dsig "github.com/russellhaering/goxmldsig"
|
||||
)
|
||||
@@ -113,7 +110,7 @@ func GenerateSamlRequest(id, relayState, host, lang string) (auth string, method
|
||||
func buildSp(provider *Provider, samlResponse string, host string) (*saml2.SAMLServiceProvider, error) {
|
||||
_, origin := getOriginFromHost(host)
|
||||
|
||||
certStore, err := buildSpCertificateStore(provider, samlResponse)
|
||||
certStore, err := buildSpCertificateStore(provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -152,15 +149,10 @@ func buildSpKeyStore() (dsig.X509KeyStore, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildSpCertificateStore(provider *Provider, samlResponse string) (certStore dsig.MemoryX509CertificateStore, err error) {
|
||||
certEncodedData := ""
|
||||
if samlResponse != "" {
|
||||
certEncodedData, err = getCertificateFromSamlResponse(samlResponse, provider.Type)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else if provider.IdP != "" {
|
||||
certEncodedData = provider.IdP
|
||||
func buildSpCertificateStore(provider *Provider) (certStore dsig.MemoryX509CertificateStore, err error) {
|
||||
certEncodedData := provider.IdP
|
||||
if certEncodedData == "" {
|
||||
return dsig.MemoryX509CertificateStore{}, fmt.Errorf("the IdP certificate of provider: %s is empty", provider.Name)
|
||||
}
|
||||
|
||||
var certData []byte
|
||||
@@ -186,30 +178,3 @@ func buildSpCertificateStore(provider *Provider, samlResponse string) (certStore
|
||||
}
|
||||
return certStore, nil
|
||||
}
|
||||
|
||||
func getCertificateFromSamlResponse(samlResponse string, providerType string) (string, error) {
|
||||
de, err := base64.StdEncoding.DecodeString(samlResponse)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var (
|
||||
expression string
|
||||
deStr = strings.Replace(string(de), "\n", "", -1)
|
||||
tagMap = map[string]string{
|
||||
"Aliyun IDaaS": "ds",
|
||||
"Keycloak": "dsig",
|
||||
}
|
||||
)
|
||||
tag := tagMap[providerType]
|
||||
if tag == "" {
|
||||
// <ds:X509Certificate>...</ds:X509Certificate>
|
||||
// <dsig:X509Certificate>...</dsig:X509Certificate>
|
||||
// <X509Certificate>...</X509Certificate>
|
||||
// ...
|
||||
expression = "<[^>]*:?X509Certificate>([\\s\\S]*?)<[^>]*:?X509Certificate>"
|
||||
} else {
|
||||
expression = fmt.Sprintf("<%s:X509Certificate>([\\s\\S]*?)</%s:X509Certificate>", tag, tag)
|
||||
}
|
||||
res := regexp.MustCompile(expression).FindStringSubmatch(deStr)
|
||||
return res[1], nil
|
||||
}
|
||||
|
||||
@@ -72,16 +72,23 @@ func GetServer(id string) (*Server, error) {
|
||||
|
||||
func UpdateServer(id string, server *Server) (bool, error) {
|
||||
owner, name := util.GetOwnerAndNameFromIdNoCheck(id)
|
||||
if s, err := getServer(owner, name); err != nil {
|
||||
oldServer, err := getServer(owner, name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
} else if s == nil {
|
||||
}
|
||||
if oldServer == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if server.Token == "" {
|
||||
server.Token = oldServer.Token
|
||||
}
|
||||
|
||||
server.UpdatedTime = util.GetCurrentTime()
|
||||
|
||||
syncServerTools(server)
|
||||
_, err := ormer.Engine.ID(core.PK{owner, name}).AllCols().Update(server)
|
||||
_ = syncServerTools(server)
|
||||
|
||||
_, err = ormer.Engine.ID(core.PK{owner, name}).AllCols().Update(server)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -89,25 +96,66 @@ func UpdateServer(id string, server *Server) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func syncServerTools(server *Server) {
|
||||
if server.Tools == nil {
|
||||
server.Tools = []*Tool{}
|
||||
func SyncMcpTool(id string, server *Server, isCleared bool) (bool, error) {
|
||||
owner, name := util.GetOwnerAndNameFromIdNoCheck(id)
|
||||
|
||||
if isCleared {
|
||||
server.Tools = nil
|
||||
server.UpdatedTime = util.GetCurrentTime()
|
||||
_, err := ormer.Engine.ID(core.PK{owner, name}).Cols("tools", "updated_time").Update(server)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
oldServer, err := getServer(owner, name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if oldServer == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if server.Token == "" {
|
||||
server.Token = oldServer.Token
|
||||
}
|
||||
|
||||
server.UpdatedTime = util.GetCurrentTime()
|
||||
|
||||
err = syncServerTools(server)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
_, err = ormer.Engine.ID(core.PK{owner, name}).AllCols().Update(server)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func syncServerTools(server *Server) error {
|
||||
oldTools := server.Tools
|
||||
if oldTools == nil {
|
||||
oldTools = []*Tool{}
|
||||
}
|
||||
|
||||
tools, err := mcp.GetServerTools(server.Owner, server.Name, server.Url, server.Token)
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var newTools []*Tool
|
||||
for _, tool := range tools {
|
||||
oldToolIndex := slices.IndexFunc(server.Tools, func(oldTool *Tool) bool {
|
||||
oldToolIndex := slices.IndexFunc(oldTools, func(oldTool *Tool) bool {
|
||||
return oldTool.Name == tool.Name
|
||||
})
|
||||
|
||||
isAllowed := true
|
||||
if oldToolIndex != -1 {
|
||||
isAllowed = server.Tools[oldToolIndex].IsAllowed
|
||||
isAllowed = oldTools[oldToolIndex].IsAllowed
|
||||
}
|
||||
|
||||
newTool := Tool{
|
||||
@@ -118,6 +166,7 @@ func syncServerTools(server *Server) {
|
||||
}
|
||||
|
||||
server.Tools = newTools
|
||||
return nil
|
||||
}
|
||||
|
||||
func AddServer(server *Server) (bool, error) {
|
||||
|
||||
@@ -16,9 +16,7 @@ package object
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/casdoor/casdoor/util"
|
||||
"github.com/xorm-io/core"
|
||||
@@ -234,8 +232,7 @@ func (site *Site) GetChallengeMap() map[string]string {
|
||||
|
||||
func (site *Site) GetHost() string {
|
||||
if len(site.Hosts) != 0 {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
return site.Hosts[rand.Intn(len(site.Hosts))]
|
||||
return site.Hosts[util.RandomIntn(len(site.Hosts))]
|
||||
}
|
||||
|
||||
if site.Host != "" {
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/casdoor/casdoor/util"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
@@ -122,6 +123,10 @@ func (p *DatabaseSyncerProvider) AddUser(user *OriginalUser) (bool, error) {
|
||||
// UpdateUser updates an existing user in the database
|
||||
func (p *DatabaseSyncerProvider) UpdateUser(user *OriginalUser) (bool, error) {
|
||||
key := p.Syncer.getTargetTablePrimaryKey()
|
||||
if !util.FilterSQLIdentifier(key) {
|
||||
return false, fmt.Errorf("object.UpdateUser: invalid primary key column name: %s", key)
|
||||
}
|
||||
|
||||
m := p.Syncer.getMapFromOriginalUser(user)
|
||||
pkValue := m[key]
|
||||
delete(m, key)
|
||||
|
||||
@@ -43,7 +43,8 @@ type Token struct {
|
||||
CodeChallenge string `xorm:"varchar(100)" json:"codeChallenge"`
|
||||
CodeIsUsed bool `json:"codeIsUsed"`
|
||||
CodeExpireIn int64 `json:"codeExpireIn"`
|
||||
Resource string `xorm:"varchar(255)" json:"resource"` // RFC 8707 Resource Indicator
|
||||
Resource string `xorm:"varchar(255)" json:"resource"` // RFC 8707 Resource Indicator
|
||||
DPoPJkt string `xorm:"varchar(255) 'dpop_jkt'" json:"dPoPJkt"` // RFC 9449 DPoP JWK thumbprint binding
|
||||
}
|
||||
|
||||
func GetTokenCount(owner, organization, field, value string) (int64, error) {
|
||||
@@ -235,3 +236,9 @@ func ExpireTokenByUser(owner, username string) (bool, error) {
|
||||
|
||||
return affected != 0, nil
|
||||
}
|
||||
|
||||
// updateTokenDPoP updates the token_type and dpop_jkt columns for DPoP binding (RFC 9449).
|
||||
func updateTokenDPoP(token *Token) error {
|
||||
_, err := ormer.Engine.ID(core.PK{token.Owner, token.Name}).Cols("token_type", "dpop_jkt").Update(token)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
"encoding/pem"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -273,7 +273,7 @@ func GenerateCasToken(userId string, service string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
st := fmt.Sprintf("ST-%d", rand.Int())
|
||||
st := fmt.Sprintf("ST-%d", util.RandomIntn(math.MaxInt))
|
||||
stToServiceResponse.Store(st, &CasAuthenticationSuccessWrapper{
|
||||
AuthenticationSuccess: &authenticationSuccess,
|
||||
Service: service,
|
||||
|
||||
157
object/token_dpop.go
Normal file
157
object/token_dpop.go
Normal file
@@ -0,0 +1,157 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package object
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const dpopMaxAgeSeconds = 300
|
||||
|
||||
// DPoPProofClaims represents the payload claims of a DPoP proof JWT (RFC 9449).
|
||||
type DPoPProofClaims struct {
|
||||
Jti string `json:"jti"`
|
||||
Htm string `json:"htm"`
|
||||
Htu string `json:"htu"`
|
||||
Ath string `json:"ath,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// ValidateDPoPProof validates a DPoP proof JWT as specified in RFC 9449.
|
||||
//
|
||||
// - proofToken: the compact-serialized DPoP proof JWT from the DPoP HTTP header
|
||||
// - method: the HTTP request method (e.g., "POST", "GET")
|
||||
// - htu: the HTTP request URL without query string or fragment
|
||||
// - accessToken: the access token string; empty at the token endpoint,
|
||||
// non-empty at protected resource endpoints (enables ath claim validation)
|
||||
//
|
||||
// On success it returns the base64url-encoded SHA-256 JWK thumbprint (jkt) of
|
||||
// the DPoP public key embedded in the proof header.
|
||||
func ValidateDPoPProof(proofToken, method, htu, accessToken string) (string, error) {
|
||||
parts := strings.Split(proofToken, ".")
|
||||
if len(parts) != 3 {
|
||||
return "", fmt.Errorf("invalid DPoP proof JWT format")
|
||||
}
|
||||
|
||||
// Decode and inspect the JOSE header before signature verification.
|
||||
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode DPoP proof header: %w", err)
|
||||
}
|
||||
|
||||
var header struct {
|
||||
Typ string `json:"typ"`
|
||||
Alg string `json:"alg"`
|
||||
JWK json.RawMessage `json:"jwk"`
|
||||
}
|
||||
if err = json.Unmarshal(headerBytes, &header); err != nil {
|
||||
return "", fmt.Errorf("failed to parse DPoP proof header: %w", err)
|
||||
}
|
||||
|
||||
// typ MUST be exactly "dpop+jwt" (RFC 9449 §4.2).
|
||||
if header.Typ != "dpop+jwt" {
|
||||
return "", fmt.Errorf("DPoP proof typ must be \"dpop+jwt\", got %q", header.Typ)
|
||||
}
|
||||
|
||||
// alg MUST identify an asymmetric digital signature algorithm;
|
||||
// symmetric algorithms (HS*) are explicitly forbidden (RFC 9449 §4.2).
|
||||
if header.Alg == "" || strings.HasPrefix(header.Alg, "HS") {
|
||||
return "", fmt.Errorf("DPoP proof must use an asymmetric algorithm, got %q", header.Alg)
|
||||
}
|
||||
|
||||
// jwk MUST be present (RFC 9449 §4.2).
|
||||
if len(header.JWK) == 0 {
|
||||
return "", fmt.Errorf("DPoP proof header must contain the jwk claim")
|
||||
}
|
||||
|
||||
var jwkKey jose.JSONWebKey
|
||||
if err = jwkKey.UnmarshalJSON(header.JWK); err != nil {
|
||||
return "", fmt.Errorf("failed to parse DPoP JWK: %w", err)
|
||||
}
|
||||
|
||||
// Compute the JWK SHA-256 thumbprint per RFC 7638.
|
||||
thumbprintBytes, err := jwkKey.Thumbprint(crypto.SHA256)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to compute DPoP JWK thumbprint: %w", err)
|
||||
}
|
||||
jkt := base64.RawURLEncoding.EncodeToString(thumbprintBytes)
|
||||
|
||||
// Verify the proof's signature using the public key embedded in the header.
|
||||
// WithoutClaimsValidation is used so that we can perform all claim checks
|
||||
// ourselves (jwt library exp/nbf validation is not appropriate here).
|
||||
t, err := jwt.ParseWithClaims(proofToken, &DPoPProofClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return jwkKey.Key, nil
|
||||
}, jwt.WithoutClaimsValidation())
|
||||
if err != nil || !t.Valid {
|
||||
return "", fmt.Errorf("DPoP proof signature verification failed: %w", err)
|
||||
}
|
||||
|
||||
claims, ok := t.Claims.(*DPoPProofClaims)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("failed to parse DPoP proof claims")
|
||||
}
|
||||
|
||||
// htm MUST match the HTTP request method (RFC 9449 §4.2).
|
||||
if !strings.EqualFold(claims.Htm, method) {
|
||||
return "", fmt.Errorf("DPoP proof htm %q does not match request method %q", claims.Htm, method)
|
||||
}
|
||||
|
||||
// htu MUST match the request URL without query/fragment (RFC 9449 §4.2).
|
||||
if !strings.EqualFold(claims.Htu, htu) {
|
||||
return "", fmt.Errorf("DPoP proof htu %q does not match request URL %q", claims.Htu, htu)
|
||||
}
|
||||
|
||||
// iat MUST be present and within the acceptable time window (RFC 9449 §4.2).
|
||||
if claims.IssuedAt == nil {
|
||||
return "", fmt.Errorf("DPoP proof missing iat claim")
|
||||
}
|
||||
age := time.Since(claims.IssuedAt.Time).Abs()
|
||||
if age > time.Duration(dpopMaxAgeSeconds)*time.Second {
|
||||
return "", fmt.Errorf("DPoP proof iat is outside the acceptable time window (%d seconds)", dpopMaxAgeSeconds)
|
||||
}
|
||||
|
||||
// jti MUST be present to support replay detection (RFC 9449 §4.2).
|
||||
if claims.Jti == "" {
|
||||
return "", fmt.Errorf("DPoP proof missing jti claim")
|
||||
}
|
||||
|
||||
// ath MUST be validated at protected resource endpoints (RFC 9449 §4.2).
|
||||
// It is the base64url-encoded SHA-256 hash of the ASCII access token string.
|
||||
if accessToken != "" {
|
||||
hash := sha256.Sum256([]byte(accessToken))
|
||||
expectedAth := base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
if claims.Ath != expectedAth {
|
||||
return "", fmt.Errorf("DPoP proof ath claim does not match access token hash")
|
||||
}
|
||||
}
|
||||
|
||||
return jkt, nil
|
||||
}
|
||||
|
||||
// GetDPoPHtu constructs the full DPoP htu URL for a given host and path.
|
||||
// It uses the same origin-detection logic as the rest of the backend.
|
||||
func GetDPoPHtu(host, path string) string {
|
||||
_, originBackend := getOriginFromHost(host)
|
||||
return originBackend + path
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
800
object/token_oauth_util.go
Normal file
800
object/token_oauth_util.go
Normal file
@@ -0,0 +1,800 @@
|
||||
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package object
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/casdoor/casdoor/i18n"
|
||||
"github.com/casdoor/casdoor/util"
|
||||
"github.com/xorm-io/core"
|
||||
)
|
||||
|
||||
const (
|
||||
hourSeconds = int(time.Hour / time.Second)
|
||||
InvalidRequest = "invalid_request"
|
||||
InvalidClient = "invalid_client"
|
||||
InvalidGrant = "invalid_grant"
|
||||
UnauthorizedClient = "unauthorized_client"
|
||||
UnsupportedGrantType = "unsupported_grant_type"
|
||||
InvalidScope = "invalid_scope"
|
||||
EndpointError = "endpoint_error"
|
||||
)
|
||||
|
||||
var DeviceAuthMap = sync.Map{}
|
||||
|
||||
type Code struct {
|
||||
Message string `xorm:"varchar(100)" json:"message"`
|
||||
Code string `xorm:"varchar(100)" json:"code"`
|
||||
}
|
||||
|
||||
type TokenWrapper struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
IdToken string `json:"id_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
type TokenError struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
}
|
||||
|
||||
// DPoPConfirmation holds the DPoP key confirmation claim (RFC 9449).
|
||||
type DPoPConfirmation struct {
|
||||
JKT string `json:"jkt"`
|
||||
}
|
||||
|
||||
type IntrospectionResponse struct {
|
||||
Active bool `json:"active"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
ClientId string `json:"client_id,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
TokenType string `json:"token_type,omitempty"`
|
||||
Exp int64 `json:"exp,omitempty"`
|
||||
Iat int64 `json:"iat,omitempty"`
|
||||
Nbf int64 `json:"nbf,omitempty"`
|
||||
Sub string `json:"sub,omitempty"`
|
||||
Aud []string `json:"aud,omitempty"`
|
||||
Iss string `json:"iss,omitempty"`
|
||||
Jti string `json:"jti,omitempty"`
|
||||
Cnf *DPoPConfirmation `json:"cnf,omitempty"` // RFC 9449 DPoP key binding
|
||||
}
|
||||
|
||||
type DeviceAuthCache struct {
|
||||
UserSignIn bool
|
||||
UserName string
|
||||
ApplicationId string
|
||||
Scope string
|
||||
RequestAt time.Time
|
||||
}
|
||||
|
||||
type DeviceAuthResponse struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationUri string `json:"verification_uri"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
|
||||
// validateResourceURI validates that the resource parameter is a valid absolute URI
|
||||
// according to RFC 8707 Section 2
|
||||
func validateResourceURI(resource string) error {
|
||||
if resource == "" {
|
||||
return nil // empty resource is allowed (backward compatibility)
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(resource)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resource must be a valid URI")
|
||||
}
|
||||
|
||||
// RFC 8707: The resource parameter must be an absolute URI
|
||||
if !parsedURL.IsAbs() {
|
||||
return fmt.Errorf("resource must be an absolute URI")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// pkceChallenge returns the base64-URL-encoded SHA256 hash of verifier, per RFC 7636
|
||||
func pkceChallenge(verifier string) string {
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// IsGrantTypeValid checks if grantType is allowed in the current application.
|
||||
// authorization_code is allowed by default.
|
||||
func IsGrantTypeValid(method string, grantTypes []string) bool {
|
||||
if method == "authorization_code" {
|
||||
return true
|
||||
}
|
||||
for _, m := range grantTypes {
|
||||
if m == method {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isRegexScope returns true if the scope string contains regex metacharacters.
|
||||
func isRegexScope(scope string) bool {
|
||||
return strings.ContainsAny(scope, ".*+?^${}()|[]\\")
|
||||
}
|
||||
|
||||
// IsScopeValidAndExpand expands any regex patterns in the space-separated scope string
|
||||
// against the application's configured scopes. Literal scopes are kept as-is
|
||||
// after verifying they exist in the allowed list. Regex scopes are matched
|
||||
// against every allowed scope name; all matches replace the pattern.
|
||||
// If the application has no defined scopes, the original scope string is
|
||||
// returned unchanged (backward-compatible behaviour).
|
||||
// Returns the expanded scope string and whether the scope is valid.
|
||||
func IsScopeValidAndExpand(scope string, application *Application) (string, bool) {
|
||||
if len(application.Scopes) == 0 || scope == "" {
|
||||
return scope, true
|
||||
}
|
||||
|
||||
allowedNames := make([]string, 0, len(application.Scopes))
|
||||
allowedSet := make(map[string]bool, len(application.Scopes))
|
||||
for _, s := range application.Scopes {
|
||||
allowedNames = append(allowedNames, s.Name)
|
||||
allowedSet[s.Name] = true
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var expanded []string
|
||||
|
||||
for _, s := range strings.Fields(scope) {
|
||||
// Try exact match first.
|
||||
if allowedSet[s] {
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
expanded = append(expanded, s)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Not an exact match – if it looks like a regex, try pattern matching.
|
||||
if !isRegexScope(s) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Treat as regex pattern – must be a valid regex and match ≥ 1 scope.
|
||||
re, err := regexp.Compile("^" + s + "$")
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
matched := false
|
||||
for _, name := range allowedNames {
|
||||
if re.MatchString(name) {
|
||||
matched = true
|
||||
if !seen[name] {
|
||||
seen[name] = true
|
||||
expanded = append(expanded, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(expanded, " "), true
|
||||
}
|
||||
|
||||
// IsScopeValid checks whether all space-separated scopes in the scope string
|
||||
// are defined in the application's Scopes list (including regex expansion).
|
||||
// If the application has no defined scopes, every scope is considered valid
|
||||
// (backward-compatible behaviour).
|
||||
func IsScopeValid(scope string, application *Application) bool {
|
||||
_, ok := IsScopeValidAndExpand(scope, application)
|
||||
return ok
|
||||
}
|
||||
|
||||
func ExpireTokenByAccessToken(accessToken string) (bool, *Application, *Token, error) {
|
||||
token, err := GetTokenByAccessToken(accessToken)
|
||||
if err != nil {
|
||||
return false, nil, nil, err
|
||||
}
|
||||
if token == nil {
|
||||
return false, nil, nil, nil
|
||||
}
|
||||
|
||||
token.ExpiresIn = 0
|
||||
affected, err := ormer.Engine.ID(core.PK{token.Owner, token.Name}).Cols("expires_in").Update(token)
|
||||
if err != nil {
|
||||
return false, nil, nil, err
|
||||
}
|
||||
|
||||
application, err := getApplication(token.Owner, token.Application)
|
||||
if err != nil {
|
||||
return false, nil, nil, err
|
||||
}
|
||||
|
||||
return affected != 0, application, token, nil
|
||||
}
|
||||
|
||||
func CheckOAuthLogin(clientId string, responseType string, redirectUri string, scope string, state string, lang string) (string, *Application, error) {
|
||||
if responseType != "code" && responseType != "token" && responseType != "id_token" {
|
||||
return fmt.Sprintf(i18n.Translate(lang, "token:Grant_type: %s is not supported in this application"), responseType), nil, nil
|
||||
}
|
||||
|
||||
application, err := GetApplicationByClientId(clientId)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
if application == nil {
|
||||
return i18n.Translate(lang, "token:Invalid client_id"), nil, nil
|
||||
}
|
||||
|
||||
if !application.IsRedirectUriValid(redirectUri) {
|
||||
return fmt.Sprintf(i18n.Translate(lang, "token:Redirect URI: %s doesn't exist in the allowed Redirect URI list"), redirectUri), application, nil
|
||||
}
|
||||
|
||||
if !IsScopeValid(scope, application) {
|
||||
return i18n.Translate(lang, "token:Invalid scope"), application, nil
|
||||
}
|
||||
|
||||
// Mask application for /api/get-app-login
|
||||
application.ClientSecret = ""
|
||||
return "", application, nil
|
||||
}
|
||||
|
||||
func GetOAuthCode(userId string, clientId string, provider string, signinMethod string, responseType string, redirectUri string, scope string, state string, nonce string, challenge string, resource string, host string, lang string) (*Code, error) {
|
||||
user, err := GetUser(userId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return &Code{
|
||||
Message: fmt.Sprintf("general:The user: %s doesn't exist", userId),
|
||||
Code: "",
|
||||
}, nil
|
||||
}
|
||||
if user.IsForbidden {
|
||||
return &Code{
|
||||
Message: "error: the user is forbidden to sign in, please contact the administrator",
|
||||
Code: "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
msg, application, err := CheckOAuthLogin(clientId, responseType, redirectUri, scope, state, lang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if msg != "" {
|
||||
return &Code{
|
||||
Message: msg,
|
||||
Code: "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Expand regex/wildcard scopes to concrete scope names.
|
||||
expandedScope, ok := IsScopeValidAndExpand(scope, application)
|
||||
if !ok {
|
||||
return &Code{
|
||||
Message: i18n.Translate(lang, "token:Invalid scope"),
|
||||
Code: "",
|
||||
}, nil
|
||||
}
|
||||
scope = expandedScope
|
||||
|
||||
// Validate resource parameter (RFC 8707)
|
||||
if err := validateResourceURI(resource); err != nil {
|
||||
return &Code{
|
||||
Message: err.Error(),
|
||||
Code: "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
err = ExtendUserWithRolesAndPermissions(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, provider, signinMethod, nonce, scope, resource, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if challenge == "null" {
|
||||
challenge = ""
|
||||
}
|
||||
|
||||
token := &Token{
|
||||
Owner: application.Owner,
|
||||
Name: tokenName,
|
||||
CreatedTime: util.GetCurrentTime(),
|
||||
Application: application.Name,
|
||||
Organization: user.Owner,
|
||||
User: user.Name,
|
||||
Code: util.GenerateClientId(),
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: int(application.ExpireInHours * float64(hourSeconds)),
|
||||
Scope: scope,
|
||||
TokenType: "Bearer",
|
||||
CodeChallenge: challenge,
|
||||
CodeIsUsed: false,
|
||||
CodeExpireIn: time.Now().Add(time.Minute * 5).Unix(),
|
||||
Resource: resource,
|
||||
}
|
||||
_, err = AddToken(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Code{
|
||||
Message: "",
|
||||
Code: token.Code,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func RefreshToken(application *Application, grantType string, refreshToken string, scope string, clientId string, clientSecret string, host string, dpopProof string) (interface{}, error) {
|
||||
if grantType != "refresh_token" {
|
||||
return &TokenError{
|
||||
Error: UnsupportedGrantType,
|
||||
ErrorDescription: "grant_type should be refresh_token",
|
||||
}, nil
|
||||
}
|
||||
|
||||
var err error
|
||||
if application == nil {
|
||||
application, err = GetApplicationByClientId(clientId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if application == nil {
|
||||
return &TokenError{
|
||||
Error: InvalidClient,
|
||||
ErrorDescription: "client_id is invalid",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if clientSecret != "" && application.ClientSecret != clientSecret {
|
||||
return &TokenError{
|
||||
Error: InvalidClient,
|
||||
ErrorDescription: "client_secret is invalid",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// check whether the refresh token is valid, and has not expired.
|
||||
token, err := GetTokenByRefreshToken(refreshToken)
|
||||
if err != nil || token == nil {
|
||||
return &TokenError{
|
||||
Error: InvalidGrant,
|
||||
ErrorDescription: "refresh token is invalid or revoked",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// check if the token has been invalidated (e.g., by SSO logout)
|
||||
if token.ExpiresIn <= 0 {
|
||||
return &TokenError{
|
||||
Error: InvalidGrant,
|
||||
ErrorDescription: "refresh token is expired",
|
||||
}, nil
|
||||
}
|
||||
|
||||
cert, err := getCertByApplication(application)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cert == nil {
|
||||
return &TokenError{
|
||||
Error: InvalidGrant,
|
||||
ErrorDescription: fmt.Sprintf("cert: %s cannot be found", application.Cert),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var oldTokenScope string
|
||||
if application.TokenFormat == "JWT-Standard" {
|
||||
oldToken, err := ParseStandardJwtToken(refreshToken, cert)
|
||||
if err != nil {
|
||||
return &TokenError{
|
||||
Error: InvalidGrant,
|
||||
ErrorDescription: fmt.Sprintf("parse refresh token error: %s", err.Error()),
|
||||
}, nil
|
||||
}
|
||||
oldTokenScope = oldToken.Scope
|
||||
} else {
|
||||
oldToken, err := ParseJwtToken(refreshToken, cert)
|
||||
if err != nil {
|
||||
return &TokenError{
|
||||
Error: InvalidGrant,
|
||||
ErrorDescription: fmt.Sprintf("parse refresh token error: %s", err.Error()),
|
||||
}, nil
|
||||
}
|
||||
oldTokenScope = oldToken.Scope
|
||||
}
|
||||
|
||||
if scope == "" {
|
||||
scope = oldTokenScope
|
||||
}
|
||||
|
||||
// generate a new token
|
||||
user, err := getUser(application.Organization, token.User)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return "", fmt.Errorf("The user: %s doesn't exist", util.GetId(application.Organization, token.User))
|
||||
}
|
||||
|
||||
if user.IsForbidden {
|
||||
return &TokenError{
|
||||
Error: InvalidGrant,
|
||||
ErrorDescription: "the user is forbidden to sign in, please contact the administrator",
|
||||
}, nil
|
||||
}
|
||||
|
||||
err = ExtendUserWithRolesAndPermissions(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newAccessToken, newRefreshToken, tokenName, err := generateJwtToken(application, user, "", "", "", scope, "", host)
|
||||
if err != nil {
|
||||
return &TokenError{
|
||||
Error: EndpointError,
|
||||
ErrorDescription: fmt.Sprintf("generate jwt token error: %s", err.Error()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
newToken := &Token{
|
||||
Owner: application.Owner,
|
||||
Name: tokenName,
|
||||
CreatedTime: util.GetCurrentTime(),
|
||||
Application: application.Name,
|
||||
Organization: user.Owner,
|
||||
User: user.Name,
|
||||
Code: util.GenerateClientId(),
|
||||
AccessToken: newAccessToken,
|
||||
RefreshToken: newRefreshToken,
|
||||
ExpiresIn: int(application.ExpireInHours * float64(hourSeconds)),
|
||||
Scope: scope,
|
||||
TokenType: "Bearer",
|
||||
}
|
||||
_, err = AddToken(newToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Apply DPoP binding to the refreshed token if a DPoP proof was provided.
|
||||
if dpopProof != "" {
|
||||
dpopHtu := GetDPoPHtu(host, "/api/login/oauth/access_token")
|
||||
jkt, err := ValidateDPoPProof(dpopProof, "POST", dpopHtu, "")
|
||||
if err != nil {
|
||||
return &TokenError{
|
||||
Error: "invalid_dpop_proof",
|
||||
ErrorDescription: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
newToken.TokenType = "DPoP"
|
||||
newToken.DPoPJkt = jkt
|
||||
if err = updateTokenDPoP(newToken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
_, err = DeleteToken(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tokenWrapper := &TokenWrapper{
|
||||
AccessToken: newToken.AccessToken,
|
||||
IdToken: newToken.AccessToken,
|
||||
RefreshToken: newToken.RefreshToken,
|
||||
TokenType: newToken.TokenType,
|
||||
ExpiresIn: newToken.ExpiresIn,
|
||||
Scope: newToken.Scope,
|
||||
}
|
||||
return tokenWrapper, nil
|
||||
}
|
||||
|
||||
func ValidateJwtAssertion(clientAssertion string, application *Application, host string) (bool, *Claims, error) {
|
||||
_, originBackend := getOriginFromHost(host)
|
||||
|
||||
clientCert, err := getCert(application.Owner, application.ClientCert)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
if clientCert == nil {
|
||||
return false, nil, fmt.Errorf("client certificate is not configured for application: [%s]", application.GetId())
|
||||
}
|
||||
|
||||
claims, err := ParseJwtToken(clientAssertion, clientCert)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
|
||||
if !slices.Contains(application.RedirectUris, claims.Issuer) {
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
if !slices.Contains(claims.Audience, fmt.Sprintf("%s/api/login/oauth/access_token", originBackend)) {
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
return true, claims, nil
|
||||
}
|
||||
|
||||
func ValidateClientAssertion(clientAssertion string, host string) (bool, *Application, error) {
|
||||
token, err := ParseJwtTokenWithoutValidation(clientAssertion)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
|
||||
clientId, err := token.Claims.GetSubject()
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
|
||||
application, err := GetApplicationByClientId(clientId)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
if application == nil {
|
||||
return false, nil, fmt.Errorf("application not found for client: [%s]", clientId)
|
||||
}
|
||||
|
||||
ok, _, err := ValidateJwtAssertion(clientAssertion, application, host)
|
||||
if err != nil {
|
||||
return false, application, err
|
||||
}
|
||||
if !ok {
|
||||
return false, application, nil
|
||||
}
|
||||
|
||||
return true, application, nil
|
||||
}
|
||||
|
||||
// mintImplicitToken mints a token for an already-authenticated user.
|
||||
// Callers must verify user identity before calling this function.
|
||||
func mintImplicitToken(application *Application, username string, scope string, nonce string, host string) (*Token, *TokenError, error) {
|
||||
expandedScope, ok := IsScopeValidAndExpand(scope, application)
|
||||
if !ok {
|
||||
return nil, &TokenError{
|
||||
Error: InvalidScope,
|
||||
ErrorDescription: "the requested scope is invalid or not defined in the application",
|
||||
}, nil
|
||||
}
|
||||
scope = expandedScope
|
||||
|
||||
user, err := GetUserByFields(application.Organization, username)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, &TokenError{
|
||||
Error: InvalidGrant,
|
||||
ErrorDescription: "the user does not exist",
|
||||
}, nil
|
||||
}
|
||||
if user.IsForbidden {
|
||||
return nil, &TokenError{
|
||||
Error: InvalidGrant,
|
||||
ErrorDescription: "the user is forbidden to sign in, please contact the administrator",
|
||||
}, nil
|
||||
}
|
||||
|
||||
token, err := GetTokenByUser(application, user, scope, nonce, host)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return token, nil, nil
|
||||
}
|
||||
|
||||
// parseAndValidateSubjectToken validates a subject_token for RFC 8693 token exchange.
|
||||
// It uses the ISSUING application's certificate (not the requesting client's) and
|
||||
// enforces audience binding to prevent cross-client token reuse.
|
||||
func parseAndValidateSubjectToken(subjectToken string, requestingClientId string) (owner, name, scope string, tokenErr *TokenError, err error) {
|
||||
unverifiedToken, err := ParseJwtTokenWithoutValidation(subjectToken)
|
||||
if err != nil {
|
||||
return "", "", "", &TokenError{Error: InvalidGrant, ErrorDescription: fmt.Sprintf("invalid subject_token: %s", err.Error())}, nil
|
||||
}
|
||||
|
||||
unverifiedClaims, ok := unverifiedToken.Claims.(*Claims)
|
||||
if !ok || unverifiedClaims.Azp == "" {
|
||||
return "", "", "", &TokenError{Error: InvalidGrant, ErrorDescription: "subject_token is missing the azp claim"}, nil
|
||||
}
|
||||
|
||||
issuingApp, err := GetApplicationByClientId(unverifiedClaims.Azp)
|
||||
if err != nil {
|
||||
return "", "", "", nil, err
|
||||
}
|
||||
if issuingApp == nil {
|
||||
return "", "", "", &TokenError{Error: InvalidGrant, ErrorDescription: fmt.Sprintf("subject_token issuing application not found: %s", unverifiedClaims.Azp)}, nil
|
||||
}
|
||||
|
||||
cert, err := getCertByApplication(issuingApp)
|
||||
if err != nil {
|
||||
return "", "", "", nil, err
|
||||
}
|
||||
if cert == nil {
|
||||
return "", "", "", &TokenError{Error: EndpointError, ErrorDescription: fmt.Sprintf("cert for issuing application %s cannot be found", unverifiedClaims.Azp)}, nil
|
||||
}
|
||||
|
||||
if issuingApp.TokenFormat == "JWT-Standard" {
|
||||
standardClaims, err := ParseStandardJwtToken(subjectToken, cert)
|
||||
if err != nil {
|
||||
return "", "", "", &TokenError{Error: InvalidGrant, ErrorDescription: fmt.Sprintf("invalid subject_token: %s", err.Error())}, nil
|
||||
}
|
||||
return standardClaims.Owner, standardClaims.Name, standardClaims.Scope, nil, nil
|
||||
}
|
||||
|
||||
claims, err := ParseJwtToken(subjectToken, cert)
|
||||
if err != nil {
|
||||
return "", "", "", &TokenError{Error: InvalidGrant, ErrorDescription: fmt.Sprintf("invalid subject_token: %s", err.Error())}, nil
|
||||
}
|
||||
|
||||
// Audience binding: requesting client must be the issuer itself or appear in token's aud.
|
||||
// Prevents an attacker from exchanging App A's token to obtain an App B token (RFC 8693 §2.1).
|
||||
if issuingApp.ClientId != requestingClientId {
|
||||
audienceMatched := false
|
||||
for _, aud := range claims.Audience {
|
||||
if aud == requestingClientId {
|
||||
audienceMatched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !audienceMatched {
|
||||
return "", "", "", &TokenError{Error: InvalidGrant, ErrorDescription: fmt.Sprintf("subject_token audience does not include the requesting client '%s'", requestingClientId)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return claims.Owner, claims.Name, claims.Scope, nil, nil
|
||||
}
|
||||
|
||||
// createGuestUserToken creates a new guest user and returns a token for them.
|
||||
func createGuestUserToken(application *Application, clientSecret string, verifier string) (*Token, *TokenError, error) {
|
||||
if clientSecret != "" && application.ClientSecret != clientSecret {
|
||||
return nil, &TokenError{
|
||||
Error: InvalidClient,
|
||||
ErrorDescription: "client_secret is invalid",
|
||||
}, nil
|
||||
}
|
||||
|
||||
guestUsername := generateGuestUsername()
|
||||
guestPassword := util.GenerateId()
|
||||
|
||||
organization, err := GetOrganization(util.GetId("admin", application.Organization))
|
||||
if err != nil {
|
||||
return nil, &TokenError{
|
||||
Error: EndpointError,
|
||||
ErrorDescription: fmt.Sprintf("failed to get organization: %s", err.Error()),
|
||||
}, nil
|
||||
}
|
||||
if organization == nil {
|
||||
return nil, &TokenError{
|
||||
Error: InvalidClient,
|
||||
ErrorDescription: fmt.Sprintf("organization: %s does not exist", application.Organization),
|
||||
}, nil
|
||||
}
|
||||
|
||||
initScore, err := organization.GetInitScore()
|
||||
if err != nil {
|
||||
return nil, &TokenError{
|
||||
Error: EndpointError,
|
||||
ErrorDescription: fmt.Sprintf("failed to get init score: %s", err.Error()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
newUserId, idErr := GenerateIdForNewUser(application)
|
||||
if idErr != nil {
|
||||
newUserId = util.GenerateId()
|
||||
}
|
||||
|
||||
guestUser := &User{
|
||||
Owner: application.Organization,
|
||||
Name: guestUsername,
|
||||
CreatedTime: util.GetCurrentTime(),
|
||||
Id: newUserId,
|
||||
Type: "normal-user",
|
||||
Password: guestPassword,
|
||||
Tag: "guest-user",
|
||||
DisplayName: fmt.Sprintf("Guest_%s", guestUsername[:8]),
|
||||
Avatar: "",
|
||||
Address: []string{},
|
||||
Email: "",
|
||||
Phone: "",
|
||||
Score: initScore,
|
||||
IsAdmin: false,
|
||||
IsForbidden: false,
|
||||
IsDeleted: false,
|
||||
SignupApplication: application.Name,
|
||||
Properties: map[string]string{},
|
||||
RegisterType: "Guest Signup",
|
||||
RegisterSource: fmt.Sprintf("%s/%s", application.Organization, application.Name),
|
||||
}
|
||||
|
||||
affected, err := AddUser(guestUser, "en")
|
||||
if err != nil {
|
||||
return nil, &TokenError{
|
||||
Error: EndpointError,
|
||||
ErrorDescription: fmt.Sprintf("failed to create guest user: %s", err.Error()),
|
||||
}, nil
|
||||
}
|
||||
if !affected {
|
||||
return nil, &TokenError{
|
||||
Error: EndpointError,
|
||||
ErrorDescription: "failed to create guest user",
|
||||
}, nil
|
||||
}
|
||||
|
||||
err = ExtendUserWithRolesAndPermissions(guestUser)
|
||||
if err != nil {
|
||||
return nil, &TokenError{
|
||||
Error: EndpointError,
|
||||
ErrorDescription: fmt.Sprintf("failed to extend user: %s", err.Error()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
accessToken, refreshToken, tokenName, err := generateJwtToken(application, guestUser, "", "", "", "", "", "")
|
||||
if err != nil {
|
||||
return nil, &TokenError{
|
||||
Error: EndpointError,
|
||||
ErrorDescription: fmt.Sprintf("failed to generate token: %s", err.Error()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
token := &Token{
|
||||
Owner: application.Owner,
|
||||
Name: tokenName,
|
||||
CreatedTime: util.GetCurrentTime(),
|
||||
Application: application.Name,
|
||||
Organization: guestUser.Owner,
|
||||
User: guestUser.Name,
|
||||
Code: util.GenerateClientId(),
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: int(application.ExpireInHours * float64(hourSeconds)),
|
||||
Scope: "",
|
||||
TokenType: "Bearer",
|
||||
CodeChallenge: "",
|
||||
CodeIsUsed: true,
|
||||
CodeExpireIn: 0,
|
||||
}
|
||||
|
||||
_, err = AddToken(token)
|
||||
if err != nil {
|
||||
return nil, &TokenError{
|
||||
Error: EndpointError,
|
||||
ErrorDescription: fmt.Sprintf("failed to add token: %s", err.Error()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return token, nil, nil
|
||||
}
|
||||
|
||||
// generateGuestUsername generates a unique username for guest users.
|
||||
func generateGuestUsername() string {
|
||||
return fmt.Sprintf("guest_%s", util.GenerateUUID())
|
||||
}
|
||||
@@ -406,6 +406,9 @@ func GetUsersByTagWithFilter(owner string, tag string, cond builder.Cond) ([]*Us
|
||||
|
||||
func GetSortedUsers(owner string, sorter string, limit int) ([]*User, error) {
|
||||
users := []*User{}
|
||||
if !util.FilterSQLIdentifier(sorter) {
|
||||
return nil, fmt.Errorf("object.GetSortedUsers() error: invalid sorter field: %s", sorter)
|
||||
}
|
||||
err := ormer.Engine.Desc(sorter).Limit(limit, 0).Find(&users, &User{Owner: owner})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -37,6 +37,10 @@ func GetUserByField(organizationName string, field string, value string) (*User,
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if !util.FilterSQLIdentifier(field) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
user := User{Owner: organizationName}
|
||||
existed, err := ormer.Engine.Where(fmt.Sprintf("%s=?", strings.ToLower(field)), value).Get(&user)
|
||||
if err != nil {
|
||||
|
||||
@@ -15,10 +15,11 @@
|
||||
package object
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
"math/big"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -476,10 +477,13 @@ func GetVerifyType(username string) (verificationCodeType string) {
|
||||
var stdNums = []byte("0123456789")
|
||||
|
||||
func getRandomCode(length int) string {
|
||||
var result []byte
|
||||
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
result := make([]byte, length)
|
||||
for i := 0; i < length; i++ {
|
||||
result = append(result, stdNums[r.Intn(len(stdNums))])
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(stdNums))))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
result[i] = stdNums[n.Int64()]
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ type OidcDiscovery struct {
|
||||
RequestParameterSupported bool `json:"request_parameter_supported"`
|
||||
RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported"`
|
||||
EndSessionEndpoint string `json:"end_session_endpoint"`
|
||||
DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"` // RFC 9449
|
||||
}
|
||||
|
||||
type WebFinger struct {
|
||||
@@ -167,6 +168,7 @@ func GetOidcDiscovery(host string, applicationName string) OidcDiscovery {
|
||||
RequestParameterSupported: true,
|
||||
RequestObjectSigningAlgValuesSupported: []string{"HS256", "HS384", "HS512"},
|
||||
EndSessionEndpoint: fmt.Sprintf("%s/api/logout", originBackend),
|
||||
DPoPSigningAlgValuesSupported: []string{"RS256", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512"},
|
||||
}
|
||||
|
||||
return oidcDiscovery
|
||||
|
||||
@@ -141,6 +141,19 @@ func getObject(ctx *context.Context) (string, string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// For non-GET requests, if the `id` query param is present it is the
|
||||
// authoritative identifier of the object being operated on. Use it
|
||||
// instead of the request body so that an attacker cannot spoof the
|
||||
// object owner by injecting "owner":"admin" (or any other value) into
|
||||
// the request body while pointing the URL at a different organization's
|
||||
// resource.
|
||||
if id := ctx.Input.Query("id"); id != "" {
|
||||
owner, name, err := util.GetOwnerAndNameFromIdWithError(id)
|
||||
if err == nil {
|
||||
return owner, name, nil
|
||||
}
|
||||
}
|
||||
|
||||
body := ctx.Input.RequestBody
|
||||
if len(body) == 0 {
|
||||
return ctx.Request.Form.Get("owner"), ctx.Request.Form.Get("name"), nil
|
||||
@@ -287,6 +300,12 @@ func ApiFilter(ctx *context.Context) {
|
||||
|
||||
isAllowed := authz.IsAllowed(subOwner, subName, method, urlPath, objOwner, objName, extraInfo)
|
||||
|
||||
if method != "GET" && !strings.HasSuffix(urlPath, "-entry") {
|
||||
util.SafeGoroutine(func() {
|
||||
writePermissionLog(objOwner, subOwner, subName, method, urlPath, isAllowed)
|
||||
})
|
||||
}
|
||||
|
||||
result := "deny"
|
||||
if isAllowed {
|
||||
result = "allow"
|
||||
@@ -324,6 +343,34 @@ func ApiFilter(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func writePermissionLog(objOwner, subOwner, subName, method, urlPath string, allowed bool) {
|
||||
providers, err := object.GetProvidersByCategory(objOwner, "Log")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
severity := "info"
|
||||
if !allowed {
|
||||
severity = "warning"
|
||||
}
|
||||
message := fmt.Sprintf("sub=%s/%s method=%s url=%s objOwner=%s allowed=%v", subOwner, subName, method, urlPath, objOwner, allowed)
|
||||
|
||||
for _, provider := range providers {
|
||||
// System Log is a pull-based collector; it does not accept Write calls.
|
||||
if provider.Type == "System Log" {
|
||||
continue
|
||||
}
|
||||
if provider.State == "Disabled" {
|
||||
continue
|
||||
}
|
||||
logProvider, err := object.GetLogProviderFromProvider(provider)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = logProvider.Write(severity, message)
|
||||
}
|
||||
}
|
||||
|
||||
func formatExtraInfo(extra map[string]interface{}) string {
|
||||
if extra == nil {
|
||||
return ""
|
||||
|
||||
@@ -70,6 +70,25 @@ func AutoSigninFilter(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate DPoP proof for DPoP-bound tokens (RFC 9449).
|
||||
if token.TokenType == "DPoP" {
|
||||
dpopProof := ctx.Request.Header.Get("DPoP")
|
||||
if dpopProof == "" {
|
||||
responseError(ctx, "DPoP proof header required for DPoP-bound access token")
|
||||
return
|
||||
}
|
||||
htu := object.GetDPoPHtu(ctx.Request.Host, ctx.Request.URL.Path)
|
||||
jkt, dpopErr := object.ValidateDPoPProof(dpopProof, ctx.Request.Method, htu, accessToken)
|
||||
if dpopErr != nil {
|
||||
responseError(ctx, fmt.Sprintf("DPoP proof validation failed: %s", dpopErr.Error()))
|
||||
return
|
||||
}
|
||||
if jkt != token.DPoPJkt {
|
||||
responseError(ctx, "DPoP proof key binding mismatch")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
userId := util.GetId(token.Organization, token.User)
|
||||
application, err := object.GetApplicationByUserId(fmt.Sprintf("app/%s", token.Application))
|
||||
if err != nil {
|
||||
@@ -97,6 +116,17 @@ func AutoSigninFilter(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// "/page?accessKey=123&accessSecret=456"
|
||||
userId, err = getUsernameByAccessKey(ctx)
|
||||
if err != nil {
|
||||
responseError(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
if userId != "" {
|
||||
setSessionUser(ctx, userId)
|
||||
return
|
||||
}
|
||||
|
||||
// "/page?username=built-in/admin&password=123"
|
||||
userId = ctx.Input.Query("username")
|
||||
password := ctx.Input.Query("password")
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/beego/beego/v2/server/web/context"
|
||||
"github.com/casdoor/casdoor/conf"
|
||||
@@ -110,8 +111,8 @@ func denyMcpRequest(ctx *context.Context) {
|
||||
}
|
||||
|
||||
func getUsernameByClientIdSecret(ctx *context.Context) (string, error) {
|
||||
clientId, clientSecret, ok := ctx.Request.BasicAuth()
|
||||
if !ok {
|
||||
clientId, clientSecret, fromBasicAuth := ctx.Request.BasicAuth()
|
||||
if !fromBasicAuth {
|
||||
clientId = ctx.Input.Query("clientId")
|
||||
clientSecret = ctx.Input.Query("clientSecret")
|
||||
}
|
||||
@@ -125,16 +126,71 @@ func getUsernameByClientIdSecret(ctx *context.Context) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
if application == nil {
|
||||
if fromBasicAuth {
|
||||
// The Basic Auth credentials may come from a reverse proxy protecting Casdoor with
|
||||
// HTTP Basic Auth. In that case, the username is not an OAuth client ID, so we
|
||||
// silently ignore it instead of returning an error that would break the whole system.
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("Application not found for client ID: %s", clientId)
|
||||
}
|
||||
|
||||
if application.ClientSecret != clientSecret {
|
||||
if fromBasicAuth {
|
||||
// Same as above: the secret mismatch may be due to proxy-level Basic Auth credentials.
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("Incorrect client secret for application: %s", application.Name)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("app/%s", application.Name), nil
|
||||
}
|
||||
|
||||
func getUsernameByAccessKey(ctx *context.Context) (string, error) {
|
||||
accessKey := ctx.Input.Query("accessKey")
|
||||
accessSecret := ctx.Input.Query("accessSecret")
|
||||
|
||||
if accessKey == "" || accessSecret == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
key, err := object.GetKeyByAccessKey(accessKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if key == nil {
|
||||
return "", fmt.Errorf("Access key not found: %s", accessKey)
|
||||
}
|
||||
|
||||
if key.AccessSecret != accessSecret {
|
||||
return "", fmt.Errorf("Incorrect access secret for key: %s", key.Name)
|
||||
}
|
||||
|
||||
if key.State != "Active" {
|
||||
return "", fmt.Errorf("Access key is not active: %s", key.Name)
|
||||
}
|
||||
|
||||
if key.ExpireTime != "" {
|
||||
expireTime, err := time.Parse(time.RFC3339, key.ExpireTime)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Invalid expire time format for key: %s", key.Name)
|
||||
}
|
||||
if time.Now().After(expireTime) {
|
||||
return "", fmt.Errorf("Access key has expired, expireTime = %s", key.ExpireTime)
|
||||
}
|
||||
}
|
||||
|
||||
if key.User != "" {
|
||||
return util.GetId(key.Organization, key.User), nil
|
||||
}
|
||||
|
||||
if key.Application != "" {
|
||||
return fmt.Sprintf("app/%s", key.Application), nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func getSessionUser(ctx *context.Context) string {
|
||||
user := ctx.Input.CruSession.Get(stdcontext.Background(), "username")
|
||||
if user == nil {
|
||||
@@ -182,8 +238,9 @@ func parseBearerToken(ctx *context.Context) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Accept both "Bearer" (RFC 6750) and "DPoP" (RFC 9449) authorization schemes.
|
||||
prefix := tokens[0]
|
||||
if prefix != "Bearer" {
|
||||
if prefix != "Bearer" && prefix != "DPoP" {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
48
routers/request_body_filter.go
Normal file
48
routers/request_body_filter.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package routers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/beego/beego/v2/server/web/context"
|
||||
)
|
||||
|
||||
// RequestBodyFilter reads the raw request body early (before Beego's CopyBody
|
||||
// and ParseForm) and caches it in Input.RequestBody. This prevents silent data
|
||||
// corruption when clients send requests without a Content-Type: application/json
|
||||
// header: without this filter, Beego's ParseForm may consume the body before
|
||||
// controllers can read it, causing json.Unmarshal to receive empty bytes and
|
||||
// produce zero-value structs that overwrite real data on AllCols().Update().
|
||||
func RequestBodyFilter(ctx *context.Context) {
|
||||
if ctx.Request.Method == http.MethodGet || ctx.Request.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
if ctx.Request.Body == nil || ctx.Request.Body == http.NoBody {
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(ctx.Request.Body)
|
||||
if err != nil || len(body) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Restore Request.Body so Beego's subsequent CopyBody and ParseForm can read it.
|
||||
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
// Cache the raw bytes directly so controllers always have access to them.
|
||||
ctx.Input.RequestBody = body
|
||||
}
|
||||
@@ -134,19 +134,25 @@ func InitAPI() {
|
||||
|
||||
web.Router("/api/get-servers", &controllers.ApiController{}, "GET:GetServers")
|
||||
web.Router("/api/get-online-servers", &controllers.ApiController{}, "GET:GetOnlineServers")
|
||||
web.Router("/api/sync-intranet-servers", &controllers.ApiController{}, "POST:SyncIntranetServers")
|
||||
web.Router("/api/get-server", &controllers.ApiController{}, "GET:GetServer")
|
||||
web.Router("/api/update-server", &controllers.ApiController{}, "POST:UpdateServer")
|
||||
web.Router("/api/sync-mcp-tool", &controllers.ApiController{}, "POST:SyncMcpTool")
|
||||
web.Router("/api/add-server", &controllers.ApiController{}, "POST:AddServer")
|
||||
web.Router("/api/delete-server", &controllers.ApiController{}, "POST:DeleteServer")
|
||||
web.Router("/api/server/:owner/:name", &controllers.ApiController{}, "GET:ProxyServer")
|
||||
web.Router("/api/server/:owner/:name", &controllers.ApiController{}, "POST:ProxyServer")
|
||||
|
||||
web.Router("/api/get-entries", &controllers.ApiController{}, "GET:GetEntries")
|
||||
web.Router("/api/get-entry", &controllers.ApiController{}, "GET:GetEntry")
|
||||
web.Router("/api/get-openclaw-session-graph", &controllers.ApiController{}, "GET:GetOpenClawSessionGraph")
|
||||
web.Router("/api/update-entry", &controllers.ApiController{}, "POST:UpdateEntry")
|
||||
web.Router("/api/add-entry", &controllers.ApiController{}, "POST:AddEntry")
|
||||
web.Router("/api/delete-entry", &controllers.ApiController{}, "POST:DeleteEntry")
|
||||
|
||||
web.Router("/api/v1/traces", &controllers.ApiController{}, "POST:AddTrace")
|
||||
web.Router("/api/v1/traces", &controllers.ApiController{}, "POST:AddOtlpTrace")
|
||||
web.Router("/api/v1/metrics", &controllers.ApiController{}, "POST:AddOtlpMetrics")
|
||||
web.Router("/api/v1/logs", &controllers.ApiController{}, "POST:AddOtlpLogs")
|
||||
|
||||
web.Router("/api/get-global-sites", &controllers.ApiController{}, "GET:GetGlobalSites")
|
||||
web.Router("/api/get-sites", &controllers.ApiController{}, "GET:GetSites")
|
||||
@@ -242,6 +248,7 @@ func InitAPI() {
|
||||
web.Router("/api/update-product", &controllers.ApiController{}, "POST:UpdateProduct")
|
||||
web.Router("/api/add-product", &controllers.ApiController{}, "POST:AddProduct")
|
||||
web.Router("/api/delete-product", &controllers.ApiController{}, "POST:DeleteProduct")
|
||||
web.Router("/api/buy-product", &controllers.ApiController{}, "POST:BuyProduct")
|
||||
|
||||
web.Router("/api/get-orders", &controllers.ApiController{}, "GET:GetOrders")
|
||||
web.Router("/api/get-user-orders", &controllers.ApiController{}, "GET:GetUserOrders")
|
||||
|
||||
@@ -1138,6 +1138,70 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/buy-product": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Product API"
|
||||
],
|
||||
"description": "buy product using the deprecated compatibility endpoint, prefer place-order plus pay-order for new integrations",
|
||||
"operationId": "ApiController.BuyProduct",
|
||||
"deprecated": true,
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "id",
|
||||
"description": "The id ( owner/name ) of the product",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "providerName",
|
||||
"description": "The name of the provider",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "pricingName",
|
||||
"description": "The name of the pricing (for subscription)",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "planName",
|
||||
"description": "The name of the plan (for subscription)",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "userName",
|
||||
"description": "The username to buy product for (admin only)",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "paymentEnv",
|
||||
"description": "The payment environment",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "customPrice",
|
||||
"description": "Custom price for recharge products",
|
||||
"type": "number"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The Response object",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/controllers.Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/delete-adapter": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -10488,4 +10552,4 @@
|
||||
"in": "header"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,6 +734,49 @@ paths:
|
||||
description: The Response object
|
||||
schema:
|
||||
$ref: '#/definitions/object.Userinfo'
|
||||
/api/buy-product:
|
||||
post:
|
||||
tags:
|
||||
- Product API
|
||||
description: buy product using the deprecated compatibility endpoint, prefer place-order plus pay-order for new integrations
|
||||
operationId: ApiController.BuyProduct
|
||||
deprecated: true
|
||||
parameters:
|
||||
- in: query
|
||||
name: id
|
||||
description: The id ( owner/name ) of the product
|
||||
required: true
|
||||
type: string
|
||||
- in: query
|
||||
name: providerName
|
||||
description: The name of the provider
|
||||
required: true
|
||||
type: string
|
||||
- in: query
|
||||
name: pricingName
|
||||
description: The name of the pricing (for subscription)
|
||||
type: string
|
||||
- in: query
|
||||
name: planName
|
||||
description: The name of the plan (for subscription)
|
||||
type: string
|
||||
- in: query
|
||||
name: userName
|
||||
description: The username to buy product for (admin only)
|
||||
type: string
|
||||
- in: query
|
||||
name: paymentEnv
|
||||
description: The payment environment
|
||||
type: string
|
||||
- in: query
|
||||
name: customPrice
|
||||
description: Custom price for recharge products
|
||||
type: number
|
||||
responses:
|
||||
"200":
|
||||
description: The Response object
|
||||
schema:
|
||||
$ref: '#/definitions/controllers.Response'
|
||||
/api/delete-adapter:
|
||||
post:
|
||||
tags:
|
||||
|
||||
32
util/json.go
32
util/json.go
@@ -67,3 +67,35 @@ func TryJsonToAnonymousStruct(j string) (interface{}, error) {
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
// InterfaceToEnforceValue converts a single request value for use in Casbin ABAC enforcement.
|
||||
// - Strings that are valid JSON objects are converted to anonymous structs so Casbin can
|
||||
// access their fields (e.g. r.sub.DivisionGuid).
|
||||
// - Maps (map[string]interface{}) produced by direct JSON unmarshaling are re-marshaled and
|
||||
// then converted to anonymous structs in the same way.
|
||||
// - All other values are returned unchanged.
|
||||
func InterfaceToEnforceValue(v interface{}) interface{} {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
jStruct, err := TryJsonToAnonymousStruct(val)
|
||||
if err == nil {
|
||||
return jStruct
|
||||
}
|
||||
return val
|
||||
case map[string]interface{}:
|
||||
// The value was already decoded as a JSON object; re-encode it so we
|
||||
// can reuse TryJsonToAnonymousStruct to produce a named-field struct
|
||||
// that Casbin can evaluate with dot-notation (r.sub.Field).
|
||||
jsonBytes, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
return val
|
||||
}
|
||||
jStruct, err := TryJsonToAnonymousStruct(string(jsonBytes))
|
||||
if err == nil {
|
||||
return jStruct
|
||||
}
|
||||
return val
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,13 @@
|
||||
|
||||
package util
|
||||
|
||||
import "github.com/thanhpk/randstr"
|
||||
import (
|
||||
"crypto/rand"
|
||||
"math/big"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/thanhpk/randstr"
|
||||
)
|
||||
|
||||
func GenerateClientId() string {
|
||||
return randstr.Hex(10)
|
||||
@@ -27,3 +33,57 @@ func GenerateClientSecret() string {
|
||||
func GeneratePasswordSalt() string {
|
||||
return randstr.Hex(10)
|
||||
}
|
||||
|
||||
// RandomIntn returns a cryptographically secure random int in [0, n).
|
||||
func RandomIntn(n int) int {
|
||||
val, err := rand.Int(rand.Reader, big.NewInt(int64(n)))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return int(val.Int64())
|
||||
}
|
||||
|
||||
// GenerateUUID returns a random UUID v4 string.
|
||||
func GenerateUUID() string {
|
||||
return uuid.NewString()
|
||||
}
|
||||
|
||||
// RandomStringFromCharset returns a cryptographically secure random string
|
||||
// of the given length drawn from charset.
|
||||
func RandomStringFromCharset(charset string, length int) string {
|
||||
result := make([]byte, length)
|
||||
for i := range result {
|
||||
result[i] = charset[RandomIntn(len(charset))]
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func GetRandomName() string {
|
||||
return RandomStringFromCharset("0123456789abcdefghijklmnopqrstuvwxyz", 6)
|
||||
}
|
||||
|
||||
func generateRandomString(length int) (string, error) {
|
||||
return RandomStringFromCharset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", length), nil
|
||||
}
|
||||
|
||||
func GenerateTwoUniqueRandomStrings() (string, string, error) {
|
||||
len1 := 16 + int(big.NewInt(17).Int64())
|
||||
len2 := 16 + int(big.NewInt(17).Int64())
|
||||
|
||||
str1, err := generateRandomString(len1)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
str2, err := generateRandomString(len2)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
for str1 == str2 {
|
||||
str2, err = generateRandomString(len2)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
return str1, str2, nil
|
||||
}
|
||||
|
||||
@@ -20,8 +20,6 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -30,7 +28,6 @@ import (
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/nyaruka/phonenumbers"
|
||||
)
|
||||
|
||||
@@ -167,7 +164,7 @@ func GetSharedOrgFromApp(rawName string) (name string, organization string) {
|
||||
}
|
||||
|
||||
func GenerateId() string {
|
||||
return uuid.NewString()
|
||||
return GenerateUUID()
|
||||
}
|
||||
|
||||
func GenerateTimeId() string {
|
||||
@@ -175,7 +172,7 @@ func GenerateTimeId() string {
|
||||
tm := time.Unix(timestamp, 0)
|
||||
t := tm.Format("20060102_150405")
|
||||
|
||||
random := uuid.NewString()[0:7]
|
||||
random := GenerateUUID()[0:7]
|
||||
|
||||
res := fmt.Sprintf("%s_%s", t, random)
|
||||
return res
|
||||
@@ -189,16 +186,6 @@ func GenerateSimpleTimeId() string {
|
||||
return t
|
||||
}
|
||||
|
||||
func GetRandomName() string {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
const charset = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||||
result := make([]byte, 6)
|
||||
for i := range result {
|
||||
result[i] = charset[rand.Intn(len(charset))]
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func GetId(owner, name string) string {
|
||||
return fmt.Sprintf("%s/%s", owner, name)
|
||||
}
|
||||
@@ -355,7 +342,7 @@ func GetValueFromDataSourceName(key string, dataSourceName string) string {
|
||||
func GetUsernameFromEmail(email string) string {
|
||||
tokens := strings.Split(email, "@")
|
||||
if len(tokens) == 0 {
|
||||
return uuid.NewString()
|
||||
return GenerateUUID()
|
||||
} else {
|
||||
return tokens[0]
|
||||
}
|
||||
@@ -395,36 +382,24 @@ func StringToInterfaceArray2d(arrays [][]string) [][]interface{} {
|
||||
return interfaceArrays
|
||||
}
|
||||
|
||||
func generateRandomString(length int) (string, error) {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, length)
|
||||
for i := range b {
|
||||
var c byte
|
||||
index := rand.Intn(len(charset))
|
||||
c = charset[index]
|
||||
b[i] = c
|
||||
// InterfaceToEnforceArray converts a []interface{} request for use in Casbin ABAC enforcement.
|
||||
// Each element is processed by InterfaceToEnforceValue: plain strings that are valid JSON
|
||||
// objects and map values decoded directly from JSON are both converted to anonymous structs
|
||||
// so Casbin can evaluate attribute-based rules with dot-notation (r.sub.Field).
|
||||
func InterfaceToEnforceArray(array []interface{}) []interface{} {
|
||||
result := make([]interface{}, len(array))
|
||||
for i, elem := range array {
|
||||
result[i] = InterfaceToEnforceValue(elem)
|
||||
}
|
||||
return string(b), nil
|
||||
return result
|
||||
}
|
||||
|
||||
func GenerateTwoUniqueRandomStrings() (string, string, error) {
|
||||
len1 := 16 + int(big.NewInt(17).Int64())
|
||||
len2 := 16 + int(big.NewInt(17).Int64())
|
||||
|
||||
str1, err := generateRandomString(len1)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
// InterfaceToEnforceArray2d applies InterfaceToEnforceArray to every row in a
|
||||
// two-dimensional slice, for use with Casbin BatchEnforce.
|
||||
func InterfaceToEnforceArray2d(arrays [][]interface{}) [][]interface{} {
|
||||
result := make([][]interface{}, len(arrays))
|
||||
for i, arr := range arrays {
|
||||
result[i] = InterfaceToEnforceArray(arr)
|
||||
}
|
||||
str2, err := generateRandomString(len2)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
for str1 == str2 {
|
||||
str2, err = generateRandomString(len2)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
return str1, str2, nil
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -25,17 +25,19 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
rePhone *regexp.Regexp
|
||||
ReWhiteSpace *regexp.Regexp
|
||||
ReFieldWhiteList *regexp.Regexp
|
||||
ReUserName *regexp.Regexp
|
||||
ReUserNameWithEmail *regexp.Regexp
|
||||
rePhone *regexp.Regexp
|
||||
ReWhiteSpace *regexp.Regexp
|
||||
ReFieldWhiteList *regexp.Regexp
|
||||
ReFieldWhiteListIdentifier *regexp.Regexp
|
||||
ReUserName *regexp.Regexp
|
||||
ReUserNameWithEmail *regexp.Regexp
|
||||
)
|
||||
|
||||
func init() {
|
||||
rePhone, _ = regexp.Compile(`(\d{3})\d*(\d{4})`)
|
||||
ReWhiteSpace, _ = regexp.Compile(`\s`)
|
||||
ReFieldWhiteList, _ = regexp.Compile(`^[A-Za-z0-9]+$`)
|
||||
ReFieldWhiteListIdentifier, _ = regexp.Compile(`^[A-Za-z][A-Za-z0-9_]*$`)
|
||||
ReUserName, _ = regexp.Compile("^[a-zA-Z0-9]+([-._][a-zA-Z0-9]+)*$")
|
||||
ReUserNameWithEmail, _ = regexp.Compile(`^([a-zA-Z0-9]+([-._][a-zA-Z0-9]+)*)|([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})$`) // Add support for email formats
|
||||
}
|
||||
@@ -104,6 +106,13 @@ func FilterField(field string) bool {
|
||||
return ReFieldWhiteList.MatchString(field)
|
||||
}
|
||||
|
||||
// FilterSQLIdentifier validates that field is a safe SQL column identifier.
|
||||
// It allows letters, digits, and underscores (e.g. "id_card", "created_time"),
|
||||
// and requires the name to start with a letter to block numeric/special-char attacks.
|
||||
func FilterSQLIdentifier(field string) bool {
|
||||
return ReFieldWhiteListIdentifier.MatchString(field)
|
||||
}
|
||||
|
||||
func IsValidOrigin(origin string) (bool, error) {
|
||||
urlObj, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@ant-design/cssinjs": "^1.23.0",
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"@ant-design/cssinjs": "^2.1.2",
|
||||
"@ant-design/icons": "^6.1.1",
|
||||
"@craco/craco": "^6.4.5",
|
||||
"@crowdin/cli": "^3.7.10",
|
||||
"@ctrl/tinycolor": "^3.5.0",
|
||||
@@ -24,11 +24,11 @@
|
||||
"@web3-onboard/sequence": "^2.0.8",
|
||||
"@web3-onboard/taho": "^2.0.5",
|
||||
"@web3-onboard/trust": "^2.0.4",
|
||||
"antd": "5.24.1",
|
||||
"antd-token-previewer": "^2.0.8",
|
||||
"antd": "6.3.5",
|
||||
"antd-token-previewer": "^3.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"cookie": "0.5.0",
|
||||
"codemirror": "^6.0.1",
|
||||
"cookie": "0.5.0",
|
||||
"copy-to-clipboard": "^3.3.1",
|
||||
"core-js": "^3.25.0",
|
||||
"craco-less": "^2.0.0",
|
||||
@@ -54,6 +54,7 @@
|
||||
"react-highlight-words": "^0.18.0",
|
||||
"react-i18next": "^11.8.7",
|
||||
"react-metamask-avatar": "^1.2.1",
|
||||
"reactflow": "^11.11.4",
|
||||
"react-router-dom": "^5.3.3",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-social-login-buttons": "^3.4.0",
|
||||
@@ -106,6 +107,10 @@
|
||||
"stylelint-config-recommended-less": "^1.0.4",
|
||||
"stylelint-config-standard": "^28.0.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"@ant-design/cssinjs": "^2.1.2",
|
||||
"rc-util": "^5.43.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/**/*.{css,less}": [
|
||||
"stylelint --fix"
|
||||
|
||||
@@ -125,7 +125,7 @@ class AdapterListPage extends BaseListPage {
|
||||
title: i18next.t("adapter:Use same DB"),
|
||||
dataIndex: "useSameDb",
|
||||
key: "useSameDb",
|
||||
width: "120px",
|
||||
width: "130px",
|
||||
sorter: true,
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
@@ -148,7 +148,7 @@ class AdapterListPage extends BaseListPage {
|
||||
title: i18next.t("syncer:Database type"),
|
||||
dataIndex: "databaseType",
|
||||
key: "databaseType",
|
||||
width: "120px",
|
||||
width: "140px",
|
||||
sorter: (a, b) => a.databaseType.localeCompare(b.databaseType),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -23,6 +23,7 @@ import {Alert, Button, ConfigProvider, Drawer, FloatButton, Layout, Result, Tool
|
||||
import {Route, Switch, withRouter} from "react-router-dom";
|
||||
import CustomGithubCorner from "./common/CustomGithubCorner";
|
||||
import * as Conf from "./Conf";
|
||||
import {shadcnThemeComponents, shadcnThemeToken} from "./shadcnTheme";
|
||||
|
||||
import * as Auth from "./auth/Auth";
|
||||
import EntryPage from "./EntryPage";
|
||||
@@ -177,7 +178,7 @@ class App extends Component {
|
||||
"/applications", "/providers", "/resources", "/certs", "/keys", // Identity
|
||||
"/roles", "/permissions", "/models", "/adapters", "/enforcers", // Authorization
|
||||
"/agents", "/servers", "/server-store", "/entries", "/sites", "/rules", // LLM AI
|
||||
"/sessions", "/records", "/tokens", "/verifications", // Logging & Auditing
|
||||
"/sessions", "/records", "/tokens", "/verifications", // Auditing
|
||||
"/products", "/orders", "/payments", "/plans", "/pricings", "/subscriptions", "/transactions", // Business
|
||||
"/sysinfo", "/forms", "/syncers", "/webhooks", "/webhook-events", "/tickets", "/swagger", // Admin
|
||||
];
|
||||
@@ -614,9 +615,11 @@ class App extends Component {
|
||||
locale={getAntdLocale(Setting.getLanguage())}
|
||||
theme={{
|
||||
token: {
|
||||
...shadcnThemeToken,
|
||||
colorPrimary: themeData.colorPrimary,
|
||||
borderRadius: themeData.borderRadius,
|
||||
},
|
||||
components: shadcnThemeComponents,
|
||||
algorithm: Setting.getAlgorithm(this.state.themeAlgorithm),
|
||||
}}>
|
||||
<StyleProvider hashPriority="high" transformers={[legacyLogicalPropertiesTransformer]}>
|
||||
@@ -756,10 +759,12 @@ class App extends Component {
|
||||
locale={getAntdLocale(Setting.getLanguage())}
|
||||
theme={{
|
||||
token: {
|
||||
...shadcnThemeToken,
|
||||
colorPrimary: this.state.themeData.colorPrimary,
|
||||
colorInfo: this.state.themeData.colorPrimary,
|
||||
borderRadius: this.state.themeData.borderRadius,
|
||||
},
|
||||
components: shadcnThemeComponents,
|
||||
algorithm: Setting.getAlgorithm(this.state.themeAlgorithm),
|
||||
}}>
|
||||
<StyleProvider hashPriority="high" transformers={[legacyLogicalPropertiesTransformer]}>
|
||||
|
||||
@@ -49,7 +49,7 @@ img {
|
||||
justify-content: center;
|
||||
border-radius: 5px;
|
||||
width: 45px;
|
||||
height: 64px;
|
||||
height: 52px;
|
||||
float: right;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -58,14 +58,34 @@ img {
|
||||
}
|
||||
}
|
||||
|
||||
.saas-hosting-btn {
|
||||
font-weight: bold;
|
||||
background-color: rgba(87, 52, 211, 0.4);
|
||||
padding: 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
border-radius: 5px;
|
||||
transition: background-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(87, 52, 211, 0.65);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(87, 52, 211, 0.35);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.org-select {
|
||||
display: flex;
|
||||
position: relative;
|
||||
transform: translateY(50%);
|
||||
margin: 0 10px !important;
|
||||
float: right;
|
||||
min-width: 120px;
|
||||
max-width: 180px;
|
||||
min-width: 200px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.rightDropDown {
|
||||
@@ -85,6 +105,8 @@ img {
|
||||
box-shadow: 0 1px 5px 0 rgb(51 51 51 / 14%);
|
||||
flex: 1;
|
||||
align-items: stretch;
|
||||
margin: 0 3px 3px 3px !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
.side-image {
|
||||
@@ -151,3 +173,12 @@ img {
|
||||
.ant-menu-horizontal {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.ant-layout-sider-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ant-menu-inline {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -594,6 +594,20 @@ class ApplicationEditPage extends React.Component {
|
||||
}} />
|
||||
</Col>
|
||||
</Row>
|
||||
{
|
||||
this.state.application.organization !== "built-in" ? (
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 19 : 2}>
|
||||
{Setting.getLabel(i18next.t("application:Enable guest signin"), i18next.t("application:Enable guest signin - Tooltip"))} :
|
||||
</Col>
|
||||
<Col span={1} >
|
||||
<Switch checked={this.state.application.enableGuestSignin} onChange={checked => {
|
||||
this.updateApplicationField("enableGuestSignin", checked);
|
||||
}} />
|
||||
</Col>
|
||||
</Row>
|
||||
) : null
|
||||
}
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 19 : 2}>
|
||||
{Setting.getLabel(i18next.t("application:Enable exclusive signin"), i18next.t("application:Enable exclusive signin - Tooltip"))} :
|
||||
|
||||
@@ -27,8 +27,8 @@ export let StaticBaseUrl = "https://cdn.casbin.org";
|
||||
export const InitThemeAlgorithm = true;
|
||||
export const ThemeDefault = {
|
||||
themeType: "default",
|
||||
colorPrimary: "#5734d3",
|
||||
borderRadius: 6,
|
||||
colorPrimary: "#262626",
|
||||
borderRadius: 10,
|
||||
isCompact: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -13,17 +13,15 @@
|
||||
// limitations under the License.
|
||||
|
||||
import React from "react";
|
||||
import {Link} from "react-router-dom";
|
||||
import {Button, Card, Col, Input, Row, Select} from "antd";
|
||||
import {LinkOutlined} from "@ant-design/icons";
|
||||
import * as EntryBackend from "./backend/EntryBackend";
|
||||
import * as Setting from "./Setting";
|
||||
import i18next from "i18next";
|
||||
import * as OrganizationBackend from "./backend/OrganizationBackend";
|
||||
import * as ApplicationBackend from "./backend/ApplicationBackend";
|
||||
import EntryMessageViewer from "./EntryMessageViewer";
|
||||
|
||||
const {Option} = Select;
|
||||
const {TextArea} = Input;
|
||||
|
||||
class EntryEditPage extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -33,7 +31,6 @@ class EntryEditPage extends React.Component {
|
||||
owner: props.match.params.organizationName,
|
||||
entry: null,
|
||||
organizations: [],
|
||||
applications: [],
|
||||
mode: props.location.mode !== undefined ? props.location.mode : "edit",
|
||||
};
|
||||
}
|
||||
@@ -41,7 +38,6 @@ class EntryEditPage extends React.Component {
|
||||
UNSAFE_componentWillMount() {
|
||||
this.getEntry();
|
||||
this.getOrganizations();
|
||||
this.getApplications(this.state.owner);
|
||||
}
|
||||
|
||||
getEntry() {
|
||||
@@ -73,22 +69,8 @@ class EntryEditPage extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
getApplications(owner) {
|
||||
ApplicationBackend.getApplicationsByOrganization("admin", owner)
|
||||
.then((res) => {
|
||||
this.setState({
|
||||
applications: res.data || [],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateEntryField(key, value) {
|
||||
const entry = this.state.entry;
|
||||
if (key === "owner" && entry.owner !== value) {
|
||||
entry.application = "";
|
||||
this.getApplications(value);
|
||||
}
|
||||
|
||||
entry[key] = value;
|
||||
this.setState({
|
||||
entry: entry,
|
||||
@@ -179,22 +161,14 @@ class EntryEditPage extends React.Component {
|
||||
</Row>
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{Setting.getLabel(i18next.t("general:Listening URL"), i18next.t("general:Listening URL - Tooltip"))} :
|
||||
{i18next.t("general:Provider")}:
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
<Input prefix={<LinkOutlined />} value={this.state.entry.url} onChange={e => {
|
||||
this.updateEntryField("url", e.target.value);
|
||||
}} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{Setting.getLabel(i18next.t("token:Access token"), i18next.t("token:Access token - Tooltip"))} :
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
<Input.Password placeholder={"***"} value={this.state.entry.token} onChange={e => {
|
||||
this.updateEntryField("token", e.target.value);
|
||||
}} />
|
||||
{this.state.entry.provider ? (
|
||||
<Link to={`/providers/${this.state.entry.owner}/${this.state.entry.provider}`}>
|
||||
{this.state.entry.provider}
|
||||
</Link>
|
||||
) : null}
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
@@ -202,23 +176,38 @@ class EntryEditPage extends React.Component {
|
||||
{Setting.getLabel(i18next.t("general:Application"), i18next.t("general:Application - Tooltip"))} :
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
<Select virtual={false} style={{width: "100%"}} value={this.state.entry.application} onChange={(value => {this.updateEntryField("application", value);})}>
|
||||
{
|
||||
this.state.applications.map((application, index) => <Option key={index} value={application.name}>{application.name}</Option>)
|
||||
}
|
||||
</Select>
|
||||
{this.state.entry.application ? (
|
||||
<Link to={`/applications/${this.state.entry.owner}/${this.state.entry.application}`}>
|
||||
{this.state.entry.application}
|
||||
</Link>
|
||||
) : null}
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{i18next.t("payment:Message")}:
|
||||
{Setting.getLabel(i18next.t("general:Type"), i18next.t("general:Type - Tooltip"))} :
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
<TextArea autoSize={{minRows: 8, maxRows: 20}} value={this.state.entry.message} onChange={e => {
|
||||
this.updateEntryField("message", e.target.value);
|
||||
}} />
|
||||
<Input disabled value={this.state.entry.type ?? ""} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{i18next.t("general:Client IP")}:
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
<Input disabled value={this.state.entry.clientIp ?? ""} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{i18next.t("general:User agent")}:
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
<Input disabled value={this.state.entry.userAgent ?? ""} />
|
||||
</Col>
|
||||
</Row>
|
||||
<EntryMessageViewer entry={this.state.entry} labelSpan={(Setting.isMobile()) ? 22 : 2} contentSpan={22} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,26 +14,39 @@
|
||||
|
||||
import React from "react";
|
||||
import {Link} from "react-router-dom";
|
||||
import {Button, Table} from "antd";
|
||||
import {Button, Popover, Table} from "antd";
|
||||
import moment from "moment";
|
||||
import * as Setting from "./Setting";
|
||||
import * as EntryBackend from "./backend/EntryBackend";
|
||||
import * as ProviderBackend from "./backend/ProviderBackend";
|
||||
import i18next from "i18next";
|
||||
import BaseListPage from "./BaseListPage";
|
||||
import PopconfirmModal from "./common/modal/PopconfirmModal";
|
||||
import EntryMessageViewer from "./EntryMessageViewer";
|
||||
|
||||
class EntryListPage extends BaseListPage {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
...this.state,
|
||||
providerMap: {},
|
||||
providerOwner: "",
|
||||
};
|
||||
}
|
||||
|
||||
newEntry() {
|
||||
const randomName = Setting.getRandomName();
|
||||
const randomHex = Math.random().toString(16).slice(2, 18);
|
||||
const owner = Setting.getRequestOrganization(this.props.account);
|
||||
return {
|
||||
owner: owner,
|
||||
name: `entry_${randomName}`,
|
||||
name: randomHex,
|
||||
createdTime: moment().format(),
|
||||
displayName: `New Entry - ${randomName}`,
|
||||
url: "",
|
||||
token: "",
|
||||
displayName: randomHex,
|
||||
provider: "",
|
||||
application: "",
|
||||
type: "",
|
||||
clientIp: "",
|
||||
userAgent: "",
|
||||
message: "",
|
||||
};
|
||||
}
|
||||
@@ -74,35 +87,94 @@ class EntryListPage extends BaseListPage {
|
||||
});
|
||||
}
|
||||
|
||||
getProviders(owner) {
|
||||
if (!owner) {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
|
||||
if (this.state.providerOwner === owner) {
|
||||
return Promise.resolve(this.state.providerMap);
|
||||
}
|
||||
|
||||
return ProviderBackend.getProviders(owner)
|
||||
.then((res) => {
|
||||
if (res.status !== "ok") {
|
||||
return {};
|
||||
}
|
||||
|
||||
const providerMap = {};
|
||||
(res.data || []).forEach((provider) => {
|
||||
if (provider?.category === "Log" && provider?.name) {
|
||||
providerMap[provider.name] = provider;
|
||||
}
|
||||
});
|
||||
|
||||
this.setState({
|
||||
providerMap,
|
||||
providerOwner: owner,
|
||||
});
|
||||
|
||||
return providerMap;
|
||||
})
|
||||
.catch(() => {
|
||||
this.setState({
|
||||
providerMap: {},
|
||||
providerOwner: "",
|
||||
});
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
||||
fetch = (params = {}) => {
|
||||
const field = params.searchedColumn, value = params.searchText;
|
||||
const sortField = params.sortField, sortOrder = params.sortOrder;
|
||||
const owner = Setting.getRequestOrganization(this.props.account);
|
||||
if (!params.pagination) {
|
||||
params.pagination = {current: 1, pageSize: 10};
|
||||
}
|
||||
|
||||
this.setState({loading: true});
|
||||
EntryBackend.getEntries(Setting.getRequestOrganization(this.props.account), params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder)
|
||||
.then((res) => {
|
||||
this.setState({loading: false});
|
||||
if (res.status === "ok") {
|
||||
this.setState({
|
||||
data: res.data,
|
||||
pagination: {
|
||||
...params.pagination,
|
||||
total: res.data2,
|
||||
},
|
||||
searchText: params.searchText,
|
||||
searchedColumn: params.searchedColumn,
|
||||
});
|
||||
} else {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to get")}: ${res.msg}`);
|
||||
}
|
||||
});
|
||||
Promise.all([
|
||||
EntryBackend.getEntries(owner, params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder),
|
||||
this.getProviders(owner),
|
||||
]).then(([res]) => {
|
||||
this.setState({loading: false});
|
||||
if (res.status === "ok") {
|
||||
this.setState({
|
||||
data: res.data,
|
||||
pagination: {
|
||||
...params.pagination,
|
||||
total: res.data2,
|
||||
},
|
||||
searchText: params.searchText,
|
||||
searchedColumn: params.searchedColumn,
|
||||
});
|
||||
} else {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to get")}: ${res.msg}`);
|
||||
}
|
||||
}).catch(error => {
|
||||
this.setState({loading: false});
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
|
||||
});
|
||||
};
|
||||
|
||||
renderTable(entries) {
|
||||
const columns = [
|
||||
{
|
||||
title: i18next.t("general:Organization"),
|
||||
dataIndex: "owner",
|
||||
key: "owner",
|
||||
width: "130px",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("owner"),
|
||||
render: (text) => {
|
||||
return (
|
||||
<Link to={`/organizations/${text}`}>
|
||||
{text}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:Name"),
|
||||
dataIndex: "name",
|
||||
@@ -110,7 +182,7 @@ class EntryListPage extends BaseListPage {
|
||||
width: "160px",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("name"),
|
||||
render: (text, record, index) => {
|
||||
render: (text, record) => {
|
||||
return (
|
||||
<Link to={`/entries/${record.owner}/${text}`}>
|
||||
{text}
|
||||
@@ -118,56 +190,91 @@ class EntryListPage extends BaseListPage {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:Organization"),
|
||||
dataIndex: "owner",
|
||||
key: "owner",
|
||||
width: "130px",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("owner"),
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:Created time"),
|
||||
dataIndex: "createdTime",
|
||||
key: "createdTime",
|
||||
width: "180px",
|
||||
sorter: true,
|
||||
render: (text, record, index) => {
|
||||
render: (text) => {
|
||||
return Setting.getFormattedDate(text);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:Display name"),
|
||||
dataIndex: "displayName",
|
||||
key: "displayName",
|
||||
title: i18next.t("general:Provider"),
|
||||
dataIndex: "provider",
|
||||
key: "provider",
|
||||
width: "160px",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("displayName"),
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:Listening URL"),
|
||||
dataIndex: "url",
|
||||
key: "url",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("url"),
|
||||
render: (text) => {
|
||||
...this.getColumnSearchProps("provider"),
|
||||
render: (text, record) => {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<a target="_blank" rel="noreferrer" href={text}>
|
||||
{Setting.getShortText(text, 40)}
|
||||
</a>
|
||||
<Link to={`/providers/${record.owner}/${text}`}>
|
||||
{text}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:Application"),
|
||||
dataIndex: "application",
|
||||
key: "application",
|
||||
title: i18next.t("general:Type"),
|
||||
dataIndex: "type",
|
||||
key: "type",
|
||||
width: "140px",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("application"),
|
||||
...this.getColumnSearchProps("type"),
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:Client IP"),
|
||||
dataIndex: "clientIp",
|
||||
key: "clientIp",
|
||||
width: "140px",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("clientIp", (row, highlightContent) => (
|
||||
<a target="_blank" rel="noreferrer" href={`https://db-ip.com/${row.text}`}>
|
||||
{highlightContent}
|
||||
</a>
|
||||
)),
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:User agent"),
|
||||
dataIndex: "userAgent",
|
||||
key: "userAgent",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("userAgent"),
|
||||
},
|
||||
{
|
||||
title: i18next.t("payment:Message"),
|
||||
dataIndex: "message",
|
||||
key: "message",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("message"),
|
||||
render: (text, record) => {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Popover
|
||||
placement="topRight"
|
||||
content={(
|
||||
<div style={{width: Setting.isMobile() ? Math.min(window.innerWidth - 40, 720) : 720}}>
|
||||
<EntryMessageViewer
|
||||
entry={record}
|
||||
provider={this.state.providerMap[record.provider] ?? null}
|
||||
labelSpan={24}
|
||||
contentSpan={24}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
title=""
|
||||
trigger="hover"
|
||||
>
|
||||
{Setting.getShortText(text, 60)}
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:Action"),
|
||||
|
||||
712
web/src/EntryMessageViewer.js
Normal file
712
web/src/EntryMessageViewer.js
Normal file
@@ -0,0 +1,712 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from "react";
|
||||
import {Alert, Button, Col, Descriptions, Drawer, Row, Table} from "antd";
|
||||
import * as Setting from "./Setting";
|
||||
import i18next from "i18next";
|
||||
import Editor from "./common/Editor";
|
||||
import SELinuxEntryViewer from "./SELinuxEntryViewer";
|
||||
import * as ProviderBackend from "./backend/ProviderBackend";
|
||||
import OpenClawSessionGraphViewer from "./OpenClawSessionGraphViewer";
|
||||
import {isOpenClawSessionEntry} from "./OpenClawSessionGraphUtils";
|
||||
|
||||
class EntryMessageViewer extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
traceSpanDrawerVisible: false,
|
||||
selectedTraceSpan: null,
|
||||
provider: null,
|
||||
};
|
||||
this.pendingProviderRequestKey = "";
|
||||
this.isUnmounted = false;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.isUnmounted = false;
|
||||
this.getProvider();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (
|
||||
prevProps.entry?.provider !== this.props.entry?.provider
|
||||
|| prevProps.entry?.owner !== this.props.entry?.owner
|
||||
|| prevProps.provider !== this.props.provider
|
||||
) {
|
||||
this.getProvider();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.isUnmounted = true;
|
||||
this.pendingProviderRequestKey = "";
|
||||
}
|
||||
|
||||
getProvider() {
|
||||
if (this.props.provider) {
|
||||
this.pendingProviderRequestKey = "";
|
||||
if (this.state.provider !== null) {
|
||||
this.setState({provider: null});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const providerName = this.props.entry?.provider;
|
||||
const owner = this.props.entry?.owner;
|
||||
|
||||
if (!providerName || !owner) {
|
||||
this.pendingProviderRequestKey = "";
|
||||
if (this.state.provider !== null) {
|
||||
this.setState({provider: null});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const requestKey = `${owner}/${providerName}`;
|
||||
this.pendingProviderRequestKey = requestKey;
|
||||
|
||||
ProviderBackend.getProvider(owner, providerName)
|
||||
.then((res) => {
|
||||
if (this.isUnmounted || this.pendingProviderRequestKey !== requestKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status === "ok") {
|
||||
this.setState({
|
||||
provider: res.data ?? null,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
provider: null,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (this.isUnmounted || this.pendingProviderRequestKey !== requestKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
provider: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getEditorMaxWidth() {
|
||||
return Setting.isMobile() ? window.innerWidth - 60 : 560;
|
||||
}
|
||||
|
||||
getLabelSpan() {
|
||||
return this.props.labelSpan ?? (Setting.isMobile() ? 22 : 2);
|
||||
}
|
||||
|
||||
getContentSpan() {
|
||||
return this.props.contentSpan ?? 22;
|
||||
}
|
||||
|
||||
formatJsonValue(value) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value), null, 2);
|
||||
} catch (e) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
formatAnyValue(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (value.stringValue !== undefined) {
|
||||
return value.stringValue;
|
||||
}
|
||||
|
||||
if (value.boolValue !== undefined) {
|
||||
return `${value.boolValue}`;
|
||||
}
|
||||
|
||||
if (value.intValue !== undefined) {
|
||||
return `${value.intValue}`;
|
||||
}
|
||||
|
||||
if (value.doubleValue !== undefined) {
|
||||
return `${value.doubleValue}`;
|
||||
}
|
||||
|
||||
if (value.bytesValue !== undefined) {
|
||||
return value.bytesValue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value.arrayValue?.values)) {
|
||||
return value.arrayValue.values.map(item => this.formatAnyValue(item)).join(", ");
|
||||
}
|
||||
|
||||
if (Array.isArray(value.kvlistValue?.values)) {
|
||||
return value.kvlistValue.values.map(item => `${item?.key || "-"}=${this.formatAnyValue(item?.value)}`).join(", ");
|
||||
}
|
||||
|
||||
return this.formatJsonValue(value);
|
||||
}
|
||||
|
||||
getAnyValueType(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
if (value.stringValue !== undefined) {
|
||||
return "string";
|
||||
}
|
||||
|
||||
if (value.boolValue !== undefined) {
|
||||
return "bool";
|
||||
}
|
||||
|
||||
if (value.intValue !== undefined) {
|
||||
return "int";
|
||||
}
|
||||
|
||||
if (value.doubleValue !== undefined) {
|
||||
return "double";
|
||||
}
|
||||
|
||||
if (value.bytesValue !== undefined) {
|
||||
return "bytes";
|
||||
}
|
||||
|
||||
if (Array.isArray(value.arrayValue?.values)) {
|
||||
return "array";
|
||||
}
|
||||
|
||||
if (Array.isArray(value.kvlistValue?.values)) {
|
||||
return "map";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
getAttributeValue(attributes, key) {
|
||||
const attribute = attributes.find(item => item?.key === key);
|
||||
return attribute ? this.formatAnyValue(attribute.value) : "";
|
||||
}
|
||||
|
||||
renderTraceAttributeTable(attributes) {
|
||||
const rows = Array.isArray(attributes) ? attributes.map((attribute, index) => ({
|
||||
key: `${attribute?.key || "attribute"}-${index}`,
|
||||
name: attribute?.key || "-",
|
||||
type: this.getAnyValueType(attribute?.value),
|
||||
value: this.formatAnyValue(attribute?.value) || "-",
|
||||
})) : [];
|
||||
|
||||
if (rows.length === 0) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: i18next.t("user:Keys"),
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
width: 220,
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:Type"),
|
||||
dataIndex: "type",
|
||||
key: "type",
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: i18next.t("user:Values"),
|
||||
dataIndex: "value",
|
||||
key: "value",
|
||||
render: value => (
|
||||
<div style={{whiteSpace: "pre-wrap", wordBreak: "break-word"}}>
|
||||
{value}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
scroll={{x: "max-content"}}
|
||||
size="small"
|
||||
bordered
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
rowKey="key"
|
||||
pagination={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
normalizeIntegerString(value) {
|
||||
const text = `${value ?? ""}`.trim();
|
||||
if (!/^\d+$/.test(text)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return text.replace(/^0+(?=\d)/, "");
|
||||
}
|
||||
|
||||
subtractIntegerStrings(minuend, subtrahend) {
|
||||
const left = this.normalizeIntegerString(minuend);
|
||||
const right = this.normalizeIntegerString(subtrahend);
|
||||
if (!left || !right) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (left.length < right.length || (left.length === right.length && left < right)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let borrow = 0;
|
||||
let result = "";
|
||||
|
||||
for (let i = 0; i < left.length; i++) {
|
||||
const leftDigit = Number(left[left.length - 1 - i]);
|
||||
const rightDigit = Number(right[right.length - 1 - i] || 0);
|
||||
let digit = leftDigit - borrow - rightDigit;
|
||||
if (digit < 0) {
|
||||
digit += 10;
|
||||
borrow = 1;
|
||||
} else {
|
||||
borrow = 0;
|
||||
}
|
||||
|
||||
result = `${digit}${result}`;
|
||||
}
|
||||
|
||||
return result.replace(/^0+(?=\d)/, "");
|
||||
}
|
||||
|
||||
getTraceData() {
|
||||
if (this.props.entry?.type !== "trace") {
|
||||
return {spans: [], error: ""};
|
||||
}
|
||||
|
||||
const message = this.props.entry?.message?.trim();
|
||||
if (!message) {
|
||||
return {spans: [], error: ""};
|
||||
}
|
||||
|
||||
try {
|
||||
const trace = JSON.parse(message);
|
||||
return {
|
||||
spans: this.flattenTraceSpans(trace),
|
||||
error: "",
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
spans: [],
|
||||
error: e.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
flattenTraceSpans(trace) {
|
||||
const spans = [];
|
||||
const resourceSpans = Array.isArray(trace?.resourceSpans) ? trace.resourceSpans : [];
|
||||
|
||||
resourceSpans.forEach((resourceSpan, resourceIndex) => {
|
||||
const resource = resourceSpan?.resource ?? {};
|
||||
const resourceAttributes = Array.isArray(resource.attributes) ? resource.attributes : [];
|
||||
const serviceName = this.getAttributeValue(resourceAttributes, "service.name");
|
||||
const scopeSpans = Array.isArray(resourceSpan?.scopeSpans) ? resourceSpan.scopeSpans : [];
|
||||
|
||||
scopeSpans.forEach((scopeSpan, scopeIndex) => {
|
||||
const scope = scopeSpan?.scope ?? {};
|
||||
const scopeSchemaUrl = scopeSpan?.schemaUrl ?? "";
|
||||
const innerSpans = Array.isArray(scopeSpan?.spans) ? scopeSpan.spans : [];
|
||||
|
||||
innerSpans.forEach((span, spanIndex) => {
|
||||
spans.push({
|
||||
key: `${resourceIndex}-${scopeIndex}-${spanIndex}-${span?.spanId ?? span?.name ?? "span"}`,
|
||||
resource,
|
||||
resourceAttributes,
|
||||
resourceSchemaUrl: resourceSpan?.schemaUrl ?? "",
|
||||
scope,
|
||||
scopeSchemaUrl,
|
||||
serviceName,
|
||||
span,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return spans;
|
||||
}
|
||||
|
||||
formatTraceTimestamp(unixNano) {
|
||||
if (!unixNano) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const normalized = this.normalizeIntegerString(unixNano);
|
||||
if (!normalized) {
|
||||
return `${unixNano}`;
|
||||
}
|
||||
|
||||
const padded = normalized.padStart(9, "0");
|
||||
const milliseconds = Number(padded.slice(0, -6) || "0");
|
||||
const nanoseconds = padded.slice(-9);
|
||||
const date = new Date(milliseconds);
|
||||
if (!Number.isFinite(milliseconds) || Number.isNaN(date.getTime())) {
|
||||
return `${unixNano}`;
|
||||
}
|
||||
|
||||
return `${Setting.getFormattedDate(date.toISOString())}.${nanoseconds}`;
|
||||
}
|
||||
|
||||
getSpanDuration(span) {
|
||||
if (!span?.startTimeUnixNano || !span?.endTimeUnixNano) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const duration = this.subtractIntegerStrings(span.endTimeUnixNano, span.startTimeUnixNano);
|
||||
if (!duration) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const durationNumber = Number(duration);
|
||||
if (!Number.isFinite(durationNumber)) {
|
||||
return `${duration} ns`;
|
||||
}
|
||||
|
||||
if (durationNumber >= 1e9) {
|
||||
return `${(durationNumber / 1e9).toFixed(3)} s`;
|
||||
}
|
||||
|
||||
if (durationNumber >= 1e6) {
|
||||
return `${(durationNumber / 1e6).toFixed(3)} ms`;
|
||||
}
|
||||
|
||||
if (durationNumber >= 1e3) {
|
||||
return `${(durationNumber / 1e3).toFixed(3)} us`;
|
||||
}
|
||||
|
||||
return `${durationNumber} ns`;
|
||||
}
|
||||
|
||||
getSpanStatus(span) {
|
||||
const code = span?.status?.code ?? "";
|
||||
const message = span?.status?.message ?? "";
|
||||
|
||||
if (code && message) {
|
||||
return `${code}: ${message}`;
|
||||
}
|
||||
|
||||
return code || message || "-";
|
||||
}
|
||||
|
||||
getScopeName(scope) {
|
||||
if (!scope?.name) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return scope.version ? `${scope.name}@${scope.version}` : scope.name;
|
||||
}
|
||||
|
||||
openTraceSpanDrawer(traceSpan) {
|
||||
this.setState({
|
||||
traceSpanDrawerVisible: true,
|
||||
selectedTraceSpan: traceSpan,
|
||||
});
|
||||
}
|
||||
|
||||
closeTraceSpanDrawer = () => {
|
||||
this.setState({
|
||||
traceSpanDrawerVisible: false,
|
||||
selectedTraceSpan: null,
|
||||
});
|
||||
};
|
||||
|
||||
renderJsonEditor(value) {
|
||||
const formattedValue = this.formatJsonValue(value);
|
||||
if (!formattedValue) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return (
|
||||
<Editor
|
||||
value={formattedValue}
|
||||
lang="json"
|
||||
fillHeight
|
||||
fillWidth
|
||||
maxWidth={this.getEditorMaxWidth()}
|
||||
dark
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderMessageEditor() {
|
||||
const message = this.formatJsonValue(this.props.entry?.message) || "";
|
||||
const lang = this.shouldRenderTraceViewer() ? "json" : undefined;
|
||||
|
||||
return (
|
||||
<Editor
|
||||
value={message}
|
||||
lang={lang}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
shouldRenderTraceViewer() {
|
||||
return `${this.props.entry?.type ?? ""}`.trim().toLowerCase() === "trace";
|
||||
}
|
||||
|
||||
getProviderViewerType() {
|
||||
const provider = this.props.provider ?? this.state.provider;
|
||||
if (!provider) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const category = `${provider.category ?? ""}`.trim();
|
||||
const type = `${provider.type ?? ""}`.trim();
|
||||
|
||||
if (category === "Log" && type === "SELinux Log") {
|
||||
return "selinux";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
renderSpecializedViewer() {
|
||||
const provider = this.props.provider ?? this.state.provider;
|
||||
switch (this.getProviderViewerType()) {
|
||||
case "selinux":
|
||||
return <SELinuxEntryViewer entry={this.props.entry} labelSpan={this.getLabelSpan()} contentSpan={this.getContentSpan()} />;
|
||||
default:
|
||||
if (this.shouldRenderTraceViewer()) {
|
||||
return this.renderTraceSpans();
|
||||
}
|
||||
if (isOpenClawSessionEntry(this.props.entry, provider)) {
|
||||
return <OpenClawSessionGraphViewer entry={this.props.entry} provider={provider} labelSpan={this.getLabelSpan()} contentSpan={this.getContentSpan()} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
renderTraceSpans() {
|
||||
if (this.props.entry?.type !== "trace") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {spans, error} = this.getTraceData();
|
||||
const columns = [
|
||||
{
|
||||
title: i18next.t("general:Name"),
|
||||
dataIndex: ["span", "name"],
|
||||
key: "name",
|
||||
width: 220,
|
||||
render: (text, record) => (
|
||||
<Button type="link" style={{padding: 0}} onClick={() => this.openTraceSpanDrawer(record)}>
|
||||
{text || record.span?.spanId || "-"}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: i18next.t("entry:Service", {defaultValue: "Service"}),
|
||||
dataIndex: "serviceName",
|
||||
key: "serviceName",
|
||||
width: 180,
|
||||
render: value => value || "-",
|
||||
},
|
||||
{
|
||||
title: i18next.t("entry:Span ID", {defaultValue: "Span ID"}),
|
||||
dataIndex: ["span", "spanId"],
|
||||
key: "spanId",
|
||||
width: 180,
|
||||
render: value => value || "-",
|
||||
},
|
||||
{
|
||||
title: i18next.t("entry:Start time", {defaultValue: "Start time"}),
|
||||
dataIndex: ["span", "startTimeUnixNano"],
|
||||
key: "startTimeUnixNano",
|
||||
width: 220,
|
||||
render: value => this.formatTraceTimestamp(value),
|
||||
},
|
||||
{
|
||||
title: i18next.t("entry:Duration", {defaultValue: "Duration"}),
|
||||
key: "duration",
|
||||
width: 120,
|
||||
render: (_, record) => this.getSpanDuration(record.span),
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:Action"),
|
||||
key: "action",
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
<Button type="link" onClick={() => this.openTraceSpanDrawer(record)}>
|
||||
{i18next.t("general:View")}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={this.getLabelSpan()}>
|
||||
{i18next.t("entry:Trace spans", {defaultValue: "Trace spans"})}:
|
||||
</Col>
|
||||
<Col span={this.getContentSpan()} >
|
||||
{error ? (
|
||||
<Alert
|
||||
message={`${i18next.t("entry:Failed to parse trace message", {defaultValue: "Failed to parse trace message"})}: ${error}`}
|
||||
type="warning"
|
||||
showIcon
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
scroll={{x: "max-content"}}
|
||||
size="small"
|
||||
bordered
|
||||
columns={columns}
|
||||
dataSource={spans}
|
||||
rowKey="key"
|
||||
onRow={record => ({
|
||||
onClick: () => this.openTraceSpanDrawer(record),
|
||||
style: {cursor: "pointer"},
|
||||
})}
|
||||
pagination={spans.length > 10 ? {pageSize: 10, hideOnSinglePage: true} : false}
|
||||
locale={{emptyText: i18next.t("entry:No spans", {defaultValue: "No spans"})}}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
{this.renderTraceSpanDrawer()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderTraceSpanDrawer() {
|
||||
const traceSpan = this.state.selectedTraceSpan;
|
||||
const span = traceSpan?.span;
|
||||
if (!traceSpan) {
|
||||
return (
|
||||
<Drawer
|
||||
title={i18next.t("entry:Span detail", {defaultValue: "Span detail"})}
|
||||
width={Setting.isMobile() ? "100%" : 760}
|
||||
placement="right"
|
||||
destroyOnClose
|
||||
onClose={this.closeTraceSpanDrawer}
|
||||
open={this.state.traceSpanDrawerVisible}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={`${i18next.t("entry:Span detail", {defaultValue: "Span detail"})}: ${span?.name || span?.spanId || "-"}`}
|
||||
width={Setting.isMobile() ? "100%" : 760}
|
||||
placement="right"
|
||||
destroyOnClose
|
||||
onClose={this.closeTraceSpanDrawer}
|
||||
open={this.state.traceSpanDrawerVisible}
|
||||
>
|
||||
<Descriptions
|
||||
bordered
|
||||
size="small"
|
||||
column={1}
|
||||
layout={Setting.isMobile() ? "vertical" : "horizontal"}
|
||||
style={{padding: "12px", height: "100%", overflowY: "auto"}}
|
||||
>
|
||||
<Descriptions.Item label={i18next.t("general:Name")}>
|
||||
{span?.name || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Service", {defaultValue: "Service"})}>
|
||||
{traceSpan.serviceName || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("provider:Scope", {defaultValue: "Scope"})}>
|
||||
{this.getScopeName(traceSpan.scope)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("general:Type")}>
|
||||
{span?.kind || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Trace ID", {defaultValue: "Trace ID"})}>
|
||||
{span?.traceId || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Span ID", {defaultValue: "Span ID"})}>
|
||||
{span?.spanId || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Parent Span ID", {defaultValue: "Parent Span ID"})}>
|
||||
{span?.parentSpanId || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("general:Status")}>
|
||||
{this.getSpanStatus(span)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Start time", {defaultValue: "Start time"})}>
|
||||
{this.formatTraceTimestamp(span?.startTimeUnixNano)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("subscription:End time", {defaultValue: "End time"})}>
|
||||
{this.formatTraceTimestamp(span?.endTimeUnixNano)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Duration", {defaultValue: "Duration"})}>
|
||||
{this.getSpanDuration(span)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Resource schema URL", {defaultValue: "Resource schema URL"})}>
|
||||
{traceSpan.resourceSchemaUrl || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Scope schema URL", {defaultValue: "Scope schema URL"})}>
|
||||
{traceSpan.scopeSchemaUrl || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Resource attributes", {defaultValue: "Resource attributes"})}>
|
||||
{this.renderTraceAttributeTable(traceSpan.resourceAttributes)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Span attributes", {defaultValue: "Span attributes"})}>
|
||||
{this.renderTraceAttributeTable(span?.attributes)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("webhook:Events", {defaultValue: "Events"})}>
|
||||
{this.renderJsonEditor(span?.events)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Links", {defaultValue: "Links"})}>
|
||||
{this.renderJsonEditor(span?.links)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Raw span", {defaultValue: "Raw span"})}>
|
||||
{this.renderJsonEditor(span)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
{this.renderSpecializedViewer()}
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={this.getLabelSpan()}>
|
||||
{i18next.t("payment:Message")}:
|
||||
</Col>
|
||||
<Col span={this.getContentSpan()} >
|
||||
{this.renderMessageEditor()}
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default EntryMessageViewer;
|
||||
@@ -164,7 +164,7 @@ class InvitationListPage extends BaseListPage {
|
||||
title: i18next.t("invitation:Used count"),
|
||||
dataIndex: "usedCount",
|
||||
key: "usedCount",
|
||||
width: "130px",
|
||||
width: "140px",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("usedCount"),
|
||||
},
|
||||
|
||||
@@ -14,17 +14,19 @@
|
||||
|
||||
import * as Setting from "./Setting";
|
||||
import {Avatar, Button, Card, Drawer, Dropdown, Menu, Result, Tooltip} from "antd";
|
||||
import Sider from "antd/es/layout/Sider";
|
||||
import EnableMfaNotification from "./common/notifaction/EnableMfaNotification";
|
||||
import {Link, Redirect, Route, Switch, withRouter} from "react-router-dom";
|
||||
import React, {useState} from "react";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import i18next from "i18next";
|
||||
import {
|
||||
AppstoreTwoTone,
|
||||
BarsOutlined, CheckCircleTwoTone, DeploymentUnitOutlined, DollarTwoTone, DownOutlined,
|
||||
HomeTwoTone,
|
||||
LockTwoTone, LogoutOutlined,
|
||||
SafetyCertificateTwoTone, SettingOutlined, SettingTwoTone,
|
||||
WalletTwoTone
|
||||
AppstoreOutlined,
|
||||
BarsOutlined, CheckCircleOutlined, DeploymentUnitOutlined, DollarOutlined, DownOutlined,
|
||||
HomeOutlined,
|
||||
LockOutlined, LogoutOutlined,
|
||||
MenuFoldOutlined, MenuUnfoldOutlined,
|
||||
SafetyCertificateOutlined, SettingOutlined,
|
||||
WalletOutlined
|
||||
} from "@ant-design/icons";
|
||||
import Dashboard from "./basic/Dashboard";
|
||||
import AppListPage from "./basic/AppListPage";
|
||||
@@ -97,6 +99,7 @@ import ThemeSelect from "./common/select/ThemeSelect";
|
||||
import OpenTour from "./common/OpenTour";
|
||||
import OrganizationSelect from "./common/select/OrganizationSelect";
|
||||
import AccountAvatar from "./account/AccountAvatar";
|
||||
import BreadcrumbBar from "./common/BreadcrumbBar";
|
||||
import {Content, Header} from "antd/es/layout/layout";
|
||||
import * as AuthBackend from "./auth/AuthBackend";
|
||||
import {clearWeb3AuthToken} from "./auth/Web3Auth";
|
||||
@@ -119,11 +122,57 @@ import SiteEditPage from "./SiteEditPage";
|
||||
import RuleListPage from "./RuleListPage";
|
||||
import RuleEditPage from "./RuleEditPage";
|
||||
|
||||
function getMenuParentKey(uri) {
|
||||
if (!uri) {return null;}
|
||||
if (uri === "/" || uri.includes("/shortcuts") || uri.includes("/apps")) {return "/home";}
|
||||
if (uri.includes("/organizations") || uri.includes("/trees") || uri.includes("/groups") || uri.includes("/users") || uri.includes("/invitations")) {return "/orgs";}
|
||||
if (uri.includes("/applications") || uri.includes("/providers") || uri.includes("/resources") || uri.includes("/certs") || uri.includes("/keys")) {return "/identity";}
|
||||
if (uri.includes("/agents") || uri.includes("/servers") || uri.includes("/entries") || uri.includes("/sites") || uri.includes("/rules")) {return "/gateway";}
|
||||
if (uri.includes("/roles") || uri.includes("/permissions") || uri.includes("/models") || uri.includes("/adapters") || uri.includes("/enforcers")) {return "/auth";}
|
||||
if (uri.includes("/records") || uri.includes("/tokens") || uri.includes("/sessions") || uri.includes("/verifications")) {return "/logs";}
|
||||
if (uri.includes("/products") || uri.includes("/orders") || uri.includes("/payments") || uri.includes("/plans") || uri.includes("/pricings") || uri.includes("/subscriptions") || uri.includes("/transactions") || uri.includes("/cart")) {return "/business";}
|
||||
if (uri.includes("/sysinfo") || uri.includes("/forms") || uri.includes("/syncers") || uri.includes("/webhooks") || uri.includes("/tickets")) {return "/admin";}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ManagementPage(props) {
|
||||
const [menuVisible, setMenuVisible] = useState(false);
|
||||
const [siderCollapsed, setSiderCollapsed] = useState(() => localStorage.getItem("siderCollapsed") === "true");
|
||||
const [menuOpenKeys, setMenuOpenKeys] = useState(() => {
|
||||
const parentKey = getMenuParentKey(props.uri || location.pathname);
|
||||
return parentKey ? [parentKey] : [];
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const parentKey = getMenuParentKey(props.uri);
|
||||
if (parentKey) {
|
||||
setMenuOpenKeys(prev =>
|
||||
prev.includes(parentKey) ? prev : [...prev, parentKey]
|
||||
);
|
||||
}
|
||||
}, [props.uri]);
|
||||
const organization = props.account?.organization;
|
||||
const navItems = Setting.isLocalAdminUser(props.account) ? organization?.navItems : (organization?.userNavItems ?? []);
|
||||
const widgetItems = organization?.widgetItems;
|
||||
const currentUri = props.uri || location.pathname;
|
||||
const selectedLeafKey = "/" + (currentUri.split("/").filter(Boolean)[0] || "");
|
||||
|
||||
const isDark = props.themeAlgorithm.includes("dark");
|
||||
const textColor = isDark ? "white" : "black";
|
||||
const siderLogo = (() => {
|
||||
if (!props.account?.organization) {return Setting.getLogo(props.themeAlgorithm);}
|
||||
let logo = props.account.organization.logo || Setting.getLogo(props.themeAlgorithm);
|
||||
if (isDark && props.account.organization.logoDark) {
|
||||
logo = props.account.organization.logoDark;
|
||||
}
|
||||
return logo;
|
||||
})();
|
||||
|
||||
const toggleSider = () => {
|
||||
const next = !siderCollapsed;
|
||||
setSiderCollapsed(next);
|
||||
localStorage.setItem("siderCollapsed", String(next));
|
||||
};
|
||||
|
||||
function logout() {
|
||||
AuthBackend.logout()
|
||||
@@ -150,13 +199,13 @@ function ManagementPage(props) {
|
||||
function renderAvatar() {
|
||||
if (props.account.avatar === "") {
|
||||
return (
|
||||
<Avatar style={{backgroundColor: Setting.getAvatarColor(props.account.name), verticalAlign: "middle"}} size="large">
|
||||
<Avatar style={{backgroundColor: Setting.getAvatarColor(props.account.name), verticalAlign: "middle", marginLeft: 8}} size="large">
|
||||
{Setting.getShortName(props.account.name)}
|
||||
</Avatar>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Avatar src={props.account.avatar} style={{verticalAlign: "middle"}} size="large"
|
||||
<Avatar src={props.account.avatar} style={{verticalAlign: "middle", marginLeft: 8}} size="large"
|
||||
icon={<AccountAvatar src={props.account.avatar} style={{verticalAlign: "middle"}} size={40} />}
|
||||
>
|
||||
{Setting.getShortName(props.account.name)}
|
||||
@@ -235,7 +284,7 @@ function ManagementPage(props) {
|
||||
Setting.getItem(<ThemeSelect themeAlgorithm={props.themeAlgorithm} onChange={props.setLogoAndThemeAlgorithm} />, "theme"),
|
||||
Setting.getItem(<LanguageSelect languages={props.account.organization.languages} />, "language"),
|
||||
Setting.getItem(Conf.AiAssistantUrl?.trim() && (
|
||||
<Tooltip title="Click to open AI assistant">
|
||||
<Tooltip title={i18next.t("general:Click to open AI assistant")}>
|
||||
<div className="select-box" onClick={props.openAiAssistant}>
|
||||
<DeploymentUnitOutlined style={{fontSize: "24px"}} />
|
||||
</div>
|
||||
@@ -245,10 +294,10 @@ function ManagementPage(props) {
|
||||
];
|
||||
|
||||
if (widgetItemsIsAll()) {
|
||||
return widgets.map(item => item.label);
|
||||
return widgets.reverse().map(item => item.label);
|
||||
}
|
||||
|
||||
return widgets.filter(item => widgetItems.includes(item.key)).map(item => item.label);
|
||||
return widgets.filter(item => widgetItems.includes(item.key)).reverse().map(item => item.label);
|
||||
}
|
||||
|
||||
function renderAccountMenu() {
|
||||
@@ -263,8 +312,13 @@ function ManagementPage(props) {
|
||||
} else {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{renderRightDropdown()}
|
||||
{renderWidgets()}
|
||||
{Setting.isLocalAdminUser(props.account) && Conf.ShowGithubCorner && !Setting.isMobile() &&
|
||||
<a href={"https://casdoor.com"} target="_blank" rel="noreferrer" style={{marginRight: "8px"}}>
|
||||
<span className="saas-hosting-btn">
|
||||
🚀 SaaS Hosting 🔥
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
{Setting.isAdminUser(props.account) && (props.uri.indexOf("/trees") === -1) &&
|
||||
<OrganizationSelect
|
||||
initValue={Setting.getOrganization()}
|
||||
@@ -276,6 +330,8 @@ function ManagementPage(props) {
|
||||
}}
|
||||
/>
|
||||
}
|
||||
{renderWidgets()}
|
||||
{renderRightDropdown()}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
@@ -288,51 +344,20 @@ function ManagementPage(props) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let textColor = "black";
|
||||
const twoToneColor = props.themeData.colorPrimary;
|
||||
|
||||
let logo = props.account.organization.logo ? props.account.organization.logo : Setting.getLogo(props.themeAlgorithm);
|
||||
if (props.themeAlgorithm.includes("dark")) {
|
||||
if (props.account.organization.logoDark) {
|
||||
logo = props.account.organization.logoDark;
|
||||
}
|
||||
textColor = "white";
|
||||
}
|
||||
|
||||
!Setting.isMobile() ? res.push({
|
||||
label:
|
||||
<Link to="/">
|
||||
<img className="logo" src={logo ?? props.logo} alt="logo" />
|
||||
</Link>,
|
||||
disabled: true, key: "logo",
|
||||
style: {
|
||||
padding: 0,
|
||||
height: "auto",
|
||||
},
|
||||
}) : null;
|
||||
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/">{i18next.t("general:Home")}</Link>, "/home", <HomeTwoTone twoToneColor={twoToneColor} />, [
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/">{i18next.t("general:Home")}</Link>, "/home", <HomeOutlined />, [
|
||||
Setting.getItem(<Link to="/">{i18next.t("general:Dashboard")}</Link>, "/"),
|
||||
Setting.getItem(<Link to="/shortcuts">{i18next.t("general:Shortcuts")}</Link>, "/shortcuts"),
|
||||
Setting.getItem(<Link to="/apps">{i18next.t("general:Apps")}</Link>, "/apps"),
|
||||
]));
|
||||
|
||||
if (Setting.isLocalAdminUser(props.account) && Conf.ShowGithubCorner) {
|
||||
res.push(Setting.getItem(<a href={"https://casdoor.com"}>
|
||||
<span style={{fontWeight: "bold", backgroundColor: "rgba(87,52,211,0.4)", marginTop: "12px", paddingLeft: "5px", paddingRight: "5px", display: "flex", alignItems: "center", height: "40px", borderRadius: "5px"}}>
|
||||
🚀 SaaS Hosting 🔥
|
||||
</span>
|
||||
</a>, "#"));
|
||||
}
|
||||
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/organizations">{i18next.t("general:User Management")}</Link>, "/orgs", <AppstoreTwoTone twoToneColor={twoToneColor} />, [
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/organizations">{i18next.t("general:User Management")}</Link>, "/orgs", <AppstoreOutlined />, [
|
||||
Setting.getItem(<Link to="/organizations">{i18next.t("general:Organizations")}</Link>, "/organizations"),
|
||||
Setting.getItem(<Link to="/groups">{i18next.t("general:Groups")}</Link>, "/groups"),
|
||||
Setting.getItem(<Link to="/users">{i18next.t("general:Users")}</Link>, "/users"),
|
||||
Setting.getItem(<Link to="/invitations">{i18next.t("general:Invitations")}</Link>, "/invitations"),
|
||||
]));
|
||||
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/applications">{i18next.t("general:Identity")}</Link>, "/identity", <LockTwoTone twoToneColor={twoToneColor} />, [
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/applications">{i18next.t("general:Identity")}</Link>, "/identity", <LockOutlined />, [
|
||||
Setting.getItem(<Link to="/applications">{i18next.t("general:Applications")}</Link>, "/applications"),
|
||||
Setting.getItem(<Link to="/providers">{i18next.t("application:Providers")}</Link>, "/providers"),
|
||||
Setting.getItem(<Link to="/resources">{i18next.t("general:Resources")}</Link>, "/resources"),
|
||||
@@ -340,7 +365,7 @@ function ManagementPage(props) {
|
||||
Setting.getItem(<Link to="/keys">{i18next.t("general:Keys")}</Link>, "/keys"),
|
||||
]));
|
||||
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/roles">{i18next.t("general:Authorization")}</Link>, "/auth", <SafetyCertificateTwoTone twoToneColor={twoToneColor} />, [
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/roles">{i18next.t("general:Authorization")}</Link>, "/auth", <SafetyCertificateOutlined />, [
|
||||
Setting.getItem(<Link to="/roles">{i18next.t("general:Roles")}</Link>, "/roles"),
|
||||
Setting.getItem(<Link to="/permissions">{i18next.t("general:Permissions")}</Link>, "/permissions"),
|
||||
Setting.getItem(<Link to="/models">{i18next.t("general:Models")}</Link>, "/models"),
|
||||
@@ -354,7 +379,7 @@ function ManagementPage(props) {
|
||||
}
|
||||
})));
|
||||
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/sites">{i18next.t("general:LLM AI")}</Link>, "/gateway", <CheckCircleTwoTone twoToneColor={twoToneColor} />, [
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/sites">{i18next.t("general:LLM AI")}</Link>, "/gateway", <CheckCircleOutlined />, [
|
||||
Setting.getItem(<Link to="/agents">{i18next.t("general:Agents")}</Link>, "/agents"),
|
||||
Setting.getItem(<Link to="/servers">{i18next.t("general:MCP Servers")}</Link>, "/servers"),
|
||||
Setting.getItem(<Link to="/server-store">{i18next.t("general:MCP Store")}</Link>, "/server-store"),
|
||||
@@ -363,14 +388,14 @@ function ManagementPage(props) {
|
||||
Setting.getItem(<Link to="/rules">{i18next.t("general:Rules")}</Link>, "/rules"),
|
||||
]));
|
||||
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/sessions">{i18next.t("general:Logging & Auditing")}</Link>, "/logs", <WalletTwoTone twoToneColor={twoToneColor} />, [
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/sessions">{i18next.t("general:Auditing")}</Link>, "/logs", <WalletOutlined />, [
|
||||
Setting.getItem(<Link to="/sessions">{i18next.t("general:Sessions")}</Link>, "/sessions"),
|
||||
Setting.getItem(<Link to="/records">{i18next.t("general:Records")}</Link>, "/records"),
|
||||
Setting.getItem(<Link to="/tokens">{i18next.t("general:Tokens")}</Link>, "/tokens"),
|
||||
Setting.getItem(<Link to="/verifications">{i18next.t("general:Verifications")}</Link>, "/verifications"),
|
||||
]));
|
||||
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/products">{i18next.t("general:Business & Payments")}</Link>, "/business", <DollarTwoTone twoToneColor={twoToneColor} />, [
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/products">{i18next.t("general:Business")}</Link>, "/business", <DollarOutlined />, [
|
||||
Setting.getItem(<Link to="/product-store">{i18next.t("general:Product Store")}</Link>, "/product-store"),
|
||||
Setting.getItem(<Link to="/products">{i18next.t("general:Products")}</Link>, "/products"),
|
||||
Setting.getItem(<Link to="/cart">{i18next.t("general:Cart")}</Link>, "/cart"),
|
||||
@@ -383,7 +408,7 @@ function ManagementPage(props) {
|
||||
]));
|
||||
|
||||
if (Setting.isAdminUser(props.account)) {
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/sysinfo">{i18next.t("general:Admin")}</Link>, "/admin", <SettingTwoTone twoToneColor={twoToneColor} />, [
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/sysinfo">{i18next.t("general:Admin")}</Link>, "/admin", <SettingOutlined />, [
|
||||
Setting.getItem(<Link to="/sysinfo">{i18next.t("general:System Info")}</Link>, "/sysinfo"),
|
||||
Setting.getItem(<Link to="/forms">{i18next.t("general:Forms")}</Link>, "/forms"),
|
||||
Setting.getItem(<Link to="/syncers">{i18next.t("general:Syncers")}</Link>, "/syncers"),
|
||||
@@ -392,7 +417,7 @@ function ManagementPage(props) {
|
||||
Setting.getItem(<Link to="/tickets">{i18next.t("general:Tickets")}</Link>, "/tickets"),
|
||||
Setting.getItem(<a target="_blank" rel="noreferrer" href={Setting.isLocalhost() ? `${Setting.ServerUrl}/swagger` : "/swagger"}>{i18next.t("general:Swagger")}</a>, "/swagger")]));
|
||||
} else {
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/syncers">{i18next.t("general:Admin")}</Link>, "/admin", <SettingTwoTone twoToneColor={twoToneColor} />, [
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/syncers">{i18next.t("general:Admin")}</Link>, "/admin", <SettingOutlined />, [
|
||||
Setting.getItem(<Link to="/forms">{i18next.t("general:Forms")}</Link>, "/forms"),
|
||||
Setting.getItem(<Link to="/syncers">{i18next.t("general:Syncers")}</Link>, "/syncers"),
|
||||
Setting.getItem(<Link to="/webhooks">{i18next.t("general:Webhooks")}</Link>, "/webhooks"),
|
||||
@@ -580,52 +605,113 @@ function ManagementPage(props) {
|
||||
setMenuVisible(true);
|
||||
};
|
||||
|
||||
const siderWidth = 256;
|
||||
const siderCollapsedWidth = 80;
|
||||
const showSider = !Setting.isMobile() && !props.requiredEnableMfa;
|
||||
const contentMarginLeft = showSider ? (siderCollapsed ? siderCollapsedWidth : siderWidth) : 0;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<EnableMfaNotification account={props.account} />
|
||||
<Header style={{display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0", marginBottom: "4px", backgroundColor: props.themeAlgorithm.includes("dark") ? "black" : "white"}} >
|
||||
{
|
||||
props.requiredEnableMfa || (Setting.isMobile() ? (
|
||||
<React.Fragment>
|
||||
<Drawer title={i18next.t("general:Close")} placement="left" open={menuVisible} onClose={onClose}>
|
||||
<Menu
|
||||
items={getMenuItems()}
|
||||
mode={"inline"}
|
||||
selectedKeys={[props.selectedMenuKey]}
|
||||
style={{lineHeight: "64px"}}
|
||||
onClick={onClose}
|
||||
>
|
||||
</Menu>
|
||||
</Drawer>
|
||||
<Button icon={<BarsOutlined />} onClick={showMenu} type="text">
|
||||
{i18next.t("general:Menu")}
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
// Padding 1px for Menu Item Highlight border
|
||||
<div style={{flex: 1, overflow: "hidden", paddingBottom: "1px"}}>
|
||||
<Menu
|
||||
onClick={onClose}
|
||||
items={getMenuItems()}
|
||||
mode={"horizontal"}
|
||||
selectedKeys={[props.selectedMenuKey]}
|
||||
style={{backgroundColor: props.themeAlgorithm.includes("dark") ? "black" : "white"}}
|
||||
{showSider && (
|
||||
<Sider
|
||||
collapsed={siderCollapsed}
|
||||
collapsedWidth={siderCollapsedWidth}
|
||||
width={siderWidth}
|
||||
trigger={null}
|
||||
theme={isDark ? "dark" : "light"}
|
||||
style={{
|
||||
height: "100vh",
|
||||
position: "fixed",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
zIndex: 100,
|
||||
boxShadow: "2px 0 8px rgba(0,0,0,0.08)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
height: 52,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: siderCollapsed ? "center" : "flex-start",
|
||||
padding: siderCollapsed ? "0" : "0 16px 0 24px",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<Link to="/">
|
||||
<img
|
||||
src={siderCollapsed ? (organization?.favicon || siderLogo || props.logo) : (siderLogo ?? props.logo)}
|
||||
alt="logo"
|
||||
style={{
|
||||
height: siderCollapsed ? 28 : 40,
|
||||
width: siderCollapsed ? 28 : undefined,
|
||||
maxWidth: siderCollapsed ? 28 : 160,
|
||||
objectFit: "contain",
|
||||
borderRadius: siderCollapsed ? 4 : 0,
|
||||
transition: "max-width 0.2s, height 0.2s, width 0.2s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
<div style={{flexShrink: 0}}>
|
||||
{renderAccountMenu()}
|
||||
</div>
|
||||
</Header>
|
||||
<Content style={{display: "flex", flexDirection: "column"}} >
|
||||
{isWithoutCard() ?
|
||||
renderRouter() :
|
||||
<Card className="content-warp-card">
|
||||
{renderRouter()}
|
||||
</Card>
|
||||
}
|
||||
</Content>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="sider-menu-container" style={{flex: 1, overflow: "auto"}}>
|
||||
<Menu
|
||||
mode="inline"
|
||||
items={getMenuItems()}
|
||||
selectedKeys={[selectedLeafKey]}
|
||||
openKeys={menuOpenKeys}
|
||||
onOpenChange={setMenuOpenKeys}
|
||||
theme={isDark ? "dark" : "light"}
|
||||
style={{borderRight: 0}}
|
||||
/>
|
||||
</div>
|
||||
</Sider>
|
||||
)}
|
||||
<div style={{marginLeft: contentMarginLeft, transition: "margin-left 0.2s", display: "flex", flexDirection: "column", minHeight: "100vh"}}>
|
||||
<Header style={{display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0", marginBottom: "4px", backgroundColor: isDark ? "black" : "white", position: "sticky", top: 0, zIndex: 99, boxShadow: "0 1px 4px rgba(0,0,0,0.08)", height: "52px", lineHeight: "52px"}}>
|
||||
<div style={{display: "flex", alignItems: "center"}}>
|
||||
{props.requiredEnableMfa ? null : (Setting.isMobile() ? (
|
||||
<React.Fragment>
|
||||
<Drawer title={i18next.t("general:Close")} placement="left" open={menuVisible} onClose={onClose}>
|
||||
<Menu
|
||||
items={getMenuItems()}
|
||||
mode={"inline"}
|
||||
selectedKeys={[selectedLeafKey]}
|
||||
openKeys={menuOpenKeys}
|
||||
onOpenChange={setMenuOpenKeys}
|
||||
style={{lineHeight: "48px"}}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</Drawer>
|
||||
<Button icon={<BarsOutlined />} onClick={showMenu} type="text">
|
||||
{i18next.t("general:Menu")}
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<Button
|
||||
icon={siderCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={toggleSider}
|
||||
type="text"
|
||||
style={{fontSize: 16, width: 40, height: 40}}
|
||||
/>
|
||||
))}
|
||||
<BreadcrumbBar uri={currentUri} />
|
||||
</div>
|
||||
<div style={{flexShrink: 0, display: "flex", alignItems: "center"}}>
|
||||
{renderAccountMenu()}
|
||||
</div>
|
||||
</Header>
|
||||
<Content style={{display: "flex", flexDirection: "column"}}>
|
||||
{isWithoutCard() ?
|
||||
renderRouter() :
|
||||
<Card className="content-warp-card" styles={{body: {padding: 0, margin: 0}}}>
|
||||
{renderRouter()}
|
||||
</Card>
|
||||
}
|
||||
</Content>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
351
web/src/OpenClawSessionGraphUtils.js
Normal file
351
web/src/OpenClawSessionGraphUtils.js
Normal file
@@ -0,0 +1,351 @@
|
||||
const openClawPayloadKinds = new Set(["task", "tool_call", "tool_result", "final"]);
|
||||
|
||||
export function isOpenClawSessionEntry(entry, provider) {
|
||||
if (!entry || `${entry.type ?? ""}`.trim().toLowerCase() !== "session") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (provider?.category === "Log" && provider?.type === "Agent" && provider?.subType === "OpenClaw") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (provider) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = parseOpenClawBehaviorPayload(entry.message);
|
||||
return Boolean(payload?.sessionId && payload?.entryId && payload?.kind);
|
||||
}
|
||||
|
||||
function parseOpenClawBehaviorPayload(message) {
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const source = typeof message === "string" ? message : JSON.stringify(message);
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(source);
|
||||
const kind = `${payload?.kind ?? ""}`.trim();
|
||||
const sessionId = `${payload?.sessionId ?? ""}`.trim();
|
||||
const entryId = `${payload?.entryId ?? ""}`.trim();
|
||||
if (!kind || !sessionId || !entryId || !openClawPayloadKinds.has(kind)) {
|
||||
return null;
|
||||
}
|
||||
return payload;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getOpenClawNodeTarget(node) {
|
||||
return node?.query || node?.url || node?.path || node?.tool || "";
|
||||
}
|
||||
|
||||
export function getOpenClawNodeColor(node) {
|
||||
switch (node?.kind) {
|
||||
case "task":
|
||||
return "#4c6ef5";
|
||||
case "assistant_step":
|
||||
return "#0f766e";
|
||||
case "tool_call":
|
||||
return "#f08c00";
|
||||
case "tool_result":
|
||||
return node?.ok === false ? "#e03131" : "#2f9e44";
|
||||
case "final":
|
||||
return "#6c5ce7";
|
||||
default:
|
||||
return "#868e96";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return `${value ?? ""}`.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function stripLeadingPrefix(text, prefix) {
|
||||
const normalizedText = normalizeText(text);
|
||||
const normalizedPrefix = normalizeText(prefix);
|
||||
if (!normalizedText || !normalizedPrefix) {
|
||||
return normalizedText;
|
||||
}
|
||||
|
||||
if (normalizedText.toLowerCase().startsWith(normalizedPrefix.toLowerCase())) {
|
||||
return normalizedText.slice(normalizedPrefix.length).trim();
|
||||
}
|
||||
|
||||
return normalizedText;
|
||||
}
|
||||
|
||||
function getAssistantStepTitle(node) {
|
||||
const summary = normalizeText(node?.summary);
|
||||
const match = summary.match(/^(\d+\s+tool calls?)(?:\s*:\s*.+)?$/i);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
return summary || node?.id || "-";
|
||||
}
|
||||
|
||||
function getToolCallTitle(node) {
|
||||
const target = normalizeText(getOpenClawNodeTarget(node));
|
||||
if (target) {
|
||||
return target;
|
||||
}
|
||||
|
||||
const prefix = node?.tool ? `${node.tool}:` : "";
|
||||
return stripLeadingPrefix(node?.summary, prefix) || normalizeText(node?.summary) || node?.id || "-";
|
||||
}
|
||||
|
||||
function getToolResultTitle(node) {
|
||||
const target = normalizeText(getOpenClawNodeTarget(node));
|
||||
if (target) {
|
||||
return target;
|
||||
}
|
||||
if (node?.ok === false && node?.error) {
|
||||
return normalizeText(node.error);
|
||||
}
|
||||
|
||||
const prefix = node?.tool ? `${node.tool} ${node.ok === false ? "failed" : "ok"}:` : "";
|
||||
return stripLeadingPrefix(node?.summary, prefix) || normalizeText(node?.summary) || node?.id || "-";
|
||||
}
|
||||
|
||||
function getNodeTitle(node) {
|
||||
switch (node?.kind) {
|
||||
case "assistant_step":
|
||||
return getAssistantStepTitle(node);
|
||||
case "tool_call":
|
||||
return getToolCallTitle(node);
|
||||
case "tool_result":
|
||||
return getToolResultTitle(node);
|
||||
default:
|
||||
return normalizeText(node?.summary) || node?.id || "-";
|
||||
}
|
||||
}
|
||||
|
||||
function compareNodes(left, right) {
|
||||
const leftTimestamp = `${left?.timestamp ?? ""}`.trim();
|
||||
const rightTimestamp = `${right?.timestamp ?? ""}`.trim();
|
||||
const leftMillis = parseTimestampMillis(leftTimestamp);
|
||||
const rightMillis = parseTimestampMillis(rightTimestamp);
|
||||
if (leftMillis !== null && rightMillis !== null) {
|
||||
if (leftMillis !== rightMillis) {
|
||||
return leftMillis - rightMillis;
|
||||
}
|
||||
} else if (leftTimestamp !== rightTimestamp) {
|
||||
return leftTimestamp.localeCompare(rightTimestamp);
|
||||
}
|
||||
|
||||
return `${left?.id ?? ""}`.localeCompare(`${right?.id ?? ""}`);
|
||||
}
|
||||
|
||||
function parseTimestampMillis(timestamp) {
|
||||
if (!timestamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const milliseconds = Date.parse(timestamp);
|
||||
if (Number.isNaN(milliseconds)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return milliseconds;
|
||||
}
|
||||
|
||||
function buildTreeIndexes(graph) {
|
||||
const sourceNodes = Array.isArray(graph?.nodes) ? graph.nodes : [];
|
||||
const sourceEdges = Array.isArray(graph?.edges) ? graph.edges : [];
|
||||
const nodeMap = Object.fromEntries(sourceNodes.map(node => [node.id, node]));
|
||||
const childrenMap = new Map();
|
||||
const incomingCount = new Map();
|
||||
|
||||
sourceNodes.forEach((node) => {
|
||||
childrenMap.set(node.id, []);
|
||||
incomingCount.set(node.id, 0);
|
||||
});
|
||||
|
||||
sourceEdges.forEach((edge) => {
|
||||
if (!nodeMap[edge.source] || !nodeMap[edge.target]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!childrenMap.has(edge.source)) {
|
||||
childrenMap.set(edge.source, []);
|
||||
}
|
||||
childrenMap.get(edge.source).push(edge.target);
|
||||
incomingCount.set(edge.target, (incomingCount.get(edge.target) || 0) + 1);
|
||||
});
|
||||
|
||||
childrenMap.forEach((childIds) => childIds.sort((left, right) => compareNodes(nodeMap[left], nodeMap[right])));
|
||||
const roots = sourceNodes
|
||||
.filter(node => !incomingCount.get(node.id))
|
||||
.sort(compareNodes)
|
||||
.map(node => node.id);
|
||||
|
||||
return {nodeMap, childrenMap, roots};
|
||||
}
|
||||
|
||||
function computeTreeLayout(graph) {
|
||||
const {nodeMap, childrenMap, roots} = buildTreeIndexes(graph);
|
||||
const positions = new Map();
|
||||
const visited = new Set();
|
||||
const verticalGap = 160;
|
||||
const horizontalGap = 320;
|
||||
let cursor = 0;
|
||||
|
||||
function placeNode(nodeId, depth, stack) {
|
||||
if (!nodeMap[nodeId]) {
|
||||
return {top: cursor * verticalGap, bottom: cursor * verticalGap, center: cursor * verticalGap};
|
||||
}
|
||||
if (positions.has(nodeId)) {
|
||||
const y = positions.get(nodeId).y;
|
||||
return {top: y, bottom: y, center: y};
|
||||
}
|
||||
if (stack.has(nodeId)) {
|
||||
const y = cursor * verticalGap;
|
||||
cursor += 1;
|
||||
positions.set(nodeId, {x: depth * horizontalGap, y});
|
||||
visited.add(nodeId);
|
||||
return {top: y, bottom: y, center: y};
|
||||
}
|
||||
|
||||
stack.add(nodeId);
|
||||
const childIds = (childrenMap.get(nodeId) || []).filter(childId => nodeMap[childId]);
|
||||
if (childIds.length === 0) {
|
||||
const y = cursor * verticalGap;
|
||||
cursor += 1;
|
||||
positions.set(nodeId, {x: depth * horizontalGap, y});
|
||||
visited.add(nodeId);
|
||||
stack.delete(nodeId);
|
||||
return {top: y, bottom: y, center: y};
|
||||
}
|
||||
|
||||
const childBoxes = childIds.map(childId => placeNode(childId, depth + 1, stack));
|
||||
const top = childBoxes[0].top;
|
||||
const bottom = childBoxes[childBoxes.length - 1].bottom;
|
||||
const center = childBoxes.length === 1 ? childBoxes[0].center : (top + bottom) / 2;
|
||||
positions.set(nodeId, {x: depth * horizontalGap, y: center});
|
||||
visited.add(nodeId);
|
||||
stack.delete(nodeId);
|
||||
return {top, bottom, center};
|
||||
}
|
||||
|
||||
roots.forEach(rootId => placeNode(rootId, 0, new Set()));
|
||||
|
||||
Object.values(nodeMap)
|
||||
.filter(node => !visited.has(node.id))
|
||||
.sort(compareNodes)
|
||||
.forEach((node) => {
|
||||
placeNode(node.id, 0, new Set());
|
||||
});
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
function getNodeSubtitle(node) {
|
||||
switch (node?.kind) {
|
||||
case "assistant_step": {
|
||||
const summary = normalizeText(node?.summary);
|
||||
const parts = summary.split(":");
|
||||
const detail = parts.length > 1 ? parts.slice(1).join(":").trim() : "";
|
||||
return detail || node?.timestamp || "-";
|
||||
}
|
||||
case "tool_call":
|
||||
return normalizeText(node?.tool) || node?.timestamp || "-";
|
||||
case "tool_result":
|
||||
if (node?.ok === false) {
|
||||
return normalizeText(node?.error) || `${normalizeText(node?.tool) || "tool"} failed`;
|
||||
}
|
||||
return `${normalizeText(node?.tool) || "tool"} ok`;
|
||||
default:
|
||||
return getOpenClawNodeTarget(node) || node?.timestamp || "-";
|
||||
}
|
||||
}
|
||||
|
||||
function getNodeBackground(node) {
|
||||
switch (node?.kind) {
|
||||
case "assistant_step":
|
||||
return "#f0fdfa";
|
||||
case "tool_call":
|
||||
return "#fff7ed";
|
||||
case "tool_result":
|
||||
return node?.ok === false ? "#fff5f5" : "#f3faf4";
|
||||
case "final":
|
||||
return "#f5f3ff";
|
||||
default:
|
||||
return "#ffffff";
|
||||
}
|
||||
}
|
||||
|
||||
function getEdgeStyle(edge, nodeMap) {
|
||||
const targetNode = nodeMap[edge.target];
|
||||
if (targetNode?.kind === "tool_result" && targetNode?.ok === false) {
|
||||
return {
|
||||
stroke: "#e03131",
|
||||
strokeWidth: 2.5,
|
||||
};
|
||||
}
|
||||
|
||||
if (targetNode?.originalParentId && targetNode.originalParentId !== targetNode.parentId) {
|
||||
return {
|
||||
stroke: "#0f766e",
|
||||
strokeWidth: 2.5,
|
||||
strokeDasharray: "6 4",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stroke: "#94a3b8",
|
||||
strokeWidth: 2,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOpenClawFlowElements(graph) {
|
||||
const sourceNodes = Array.isArray(graph?.nodes) ? graph.nodes : [];
|
||||
const sourceEdges = Array.isArray(graph?.edges) ? graph.edges : [];
|
||||
const nodeMap = Object.fromEntries(sourceNodes.map(node => [node.id, node]));
|
||||
const positions = computeTreeLayout(graph);
|
||||
|
||||
const flowNodes = sourceNodes
|
||||
.slice()
|
||||
.sort(compareNodes)
|
||||
.map((node) => {
|
||||
const color = getOpenClawNodeColor(node);
|
||||
const position = positions.get(node.id) || {x: 0, y: 0};
|
||||
return {
|
||||
id: node.id,
|
||||
position,
|
||||
data: {
|
||||
title: getNodeTitle(node),
|
||||
subtitle: getNodeSubtitle(node),
|
||||
rawNode: node,
|
||||
isAnchor: node.isAnchor,
|
||||
},
|
||||
draggable: false,
|
||||
selectable: true,
|
||||
style: {
|
||||
width: 250,
|
||||
minHeight: 76,
|
||||
padding: "12px 14px",
|
||||
borderRadius: 14,
|
||||
border: node.isAnchor ? `3px solid ${color}` : `1px solid ${color}`,
|
||||
boxShadow: node.isAnchor ? "0 8px 24px rgba(0, 0, 0, 0.12)" : "0 4px 14px rgba(0, 0, 0, 0.08)",
|
||||
background: getNodeBackground(node),
|
||||
color: "#1f2937",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const flowEdges = sourceEdges.map(edge => ({
|
||||
id: `${edge.source}-${edge.target}`,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: "smoothstep",
|
||||
animated: false,
|
||||
style: getEdgeStyle(edge, nodeMap),
|
||||
}));
|
||||
|
||||
return {nodes: flowNodes, edges: flowEdges};
|
||||
}
|
||||
390
web/src/OpenClawSessionGraphViewer.js
Normal file
390
web/src/OpenClawSessionGraphViewer.js
Normal file
@@ -0,0 +1,390 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Alert,
|
||||
Col,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Row,
|
||||
Spin,
|
||||
Tag,
|
||||
Typography
|
||||
} from "antd";
|
||||
import i18next from "i18next";
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
ReactFlowProvider
|
||||
} from "reactflow";
|
||||
import "reactflow/dist/style.css";
|
||||
import * as EntryBackend from "./backend/EntryBackend";
|
||||
import * as Setting from "./Setting";
|
||||
import {
|
||||
buildOpenClawFlowElements,
|
||||
getOpenClawNodeColor,
|
||||
getOpenClawNodeTarget
|
||||
} from "./OpenClawSessionGraphUtils";
|
||||
|
||||
const {Text} = Typography;
|
||||
|
||||
function OpenClawNodeLabel({title, subtitle}) {
|
||||
return (
|
||||
<div style={{display: "flex", flexDirection: "column", gap: "6px"}}>
|
||||
<div style={{fontSize: 13, fontWeight: 600, lineHeight: 1.35}}>
|
||||
{title || "-"}
|
||||
</div>
|
||||
<div style={{fontSize: 12, color: "#64748b", lineHeight: 1.35}}>
|
||||
{subtitle || "-"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStatusTag(node) {
|
||||
if (
|
||||
node?.kind !== "tool_result" ||
|
||||
node?.ok === undefined ||
|
||||
node?.ok === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return node.ok ? (
|
||||
<Tag color="success">{i18next.t("general:OK")}</Tag>
|
||||
) : (
|
||||
<Tag color="error">{i18next.t("entry:Failed", {defaultValue: "Failed"})}</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
function OpenClawSessionGraphCanvas(props) {
|
||||
const {graph, onNodeSelect} = props;
|
||||
const [reactFlowInstance, setReactFlowInstance] = React.useState(null);
|
||||
const elements = React.useMemo(() => {
|
||||
const flowElements = buildOpenClawFlowElements(graph);
|
||||
return {
|
||||
nodes: flowElements.nodes.map((node) => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
label: (
|
||||
<OpenClawNodeLabel
|
||||
title={node.data.title}
|
||||
subtitle={node.data.subtitle}
|
||||
/>
|
||||
),
|
||||
},
|
||||
})),
|
||||
edges: flowElements.edges,
|
||||
};
|
||||
}, [graph]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!reactFlowInstance || elements.nodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
reactFlowInstance.fitView({padding: 0.2, duration: 0});
|
||||
const anchorNode = elements.nodes.find((node) => node.data?.isAnchor);
|
||||
if (!anchorNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
reactFlowInstance.setCenter(
|
||||
anchorNode.position.x + 125,
|
||||
anchorNode.position.y + 38,
|
||||
{zoom: 1.02, duration: 0}
|
||||
);
|
||||
}, 0);
|
||||
}, [elements.nodes, reactFlowInstance]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: 460,
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={elements.nodes}
|
||||
edges={elements.edges}
|
||||
fitView
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
onInit={setReactFlowInstance}
|
||||
onNodeClick={(_, node) => onNodeSelect(node.data?.rawNode ?? null)}
|
||||
>
|
||||
<MiniMap
|
||||
pannable
|
||||
zoomable
|
||||
nodeColor={(node) => getOpenClawNodeColor(node.data?.rawNode)}
|
||||
/>
|
||||
<Controls showInteractive={false} />
|
||||
<Background color="#f1f5f9" gap={16} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
class OpenClawSessionGraphViewer extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
loading: false,
|
||||
error: "",
|
||||
graph: null,
|
||||
selectedNode: null,
|
||||
};
|
||||
this.requestKey = "";
|
||||
this.isUnmounted = false;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.isUnmounted = false;
|
||||
this.loadGraph();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (
|
||||
prevProps.entry?.owner !== this.props.entry?.owner ||
|
||||
prevProps.entry?.name !== this.props.entry?.name ||
|
||||
prevProps.provider !== this.props.provider
|
||||
) {
|
||||
this.loadGraph();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.isUnmounted = true;
|
||||
this.requestKey = "";
|
||||
}
|
||||
|
||||
getLabelSpan() {
|
||||
return this.props.labelSpan ?? (Setting.isMobile() ? 22 : 2);
|
||||
}
|
||||
|
||||
getContentSpan() {
|
||||
return this.props.contentSpan ?? 22;
|
||||
}
|
||||
|
||||
loadGraph() {
|
||||
if (!this.props.entry?.owner || !this.props.entry?.name) {
|
||||
this.requestKey = "";
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: "",
|
||||
graph: null,
|
||||
selectedNode: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requestKey = `${this.props.entry.owner}/${this.props.entry.name}`;
|
||||
this.requestKey = requestKey;
|
||||
this.setState({loading: true, error: "", selectedNode: null});
|
||||
|
||||
EntryBackend.getOpenClawSessionGraph(
|
||||
this.props.entry.owner,
|
||||
this.props.entry.name
|
||||
)
|
||||
.then((res) => {
|
||||
if (this.isUnmounted || this.requestKey !== requestKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status === "ok" && res.data) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: "",
|
||||
graph: res.data,
|
||||
});
|
||||
} else if (res.status === "ok") {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: "",
|
||||
graph: null,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: `${i18next.t("entry:Failed to load session graph", {defaultValue: "Failed to load session graph"})}: ${res.msg}`,
|
||||
graph: null,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (this.isUnmounted || this.requestKey !== requestKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: `${i18next.t("entry:Failed to load session graph", {defaultValue: "Failed to load session graph"})}: ${error}`,
|
||||
graph: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderStats() {
|
||||
const stats = this.state.graph?.stats;
|
||||
if (!stats) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 12}}
|
||||
>
|
||||
<Tag color="default">{i18next.t("entry:Nodes", {defaultValue: "Nodes"})}: {stats.totalNodes}</Tag>
|
||||
<Tag color="blue">{i18next.t("entry:Tasks", {defaultValue: "Tasks"})}: {stats.taskCount}</Tag>
|
||||
<Tag color="orange">{i18next.t("entry:Tool calls", {defaultValue: "Tool calls"})}: {stats.toolCallCount}</Tag>
|
||||
<Tag color="green">{i18next.t("entry:Results", {defaultValue: "Results"})}: {stats.toolResultCount}</Tag>
|
||||
<Tag color="purple">{i18next.t("entry:Finals", {defaultValue: "Finals"})}: {stats.finalCount}</Tag>
|
||||
{stats.failedCount > 0 ? (
|
||||
<Tag color="red">{i18next.t("entry:Failed", {defaultValue: "Failed"})}: {stats.failedCount}</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderNodeText(value) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{whiteSpace: "pre-wrap", wordBreak: "break-word"}}>
|
||||
{value}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderNodeDrawer() {
|
||||
const node = this.state.selectedNode;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={node?.summary || i18next.t("entry:Session graph node", {defaultValue: "Session graph node"})}
|
||||
width={Setting.isMobile() ? "100%" : 720}
|
||||
placement="right"
|
||||
onClose={() => this.setState({selectedNode: null})}
|
||||
open={this.state.selectedNode !== null}
|
||||
destroyOnClose
|
||||
>
|
||||
{node ? (
|
||||
<Descriptions
|
||||
bordered
|
||||
size="small"
|
||||
column={1}
|
||||
layout={Setting.isMobile() ? "vertical" : "horizontal"}
|
||||
style={{padding: "12px", height: "100%", overflowY: "auto"}}
|
||||
>
|
||||
<Descriptions.Item label={i18next.t("general:Type")}>
|
||||
<div style={{display: "flex", alignItems: "center", gap: 8}}>
|
||||
<Text>{node.kind || "-"}</Text>
|
||||
{getStatusTag(node)}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Summary", {defaultValue: "Summary"})}>
|
||||
{node.summary || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("general:Timestamp")}>
|
||||
{node.timestamp || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Entry ID", {defaultValue: "Entry ID"})}>
|
||||
{node.entryId || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Tool Call ID", {defaultValue: "Tool Call ID"})}>
|
||||
{node.toolCallId || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={`${i18next.t("general:Parent")} ${i18next.t("general:ID")}`}>
|
||||
{node.parentId || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Original Parent ID", {defaultValue: "Original Parent ID"})}>
|
||||
{node.originalParentId || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Target", {defaultValue: "Target"})}>
|
||||
{getOpenClawNodeTarget(node) || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Tool", {defaultValue: "Tool"})}>
|
||||
{node.tool || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Query", {defaultValue: "Query"})}>
|
||||
{this.renderNodeText(node.query)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("general:URL")}>
|
||||
{this.renderNodeText(node.url)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Path", {defaultValue: "Path"})}>
|
||||
{this.renderNodeText(node.path)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("general:Error")}>
|
||||
{this.renderNodeText(node.error)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Text", {defaultValue: "Text"})}>
|
||||
{this.renderNodeText(node.text)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : null}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
if (this.state.loading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
padding: "48px 0",
|
||||
}}
|
||||
>
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (this.state.error) {
|
||||
return <Alert type="warning" showIcon message={this.state.error} />;
|
||||
}
|
||||
|
||||
if (!this.state.graph) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{this.renderStats()}
|
||||
<ReactFlowProvider>
|
||||
<OpenClawSessionGraphCanvas
|
||||
graph={this.state.graph}
|
||||
onNodeSelect={(selectedNode) => this.setState({selectedNode})}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
{this.renderNodeDrawer()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.loading && !this.state.error && !this.state.graph) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Row style={{marginTop: "20px"}}>
|
||||
<Col style={{marginTop: "5px"}} span={this.getLabelSpan()}>
|
||||
{i18next.t("entry:Session graph", {defaultValue: "Session Graph"})}:
|
||||
</Col>
|
||||
<Col span={this.getContentSpan()}>
|
||||
<div data-testid="openclaw-session-graph">{this.renderContent()}</div>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenClawSessionGraphViewer;
|
||||
@@ -223,7 +223,7 @@ class OrganizationListPage extends BaseListPage {
|
||||
title: i18next.t("general:Password type"),
|
||||
dataIndex: "passwordType",
|
||||
key: "passwordType",
|
||||
width: "150px",
|
||||
width: "160px",
|
||||
sorter: true,
|
||||
filterMultiple: false,
|
||||
filters: [
|
||||
@@ -267,7 +267,7 @@ class OrganizationListPage extends BaseListPage {
|
||||
title: i18next.t("organization:User balance"),
|
||||
dataIndex: "userBalance",
|
||||
key: "userBalance",
|
||||
width: "120px",
|
||||
width: "130px",
|
||||
sorter: true,
|
||||
render: (text, record, index) => {
|
||||
return text ?? 0;
|
||||
@@ -277,7 +277,7 @@ class OrganizationListPage extends BaseListPage {
|
||||
title: i18next.t("organization:Balance credit"),
|
||||
dataIndex: "balanceCredit",
|
||||
key: "balanceCredit",
|
||||
width: "120px",
|
||||
width: "130px",
|
||||
sorter: true,
|
||||
render: (text, record, index) => {
|
||||
return text ?? 0;
|
||||
@@ -287,7 +287,7 @@ class OrganizationListPage extends BaseListPage {
|
||||
title: i18next.t("organization:Balance currency"),
|
||||
dataIndex: "balanceCurrency",
|
||||
key: "balanceCurrency",
|
||||
width: "140px",
|
||||
width: "160px",
|
||||
sorter: true,
|
||||
render: (text, record, index) => {
|
||||
return text || "USD";
|
||||
|
||||
@@ -415,7 +415,7 @@ class PermissionListPage extends BaseListPage {
|
||||
dataIndex: "approveTime",
|
||||
key: "approveTime",
|
||||
filterMultiple: false,
|
||||
width: "120px",
|
||||
width: "130px",
|
||||
sorter: true,
|
||||
render: (text, record, index) => {
|
||||
return Setting.getFormattedDate(text);
|
||||
|
||||
@@ -341,7 +341,7 @@ class ProductStorePage extends React.Component {
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<div style={{padding: "16px"}}>
|
||||
<FloatingCartButton
|
||||
itemCount={this.state.cartItemCount}
|
||||
onClick={() => this.props.history.push("/cart")}
|
||||
|
||||
@@ -37,6 +37,31 @@ import {renderLogProviderFields} from "./provider/LogProviderFields";
|
||||
const {Option} = Select;
|
||||
const {TextArea} = Input;
|
||||
|
||||
function isDefaultProviderName(name) {
|
||||
return /^provider_[a-z0-9]+$/.test(name);
|
||||
}
|
||||
|
||||
function isDefaultProviderDisplayName(displayName) {
|
||||
return /^New Provider - [a-z0-9]+$/.test(displayName);
|
||||
}
|
||||
|
||||
function getAutoProviderName(category, type, subType) {
|
||||
const catSlug = category.toLowerCase().replace(/[\s-]+/g, "_").replace(/[^a-z0-9_]/g, "");
|
||||
const typeSlug = type.toLowerCase().replace(/[\s-]+/g, "_").replace(/[^a-z0-9_]/g, "");
|
||||
if (subType) {
|
||||
const subTypeSlug = subType.toLowerCase().replace(/[\s-]+/g, "_").replace(/[^a-z0-9_]/g, "");
|
||||
return `provider_${catSlug}_${typeSlug}_${subTypeSlug}`;
|
||||
}
|
||||
return `provider_${catSlug}_${typeSlug}`;
|
||||
}
|
||||
|
||||
function getAutoProviderDisplayName(category, type, subType) {
|
||||
if (subType) {
|
||||
return `${category} ${type} ${subType}`;
|
||||
}
|
||||
return `${category} ${type}`;
|
||||
}
|
||||
|
||||
const defaultUserMapping = {
|
||||
id: "id",
|
||||
username: "username",
|
||||
@@ -77,6 +102,8 @@ class ProviderEditPage extends React.Component {
|
||||
certs: [],
|
||||
organizations: [],
|
||||
mode: props.location.mode !== undefined ? props.location.mode : "edit",
|
||||
nameNotUserEdited: false,
|
||||
displayNameNotUserEdited: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,6 +114,17 @@ class ProviderEditPage extends React.Component {
|
||||
}
|
||||
|
||||
getProvider() {
|
||||
if (this.state.mode === "add" && this.props.location.provider) {
|
||||
const provider = this.props.location.provider;
|
||||
provider.userMapping = provider.userMapping || defaultUserMapping;
|
||||
this.setState({
|
||||
provider: provider,
|
||||
nameNotUserEdited: isDefaultProviderName(provider.name),
|
||||
displayNameNotUserEdited: isDefaultProviderDisplayName(provider.displayName),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
ProviderBackend.getProvider(this.state.owner, this.state.providerName)
|
||||
.then((res) => {
|
||||
if (res.data === null) {
|
||||
@@ -115,6 +153,8 @@ class ProviderEditPage extends React.Component {
|
||||
}
|
||||
this.setState({
|
||||
provider: provider,
|
||||
nameNotUserEdited: isDefaultProviderName(provider.name),
|
||||
displayNameNotUserEdited: isDefaultProviderDisplayName(provider.displayName),
|
||||
});
|
||||
} else {
|
||||
Setting.showMessage("error", res.msg);
|
||||
@@ -455,7 +495,11 @@ class ProviderEditPage extends React.Component {
|
||||
}
|
||||
|
||||
getProviderSubTypeOptions(type) {
|
||||
if (type === "WeCom" || type === "Infoflow") {
|
||||
if (type === "Agent") {
|
||||
return ([
|
||||
{id: "OpenClaw", name: "OpenClaw"},
|
||||
]);
|
||||
} else if (type === "WeCom" || type === "Infoflow") {
|
||||
return (
|
||||
[
|
||||
{id: "Internal", name: i18next.t("provider:Internal")},
|
||||
@@ -649,6 +693,7 @@ class ProviderEditPage extends React.Component {
|
||||
<Col span={22} >
|
||||
<Input value={this.state.provider.name} onChange={e => {
|
||||
this.updateProviderField("name", e.target.value);
|
||||
this.setState({nameNotUserEdited: false});
|
||||
}} />
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -659,6 +704,7 @@ class ProviderEditPage extends React.Component {
|
||||
<Col span={22} >
|
||||
<Input value={this.state.provider.displayName} onChange={e => {
|
||||
this.updateProviderField("displayName", e.target.value);
|
||||
this.setState({displayNameNotUserEdited: false});
|
||||
}} />
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -682,10 +728,13 @@ class ProviderEditPage extends React.Component {
|
||||
<Col span={22} >
|
||||
<Select virtual={false} style={{width: "100%"}} value={this.state.provider.category} onChange={(value => {
|
||||
this.updateProviderField("category", value);
|
||||
let defaultType = "";
|
||||
if (value === "OAuth") {
|
||||
this.updateProviderField("type", "Google");
|
||||
defaultType = "Google";
|
||||
this.updateProviderField("type", defaultType);
|
||||
} else if (value === "Email") {
|
||||
this.updateProviderField("type", "Default");
|
||||
defaultType = "Default";
|
||||
this.updateProviderField("type", defaultType);
|
||||
this.updateProviderField("host", "smtp.example.com");
|
||||
this.updateProviderField("port", 465);
|
||||
this.updateProviderField("sslMode", "Auto");
|
||||
@@ -694,33 +743,53 @@ class ProviderEditPage extends React.Component {
|
||||
this.updateProviderField("metadata", Setting.getDefaultInvitationHtmlEmailContent());
|
||||
this.updateProviderField("receiver", this.props.account.email);
|
||||
} else if (value === "SMS") {
|
||||
this.updateProviderField("type", "Twilio SMS");
|
||||
defaultType = "Twilio SMS";
|
||||
this.updateProviderField("type", defaultType);
|
||||
} else if (value === "Storage") {
|
||||
this.updateProviderField("type", "AWS S3");
|
||||
defaultType = "AWS S3";
|
||||
this.updateProviderField("type", defaultType);
|
||||
} else if (value === "SAML") {
|
||||
this.updateProviderField("type", "Keycloak");
|
||||
defaultType = "Keycloak";
|
||||
this.updateProviderField("type", defaultType);
|
||||
} else if (value === "Payment") {
|
||||
this.updateProviderField("type", "PayPal");
|
||||
defaultType = "PayPal";
|
||||
this.updateProviderField("type", defaultType);
|
||||
} else if (value === "Captcha") {
|
||||
this.updateProviderField("type", "Default");
|
||||
defaultType = "Default";
|
||||
this.updateProviderField("type", defaultType);
|
||||
} else if (value === "Web3") {
|
||||
this.updateProviderField("type", "MetaMask");
|
||||
defaultType = "MetaMask";
|
||||
this.updateProviderField("type", defaultType);
|
||||
} else if (value === "Notification") {
|
||||
this.updateProviderField("type", "Telegram");
|
||||
defaultType = "Telegram";
|
||||
this.updateProviderField("type", defaultType);
|
||||
} else if (value === "Face ID") {
|
||||
this.updateProviderField("type", "Alibaba Cloud Facebody");
|
||||
defaultType = "Alibaba Cloud Facebody";
|
||||
this.updateProviderField("type", defaultType);
|
||||
} else if (value === "MFA") {
|
||||
this.updateProviderField("type", "RADIUS");
|
||||
defaultType = "RADIUS";
|
||||
this.updateProviderField("type", defaultType);
|
||||
this.updateProviderField("host", "");
|
||||
this.updateProviderField("port", 1812);
|
||||
} else if (value === "ID Verification") {
|
||||
this.updateProviderField("type", "Jumio");
|
||||
defaultType = "Jumio";
|
||||
this.updateProviderField("type", defaultType);
|
||||
this.updateProviderField("endpoint", "");
|
||||
} else if (value === "Log") {
|
||||
this.updateProviderField("type", "Linux Syslog");
|
||||
this.updateProviderField("host", "127.0.0.1");
|
||||
this.updateProviderField("port", 514);
|
||||
this.updateProviderField("title", "casdoor");
|
||||
defaultType = "Casdoor Permission Log";
|
||||
this.updateProviderField("type", defaultType);
|
||||
this.updateProviderField("host", "");
|
||||
this.updateProviderField("port", 0);
|
||||
this.updateProviderField("title", "");
|
||||
this.updateProviderField("state", "Enabled");
|
||||
}
|
||||
if (defaultType) {
|
||||
if (this.state.nameNotUserEdited) {
|
||||
this.updateProviderField("name", getAutoProviderName(value, defaultType, ""));
|
||||
}
|
||||
if (this.state.displayNameNotUserEdited) {
|
||||
this.updateProviderField("displayName", getAutoProviderDisplayName(value, defaultType, ""));
|
||||
}
|
||||
}
|
||||
})}>
|
||||
{
|
||||
@@ -770,6 +839,12 @@ class ProviderEditPage extends React.Component {
|
||||
this.updateProviderField("method", "GET");
|
||||
this.updateProviderField("title", "");
|
||||
}
|
||||
if (this.state.nameNotUserEdited) {
|
||||
this.updateProviderField("name", getAutoProviderName(this.state.provider.category, value, ""));
|
||||
}
|
||||
if (this.state.displayNameNotUserEdited) {
|
||||
this.updateProviderField("displayName", getAutoProviderDisplayName(this.state.provider.category, value, ""));
|
||||
}
|
||||
})}>
|
||||
{
|
||||
Setting.getProviderTypeOptions(this.state.provider.category)
|
||||
@@ -783,7 +858,7 @@ class ProviderEditPage extends React.Component {
|
||||
</Col>
|
||||
</Row>
|
||||
{
|
||||
this.state.provider.type !== "WeCom" && this.state.provider.type !== "Infoflow" && this.state.provider.type !== "WeChat" ? null : (
|
||||
this.state.provider.type !== "WeCom" && this.state.provider.type !== "Infoflow" && this.state.provider.type !== "WeChat" && this.state.provider.type !== "Agent" ? null : (
|
||||
<React.Fragment>
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={2}>
|
||||
@@ -792,6 +867,12 @@ class ProviderEditPage extends React.Component {
|
||||
<Col span={22} >
|
||||
<Select virtual={false} style={{width: "100%"}} value={this.state.provider.subType} onChange={value => {
|
||||
this.updateProviderField("subType", value);
|
||||
if (this.state.nameNotUserEdited) {
|
||||
this.updateProviderField("name", getAutoProviderName(this.state.provider.category, this.state.provider.type, value));
|
||||
}
|
||||
if (this.state.displayNameNotUserEdited) {
|
||||
this.updateProviderField("displayName", getAutoProviderDisplayName(this.state.provider.category, this.state.provider.type, value));
|
||||
}
|
||||
}}>
|
||||
{
|
||||
this.getProviderSubTypeOptions(this.state.provider.type).map((providerSubType, index) => <Option key={index} value={providerSubType.id}>{providerSubType.name}</Option>)
|
||||
@@ -986,16 +1067,18 @@ class ProviderEditPage extends React.Component {
|
||||
this.state.provider,
|
||||
this.updateProviderField.bind(this)
|
||||
) : null}
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{Setting.getLabel(i18next.t("provider:Provider URL"), i18next.t("provider:Provider URL - Tooltip"))} :
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
<Input prefix={<LinkOutlined />} value={this.state.provider.providerUrl} onChange={e => {
|
||||
this.updateProviderField("providerUrl", e.target.value);
|
||||
}} />
|
||||
</Col>
|
||||
</Row>
|
||||
{this.state.provider.category !== "Log" && (
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{Setting.getLabel(i18next.t("provider:Provider URL"), i18next.t("provider:Provider URL - Tooltip"))} :
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
<Input prefix={<LinkOutlined />} value={this.state.provider.providerUrl} onChange={e => {
|
||||
this.updateProviderField("providerUrl", e.target.value);
|
||||
}} />
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
{
|
||||
this.state.provider.category === "Captcha" ? renderCaptchaProviderFields(
|
||||
this.state.provider,
|
||||
@@ -1008,13 +1091,18 @@ class ProviderEditPage extends React.Component {
|
||||
|
||||
submitProviderEdit(exitAfterSave) {
|
||||
const provider = Setting.deepCopy(this.state.provider);
|
||||
ProviderBackend.updateProvider(this.state.owner, this.state.providerName, provider)
|
||||
const isAdd = this.state.mode === "add";
|
||||
const apiCall = isAdd
|
||||
? ProviderBackend.addProvider(provider)
|
||||
: ProviderBackend.updateProvider(this.state.owner, this.state.providerName, provider);
|
||||
apiCall
|
||||
.then((res) => {
|
||||
if (res.status === "ok") {
|
||||
Setting.showMessage("success", i18next.t("general:Successfully saved"));
|
||||
this.setState({
|
||||
owner: this.state.provider.owner,
|
||||
providerName: this.state.provider.name,
|
||||
mode: "edit",
|
||||
});
|
||||
|
||||
if (exitAfterSave) {
|
||||
@@ -1024,7 +1112,9 @@ class ProviderEditPage extends React.Component {
|
||||
}
|
||||
} else {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to save")}: ${res.msg}`);
|
||||
this.updateProviderField("name", this.state.providerName);
|
||||
if (!isAdd) {
|
||||
this.updateProviderField("name", this.state.providerName);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -1033,17 +1123,7 @@ class ProviderEditPage extends React.Component {
|
||||
}
|
||||
|
||||
deleteProvider() {
|
||||
ProviderBackend.deleteProvider(this.state.provider)
|
||||
.then((res) => {
|
||||
if (res.status === "ok") {
|
||||
this.props.history.push("/providers");
|
||||
} else {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to delete")}: ${res.msg}`);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
|
||||
});
|
||||
this.props.history.push("/providers");
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -51,24 +51,13 @@ class ProviderListPage extends BaseListPage {
|
||||
enableSignUp: true,
|
||||
host: "",
|
||||
port: 0,
|
||||
providerUrl: "https://github.com/organizations/xxx/settings/applications/1234567",
|
||||
providerUrl: "",
|
||||
};
|
||||
}
|
||||
|
||||
addProvider() {
|
||||
const newProvider = this.newProvider();
|
||||
ProviderBackend.addProvider(newProvider)
|
||||
.then((res) => {
|
||||
if (res.status === "ok") {
|
||||
this.props.history.push({pathname: `/providers/${newProvider.owner}/${newProvider.name}`, mode: "add"});
|
||||
Setting.showMessage("success", i18next.t("general:Successfully added"));
|
||||
} else {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to add")}: ${res.msg}`);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
|
||||
});
|
||||
this.props.history.push({pathname: `/providers/${newProvider.owner}/${newProvider.name}`, mode: "add", provider: newProvider});
|
||||
}
|
||||
|
||||
deleteProvider(i) {
|
||||
|
||||
@@ -72,7 +72,7 @@ class RecordListPage extends BaseListPage {
|
||||
title: i18next.t("general:Organization"),
|
||||
dataIndex: "organization",
|
||||
key: "organization",
|
||||
width: "110px",
|
||||
width: "140px",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("organization"),
|
||||
render: (text, record, index) => {
|
||||
@@ -102,7 +102,7 @@ class RecordListPage extends BaseListPage {
|
||||
title: i18next.t("general:Method"),
|
||||
dataIndex: "method",
|
||||
key: "method",
|
||||
width: "100px",
|
||||
width: "110px",
|
||||
sorter: true,
|
||||
filterMultiple: false,
|
||||
filters: [
|
||||
@@ -129,7 +129,7 @@ class RecordListPage extends BaseListPage {
|
||||
title: i18next.t("user:Language"),
|
||||
dataIndex: "language",
|
||||
key: "language",
|
||||
width: "90px",
|
||||
width: "120px",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("language"),
|
||||
},
|
||||
@@ -204,13 +204,13 @@ class RecordListPage extends BaseListPage {
|
||||
sorter: true,
|
||||
fixed: "right",
|
||||
render: (text, record, index) => (
|
||||
<Button type="link" onClick={() => {
|
||||
<Button onClick={() => {
|
||||
this.setState({
|
||||
detailRecord: record,
|
||||
detailShow: true,
|
||||
});
|
||||
}}>
|
||||
{i18next.t("general:Detail")}
|
||||
{i18next.t("general:View")}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
|
||||
164
web/src/SELinuxEntryViewer.js
Normal file
164
web/src/SELinuxEntryViewer.js
Normal file
@@ -0,0 +1,164 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from "react";
|
||||
import {Col, Descriptions, Row, Tag} from "antd";
|
||||
import * as Setting from "./Setting";
|
||||
import i18next from "i18next";
|
||||
|
||||
class SELinuxEntryViewer extends React.Component {
|
||||
getLabelSpan() {
|
||||
return this.props.labelSpan ?? (Setting.isMobile() ? 22 : 2);
|
||||
}
|
||||
|
||||
getContentSpan() {
|
||||
return this.props.contentSpan ?? 22;
|
||||
}
|
||||
|
||||
getMessage() {
|
||||
return `${this.props.entry?.message ?? ""}`.trim();
|
||||
}
|
||||
|
||||
getSeverityColor(severity) {
|
||||
switch ((severity || "").toLowerCase()) {
|
||||
case "warning":
|
||||
return "orange";
|
||||
case "error":
|
||||
return "red";
|
||||
case "info":
|
||||
return "blue";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
extractValue(message, key) {
|
||||
const escapedKey = this.escapeRegExp(key);
|
||||
const quotedMatch = message.match(new RegExp(`(?:^|\\s)${escapedKey}="([^"]*)"`, "i"));
|
||||
if (quotedMatch) {
|
||||
return quotedMatch[1];
|
||||
}
|
||||
|
||||
const plainMatch = message.match(new RegExp(`(?:^|\\s)${escapedKey}=([^\\s]+)`, "i"));
|
||||
return plainMatch ? plainMatch[1] : "";
|
||||
}
|
||||
|
||||
parseMessage() {
|
||||
const message = this.getMessage();
|
||||
const severityMatch = message.match(/^\[([^\]]+)\]\s*/);
|
||||
const severity = severityMatch ? severityMatch[1] : "";
|
||||
const body = severityMatch ? message.slice(severityMatch[0].length) : message;
|
||||
|
||||
const details = {
|
||||
severity,
|
||||
auditType: this.extractValue(body, "type"),
|
||||
auditStamp: (body.match(/msg=audit\(([^)]+)\)/) || [])[1] || "",
|
||||
decision: (body.match(/\bavc:\s+([a-z_]+)/i) || [])[1] || "",
|
||||
permission: (body.match(/\{\s*([^}]+?)\s*\}/) || [])[1] || "",
|
||||
pid: this.extractValue(body, "pid"),
|
||||
command: this.extractValue(body, "comm"),
|
||||
executable: this.extractValue(body, "exe"),
|
||||
path: this.extractValue(body, "path"),
|
||||
device: this.extractValue(body, "dev"),
|
||||
inode: this.extractValue(body, "ino"),
|
||||
sourceContext: this.extractValue(body, "scontext"),
|
||||
targetContext: this.extractValue(body, "tcontext"),
|
||||
targetClass: this.extractValue(body, "tclass"),
|
||||
permissive: this.extractValue(body, "permissive"),
|
||||
rawBody: body,
|
||||
};
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
renderValue(value, render) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return render ? render(value) : value;
|
||||
}
|
||||
|
||||
render() {
|
||||
const details = this.parseMessage();
|
||||
|
||||
return (
|
||||
<Row style={{marginTop: "20px"}}>
|
||||
<Col style={{marginTop: "5px"}} span={this.getLabelSpan()}>
|
||||
{i18next.t("entry:SELinux event", {defaultValue: "SELinux event"})}:
|
||||
</Col>
|
||||
<Col span={this.getContentSpan()}>
|
||||
<Descriptions
|
||||
bordered
|
||||
size="small"
|
||||
column={Setting.isMobile() ? 1 : 2}
|
||||
layout={Setting.isMobile() ? "vertical" : "horizontal"}
|
||||
>
|
||||
<Descriptions.Item label={i18next.t("general:Severity")}>
|
||||
{this.renderValue(details.severity, value => <Tag color={this.getSeverityColor(value)}>{value}</Tag>)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("general:Type")}>
|
||||
{this.renderValue(details.auditType)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Decision", {defaultValue: "Decision"})}>
|
||||
{this.renderValue(details.decision)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Permission", {defaultValue: "Permission"})}>
|
||||
{this.renderValue(details.permission)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Audit stamp", {defaultValue: "Audit stamp"})}>
|
||||
{this.renderValue(details.auditStamp)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Permissive", {defaultValue: "Permissive"})}>
|
||||
{this.renderValue(details.permissive)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Process ID", {defaultValue: "Process ID"})}>
|
||||
{this.renderValue(details.pid)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Command", {defaultValue: "Command"})}>
|
||||
{this.renderValue(details.command)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Executable", {defaultValue: "Executable"})}>
|
||||
{this.renderValue(details.executable)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Target class", {defaultValue: "Target class"})}>
|
||||
{this.renderValue(details.targetClass)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("general:Path")}>
|
||||
{this.renderValue(details.path)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Device", {defaultValue: "Device"})}>
|
||||
{this.renderValue(details.device)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Inode", {defaultValue: "Inode"})}>
|
||||
{this.renderValue(details.inode)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Source context", {defaultValue: "Source context"})} span={Setting.isMobile() ? 1 : 2}>
|
||||
{this.renderValue(details.sourceContext)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Target context", {defaultValue: "Target context"})} span={Setting.isMobile() ? 1 : 2}>
|
||||
{this.renderValue(details.targetContext)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default SELinuxEntryViewer;
|
||||
@@ -120,6 +120,38 @@ class ServerEditPage extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
syncMcpTool() {
|
||||
const server = Setting.deepCopy(this.state.server);
|
||||
ServerBackend.syncMcpTool(this.state.owner, this.state.serverName, server)
|
||||
.then((res) => {
|
||||
if (res.status === "ok") {
|
||||
Setting.showMessage("success", i18next.t("general:Successfully modified"));
|
||||
this.getServer();
|
||||
} else {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to update")}: ${res.msg}`);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
clearMcpTool() {
|
||||
const server = Setting.deepCopy(this.state.server);
|
||||
ServerBackend.syncMcpTool(this.state.owner, this.state.serverName, server, true)
|
||||
.then((res) => {
|
||||
if (res.status === "ok") {
|
||||
Setting.showMessage("success", i18next.t("general:Successfully modified"));
|
||||
this.getServer();
|
||||
} else {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to update")}: ${res.msg}`);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
deleteServer() {
|
||||
ServerBackend.deleteServer(this.state.server)
|
||||
.then((res) => {
|
||||
@@ -214,6 +246,8 @@ class ServerEditPage extends React.Component {
|
||||
{Setting.getLabel(i18next.t("general:Tool"), i18next.t("general:Tool - Tooltip"))} :
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
{this.state.mode !== "add" ? <Button type="primary" style={{marginBottom: "5px"}} onClick={() => this.syncMcpTool()}>{i18next.t("general:Sync")}</Button> : null}
|
||||
{this.state.mode !== "add" ? <Button style={{marginBottom: "5px", marginLeft: "10px"}} onClick={() => this.clearMcpTool()}>{i18next.t("general:Clear")}</Button> : null}
|
||||
<ToolTable
|
||||
tools={this.state.server?.tools || []}
|
||||
onUpdateTable={(value) => {this.updateServerField("tools", value);}}
|
||||
|
||||
@@ -21,8 +21,25 @@ import * as ServerBackend from "./backend/ServerBackend";
|
||||
import i18next from "i18next";
|
||||
import BaseListPage from "./BaseListPage";
|
||||
import PopconfirmModal from "./common/modal/PopconfirmModal";
|
||||
import ScanServerModal from "./common/modal/ScanServerModal";
|
||||
|
||||
class ServerListPage extends BaseListPage {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
...this.state,
|
||||
scanLoading: false,
|
||||
scanResult: null,
|
||||
scanServers: [],
|
||||
showScanModal: false,
|
||||
scanFilters: {
|
||||
cidrs: ["127.0.0.1/32"],
|
||||
ports: ["1-65535"],
|
||||
paths: ["/", "/mcp", "/sse", "/mcp/sse"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
newServer() {
|
||||
const randomName = Setting.getRandomName();
|
||||
const owner = Setting.getRequestOrganization(this.props.account);
|
||||
@@ -99,6 +116,93 @@ class ServerListPage extends BaseListPage {
|
||||
});
|
||||
};
|
||||
|
||||
scanIntranetServers = (scanRequest) => {
|
||||
this.setState({scanLoading: true});
|
||||
ServerBackend.syncIntranetServers(scanRequest)
|
||||
.then((res) => {
|
||||
this.setState({scanLoading: false});
|
||||
if (res.status === "ok") {
|
||||
const scanResult = res.data ?? {};
|
||||
const scanServers = scanResult.servers ?? [];
|
||||
this.setState({scanResult: scanResult, scanServers: scanServers});
|
||||
Setting.showMessage("success", `${i18next.t("general:Successfully got")}: ${scanServers.length} server(s)`);
|
||||
} else {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to get")}: ${res.msg}`);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
this.setState({scanLoading: false});
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
|
||||
});
|
||||
};
|
||||
|
||||
openScanModal = () => {
|
||||
this.setState({showScanModal: true});
|
||||
};
|
||||
|
||||
closeScanModal = () => {
|
||||
if (this.state.scanLoading) {
|
||||
return;
|
||||
}
|
||||
this.setState({showScanModal: false});
|
||||
};
|
||||
|
||||
submitScan = () => {
|
||||
const cidr = this.state.scanFilters.cidrs
|
||||
.map(item => item.trim())
|
||||
.filter(item => item !== "");
|
||||
const ports = this.state.scanFilters.ports
|
||||
.map(item => `${item}`.trim())
|
||||
.filter(item => item !== "");
|
||||
const paths = this.state.scanFilters.paths
|
||||
.map(item => item.trim())
|
||||
.filter(item => item !== "");
|
||||
|
||||
if (cidr.length === 0) {
|
||||
Setting.showMessage("error", i18next.t("server:Please select at least one IP range"));
|
||||
return;
|
||||
}
|
||||
if (ports.length === 0) {
|
||||
Setting.showMessage("error", i18next.t("server:Please select at least one port"));
|
||||
return;
|
||||
}
|
||||
|
||||
const invalidPort = ports.find(item => !/^\d+$|^\d+\s*-\s*\d+$/.test(item));
|
||||
if (invalidPort !== undefined) {
|
||||
Setting.showMessage("error", `Invalid port expression: ${invalidPort}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.scanIntranetServers({cidr: cidr, ports: ports, paths: paths});
|
||||
};
|
||||
|
||||
addScannedServer = (scanServer) => {
|
||||
const owner = Setting.getRequestOrganization(this.props.account);
|
||||
const randomName = Setting.getRandomName();
|
||||
const newServer = {
|
||||
owner: owner,
|
||||
name: `server_${randomName}`,
|
||||
createdTime: moment().format(),
|
||||
displayName: `Scanned MCP ${scanServer.host}:${scanServer.port}`,
|
||||
url: scanServer.url,
|
||||
application: "",
|
||||
};
|
||||
|
||||
ServerBackend.addServer(newServer)
|
||||
.then((res) => {
|
||||
if (res.status === "ok") {
|
||||
Setting.showMessage("success", i18next.t("general:Successfully added"));
|
||||
const {pagination} = this.state;
|
||||
this.fetch({pagination});
|
||||
} else {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to add")}: ${res.msg}`);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
|
||||
});
|
||||
};
|
||||
|
||||
renderTable(servers) {
|
||||
const columns = [
|
||||
{
|
||||
@@ -209,11 +313,24 @@ class ServerListPage extends BaseListPage {
|
||||
<div>
|
||||
{i18next.t("server:Edit MCP Server")}
|
||||
<Button type="primary" size="small" onClick={() => this.addServer()}>{i18next.t("general:Add")}</Button>
|
||||
|
||||
<Button size="small" onClick={this.openScanModal}>{i18next.t("server:Scan server")}</Button>
|
||||
|
||||
<Button size="small" onClick={() => this.props.history.push("/server-store")}>{i18next.t("general:MCP Store")}</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<ScanServerModal
|
||||
open={this.state.showScanModal}
|
||||
loading={this.state.scanLoading}
|
||||
scanFilters={this.state.scanFilters}
|
||||
scanResult={this.state.scanResult}
|
||||
scanServers={this.state.scanServers}
|
||||
onSubmit={this.submitScan}
|
||||
onCancel={this.closeScanModal}
|
||||
onChangeScanFilters={(patch) => this.setState(prevState => ({scanFilters: {...prevState.scanFilters, ...patch}}))}
|
||||
onAddScannedServer={this.addScannedServer}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ class ServerStorePage extends React.Component {
|
||||
onlineServerList: [],
|
||||
creatingOnlineServerId: "",
|
||||
onlineNameFilter: "",
|
||||
onlineTagFilter: [],
|
||||
onlineCategoryFilter: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ class ServerStorePage extends React.Component {
|
||||
this.setState({
|
||||
onlineListLoading: true,
|
||||
onlineNameFilter: "",
|
||||
onlineTagFilter: [],
|
||||
onlineCategoryFilter: [],
|
||||
});
|
||||
|
||||
ServerBackend.getOnlineServers()
|
||||
@@ -72,16 +72,17 @@ class ServerStorePage extends React.Component {
|
||||
createServerFromOnline = (onlineServer) => {
|
||||
const owner = Setting.getRequestOrganization(this.props.account);
|
||||
const serverName = this.getOnlineServerName(onlineServer);
|
||||
const serverUrl = onlineServer.production;
|
||||
const serverUrl = onlineServer.endpoint;
|
||||
|
||||
if (!serverUrl) {
|
||||
Setting.showMessage("error", i18next.t("server:Production endpoint is empty"));
|
||||
return;
|
||||
}
|
||||
|
||||
const randomName = Setting.getRandomName();
|
||||
const newServer = {
|
||||
owner: owner,
|
||||
name: serverName,
|
||||
name: serverName + randomName,
|
||||
createdTime: moment().format(),
|
||||
displayName: onlineServer.name || serverName,
|
||||
url: serverUrl,
|
||||
@@ -107,20 +108,27 @@ class ServerStorePage extends React.Component {
|
||||
|
||||
normalizeOnlineServers = (onlineServers) => {
|
||||
return onlineServers.map((server, index) => {
|
||||
const rawTags = Array.isArray(server?.tags) ? server.tags : [];
|
||||
const categoriesRaw = [server?.category].filter((category) => typeof category === "string" && category.trim() !== "");
|
||||
|
||||
return {
|
||||
id: server.id ?? `${server.name ?? "server"}-${index}`,
|
||||
name: server.name ?? "",
|
||||
nameText: (server.name ?? "").toLowerCase(),
|
||||
tagsRaw: rawTags,
|
||||
tagsLower: rawTags.map((tag) => tag.toLowerCase()),
|
||||
production: server.endpoints?.production ?? "",
|
||||
categoriesRaw: categoriesRaw,
|
||||
categoriesLower: categoriesRaw.map((category) => category.toLowerCase()),
|
||||
endpoint: server.endpoints?.production ?? server.endpoint ?? "",
|
||||
description: server.description ?? "",
|
||||
authentication: server?.authentication?.type,
|
||||
website: server?.maintainer?.website,
|
||||
website: server?.maintainer?.website ?? server?.website,
|
||||
};
|
||||
}).filter(server => server.production.startsWith("http"));
|
||||
}).filter(server => server.endpoint.startsWith("http"));
|
||||
};
|
||||
|
||||
getWebsiteUrl = (website) => {
|
||||
if (!website) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return /^https?:\/\//i.test(website) ? website : `https://${website}`;
|
||||
};
|
||||
|
||||
getOnlineServersFromResponse = (data) => {
|
||||
@@ -139,19 +147,19 @@ class ServerStorePage extends React.Component {
|
||||
return [];
|
||||
};
|
||||
|
||||
getOnlineTagOptions = () => {
|
||||
const tags = this.state.onlineServerList.flatMap((server) => server.tagsRaw || []);
|
||||
return [...new Set(tags)].sort((a, b) => a.localeCompare(b)).map((tag) => ({label: tag, value: tag.toLowerCase()}));
|
||||
getOnlineCategoryOptions = () => {
|
||||
const categories = this.state.onlineServerList.flatMap((server) => server.categoriesRaw || []);
|
||||
return [...new Set(categories)].sort((a, b) => a.localeCompare(b)).map((category) => ({label: category, value: category.toLowerCase()}));
|
||||
};
|
||||
|
||||
getFilteredOnlineServers = () => {
|
||||
const nameFilter = this.state.onlineNameFilter.trim().toLowerCase();
|
||||
const tagFilter = this.state.onlineTagFilter;
|
||||
const categoryFilter = this.state.onlineCategoryFilter;
|
||||
|
||||
return this.state.onlineServerList.filter((server) => {
|
||||
const nameMatched = !nameFilter || server.nameText.includes(nameFilter);
|
||||
const tagMatched = tagFilter.length === 0 || tagFilter.some((tag) => server.tagsLower.includes(tag));
|
||||
return nameMatched && tagMatched;
|
||||
const categoryMatched = categoryFilter.length === 0 || categoryFilter.some((category) => server.categoriesLower.includes(category));
|
||||
return nameMatched && categoryMatched;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -180,19 +188,23 @@ class ServerStorePage extends React.Component {
|
||||
<Text type="secondary">{server.description || "-"}</Text>
|
||||
</div>
|
||||
<div style={{marginBottom: "8px"}}>
|
||||
<Text strong>{i18next.t("application:Authentication")}: </Text>
|
||||
<Text>{server.authentication || "-"}</Text>
|
||||
<Text strong>{i18next.t("general:Url")}: </Text>
|
||||
{server.website ? (
|
||||
<a target="_blank" rel="noreferrer" href={this.getWebsiteUrl(server.endpoint)}>{server.endpoint}</a>
|
||||
) : (
|
||||
<Text>-</Text>
|
||||
)}
|
||||
</div>
|
||||
<div style={{marginBottom: "8px"}}>
|
||||
<Text strong>{i18next.t("general:Website")}: </Text>
|
||||
{server.website ? (
|
||||
<a target="_blank" rel="noreferrer" href={`https://${server.website}`}>{server.website}</a>
|
||||
<a target="_blank" rel="noreferrer" href={this.getWebsiteUrl(server.website)}>{server.website}</a>
|
||||
) : (
|
||||
<Text>-</Text>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{(server.tagsRaw || []).map((tag) => <Tag key={`${server.id}-${tag}`}>{tag}</Tag>)}
|
||||
{(server.categoriesRaw || []).map((category) => <Tag key={`${server.id}-${category}`}>{category}</Tag>)}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
@@ -203,7 +215,7 @@ class ServerStorePage extends React.Component {
|
||||
const filteredServers = this.getFilteredOnlineServers();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{padding: "16px"}}>
|
||||
<div style={{display: "flex", gap: "8px", marginBottom: "12px"}}>
|
||||
<Input
|
||||
allowClear
|
||||
@@ -214,13 +226,13 @@ class ServerStorePage extends React.Component {
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
placeholder={i18next.t("general:Tag")}
|
||||
value={this.state.onlineTagFilter}
|
||||
onChange={(values) => this.setState({onlineTagFilter: values})}
|
||||
options={this.getOnlineTagOptions()}
|
||||
placeholder={i18next.t("general:Category")}
|
||||
value={this.state.onlineCategoryFilter}
|
||||
onChange={(values) => this.setState({onlineCategoryFilter: values})}
|
||||
options={this.getOnlineCategoryOptions()}
|
||||
style={{minWidth: "260px"}}
|
||||
/>
|
||||
<Button onClick={() => this.setState({onlineNameFilter: "", onlineTagFilter: []})}>
|
||||
<Button onClick={() => this.setState({onlineNameFilter: "", onlineCategoryFilter: []})}>
|
||||
{i18next.t("general:Clear")}
|
||||
</Button>
|
||||
<Button onClick={this.fetchOnlineServers}>
|
||||
|
||||
@@ -140,6 +140,7 @@ class SessionListPage extends BaseListPage {
|
||||
|
||||
const paginationProps = {
|
||||
total: this.state.pagination.total,
|
||||
pageSize: this.state.pagination.pageSize,
|
||||
showQuickJumper: true,
|
||||
showSizeChanger: true,
|
||||
showTotal: () => i18next.t("general:{total} in total").replace("{total}", this.state.pagination.total),
|
||||
@@ -148,6 +149,11 @@ class SessionListPage extends BaseListPage {
|
||||
return (
|
||||
<div>
|
||||
<Table scroll={{x: "max-content"}} columns={columns} dataSource={sessions} rowKey={(record) => `${record.owner}/${record.name}`} size="middle" bordered pagination={paginationProps}
|
||||
title={() => (
|
||||
<div>
|
||||
{i18next.t("general:Sessions")}
|
||||
</div>
|
||||
)}
|
||||
loading={this.state.loading}
|
||||
onChange={this.handleTableChange}
|
||||
/>
|
||||
|
||||
@@ -456,9 +456,21 @@ export const OtherProviderInfo = {
|
||||
},
|
||||
},
|
||||
Log: {
|
||||
"Linux Syslog": {
|
||||
"Casdoor Permission Log": {
|
||||
logo: `${StaticBaseUrl}/img/social_default.png`,
|
||||
url: "https://man7.org/linux/man-pages/man3/syslog.3.html",
|
||||
url: "https://casdoor.org",
|
||||
},
|
||||
"System Log": {
|
||||
logo: `${StaticBaseUrl}/img/social_default.png`,
|
||||
url: "https://en.wikipedia.org/wiki/Syslog",
|
||||
},
|
||||
"Agent": {
|
||||
logo: `${StaticBaseUrl}/img/social_default.png`,
|
||||
url: "",
|
||||
},
|
||||
"SELinux Log": {
|
||||
logo: `${StaticBaseUrl}/img/social_default.png`,
|
||||
url: "https://github.com/SELinuxProject/selinux",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1427,7 +1439,10 @@ export function getProviderTypeOptions(category) {
|
||||
]);
|
||||
} else if (category === "Log") {
|
||||
return ([
|
||||
{id: "Linux Syslog", name: "Linux Syslog"},
|
||||
{id: "Casdoor Permission Log", name: "Casdoor Permission Log"},
|
||||
{id: "System Log", name: "System Log"},
|
||||
{id: "Agent", name: "Agent"},
|
||||
{id: "SELinux Log", name: "SELinux Log"},
|
||||
]);
|
||||
} else {
|
||||
return [];
|
||||
@@ -1474,6 +1489,43 @@ function isSigninMethodEnabled(application, signinMethod) {
|
||||
}
|
||||
}
|
||||
|
||||
export const CaptchaRule = {
|
||||
Always: "Always",
|
||||
Never: "Never",
|
||||
Dynamic: "Dynamic",
|
||||
InternetOnly: "Internet-Only",
|
||||
};
|
||||
|
||||
export function getCaptchaProviderItems(application) {
|
||||
const providers = application?.providers;
|
||||
if (!providers) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return providers.filter(providerItem => providerItem?.provider?.category === "Captcha");
|
||||
}
|
||||
|
||||
export function getCaptchaRule(application) {
|
||||
const captchaProviderItems = getCaptchaProviderItems(application);
|
||||
if (captchaProviderItems.some(providerItem => providerItem.rule === CaptchaRule.Always)) {
|
||||
return CaptchaRule.Always;
|
||||
} else if (captchaProviderItems.some(providerItem => providerItem.rule === CaptchaRule.Dynamic)) {
|
||||
return CaptchaRule.Dynamic;
|
||||
} else if (captchaProviderItems.some(providerItem => providerItem.rule === CaptchaRule.InternetOnly)) {
|
||||
return CaptchaRule.InternetOnly;
|
||||
}
|
||||
|
||||
return CaptchaRule.Never;
|
||||
}
|
||||
|
||||
export function isInlineCaptchaEnabled(application) {
|
||||
return application?.signinItems?.some(signinItem => signinItem.name === "Captcha" && signinItem.rule === "inline") || false;
|
||||
}
|
||||
|
||||
export function isCaptchaEnabled(application) {
|
||||
return getCaptchaRule(application) !== CaptchaRule.Never;
|
||||
}
|
||||
|
||||
export function isPasswordEnabled(application) {
|
||||
return isSigninMethodEnabled(application, "Password");
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ class SiteListPage extends BaseListPage {
|
||||
title: i18next.t("site:Other domains"),
|
||||
dataIndex: "otherDomains",
|
||||
key: "otherDomains",
|
||||
width: "120px",
|
||||
width: "140px",
|
||||
sorter: (a, b) => a.otherDomains.localeCompare(b.otherDomains),
|
||||
render: (text, record, index) => {
|
||||
return record.otherDomains.map(domain => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user