diff --git a/addons/base/data/core_user_apikey_rule_data.xml b/addons/base/data/core_user_apikey_rule_data.xml
new file mode 100644
index 00000000..ecc176af
--- /dev/null
+++ b/addons/base/data/core_user_apikey_rule_data.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ Users: own API keys
+ core.user.apikey
+ [["user_id", "=", "$uid"]]
+ True
+ True
+ True
+ True
+ False
+ [(4, ref('base.group_user'))]
+
+
+
diff --git a/addons/base/manifest.json b/addons/base/manifest.json
index 14ed86f4..6630b355 100644
--- a/addons/base/manifest.json
+++ b/addons/base/manifest.json
@@ -7,6 +7,7 @@
"application": true,
"data": [
"data/partner_property_definition_data.xml",
+ "data/core_user_apikey_rule_data.xml",
"views/core_company_actions.xml",
"views/core_users_actions.xml",
"views/platform_actions.xml",
diff --git a/addons/base/views/core_user_apikey_form_views.xml b/addons/base/views/core_user_apikey_form_views.xml
index 91a269dd..8b40e57e 100644
--- a/addons/base/views/core_user_apikey_form_views.xml
+++ b/addons/base/views/core_user_apikey_form_views.xml
@@ -12,10 +12,17 @@
+
+
+
+
+
+
+
diff --git a/addons/base/views/core_user_apikey_list_views.xml b/addons/base/views/core_user_apikey_list_views.xml
index a22d82d3..78a8a4fa 100644
--- a/addons/base/views/core_user_apikey_list_views.xml
+++ b/addons/base/views/core_user_apikey_list_views.xml
@@ -5,6 +5,8 @@
+
+
diff --git a/core/engine/render/menu_entry.go b/core/engine/render/menu_entry.go
new file mode 100644
index 00000000..0dbaeec7
--- /dev/null
+++ b/core/engine/render/menu_entry.go
@@ -0,0 +1,96 @@
+package render
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "sumeru/core/orm"
+)
+
+// ModuleWorkspaceEntryURL is the user-facing link to open an application module (/web/{name}).
+func ModuleWorkspaceEntryURL(ctx context.Context, moduleName string) string {
+ _ = ctx
+ if href := ModuleWebURL(moduleName); href != "" {
+ return href
+ }
+ return HomeWebURL()
+}
+
+// MenuWorkspaceEntryURL builds the internal workspace URL for a menu row (action + menu_id).
+func MenuWorkspaceEntryURL(ctx context.Context, menuID int) string {
+ if 0 >= menuID {
+ return HomeWebURL()
+ }
+ actionID, leafMenuID := orm.MenuEntryAction(ctx, menuID)
+ if 0 == actionID {
+ return HomeWebURL()
+ }
+ menuIDStr := strconv.Itoa(menuID)
+ if leafMenuID > 0 {
+ menuIDStr = strconv.Itoa(leafMenuID)
+ }
+ if href := urlActionHref(ctx, actionID); href != "" {
+ return href
+ }
+ return WorkspaceURL(WorkspaceQuery{ActionID: actionID, MenuID: menuIDStr})
+}
+
+// WorkspaceURLFromMenuAction builds the internal workspace URL from a resolved menu entry action.
+func WorkspaceURLFromMenuAction(ctx context.Context, actionID, rootMenuID, leafMenuID int) string {
+ if actionID == 0 {
+ return HomeWebURL()
+ }
+ menuIDStr := strconv.Itoa(rootMenuID)
+ if leafMenuID > 0 {
+ menuIDStr = strconv.Itoa(leafMenuID)
+ }
+ if href := urlActionHref(ctx, actionID); href != "" {
+ return href
+ }
+ return WorkspaceURL(WorkspaceQuery{ActionID: actionID, MenuID: menuIDStr})
+}
+
+// WorkspaceURLWithDebug appends debug=true to a workspace URL when enabled.
+func WorkspaceURLWithDebug(workspaceURL string, debugEnabled bool) string {
+ workspaceURL = strings.TrimSpace(workspaceURL)
+ if !debugEnabled || workspaceURL == "" {
+ return workspaceURL
+ }
+ if strings.Contains(workspaceURL, "debug=") {
+ return workspaceURL
+ }
+ sep := "?"
+ if strings.Contains(workspaceURL, "?") {
+ sep = "&"
+ }
+ return workspaceURL + sep + "debug=true"
+}
+
+func urlActionHref(ctx context.Context, actionID int) string {
+ row, err := orm.SearchOne(ctx, "sys.action.url", map[string]interface{}{"id": actionID})
+ if err != nil {
+ return ""
+ }
+ url := strings.TrimSpace(orm.AsString(row["url"]))
+ if url == "" || !strings.HasPrefix(url, "/") {
+ return ""
+ }
+ return url
+}
+
+func menuItemWorkspaceHref(ctx context.Context, menuRowID int, directActionID int, urlHrefs map[int]string, moduleName string, isRoot bool) string {
+ if isRoot && strings.TrimSpace(moduleName) != "" {
+ if href := ModuleWebURL(moduleName); href != "" {
+ return href
+ }
+ }
+ if directActionID != 0 {
+ if href := urlHrefs[directActionID]; href != "" {
+ return href
+ }
+ return fmt.Sprintf("/web?action=%d&menu_id=%d", directActionID, menuRowID)
+ }
+ return MenuWorkspaceEntryURL(ctx, menuRowID)
+}
diff --git a/core/engine/render/menus.go b/core/engine/render/menus.go
index 6ff207c5..e6ff6953 100644
--- a/core/engine/render/menus.go
+++ b/core/engine/render/menus.go
@@ -89,6 +89,11 @@ func fetchShellMenus(ctx context.Context) []parser.MenuItem {
continue
}
+ isRoot := !parentID.Valid || parentID.Int64 == int64(id)
+ directAction := 0
+ if actionID.Valid && actionID.Int64 != 0 {
+ directAction = int(actionID.Int64)
+ }
m := parser.MenuItem{
ID: fmt.Sprintf("%d", id),
Name: orm.Translate(ctx, lang, name),
@@ -96,19 +101,14 @@ func fetchShellMenus(ctx context.Context) []parser.MenuItem {
Module: mod,
WebIcon: sanitizeMenuIcon(webIcon),
AccessGroups: strings.TrimSpace(accessGroups),
- Action: fmt.Sprintf("/web?menu_id=%d", id),
}
if parentID.Valid && parentID.Int64 != int64(id) {
m.ParentID = fmt.Sprintf("%d", parentID.Int64)
}
- if actionID.Valid && actionID.Int64 != 0 {
- if href := urlActionHrefs[int(actionID.Int64)]; href != "" {
- m.Action = href
- } else {
- m.Action = fmt.Sprintf("/web?action=%d&menu_id=%d", actionID.Int64, id)
- }
- } else if !parentID.Valid && strings.EqualFold(strings.TrimSpace(name), "Home") {
- m.Action = "/web/home"
+ if isRoot && strings.EqualFold(strings.TrimSpace(name), "Home") {
+ m.Action = HomeWebURL()
+ } else {
+ m.Action = menuItemWorkspaceHref(ctx, id, directAction, urlActionHrefs, mod, isRoot)
}
allMenus = append(allMenus, m)
}
@@ -408,11 +408,8 @@ func BuildAppLauncherJSON(ctx context.Context) template.JS {
if name == "" {
continue
}
+ href := ModuleWorkspaceEntryURL(ctx, name)
menuID := RootMenuIDForModule(ctx, name)
- href := "/web/home"
- if menuID > 0 {
- href = fmt.Sprintf("/web?menu_id=%d", menuID)
- }
items = append(items, appLauncherItem{
Kind: "app",
Name: name,
diff --git a/core/engine/render/shell_routes.go b/core/engine/render/shell_routes.go
new file mode 100644
index 00000000..e19bf55d
--- /dev/null
+++ b/core/engine/render/shell_routes.go
@@ -0,0 +1,86 @@
+package render
+
+import (
+ "regexp"
+ "strings"
+)
+
+const shellWebPrefix = "/web/"
+
+var moduleNameRE = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
+
+// ReservedWebSegments are /web/{segment} paths owned by the kernel, not application modules.
+var ReservedWebSegments = map[string]struct{}{
+ "login": {}, "logout": {}, "home": {}, "apps": {}, "settings": {},
+ "import": {}, "export": {}, "bulk": {}, "swc": {}, "debug": {},
+ "company": {}, "module": {}, "action": {}, "session": {}, "report": {},
+ "kanban": {}, "setup": {}, "portal": {}, "api": {}, "user": {}, "content": {},
+}
+
+// AppsWebURL is the installed-apps administration catalog.
+func AppsWebURL() string {
+ return "/web/apps"
+}
+
+// ModuleWebURL returns /web/{moduleName} when the name is a valid, non-reserved module segment.
+func ModuleWebURL(moduleName string) string {
+ moduleName = strings.TrimSpace(moduleName)
+ if !ValidModuleWebSegment(moduleName) {
+ return ""
+ }
+ return shellWebPrefix + moduleName
+}
+
+// ValidModuleWebSegment reports whether segment is safe to use as /web/{segment}.
+func ValidModuleWebSegment(segment string) bool {
+ segment = strings.TrimSpace(segment)
+ if segment == "" || !moduleNameRE.MatchString(segment) {
+ return false
+ }
+ _, reserved := ReservedWebSegments[strings.ToLower(segment)]
+ return !reserved
+}
+
+// ParseDebugQuery interprets the debug query param: true, or true/{moduleName}.
+func ParseDebugQuery(debugParam string) (enabled bool, moduleName string) {
+ debugParam = strings.TrimSpace(debugParam)
+ if debugParam == "" {
+ return false, ""
+ }
+ if debugParam == "true" {
+ return true, ""
+ }
+ const prefix = "true/"
+ if strings.HasPrefix(debugParam, prefix) {
+ mod := strings.TrimSpace(debugParam[len(prefix):])
+ if ValidModuleWebSegment(mod) {
+ return true, mod
+ }
+ return true, mod
+ }
+ return false, ""
+}
+
+// DebugQueryValue builds the debug query value for a module (debug=true/crm).
+func DebugQueryValue(moduleName string) string {
+ moduleName = strings.TrimSpace(moduleName)
+ if moduleName == "" {
+ return "true"
+ }
+ return "true/" + moduleName
+}
+
+// WebDebugQueryURL is GET /web?debug=true or /web?debug=true/{module}.
+func WebDebugQueryURL(moduleName string) string {
+ q := DebugQueryValue(moduleName)
+ return WorkspaceRoute + "?debug=" + q
+}
+
+// ModuleWebURLWithDebugAlias returns /web/{module}?debug=true (alias for module entry with debug).
+func ModuleWebURLWithDebugAlias(moduleName string) string {
+ base := ModuleWebURL(moduleName)
+ if base == "" {
+ return ""
+ }
+ return base + "?debug=true"
+}
diff --git a/core/engine/templates/pages/settings_account.html b/core/engine/templates/pages/settings_account.html
index f60a89c3..a58640242 100644
--- a/core/engine/templates/pages/settings_account.html
+++ b/core/engine/templates/pages/settings_account.html
@@ -2,7 +2,7 @@
@@ -24,6 +24,61 @@
Password
+
+ API keys
+ Create keys for programmatic access. Each key is shown once; choose an expiration up to {{.MaxKeyDurationDays}} days.
+ {{if .APIKeys}}
+
+
+
+ | Name |
+ Prefix |
+ Expires |
+ Last used |
+ Status |
+ |
+
+
+
+ {{range .APIKeys}}
+
+ | {{.Name}} |
+ {{.Prefix}} |
+ {{.ExpiresAt}} |
+ {{.LastUsedAt}} |
+ {{if .Active}}Active{{else}}Revoked{{end}} |
+
+ {{if .Active}}
+
+ {{end}}
+ |
+
+ {{end}}
+
+
+ {{else}}
+ You have no API keys yet.
+ {{end}}
+
+
+
Two-factor authentication
{{if .TOTP.Enabled}}
diff --git a/core/module/data_sync_records.go b/core/module/data_sync_records.go
index 7e8d7ee0..376ff889 100644
--- a/core/module/data_sync_records.go
+++ b/core/module/data_sync_records.go
@@ -139,6 +139,7 @@ func syncRegistryRecordByModel(ctx context.Context, moduleName string, xmlRecord
// Prefer external id when already synced (states/cities have no natural unique key).
if existingID, _, err := orm.ResolveXmlId(ctx, moduleName+"."+xmlRecord.ID); err == nil && existingID > 0 {
if err := orm.UpdateRecordByID(ctx, xmlRecord.Model, existingID, fieldValues); err == nil {
+ finishRegistryRecordSync(ctx, moduleName, xmlRecord, existingID, fieldMapStrings, impliedEval)
return
}
// Stale xml id — fall through to create and refresh model_data.
@@ -154,6 +155,7 @@ func syncRegistryRecordByModel(ctx context.Context, moduleName string, xmlRecord
if err := linkXMLRecord(ctx, moduleName, xmlRecord.ID, xmlRecord.Model, int(eid)); err != nil {
return
}
+ finishRegistryRecordSync(ctx, moduleName, xmlRecord, int(eid), fieldMapStrings, impliedEval)
return
}
}
@@ -174,6 +176,13 @@ func syncRegistryRecordByModel(ctx context.Context, moduleName string, xmlRecord
syncWarn(ctx, platformmsg.FmtGenericUpsertWarn, xmlRecord.Model, xmlRecord.ID, err)
return
}
+ finishRegistryRecordSync(ctx, moduleName, xmlRecord, id, fieldMapStrings, impliedEval)
+ if err := linkXMLRecord(ctx, moduleName, xmlRecord.ID, xmlRecord.Model, id); err != nil {
+ return
+ }
+}
+
+func finishRegistryRecordSync(ctx context.Context, moduleName string, xmlRecord parser.Record, id int, fieldMapStrings map[string]string, impliedEval string) {
if xmlRecord.Model == "core.group" {
if impliedEval != "" {
if err := syncCoreGroupImpliedFromEval(ctx, moduleName, id, impliedEval); err != nil {
@@ -191,43 +200,63 @@ func syncRegistryRecordByModel(ctx context.Context, moduleName string, xmlRecord
}
}
}
- if err := linkXMLRecord(ctx, moduleName, xmlRecord.ID, xmlRecord.Model, id); err != nil {
- return
- }
}
// ConvertRecordScalar coerces XML/form string values into types used for registry upserts.
func ConvertRecordScalar(ctx context.Context, moduleName, model, column, rawValue string) interface{} {
- trimmedValue := strings.TrimSpace(rawValue)
+ value := strings.TrimSpace(rawValue)
+
if strings.HasPrefix(column, "perm_") {
- return parseBoolish(trimmedValue)
+ return parseBoolish(value)
}
- if column == "group_id" || column == "user_id" || column == "rule_id" || column == "implied_group_id" || column == "parent_id" || column == "category_id" || column == "country_id" || column == "state_id" || column == "city_id" || strings.HasSuffix(column, "_id") {
- if isEmptyRef(trimmedValue) {
- return nil
- }
- if id, err := resolveXMLIDInModule(ctx, moduleName, trimmedValue); err == nil && id > 0 {
- return id
- }
- if n, err := strconv.ParseInt(trimmedValue, 10, 64); err == nil {
- return n
+ if isSyncForeignKeyColumn(column) {
+ return coerceSyncForeignKey(ctx, moduleName, value)
+ }
+ if "active" == column || strings.HasSuffix(column, "_active") {
+ return parseBoolish(value)
+ }
+ if evaluated, err := eval.SafeEval(value); err == nil {
+ if out, ok := coerceSyncEvalResult(ctx, moduleName, evaluated); ok {
+ return out
}
+ }
+ return value
+}
+
+func isSyncForeignKeyColumn(column string) bool {
+ switch column {
+ case "group_id", "user_id", "rule_id", "implied_group_id", "parent_id",
+ "category_id", "country_id", "state_id", "city_id":
+ return true
+ default:
+ return strings.HasSuffix(column, "_id")
+ }
+}
+
+func coerceSyncForeignKey(ctx context.Context, moduleName, value string) interface{} {
+ if isEmptyRef(value) {
return nil
}
- if column == "active" || strings.HasSuffix(column, "_active") {
- return parseBoolish(trimmedValue)
- }
- if v, err := eval.SafeEval(trimmedValue); err == nil {
- switch typed := v.(type) {
- case bool, int64, float64, string:
- return typed
- case []interface{}:
- return resolveEvalTuple(ctx, moduleName, typed)
- case nil:
- return nil
- }
+ if id, err := resolveXMLIDInModule(ctx, moduleName, value); err == nil && 0 < id {
+ return id
+ }
+ if n, err := strconv.ParseInt(value, 10, 64); err == nil {
+ return n
+ }
+ return nil
+}
+
+func coerceSyncEvalResult(ctx context.Context, moduleName string, evaluated interface{}) (out interface{}, ok bool) {
+ switch typed := evaluated.(type) {
+ case bool, int64, float64, string:
+ return typed, true
+ case []interface{}:
+ return resolveEvalTuple(ctx, moduleName, typed), true
+ case nil:
+ return nil, true
+ default:
+ return nil, false
}
- return trimmedValue
}
func parseBoolish(value string) bool {
diff --git a/core/module/group_implied_sync.go b/core/module/group_implied_sync.go
index 9e65d50b..a608518d 100644
--- a/core/module/group_implied_sync.go
+++ b/core/module/group_implied_sync.go
@@ -120,3 +120,31 @@ func EnsureSystemImpliesManagerGroup(ctx context.Context, moduleName, recordXMLI
}
return nil
}
+
+// ReconcileSystemManagerImpliedForModule ensures base.group_system implies each Manager group declared in module XML.
+func ReconcileSystemManagerImpliedForModule(ctx context.Context, moduleName string) {
+ if orm.DB == nil {
+ return
+ }
+ moduleName = strings.TrimSpace(moduleName)
+ if moduleName == "" {
+ return
+ }
+ rows, err := orm.Search(ctx, "sys.model.data", [][]interface{}{
+ {"module", "=", moduleName},
+ {"model", "=", "core.group"},
+ })
+ if err != nil {
+ return
+ }
+ for _, row := range rows {
+ xmlID := strings.TrimSpace(orm.AsString(row["name"]))
+ gid64, ok := orm.CoerceInt64(row["core_id"])
+ if !ok || gid64 <= 0 || xmlID == "" {
+ continue
+ }
+ if err := EnsureSystemImpliesManagerGroup(ctx, moduleName, xmlID, int(gid64)); err != nil {
+ syncWarn(ctx, "Warning: reconcile system→manager %s.%s: %v", moduleName, xmlID, err)
+ }
+ }
+}
diff --git a/core/module/install.go b/core/module/install.go
index ce0f38f0..2772d182 100644
--- a/core/module/install.go
+++ b/core/module/install.go
@@ -118,6 +118,7 @@ func reloadModuleData(ctx context.Context, moduleName string, mode moduleReloadM
if err := SyncAddonTranslationPOFiles(ctx, addon.Path, moduleName); err != nil {
return recordSyncToDBResult(ctx, moduleName, err)
}
+ ReconcileSystemManagerImpliedForModule(ctx, moduleName)
return finalizeModuleReload(ctx, moduleName, mode)
}
diff --git a/core/orm/apikey.go b/core/orm/apikey.go
index 1fb4d2cd..bba37f90 100644
--- a/core/orm/apikey.go
+++ b/core/orm/apikey.go
@@ -11,6 +11,15 @@ import (
"time"
)
+const (
+ // MaxAPIKeyLifetime is the longest allowed key lifetime from creation.
+ MaxAPIKeyLifetime = 30 * 24 * time.Hour
+ // MaxAPIKeyDurationDays is the maximum duration_days accepted from UI and API.
+ MaxAPIKeyDurationDays = 30
+ // AuthLoginAPIKeyLifetime is the fixed TTL for keys minted by auth.login.
+ AuthLoginAPIKeyLifetime = 24 * time.Hour
+)
+
// HashAPIKey returns a hex SHA-256 digest of the raw key.
func HashAPIKey(raw string) string {
sum := sha256.Sum256([]byte(raw))
@@ -31,6 +40,34 @@ func GenerateAPIKey() (raw, prefix, hash string, err error) {
return raw, prefix, HashAPIKey(raw), nil
}
+// ValidateAPIKeyExpiresAt ensures expiresAt is in the future and within MaxAPIKeyLifetime.
+func ValidateAPIKeyExpiresAt(expiresAt, now time.Time) error {
+ if expiresAt.IsZero() {
+ return fmt.Errorf("api key expiration is required")
+ }
+ expiresAt = expiresAt.UTC()
+ now = now.UTC()
+ if !expiresAt.After(now) {
+ return fmt.Errorf("api key expiration must be in the future")
+ }
+ if expiresAt.Sub(now) > MaxAPIKeyLifetime {
+ return fmt.Errorf("api key expiration cannot exceed %d days", MaxAPIKeyDurationDays)
+ }
+ return nil
+}
+
+// ExpiresAtFromDurationDays computes UTC expiry from whole days (1..MaxAPIKeyDurationDays).
+func ExpiresAtFromDurationDays(days int, now time.Time) (time.Time, error) {
+ if days < 1 || days > MaxAPIKeyDurationDays {
+ return time.Time{}, fmt.Errorf("duration must be between 1 and %d days", MaxAPIKeyDurationDays)
+ }
+ expires := now.UTC().Add(time.Duration(days) * 24 * time.Hour)
+ if err := ValidateAPIKeyExpiresAt(expires, now); err != nil {
+ return time.Time{}, err
+ }
+ return expires, nil
+}
+
// UIDFromAPIKey resolves an active API key to a user id. Returns 0 if invalid.
func UIDFromAPIKey(ctx context.Context, raw string) int {
raw = strings.TrimSpace(raw)
@@ -66,10 +103,13 @@ func UIDFromAPIKey(ctx context.Context, raw string) int {
}
// CreateAPIKeyForUser inserts a new key row. Returns the raw key (caller must show once).
-func CreateAPIKeyForUser(ctx context.Context, userID int, name string) (raw string, err error) {
+func CreateAPIKeyForUser(ctx context.Context, userID int, name string, expiresAt time.Time) (raw string, err error) {
if 0 >= userID {
return "", fmt.Errorf("user id required")
}
+ if err := ValidateAPIKeyExpiresAt(expiresAt, time.Now().UTC()); err != nil {
+ return "", err
+ }
inst, ok := Registry["core.user.apikey"]
if !ok {
return "", fmt.Errorf("unknown model %q", "core.user.apikey")
@@ -93,6 +133,7 @@ func CreateAPIKeyForUser(ctx context.Context, userID int, name string) (raw stri
"key_hash": hash,
"active": true,
"create_date": time.Now().UTC().Format(time.RFC3339),
+ "expires_at": expiresAt.UTC().Format(time.RFC3339),
})
return raw, err
}
diff --git a/core/orm/menu_access.go b/core/orm/menu_access.go
index 1079675d..ea7edaf0 100644
--- a/core/orm/menu_access.go
+++ b/core/orm/menu_access.go
@@ -20,14 +20,18 @@ func UserHasAnyAccessGroup(ctx context.Context, uid int, accessGroupsCSV string)
}
// UserMayAccessMenu applies fail-closed menu policy: empty access_groups is visible
-// only to base.group_system (and superuser/bypass). Tagged menus require a matching group.
+// only to base.group_system (and superuser/bypass). Tagged menus require a matching group,
+// except base.group_system (Settings admin) which may open any menu for app navigation.
func UserMayAccessMenu(ctx context.Context, uid int, accessGroupsCSV string) bool {
if SecurityBypass(ctx) || uid == superuserUID {
return true
}
+ if UserHasGroupXML(ctx, uid, "base.group_system") {
+ return true
+ }
ids := NormalizeAccessGroupList(accessGroupsCSV)
if 0 == len(ids) {
- return UserHasGroupXML(ctx, uid, "base.group_system")
+ return false
}
return userMatchesAnyGroupXML(ctx, uid, ids)
}
diff --git a/core/orm/menu_action.go b/core/orm/menu_action.go
new file mode 100644
index 00000000..17aa825d
--- /dev/null
+++ b/core/orm/menu_action.go
@@ -0,0 +1,173 @@
+package orm
+
+import (
+ "context"
+ "strconv"
+ "strings"
+)
+
+const sysMenuModel = "sys.menu"
+
+type menuNavRow struct {
+ id int
+ actionID int
+ accessGroups string
+}
+
+// MenuRecordActionID returns a non-zero sys.menu action_id when present.
+func MenuRecordActionID(menuRecord map[string]interface{}) (actionID int, ok bool) {
+ actionID64, hasAction := CoerceInt64(menuRecord["action_id"])
+ if !hasAction || 0 == actionID64 {
+ return 0, false
+ }
+ return int(actionID64), true
+}
+
+// WindowActionIDFromMenu resolves a window action id from a menu row or its descendants.
+func WindowActionIDFromMenu(ctx context.Context, menuID int) int {
+ actionID, _ := MenuEntryAction(ctx, menuID)
+ return actionID
+}
+
+// MenuEntryAction returns the action_id and the menu row id that owns it (leaf when resolved from a folder).
+func MenuEntryAction(ctx context.Context, menuID int) (actionID int, leafMenuID int) {
+ if 0 >= menuID || DB == nil {
+ return 0, 0
+ }
+ row, ok := loadMenuNavRow(ctx, menuID)
+ if !ok {
+ return 0, 0
+ }
+ if row.actionID != 0 {
+ return row.actionID, menuID
+ }
+ for _, child := range loadMenuNavChildren(ctx, menuID) {
+ if aid, leaf := MenuEntryAction(ctx, child.id); aid != 0 {
+ return aid, leaf
+ }
+ }
+ return 0, 0
+}
+
+// MenuEntryActionForUser returns the first window action under menuID the user may open.
+func MenuEntryActionForUser(ctx context.Context, uid, menuID int) (actionID int, leafMenuID int) {
+ if 0 >= menuID || DB == nil {
+ return 0, 0
+ }
+ return menuEntryActionForUser(ctx, uid, menuID)
+}
+
+func menuEntryActionForUser(ctx context.Context, uid, menuID int) (actionID int, leafMenuID int) {
+ row, ok := loadMenuNavRow(ctx, menuID)
+ if !ok {
+ return 0, 0
+ }
+ if row.actionID != 0 && UserMayAccessMenu(ctx, uid, row.accessGroups) {
+ return row.actionID, menuID
+ }
+ for _, child := range loadMenuNavChildren(ctx, menuID) {
+ if aid, leaf := menuEntryActionForUser(ctx, uid, child.id); aid != 0 {
+ return aid, leaf
+ }
+ }
+ return 0, 0
+}
+
+// MenuIDForWindowAction finds the descendant menu row that binds actionID under parentMenuID.
+func MenuIDForWindowAction(ctx context.Context, parentMenuID, actionID int) int {
+ if 0 >= parentMenuID || 0 == actionID {
+ return 0
+ }
+ _, leafID := walkMenuChildren(ctx, parentMenuID, func(childMenuID int, _ map[string]interface{}, childActionID int) (int, int, bool) {
+ if childActionID == actionID {
+ return actionID, childMenuID, true
+ }
+ if leaf := MenuIDForWindowAction(ctx, childMenuID, actionID); leaf > 0 {
+ return actionID, leaf, true
+ }
+ return 0, 0, false
+ })
+ return leafID
+}
+
+// CanonicalMenuID rewrites folder/root menu_id to the leaf menu that owns actionID.
+func CanonicalMenuID(ctx context.Context, menuQuery string, actionID int) string {
+ menuID, ok := ParseMenuIDString(menuQuery)
+ if !ok || 0 == actionID {
+ return menuQuery
+ }
+ row, err := SearchOne(ctx, sysMenuModel, map[string]interface{}{"id": menuID})
+ if err != nil {
+ return menuQuery
+ }
+ if aid, ok := MenuRecordActionID(row); ok && aid == actionID {
+ return menuQuery
+ }
+ if leafID := MenuIDForWindowAction(ctx, menuID, actionID); leafID > 0 {
+ return strconv.Itoa(leafID)
+ }
+ return menuQuery
+}
+
+// ParseMenuIDString parses a positive numeric menu_id query value.
+func ParseMenuIDString(menuQuery string) (menuID int, ok bool) {
+ menuID, err := strconv.Atoi(strings.TrimSpace(menuQuery))
+ if err != nil || 0 >= menuID {
+ return 0, false
+ }
+ return menuID, true
+}
+
+func loadMenuNavRow(ctx context.Context, menuID int) (menuNavRow, bool) {
+ table := MustQuotedTableName(sysMenuModel)
+ q := `SELECT id, COALESCE(action_id, 0), COALESCE(NULLIF(TRIM(access_groups), ''), '')
+ FROM ` + table + ` WHERE id = $1`
+ var row menuNavRow
+ err := DB.QueryRowContext(ctx, q, menuID).Scan(&row.id, &row.actionID, &row.accessGroups)
+ if err != nil {
+ return menuNavRow{}, false
+ }
+ return row, true
+}
+
+func loadMenuNavChildren(ctx context.Context, parentMenuID int) []menuNavRow {
+ table := MustQuotedTableName(sysMenuModel)
+ q := `SELECT id, COALESCE(action_id, 0), COALESCE(NULLIF(TRIM(access_groups), ''), '')
+ FROM ` + table + ` WHERE parent_id = $1 ORDER BY sequence ASC, id ASC`
+ rows, err := DB.QueryContext(ctx, q, parentMenuID)
+ if err != nil {
+ return nil
+ }
+ defer rows.Close()
+ out := make([]menuNavRow, 0, 8)
+ for rows.Next() {
+ var row menuNavRow
+ if err := rows.Scan(&row.id, &row.actionID, &row.accessGroups); err != nil {
+ continue
+ }
+ out = append(out, row)
+ }
+ if err := rows.Err(); err != nil {
+ return nil
+ }
+ return out
+}
+
+func walkMenuChildren(
+ ctx context.Context,
+ parentMenuID int,
+ visit func(childMenuID int, childRow map[string]interface{}, childActionID int) (actionID int, menuID int, ok bool),
+) (actionID int, menuID int) {
+ for _, child := range loadMenuNavChildren(ctx, parentMenuID) {
+ childMenuID := child.id
+ row := map[string]interface{}{
+ "id": childMenuID,
+ "action_id": child.actionID,
+ "access_groups": child.accessGroups,
+ }
+ if a, m, ok := visit(childMenuID, row, child.actionID); ok {
+ return a, m
+ }
+ }
+ return 0, 0
+}
diff --git a/core/orm/setup_seed.go b/core/orm/setup_seed.go
index 31cf5032..38df7612 100644
--- a/core/orm/setup_seed.go
+++ b/core/orm/setup_seed.go
@@ -305,22 +305,26 @@ func ensureBootstrapACLs(ctx context.Context, adminGID, userGID int) {
}, "name")
}
- for _, pair := range []struct{ model, name string }{
- {"core.company", "access_core_company_user"},
- {"core.user", "access_core_user_user"},
- {"core.group", "access_core_group_user_read"},
- {"sys.access", "access_sys_access_user_read"},
- {"sys.rule", "access_sys_rule_user_read"},
- {"sys.property.definition", "access_sys_property_definition_user"},
+ for _, pair := range []struct {
+ model, name string
+ read, write, create, unlink bool
+ }{
+ {"core.company", "access_core_company_user", true, false, false, false},
+ {"core.user", "access_core_user_user", true, false, false, false},
+ {"core.user.apikey", "access_core_user_apikey_user", true, true, true, false},
+ {"core.group", "access_core_group_user_read", true, false, false, false},
+ {"sys.access", "access_sys_access_user_read", true, false, false, false},
+ {"sys.rule", "access_sys_rule_user_read", true, false, false, false},
+ {"sys.property.definition", "access_sys_property_definition_user", true, false, false, false},
} {
_, _ = Upsert(ctx, RegistryModel("sys.access"), map[string]interface{}{
"name": pair.name,
"model": pair.model,
"group_id": NullableGroupIDForAccess(userGID),
- "perm_read": true,
- "perm_write": false,
- "perm_create": false,
- "perm_unlink": false,
+ "perm_read": pair.read,
+ "perm_write": pair.write,
+ "perm_create": pair.create,
+ "perm_unlink": pair.unlink,
}, "name")
}
}
diff --git a/core/orm/user_auth.go b/core/orm/user_auth.go
index ae18067a..a103f4ef 100644
--- a/core/orm/user_auth.go
+++ b/core/orm/user_auth.go
@@ -121,9 +121,11 @@ func CreateUserSession(ctx context.Context, userID int, opts SessionCreateOpts)
return sessionID, nil
}
-// IssueAPIKeyForLogin mints a one-time API key for auth.login responses.
+// IssueAPIKeyForLogin mints an API key for auth.login (24h TTL, elevated create).
func IssueAPIKeyForLogin(ctx context.Context, userID int) (raw string, err error) {
- return CreateAPIKeyForUser(ctx, userID, "auth.login")
+ ctx = AuditedBypass(ctx, "auth.login.apikey")
+ expires := time.Now().UTC().Add(AuthLoginAPIKeyLifetime)
+ return CreateAPIKeyForUser(ctx, userID, "auth.login", expires)
}
// ResolveCompanyTenant resolves X-Sumeru-Tenant to a company id (numeric or name).
diff --git a/core/server/web/apikey_handlers.go b/core/server/web/apikey_handlers.go
index f82e68c0..bbe328bf 100644
--- a/core/server/web/apikey_handlers.go
+++ b/core/server/web/apikey_handlers.go
@@ -4,6 +4,7 @@ import (
"net/http"
"strconv"
"strings"
+ "time"
"sumeru/core/errcode"
"sumeru/core/orm"
@@ -28,9 +29,22 @@ func ActionCreateAPIKey(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
targetUserID := apiKeyTargetUserID(r)
+ if targetUserID <= 0 {
+ http.Error(w, "Could not create API key", http.StatusUnauthorized)
+ return
+ }
keyName := strings.TrimSpace(r.PostFormValue("name"))
+ expiresAt, msg, err := apiKeyExpiresAtFromForm(r)
+ if msg != "" {
+ redirectWithWebMessage(w, r, r.PostFormValue("next"), msg)
+ return
+ }
+ if err != nil {
+ redirectWithWebMessage(w, r, r.PostFormValue("next"), "api_key_duration_invalid")
+ return
+ }
- rawKey, err := orm.CreateAPIKeyForUser(ctx, targetUserID, keyName)
+ rawKey, err := orm.CreateAPIKeyForUser(ctx, targetUserID, keyName, expiresAt)
if err != nil {
WebLogEvent(ctx, WebLogInput{
Route: createAPIKeyRoute,
@@ -86,3 +100,19 @@ func htmlEscape(s string) string {
repl := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """)
return repl.Replace(s)
}
+
+func apiKeyExpiresAtFromForm(r *http.Request) (time.Time, string, error) {
+ daysStr := strings.TrimSpace(r.PostFormValue("duration_days"))
+ if daysStr == "" {
+ return time.Time{}, "api_key_duration_required", nil
+ }
+ days, err := strconv.Atoi(daysStr)
+ if err != nil {
+ return time.Time{}, "api_key_duration_invalid", err
+ }
+ expires, err := orm.ExpiresAtFromDurationDays(days, time.Now().UTC())
+ if err != nil {
+ return time.Time{}, "api_key_duration_invalid", err
+ }
+ return expires, "", nil
+}
diff --git a/core/server/web/apps_detail.go b/core/server/web/apps_detail.go
index 6b8c4270..36d42bb7 100644
--- a/core/server/web/apps_detail.go
+++ b/core/server/web/apps_detail.go
@@ -2,7 +2,6 @@ package web
import (
"context"
- "fmt"
"net/http"
"strings"
@@ -113,11 +112,13 @@ func moduleOpenAppURL(ctx context.Context, parsed moduleRow) string {
if parsed.State != "installed" || !parsed.Active {
return ""
}
- menuID := render.RootMenuIDForModule(ctx, parsed.Name)
- if 0 >= menuID {
- return ""
+ if href := render.ModuleWebURL(parsed.Name); href != "" {
+ if 0 >= render.RootMenuIDForModule(ctx, parsed.Name) {
+ return ""
+ }
+ return href
}
- return fmt.Sprintf("/web?menu_id=%d", menuID)
+ return ""
}
func moduleDepends(moduleName string, listedModules []appsModule, browse appsBrowseState) []appsModuleDep {
diff --git a/core/server/web/module_entry.go b/core/server/web/module_entry.go
new file mode 100644
index 00000000..b146aad4
--- /dev/null
+++ b/core/server/web/module_entry.go
@@ -0,0 +1,105 @@
+package web
+
+import (
+ "context"
+ "net/http"
+ "strings"
+
+ "sumeru/core/engine/render"
+ "sumeru/core/orm"
+)
+
+const moduleWebPrefix = "/web/"
+
+func registerModuleWebRoutes(mux *http.ServeMux) {
+ mux.HandleFunc(moduleWebPrefix, moduleWebPrefixHandler)
+}
+
+func moduleWebPrefixHandler(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if !requireLogin(w, r) {
+ return
+ }
+ rest := strings.TrimPrefix(r.URL.Path, moduleWebPrefix)
+ if rest == "" || strings.Contains(rest, "/") {
+ http.NotFound(w, r)
+ return
+ }
+ moduleName := strings.TrimSpace(rest)
+ if !render.ValidModuleWebSegment(moduleName) {
+ http.NotFound(w, r)
+ return
+ }
+ moduleEntryHandler(w, r, moduleName)
+}
+
+func moduleEntryHandler(w http.ResponseWriter, r *http.Request, moduleName string) {
+ ctx := r.Context()
+ if !isOpenApplicationModule(ctx, moduleName) {
+ http.NotFound(w, r)
+ return
+ }
+ menuID := render.RootMenuIDForModule(ctx, moduleName)
+ if 0 >= menuID {
+ http.Redirect(w, r, homeRoute, http.StatusFound)
+ return
+ }
+ uid := orm.SecurityUID(ctx)
+ actionID, leafMenuID := orm.MenuEntryActionForUser(ctx, uid, menuID)
+ if actionID == 0 {
+ http.Redirect(w, r, homeRoute, http.StatusFound)
+ return
+ }
+ loc := render.WorkspaceURLFromMenuAction(ctx, actionID, menuID, leafMenuID)
+ if loc == render.HomeWebURL() {
+ http.Redirect(w, r, homeRoute, http.StatusFound)
+ return
+ }
+ debugEnabled := shellDebugEnabled(r)
+ loc = render.WorkspaceURLWithDebug(loc, debugEnabled)
+ http.Redirect(w, r, loc, http.StatusFound)
+}
+
+func isOpenApplicationModule(ctx context.Context, moduleName string) bool {
+ row, err := orm.SearchOne(ctx, "sys.module", map[string]interface{}{"name": moduleName})
+ if err != nil {
+ return false
+ }
+ parsed, ok := parseModuleRow(row)
+ if !ok {
+ return false
+ }
+ return parsed.State == "installed" && parsed.Application && parsed.Active
+}
+
+func shellDebugEnabled(r *http.Request) bool {
+ enabled, _ := render.ParseDebugQuery(r.URL.Query().Get("debug"))
+ return enabled
+}
+
+func redirectDebugModuleEntry(w http.ResponseWriter, r *http.Request, moduleName string) {
+ moduleName = strings.TrimSpace(moduleName)
+ if href := render.ModuleWebURL(moduleName); href != "" {
+ if shellDebugEnabled(r) {
+ href = render.ModuleWebURLWithDebugAlias(moduleName)
+ }
+ http.Redirect(w, r, href, http.StatusFound)
+ return
+ }
+ http.Redirect(w, r, homeRoute, http.StatusFound)
+}
+
+func handleWebDebugQueryRedirect(w http.ResponseWriter, r *http.Request) bool {
+ enabled, mod := render.ParseDebugQuery(r.URL.Query().Get("debug"))
+ if !enabled {
+ return false
+ }
+ if mod != "" {
+ redirectDebugModuleEntry(w, r, mod)
+ return true
+ }
+ return false
+}
diff --git a/core/server/web/module_tile_helpers.go b/core/server/web/module_tile_helpers.go
index bab7061e..e9882222 100644
--- a/core/server/web/module_tile_helpers.go
+++ b/core/server/web/module_tile_helpers.go
@@ -2,7 +2,6 @@ package web
import (
"context"
- "fmt"
"sort"
"strings"
@@ -24,10 +23,8 @@ func loadInstalledAppTiles(ctx context.Context, forHome bool) ([]render.AppTile,
continue
}
menuID, rootWebIcon := render.ModuleRootMenu(ctx, parsed.Name)
- openHref := appsRoute
- if menuID > 0 {
- openHref = menuHrefFromMenuID(menuID)
- }
+ openHref := render.ModuleWorkspaceEntryURL(ctx, parsed.Name)
+ _ = menuID
iconURL := render.ModuleIconURL(ctx, parsed.Name)
webIcon := ""
if !forHome && iconURL == "" {
@@ -57,7 +54,3 @@ func loadInstalledAppTiles(ctx context.Context, forHome bool) ([]render.AppTile,
return tiles, nil
}
-// menuHrefFromMenuID builds a workspace URL for a numeric menu id.
-func menuHrefFromMenuID(menuID int) string {
- return fmt.Sprintf("/web?menu_id=%d", menuID)
-}
diff --git a/core/server/web/query_flash.go b/core/server/web/query_flash.go
index 6bd85826..a482462c 100644
--- a/core/server/web/query_flash.go
+++ b/core/server/web/query_flash.go
@@ -76,7 +76,17 @@ func FlashFromQueryMessage(msg string) (render.FlashMessage, bool) {
case "model_acl_failed":
return render.FlashMessage{Kind: "error", Title: "Model access not saved", Body: "Check the model and try again."}, true
case "api_key_created":
- return render.FlashMessage{Kind: "success", Title: "API key created", Body: "Copy the key from the banner above if shown."}, true
+ return render.FlashMessage{Kind: "success", Title: "API key created", Body: "Open /web/apikey/reveal within 2 minutes to copy your key (shown once)."}, true
+ case "api_key_duration_required":
+ return render.FlashMessage{Kind: "error", Title: "Duration required", Body: "Enter how many days the API key should remain valid (1–30)."}, true
+ case "api_key_duration_invalid":
+ return render.FlashMessage{Kind: "error", Title: "Invalid duration", Body: "Duration must be a whole number of days from 1 to 30."}, true
+ case "api_key_create_failed":
+ return render.FlashMessage{Kind: "error", Title: "Could not create API key", Body: "Try again or contact an administrator."}, true
+ case "api_key_revoked":
+ return render.FlashMessage{Kind: "success", Title: "API key revoked", Body: "The key can no longer be used."}, true
+ case "api_key_revoke_failed":
+ return render.FlashMessage{Kind: "error", Title: "Could not revoke API key", Body: "Check the key and try again."}, true
case moduleMsgSaved:
return render.FlashMessage{Kind: "success", Title: "Saved", Body: "Changes were saved."}, true
case saveOKCreatedMsg:
diff --git a/core/server/web/routes_table.go b/core/server/web/routes_table.go
index 3926287f..cdea018f 100644
--- a/core/server/web/routes_table.go
+++ b/core/server/web/routes_table.go
@@ -38,6 +38,7 @@ func RegisterAppRoutes(mux *http.ServeMux) {
router.Apply(serveMux)
registerAppAliases(serveMux)
+ registerModuleWebRoutes(serveMux)
registerContentPrefixRoute(serveMux)
registerPortalSharePrefixRoute(serveMux)
registerAPIRPCPrefixRoute(serveMux)
diff --git a/core/server/web/settings_account.go b/core/server/web/settings_account.go
index 2199eb59..a557e0e1 100644
--- a/core/server/web/settings_account.go
+++ b/core/server/web/settings_account.go
@@ -32,9 +32,11 @@ func SettingsAccountGetHandler(w http.ResponseWriter, r *http.Request) {
flash, _ := FlashFromQueryMessage(r.URL.Query().Get("msg"))
actor := orm.SecurityUID(ctx)
renderSettingsAccountPage(w, r, settingsAccountData{
- CSRFToken: CSRFTokenForRequest(r),
- Flash: flash,
- TOTP: loadSettingsAccountTOTPState(ctx, actor),
+ CSRFToken: CSRFTokenForRequest(r),
+ Flash: flash,
+ TOTP: loadSettingsAccountTOTPState(ctx, actor),
+ APIKeys: loadSettingsAccountAPIKeys(ctx, actor),
+ MaxKeyDurationDays: orm.MaxAPIKeyDurationDays,
}, menuIDStr)
}
@@ -59,6 +61,13 @@ func SettingsAccountPostHandler(w http.ResponseWriter, r *http.Request) {
if handleSettingsAccountTOTP(w, r, ctx, actor, action) {
return
}
+ if handleSettingsAccountAPIKey(w, r, ctx, actor, action) {
+ return
+ }
+ if action != "" {
+ http.Error(w, "unknown action", http.StatusBadRequest)
+ return
+ }
pw := strings.TrimSpace(r.PostForm.Get("password_plain"))
confirm := strings.TrimSpace(r.PostForm.Get("password_plain_confirm"))
if pw == "" {
@@ -77,9 +86,11 @@ func SettingsAccountPostHandler(w http.ResponseWriter, r *http.Request) {
}
type settingsAccountData struct {
- CSRFToken string
- Flash render.FlashMessage
- TOTP settingsAccountTOTPState
+ CSRFToken string
+ Flash render.FlashMessage
+ TOTP settingsAccountTOTPState
+ APIKeys []settingsAccountAPIKeyRow
+ MaxKeyDurationDays int
}
func renderSettingsAccountPage(w http.ResponseWriter, r *http.Request, pageData settingsAccountData, menuIDStr string) {
diff --git a/core/server/web/settings_account_apikey.go b/core/server/web/settings_account_apikey.go
new file mode 100644
index 00000000..64e8ba3f
--- /dev/null
+++ b/core/server/web/settings_account_apikey.go
@@ -0,0 +1,130 @@
+package web
+
+import (
+ "context"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "sumeru/core/errcode"
+ "sumeru/core/orm"
+)
+
+type settingsAccountAPIKeyRow struct {
+ ID int
+ Name string
+ Prefix string
+ ExpiresAt string
+ LastUsedAt string
+ Active bool
+}
+
+func loadSettingsAccountAPIKeys(ctx context.Context, userID int) []settingsAccountAPIKeyRow {
+ if userID <= 0 || orm.DB == nil {
+ return nil
+ }
+ rows, err := orm.SearchPage(ctx, "core.user.apikey", [][]interface{}{
+ {"user_id", "=", userID},
+ }, 50, 0, "id desc")
+ if err != nil {
+ return nil
+ }
+ out := make([]settingsAccountAPIKeyRow, 0, len(rows))
+ for _, row := range rows {
+ id64, ok := orm.CoerceInt64(row["id"])
+ if !ok || id64 <= 0 {
+ continue
+ }
+ name, _ := row["name"].(string)
+ prefix, _ := row["key_prefix"].(string)
+ active, _ := row["active"].(bool)
+ out = append(out, settingsAccountAPIKeyRow{
+ ID: int(id64),
+ Name: strings.TrimSpace(name),
+ Prefix: strings.TrimSpace(prefix),
+ ExpiresAt: formatAPIKeyDisplayTime(row["expires_at"]),
+ LastUsedAt: formatAPIKeyDisplayTime(row["last_used_at"]),
+ Active: active,
+ })
+ }
+ return out
+}
+
+func formatAPIKeyDisplayTime(v interface{}) string {
+ s, _ := v.(string)
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return "—"
+ }
+ t, err := time.Parse(time.RFC3339, s)
+ if err != nil {
+ return s
+ }
+ return t.UTC().Format("2006-01-02 15:04 UTC")
+}
+
+func handleSettingsAccountAPIKey(w http.ResponseWriter, r *http.Request, ctx context.Context, actor int, action string) bool {
+ switch action {
+ case "create_api_key":
+ handleSettingsAccountCreateAPIKey(w, r, ctx, actor)
+ return true
+ case "revoke_api_key":
+ handleSettingsAccountRevokeAPIKey(w, r, ctx, actor)
+ return true
+ default:
+ return false
+ }
+}
+
+func handleSettingsAccountCreateAPIKey(w http.ResponseWriter, r *http.Request, ctx context.Context, actor int) {
+ expiresAt, msg, err := apiKeyExpiresAtFromForm(r)
+ if msg != "" {
+ redirectWithWebMessage(w, r, settingsAccountRoute, msg)
+ return
+ }
+ if err != nil {
+ redirectWithWebMessage(w, r, settingsAccountRoute, "api_key_duration_invalid")
+ return
+ }
+ name := strings.TrimSpace(r.PostForm.Get("api_key_name"))
+ rawKey, err := orm.CreateAPIKeyForUser(ctx, actor, name, expiresAt)
+ if err != nil {
+ WebLogEvent(ctx, WebLogInput{
+ Route: settingsAccountRoute,
+ Message: "Could not create API key",
+ Code: errcode.InternalError,
+ Operation: "create_api_key",
+ Status: logStatusFailure,
+ Err: err,
+ ContextFields: map[string]interface{}{
+ "user_id": actor,
+ },
+ })
+ redirectWithWebMessage(w, r, settingsAccountRoute, "api_key_create_failed")
+ return
+ }
+ SetAPIKeyFlash(w, rawKey)
+ redirectWithWebMessage(w, r, settingsAccountRoute, "api_key_created")
+}
+
+func handleSettingsAccountRevokeAPIKey(w http.ResponseWriter, r *http.Request, ctx context.Context, actor int) {
+ keyID, _ := strconv.Atoi(strings.TrimSpace(r.PostForm.Get("key_id")))
+ if keyID <= 0 {
+ redirectWithWebMessage(w, r, settingsAccountRoute, "api_key_revoke_failed")
+ return
+ }
+ row, err := orm.SearchOne(ctx, "core.user.apikey", map[string]interface{}{
+ "id": keyID,
+ "user_id": actor,
+ })
+ if err != nil || row == nil {
+ redirectWithWebMessage(w, r, settingsAccountRoute, "api_key_revoke_failed")
+ return
+ }
+ if err := orm.UpdateRecordByID(ctx, "core.user.apikey", keyID, map[string]interface{}{"active": false}); err != nil {
+ redirectWithWebMessage(w, r, settingsAccountRoute, "api_key_revoke_failed")
+ return
+ }
+ redirectWithWebMessage(w, r, settingsAccountRoute, "api_key_revoked")
+}
diff --git a/core/server/web/swc_workspace.go b/core/server/web/swc_workspace.go
index 7b749310..aa6babd9 100644
--- a/core/server/web/swc_workspace.go
+++ b/core/server/web/swc_workspace.go
@@ -58,8 +58,9 @@ func SwcWorkspaceHandler(w http.ResponseWriter, r *http.Request) {
} else if modelQuery != "" {
actionData = map[string]interface{}{}
resolved, err = resolveWorkspaceByModel(ctx, r, modelQuery)
+ } else if redirectIfNoWindowAction(w, r, actionQuery, menuQuery, actionID) {
+ return
} else {
- http.Error(w, "action required", http.StatusBadRequest)
return
}
if err != nil {
diff --git a/core/server/web/testexports.go b/core/server/web/testexports.go
index d8ae5d7e..f2793476 100644
--- a/core/server/web/testexports.go
+++ b/core/server/web/testexports.go
@@ -101,6 +101,10 @@ func ResolveActionIDFromQuery(ctx context.Context, actionQuery string) int {
return resolveActionIDFromQuery(ctx, actionQuery)
}
+func ResolveWorkspaceRedirect(ctx context.Context, menuID string, resolvedActionID int) (redirectURL string, shouldRedirect bool) {
+ return resolveWorkspaceRedirect(ctx, menuID, resolvedActionID)
+}
+
func MenuRecordActionID(menuRecord map[string]interface{}) (actionID int, ok bool) {
return menuRecordActionID(menuRecord)
}
@@ -371,6 +375,10 @@ func FindAppsModule(modules []AppsModule, moduleName string) (AppsModule, bool)
func APIKeyTargetUserID(r *http.Request) int { return apiKeyTargetUserID(r) }
+func APIKeyExpiresAtFromFormForTest(r *http.Request) (time.Time, string, error) {
+ return apiKeyExpiresAtFromForm(r)
+}
+
func ParseModuleRow(values map[string]interface{}) (ModuleRow, bool) { return parseModuleRow(values) }
func ModuleDisplayName(moduleName, displayName string) string {
diff --git a/core/server/web/workspace.go b/core/server/web/workspace.go
index 9cc40143..a81f8bc7 100644
--- a/core/server/web/workspace.go
+++ b/core/server/web/workspace.go
@@ -18,6 +18,10 @@ func WebHandler(w http.ResponseWriter, r *http.Request) {
return
}
+ if handleWebDebugQueryRedirect(w, r) {
+ return
+ }
+
ctx := r.Context()
actionQuery, menuQuery := workspaceQueryParams(r)
@@ -111,8 +115,8 @@ func redirectIfNoWindowAction(w http.ResponseWriter, r *http.Request, actionQuer
return false
}
- if 0 == actionID && redirectURL == appsRoute {
- WebLogf(r.Context(), workspaceRoute, "no action for query action=%q menu_id=%q; redirecting to apps", actionQuery, menuQuery)
+ if 0 == actionID {
+ WebLogf(r.Context(), workspaceRoute, "no action for query action=%q menu_id=%q; redirecting to %s", actionQuery, menuQuery, redirectURL)
}
http.Redirect(w, r, redirectURL, http.StatusFound)
return true
diff --git a/core/server/web/workspace_request.go b/core/server/web/workspace_request.go
index 2363ecc3..d946c819 100644
--- a/core/server/web/workspace_request.go
+++ b/core/server/web/workspace_request.go
@@ -48,7 +48,7 @@ func resolveWorkspaceRedirect(ctx context.Context, menuID string, resolvedAction
case isHomeMenuTree(ctx, menuID):
return homeRouteWithMenu(menuID), true
default:
- return appsRoute, true
+ return homeRouteWithMenu(menuID), true
}
}
diff --git a/core/server/web/workspace_resolve.go b/core/server/web/workspace_resolve.go
index 430242d8..9929663c 100644
--- a/core/server/web/workspace_resolve.go
+++ b/core/server/web/workspace_resolve.go
@@ -13,7 +13,11 @@ func ResolveWindowActionID(ctx context.Context, actionQuery, menuQuery string) i
if actionID := resolveActionIDFromQuery(ctx, actionQuery); actionID != 0 {
return actionID
}
- return resolveActionIDFromMenuQuery(ctx, menuQuery)
+ menuID, ok := orm.ParseMenuIDString(menuQuery)
+ if !ok {
+ return 0
+ }
+ return orm.WindowActionIDFromMenu(ctx, menuID)
}
func resolveActionIDFromQuery(ctx context.Context, actionQuery string) int {
@@ -31,45 +35,8 @@ func resolveActionIDFromQuery(ctx context.Context, actionQuery string) int {
return actionID
}
-func resolveActionIDFromMenuQuery(ctx context.Context, menuQuery string) int {
- menuID, ok := parseMenuIDString(menuQuery)
- if !ok {
- return 0
- }
- return windowActionIDFromMenu(ctx, menuID)
-}
-
-func windowActionIDFromMenu(ctx context.Context, menuID int) int {
- menuRecord, err := orm.SearchOne(ctx, sysMenuModel, map[string]interface{}{"id": menuID})
- if err != nil {
- return 0
- }
- if actionID, ok := menuRecordActionID(menuRecord); ok {
- return actionID
- }
- return firstDescendantWindowActionID(ctx, menuID)
-}
-
func menuRecordActionID(menuRecord map[string]interface{}) (actionID int, ok bool) {
- actionID64, hasAction := orm.CoerceInt64(menuRecord["action_id"])
- if !hasAction || 0 == actionID64 {
- return 0, false
- }
- return int(actionID64), true
-}
-
-// firstDescendantWindowActionID returns the first non-zero action_id in a depth-first walk
-// of children ordered by sequence, then id.
-func firstDescendantWindowActionID(ctx context.Context, parentMenuID int) int {
- return walkMenuChildren(ctx, parentMenuID, func(childMenuID int, childActionID int) (int, bool) {
- if childActionID != 0 {
- return childActionID, true
- }
- if descendantActionID := firstDescendantWindowActionID(ctx, childMenuID); descendantActionID != 0 {
- return descendantActionID, true
- }
- return 0, false
- })
+ return orm.MenuRecordActionID(menuRecord)
}
func menuIDPointsToAppLogs(ctx context.Context, menuQuery string) bool {
@@ -81,7 +48,7 @@ func menuIDMatchesXML(ctx context.Context, menuQuery, menuXMLID string) bool {
if !ok {
return false
}
- actualMenuID, ok := parseMenuIDString(menuQuery)
+ actualMenuID, ok := orm.ParseMenuIDString(menuQuery)
if !ok {
return false
}
@@ -89,7 +56,6 @@ func menuIDMatchesXML(ctx context.Context, menuQuery, menuXMLID string) bool {
}
// isHomeMenuTree reports whether menu_id is base.menu_home_root or a descendant.
-// An empty menu_id is treated as the home root (default landing behavior).
func isHomeMenuTree(ctx context.Context, menuQuery string) bool {
if strings.TrimSpace(menuQuery) == "" {
return true
@@ -102,7 +68,7 @@ func menuIsUnderXMLRoot(ctx context.Context, menuQuery, rootMenuXMLID string) bo
if !ok {
return false
}
- menuID, ok := parseMenuIDString(menuQuery)
+ menuID, ok := orm.ParseMenuIDString(menuQuery)
if !ok {
return false
}
@@ -118,67 +84,14 @@ func resolvedMenuIDFromXML(ctx context.Context, menuXMLID string) (menuID int, o
}
func parseMenuIDString(menuQuery string) (menuID int, ok bool) {
- menuID, err := strconv.Atoi(strings.TrimSpace(menuQuery))
- if err != nil || 0 >= menuID {
- return 0, false
- }
- return menuID, true
+ return orm.ParseMenuIDString(menuQuery)
}
-// actionWindowTargetModel returns the ORM technical model for a sys.action.window row (core_model).
func actionWindowTargetModel(actionData map[string]interface{}) string {
return strings.TrimSpace(orm.AsString(actionData["core_model"]))
}
// CanonicalMenuID rewrites folder/root menu_id to the leaf menu that owns actionID.
func CanonicalMenuID(ctx context.Context, menuQuery string, actionID int) string {
- menuID, ok := parseMenuIDString(menuQuery)
- if !ok || 0 == actionID {
- return menuQuery
- }
- menuRecord, err := orm.SearchOne(ctx, sysMenuModel, map[string]interface{}{"id": menuID})
- if err != nil {
- return menuQuery
- }
- if aid, ok := menuRecordActionID(menuRecord); ok && aid == actionID {
- return menuQuery
- }
- if leafID := menuIDForWindowAction(ctx, menuID, actionID); leafID > 0 {
- return strconv.Itoa(leafID)
- }
- return menuQuery
-}
-
-func menuIDForWindowAction(ctx context.Context, parentMenuID, actionID int) int {
- if 0 >= parentMenuID || 0 == actionID {
- return 0
- }
- return walkMenuChildren(ctx, parentMenuID, func(childMenuID int, childActionID int) (int, bool) {
- if childActionID == actionID {
- return childMenuID, true
- }
- if found := menuIDForWindowAction(ctx, childMenuID, actionID); found > 0 {
- return found, true
- }
- return 0, false
- })
-}
-
-// walkMenuChildren DFS-walks sys.menu children (sequence, id) and returns the first match from visit.
-func walkMenuChildren(ctx context.Context, parentMenuID int, visit func(childMenuID, childActionID int) (int, bool)) int {
- rows, err := orm.SearchPage(ctx, sysMenuModel, [][]interface{}{{"parent_id", "=", parentMenuID}}, 500, 0, "sequence asc, id asc")
- if err != nil {
- return 0
- }
- for _, row := range rows {
- childMenuID, _ := orm.CoerceInt64(row["id"])
- if childMenuID <= 0 {
- continue
- }
- aid, _ := orm.CoerceInt64(row["action_id"])
- if result, ok := visit(int(childMenuID), int(aid)); ok {
- return result
- }
- }
- return 0
+ return orm.CanonicalMenuID(ctx, menuQuery, actionID)
}
diff --git a/core/swc/src/devtools/debug.ts b/core/swc/src/devtools/debug.ts
index 28a70936..0673d1ed 100644
--- a/core/swc/src/devtools/debug.ts
+++ b/core/swc/src/devtools/debug.ts
@@ -14,7 +14,10 @@ export type DebugMode = "off" | "1" | "assets";
export function parseDebugParam(raw: string | null): DebugMode {
if (!raw) return "off";
- const v = raw.trim().toLowerCase();
+ let v = raw.trim().toLowerCase();
+ if (v.startsWith("true/")) {
+ v = "true";
+ }
if (v === "1" || v === "true" || v === "yes" || v === "on") return "1";
if (v === "assets") return "assets";
return "off";
diff --git a/core/swc/src/services/router.ts b/core/swc/src/services/router.ts
index 223b5c1f..6d4139f1 100644
--- a/core/swc/src/services/router.ts
+++ b/core/swc/src/services/router.ts
@@ -33,6 +33,14 @@ export interface WorkspaceRoute {
shell?: string;
}
+const moduleWebPathRE = /^\/web\/([a-z][a-z0-9_]*)$/;
+
+/** Module segment from /web/{module} entry URLs (empty when path is not a single-segment module open). */
+export function moduleNameFromWebPath(pathname: string): string {
+ const m = moduleWebPathRE.exec(pathname.trim());
+ return m?.[1] ?? "";
+}
+
export class RouterService {
static searchParams(route: Partial): URLSearchParams {
const params = new URLSearchParams();
@@ -56,8 +64,26 @@ export class RouterService {
return `${WEB_ROUTE}?${RouterService.searchParams(route).toString()}`;
}
- parse(location: { search: string } = window.location): WorkspaceRoute {
+ parse(location: { pathname?: string; search: string } = window.location): WorkspaceRoute {
const q = new URLSearchParams(location.search);
+ const pathname = location.pathname ?? "";
+ if (moduleNameFromWebPath(pathname) && !q.get(Q_ACTION)) {
+ return {
+ actionId: 0,
+ menuId: "",
+ viewType: "",
+ recordId: 0,
+ formEdit: false,
+ listSearch: "",
+ model: "",
+ listFilter: "",
+ listSort: "",
+ listOffset: 0,
+ listGroupBy: "",
+ listDomain: "",
+ shell: "",
+ };
+ }
return {
actionId: Number(q.get(Q_ACTION) ?? "0"),
menuId: q.get(Q_MENU_ID) ?? "",
diff --git a/core/swc/src/shell/ShellPageView.ts b/core/swc/src/shell/ShellPageView.ts
index e0ff0708..4ec26b17 100644
--- a/core/swc/src/shell/ShellPageView.ts
+++ b/core/swc/src/shell/ShellPageView.ts
@@ -8,7 +8,11 @@ interface ShellPageProps {
}
function appHref(action: string): string {
- const id = action.replace(/\D/g, "") || action;
+ const href = action.trim();
+ if (href.startsWith("/web")) {
+ return href;
+ }
+ const id = href.replace(/\D/g, "") || href;
return `/web?action=${encodeURIComponent(id)}`;
}
diff --git a/core/swc/tests/services/router-module-path.test.ts b/core/swc/tests/services/router-module-path.test.ts
new file mode 100644
index 00000000..99588e9e
--- /dev/null
+++ b/core/swc/tests/services/router-module-path.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+import { moduleNameFromWebPath, RouterService } from "../../src/services/router.js";
+
+describe("moduleNameFromWebPath", () => {
+ it("parses application module entry paths", () => {
+ expect(moduleNameFromWebPath("/web/crm")).toBe("crm");
+ expect(moduleNameFromWebPath("/web/engagement_cookbook")).toBe("engagement_cookbook");
+ });
+
+ it("rejects reserved and multi-segment paths", () => {
+ expect(moduleNameFromWebPath("/web/apps")).toBe("apps");
+ expect(moduleNameFromWebPath("/web/swc/workspace")).toBe("");
+ expect(moduleNameFromWebPath("/web")).toBe("");
+ });
+});
+
+describe("RouterService module pathname", () => {
+ it("returns empty workspace route for module entry without query action", () => {
+ const router = new RouterService();
+ const route = router.parse({ pathname: "/web/crm", search: "" });
+ expect(route.actionId).toBe(0);
+ expect(route.menuId).toBe("");
+ });
+});
diff --git a/test/core/engine/render/shell_routes_test.go b/test/core/engine/render/shell_routes_test.go
new file mode 100644
index 00000000..c249aab0
--- /dev/null
+++ b/test/core/engine/render/shell_routes_test.go
@@ -0,0 +1,35 @@
+package render_test
+
+import (
+ "sumeru/core/engine/render"
+ "testing"
+)
+
+func TestModuleWebURL(t *testing.T) {
+ if got := render.ModuleWebURL("crm"); got != "/web/crm" {
+ t.Fatalf("got %q", got)
+ }
+ if got := render.ModuleWebURL("engagement_cookbook"); got != "/web/engagement_cookbook" {
+ t.Fatalf("got %q", got)
+ }
+ if render.ModuleWebURL("apps") != "" {
+ t.Fatal("reserved segment apps should be rejected")
+ }
+ if render.ModuleWebURL("home") != "" {
+ t.Fatal("reserved segment home should be rejected")
+ }
+}
+
+func TestParseDebugQuery(t *testing.T) {
+ enabled, mod := render.ParseDebugQuery("true")
+ if !enabled || mod != "" {
+ t.Fatalf("got enabled=%v mod=%q", enabled, mod)
+ }
+ enabled, mod = render.ParseDebugQuery("true/crm")
+ if !enabled || mod != "crm" {
+ t.Fatalf("got enabled=%v mod=%q", enabled, mod)
+ }
+ if enabled, _ := render.ParseDebugQuery("false"); enabled {
+ t.Fatal("false should not enable debug")
+ }
+}
diff --git a/test/core/orm/apikey_policy_test.go b/test/core/orm/apikey_policy_test.go
new file mode 100644
index 00000000..ddf2b450
--- /dev/null
+++ b/test/core/orm/apikey_policy_test.go
@@ -0,0 +1,41 @@
+package orm_test
+
+import (
+ "testing"
+ "time"
+
+ "sumeru/core/orm"
+)
+
+func TestValidateAPIKeyExpiresAt(t *testing.T) {
+ now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
+ if err := orm.ValidateAPIKeyExpiresAt(time.Time{}, now); err == nil {
+ t.Fatal("zero expiry should fail")
+ }
+ if err := orm.ValidateAPIKeyExpiresAt(now, now); err == nil {
+ t.Fatal("past or equal expiry should fail")
+ }
+ if err := orm.ValidateAPIKeyExpiresAt(now.Add(31*24*time.Hour), now); err == nil {
+ t.Fatal("over 30 days should fail")
+ }
+ if err := orm.ValidateAPIKeyExpiresAt(now.Add(7*24*time.Hour), now); err != nil {
+ t.Fatalf("7 days should pass: %v", err)
+ }
+}
+
+func TestExpiresAtFromDurationDays(t *testing.T) {
+ now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
+ if _, err := orm.ExpiresAtFromDurationDays(0, now); err == nil {
+ t.Fatal("0 days should fail")
+ }
+ if _, err := orm.ExpiresAtFromDurationDays(31, now); err == nil {
+ t.Fatal("31 days should fail")
+ }
+ exp, err := orm.ExpiresAtFromDurationDays(30, now)
+ if err != nil {
+ t.Fatalf("30 days: %v", err)
+ }
+ if !exp.After(now) {
+ t.Fatal("expected future expiry")
+ }
+}
diff --git a/test/core/orm/menu_action_test.go b/test/core/orm/menu_action_test.go
new file mode 100644
index 00000000..958e4d7d
--- /dev/null
+++ b/test/core/orm/menu_action_test.go
@@ -0,0 +1,26 @@
+package orm_test
+
+import (
+ "sumeru/core/orm"
+ "testing"
+)
+
+func TestMenuRecordActionID(t *testing.T) {
+ actionID, ok := orm.MenuRecordActionID(map[string]interface{}{"action_id": int64(7)})
+ if !ok || actionID != 7 {
+ t.Fatalf("got actionID=%d ok=%v", actionID, ok)
+ }
+ if _, ok := orm.MenuRecordActionID(map[string]interface{}{"action_id": int64(0)}); ok {
+ t.Fatal("zero action_id should be rejected")
+ }
+}
+
+func TestParseMenuIDString(t *testing.T) {
+ menuID, ok := orm.ParseMenuIDString(" 12 ")
+ if !ok || menuID != 12 {
+ t.Fatalf("got id=%d ok=%v", menuID, ok)
+ }
+ if _, ok := orm.ParseMenuIDString("0"); ok {
+ t.Fatal("zero menu id should be rejected")
+ }
+}
diff --git a/test/core/server/web/apikey_duration_test.go b/test/core/server/web/apikey_duration_test.go
new file mode 100644
index 00000000..cd70e50f
--- /dev/null
+++ b/test/core/server/web/apikey_duration_test.go
@@ -0,0 +1,41 @@
+package web_test
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "sumeru/core/server/web"
+)
+
+func TestAPIKeyExpiresAtFromForm(t *testing.T) {
+ t.Run("missing duration", func(t *testing.T) {
+ req := httptest.NewRequest("POST", "/", strings.NewReader("name=k"))
+ _ = req.ParseForm()
+ _, msg, err := web.APIKeyExpiresAtFromFormForTest(req)
+ if err != nil || msg != "api_key_duration_required" {
+ t.Fatalf("msg=%q err=%v", msg, err)
+ }
+ })
+ t.Run("invalid duration", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("duration_days=abc"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ if err := req.ParseForm(); err != nil {
+ t.Fatal(err)
+ }
+ _, msg, _ := web.APIKeyExpiresAtFromFormForTest(req)
+ if msg != "api_key_duration_invalid" {
+ t.Fatalf("msg=%q", msg)
+ }
+ })
+ t.Run("31 days rejected", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/", nil)
+ req.PostForm = url.Values{"duration_days": {"31"}}
+ _, msg, _ := web.APIKeyExpiresAtFromFormForTest(req)
+ if msg != "api_key_duration_invalid" {
+ t.Fatalf("msg=%q", msg)
+ }
+ })
+}
diff --git a/test/core/server/web/smoke_test.go b/test/core/server/web/smoke_test.go
index db403fa2..9edfbf56 100644
--- a/test/core/server/web/smoke_test.go
+++ b/test/core/server/web/smoke_test.go
@@ -80,6 +80,25 @@ func TestSmoke_registerAppRoutesRootRedirectsToHome(t *testing.T) {
}
}
+func TestSmoke_moduleWebEntryUnauthenticatedRedirectsLogin(t *testing.T) {
+ mux := http.NewServeMux()
+ web.RegisterAppRoutes(mux)
+ h := web.SecurityMiddleware(mux)
+
+ rr := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/web/crm", nil)
+ h.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusFound {
+ t.Fatalf("GET /web/crm: status %d; want %d", rr.Code, http.StatusFound)
+ }
+ loc := rr.Header().Get("Location")
+ u, err := url.Parse(loc)
+ if err != nil || u.Path != "/web/login" {
+ t.Fatalf("Location %q: want path /web/login", loc)
+ }
+}
+
func TestSmoke_registerAppRoutesWebAppsTrailingSlashRedirect(t *testing.T) {
mux := http.NewServeMux()
web.RegisterAppRoutes(mux)
diff --git a/test/core/server/web/workspace_resolve_test.go b/test/core/server/web/workspace_resolve_test.go
index eb31340a..cf2198d8 100644
--- a/test/core/server/web/workspace_resolve_test.go
+++ b/test/core/server/web/workspace_resolve_test.go
@@ -1,8 +1,10 @@
package web_test
import (
- "sumeru/core/server/web"
+ "strings"
"testing"
+
+ "sumeru/core/server/web"
)
func TestParseMenuIDString(t *testing.T) {
@@ -54,3 +56,17 @@ func TestResolveWindowActionID_prefersActionQuery(t *testing.T) {
t.Fatalf("action query should win over menu query, got %d", got)
}
}
+
+func TestResolveWorkspaceRedirect_defaultNotApps(t *testing.T) {
+ ctx := t.Context()
+ url, ok := web.ResolveWorkspaceRedirect(ctx, "999999", 0)
+ if !ok {
+ t.Fatal("expected redirect")
+ }
+ if url == web.TestAppsRoute {
+ t.Fatalf("default redirect must not be apps catalog, got %q", url)
+ }
+ if url != web.TestHomeRoute && !strings.HasPrefix(url, web.TestHomeRoute+"?") {
+ t.Fatalf("got %q want home route prefix", url)
+ }
+}