forked from casdoor/casdoor
Compare commits
53 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 |
@@ -92,9 +92,13 @@ 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, *, *
|
||||
@@ -175,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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
1
main.go
1
main.go
@@ -90,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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -39,30 +39,37 @@ func InitLogProviders() {
|
||||
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) {
|
||||
runningCollectorsMu.Lock()
|
||||
defer runningCollectorsMu.Unlock()
|
||||
|
||||
id := provider.GetId()
|
||||
|
||||
if existing, ok := runningCollectors[id]; ok {
|
||||
_ = existing.Stop()
|
||||
delete(runningCollectors, id)
|
||||
}
|
||||
stopCollector(id)
|
||||
|
||||
lp, err := log.GetLogProvider(provider.Type, provider.Host, provider.Port, provider.Title)
|
||||
if err != nil {
|
||||
@@ -93,21 +100,16 @@ func startLogCollector(provider *Provider) {
|
||||
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) {
|
||||
runningCollectorsMu.Lock()
|
||||
defer runningCollectorsMu.Unlock()
|
||||
|
||||
id := provider.GetId()
|
||||
|
||||
if existing, ok := runningCollectors[id]; ok {
|
||||
_ = existing.Stop()
|
||||
delete(runningCollectors, id)
|
||||
}
|
||||
stopCollector(id)
|
||||
|
||||
lp, err := GetLogProviderFromProvider(provider)
|
||||
if err != nil {
|
||||
@@ -115,15 +117,53 @@ func startOpenClawProvider(provider *Provider) {
|
||||
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 = ?", "Log", "Agent", "OpenClaw").Find(&providers)
|
||||
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
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ package object
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -638,6 +663,11 @@ func GetLogProviderFromProvider(provider *Provider) (log.LogProvider, error) {
|
||||
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{
|
||||
@@ -659,3 +689,43 @@ func GetLogProviderFromProvider(provider *Provider) (log.LogProvider, error) {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -15,247 +15,15 @@
|
||||
package object
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/casdoor/casdoor/i18n"
|
||||
"github.com/casdoor/casdoor/idp"
|
||||
"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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 GetOAuthToken(grantType string, clientId string, clientSecret string, code string, verifier string, scope string, nonce string, username string, password string, host string, refreshToken string, tag string, avatar string, lang string, subjectToken string, subjectTokenType string, assertion string, clientAssertion string, clientAssertionType string, audience string, resource string) (interface{}, error) {
|
||||
func GetOAuthToken(grantType string, clientId string, clientSecret string, code string, verifier string, scope string, nonce string, username string, password string, host string, refreshToken string, tag string, avatar string, lang string, subjectToken string, subjectTokenType string, assertion string, clientAssertion string, clientAssertionType string, audience string, resource string, dpopProof string) (interface{}, error) {
|
||||
var (
|
||||
application *Application
|
||||
err error
|
||||
@@ -292,7 +60,6 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code
|
||||
}
|
||||
|
||||
// Check if grantType is allowed in the current application
|
||||
|
||||
if !IsGrantTypeValid(grantType, application.GrantTypes) && tag == "" {
|
||||
return &TokenError{
|
||||
Error: UnsupportedGrantType,
|
||||
@@ -305,7 +72,7 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code
|
||||
switch grantType {
|
||||
case "authorization_code": // Authorization Code Grant
|
||||
token, tokenError, err = GetAuthorizationCodeToken(application, clientSecret, code, verifier, resource)
|
||||
case "password": // Resource Owner Password Credentials Grant
|
||||
case "password": // Resource Owner Password Credentials Grant
|
||||
token, tokenError, err = GetPasswordToken(application, username, password, scope, host)
|
||||
case "client_credentials": // Client Credentials Grant
|
||||
token, tokenError, err = GetClientCredentialsToken(application, clientSecret, scope, host)
|
||||
@@ -318,7 +85,7 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code
|
||||
case "urn:ietf:params:oauth:grant-type:token-exchange": // Token Exchange Grant (RFC 8693)
|
||||
token, tokenError, err = GetTokenExchangeToken(application, clientSecret, subjectToken, subjectTokenType, audience, scope, host)
|
||||
case "refresh_token":
|
||||
refreshToken2, err := RefreshToken(application, grantType, refreshToken, scope, clientId, clientSecret, host)
|
||||
refreshToken2, err := RefreshToken(application, grantType, refreshToken, scope, clientId, clientSecret, host, dpopProof)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -341,6 +108,23 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code
|
||||
return tokenError, nil
|
||||
}
|
||||
|
||||
// Apply DPoP binding (RFC 9449) if a DPoP proof was supplied by the client.
|
||||
if dpopProof != "" {
|
||||
dpopHtu := GetDPoPHtu(host, "/api/login/oauth/access_token")
|
||||
jkt, dpopErr := ValidateDPoPProof(dpopProof, "POST", dpopHtu, "")
|
||||
if dpopErr != nil {
|
||||
return &TokenError{
|
||||
Error: "invalid_dpop_proof",
|
||||
ErrorDescription: dpopErr.Error(),
|
||||
}, nil
|
||||
}
|
||||
token.TokenType = "DPoP"
|
||||
token.DPoPJkt = jkt
|
||||
if err = updateTokenDPoP(token); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
token.CodeIsUsed = true
|
||||
|
||||
_, err = updateUsedByCode(token)
|
||||
@@ -360,392 +144,7 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code
|
||||
return tokenWrapper, nil
|
||||
}
|
||||
|
||||
func RefreshToken(application *Application, grantType string, refreshToken string, scope string, clientId string, clientSecret string, host string) (interface{}, error) {
|
||||
// check parameters
|
||||
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
|
||||
}
|
||||
|
||||
_, 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
|
||||
}
|
||||
|
||||
// PkceChallenge: base64-URL-encoded SHA256 hash of verifier, per rfc 7636
|
||||
func pkceChallenge(verifier string) string {
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
challenge := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(sum[:])
|
||||
return challenge
|
||||
}
|
||||
|
||||
// IsGrantTypeValid
|
||||
// Check 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
|
||||
}
|
||||
|
||||
// createGuestUserToken creates a new guest user and returns a token for them
|
||||
func createGuestUserToken(application *Application, clientSecret string, verifier string) (*Token, *TokenError, error) {
|
||||
// Verify client secret if provided
|
||||
if clientSecret != "" && application.ClientSecret != clientSecret {
|
||||
return nil, &TokenError{
|
||||
Error: InvalidClient,
|
||||
ErrorDescription: "client_secret is invalid",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Generate a unique guest username
|
||||
guestUsername := generateGuestUsername()
|
||||
|
||||
// Generate a random password for the guest user
|
||||
guestPassword := util.GenerateId()
|
||||
|
||||
// Get organization
|
||||
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
|
||||
}
|
||||
|
||||
// Get initial score
|
||||
initScore, err := organization.GetInitScore()
|
||||
if err != nil {
|
||||
return nil, &TokenError{
|
||||
Error: EndpointError,
|
||||
ErrorDescription: fmt.Sprintf("failed to get init score: %s", err.Error()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Generate a unique user ID within the confines of the application
|
||||
newUserId, idErr := GenerateIdForNewUser(application)
|
||||
if idErr != nil {
|
||||
// If we fail to generate a unique user ID, we can fallback to a random ID
|
||||
newUserId = util.GenerateId()
|
||||
}
|
||||
|
||||
// Create the guest user
|
||||
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),
|
||||
}
|
||||
|
||||
// Add the user
|
||||
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
|
||||
}
|
||||
|
||||
// Extend user with roles and permissions
|
||||
err = ExtendUserWithRolesAndPermissions(guestUser)
|
||||
if err != nil {
|
||||
return nil, &TokenError{
|
||||
Error: EndpointError,
|
||||
ErrorDescription: fmt.Sprintf("failed to extend user: %s", err.Error()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
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
|
||||
}
|
||||
|
||||
// Create token object
|
||||
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())
|
||||
}
|
||||
|
||||
// GetAuthorizationCodeToken
|
||||
// Authorization code flow
|
||||
// GetAuthorizationCodeToken handles the Authorization Code Grant flow.
|
||||
func GetAuthorizationCodeToken(application *Application, clientSecret string, code string, verifier string, resource string) (*Token, *TokenError, error) {
|
||||
if code == "" {
|
||||
return nil, &TokenError{
|
||||
@@ -851,8 +250,7 @@ func GetAuthorizationCodeToken(application *Application, clientSecret string, co
|
||||
return token, nil, nil
|
||||
}
|
||||
|
||||
// GetPasswordToken
|
||||
// Resource Owner Password Credentials flow
|
||||
// GetPasswordToken handles the Resource Owner Password Credentials Grant flow.
|
||||
func GetPasswordToken(application *Application, username string, password string, scope string, host string) (*Token, *TokenError, error) {
|
||||
expandedScope, ok := IsScopeValidAndExpand(scope, application)
|
||||
if !ok {
|
||||
@@ -935,8 +333,7 @@ func GetPasswordToken(application *Application, username string, password string
|
||||
return token, nil, nil
|
||||
}
|
||||
|
||||
// GetClientCredentialsToken
|
||||
// Client Credentials flow
|
||||
// GetClientCredentialsToken handles the Client Credentials Grant flow.
|
||||
func GetClientCredentialsToken(application *Application, clientSecret string, scope string, host string) (*Token, *TokenError, error) {
|
||||
if application.ClientSecret != clientSecret {
|
||||
return nil, &TokenError{
|
||||
@@ -988,44 +385,7 @@ func GetClientCredentialsToken(application *Application, clientSecret string, sc
|
||||
return token, nil, 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
|
||||
}
|
||||
|
||||
// GetImplicitToken
|
||||
// Implicit flow - requires password verification before minting a token
|
||||
// GetImplicitToken handles the Implicit Grant flow (requires password verification).
|
||||
func GetImplicitToken(application *Application, username string, password string, scope string, nonce string, host string) (*Token, *TokenError, error) {
|
||||
user, err := GetUserByFields(application.Organization, username)
|
||||
if err != nil {
|
||||
@@ -1059,8 +419,7 @@ func GetImplicitToken(application *Application, username string, password string
|
||||
return mintImplicitToken(application, username, scope, nonce, host)
|
||||
}
|
||||
|
||||
// GetJwtBearerToken
|
||||
// RFC 7523
|
||||
// GetJwtBearerToken handles the JWT Bearer Grant flow (RFC 7523).
|
||||
func GetJwtBearerToken(application *Application, assertion string, scope string, nonce string, host string) (*Token, *TokenError, error) {
|
||||
ok, claims, err := ValidateJwtAssertion(assertion, application, host)
|
||||
if err != nil || !ok {
|
||||
@@ -1081,65 +440,7 @@ func GetJwtBearerToken(application *Application, assertion string, scope string,
|
||||
return mintImplicitToken(application, claims.Subject, scope, nonce, host)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// GetTokenByUser
|
||||
// Implicit flow
|
||||
// GetTokenByUser mints a token for the given user (Implicit flow helper).
|
||||
func GetTokenByUser(application *Application, user *User, scope string, nonce string, host string) (*Token, error) {
|
||||
err := ExtendUserWithRolesAndPermissions(user)
|
||||
if err != nil {
|
||||
@@ -1174,8 +475,7 @@ func GetTokenByUser(application *Application, user *User, scope string, nonce st
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// GetWechatMiniProgramToken
|
||||
// Wechat Mini Program flow
|
||||
// GetWechatMiniProgramToken handles the WeChat Mini Program flow.
|
||||
func GetWechatMiniProgramToken(application *Application, code string, host string, username string, avatar string, lang string) (*Token, *TokenError, error) {
|
||||
mpProvider := GetWechatMiniProgramProvider(application)
|
||||
if mpProvider == nil {
|
||||
@@ -1290,72 +590,9 @@ func GetWechatMiniProgramToken(application *Application, code string, host strin
|
||||
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
|
||||
}
|
||||
|
||||
// GetTokenExchangeToken
|
||||
// Token Exchange Grant (RFC 8693)
|
||||
// Exchanges a subject token for a new token with different audience or scope
|
||||
// GetTokenExchangeToken handles the Token Exchange Grant flow (RFC 8693).
|
||||
// Exchanges a subject token for a new token with different audience or scope.
|
||||
func GetTokenExchangeToken(application *Application, clientSecret string, subjectToken string, subjectTokenType string, audience string, scope string, host string) (*Token, *TokenError, error) {
|
||||
// Verify client secret
|
||||
if application.ClientSecret != clientSecret {
|
||||
return nil, &TokenError{
|
||||
Error: InvalidClient,
|
||||
@@ -1363,7 +600,6 @@ func GetTokenExchangeToken(application *Application, clientSecret string, subjec
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validate subject_token parameter
|
||||
if subjectToken == "" {
|
||||
return nil, &TokenError{
|
||||
Error: InvalidRequest,
|
||||
@@ -1371,13 +607,11 @@ func GetTokenExchangeToken(application *Application, clientSecret string, subjec
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validate subject_token_type parameter
|
||||
// RFC 8693 defines standard token type identifiers
|
||||
if subjectTokenType == "" {
|
||||
subjectTokenType = "urn:ietf:params:oauth:token-type:access_token" // Default to access_token
|
||||
}
|
||||
|
||||
// Support common token types
|
||||
supportedTokenTypes := []string{
|
||||
"urn:ietf:params:oauth:token-type:access_token",
|
||||
"urn:ietf:params:oauth:token-type:jwt",
|
||||
@@ -1407,7 +641,6 @@ func GetTokenExchangeToken(application *Application, clientSecret string, subjec
|
||||
return nil, tokenError, nil
|
||||
}
|
||||
|
||||
// Get the user from the subject token
|
||||
user, err := getUser(subjectOwner, subjectName)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -1426,20 +659,17 @@ func GetTokenExchangeToken(application *Application, clientSecret string, subjec
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Handle scope parameter
|
||||
// If scope is not provided, use the scope from the subject token
|
||||
// If scope is provided, it should be a subset of the subject token's scope (downscoping)
|
||||
// If scope is not provided, use the scope from the subject token.
|
||||
// If scope is provided, it should be a subset of the subject token's scope (downscoping).
|
||||
if scope == "" {
|
||||
scope = subjectScope
|
||||
} else {
|
||||
// Validate scope downscoping (basic implementation)
|
||||
// In a production environment, you would implement more sophisticated scope validation
|
||||
if subjectScope != "" {
|
||||
subjectScopes := strings.Split(subjectScope, " ")
|
||||
requestedScopes := strings.Split(scope, " ")
|
||||
for _, requestedScope := range requestedScopes {
|
||||
if requestedScope == "" {
|
||||
continue // Skip empty strings
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, existingScope := range subjectScopes {
|
||||
@@ -1458,13 +688,11 @@ func GetTokenExchangeToken(application *Application, clientSecret string, subjec
|
||||
}
|
||||
}
|
||||
|
||||
// Extend user with roles and permissions
|
||||
err = ExtendUserWithRolesAndPermissions(user)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Generate new JWT token
|
||||
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, "", "", "", scope, "", host)
|
||||
if err != nil {
|
||||
return nil, &TokenError{
|
||||
@@ -1473,7 +701,6 @@ func GetTokenExchangeToken(application *Application, clientSecret string, subjec
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Create token object
|
||||
token := &Token{
|
||||
Owner: application.Owner,
|
||||
Name: tokenName,
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -347,6 +360,9 @@ func writePermissionLog(objOwner, subOwner, subName, method, urlPath string, all
|
||||
if provider.Type == "System Log" {
|
||||
continue
|
||||
}
|
||||
if provider.State == "Disabled" {
|
||||
continue
|
||||
}
|
||||
logProvider, err := object.GetLogProviderFromProvider(provider)
|
||||
if err != nil {
|
||||
continue
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -145,6 +145,7 @@ func InitAPI() {
|
||||
|
||||
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")
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,3 +381,25 @@ func StringToInterfaceArray2d(arrays [][]string) [][]interface{} {
|
||||
}
|
||||
return interfaceArrays
|
||||
}
|
||||
|
||||
// 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 result
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ 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) {
|
||||
@@ -491,6 +493,7 @@ class EntryMessageViewer extends React.Component {
|
||||
}
|
||||
|
||||
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()} />;
|
||||
@@ -498,6 +501,9 @@ class EntryMessageViewer extends React.Component {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -114,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) {
|
||||
@@ -770,6 +781,7 @@ class ProviderEditPage extends React.Component {
|
||||
this.updateProviderField("host", "");
|
||||
this.updateProviderField("port", 0);
|
||||
this.updateProviderField("title", "");
|
||||
this.updateProviderField("state", "Enabled");
|
||||
}
|
||||
if (defaultType) {
|
||||
if (this.state.nameNotUserEdited) {
|
||||
@@ -1079,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) {
|
||||
@@ -1095,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 => {
|
||||
@@ -1104,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() {
|
||||
|
||||
@@ -57,18 +57,7 @@ class ProviderListPage extends BaseListPage {
|
||||
|
||||
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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -215,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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -162,7 +162,7 @@ class SyncerListPage extends BaseListPage {
|
||||
title: i18next.t("syncer:Database type"),
|
||||
dataIndex: "databaseType",
|
||||
key: "databaseType",
|
||||
width: "130px",
|
||||
width: "140px",
|
||||
sorter: (a, b) => a.databaseType.localeCompare(b.databaseType),
|
||||
},
|
||||
{
|
||||
@@ -215,7 +215,7 @@ class SyncerListPage extends BaseListPage {
|
||||
title: i18next.t("syncer:Sync interval"),
|
||||
dataIndex: "syncInterval",
|
||||
key: "syncInterval",
|
||||
width: "140px",
|
||||
width: "150px",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("syncInterval"),
|
||||
},
|
||||
|
||||
@@ -109,7 +109,7 @@ class TokenListPage extends BaseListPage {
|
||||
title: i18next.t("general:Application"),
|
||||
dataIndex: "application",
|
||||
key: "application",
|
||||
width: "120px",
|
||||
width: "130px",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("application"),
|
||||
render: (text, record, index) => {
|
||||
@@ -124,7 +124,7 @@ class TokenListPage extends BaseListPage {
|
||||
title: i18next.t("general:Organization"),
|
||||
dataIndex: "organization",
|
||||
key: "organization",
|
||||
width: "120px",
|
||||
width: "140px",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("organization"),
|
||||
render: (text, record, index) => {
|
||||
|
||||
@@ -396,7 +396,7 @@ class UserListPage extends BaseListPage {
|
||||
title: i18next.t("application:Real name"),
|
||||
dataIndex: "realName",
|
||||
key: "realName",
|
||||
width: "120px",
|
||||
width: "130px",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("realName"),
|
||||
},
|
||||
@@ -464,7 +464,7 @@ class UserListPage extends BaseListPage {
|
||||
title: i18next.t("user:Register source"),
|
||||
dataIndex: "registerSource",
|
||||
key: "registerSource",
|
||||
width: "150px",
|
||||
width: "160px",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("registerSource"),
|
||||
},
|
||||
@@ -482,7 +482,7 @@ class UserListPage 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;
|
||||
@@ -492,7 +492,7 @@ class UserListPage 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";
|
||||
@@ -514,7 +514,7 @@ class UserListPage extends BaseListPage {
|
||||
title: i18next.t("user:Is forbidden"),
|
||||
dataIndex: "isForbidden",
|
||||
key: "isForbidden",
|
||||
width: "110px",
|
||||
width: "120px",
|
||||
sorter: true,
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
@@ -545,9 +545,9 @@ class UserListPage extends BaseListPage {
|
||||
const disabled = (record.owner === this.props.account.owner && record.name === this.props.account.name) || (record.owner === "built-in" && record.name === "admin");
|
||||
return (
|
||||
<Space>
|
||||
<Button size={isTreePage ? "small" : "middle"} type="primary" onClick={() => {
|
||||
<Button size={isTreePage ? "small" : "middle"} onClick={() => {
|
||||
this.impersonateUser(`${record.owner}/${record.name}`);
|
||||
}}>{i18next.t("general:Impersonation")}
|
||||
}}>{i18next.t("general:Impersonate")}
|
||||
</Button>
|
||||
<Button size={isTreePage ? "small" : "middle"} type="primary" onClick={() => {
|
||||
sessionStorage.setItem("userListUrl", window.location.pathname);
|
||||
|
||||
@@ -142,7 +142,7 @@ class VerificationListPage extends BaseListPage {
|
||||
title: i18next.t("login:Verification code"),
|
||||
dataIndex: "code",
|
||||
key: "code",
|
||||
width: "150px",
|
||||
width: "160px",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("code"),
|
||||
},
|
||||
|
||||
@@ -148,7 +148,7 @@ class WebhookListPage extends BaseListPage {
|
||||
title: i18next.t("webhook:Content type"),
|
||||
dataIndex: "contentType",
|
||||
key: "contentType",
|
||||
width: "140px",
|
||||
width: "150px",
|
||||
sorter: true,
|
||||
filterMultiple: false,
|
||||
filters: [
|
||||
@@ -171,7 +171,7 @@ class WebhookListPage extends BaseListPage {
|
||||
title: i18next.t("webhook:Is user extended"),
|
||||
dataIndex: "isUserExtended",
|
||||
key: "isUserExtended",
|
||||
width: "140px",
|
||||
width: "150px",
|
||||
sorter: true,
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
|
||||
@@ -926,7 +926,7 @@ class LoginPage extends React.Component {
|
||||
{
|
||||
application.providers.filter(providerItem => this.isProviderVisible(providerItem)).map((providerItem, id) => {
|
||||
if (providerHint === providerItem.provider.name) {
|
||||
goToLink(Provider.getAuthUrl(application, providerItem.provider, "signup"));
|
||||
goToLink(Provider.getAuthUrl(application, providerItem.provider, this.state.mode ?? "signup"));
|
||||
return;
|
||||
}
|
||||
return (
|
||||
@@ -939,7 +939,7 @@ class LoginPage extends React.Component {
|
||||
}
|
||||
}}>
|
||||
{
|
||||
ProviderButton.renderProviderLogo(providerItem.provider, application, null, null, signinItem.rule, this.props.location)
|
||||
ProviderButton.renderProviderLogo(providerItem.provider, application, null, null, signinItem.rule, this.props.location, this.state.mode ?? "signup")
|
||||
}
|
||||
</span>
|
||||
);
|
||||
@@ -1550,7 +1550,7 @@ class LoginPage extends React.Component {
|
||||
|
||||
const visibleOAuthProviderItems = (application.providers === null) ? [] : application.providers.filter(providerItem => this.isProviderVisible(providerItem) && providerItem.provider?.category !== "SAML");
|
||||
if (this.props.preview !== "auto" && !Setting.isPasswordEnabled(application) && !Setting.isCodeSigninEnabled(application) && !Setting.isWebAuthnEnabled(application) && !Setting.isLdapEnabled(application) && visibleOAuthProviderItems.length === 1) {
|
||||
Setting.goToLink(Provider.getAuthUrl(application, visibleOAuthProviderItems[0].provider, "signup"));
|
||||
Setting.goToLink(Provider.getAuthUrl(application, visibleOAuthProviderItems[0].provider, this.state.mode ?? "signup"));
|
||||
return (
|
||||
<div style={{display: "flex", justifyContent: "center", alignItems: "center", width: "100%"}}>
|
||||
<Spin size="large" tip={i18next.t("login:Signing in...")} />
|
||||
|
||||
@@ -143,20 +143,20 @@ export function goToWeb3Url(application, provider, method) {
|
||||
}
|
||||
}
|
||||
|
||||
export function renderProviderLogo(provider, application, width, margin, size, location) {
|
||||
export function renderProviderLogo(provider, application, width, margin, size, location, method = "signup") {
|
||||
if (size === "small") {
|
||||
if (provider.category === "OAuth") {
|
||||
if (provider.type === "WeChat" && provider.clientId2 !== "" && provider.clientSecret2 !== "" && provider.disableSsl === true && !navigator.userAgent.includes("MicroMessenger")) {
|
||||
return (
|
||||
<a key={provider.displayName} >
|
||||
<img width={width} height={width} src={getProviderLogoURL(provider)} alt={provider.displayName} className="provider-img" style={{margin: margin}} onClick={() => {
|
||||
WechatOfficialAccountModal(application, provider, "signup");
|
||||
WechatOfficialAccountModal(application, provider, method);
|
||||
}} />
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<a key={provider.displayName} href={Provider.getAuthUrl(application, provider, "signup")}>
|
||||
<a key={provider.displayName} href={Provider.getAuthUrl(application, provider, method)}>
|
||||
<img width={width} height={width} src={getProviderLogoURL(provider)} alt={provider.displayName} className="provider-img" style={{margin: margin}} />
|
||||
</a>
|
||||
);
|
||||
@@ -169,7 +169,7 @@ export function renderProviderLogo(provider, application, width, margin, size, l
|
||||
);
|
||||
} else if (provider.category === "Web3") {
|
||||
return (
|
||||
<a key={provider.displayName} onClick={() => goToWeb3Url(application, provider, "signup")}>
|
||||
<a key={provider.displayName} onClick={() => goToWeb3Url(application, provider, method)}>
|
||||
<img width={width} height={width} src={getProviderLogoURL(provider)} alt={provider.displayName} className="provider-img" style={{margin: margin}} />
|
||||
</a>
|
||||
);
|
||||
@@ -183,7 +183,7 @@ export function renderProviderLogo(provider, application, width, margin, size, l
|
||||
const customSpanStyle = {textAlign: "center", width: "100%", fontSize: "19px"};
|
||||
if (provider.category === "OAuth") {
|
||||
return (
|
||||
<a key={provider.displayName} href={Provider.getAuthUrl(application, provider, "signup")} style={customAStyle}>
|
||||
<a key={provider.displayName} href={Provider.getAuthUrl(application, provider, method)} style={customAStyle}>
|
||||
<div style={customButtonStyle}>
|
||||
<img width={26} src={getProviderLogoURL(provider)} alt={provider.displayName} className="provider-img" style={customImgStyle} />
|
||||
<span style={customSpanStyle}>{text}</span>
|
||||
@@ -215,7 +215,7 @@ export function renderProviderLogo(provider, application, width, margin, size, l
|
||||
} else if (provider.category === "Web3") {
|
||||
return (
|
||||
<div key={provider.displayName} className="provider-big-img">
|
||||
<a onClick={() => goToWeb3Url(application, provider, "signup")}>
|
||||
<a onClick={() => goToWeb3Url(application, provider, method)}>
|
||||
{
|
||||
getSigninButton(provider)
|
||||
}
|
||||
@@ -225,7 +225,7 @@ export function renderProviderLogo(provider, application, width, margin, size, l
|
||||
} else {
|
||||
return (
|
||||
<div key={provider.displayName} className="provider-big-img">
|
||||
<a href={Provider.getAuthUrl(application, provider, "signup")}>
|
||||
<a href={Provider.getAuthUrl(application, provider, method)}>
|
||||
{
|
||||
getSigninButton(provider)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,13 @@ export function getEntry(owner, name) {
|
||||
}).then(res => res.json());
|
||||
}
|
||||
|
||||
export function getOpenClawSessionGraph(owner, name) {
|
||||
return fetch(`${Setting.ServerUrl}/api/get-openclaw-session-graph?id=${owner}/${encodeURIComponent(name)}`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
}).then(res => res.json());
|
||||
}
|
||||
|
||||
export function updateEntry(owner, name, entry) {
|
||||
const newEntry = Setting.deepCopy(entry);
|
||||
return fetch(`${Setting.ServerUrl}/api/update-entry?id=${owner}/${encodeURIComponent(name)}`, {
|
||||
|
||||
@@ -122,8 +122,8 @@ const Dashboard = (props) => {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div style={{display: "flex", justifyContent: "center", alignItems: "center"}}>
|
||||
<Spin size="large" tip={i18next.t("login:Loading")} style={{paddingTop: "10%"}} />
|
||||
<div style={{display: "flex", justifyContent: "center", alignItems: "center", height: "calc(100vh - 120px)"}}>
|
||||
<Spin size="large" tip={i18next.t("login:Loading")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ const ShortcutsPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{display: "flex", justifyContent: "center", flexDirection: "column", alignItems: "center"}}>
|
||||
<div style={{display: "flex", justifyContent: "center", flexDirection: "column", alignItems: "center", padding: "16px"}}>
|
||||
<GridCards items={getItems()} />
|
||||
</div>
|
||||
);
|
||||
|
||||
109
web/src/common/BreadcrumbBar.js
Normal file
109
web/src/common/BreadcrumbBar.js
Normal file
@@ -0,0 +1,109 @@
|
||||
// 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 {Breadcrumb} from "antd";
|
||||
import {Link} from "react-router-dom";
|
||||
import i18next from "i18next";
|
||||
|
||||
const RESOURCE_LABELS = {
|
||||
"apps": "general:Apps",
|
||||
"shortcuts": "general:Shortcuts",
|
||||
"account": "account:My Account",
|
||||
"organizations": "general:Organizations",
|
||||
"users": "general:Users",
|
||||
"groups": "general:Groups",
|
||||
"trees": "general:Groups",
|
||||
"invitations": "general:Invitations",
|
||||
"applications": "general:Applications",
|
||||
"providers": "application:Providers",
|
||||
"resources": "general:Resources",
|
||||
"certs": "general:Certs",
|
||||
"keys": "general:Keys",
|
||||
"agents": "general:Agents",
|
||||
"servers": "general:MCP Servers",
|
||||
"server-store": "general:MCP Store",
|
||||
"entries": "general:Entries",
|
||||
"sites": "general:Sites",
|
||||
"rules": "general:Rules",
|
||||
"roles": "general:Roles",
|
||||
"permissions": "general:Permissions",
|
||||
"models": "general:Models",
|
||||
"adapters": "general:Adapters",
|
||||
"enforcers": "general:Enforcers",
|
||||
"sessions": "general:Sessions",
|
||||
"records": "general:Records",
|
||||
"tokens": "general:Tokens",
|
||||
"verifications": "general:Verifications",
|
||||
"product-store": "general:Product Store",
|
||||
"products": "general:Products",
|
||||
"cart": "general:Cart",
|
||||
"orders": "general:Orders",
|
||||
"payments": "general:Payments",
|
||||
"plans": "general:Plans",
|
||||
"pricings": "general:Pricings",
|
||||
"subscriptions": "general:Subscriptions",
|
||||
"transactions": "general:Transactions",
|
||||
"sysinfo": "general:System Info",
|
||||
"forms": "general:Forms",
|
||||
"syncers": "general:Syncers",
|
||||
"webhooks": "general:Webhooks",
|
||||
"webhook-events": "general:Webhook Events",
|
||||
"tickets": "general:Tickets",
|
||||
"ldap": "general:LDAP",
|
||||
"mfa": "general:MFA",
|
||||
};
|
||||
|
||||
function buildBreadcrumbItems(uri) {
|
||||
const pathSegments = (uri || "").split("/").filter(Boolean);
|
||||
|
||||
const homeItem = {title: <Link to="/">{i18next.t("general:Home")}</Link>};
|
||||
|
||||
if (pathSegments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootSegment = pathSegments[0];
|
||||
const listLabelKey = RESOURCE_LABELS[rootSegment];
|
||||
if (!listLabelKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pathSegments.length === 1) {
|
||||
return [
|
||||
homeItem,
|
||||
{title: i18next.t(listLabelKey)},
|
||||
];
|
||||
}
|
||||
|
||||
const lastSegment = pathSegments[pathSegments.length - 1];
|
||||
const lastLabelKey = RESOURCE_LABELS[lastSegment];
|
||||
const lastLabel = lastLabelKey ? i18next.t(lastLabelKey) : lastSegment;
|
||||
|
||||
return [
|
||||
homeItem,
|
||||
{title: <Link to={`/${rootSegment}`}>{i18next.t(listLabelKey)}</Link>},
|
||||
{title: lastLabel},
|
||||
];
|
||||
}
|
||||
|
||||
const BreadcrumbBar = ({uri}) => {
|
||||
const items = buildBreadcrumbItems(uri);
|
||||
if (!items) {
|
||||
return null;
|
||||
}
|
||||
return <Breadcrumb items={items} style={{marginLeft: 8}} />;
|
||||
};
|
||||
|
||||
export default BreadcrumbBar;
|
||||
@@ -61,7 +61,7 @@ export const NavItemTree = ({disabled, checkedKeys, defaultExpandedKeys, onCheck
|
||||
],
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:Logging & Auditing"),
|
||||
title: i18next.t("general:Auditing"),
|
||||
key: "/sessions-top",
|
||||
children: [
|
||||
{title: i18next.t("general:Sessions"), key: "/sessions"},
|
||||
@@ -71,7 +71,7 @@ export const NavItemTree = ({disabled, checkedKeys, defaultExpandedKeys, onCheck
|
||||
],
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:Business & Payments"),
|
||||
title: i18next.t("general:Business"),
|
||||
key: "/business-top",
|
||||
children: [
|
||||
{title: i18next.t("general:Products"), key: "/products"},
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import React from "react";
|
||||
import {Tooltip} from "antd";
|
||||
import {QuestionCircleOutlined} from "@ant-design/icons";
|
||||
import i18next from "i18next";
|
||||
import * as TourConfig from "../TourConfig";
|
||||
import * as Setting from "../Setting";
|
||||
|
||||
@@ -34,7 +35,7 @@ class OpenTour extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
this.canTour() ?
|
||||
<Tooltip title="Click to open tour">
|
||||
<Tooltip title={i18next.t("general:Click to open tour")}>
|
||||
<div className="select-box" style={{display: Setting.isMobile() ? "none" : null, ...this.props.style}} onClick={() => TourConfig.setIsTourVisible(true)} >
|
||||
<QuestionCircleOutlined style={{fontSize: "24px"}} />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
|
||||
|
||||
html {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari/WebKit */
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
"Inter",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
@@ -32,13 +44,19 @@ code {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.ant-table.ant-table-middle .ant-table-title,
|
||||
.ant-table.ant-table-middle .ant-table-footer,
|
||||
.ant-table.ant-table-middle thead > tr > th,
|
||||
.ant-table.ant-table-middle tbody > tr > td {
|
||||
.ant-table.ant-table-medium .ant-table-title {
|
||||
padding: 5px 8px !important;
|
||||
}
|
||||
|
||||
.ant-table.ant-table-medium .ant-table-footer,
|
||||
.ant-table.ant-table-medium .ant-table-cell {
|
||||
padding: 1px 8px !important;
|
||||
}
|
||||
|
||||
.ant-table.ant-table-medium .ant-table-thead .ant-table-cell {
|
||||
padding: 10px 8px !important;
|
||||
}
|
||||
|
||||
.ant-list-sm .ant-list-item {
|
||||
padding: 2px !important;
|
||||
}
|
||||
@@ -67,3 +85,83 @@ code {
|
||||
.no-horizontal-scroll-editor [class*="CodeMirror-scroll"] {
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
/* Bold overrides for key UI elements */
|
||||
|
||||
/* Sidebar menu: item labels */
|
||||
.ant-menu .ant-menu-item,
|
||||
.ant-menu .ant-menu-item a,
|
||||
.ant-menu .ant-menu-submenu-title {
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
/* Sidebar menu: group titles */
|
||||
.ant-menu .ant-menu-item-group-title {
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* Table: column headers (thead) */
|
||||
.ant-table .ant-table-thead > tr > th,
|
||||
.ant-table .ant-table-thead > tr > td {
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
/* Table: footer */
|
||||
.ant-table .ant-table-footer {
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
/* Table: title bar */
|
||||
.ant-table .ant-table-title {
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
/* Card: header title */
|
||||
.ant-card .ant-card-head-title {
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
/* Descriptions: item labels */
|
||||
.ant-descriptions .ant-descriptions-item-label {
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
/* Tabs: tab button labels */
|
||||
.ant-tabs .ant-tabs-tab .ant-tabs-tab-btn {
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
/* Layout sider: menu item and submenu labels */
|
||||
.ant-layout-sider .ant-menu-item,
|
||||
.ant-layout-sider .ant-menu-submenu-title {
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
/* Table loading spinner - give the overlay enough height so spinner appears centered (antd v6) */
|
||||
.ant-table-wrapper .ant-spin.ant-spin-spinning {
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
/* Sidebar menu: selected item - darker background */
|
||||
.ant-layout-sider .ant-menu-light .ant-menu-item-selected,
|
||||
.ant-layout-sider .ant-menu-light>.ant-menu .ant-menu-item-selected {
|
||||
background-color: rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
/* Sidebar menu container: hide scrollbar but keep scroll behavior */
|
||||
.sider-menu-container {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
}
|
||||
.sider-menu-container::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari/WebKit */
|
||||
}
|
||||
|
||||
/* Message popups: static API renders outside ConfigProvider, override font explicitly */
|
||||
.ant-message,
|
||||
.ant-message .ant-message-notice-content,
|
||||
.ant-message .ant-message-custom-content,
|
||||
.ant-message .ant-message-custom-content span {
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif !important;
|
||||
}
|
||||
|
||||
@@ -284,8 +284,8 @@
|
||||
"Cancel": "取消",
|
||||
"Captcha": "人机验证码",
|
||||
"Cart": "购物车",
|
||||
"Category": "Category",
|
||||
"Category - Tooltip": "Category - Tooltip",
|
||||
"Category": "类别",
|
||||
"Category - Tooltip": "所属的类别",
|
||||
"Cert": "证书",
|
||||
"Cert - Tooltip": "该应用所对应的客户端SDK需要验证的公钥证书",
|
||||
"Certs": "证书",
|
||||
@@ -347,14 +347,14 @@
|
||||
"Failed to upload": "上传失败",
|
||||
"Failed to verify": "验证失败",
|
||||
"False": "假",
|
||||
"Favicon": "组织Favicon",
|
||||
"Favicon - Tooltip": "该组织所有Casdoor页面中所使用的Favicon图标URL",
|
||||
"Favicon": "图标",
|
||||
"Favicon - Tooltip": "该组织所有Casdoor页面中所使用的Favicon图标链接",
|
||||
"Filter": "筛选",
|
||||
"First name": "名字",
|
||||
"First name - Tooltip": "用户的名字",
|
||||
"Forced redirect origin - Tooltip": "强制重定向到指定的来源",
|
||||
"Forget URL": "忘记密码URL",
|
||||
"Forget URL - Tooltip": "自定义忘记密码页面的URL,不设置时采用Casdoor默认的忘记密码页面,设置后Casdoor各类页面的忘记密码链接会跳转到该URL",
|
||||
"Forget URL": "忘记密码链接",
|
||||
"Forget URL - Tooltip": "自定义忘记密码页面的链接,不设置时采用Casdoor默认的忘记密码页面,设置后Casdoor各类页面的忘记密码链接会跳转到该URL",
|
||||
"Forms": "表单",
|
||||
"Found some texts still not translated? Please help us translate at": "发现有些文字尚未翻译?请移步这里帮我们翻译:",
|
||||
"Generate": "生成",
|
||||
|
||||
@@ -13,24 +13,62 @@
|
||||
// limitations under the License.
|
||||
|
||||
import React from "react";
|
||||
import {Col, Input, Row} from "antd";
|
||||
import {Col, Input, Row, Select} from "antd";
|
||||
import * as Setting from "../Setting";
|
||||
import i18next from "i18next";
|
||||
|
||||
const {Option} = Select;
|
||||
|
||||
export function renderLogProviderFields(provider, updateProviderField) {
|
||||
if (provider.type === "Agent" && provider.subType === "OpenClaw") {
|
||||
return (
|
||||
return (
|
||||
<React.Fragment>
|
||||
{provider.type === "Agent" && provider.subType === "OpenClaw" ? (
|
||||
<React.Fragment>
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{Setting.getLabel(i18next.t("provider:Host"), i18next.t("provider:Host - Tooltip"))} :
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
<Input value={provider.host} onChange={e => {
|
||||
updateProviderField("host", e.target.value);
|
||||
}} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{Setting.getLabel(i18next.t("provider:Agent ID"), i18next.t("provider:Agent ID - Tooltip"))} :
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
<Input value={provider.title} onChange={e => {
|
||||
updateProviderField("title", e.target.value);
|
||||
}} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{i18next.t("general:Path")} :
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
<Input value={provider.endpoint} onChange={e => {
|
||||
updateProviderField("endpoint", e.target.value);
|
||||
}} />
|
||||
</Col>
|
||||
</Row>
|
||||
</React.Fragment>
|
||||
) : null}
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{Setting.getLabel(i18next.t("provider:Host"), i18next.t("provider:Host - Tooltip"))} :
|
||||
{Setting.getLabel(i18next.t("general:State"), i18next.t("general:State - Tooltip"))} :
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
<Input value={provider.host} placeholder="e.g. 192.168.1.100" onChange={e => {
|
||||
updateProviderField("host", e.target.value);
|
||||
}} />
|
||||
<Select virtual={false} style={{width: "100%"}} value={provider.state || "Enabled"} onChange={value => {
|
||||
updateProviderField("state", value);
|
||||
}}>
|
||||
<Option value="Enabled">{i18next.t("general:Enabled")}</Option>
|
||||
<Option value="Disabled">{i18next.t("general:Disabled")}</Option>
|
||||
</Select>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,6 +164,16 @@ export function renderOAuthProviderFields(provider, updateProviderField, renderU
|
||||
}} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{Setting.getLabel(i18next.t("provider:Logout URL"), i18next.t("provider:Logout URL - Tooltip"))}
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
<Input value={provider.customLogoutUrl} onChange={e => {
|
||||
updateProviderField("customLogoutUrl", e.target.value);
|
||||
}} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{Setting.getLabel(i18next.t("provider:Enable PKCE"), i18next.t("provider:Enable PKCE - Tooltip"))} :
|
||||
|
||||
168
web/src/shadcnTheme.js
Normal file
168
web/src/shadcnTheme.js
Normal file
@@ -0,0 +1,168 @@
|
||||
// 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.
|
||||
|
||||
// Shadcn-style Ant Design theme configuration.
|
||||
// Adapted from the "shadcn" preset on https://ant.design/
|
||||
|
||||
export const shadcnThemeToken = {
|
||||
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
|
||||
colorPrimary: "#262626",
|
||||
colorSuccess: "#22c55e",
|
||||
colorWarning: "#f97316",
|
||||
colorError: "#ef4444",
|
||||
colorInfo: "#262626",
|
||||
colorTextBase: "#262626",
|
||||
colorBgBase: "#ffffff",
|
||||
colorPrimaryBg: "#f5f5f5",
|
||||
colorPrimaryBgHover: "#e5e5e5",
|
||||
colorPrimaryBorder: "#d4d4d4",
|
||||
colorPrimaryBorderHover: "#a3a3a3",
|
||||
colorPrimaryHover: "#404040",
|
||||
colorPrimaryActive: "#171717",
|
||||
colorPrimaryText: "#262626",
|
||||
colorPrimaryTextHover: "#404040",
|
||||
colorPrimaryTextActive: "#171717",
|
||||
colorSuccessBg: "#f0fdf4",
|
||||
colorSuccessBgHover: "#dcfce7",
|
||||
colorSuccessBorder: "#bbf7d0",
|
||||
colorSuccessBorderHover: "#86efac",
|
||||
colorSuccessHover: "#16a34a",
|
||||
colorSuccessActive: "#15803d",
|
||||
colorSuccessText: "#16a34a",
|
||||
colorSuccessTextHover: "#16a34a",
|
||||
colorSuccessTextActive: "#15803d",
|
||||
colorWarningBg: "#fff7ed",
|
||||
colorWarningBgHover: "#fed7aa",
|
||||
colorWarningBorder: "#fdba74",
|
||||
colorWarningBorderHover: "#fb923c",
|
||||
colorWarningHover: "#ea580c",
|
||||
colorWarningActive: "#c2410c",
|
||||
colorWarningText: "#ea580c",
|
||||
colorWarningTextHover: "#ea580c",
|
||||
colorWarningTextActive: "#c2410c",
|
||||
colorErrorBg: "#fef2f2",
|
||||
colorErrorBgHover: "#fecaca",
|
||||
colorErrorBorder: "#fca5a5",
|
||||
colorErrorBorderHover: "#f87171",
|
||||
colorErrorHover: "#dc2626",
|
||||
colorErrorActive: "#b91c1c",
|
||||
colorErrorText: "#dc2626",
|
||||
colorErrorTextHover: "#dc2626",
|
||||
colorErrorTextActive: "#b91c1c",
|
||||
colorInfoBg: "#f5f5f5",
|
||||
colorInfoBgHover: "#e5e5e5",
|
||||
colorInfoBorder: "#d4d4d4",
|
||||
colorInfoBorderHover: "#a3a3a3",
|
||||
colorInfoHover: "#404040",
|
||||
colorInfoActive: "#171717",
|
||||
colorInfoText: "#262626",
|
||||
colorInfoTextHover: "#404040",
|
||||
colorInfoTextActive: "#171717",
|
||||
colorText: "#262626",
|
||||
colorTextSecondary: "#525252",
|
||||
colorTextTertiary: "#737373",
|
||||
colorTextQuaternary: "#a3a3a3",
|
||||
colorTextDisabled: "#a3a3a3",
|
||||
colorBgContainer: "#ffffff",
|
||||
colorBgElevated: "#ffffff",
|
||||
colorBgLayout: "#fafafa",
|
||||
colorBgSpotlight: "rgba(38, 38, 38, 0.85)",
|
||||
colorBgMask: "rgba(38, 38, 38, 0.45)",
|
||||
colorBorder: "#e5e5e5",
|
||||
colorBorderSecondary: "#f5f5f5",
|
||||
borderRadius: 10,
|
||||
borderRadiusXS: 2,
|
||||
borderRadiusSM: 6,
|
||||
borderRadiusLG: 14,
|
||||
padding: 16,
|
||||
paddingSM: 12,
|
||||
paddingLG: 24,
|
||||
margin: 16,
|
||||
marginSM: 12,
|
||||
marginLG: 24,
|
||||
boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)",
|
||||
boxShadowSecondary: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)",
|
||||
};
|
||||
|
||||
export const shadcnThemeComponents = {
|
||||
Button: {
|
||||
primaryShadow: "none",
|
||||
defaultShadow: "none",
|
||||
dangerShadow: "none",
|
||||
defaultBorderColor: "#e4e4e7",
|
||||
defaultColor: "#18181b",
|
||||
defaultBg: "#ffffff",
|
||||
defaultHoverBg: "#f4f4f5",
|
||||
defaultHoverBorderColor: "#d4d4d8",
|
||||
defaultHoverColor: "#18181b",
|
||||
defaultActiveBg: "#e4e4e7",
|
||||
defaultActiveBorderColor: "#d4d4d8",
|
||||
borderRadius: 6,
|
||||
},
|
||||
Input: {
|
||||
activeShadow: "none",
|
||||
hoverBorderColor: "#a1a1aa",
|
||||
activeBorderColor: "#18181b",
|
||||
borderRadius: 6,
|
||||
},
|
||||
Select: {
|
||||
optionSelectedBg: "#f4f4f5",
|
||||
optionActiveBg: "#fafafa",
|
||||
optionSelectedFontWeight: 500,
|
||||
borderRadius: 6,
|
||||
},
|
||||
Alert: {
|
||||
borderRadiusLG: 8,
|
||||
},
|
||||
Modal: {
|
||||
borderRadiusLG: 12,
|
||||
},
|
||||
Progress: {
|
||||
defaultColor: "#18181b",
|
||||
remainingColor: "#f4f4f5",
|
||||
},
|
||||
Steps: {
|
||||
iconSize: 32,
|
||||
},
|
||||
Switch: {
|
||||
trackHeight: 24,
|
||||
trackMinWidth: 44,
|
||||
innerMinMargin: 4,
|
||||
innerMaxMargin: 24,
|
||||
},
|
||||
Checkbox: {
|
||||
borderRadiusSM: 4,
|
||||
},
|
||||
Slider: {
|
||||
trackBg: "#f4f4f5",
|
||||
trackHoverBg: "#e4e4e7",
|
||||
handleSize: 18,
|
||||
handleSizeHover: 20,
|
||||
railSize: 6,
|
||||
},
|
||||
ColorPicker: {
|
||||
borderRadius: 6,
|
||||
},
|
||||
Menu: {
|
||||
itemFontSize: 14,
|
||||
groupTitleFontSize: 12,
|
||||
itemHeight: 40,
|
||||
fontWeightStrong: 600,
|
||||
},
|
||||
Table: {
|
||||
headerBg: "#fafafa",
|
||||
headerSplitColor: "#e5e5e5",
|
||||
fontWeightStrong: 600,
|
||||
},
|
||||
};
|
||||
1129
web/yarn.lock
1129
web/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user