mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-11-01 12:42:19 +08:00
* cubesandbox support
* feat(agent): add sandbox shell execution, file management tools, and Artifacts collection/download system
- Add three sandbox agent tools: shell_exec / sandbox_ls / sandbox_read
- shell_exec: safely execute shell commands in session sandbox with timeout & output truncation
- sandbox_ls: enumerate files under sandbox artifact output directory with recursive listing & pagination
- sandbox_read: read sandbox file content with offset-based chunked reading
- Add Artifacts system:
- ArtifactCollector: real-time collection of sandbox tool output files, attached to messages
- /api/artifacts/{session_id}/{message_id}/{filename}: download endpoint for frontend
- ChatArtifactsDrawer.vue: frontend artifacts sidebar UI
- DB migration 000066_message_artifacts: add artifacts column to messages table
- Attachment sandbox staging: upload user attachments to session sandbox on QA request for skill tool access
- Update Agent Prompts: add shell/sandbox tool usage constraints and safety rules
- Extend Agent Observe: include agent steps in Langfuse observability tracing
- Refactor Sandbox SessionManager: support session-scoped file listing, reading, and status queries
- Frontend: render artifact events in AgentStreamDisplay, i18n updates for 4 locales
- Add comprehensive tests: shell_exec_test, sandbox_read_test, artifact_collector_test, etc.
* feat(agent):add remote sandbox client abstract & client errors
* feat(sandbox): implement Redis binding store with distributed lifecycle lock;
* feat(sandbox): add remote session lifecycle with ownership context
Implement remoteSessionLifecycle to coordinate persistent remote sandboxes
across multi-instance deployments. Supports Resolve (lazy provision,
recovery, race-safe create) and Destroy with compare-delete semantics.
Add ownership context isolation to decouple remote resource cleanup from
request cancellation.
* feat(sandbox): add CubeRemoteClient adapter for RemoteSandboxClient
Implement CubeRemoteClient as a provider-neutral RemoteSandboxClient
implementation. Error normalization, timeout mapping, and metadata
support are fully integrated. SDK details remain private to the
adapter.
* refactor(sandbox): decouple SessionBoundManager from Cube
Remove internal/sandbox/cube.go (CubeSandbox, sync.Map reaper). Add
RemoteSandbox as provider-neutral Sandbox interface implementation.
Rewrite SessionBoundManager to depend on RemoteSandboxClient +
SessionSandboxBindingStore + SessionExistenceChecker, with all
lifecycle ops delegated to remoteSessionLifecycle (Redis + distributed
lock). Application-layer interfaces migrated to RemoteDirEntry/StatEntry
with time.Time ModTime. Cleanup delegated to provider idle-timeout.
* refactor(agent): enhance session capability handling in sandbox manager
- Introduced sessionFileStore and sessionShellExecutor functions to centralize capability checks for session-scoped file and shell execution.
- Updated agent service to register tools based on the presence of session filesystem capabilities, improving compatibility with various sandbox backends.
- Refactored tests to validate behavior when session capabilities are absent, ensuring robust handling of different sandbox types.
- Removed Cube-specific checks, promoting a more provider-neutral approach to session management.
* refactor(sandbox): add E2B backend, modernize Cube adapter, and enable multi-instance Redis binding
- Add e2b_remote_client.go (~850 lines) — full RemoteSandboxClient implementation
covering lifecycle, execution, and filesystem ops (ListDir/Stat/MakeDir/Remove)
- Wire E2B into NewManagerFromType and container/bootstrap path (buildE2BManager)
- Dependency: matiasinsaurralde/go-e2b v0.1.0
- Cut ~1500 lines of legacy SDK wrapper (cube_client.go, cube_envd.go,
cube_defaults.go) and replace with direct *cubesandbox.Client usage
- Parse CubeProxy URL directly in adapter (parseProxyURL), removing the
old hand-rolled envd transport layer
- Pass lifecycle extra fields (onTimeout, autoResume) via SDK CreateOptions.Extra
- Target Cube deployment v0.5.1
- Add RemoteNetworkPolicy to RemoteCreateRequest with AllowInternetAccess,
AllowPublicTraffic, AllowOut/DenyOut — shared by Cube and E2B adapters
- Both adapters default to permissive (internet+public on) for backwards compat
- Add selectSessionBindingStore() — Redis-backed or in-memory, controlled by
WEKNORA_SANDBOX_REDIS_NAMESPACE / WEKNORA_REDIS_NAMESPACE
- Add repositorySessionExistenceChecker — checks session existence via DB
(gorm.ErrRecordNotFound → not exists, transient errors → preserve binding)
- newSandboxManager() now accepts redisClient + sessionRepo; deprecated
hardcoded NewMemorySessionSandboxBindingStore() in production path
- buildSessionCreateRequest default timeout action: Kill → Pause+AutoResume
(Cube and E2B). Sessions survive brief idle periods; providers still
handle TTL-based reaping.
- buildSessionCreateRequest() and effectiveHTTPTimeout() parameterized
by RemoteProvider (was hardcoded to Cube Config fields)
- NewSessionBoundManager validates provider and delegates defaults to
per-provider apply*Defaults helpers
- Legacy SDK wrapper: cube_client.go, cube_envd.go, cube_defaults.go,
cube_envd_test.go, cube_net_diag_test.go, cube_test.go (~2500 lines)
- Unused helpers: DefaultManager.validateExecution, IsRemoteTerminal,
IsRemoteUnsupported, IsRemoteTransient, ShouldPreserveRemoteBinding
- Dead Config fields: CubeEnvdPort, CubeIdleTTL, AllowedCmds
- Dead ExecuteResult method: GetOutput
- Unused test: session_artifacts_integration_test.go
- Preloaded skill: sandbox-test-python
- Extract shared httpErrorKind() from per-adapter normalize functions
- Add SupportsFilesystemEnumeration capability flag — gates SessionFileStore
advertisement (E2B returns true post-reconnect, Cube always true)
- Fix stale comments: Cube-only → remote backends, remove "tomorrow"/"future"
- Bump session sandbox TTL defaults: 604800s → 300s (.env.example)
- Update docs/agent-skills.md: full multi-instance deployment guide
Total: 29 files, +1188 / -3721
* feat(agent): bind collected skill artifacts to owning message in resource registry
When ArtifactCollector persists a skill-generated file it now registers a
resource:// binding (owner_type=message, owner_id=assistantMessageID,
relation=artifact) in addition to the messages.artifacts JSONB record. This
mirrors how knowledge uploads and chat attachments bind their resources, so
artifacts can be enumerated and garbage-collected through the resource
registry instead of only via the message JSONB column.
- ArtifactCollector: inject ResourceCatalog, add messageID to Collect/
maybePersist, and add best-effort bindArtifactResource (binding failures are
logged, never drop the artifact; only resource:// refs are bound).
- agent_stream_handler: pass assistantMessageID into Collect.
- Renumber message_artifacts migration 000066 -> 000077 to resolve the
version collision with existing migrations (upstream now uses 000075/000076).
- Tests: update collector signatures and add coverage for the binding path
and for graceful degradation when Bind fails.
Note: binding applies to newly generated artifacts only; existing rows are
not backfilled.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(sandbox): harden remote sandbox security and artifact collection
Disable Cube local fallback so skill scripts never run on the host when
the remote provider is unavailable. Require Redis for cube/e2b binding
unless WEKNORA_SANDBOX_ALLOW_MEMORY_BINDING=true for single-node dev.
Collect artifacts with WithoutCancel so user stop does not drop files,
reject shell_exec work_dir outside /workspace, and refuse session-scoped
Execute when a local fallback would be engaged.
* fix(ci): gofmt sandbox files and resolve artifact drawer type error
Apply gofmt to PR-touched Go files and narrow ChatArtifactsDrawer
props in AgentStreamDisplay so session-id is always a string.
* fix(sandbox): adapt to upstream main after rebase
Renumber the message_artifacts migration 000077 -> 000079: upstream now
uses 000077_remove_wiki_log and 000078_chunk_editing_and_custom_metadata.
Declare empty model-handle policies for shell_exec / list_sandbox_files /
read_sandbox_file. Upstream added a coverage test requiring every
UI-exposed tool to declare one; sandbox output is arbitrary skill-authored
content, so it gets the same treatment as execute_skill_script.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(sandbox): replace single tenant sandbox config column with a configs table to support multi-sandbox config per tenant.
* feat(sandbox): add sandbox config entity, cordon lease and session pin field
* feat(sandbox): add sandbox config repository with tenant scoping and cordon lease
* feat(sandbox): stamp sandbox config ID onto sandbox metadata
* feat(sandbox): scope sandbox listing, release and reaping to a single config
* feat(sandbox): resolve managers per config and refuse cordoned or missing configs
* feat(sandbox): implement session pinning and config identity change detection
- Add SessionSandboxPinner with read/pin/clear to bind session to config
- Refactor four entry points (execute/attachment/artifact/destroy) to use pinned config
- Add config_identity.go to detect identity changes by effective values (APIURL/APIKey/Domain/ProxyURL)
- Add SandboxIdentityChanged with key-masked comparison to prevent false 409 conflicts
- Fix artifact collection for default backend and destroy for legacy empty-pin sessions
* feat(sandbox): add multi-tenant sandbox config management and HTTP APIs
Service layer:
- Add sandbox config business service supporting CRUD and inventory listing
- For non-identity changes (e.g., template, TTL), save directly without locking or remote interactions
- For identity changes (e.g., API key, endpoint), reject save if there are still live sandboxes under this config on the remote side,
and return structured inventory details (sandbox count, affected sessions, related agents) for frontend prompting
- Before deleting a config, check remote live sandboxes; if occupied, reject and prompt user to end sessions or create a new config
- Identity comparison based on effective values to avoid normal saves being misidentified as credential rotation
HTTP interfaces and routing:
- Add REST endpoints for sandbox config (List/Create/Get/Update/Delete/Inventory)
- Mask sensitive credentials (API key, etc.) in all read responses
- Return 409 with structured inventory details on rejection scenarios
- sandbox-check probing supports specifying an existing config_id directly
- Remove old handler file, consolidate validation logic into service layer
- DI wiring and route registration for all six endpoints
* feat(sandbox): add sandbox_config_id field to allow agent referencing named sandbox config
- Add SandboxConfigID field to CustomAgentConfig with omitempty; empty value falls back to global defaults
- Validate config existence on create/update; return 400 if not found
- Inject sandboxConfigService into CustomAgentHandler
- Add unit tests covering field serialization and validation logic
* feat(sandbox, agent): Migrate sandbox config to multi-config CRUD, support named backend selection and workspace scripts policy
Migrate frontend API from legacy KV single-config to /sandbox-configs REST interface, supplement type definitions, detection methods and policy fields; refactor sandbox settings page to multi-config card grid, manage configs with cube/e2b tabs, add editor drawer for create/edit/delete, connection testing and occupancy hints, and add workspace scripts policy toggle; add sandbox config dropdown in Agent editor skill section, integrate config caching, and support selecting named config or falling back to deployment default.
* feat(sandbox): Unify sandbox disable semantics and add tenant-level policy master switch
- Backend: Unify interception in resolveTenantSandboxForConfig with priority: global disable > tenant disable > default fallback > named config; add deploymentSandboxDisabled() using env var to avoid mis‑impacting named E2B; update WorkspaceSandboxPolicy comment as master switch for entire workspace; add unit tests for various scenarios.
- Frontend: Sync i18n copy: button text changed to "Disable/Enable sandbox execution"; confirmation dialog clarifies impact scope; success/failure messages aligned.
- Semantic layering: WEKNORA_SANDBOX_MODE=disabled disables globally; tenant‑level "Disable sandbox execution" only disables for the current workspace.
* feat(sandbox): Add SSRF protection, Agent binding to named configs, and reference query capabilities
- Add outbound URL security guard to validate Cube/E2B API/Proxy addresses against SSRF (including cloud metadata), support allowing private endpoints via env var, link-local always blocked
- Add named backend type validation, only cube/e2b can be stored as workspace named config
- Add sandbox config ID field to Agent at runtime, resolve sandbox for attachments and execution based on Agent's selected named config instead of always global default
- Add query to list Agents referencing a sandbox config ID, useful for deletion/changes to show dependents
- Adapt artifact collector and other callers to fill missing resolve signature parameter
* fix(sandbox): fix session teardown and config-update, add HTTP pool sharing for Cube
Session teardown & deletion ordering
- Move sandbox destroy (and pin `Clear`) before soft‑delete operations (`DeleteSession`/`BatchDeleteSessions`/`DeleteAllSessions`) to prevent remote sandbox leaks and stale pins.
- Pass deployment‑wide `SessionBoundManager` as fallback into `resolveTenantSandboxForConfig` for sentinel pins (`"-"`) to support deployment‑default sessions.
## Config update edge cases
- When identity fields change but old credentials cannot build a client or list config sandboxes, log a warning and proceed with the update instead of returning `sandbox_inventory_unverifiable`; live sandboxes still return 409.
- UI: move `sandboxes_still_live` / `unverifiable` alerts to the top of the sandbox config drawer and auto‑scroll on save refusal and connectivity check results.
Artifact collection for Cube skills
- Pre‑create `/workspace/output` via envd with correct ownership/permissions for `DefaultSandboxExecUser` to ensure Cube skill artifacts are collected properly by `ArtifactCollector`.
HTTP pool sharing for named Cube configs
- Named sandbox configs rebuilt a client on every `Resolve`, causing fresh TCP connections to CubeAPI and the envd proxy on each request.
- Add `CubeTransportPool` and `cubeSplitTransport`:
- Control plane traffic reuses the same `SharedTransport` as E2B.
- Data plane traffic uses a guarded transport cached per proxy host:port, preserving the sandbox `Host` header.
- Wire the pool through the tenant resolver `buildClient`; keep SDK defaults for the deployment‑default manager and connectivity probes.
* feat(sandbox): sandbox execution DefaultUser change from root to user
* fix(multi-sandbox-config-per-tenant): align deployment config with tenant resolver and centralize defaults
- Merge form overrides onto DeploymentConfig() via WEKNORA_SANDBOX_* env vars
instead of DefaultConfig() hardcoded values, allowing empty fields to inherit
from .env rather than Go constants
- Centralize deployment baseline parsing in sandbox/defaults.go with
DeploymentConfig, CubeConfigFromEnv, E2BConfigFromEnv
- Delegate container startup to shared helpers to avoid drift
- Propagate tenant env_vars through ResolveEffectiveConfig into
buildSessionCreateRequest for both Cube and E2B session sandboxes
* fix(multi-sandbox-config-per-tenant): fix some bugs frontend.
* fix(sandbox): guard check-path dials, fix staging gate and shared-agent binding tenant
Three defects found while reviewing the remote sandbox work:
- The Cube connectivity probe and the deployment-default Cube/E2B managers
built clients without a guarded transport, so an admin-supplied api_url or
proxy_url could reach link-local addresses via DNS rebinding even though
save-time validation passed. All three now dial through the same outbound
guard the per-tenant resolver enforces.
- Attachment staging was gated on the process-wide manager, so a workspace
pointing an agent at a named Cube/E2B config while the deployment default
stayed docker/local would run its tools remotely against an empty
/workspace/input. The gate now resolves the manager the session actually
runs on.
- Session to sandbox bindings were keyed by the tenant in context, which a
shared agent deliberately swaps for the agent owner's so models, KBs and
named configs resolve in the sharing workspace. Session deletion only ever
knows the session's own tenant, so teardown looked up a key nobody wrote
and abandoned a paused MicroVM that keeps billing. The binding tenant is
now carried separately and survives CloneContext.
* chore(sandbox): renumber migrations above upstream and de-collide test stub
Upstream main took 000079 (knowledge_folder_path) and 000080
(knowledge_base_auto_tag_config) while this branch was open, so the three
sandbox migrations move to 000081-000083. golang-migrate requires unique,
ordered versions, and the RAISE NOTICE labels move with the file names.
Main also added a handler test declaring stubSessionService in the same
package; the artifact download test's configurable variant is renamed rather
than merged, since the two stubs answer GetSession differently.
---------
Co-authored-by: hailongzhao <hailongzhao@tencent.com>
Co-authored-by: wizardchen <wizardchen@tencent.com>
2298 lines
92 KiB
Go
2298 lines
92 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Tencent/WeKnora/internal/application/repository"
|
|
"github.com/Tencent/WeKnora/internal/application/service"
|
|
"github.com/Tencent/WeKnora/internal/application/service/file"
|
|
"github.com/Tencent/WeKnora/internal/config"
|
|
"github.com/Tencent/WeKnora/internal/database"
|
|
apperrors "github.com/Tencent/WeKnora/internal/errors"
|
|
"github.com/Tencent/WeKnora/internal/infrastructure/docparser"
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|
modellimiter "github.com/Tencent/WeKnora/internal/models/limiter"
|
|
"github.com/Tencent/WeKnora/internal/runtime"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|
secutils "github.com/Tencent/WeKnora/internal/utils"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/neo4j/neo4j-go-driver/v6/neo4j"
|
|
)
|
|
|
|
type runtimeKnowledgeCanceller interface {
|
|
CancelKnowledgeParse(ctx context.Context, knowledgeID string) (*types.Knowledge, error)
|
|
}
|
|
|
|
// SystemHandler handles system-related requests
|
|
type SystemHandler struct {
|
|
cfg *config.Config
|
|
neo4jDriver neo4j.Driver
|
|
documentReader interfaces.DocumentReader
|
|
tenantSvc interfaces.TenantService
|
|
userSvc interfaces.UserService
|
|
systemSettingSvc interfaces.SystemSettingService
|
|
apiKeySvc interfaces.TenantAPIKeyService
|
|
// auditSvc is optional — when nil, emitAdminAudit no-ops so unit
|
|
// tests that wire a partial container still compile. In production
|
|
// the dig graph always provides one.
|
|
auditSvc interfaces.AuditLogService
|
|
// taskInspector backs the SystemAdmin runtime queue dashboard. Always
|
|
// provided by the container (asynq-backed in Redis mode, a no-op in
|
|
// Lite mode), so GetRuntimeQueues can distinguish "no queues in this
|
|
// deployment" from "queues are empty".
|
|
taskInspector interfaces.TaskInspector
|
|
// knowledgeSvc supplies the domain-level cancellation path used by the
|
|
// runtime task console. It updates business state and tracing before queue
|
|
// records are removed, unlike a raw Redis deletion.
|
|
knowledgeSvc runtimeKnowledgeCanceller
|
|
// storageBackendRepo lets GetStorageEngineStatus report multi-instance
|
|
// storage backends (Settings → Storage) as "available", not just the legacy
|
|
// singleton tenant.StorageEngineConfig. Optional — nil in partially-wired
|
|
// unit tests, in which case only the legacy config is consulted.
|
|
storageBackendRepo interfaces.StorageBackendRepository
|
|
sandboxConfigSvc sandboxConfigService
|
|
}
|
|
|
|
// NewSystemHandler creates a new system handler
|
|
func NewSystemHandler(cfg *config.Config,
|
|
neo4jDriver neo4j.Driver,
|
|
documentReader interfaces.DocumentReader,
|
|
tenantSvc interfaces.TenantService,
|
|
userSvc interfaces.UserService,
|
|
systemSettingSvc interfaces.SystemSettingService,
|
|
apiKeySvc interfaces.TenantAPIKeyService,
|
|
auditSvc interfaces.AuditLogService,
|
|
taskInspector interfaces.TaskInspector,
|
|
knowledgeSvc interfaces.KnowledgeService,
|
|
storageBackendRepo interfaces.StorageBackendRepository,
|
|
sandboxConfigSvc *service.TenantSandboxConfigService,
|
|
) *SystemHandler {
|
|
return &SystemHandler{
|
|
cfg: cfg,
|
|
neo4jDriver: neo4jDriver,
|
|
documentReader: documentReader,
|
|
tenantSvc: tenantSvc,
|
|
userSvc: userSvc,
|
|
systemSettingSvc: systemSettingSvc,
|
|
apiKeySvc: apiKeySvc,
|
|
auditSvc: auditSvc,
|
|
taskInspector: taskInspector,
|
|
knowledgeSvc: knowledgeSvc,
|
|
storageBackendRepo: storageBackendRepo,
|
|
sandboxConfigSvc: sandboxConfigSvc,
|
|
}
|
|
}
|
|
|
|
type platformAPIKeyCreateRequest struct {
|
|
Name string `json:"name"`
|
|
Capabilities []string `json:"capabilities"`
|
|
ExpiresAt *int64 `json:"expires_at_unix"`
|
|
}
|
|
|
|
// ListPlatformAPIKeys godoc
|
|
// @Summary List platform API keys
|
|
// @Description Lists non-workspace API keys. Full tokens are always masked. Human SystemAdmin only.
|
|
// @Tags System Admin
|
|
// @Produce json
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Security Bearer
|
|
// @Router /system/admin/api-keys [get]
|
|
func (h *SystemHandler) ListPlatformAPIKeys(c *gin.Context) {
|
|
keys, err := h.apiKeySvc.ListPlatformAPIKeys(c.Request.Context())
|
|
if err != nil {
|
|
c.Error(apperrors.NewInternalServerError("Failed to list platform API keys").WithDetails(err.Error()))
|
|
return
|
|
}
|
|
response := make([]tenantAPIKeyResponse, 0, len(keys))
|
|
for _, key := range keys {
|
|
item := tenantAPIKeyForResponse(key)
|
|
item.APIKey = maskManagedAPIKey(key.APIKey)
|
|
response = append(response, item)
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": response})
|
|
}
|
|
|
|
// CreatePlatformAPIKey godoc
|
|
// @Summary Create a platform API key
|
|
// @Description Creates a capability-scoped key that may target any workspace through X-Tenant-ID. The plaintext token is returned once. Human SystemAdmin only.
|
|
// @Tags System Admin
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body platformAPIKeyCreateRequest true "Platform API key"
|
|
// @Success 201 {object} map[string]interface{}
|
|
// @Security Bearer
|
|
// @Router /system/admin/api-keys [post]
|
|
func (h *SystemHandler) CreatePlatformAPIKey(c *gin.Context) {
|
|
var req platformAPIKeyCreateRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.Error(apperrors.NewValidationError("Invalid request data").WithDetails(err.Error()))
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.Name) == "" {
|
|
c.Error(apperrors.NewValidationError("name is required"))
|
|
return
|
|
}
|
|
capabilities := types.NormalizeAPIKeyCapabilities(types.StringArray(req.Capabilities))
|
|
if len(capabilities) == 0 || len(capabilities) != len(req.Capabilities) {
|
|
c.Error(apperrors.NewValidationError("valid capabilities are required"))
|
|
return
|
|
}
|
|
var expiresAt *time.Time
|
|
if req.ExpiresAt != nil {
|
|
value := time.Unix(*req.ExpiresAt, 0).UTC()
|
|
if !value.After(time.Now().UTC()) {
|
|
c.Error(apperrors.NewValidationError("expires_at_unix must be in the future"))
|
|
return
|
|
}
|
|
expiresAt = &value
|
|
}
|
|
result, err := h.apiKeySvc.CreateAPIKey(c.Request.Context(), interfaces.TenantAPIKeyCreateRequest{
|
|
ScopeType: types.APIKeyScopePlatform,
|
|
Name: req.Name,
|
|
Capabilities: []string(capabilities),
|
|
ExpiresAt: expiresAt,
|
|
})
|
|
if err != nil {
|
|
c.Error(apperrors.NewInternalServerError("Failed to create platform API key").WithDetails(err.Error()))
|
|
return
|
|
}
|
|
item := tenantAPIKeyForResponse(result.APIKey)
|
|
item.APIKey = maskManagedAPIKey(result.Token)
|
|
h.emitAPIKeyAudit(c.Request.Context(), types.AuditActionSystemAPIKeyCreated, result.APIKey)
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"success": true,
|
|
"data": tenantAPIKeyCreateResponse{tenantAPIKeyResponse: item, Token: result.Token},
|
|
})
|
|
}
|
|
|
|
// DeletePlatformAPIKey godoc
|
|
// @Summary Revoke a platform API key
|
|
// @Description Immediately revokes a platform API key. Human SystemAdmin only.
|
|
// @Tags System Admin
|
|
// @Produce json
|
|
// @Param key_id path int true "API key ID"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Security Bearer
|
|
// @Router /system/admin/api-keys/{key_id} [delete]
|
|
func (h *SystemHandler) DeletePlatformAPIKey(c *gin.Context) {
|
|
id, err := strconv.ParseUint(c.Param("key_id"), 10, 64)
|
|
if err != nil || id == 0 {
|
|
c.Error(apperrors.NewBadRequestError("Invalid API key ID"))
|
|
return
|
|
}
|
|
if err := h.apiKeySvc.RevokePlatformAPIKey(c.Request.Context(), id); err != nil {
|
|
c.Error(apperrors.NewNotFoundError("Platform API key not found"))
|
|
return
|
|
}
|
|
h.emitAPIKeyAudit(c.Request.Context(), types.AuditActionSystemAPIKeyRevoked, &types.TenantAPIKey{ID: id})
|
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
}
|
|
|
|
func maskManagedAPIKey(token string) string {
|
|
token = strings.TrimSpace(token)
|
|
if len(token) <= 12 {
|
|
return "***"
|
|
}
|
|
return token[:7] + "..." + token[len(token)-4:]
|
|
}
|
|
|
|
func (h *SystemHandler) emitAPIKeyAudit(ctx context.Context, action types.AuditAction, key *types.TenantAPIKey) {
|
|
if h.auditSvc == nil || key == nil {
|
|
return
|
|
}
|
|
actorID, _ := types.UserIDFromContext(ctx)
|
|
details, _ := json.Marshal(map[string]any{
|
|
"scope_type": types.APIKeyScopePlatform,
|
|
"capabilities": key.Capabilities,
|
|
})
|
|
_ = h.auditSvc.Log(ctx, &types.AuditLog{
|
|
TenantID: 0, ActorUserID: actorID, ActorRole: systemAuditActorRole(ctx),
|
|
Action: action, TargetType: "api_key", TargetID: strconv.FormatUint(key.ID, 10),
|
|
Outcome: types.AuditOutcomeSuccess, Details: types.JSON(details),
|
|
})
|
|
}
|
|
|
|
func systemAuditActorRole(ctx context.Context) string {
|
|
if scope, ok := types.TenantAPIKeyScopeFromContext(ctx); ok && scope.IsPlatform() {
|
|
return "platform_api_key"
|
|
}
|
|
return "system_admin"
|
|
}
|
|
|
|
// emitAdminAudit writes one audit row for a system-admin lifecycle event
|
|
// (promote / revoke). Best-effort — a nil audit service or a write
|
|
// failure does not bubble up to the caller. Mirrors the failure
|
|
// semantics of tenantMemberService.emitAudit and the system settings
|
|
// audit hook.
|
|
//
|
|
// `details` may be nil; the JSON `{}` default applies.
|
|
func (h *SystemHandler) emitAdminAudit(
|
|
ctx context.Context,
|
|
action types.AuditAction,
|
|
target *types.User,
|
|
details map[string]any,
|
|
) {
|
|
if h.auditSvc == nil {
|
|
return
|
|
}
|
|
actorID, _ := types.UserIDFromContext(ctx)
|
|
var detailsJSON types.JSON
|
|
if details != nil {
|
|
if b, err := json.Marshal(details); err == nil {
|
|
detailsJSON = types.JSON(b)
|
|
}
|
|
}
|
|
entry := &types.AuditLog{
|
|
// tenant_id=0 marks the row as system-scope. The audit_logs
|
|
// table is tenant-scoped; 0 is the convention for platform-wide
|
|
// events (matches AuditActionSystemSettingChanged).
|
|
TenantID: 0,
|
|
ActorUserID: actorID,
|
|
ActorRole: systemAuditActorRole(ctx),
|
|
Action: action,
|
|
TargetType: "user",
|
|
Outcome: types.AuditOutcomeSuccess,
|
|
Details: detailsJSON,
|
|
}
|
|
if target != nil {
|
|
entry.TargetID = target.ID
|
|
entry.TargetUserID = target.ID
|
|
}
|
|
_ = h.auditSvc.Log(ctx, entry)
|
|
}
|
|
|
|
// GetSystemInfoResponse defines the response structure for system info
|
|
type GetSystemInfoResponse struct {
|
|
Version string `json:"version"`
|
|
Edition string `json:"edition"`
|
|
CommitID string `json:"commit_id,omitempty"`
|
|
BuildTime string `json:"build_time,omitempty"`
|
|
GoVersion string `json:"go_version,omitempty"`
|
|
KeywordIndexEngine string `json:"keyword_index_engine,omitempty"`
|
|
VectorStoreEngine string `json:"vector_store_engine,omitempty"`
|
|
GraphDatabaseEngine string `json:"graph_database_engine,omitempty"`
|
|
MinioEnabled bool `json:"minio_enabled,omitempty"`
|
|
DBVersion string `json:"db_version,omitempty"`
|
|
// DBMigrationError carries the human-readable error message recorded when
|
|
// the most recent startup migration attempt failed. Empty when migrations
|
|
// succeeded; non-empty values let the frontend surface a troubleshooting
|
|
// banner instead of silently hiding the DB version row (see issue #1319).
|
|
DBMigrationError string `json:"db_migration_error,omitempty"`
|
|
// StartedAt is the server process boot time (RFC3339, UTC).
|
|
StartedAt string `json:"started_at,omitempty"`
|
|
// UptimeSeconds is seconds elapsed since process start.
|
|
UptimeSeconds int64 `json:"uptime_seconds,omitempty"`
|
|
}
|
|
|
|
// 编译时注入的版本信息
|
|
var (
|
|
Version = "unknown"
|
|
Edition = "standard"
|
|
CommitID = "unknown"
|
|
BuildTime = "unknown"
|
|
GoVersion = "unknown"
|
|
)
|
|
|
|
// GetSystemInfo godoc
|
|
// @Summary 获取系统信息
|
|
// @Description 获取系统版本、构建信息和引擎配置
|
|
// @Tags 系统
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Success 200 {object} GetSystemInfoResponse "系统信息"
|
|
// @Router /system/info [get]
|
|
func (h *SystemHandler) GetSystemInfo(c *gin.Context) {
|
|
ctx := logger.CloneContext(c.Request.Context())
|
|
|
|
// Get keyword index engine from RETRIEVE_DRIVER
|
|
keywordIndexEngine := h.getKeywordIndexEngine()
|
|
|
|
// Get vector store engine from config or RETRIEVE_DRIVER
|
|
vectorStoreEngine := h.getVectorStoreEngine()
|
|
|
|
// Get graph database engine from NEO4J_ENABLE
|
|
graphDatabaseEngine := h.getGraphDatabaseEngine()
|
|
|
|
// Get MinIO enabled status
|
|
minioEnabled := h.isMinioConfigured(c)
|
|
|
|
dbMigrationErr := database.CachedMigrationError()
|
|
var dbVersion string
|
|
if ver, dirty, ok := database.CachedMigrationVersion(); ok {
|
|
dbVersion = fmt.Sprintf("%d", ver)
|
|
if dirty {
|
|
dbVersion += " (dirty)"
|
|
}
|
|
if dbMigrationErr != "" {
|
|
dbVersion += " (failed)"
|
|
}
|
|
} else if dbMigrationErr != "" {
|
|
// Failure happened before m.Version() could be read (e.g. could not
|
|
// open the database). Still emit a placeholder so the frontend renders
|
|
// the row and shows the troubleshooting banner.
|
|
dbVersion = "unknown"
|
|
}
|
|
|
|
var startedAt string
|
|
var uptimeSec int64
|
|
if boot := runtime.ServerStartedAt(); !boot.IsZero() {
|
|
startedAt = boot.UTC().Format(time.RFC3339)
|
|
uptimeSec = int64(runtime.ServerUptime().Seconds())
|
|
}
|
|
|
|
response := GetSystemInfoResponse{
|
|
Version: Version,
|
|
Edition: Edition,
|
|
CommitID: CommitID,
|
|
BuildTime: BuildTime,
|
|
GoVersion: GoVersion,
|
|
KeywordIndexEngine: keywordIndexEngine,
|
|
VectorStoreEngine: vectorStoreEngine,
|
|
GraphDatabaseEngine: graphDatabaseEngine,
|
|
MinioEnabled: minioEnabled,
|
|
DBVersion: dbVersion,
|
|
DBMigrationError: dbMigrationErr,
|
|
StartedAt: startedAt,
|
|
UptimeSeconds: uptimeSec,
|
|
}
|
|
|
|
logger.Info(ctx, "System info retrieved successfully")
|
|
c.JSON(200, gin.H{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": response,
|
|
})
|
|
}
|
|
|
|
func (h *SystemHandler) getDocReaderConnInfo() (addr, transport string) {
|
|
addr = strings.TrimSpace(os.Getenv("DOCREADER_ADDR"))
|
|
transport = strings.TrimSpace(os.Getenv("DOCREADER_TRANSPORT"))
|
|
if transport == "" {
|
|
transport = "grpc"
|
|
}
|
|
transport = strings.ToLower(transport)
|
|
return addr, transport
|
|
}
|
|
|
|
// ListParserEngines returns available document parser engines.
|
|
// Merges Go-native static engines with engines discovered from the remote
|
|
// docreader service, so newly added Python engines are auto-discovered.
|
|
// @Summary 列出可用的文档解析引擎
|
|
// @Tags 系统
|
|
// @Produce json
|
|
// @Success 200 {object} map[string]interface{} "解析引擎列表"
|
|
// @Router /system/parser-engines [get]
|
|
func (h *SystemHandler) ListParserEngines(c *gin.Context) {
|
|
var overrides map[string]string
|
|
if v, exists := c.Get(types.TenantInfoContextKey.String()); exists {
|
|
if tenant, ok := v.(*types.Tenant); ok && tenant != nil {
|
|
if tenant.ParserEngineConfig != nil {
|
|
overrides = tenant.ParserEngineConfig.ToOverridesMap()
|
|
}
|
|
if creds := tenant.Credentials.GetWeKnoraCloud(); creds != nil {
|
|
if overrides == nil {
|
|
overrides = make(map[string]string)
|
|
}
|
|
overrides["weknoracloud_app_id"] = creds.AppID
|
|
}
|
|
}
|
|
}
|
|
|
|
reader, docreaderAddr, docreaderTransport := h.resolveDocReader(c.Request.Context(), overrides)
|
|
connected := reader != nil && reader.IsConnected()
|
|
remoteEngines := h.fetchRemoteEngines(c.Request.Context(), reader, overrides)
|
|
engines := docparser.ListAllEngines(connected, overrides, remoteEngines)
|
|
c.JSON(200, gin.H{"code": 0, "msg": "success", "data": engines, "docreader_addr": docreaderAddr, "docreader_transport": docreaderTransport, "connected": connected})
|
|
}
|
|
|
|
// ReconnectDocReader reconnects the document converter to a new (or same) DocReader address.
|
|
// @Summary 重连文档解析服务
|
|
// @Tags 系统
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body object{addr string} true "DocReader 地址"
|
|
// @Success 200
|
|
// @Router /system/docreader/reconnect [post]
|
|
func (h *SystemHandler) ReconnectDocReader(c *gin.Context) {
|
|
var req struct {
|
|
Addr string `json:"addr" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(400, gin.H{"code": 1, "msg": "请提供 addr 参数"})
|
|
return
|
|
}
|
|
addr := strings.TrimSpace(req.Addr)
|
|
if addr == "" {
|
|
c.JSON(400, gin.H{"code": 1, "msg": "addr 不能为空"})
|
|
return
|
|
}
|
|
|
|
// SSRF validation for docreader address
|
|
if err := secutils.ValidateURLForSSRF(addr); err != nil {
|
|
logger.Warnf(c.Request.Context(), "SSRF validation failed for docreader addr: %v", err)
|
|
c.JSON(400, gin.H{"code": 1, "msg": secutils.FormatSSRFError("DocReader 地址", addr, err)})
|
|
return
|
|
}
|
|
|
|
if h.documentReader == nil {
|
|
c.JSON(500, gin.H{"code": 1, "msg": "document converter not initialized"})
|
|
return
|
|
}
|
|
|
|
if err := h.documentReader.Reconnect(addr); err != nil {
|
|
logger.Errorf(c.Request.Context(), "Failed to reconnect docreader to %s: %v", addr, err)
|
|
c.JSON(200, gin.H{"code": 1, "msg": fmt.Sprintf("连接失败: %v", err)})
|
|
return
|
|
}
|
|
|
|
var overrides map[string]string
|
|
if v, exists := c.Get(types.TenantInfoContextKey.String()); exists {
|
|
if tenant, ok := v.(*types.Tenant); ok && tenant != nil {
|
|
if tenant.ParserEngineConfig != nil {
|
|
overrides = tenant.ParserEngineConfig.ToOverridesMap()
|
|
}
|
|
if creds := tenant.Credentials.GetWeKnoraCloud(); creds != nil {
|
|
if overrides == nil {
|
|
overrides = make(map[string]string)
|
|
}
|
|
overrides["weknoracloud_app_id"] = creds.AppID
|
|
}
|
|
}
|
|
}
|
|
remoteEngines := h.fetchRemoteEngines(c.Request.Context(), h.documentReader, overrides)
|
|
engines := docparser.ListAllEngines(true, overrides, remoteEngines)
|
|
|
|
_, docreaderTransport := h.getDocReaderConnInfo()
|
|
c.JSON(200, gin.H{"code": 0, "msg": "连接成功", "data": engines, "docreader_addr": addr, "docreader_transport": docreaderTransport, "connected": true})
|
|
}
|
|
|
|
// CheckParserEngines runs availability check with the given config overrides (e.g. current form values).
|
|
// Used to test engine availability without saving; body shape matches ParserEngineConfig.
|
|
// @Summary 使用当前参数检测解析引擎可用性
|
|
// @Tags 系统
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body object true "解析引擎配置(与保存接口同结构)"
|
|
// @Success 200
|
|
// @Router /system/parser-engines/check [post]
|
|
func (h *SystemHandler) CheckParserEngines(c *gin.Context) {
|
|
var body types.ParserEngineConfig
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(400, gin.H{"code": 1, "msg": "请求体格式错误"})
|
|
return
|
|
}
|
|
var existing *types.ParserEngineConfig
|
|
var tenant *types.Tenant
|
|
if v, exists := c.Get(types.TenantInfoContextKey.String()); exists {
|
|
if t, ok := v.(*types.Tenant); ok && t != nil {
|
|
tenant = t
|
|
existing = t.ParserEngineConfig
|
|
}
|
|
}
|
|
merged := types.MergeParserEngineConfigForUpdate(&body, existing)
|
|
overrides := merged.ToOverridesMap()
|
|
if tenant != nil {
|
|
if creds := tenant.Credentials.GetWeKnoraCloud(); creds != nil {
|
|
if overrides == nil {
|
|
overrides = make(map[string]string)
|
|
}
|
|
overrides["weknoracloud_app_id"] = creds.AppID
|
|
}
|
|
}
|
|
reader, docreaderAddr, docreaderTransport := h.resolveDocReader(c.Request.Context(), overrides)
|
|
connected := reader != nil && reader.IsConnected()
|
|
remoteEngines := h.fetchRemoteEngines(c.Request.Context(), reader, overrides)
|
|
engines := docparser.ListAllEngines(connected, overrides, remoteEngines)
|
|
c.JSON(200, gin.H{"code": 0, "msg": "success", "data": engines, "docreader_addr": docreaderAddr, "docreader_transport": docreaderTransport, "connected": connected})
|
|
}
|
|
|
|
func (h *SystemHandler) resolveDocReader(ctx context.Context, overrides map[string]string) (interfaces.DocumentReader, string, string) {
|
|
if len(overrides) > 0 {
|
|
if addr := strings.TrimSpace(overrides["docreader_addr"]); addr != "" && service.IsWeKnoraCloudDocReaderAddr(addr) {
|
|
reader := h.ResolveDocumentReader(ctx, addr)
|
|
return reader, addr, transportFromDocReaderAddr(addr)
|
|
}
|
|
}
|
|
|
|
addr, transport := h.getDocReaderConnInfo()
|
|
return h.documentReader, addr, transport
|
|
}
|
|
|
|
func transportFromDocReaderAddr(addr string) string {
|
|
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(addr)), "https://") {
|
|
return "https"
|
|
}
|
|
return "http"
|
|
}
|
|
|
|
// fetchRemoteEngines queries the remote docreader for its engine list.
|
|
// Returns nil on any error (e.g. not connected), letting the caller
|
|
// fall back to Go's static registry only.
|
|
func (h *SystemHandler) fetchRemoteEngines(ctx context.Context, reader interfaces.DocumentReader, overrides map[string]string) []types.ParserEngineInfo {
|
|
if reader == nil || !reader.IsConnected() {
|
|
return nil
|
|
}
|
|
engines, err := reader.ListEngines(ctx, overrides)
|
|
if err != nil {
|
|
logger.Warnf(ctx, "Failed to fetch remote engines from docreader: %v", err)
|
|
return nil
|
|
}
|
|
return engines
|
|
}
|
|
|
|
// getKeywordIndexEngine returns the keyword index engine name
|
|
func (h *SystemHandler) getKeywordIndexEngine() string {
|
|
retrieveDriver := os.Getenv("RETRIEVE_DRIVER")
|
|
if retrieveDriver == "" {
|
|
return "未配置"
|
|
}
|
|
|
|
drivers := strings.Split(retrieveDriver, ",")
|
|
// Filter out engines that support keyword retrieval
|
|
keywordEngines := []string{}
|
|
for _, driver := range drivers {
|
|
driver = strings.TrimSpace(driver)
|
|
if h.supportsRetrieverType(driver, types.KeywordsRetrieverType) {
|
|
keywordEngines = append(keywordEngines, driver)
|
|
}
|
|
}
|
|
|
|
if len(keywordEngines) == 0 {
|
|
return "未配置"
|
|
}
|
|
return strings.Join(keywordEngines, ", ")
|
|
}
|
|
|
|
// getVectorStoreEngine returns the vector store engine name
|
|
func (h *SystemHandler) getVectorStoreEngine() string {
|
|
// First check config.yaml
|
|
if h.cfg != nil && h.cfg.VectorDatabase != nil && h.cfg.VectorDatabase.Driver != "" {
|
|
return h.cfg.VectorDatabase.Driver
|
|
}
|
|
|
|
// Fallback to RETRIEVE_DRIVER for vector support
|
|
retrieveDriver := os.Getenv("RETRIEVE_DRIVER")
|
|
if retrieveDriver == "" {
|
|
return "未配置"
|
|
}
|
|
|
|
drivers := strings.Split(retrieveDriver, ",")
|
|
// Filter out engines that support vector retrieval
|
|
vectorEngines := []string{}
|
|
for _, driver := range drivers {
|
|
driver = strings.TrimSpace(driver)
|
|
if h.supportsRetrieverType(driver, types.VectorRetrieverType) {
|
|
vectorEngines = append(vectorEngines, driver)
|
|
}
|
|
}
|
|
|
|
if len(vectorEngines) == 0 {
|
|
return "未配置"
|
|
}
|
|
return strings.Join(vectorEngines, ", ")
|
|
}
|
|
|
|
// getGraphDatabaseEngine returns the graph database engine name
|
|
func (h *SystemHandler) getGraphDatabaseEngine() string {
|
|
if h.neo4jDriver == nil {
|
|
return "Not Enabled"
|
|
}
|
|
return "Neo4j"
|
|
}
|
|
|
|
// supportsRetrieverType checks if a driver supports a specific retriever type
|
|
// by looking up the retrieverEngineMapping from types package
|
|
func (h *SystemHandler) supportsRetrieverType(driver string, retrieverType types.RetrieverType) bool {
|
|
// Get the mapping of all supported drivers and their capabilities
|
|
mapping := types.GetRetrieverEngineMapping()
|
|
|
|
// Check if the driver exists in the mapping
|
|
engines, exists := mapping[driver]
|
|
if !exists {
|
|
return false
|
|
}
|
|
|
|
// Check if any of the engine configurations support the requested retriever type
|
|
for _, engine := range engines {
|
|
if engine.RetrieverType == retrieverType {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// getMinioConfig resolves MinIO connection parameters from tenant config (if mode=remote) or env vars (mode=docker/default).
|
|
func (h *SystemHandler) getMinioConfig(c *gin.Context) (endpoint, accessKeyID, secretAccessKey string) {
|
|
if v, exists := c.Get(types.TenantInfoContextKey.String()); exists {
|
|
if tenant, ok := v.(*types.Tenant); ok && tenant != nil && tenant.StorageEngineConfig != nil && tenant.StorageEngineConfig.MinIO != nil {
|
|
m := tenant.StorageEngineConfig.MinIO
|
|
if m.Mode == "remote" {
|
|
return m.Endpoint, m.AccessKeyID, m.SecretAccessKey
|
|
}
|
|
}
|
|
}
|
|
endpoint = os.Getenv("MINIO_ENDPOINT")
|
|
accessKeyID = os.Getenv("MINIO_ACCESS_KEY_ID")
|
|
secretAccessKey = os.Getenv("MINIO_SECRET_ACCESS_KEY")
|
|
return
|
|
}
|
|
|
|
// activeBackendProviders returns the set of storage providers that have at
|
|
// least one active multi-instance backend registered for the caller's
|
|
// workspace. Used to keep GetStorageEngineStatus in sync with the new Storage
|
|
// settings UI, which writes to storage_backends rather than the legacy
|
|
// tenant.StorageEngineConfig singleton. Best-effort and nil-safe: a missing
|
|
// repo, missing tenant, or query error yields an empty set so callers fall
|
|
// back to the legacy config checks.
|
|
func (h *SystemHandler) activeBackendProviders(c *gin.Context) map[string]bool {
|
|
result := map[string]bool{}
|
|
if h.storageBackendRepo == nil {
|
|
return result
|
|
}
|
|
v, exists := c.Get(types.TenantInfoContextKey.String())
|
|
if !exists {
|
|
return result
|
|
}
|
|
tenant, ok := v.(*types.Tenant)
|
|
if !ok || tenant == nil {
|
|
return result
|
|
}
|
|
backends, err := h.storageBackendRepo.List(c.Request.Context(), tenant.ID)
|
|
if err != nil {
|
|
logger.Warnf(c.Request.Context(), "[storage] list backends for status failed: tenant=%d err=%v", tenant.ID, err)
|
|
return result
|
|
}
|
|
for _, backend := range backends {
|
|
if backend == nil || backend.Status != types.StorageBackendStatusActive {
|
|
continue
|
|
}
|
|
result[strings.ToLower(strings.TrimSpace(backend.Provider))] = true
|
|
}
|
|
return result
|
|
}
|
|
|
|
// isMinioConfigured checks whether MinIO connection info is available (from tenant config or env).
|
|
func (h *SystemHandler) isMinioConfigured(c *gin.Context) bool {
|
|
endpoint, accessKeyID, secretAccessKey := h.getMinioConfig(c)
|
|
return endpoint != "" && accessKeyID != "" && secretAccessKey != ""
|
|
}
|
|
|
|
// isMinioEnvAvailable checks whether MinIO env vars (MINIO_ENDPOINT etc.) are set.
|
|
func (h *SystemHandler) isMinioEnvAvailable() bool {
|
|
return os.Getenv("MINIO_ENDPOINT") != "" &&
|
|
os.Getenv("MINIO_ACCESS_KEY_ID") != "" &&
|
|
os.Getenv("MINIO_SECRET_ACCESS_KEY") != ""
|
|
}
|
|
|
|
// isCOSConfigured checks whether COS connection info is available from tenant config.
|
|
func (h *SystemHandler) isCOSConfigured(c *gin.Context) bool {
|
|
if v, exists := c.Get(types.TenantInfoContextKey.String()); exists {
|
|
if tenant, ok := v.(*types.Tenant); ok && tenant != nil && tenant.StorageEngineConfig != nil && tenant.StorageEngineConfig.COS != nil {
|
|
cosConf := tenant.StorageEngineConfig.COS
|
|
return cosConf.SecretID != "" && cosConf.SecretKey != "" && cosConf.Region != "" && cosConf.BucketName != ""
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isTOSConfigured checks whether TOS connection info is available from tenant config or env.
|
|
func (h *SystemHandler) isTOSConfigured(c *gin.Context) bool {
|
|
if v, exists := c.Get(types.TenantInfoContextKey.String()); exists {
|
|
if tenant, ok := v.(*types.Tenant); ok && tenant != nil && tenant.StorageEngineConfig != nil && tenant.StorageEngineConfig.TOS != nil {
|
|
tosConf := tenant.StorageEngineConfig.TOS
|
|
return tosConf.Endpoint != "" && tosConf.Region != "" && tosConf.AccessKey != "" && tosConf.SecretKey != "" && tosConf.BucketName != ""
|
|
}
|
|
}
|
|
return h.isTOSEnvAvailable()
|
|
}
|
|
|
|
// isOSSConfigured checks whether OSS connection info is available from tenant config.
|
|
func (h *SystemHandler) isOSSConfigured(c *gin.Context) bool {
|
|
if v, exists := c.Get(types.TenantInfoContextKey.String()); exists {
|
|
if tenant, ok := v.(*types.Tenant); ok && tenant != nil && tenant.StorageEngineConfig != nil && tenant.StorageEngineConfig.OSS != nil {
|
|
ossConf := tenant.StorageEngineConfig.OSS
|
|
return ossConf.Endpoint != "" && ossConf.Region != "" && ossConf.AccessKey != "" && ossConf.SecretKey != "" && ossConf.BucketName != ""
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isKS3Configured checks whether KS3 connection info is available from tenant config.
|
|
func (h *SystemHandler) isKS3Configured(c *gin.Context) bool {
|
|
if v, exists := c.Get(types.TenantInfoContextKey.String()); exists {
|
|
if tenant, ok := v.(*types.Tenant); ok && tenant != nil && tenant.StorageEngineConfig != nil && tenant.StorageEngineConfig.KS3 != nil {
|
|
ks3Conf := tenant.StorageEngineConfig.KS3
|
|
return ks3Conf.Endpoint != "" && ks3Conf.Region != "" && ks3Conf.AccessKey != "" && ks3Conf.SecretKey != "" && ks3Conf.BucketName != ""
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isOBSConfigured checks whether OBS connection info is available from tenant config or env.
|
|
func (h *SystemHandler) isOBSConfigured(c *gin.Context) bool {
|
|
if v, exists := c.Get(types.TenantInfoContextKey.String()); exists {
|
|
if tenant, ok := v.(*types.Tenant); ok && tenant != nil && tenant.StorageEngineConfig != nil && tenant.StorageEngineConfig.OBS != nil {
|
|
obsConf := tenant.StorageEngineConfig.OBS
|
|
return obsConf.Endpoint != "" && obsConf.Region != "" && obsConf.AccessKey != "" && obsConf.SecretKey != "" && obsConf.BucketName != ""
|
|
}
|
|
}
|
|
return h.isOBSEnvAvailable()
|
|
}
|
|
|
|
// isTOSEnvAvailable checks whether TOS env vars are set.
|
|
func (h *SystemHandler) isTOSEnvAvailable() bool {
|
|
return os.Getenv("TOS_ENDPOINT") != "" &&
|
|
os.Getenv("TOS_REGION") != "" &&
|
|
os.Getenv("TOS_ACCESS_KEY") != "" &&
|
|
os.Getenv("TOS_SECRET_KEY") != "" &&
|
|
os.Getenv("TOS_BUCKET_NAME") != ""
|
|
}
|
|
|
|
// isOBSEnvAvailable checks whether OBS env vars are set.
|
|
func (h *SystemHandler) isOBSEnvAvailable() bool {
|
|
return os.Getenv("OBS_ENDPOINT") != "" &&
|
|
os.Getenv("OBS_REGION") != "" &&
|
|
os.Getenv("OBS_ACCESS_KEY") != "" &&
|
|
os.Getenv("OBS_SECRET_KEY") != "" &&
|
|
os.Getenv("OBS_BUCKET_NAME") != ""
|
|
}
|
|
|
|
// StorageEngineStatusItem describes one storage engine's availability and description.
|
|
type StorageEngineStatusItem struct {
|
|
Name string `json:"name"` // "local", "minio", "cos", "tos", "s3", "oss", "ks3", "obs"
|
|
Allowed bool `json:"allowed"`
|
|
Available bool `json:"available"` // whether the engine can be used
|
|
Description string `json:"description"` // short description for UI
|
|
}
|
|
|
|
// GetStorageEngineStatusResponse is the response for GET /system/storage-engine-status.
|
|
type GetStorageEngineStatusResponse struct {
|
|
Engines []StorageEngineStatusItem `json:"engines"`
|
|
AllowedProviders []string `json:"allowed_providers"`
|
|
MinioEnvAvailable bool `json:"minio_env_available"`
|
|
}
|
|
|
|
// GetStorageEngineStatus godoc
|
|
// @Summary 获取存储引擎状态
|
|
// @Description 返回 Local、MinIO、COS 各存储引擎的可用状态及说明,供全局设置与知识库选择使用
|
|
// @Tags 系统
|
|
// @Produce json
|
|
// @Success 200 {object} GetStorageEngineStatusResponse
|
|
// @Router /system/storage-engine-status [get]
|
|
func (h *SystemHandler) GetStorageEngineStatus(c *gin.Context) {
|
|
// Providers that already have an active multi-instance backend registered
|
|
// (Settings → Storage). These are authoritative and independent of the
|
|
// legacy singleton tenant.StorageEngineConfig, so a workspace that only
|
|
// configured storage through the new UI is still reported as available.
|
|
activeBackend := h.activeBackendProviders(c)
|
|
minioConfigured := h.isMinioConfigured(c) || activeBackend["minio"]
|
|
minioEnvAvailable := h.isMinioEnvAvailable()
|
|
cosConfigured := h.isCOSConfigured(c) || activeBackend["cos"]
|
|
tosConfigured := h.isTOSConfigured(c) || activeBackend["tos"]
|
|
s3Configured := h.isS3Configured(c) || activeBackend["s3"]
|
|
ossConfigured := h.isOSSConfigured(c) || activeBackend["oss"]
|
|
ks3Configured := h.isKS3Configured(c) || activeBackend["ks3"]
|
|
obsConfigured := h.isOBSConfigured(c) || activeBackend["obs"]
|
|
allowed := getAllowedStorageProviders()
|
|
allowedProviders := make([]string, 0, len(getSupportedStorageProviders()))
|
|
for _, provider := range getSupportedStorageProviders() {
|
|
if allowed[provider] {
|
|
allowedProviders = append(allowedProviders, provider)
|
|
}
|
|
}
|
|
engines := []StorageEngineStatusItem{
|
|
{Name: "local", Allowed: allowed["local"], Available: true, Description: "本地文件系统存储,仅适合单机部署"},
|
|
{Name: "minio", Allowed: allowed["minio"], Available: minioConfigured || minioEnvAvailable, Description: "S3 兼容的自托管对象存储,适合内网和私有云部署"},
|
|
{Name: "cos", Allowed: allowed["cos"], Available: cosConfigured, Description: "腾讯云对象存储服务,适合公有云部署,支持 CDN 加速"},
|
|
{Name: "tos", Allowed: allowed["tos"], Available: tosConfigured, Description: "火山引擎对象存储服务,适合公有云部署"},
|
|
{Name: "s3", Allowed: allowed["s3"], Available: s3Configured, Description: "AWS S3 与兼容对象存储服务,适合公有云与混合云部署"},
|
|
{Name: "oss", Allowed: allowed["oss"], Available: ossConfigured, Description: "阿里云对象存储服务,适合公有云部署,支持 S3 兼容协议"},
|
|
{Name: "ks3", Allowed: allowed["ks3"], Available: ks3Configured, Description: "金山云对象存储服务,适合公有云部署"},
|
|
{Name: "obs", Allowed: allowed["obs"], Available: obsConfigured, Description: "华为云对象存储服务,适合公有云部署"},
|
|
}
|
|
c.JSON(200, gin.H{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": GetStorageEngineStatusResponse{Engines: engines, AllowedProviders: allowedProviders, MinioEnvAvailable: minioEnvAvailable},
|
|
})
|
|
}
|
|
|
|
// --- Storage engine helpers ---
|
|
// cosFieldPattern validates COS region and bucket name format to prevent URL injection.
|
|
var cosFieldPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,62}$`)
|
|
|
|
// ossFieldPattern validates OSS region and bucket name format to prevent URL injection.
|
|
var ossFieldPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,62}$`)
|
|
|
|
// sanitizeStorageCheckError converts a raw storage connectivity error into a safe
|
|
// user-facing message that does not leak internal network details (hostnames, IPs, ports).
|
|
// The concrete mapping lives in internal/utils so the service layer can share it.
|
|
func sanitizeStorageCheckError(err error) string {
|
|
return secutils.SanitizeStorageConnectivityError(err)
|
|
}
|
|
|
|
// storageEndpointHost extracts the hostname from a storage endpoint string.
|
|
// Endpoints may be bare host:port, hostnames, or full URLs with a scheme
|
|
// (e.g. "http://127.0.0.1:9000" for S3-compatible stores).
|
|
func storageEndpointHost(endpoint string) string {
|
|
endpoint = strings.TrimSpace(endpoint)
|
|
if endpoint == "" {
|
|
return ""
|
|
}
|
|
if strings.Contains(endpoint, "://") {
|
|
if u, err := url.Parse(endpoint); err == nil {
|
|
if h := u.Hostname(); h != "" {
|
|
return h
|
|
}
|
|
}
|
|
}
|
|
if host, _, err := net.SplitHostPort(endpoint); err == nil {
|
|
return host
|
|
}
|
|
return endpoint
|
|
}
|
|
|
|
// isBlockedStorageEndpoint keeps the legacy handler contract while delegating
|
|
// to the same fail-closed SSRF policy used by the storage clients themselves.
|
|
// Private storage endpoints must be explicitly whitelisted by an operator.
|
|
func isBlockedStorageEndpoint(endpoint string) (bool, string) {
|
|
endpoint = strings.TrimSpace(endpoint)
|
|
if endpoint == "" {
|
|
return true, "无效的地址"
|
|
}
|
|
if err := secutils.ValidateURLForSSRF(endpoint); err != nil {
|
|
return true, secutils.FormatSSRFError("存储 Endpoint", endpoint, err)
|
|
}
|
|
return false, ""
|
|
}
|
|
|
|
// --- Storage engine connectivity check ---
|
|
|
|
// StorageCheckRequest is the body for POST /system/storage-engine-check.
|
|
type StorageCheckRequest struct {
|
|
Provider string `json:"provider"` // "minio", "cos", "tos", "s3", "oss", "ks3", "obs"
|
|
MinIO *types.MinIOEngineConfig `json:"minio,omitempty"`
|
|
COS *types.COSEngineConfig `json:"cos,omitempty"`
|
|
TOS *types.TOSEngineConfig `json:"tos,omitempty"`
|
|
S3 *types.S3EngineConfig `json:"s3,omitempty"`
|
|
OSS *types.OSSEngineConfig `json:"oss,omitempty"`
|
|
KS3 *types.KS3EngineConfig `json:"ks3,omitempty"`
|
|
OBS *types.OBSEngineConfig `json:"obs,omitempty"`
|
|
}
|
|
|
|
// StorageCheckResponse is the response for a single-engine connectivity check.
|
|
type StorageCheckResponse struct {
|
|
OK bool `json:"ok"`
|
|
Message string `json:"message"`
|
|
BucketCreated bool `json:"bucket_created,omitempty"`
|
|
}
|
|
|
|
// CheckStorageEngine tests connectivity for a single storage engine using the provided config.
|
|
// @Summary 测试存储引擎连通性
|
|
// @Description 使用当前填写的参数测试 MinIO/COS 连通性,不保存配置
|
|
// @Tags 系统
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body StorageCheckRequest true "存储引擎配置"
|
|
// @Success 200 {object} StorageCheckResponse
|
|
// @Router /system/storage-engine-check [post]
|
|
func (h *SystemHandler) CheckStorageEngine(c *gin.Context) {
|
|
ctx := logger.CloneContext(c.Request.Context())
|
|
|
|
var req StorageCheckRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(400, gin.H{"code": 1, "msg": "请求体格式错误"})
|
|
return
|
|
}
|
|
if !isStorageProviderAllowed(req.Provider) {
|
|
c.JSON(403, gin.H{"code": 1, "msg": "该存储引擎已被禁用"})
|
|
return
|
|
}
|
|
|
|
switch req.Provider {
|
|
case "minio":
|
|
h.checkMinio(c, ctx, req.MinIO)
|
|
case "cos":
|
|
h.checkCOS(c, ctx, req.COS)
|
|
case "tos":
|
|
h.checkTOS(c, ctx, req.TOS)
|
|
case "s3":
|
|
h.checkS3(c, ctx, req.S3)
|
|
case "oss":
|
|
h.checkOSS(c, ctx, req.OSS)
|
|
case "ks3":
|
|
h.checkKS3(c, ctx, req.KS3)
|
|
case "obs":
|
|
h.checkOBS(c, ctx, req.OBS)
|
|
default:
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: true, Message: "本地存储无需检测"}})
|
|
}
|
|
}
|
|
|
|
func (h *SystemHandler) isS3Configured(c *gin.Context) bool {
|
|
if v, exists := c.Get(types.TenantInfoContextKey.String()); exists {
|
|
if tenant, ok := v.(*types.Tenant); ok && tenant != nil && tenant.StorageEngineConfig != nil && tenant.StorageEngineConfig.S3 != nil {
|
|
s3Conf := tenant.StorageEngineConfig.S3
|
|
return s3Conf.Region != "" && s3Conf.BucketName != "" && (s3Conf.AccessKey == "") == (s3Conf.SecretKey == "")
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (h *SystemHandler) checkMinio(c *gin.Context, ctx context.Context, cfg *types.MinIOEngineConfig) {
|
|
if cfg == nil {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "未提供 MinIO 配置"}})
|
|
return
|
|
}
|
|
|
|
if cfg.BucketName != "" && !cosFieldPattern.MatchString(cfg.BucketName) {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Bucket 名称格式不正确,仅允许字母、数字、点、连字符"}})
|
|
return
|
|
}
|
|
|
|
endpoint, accessKeyID, secretAccessKey := cfg.Endpoint, cfg.AccessKeyID, cfg.SecretAccessKey
|
|
if cfg.Mode != "remote" {
|
|
endpoint = os.Getenv("MINIO_ENDPOINT")
|
|
accessKeyID = os.Getenv("MINIO_ACCESS_KEY_ID")
|
|
secretAccessKey = os.Getenv("MINIO_SECRET_ACCESS_KEY")
|
|
}
|
|
if endpoint == "" || accessKeyID == "" || secretAccessKey == "" {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Endpoint、Access Key、Secret Key 不能为空"}})
|
|
return
|
|
}
|
|
|
|
if cfg.Mode == "remote" {
|
|
if blocked, reason := isBlockedStorageEndpoint(endpoint); blocked {
|
|
logger.Warnf(ctx, "Storage check: MinIO endpoint blocked by SSRF protection, endpoint: %s", endpoint)
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: reason}})
|
|
return
|
|
}
|
|
}
|
|
|
|
err := file.CheckMinioConnectivity(ctx, endpoint, accessKeyID, secretAccessKey, cfg.BucketName, cfg.UseSSL)
|
|
if err != nil {
|
|
errMsg := err.Error()
|
|
// If bucket does not exist, auto-create it
|
|
if strings.Contains(errMsg, "does not exist") && cfg.BucketName != "" {
|
|
logger.Info(ctx, "Storage check: bucket does not exist, attempting auto-creation", "bucket", cfg.BucketName)
|
|
if _, mkErr := file.NewMinioFileService(endpoint, accessKeyID, secretAccessKey, cfg.BucketName, cfg.UseSSL); mkErr != nil {
|
|
logger.Error(ctx, "Storage check: failed to create bucket", "bucket", cfg.BucketName, "error", mkErr)
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: fmt.Sprintf("Failed to auto-create Bucket '%s': %s", cfg.BucketName, sanitizeStorageCheckError(mkErr))}})
|
|
return
|
|
}
|
|
logger.Info(ctx, "Storage check: bucket created", "bucket", cfg.BucketName)
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: true, BucketCreated: true, Message: fmt.Sprintf("Bucket '%s' does not exist, and has been automatically created", cfg.BucketName)}})
|
|
return
|
|
}
|
|
logger.Error(ctx, "Storage check: MinIO connectivity failed", "error", err)
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: sanitizeStorageCheckError(err)}})
|
|
return
|
|
}
|
|
|
|
msg := "连接成功"
|
|
if cfg.BucketName != "" {
|
|
msg = fmt.Sprintf("连接成功,Bucket「%s」已确认存在", cfg.BucketName)
|
|
}
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: true, Message: msg}})
|
|
}
|
|
|
|
func (h *SystemHandler) checkCOS(c *gin.Context, ctx context.Context, cfg *types.COSEngineConfig) {
|
|
if cfg == nil {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "未提供 COS 配置"}})
|
|
return
|
|
}
|
|
if cfg.SecretID == "" || cfg.SecretKey == "" || cfg.Region == "" || cfg.BucketName == "" {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Secret ID、Secret Key、Region、Bucket 名称不能为空"}})
|
|
return
|
|
}
|
|
if !cosFieldPattern.MatchString(cfg.Region) {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Region 格式不正确,仅允许字母、数字、点、连字符"}})
|
|
return
|
|
}
|
|
if !cosFieldPattern.MatchString(cfg.BucketName) {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Bucket 名称格式不正确,仅允许字母、数字、点、连字符"}})
|
|
return
|
|
}
|
|
|
|
err := file.CheckCosConnectivity(ctx, cfg.BucketName, cfg.Region, cfg.SecretID, cfg.SecretKey)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Storage check: COS connectivity failed, bucket: %s, error: %v", cfg.BucketName, err)
|
|
errMsg := err.Error()
|
|
if strings.Contains(errMsg, "403") {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "认证失败,请检查 Secret ID / Secret Key 是否正确"}})
|
|
return
|
|
}
|
|
if strings.Contains(errMsg, "404") || strings.Contains(errMsg, "NoSuchBucket") {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: fmt.Sprintf("Bucket「%s」不存在,请检查名称和 Region", cfg.BucketName)}})
|
|
return
|
|
}
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: sanitizeStorageCheckError(err)}})
|
|
return
|
|
}
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: true, Message: fmt.Sprintf("连接成功,Bucket「%s」已确认存在", cfg.BucketName)}})
|
|
}
|
|
|
|
func (h *SystemHandler) checkTOS(c *gin.Context, ctx context.Context, cfg *types.TOSEngineConfig) {
|
|
if cfg == nil {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "未提供 TOS 配置"}})
|
|
return
|
|
}
|
|
if cfg.Endpoint == "" || cfg.Region == "" || cfg.AccessKey == "" || cfg.SecretKey == "" || cfg.BucketName == "" {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Endpoint、Region、Access Key、Secret Key、Bucket 名称不能为空"}})
|
|
return
|
|
}
|
|
|
|
if blocked, reason := isBlockedStorageEndpoint(cfg.Endpoint); blocked {
|
|
logger.Warnf(ctx, "Storage check: TOS endpoint blocked by SSRF protection, endpoint: %s", cfg.Endpoint)
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: reason}})
|
|
return
|
|
}
|
|
|
|
err := file.CheckTosConnectivity(ctx, cfg.Endpoint, cfg.Region, cfg.AccessKey, cfg.SecretKey, cfg.BucketName)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Storage check: TOS connectivity failed, bucket: %s, error: %v", cfg.BucketName, err)
|
|
errMsg := err.Error()
|
|
if strings.Contains(errMsg, "403") {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "认证失败,请检查 Access Key / Secret Key 是否正确"}})
|
|
return
|
|
}
|
|
if strings.Contains(errMsg, "404") {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: fmt.Sprintf("Bucket「%s」不存在,请检查名称和 Region", cfg.BucketName)}})
|
|
return
|
|
}
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: sanitizeStorageCheckError(err)}})
|
|
return
|
|
}
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: true, Message: fmt.Sprintf("连接成功,Bucket「%s」已确认存在", cfg.BucketName)}})
|
|
}
|
|
|
|
func (h *SystemHandler) checkS3(c *gin.Context, ctx context.Context, cfg *types.S3EngineConfig) {
|
|
if cfg == nil {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "未提供 S3 配置"}})
|
|
return
|
|
}
|
|
if cfg.Region == "" || cfg.BucketName == "" {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Region、Bucket 名称不能为空"}})
|
|
return
|
|
}
|
|
if (cfg.AccessKey == "") != (cfg.SecretKey == "") {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Access Key 与 Secret Key 必须同时填写或同时留空(使用 AWS 默认凭证链)"}})
|
|
return
|
|
}
|
|
|
|
if cfg.Endpoint != "" {
|
|
if blocked, reason := isBlockedStorageEndpoint(cfg.Endpoint); blocked {
|
|
logger.Warnf(ctx, "Storage check: S3 endpoint blocked by SSRF protection, endpoint: %s", cfg.Endpoint)
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: reason}})
|
|
return
|
|
}
|
|
}
|
|
|
|
err := file.CheckS3Connectivity(ctx, cfg.Endpoint, cfg.AccessKey, cfg.SecretKey, cfg.BucketName, cfg.Region)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Storage check: S3 connectivity failed, bucket: %s, error: %v", cfg.BucketName, err)
|
|
errMsg := err.Error()
|
|
if strings.Contains(errMsg, "403") {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "认证失败,请检查静态密钥或 AWS IAM Role / 默认凭证链权限"}})
|
|
return
|
|
}
|
|
if strings.Contains(errMsg, "404") || strings.Contains(errMsg, "NotFound") {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: fmt.Sprintf("Bucket「%s」不存在,请检查名称和 Region", cfg.BucketName)}})
|
|
return
|
|
}
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: sanitizeStorageCheckError(err)}})
|
|
return
|
|
}
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: true, Message: fmt.Sprintf("连接成功,Bucket「%s」已确认存在", cfg.BucketName)}})
|
|
}
|
|
|
|
func (h *SystemHandler) checkOSS(c *gin.Context, ctx context.Context, cfg *types.OSSEngineConfig) {
|
|
if cfg == nil {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "未提供 OSS 配置"}})
|
|
return
|
|
}
|
|
|
|
endpoint, accessKey, secretKey := cfg.Endpoint, cfg.AccessKey, cfg.SecretKey
|
|
if endpoint == "" || accessKey == "" || secretKey == "" || cfg.BucketName == "" {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Endpoint、Access Key、Secret Key、Bucket Name 不能为空"}})
|
|
return
|
|
}
|
|
|
|
// Strip URL scheme before SSRF check — OSS endpoint may include http:// or https://
|
|
ssrfEndpoint := strings.TrimPrefix(strings.TrimPrefix(endpoint, "https://"), "http://")
|
|
if blocked, reason := isBlockedStorageEndpoint(ssrfEndpoint); blocked {
|
|
logger.Warnf(ctx, "Storage check: OSS endpoint blocked by SSRF protection, endpoint: %s", endpoint)
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: reason}})
|
|
return
|
|
}
|
|
if !ossFieldPattern.MatchString(cfg.Region) {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Region 格式不正确,仅允许字母、数字、点、连字符"}})
|
|
return
|
|
}
|
|
if !ossFieldPattern.MatchString(cfg.BucketName) {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Bucket 名称格式不正确,仅允许字母、数字、点、连字符"}})
|
|
return
|
|
}
|
|
|
|
err := file.CheckOssConnectivity(ctx, endpoint, cfg.Region, accessKey, secretKey, cfg.BucketName)
|
|
if err != nil {
|
|
errMsg := err.Error()
|
|
if strings.Contains(errMsg, "403") || strings.Contains(errMsg, "AccessDenied") {
|
|
logger.Errorf(ctx, "Storage check: OSS auth failed, endpoint: %s, bucket: %s", endpoint, cfg.BucketName)
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "认证失败,请检查 Access Key / Secret Key 是否正确"}})
|
|
return
|
|
}
|
|
if strings.Contains(errMsg, "404") || strings.Contains(errMsg, "NoSuchBucket") {
|
|
logger.Errorf(ctx, "Storage check: OSS bucket not found, bucket: %s", cfg.BucketName)
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: fmt.Sprintf("Bucket「%s」不存在", cfg.BucketName)}})
|
|
return
|
|
}
|
|
logger.Errorf(ctx, "Storage check: OSS connectivity failed, endpoint: %s, bucket: %s, error: %v", endpoint, cfg.BucketName, err)
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: fmt.Sprintf("OSS 连通性检测失败: %s", sanitizeStorageCheckError(err))}})
|
|
return
|
|
}
|
|
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: true, Message: fmt.Sprintf("连接成功,Bucket「%s」已确认存在", cfg.BucketName)}})
|
|
}
|
|
|
|
func (h *SystemHandler) checkKS3(c *gin.Context, ctx context.Context, cfg *types.KS3EngineConfig) {
|
|
if cfg == nil {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "未提供 KS3 配置"}})
|
|
return
|
|
}
|
|
|
|
endpoint, region, accessKey, secretKey := cfg.Endpoint, cfg.Region, cfg.AccessKey, cfg.SecretKey
|
|
if endpoint == "" || region == "" || accessKey == "" || secretKey == "" || cfg.BucketName == "" {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Endpoint、Region、Access Key、Secret Key、Bucket 名称不能为空"}})
|
|
return
|
|
}
|
|
|
|
if blocked, reason := isBlockedStorageEndpoint(endpoint); blocked {
|
|
logger.Warnf(ctx, "Storage check: KS3 endpoint blocked by SSRF protection, endpoint: %s", endpoint)
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: reason}})
|
|
return
|
|
}
|
|
|
|
err := file.CheckKS3Connectivity(ctx, endpoint, region, accessKey, secretKey, cfg.BucketName)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Storage check: KS3 connectivity failed, bucket: %s, error: %v", cfg.BucketName, err)
|
|
errMsg := err.Error()
|
|
if strings.Contains(errMsg, "403") || strings.Contains(errMsg, "AccessDenied") {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "认证失败,请检查 Access Key / Secret Key 是否正确"}})
|
|
return
|
|
}
|
|
if strings.Contains(errMsg, "404") || strings.Contains(errMsg, "NoSuchBucket") {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: fmt.Sprintf("Bucket「%s」不存在,请检查名称和 Region", cfg.BucketName)}})
|
|
return
|
|
}
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: sanitizeStorageCheckError(err)}})
|
|
return
|
|
}
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: true, Message: fmt.Sprintf("连接成功,Bucket「%s」已确认存在", cfg.BucketName)}})
|
|
}
|
|
|
|
func (h *SystemHandler) checkOBS(c *gin.Context, ctx context.Context, cfg *types.OBSEngineConfig) {
|
|
if cfg == nil {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "未提供 OBS 配置"}})
|
|
return
|
|
}
|
|
|
|
endpoint, region, accessKey, secretKey := cfg.Endpoint, cfg.Region, cfg.AccessKey, cfg.SecretKey
|
|
if endpoint == "" || region == "" || accessKey == "" || secretKey == "" || cfg.BucketName == "" {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Endpoint、Region、Access Key、Secret Key、Bucket 名称不能为空"}})
|
|
return
|
|
}
|
|
|
|
ssrfEndpoint := strings.TrimPrefix(strings.TrimPrefix(endpoint, "https://"), "http://")
|
|
if blocked, reason := isBlockedStorageEndpoint(ssrfEndpoint); blocked {
|
|
logger.Warnf(ctx, "Storage check: OBS endpoint blocked by SSRF protection, endpoint: %s", endpoint)
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: reason}})
|
|
return
|
|
}
|
|
|
|
if !ossFieldPattern.MatchString(cfg.Region) {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Region 格式不正确,仅允许字母、数字、点、连字符"}})
|
|
return
|
|
}
|
|
if !ossFieldPattern.MatchString(cfg.BucketName) {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Bucket 名称格式不正确,仅允许字母、数字、点、连字符"}})
|
|
return
|
|
}
|
|
|
|
err := file.CheckObsConnectivity(ctx, endpoint, region, accessKey, secretKey, cfg.BucketName)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Storage check: OBS connectivity failed, bucket: %s, error: %v", cfg.BucketName, err)
|
|
errMsg := err.Error()
|
|
if strings.Contains(errMsg, "403") || strings.Contains(errMsg, "AccessDenied") {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "认证失败,请检查 Access Key / Secret Key 是否正确"}})
|
|
return
|
|
}
|
|
if strings.Contains(errMsg, "404") || strings.Contains(errMsg, "NoSuchBucket") {
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: fmt.Sprintf("Bucket「%s」不存在,请检查名称和 Region", cfg.BucketName)}})
|
|
return
|
|
}
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: sanitizeStorageCheckError(err)}})
|
|
return
|
|
}
|
|
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: true, Message: fmt.Sprintf("连接成功,Bucket「%s」已确认存在", cfg.BucketName)}})
|
|
}
|
|
|
|
func (h *SystemHandler) ResolveDocumentReader(ctx context.Context, addr string) interfaces.DocumentReader {
|
|
if addr == "" {
|
|
return h.documentReader
|
|
}
|
|
|
|
if service.IsWeKnoraCloudDocReaderAddr(addr) {
|
|
creds := h.tenantSvc.GetWeKnoraCloudCredentials(ctx)
|
|
if creds == nil {
|
|
return nil
|
|
}
|
|
reader, err := docparser.NewWeKnoraCloudSignedDocumentReader(creds.AppID, creds.AppSecret)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return reader
|
|
}
|
|
|
|
reader, err := docparser.NewHTTPDocumentReader(addr)
|
|
if err != nil || reader == nil {
|
|
return reader
|
|
}
|
|
return reader
|
|
}
|
|
|
|
// PromoteUserToSystemAdminRequest defines the request for promoting a user to system admin.
|
|
//
|
|
// Either user_id (UUID) or email must be supplied. When both are present
|
|
// user_id wins — explicit IDs are unambiguous, an email collision (extremely
|
|
// rare in practice but possible during a tenant merge) would otherwise
|
|
// silently target the wrong row.
|
|
//
|
|
// We don't expose a `binding:"required"` tag on either field because gin's
|
|
// validator can't express the OR constraint; the handler does the check
|
|
// manually and returns 400 with a specific message for each branch.
|
|
type PromoteUserToSystemAdminRequest struct {
|
|
UserID string `json:"user_id"`
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
// PromoteUserToSystemAdmin godoc
|
|
// @Summary Promote a user to system administrator
|
|
// @Description Grant system administrator privileges to a user (SystemAdmin only).
|
|
// @Description Idempotent: re-promoting an existing system admin returns 200 with no DB write.
|
|
// @Description Identify the user by email (preferred for human operators) or user_id (UUID, for API clients).
|
|
// @Tags System Admin
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body PromoteUserToSystemAdminRequest true "User promotion request"
|
|
// @Success 200 {object} types.UserInfo "User promoted successfully"
|
|
// @Failure 400 {object} map[string]interface{} "Bad request"
|
|
// @Failure 403 {object} map[string]interface{} "Forbidden: not a system admin"
|
|
// @Failure 404 {object} map[string]interface{} "User not found"
|
|
// @Router /system/admin/promote [post]
|
|
func (h *SystemHandler) PromoteUserToSystemAdmin(c *gin.Context) {
|
|
ctx := logger.CloneContext(c.Request.Context())
|
|
|
|
var req PromoteUserToSystemAdminRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
userID := strings.TrimSpace(req.UserID)
|
|
email := strings.TrimSpace(req.Email)
|
|
if userID == "" && email == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Either user_id or email is required"})
|
|
return
|
|
}
|
|
|
|
// Resolve user. user_id takes priority when both are sent (see request
|
|
// struct doc); otherwise we look up by email. Both branches funnel
|
|
// into the same {nil-user / error} 404 so we don't leak whether a
|
|
// given email exists in the system to non-admins — though SystemAdmin
|
|
// is already a high-trust role, the parity keeps the surface clean.
|
|
var (
|
|
user *types.User
|
|
err error
|
|
)
|
|
switch {
|
|
case userID != "":
|
|
user, err = h.userSvc.GetUserByID(ctx, userID)
|
|
default:
|
|
user, err = h.userSvc.GetUserByEmail(ctx, email)
|
|
}
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Error fetching user (id=%q email=%q): %v", userID, email, err)
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
|
return
|
|
}
|
|
if user == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
|
return
|
|
}
|
|
|
|
if user.IsSystemAdmin {
|
|
// Idempotent: re-promoting an existing system admin is a no-op
|
|
// success. We still emit an audit row so probing the endpoint
|
|
// leaves a forensic trail (idempotent=true marks it as noop).
|
|
h.emitAdminAudit(ctx, types.AuditActionSystemAdminPromoted, user, map[string]any{
|
|
"target_email": user.Email,
|
|
"target_username": user.Username,
|
|
"idempotent": true,
|
|
})
|
|
c.JSON(http.StatusOK, user.ToUserInfo())
|
|
return
|
|
}
|
|
user.IsSystemAdmin = true
|
|
if err := h.userSvc.UpdateUser(ctx, user); err != nil {
|
|
logger.Errorf(ctx, "Error promoting user %s to system admin: %v", req.UserID, err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to promote user"})
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "User %s (ID: %s) promoted to system admin", user.Username, user.ID)
|
|
h.emitAdminAudit(ctx, types.AuditActionSystemAdminPromoted, user, map[string]any{
|
|
"target_email": user.Email,
|
|
"target_username": user.Username,
|
|
"idempotent": false,
|
|
})
|
|
c.JSON(http.StatusOK, user.ToUserInfo())
|
|
}
|
|
|
|
// RevokeSystemAdminRequest defines the request for revoking system admin privileges
|
|
type RevokeSystemAdminRequest struct {
|
|
UserID string `json:"user_id" binding:"required"`
|
|
}
|
|
|
|
// RevokeSystemAdmin godoc
|
|
// @Summary Revoke system administrator privileges from a user
|
|
// @Description Remove system administrator privileges from a user (SystemAdmin only).
|
|
// @Description Two safety guards: the caller cannot revoke their own privileges,
|
|
// @Description and revoking the last remaining system admin is rejected — both
|
|
// @Description prevent a SystemAdmin from accidentally locking the platform out
|
|
// @Description of system-level administration. Idempotent on already-non-admin users.
|
|
// @Tags System Admin
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body RevokeSystemAdminRequest true "User revocation request"
|
|
// @Success 200 {object} types.UserInfo "Privileges revoked successfully"
|
|
// @Failure 400 {object} map[string]interface{} "Bad request / would remove last admin / self-revoke"
|
|
// @Failure 403 {object} map[string]interface{} "Forbidden: not a system admin"
|
|
// @Failure 404 {object} map[string]interface{} "User not found"
|
|
// @Router /system/admin/revoke [post]
|
|
func (h *SystemHandler) RevokeSystemAdmin(c *gin.Context) {
|
|
ctx := logger.CloneContext(c.Request.Context())
|
|
|
|
var req RevokeSystemAdminRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
callerID, _ := types.UserIDFromContext(ctx)
|
|
user, err := h.userSvc.RevokeSystemAdmin(ctx, req.UserID, callerID)
|
|
switch {
|
|
case err == nil:
|
|
// Real revoke — privileges were actually removed.
|
|
logger.Infof(ctx, "System admin privileges revoked from user %s (ID: %s)", user.Username, user.ID)
|
|
h.emitAdminAudit(ctx, types.AuditActionSystemAdminRevoked, user, map[string]any{
|
|
"target_email": user.Email,
|
|
"target_username": user.Username,
|
|
"changed": true,
|
|
})
|
|
c.JSON(http.StatusOK, user.ToUserInfo())
|
|
return
|
|
case errors.Is(err, repository.ErrUserNotSystemAdmin):
|
|
// Idempotent: target was already not an admin. Return 200 so
|
|
// callers that re-issue the request after a partial failure
|
|
// don't get a confusing 4xx, but mark `changed=false` in the
|
|
// audit row so a forensic reader can tell a real revoke from a
|
|
// noop probe.
|
|
if user == nil {
|
|
logger.Errorf(ctx, "RevokeSystemAdmin returned nil user with ErrUserNotSystemAdmin for %s", req.UserID)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke system admin privileges"})
|
|
return
|
|
}
|
|
logger.Infof(ctx, "Revoke noop (user %s was not a system admin)", user.ID)
|
|
h.emitAdminAudit(ctx, types.AuditActionSystemAdminRevoked, user, map[string]any{
|
|
"target_email": user.Email,
|
|
"target_username": user.Username,
|
|
"changed": false,
|
|
})
|
|
c.JSON(http.StatusOK, user.ToUserInfo())
|
|
return
|
|
case errors.Is(err, repository.ErrCannotRevokeSelf):
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "Cannot revoke your own system admin privileges",
|
|
})
|
|
return
|
|
case errors.Is(err, repository.ErrLastSystemAdmin):
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "Cannot revoke the last remaining system administrator",
|
|
})
|
|
return
|
|
case errors.Is(err, repository.ErrUserNotFound):
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
|
return
|
|
default:
|
|
logger.Errorf(ctx, "Error revoking system admin from user %s: %v", req.UserID, err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke system admin privileges"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// ListSystemAdminsResponse defines the response structure for listing system admins.
|
|
// Total reflects the underlying COUNT(*), not just the page size, so the front
|
|
// end can render pagination metadata without a follow-up call.
|
|
type ListSystemAdminsResponse struct {
|
|
Total int64 `json:"total"`
|
|
Admins []*types.UserInfo `json:"admins"`
|
|
}
|
|
|
|
// ListSystemAdmins godoc
|
|
// @Summary List all system administrators
|
|
// @Description Retrieve a paginated list of users with system administrator
|
|
// @Description privileges (SystemAdmin only). Supports `offset` (default 0)
|
|
// @Description and `limit` (default 50, max 200) query parameters. Walks the
|
|
// @Description partial-friendly idx_users_is_system_admin index.
|
|
// @Tags System Admin
|
|
// @Produce json
|
|
// @Param offset query int false "Page offset" default(0)
|
|
// @Param limit query int false "Page size (max 200)" default(50)
|
|
// @Success 200 {object} ListSystemAdminsResponse "System admins retrieved successfully"
|
|
// @Failure 403 {object} map[string]interface{} "Forbidden: not a system admin"
|
|
// @Router /system/admin/list [get]
|
|
func (h *SystemHandler) ListSystemAdmins(c *gin.Context) {
|
|
ctx := logger.CloneContext(c.Request.Context())
|
|
|
|
// Best-effort pagination parsing — a malformed `limit=foo` falls back
|
|
// to defaults rather than 400-ing, since the call is still safe and a
|
|
// failed-page is more user-hostile than a soft default.
|
|
offset := 0
|
|
limit := 50
|
|
if v := c.Query("offset"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
|
offset = n
|
|
}
|
|
}
|
|
if v := c.Query("limit"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
limit = n
|
|
}
|
|
}
|
|
// Cap so a client can't ask for the entire table.
|
|
if limit > 200 {
|
|
limit = 200
|
|
}
|
|
|
|
users, total, err := h.userSvc.ListSystemAdmins(ctx, offset, limit)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Error listing system admins: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list system admins"})
|
|
return
|
|
}
|
|
|
|
// Always emit a non-nil slice so the JSON serialises to `[]` rather
|
|
// than `null` for an empty page — front-end iteration is safer.
|
|
infos := make([]*types.UserInfo, 0, len(users))
|
|
for _, u := range users {
|
|
infos = append(infos, u.ToUserInfo())
|
|
}
|
|
|
|
c.JSON(http.StatusOK, ListSystemAdminsResponse{
|
|
Total: total,
|
|
Admins: infos,
|
|
})
|
|
}
|
|
|
|
// ResetUserPasswordRequest defines the system-administrator password-reset
|
|
// payload. Email is intentionally the only user-facing identifier: the UI is
|
|
// an operator tool and emails are easier to verify than UUIDs. The password is
|
|
// never written to logs or audit details.
|
|
type ResetUserPasswordRequest struct {
|
|
Email string `json:"email" binding:"required,email"`
|
|
NewPassword string `json:"new_password" binding:"required"`
|
|
}
|
|
|
|
// ResetUserPassword godoc
|
|
// @Summary Reset another user's password
|
|
// @Description Replace another user's local password and revoke all of their existing sessions (SystemAdmin only).
|
|
// @Description A system administrator cannot reset their own password through this endpoint; self-service password change still requires the old password.
|
|
// @Tags System Admin
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body ResetUserPasswordRequest true "Password reset request"
|
|
// @Success 200 {object} map[string]interface{} "Password reset successfully"
|
|
// @Failure 400 {object} map[string]interface{} "Invalid request, weak password, or self reset"
|
|
// @Failure 403 {object} map[string]interface{} "Forbidden: not a system admin"
|
|
// @Failure 404 {object} map[string]interface{} "User not found"
|
|
// @Router /system/admin/users/reset-password [post]
|
|
func (h *SystemHandler) ResetUserPassword(c *gin.Context) {
|
|
ctx := logger.CloneContext(c.Request.Context())
|
|
|
|
var req ResetUserPasswordRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid password reset request"})
|
|
return
|
|
}
|
|
req.Email = strings.TrimSpace(req.Email)
|
|
if err := service.ValidatePasswordPolicy(req.NewPassword); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
user, err := h.userSvc.GetUserByEmail(ctx, req.Email)
|
|
if err != nil || user == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
|
return
|
|
}
|
|
callerID, _ := types.UserIDFromContext(ctx)
|
|
if callerID == user.ID {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot reset your own password here"})
|
|
return
|
|
}
|
|
|
|
if err := h.userSvc.AdminResetPassword(ctx, user.ID, req.NewPassword); err != nil {
|
|
if errors.Is(err, service.ErrPasswordPolicy) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
logger.Errorf(ctx, "Failed to reset password for user %s: %v", user.ID, err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reset user password"})
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "Password reset by system administrator for user ID: %s", user.ID)
|
|
h.emitAdminAudit(ctx, types.AuditActionSystemUserPasswordReset, user, map[string]any{
|
|
"target_email": user.Email,
|
|
"target_username": user.Username,
|
|
"sessions_revoked": true,
|
|
})
|
|
c.JSON(http.StatusOK, gin.H{"message": "Password reset successfully"})
|
|
}
|
|
|
|
// ============================================================================
|
|
// System Settings (P1)
|
|
// ----------------------------------------------------------------------------
|
|
// Endpoints below are mounted under /api/v1/system/admin/settings*, all
|
|
// gated to SystemAdmin via the route group's middleware. Every response
|
|
// is the raw model — no `gin.H{"data": ...}` wrapping — to match the
|
|
// project's axios interceptor contract (response.data is unwrapped at the
|
|
// HTTP layer; see frontend/src/utils/request.ts:97). The P0 ListSystemAdmins
|
|
// already follows this; do not break the convention.
|
|
// ============================================================================
|
|
|
|
// ListSystemSettings godoc
|
|
// @Summary List all system settings
|
|
// @Description Return every row in the system_settings table (system-scope,
|
|
// @Description not workspace-scoped). SystemAdmin only.
|
|
// @Tags System Admin
|
|
// @Produce json
|
|
// @Success 200 {array} types.SystemSetting "list of settings"
|
|
// @Failure 403 {object} map[string]interface{} "Forbidden: not a system admin"
|
|
// @Router /system/admin/settings [get]
|
|
// RuntimeQueuesResponse is the payload for the SystemAdmin runtime queue
|
|
// dashboard. `available` is false in Lite mode (no Redis/asynq) so the
|
|
// UI can render an "unavailable in this deployment" state instead of an
|
|
// empty table. Each pool reports configured per-process concurrency plus live
|
|
// cluster capacity/active workers aggregated from asynq server heartbeats.
|
|
type RuntimeWorkerPool struct {
|
|
Name string `json:"name"`
|
|
Concurrency int `json:"concurrency"` // configured per instance
|
|
QueueCount int `json:"queue_count"`
|
|
Instances int `json:"instances"` // live asynq server heartbeats
|
|
ClusterCapacity int `json:"cluster_capacity"` // sum of live server concurrency
|
|
Active int `json:"active"` // live workers assigned to this pool
|
|
Utilization float64 `json:"utilization"` // Active / ClusterCapacity
|
|
}
|
|
|
|
type RuntimeQueuesResponse struct {
|
|
Available bool `json:"available"`
|
|
UpstreamConcurrency int `json:"upstream_concurrency"`
|
|
ParseConcurrency int `json:"parse_concurrency"` // compatibility alias for upstream_concurrency
|
|
WikiConcurrency int `json:"wiki_concurrency"` // compatibility field
|
|
Pools []RuntimeWorkerPool `json:"pools"`
|
|
Queues []types.QueueStat `json:"queues"`
|
|
ModelLimiterAvailable bool `json:"model_limiter_available"`
|
|
Models []modellimiter.RuntimeStat `json:"models"`
|
|
Timestamp int64 `json:"timestamp"`
|
|
}
|
|
|
|
func aggregateRuntimeWorkerPools(pools []RuntimeWorkerPool, servers []types.WorkerServerStat) {
|
|
queueConfigs := map[string]map[string]int{
|
|
types.WorkerPoolCore: types.QueueWeightsForPool(types.WorkerPoolCore),
|
|
types.WorkerPoolPostProcess: types.QueueWeightsForPool(types.WorkerPoolPostProcess),
|
|
types.WorkerPoolEnrichment: types.QueueWeightsForPool(types.WorkerPoolEnrichment),
|
|
types.WorkerPoolMaintenance: types.QueueWeightsForPool(types.WorkerPoolMaintenance),
|
|
types.WorkerPoolShared: types.QueueWeightsForSharedPool(),
|
|
types.WorkerPoolWiki: types.QueueWeightsForPool(types.WorkerPoolWiki),
|
|
}
|
|
indexes := make(map[string]int, len(pools))
|
|
for i := range pools {
|
|
indexes[pools[i].Name] = i
|
|
}
|
|
for _, server := range servers {
|
|
if server.Status != "active" {
|
|
continue
|
|
}
|
|
for poolName, expectedQueues := range queueConfigs {
|
|
if !sameQueueWeights(server.Queues, expectedQueues) {
|
|
continue
|
|
}
|
|
idx, ok := indexes[poolName]
|
|
if !ok {
|
|
break
|
|
}
|
|
pools[idx].Instances++
|
|
pools[idx].ClusterCapacity += server.Concurrency
|
|
pools[idx].Active += server.Active
|
|
break
|
|
}
|
|
}
|
|
for i := range pools {
|
|
if pools[i].ClusterCapacity > 0 {
|
|
pools[i].Utilization = float64(pools[i].Active) / float64(pools[i].ClusterCapacity)
|
|
}
|
|
}
|
|
}
|
|
|
|
func sameQueueWeights(left, right map[string]int) bool {
|
|
if len(left) != len(right) {
|
|
return false
|
|
}
|
|
for name, weight := range left {
|
|
if right[name] != weight {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// GetRuntimeQueues godoc
|
|
// @Summary 获取解析任务队列运行时状态
|
|
// @Description 返回各 asynq 队列的实时深度(pending/active/scheduled/retry 等)与 worker 并发配置,仅系统管理员可见
|
|
// @Tags 系统管理
|
|
// @Produce json
|
|
// @Success 200 {object} RuntimeQueuesResponse
|
|
// @Router /system/admin/runtime/queues [get]
|
|
func (h *SystemHandler) GetRuntimeQueues(c *gin.Context) {
|
|
ctx := logger.CloneContext(c.Request.Context())
|
|
var allocation types.WorkerPoolConcurrency
|
|
if h.systemSettingSvc != nil {
|
|
allocation = types.ResolveWorkerPoolConcurrency(func(key, env string, fallback int) int {
|
|
configured := h.systemSettingSvc.GetInt(ctx, key, env, int64(fallback))
|
|
return int(configured)
|
|
})
|
|
} else {
|
|
allocation = types.DefaultWorkerPoolConcurrency()
|
|
}
|
|
queueCounts := make(map[string]int)
|
|
for _, definition := range types.QueueDefinitions() {
|
|
queueCounts[definition.Pool]++
|
|
}
|
|
resp := RuntimeQueuesResponse{
|
|
UpstreamConcurrency: allocation.UpstreamTotal(),
|
|
ParseConcurrency: allocation.UpstreamTotal(),
|
|
WikiConcurrency: allocation.Wiki,
|
|
Pools: []RuntimeWorkerPool{
|
|
{Name: types.WorkerPoolCore, Concurrency: allocation.Core, QueueCount: queueCounts[types.WorkerPoolCore]},
|
|
{Name: types.WorkerPoolPostProcess, Concurrency: allocation.PostProcess, QueueCount: queueCounts[types.WorkerPoolPostProcess]},
|
|
{Name: types.WorkerPoolEnrichment, Concurrency: allocation.Enrichment, QueueCount: queueCounts[types.WorkerPoolEnrichment]},
|
|
{Name: types.WorkerPoolMaintenance, Concurrency: allocation.Maintenance, QueueCount: queueCounts[types.WorkerPoolMaintenance]},
|
|
{Name: types.WorkerPoolShared, Concurrency: allocation.Shared, QueueCount: len(types.QueueWeightsForSharedPool())},
|
|
{Name: types.WorkerPoolWiki, Concurrency: allocation.Wiki, QueueCount: queueCounts[types.WorkerPoolWiki]},
|
|
},
|
|
Timestamp: time.Now().Unix(),
|
|
}
|
|
|
|
if h.taskInspector != nil {
|
|
stats, supported, err := h.taskInspector.QueueStats(ctx)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "get queue stats failed: %v", err)
|
|
}
|
|
resp.Available = supported
|
|
if stats != nil {
|
|
resp.Queues = stats
|
|
}
|
|
serverStats, _, serverErr := h.taskInspector.WorkerServerStats(ctx)
|
|
if serverErr != nil {
|
|
logger.Errorf(ctx, "get worker server stats failed: %v", serverErr)
|
|
} else {
|
|
aggregateRuntimeWorkerPools(resp.Pools, serverStats)
|
|
}
|
|
}
|
|
// Always emit a non-nil array so the JSON serialises to `[]` rather
|
|
// than `null`, keeping the frontend iteration simple.
|
|
if resp.Queues == nil {
|
|
resp.Queues = []types.QueueStat{}
|
|
}
|
|
modelStats, modelSupported, modelErr := modellimiter.RuntimeStats(ctx)
|
|
if modelErr != nil {
|
|
logger.Errorf(ctx, "get model concurrency stats failed: %v", modelErr)
|
|
}
|
|
resp.ModelLimiterAvailable = modelSupported
|
|
resp.Models = modelStats
|
|
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
type RuntimeTasksResponse struct {
|
|
Available bool `json:"available"`
|
|
Tasks []types.RuntimeTaskInfo `json:"tasks"`
|
|
PageSize int `json:"page_size"`
|
|
HasMore bool `json:"has_more"`
|
|
NextCursor string `json:"next_cursor,omitempty"`
|
|
}
|
|
|
|
func isKnownRuntimeQueue(name string) bool {
|
|
for _, definition := range types.QueueDefinitions() {
|
|
if definition.Name == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func runtimeTaskPageSize(c *gin.Context) int {
|
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
|
if pageSize < 1 {
|
|
pageSize = 20
|
|
}
|
|
if pageSize > 100 {
|
|
pageSize = 100
|
|
}
|
|
return pageSize
|
|
}
|
|
|
|
func (h *SystemHandler) listRuntimeTasks(c *gin.Context, state types.RuntimeTaskState) {
|
|
queue := c.Param("queue")
|
|
if !isKnownRuntimeQueue(queue) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown task queue"})
|
|
return
|
|
}
|
|
if !state.Valid() {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown task state"})
|
|
return
|
|
}
|
|
pageSize := runtimeTaskPageSize(c)
|
|
cursor := c.Query("cursor")
|
|
inspector, ok := h.taskInspector.(interfaces.RuntimeTaskInspector)
|
|
if !ok {
|
|
c.JSON(http.StatusOK, RuntimeTasksResponse{
|
|
Available: false,
|
|
Tasks: []types.RuntimeTaskInfo{},
|
|
PageSize: pageSize,
|
|
})
|
|
return
|
|
}
|
|
page, supported, err := inspector.ListRuntimeTasks(c.Request.Context(), queue, state, cursor, pageSize)
|
|
if err != nil {
|
|
if errors.Is(err, types.ErrInvalidRuntimeTaskCursor) {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "Invalid task list cursor",
|
|
"code": "runtime_task_cursor_invalid",
|
|
})
|
|
return
|
|
}
|
|
if errors.Is(err, types.ErrExpiredRuntimeTaskCursor) {
|
|
c.JSON(http.StatusConflict, gin.H{
|
|
"error": "Task list changed; refresh from the beginning",
|
|
"code": "runtime_task_cursor_expired",
|
|
})
|
|
return
|
|
}
|
|
logger.Errorf(c.Request.Context(), "list runtime queue tasks queue=%s state=%s: %v", queue, state, err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list queue tasks"})
|
|
return
|
|
}
|
|
if page.Tasks == nil {
|
|
page.Tasks = []types.RuntimeTaskInfo{}
|
|
}
|
|
c.JSON(http.StatusOK, RuntimeTasksResponse{
|
|
Available: supported,
|
|
Tasks: page.Tasks,
|
|
PageSize: pageSize,
|
|
HasMore: page.HasMore,
|
|
NextCursor: page.NextCursor,
|
|
})
|
|
}
|
|
|
|
// ListRuntimeTasks returns one live task-state page for the unified operator
|
|
// drawer. Raw task payloads are never included.
|
|
// @Summary List runtime queue tasks by state
|
|
// @Tags System Admin
|
|
// @Produce json
|
|
// @Param queue path string true "Queue name"
|
|
// @Param state query string true "Task state" Enums(pending,active,scheduled,retry,archived,completed)
|
|
// @Param cursor query string false "Opaque continuation cursor"
|
|
// @Param page_size query int false "Page size" default(20)
|
|
// @Success 200 {object} RuntimeTasksResponse
|
|
// @Router /system/admin/runtime/queues/{queue}/tasks [get]
|
|
func (h *SystemHandler) ListRuntimeTasks(c *gin.Context) {
|
|
h.listRuntimeTasks(c, types.RuntimeTaskState(c.Query("state")))
|
|
}
|
|
|
|
func (h *SystemHandler) emitQueueTaskAudit(
|
|
ctx context.Context,
|
|
action types.AuditAction,
|
|
queue, taskID string,
|
|
extra map[string]string,
|
|
) {
|
|
if h.auditSvc == nil {
|
|
return
|
|
}
|
|
actorID, _ := types.UserIDFromContext(ctx)
|
|
detailMap := map[string]string{"queue": queue, "task_id": taskID}
|
|
for key, value := range extra {
|
|
detailMap[key] = value
|
|
}
|
|
details, _ := json.Marshal(detailMap)
|
|
targetType := "queue_task"
|
|
targetID := taskID
|
|
if targetID == "" {
|
|
targetType = "task_queue"
|
|
targetID = queue
|
|
}
|
|
_ = h.auditSvc.Log(ctx, &types.AuditLog{
|
|
TenantID: 0,
|
|
ActorUserID: actorID,
|
|
ActorRole: systemAuditActorRole(ctx),
|
|
Action: action,
|
|
TargetType: targetType,
|
|
TargetID: targetID,
|
|
Outcome: types.AuditOutcomeSuccess,
|
|
Details: types.JSON(details),
|
|
})
|
|
}
|
|
|
|
func runtimeTaskParams(c *gin.Context) (string, string, bool) {
|
|
queue := c.Param("queue")
|
|
taskID := c.Param("task_id")
|
|
if !isKnownRuntimeQueue(queue) || taskID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid queue or task ID"})
|
|
return "", "", false
|
|
}
|
|
return queue, taskID, true
|
|
}
|
|
|
|
func (h *SystemHandler) mutateRuntimeTask(c *gin.Context, action types.RuntimeTaskAction) {
|
|
queue, taskID, ok := runtimeTaskParams(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
inspector, supported := h.taskInspector.(interfaces.RuntimeTaskInspector)
|
|
if !supported {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Task queue is unavailable"})
|
|
return
|
|
}
|
|
task, available, err := inspector.GetRuntimeTask(c.Request.Context(), queue, taskID)
|
|
if !available {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Task queue is unavailable"})
|
|
return
|
|
}
|
|
if err != nil || task == nil {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Task is no longer available"})
|
|
return
|
|
}
|
|
if !task.Allows(action) {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Action is not allowed for the current task state"})
|
|
return
|
|
}
|
|
|
|
var auditAction types.AuditAction
|
|
auditDetails := map[string]string{
|
|
"task_type": task.Type,
|
|
"from_state": string(task.State),
|
|
"action": string(action),
|
|
}
|
|
switch action {
|
|
case types.RuntimeTaskActionCancel:
|
|
if h.knowledgeSvc == nil || task.TenantID == 0 || task.KnowledgeID == "" {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Business cancellation is unavailable"})
|
|
return
|
|
}
|
|
cancelCtx := context.WithValue(c.Request.Context(), types.TenantIDContextKey, task.TenantID)
|
|
if _, err = h.knowledgeSvc.CancelKnowledgeParse(cancelCtx, task.KnowledgeID); err != nil {
|
|
if isKnowledgeGone(err) {
|
|
if h.purgeOrphanRuntimeTask(c.Request.Context(), inspector, queue, taskID, task.KnowledgeID) {
|
|
logger.Warnf(c.Request.Context(),
|
|
"cancel runtime queue task queue=%s task=%s: knowledge gone, purged queue record",
|
|
queue, taskID)
|
|
auditDetails["orphan_purge"] = "true"
|
|
auditAction = types.AuditActionSystemQueueTaskCancelled
|
|
break
|
|
}
|
|
}
|
|
logger.Errorf(c.Request.Context(), "cancel runtime queue task queue=%s task=%s: %v", queue, taskID, err)
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Task can no longer be cancelled"})
|
|
return
|
|
}
|
|
auditAction = types.AuditActionSystemQueueTaskCancelled
|
|
case types.RuntimeTaskActionRunNow:
|
|
available, err = inspector.RunRuntimeTask(c.Request.Context(), queue, taskID)
|
|
if !available {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Task queue is unavailable"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
logger.Errorf(c.Request.Context(), "run queue task now queue=%s task=%s: %v", queue, taskID, err)
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Task is no longer available to run"})
|
|
return
|
|
}
|
|
auditAction = types.AuditActionSystemQueueTaskRunNow
|
|
case types.RuntimeTaskActionDelete:
|
|
available, err = inspector.DeleteRuntimeTask(c.Request.Context(), queue, taskID)
|
|
if !available {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Task queue is unavailable"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
logger.Errorf(c.Request.Context(), "delete queue task queue=%s task=%s: %v", queue, taskID, err)
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Task is no longer available for deletion"})
|
|
return
|
|
}
|
|
auditAction = types.AuditActionSystemQueueTaskDeleted
|
|
default:
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown task action"})
|
|
return
|
|
}
|
|
h.emitQueueTaskAudit(c.Request.Context(), auditAction, queue, taskID, auditDetails)
|
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
}
|
|
|
|
func isKnowledgeGone(err error) bool {
|
|
if errors.Is(err, repository.ErrKnowledgeNotFound) {
|
|
return true
|
|
}
|
|
var appErr *apperrors.AppError
|
|
return errors.As(err, &appErr) && appErr.Code == apperrors.ErrNotFound
|
|
}
|
|
|
|
// purgeOrphanRuntimeTask removes queue records for a task whose knowledge row
|
|
// is already gone. CancelTasksForKnowledge sweeps sibling tasks best-effort;
|
|
// success is reported only once the requested task ID is gone.
|
|
func (h *SystemHandler) purgeOrphanRuntimeTask(
|
|
ctx context.Context,
|
|
inspector interfaces.RuntimeTaskInspector,
|
|
queue, taskID, knowledgeID string,
|
|
) bool {
|
|
if h.taskInspector != nil && knowledgeID != "" {
|
|
if _, _, err := h.taskInspector.CancelTasksForKnowledge(ctx, knowledgeID); err != nil {
|
|
logger.Warnf(ctx, "purge orphan tasks for knowledge %s: %v", knowledgeID, err)
|
|
}
|
|
}
|
|
supported, err := inspector.ForceDeleteRuntimeTask(ctx, queue, taskID)
|
|
if !supported {
|
|
return false
|
|
}
|
|
if err == nil {
|
|
return true
|
|
}
|
|
// The knowledge-wide sweep may have already removed this task ID.
|
|
if _, available, getErr := inspector.GetRuntimeTask(ctx, queue, taskID); available && getErr != nil {
|
|
return true
|
|
}
|
|
logger.Warnf(ctx, "force delete orphan task queue=%s task=%s: %v", queue, taskID, err)
|
|
return false
|
|
}
|
|
|
|
// MutateRuntimeTask executes a backend-advertised action after re-reading the
|
|
// current task state to close click/race windows.
|
|
// @Summary Run a safe runtime task action
|
|
// @Tags System Admin
|
|
// @Produce json
|
|
// @Param queue path string true "Queue name"
|
|
// @Param task_id path string true "Task ID"
|
|
// @Param action path string true "Action" Enums(cancel,run_now,delete)
|
|
// @Success 200 {object} map[string]bool
|
|
// @Router /system/admin/runtime/queues/{queue}/tasks/{task_id}/actions/{action} [post]
|
|
func (h *SystemHandler) MutateRuntimeTask(c *gin.Context) {
|
|
h.mutateRuntimeTask(c, types.RuntimeTaskAction(c.Param("action")))
|
|
}
|
|
|
|
// PurgeArchivedRuntimeTasks clears every archived (finally-failed) task in one
|
|
// queue. It only touches the archived dead-letter set — live pending/active/
|
|
// scheduled/retry tasks are untouched — and mirrors single-record delete
|
|
// semantics by leaving business state as-is (archived tasks already had their
|
|
// document status flipped to "failed" on their last retry).
|
|
// @Summary Purge all archived tasks in a queue
|
|
// @Tags System Admin
|
|
// @Produce json
|
|
// @Param queue path string true "Queue name"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Router /system/admin/runtime/queues/{queue}/archived [delete]
|
|
func (h *SystemHandler) PurgeArchivedRuntimeTasks(c *gin.Context) {
|
|
queue := c.Param("queue")
|
|
if !isKnownRuntimeQueue(queue) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid queue"})
|
|
return
|
|
}
|
|
inspector, supported := h.taskInspector.(interfaces.RuntimeTaskInspector)
|
|
if !supported {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Task queue is unavailable"})
|
|
return
|
|
}
|
|
deleted, available, err := inspector.PurgeArchivedRuntimeTasks(c.Request.Context(), queue)
|
|
if !available {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Task queue is unavailable"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
logger.Errorf(c.Request.Context(), "purge archived queue tasks queue=%s: %v", queue, err)
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Archived tasks could not be purged"})
|
|
return
|
|
}
|
|
h.emitQueueTaskAudit(c.Request.Context(), types.AuditActionSystemQueueArchivedPurged, queue, "",
|
|
map[string]string{"deleted": strconv.Itoa(deleted)})
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "deleted": deleted})
|
|
}
|
|
|
|
func (h *SystemHandler) ListSystemSettings(c *gin.Context) {
|
|
ctx := logger.CloneContext(c.Request.Context())
|
|
rows, err := h.systemSettingSvc.List(ctx)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "list system settings failed: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list system settings"})
|
|
return
|
|
}
|
|
if rows == nil {
|
|
// Always emit a non-nil array so the JSON serialises to `[]`
|
|
// rather than `null` on an empty table — front-end iteration
|
|
// is safer.
|
|
rows = []*types.SystemSetting{}
|
|
}
|
|
h.enrichSettingsModifiedBy(ctx, rows)
|
|
c.JSON(http.StatusOK, rows)
|
|
}
|
|
|
|
// enrichSettingsModifiedBy resolves LastModifiedBy (UUID) → display name
|
|
// in a single batch lookup. Failures degrade silently: the UI already
|
|
// falls back to the UUID prefix when the name is empty, so a transient
|
|
// userSvc error must not break the settings page entirely.
|
|
func (h *SystemHandler) enrichSettingsModifiedBy(ctx context.Context, rows []*types.SystemSetting) {
|
|
if len(rows) == 0 || h.userSvc == nil {
|
|
return
|
|
}
|
|
idSet := make(map[string]struct{}, len(rows))
|
|
for _, r := range rows {
|
|
if r != nil && strings.TrimSpace(r.LastModifiedBy) != "" {
|
|
idSet[r.LastModifiedBy] = struct{}{}
|
|
}
|
|
}
|
|
if len(idSet) == 0 {
|
|
return
|
|
}
|
|
ids := make([]string, 0, len(idSet))
|
|
for id := range idSet {
|
|
ids = append(ids, id)
|
|
}
|
|
users, err := h.userSvc.GetUsersByIDs(ctx, ids)
|
|
if err != nil {
|
|
logger.Warnf(ctx, "enrichSettingsModifiedBy: GetUsersByIDs failed: %v", err)
|
|
return
|
|
}
|
|
for _, r := range rows {
|
|
if r == nil {
|
|
continue
|
|
}
|
|
u, ok := users[r.LastModifiedBy]
|
|
if !ok || u == nil {
|
|
continue
|
|
}
|
|
// Username is the canonical display label; fall back to email
|
|
// for older rows where username may be empty (legacy seeded
|
|
// admins). Both empty → leave LastModifiedByName empty so the
|
|
// UI's UUID-prefix fallback kicks in.
|
|
switch {
|
|
case strings.TrimSpace(u.Username) != "":
|
|
r.LastModifiedByName = u.Username
|
|
case strings.TrimSpace(u.Email) != "":
|
|
r.LastModifiedByName = u.Email
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetSystemSetting godoc
|
|
// @Summary Get a single system setting by key
|
|
// @Description Returns the row matching :key. 404 when the key is unknown
|
|
// @Description to the registry; 200 with the row when known.
|
|
// @Tags System Admin
|
|
// @Produce json
|
|
// @Param key path string true "Setting key (e.g. file.max_size_mb)"
|
|
// @Success 200 {object} types.SystemSetting "the setting row"
|
|
// @Failure 400 {object} map[string]interface{} "Unknown key"
|
|
// @Failure 404 {object} map[string]interface{} "Key registered but DB row absent"
|
|
// @Router /system/admin/settings/{key} [get]
|
|
func (h *SystemHandler) GetSystemSetting(c *gin.Context) {
|
|
ctx := logger.CloneContext(c.Request.Context())
|
|
key := c.Param("key")
|
|
row, err := h.systemSettingSvc.Get(ctx, key)
|
|
if err != nil {
|
|
// Service-layer "unknown key" surfaces as a generic error here;
|
|
// distinguish via the error string rather than typed errors so
|
|
// we don't grow a sentinel package for a single error class.
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if row == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "setting not yet persisted"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, row)
|
|
}
|
|
|
|
// UpdateSystemSettingRequest is the body for PUT /system/admin/settings/:key.
|
|
// `value` carries the new value as raw JSON — int / string / bool depending
|
|
// on the registry-declared value_type. The service validates the type
|
|
// strictly and rejects mismatches with 400.
|
|
type UpdateSystemSettingRequest struct {
|
|
// Value is intentionally `any` (decoded as float64 / string / bool /
|
|
// etc. by the JSON unmarshaller). Service.encodeForType normalises
|
|
// these against the registry's declared type and rejects mismatches.
|
|
Value any `json:"value"`
|
|
}
|
|
|
|
// UpdateSystemSetting godoc
|
|
// @Summary Update a system setting value
|
|
// @Description Persist a new value for :key. Service validates the
|
|
// @Description rawValue against the registry's declared value_type and
|
|
// @Description rejects mismatches with 400. SystemAdmin only. Emits an
|
|
// @Description audit row (action=system.setting_changed) on success.
|
|
// @Tags System Admin
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param key path string true "Setting key"
|
|
// @Param request body UpdateSystemSettingRequest true "New value"
|
|
// @Success 200 {object} types.SystemSetting "the updated row"
|
|
// @Failure 400 {object} map[string]interface{} "Bad request / unknown key / type mismatch"
|
|
// @Failure 403 {object} map[string]interface{} "Forbidden: not a system admin"
|
|
// @Router /system/admin/settings/{key} [put]
|
|
func (h *SystemHandler) UpdateSystemSetting(c *gin.Context) {
|
|
ctx := logger.CloneContext(c.Request.Context())
|
|
key := c.Param("key")
|
|
|
|
var req UpdateSystemSettingRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
|
return
|
|
}
|
|
if req.Value == nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "value is required"})
|
|
return
|
|
}
|
|
|
|
row, err := h.systemSettingSvc.Update(ctx, key, req.Value)
|
|
if err != nil {
|
|
// Whether this is "unknown key" / "type mismatch" / "DB error"
|
|
// is encoded in the error message at the service layer; surface
|
|
// it verbatim. UI captures it as the toast text.
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
h.enrichSettingsModifiedBy(ctx, []*types.SystemSetting{row})
|
|
c.JSON(http.StatusOK, row)
|
|
}
|
|
|
|
// ApplyDefaultStorageQuotaToAllTenants godoc
|
|
// @Summary Apply the default storage quota to every existing workspace
|
|
// @Description Reads the current value of `tenant.default_storage_quota_gb`
|
|
// @Description (3-tier resolver: DB > ENV > default) and writes that many
|
|
// @Description GiB into storage_quota for every row in tenants. Bypasses
|
|
// @Description the per-workspace PUT whitelist, which forbids storage_quota
|
|
// @Description edits by Owners. SystemAdmin only.
|
|
// @Description Idempotent — running twice with the same setting is a no-op.
|
|
// @Tags System Admin
|
|
// @Produce json
|
|
// @Success 200 {object} map[string]interface{} "{ affected: int64, quota_bytes: int64 }"
|
|
// @Failure 500 {object} map[string]interface{} "DB write failed"
|
|
// @Router /system/admin/tenants/apply-default-storage-quota [post]
|
|
func (h *SystemHandler) ApplyDefaultStorageQuotaToAllTenants(c *gin.Context) {
|
|
ctx := logger.CloneContext(c.Request.Context())
|
|
|
|
// Resolve via the same 3-tier path the CreateTenant handler uses
|
|
// so the action's effect mirrors what new tenants would receive.
|
|
gb := h.systemSettingSvc.GetInt(
|
|
ctx,
|
|
"tenant.default_storage_quota_gb",
|
|
"WEKNORA_TENANT_DEFAULT_STORAGE_QUOTA_GB",
|
|
10,
|
|
)
|
|
if gb <= 0 {
|
|
gb = 10
|
|
}
|
|
quotaBytes := gb * 1024 * 1024 * 1024
|
|
|
|
affected, err := h.tenantSvc.BulkSetStorageQuota(ctx, quotaBytes)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "BulkSetStorageQuota failed: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply storage quota"})
|
|
return
|
|
}
|
|
|
|
// Audit. We capture quota_bytes (the actual value written) rather
|
|
// than the GB literal so the audit row is unambiguous if someone
|
|
// later changes the setting's units.
|
|
if h.auditSvc != nil {
|
|
actorID, _ := types.UserIDFromContext(ctx)
|
|
details, _ := json.Marshal(map[string]any{
|
|
"quota_bytes": quotaBytes,
|
|
"quota_gb": gb,
|
|
"affected": affected,
|
|
})
|
|
_ = h.auditSvc.Log(ctx, &types.AuditLog{
|
|
TenantID: 0,
|
|
ActorUserID: actorID,
|
|
ActorRole: systemAuditActorRole(ctx),
|
|
Action: types.AuditActionSystemSettingChanged,
|
|
TargetType: "tenant_storage_quota",
|
|
TargetID: "all",
|
|
Outcome: types.AuditOutcomeSuccess,
|
|
Details: types.JSON(details),
|
|
})
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"affected": affected,
|
|
"quota_bytes": quotaBytes,
|
|
"quota_gb": gb,
|
|
})
|
|
}
|
|
|
|
// ResetSystemSetting godoc
|
|
// @Summary Reset a system setting to ENV / built-in default
|
|
// @Description Removes the DB override for :key so the 3-tier resolver
|
|
// @Description falls back to the environment variable (when configured)
|
|
// @Description or the in-code default. Idempotent — resetting a key
|
|
// @Description that was never persisted returns 200.
|
|
// @Tags System Admin
|
|
// @Param key path string true "Setting key"
|
|
// @Success 200 {object} map[string]interface{} "Reset acknowledged"
|
|
// @Failure 400 {object} map[string]interface{} "Unknown key"
|
|
// @Failure 500 {object} map[string]interface{} "DB write failed"
|
|
// @Router /system/admin/settings/{key} [delete]
|
|
func (h *SystemHandler) ResetSystemSetting(c *gin.Context) {
|
|
ctx := logger.CloneContext(c.Request.Context())
|
|
key := c.Param("key")
|
|
if err := h.systemSettingSvc.Reset(ctx, key); err != nil {
|
|
// Service surfaces "unknown key" as a plain error string; treat
|
|
// every error as 400 so the UI captures it as a toast. A real
|
|
// DB failure here is rare and indistinguishable from "bad key"
|
|
// at this layer — operators see the message either way.
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
}
|