diff --git a/internal/api/webui.go b/internal/api/webui.go
new file mode 100644
index 0000000..c73316a
--- /dev/null
+++ b/internal/api/webui.go
@@ -0,0 +1,2628 @@
+/*
+ * Copyright 2026 Safronov Grigorii
+ *
+ * Licensed under the CDDL, Version 1.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ *
+ * You may obtain a copy of the License at
+ * https://opensource.org/licenses/CDDL-1.0
+ */
+
+// Файл: internal/api/webui.go
+// Назначение: Веб-интерфейс в стиле dashboard для управления СУБД futriis
+
+package api
+
+import (
+ "bufio"
+ "embed"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "futriis/internal/acl"
+ "futriis/internal/cluster"
+ "futriis/internal/log"
+ "futriis/internal/plugin"
+ "futriis/internal/storage"
+)
+
+//go:embed static/*
+var staticFiles embed.FS
+
+// LogEntry представляет запись в логе веб-интерфейса
+type LogEntry struct {
+ Timestamp int64 `json:"timestamp"`
+ Operation string `json:"operation"`
+ Target string `json:"target"`
+ User string `json:"user"`
+ Status string `json:"status"` // success/error
+ ErrorMsg string `json:"error_msg,omitempty"`
+ Details map[string]interface{} `json:"details,omitempty"`
+}
+
+// LogBuffer буферизирует логи веб-интерфейса
+type LogBuffer struct {
+ entries []LogEntry
+ mu sync.RWMutex
+ maxSize int
+ filePath string
+ file *os.File
+ writer *bufio.Writer
+}
+
+// NewLogBuffer создаёт новый буфер логов
+func NewLogBuffer(filePath string, maxSize int) (*LogBuffer, error) {
+ // Создаём директорию для логов, если её нет
+ dir := filepath.Dir(filePath)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return nil, fmt.Errorf("failed to create log directory: %v", err)
+ }
+
+ // Открываем файл для записи логов
+ file, err := os.OpenFile(filePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open log file: %v", err)
+ }
+
+ return &LogBuffer{
+ entries: make([]LogEntry, 0, maxSize),
+ maxSize: maxSize,
+ filePath: filePath,
+ file: file,
+ writer: bufio.NewWriter(file),
+ }, nil
+}
+
+// Add добавляет запись в лог
+func (lb *LogBuffer) Add(entry LogEntry) {
+ lb.mu.Lock()
+ defer lb.mu.Unlock()
+
+ // Добавляем в память
+ lb.entries = append(lb.entries, entry)
+ if len(lb.entries) > lb.maxSize {
+ lb.entries = lb.entries[len(lb.entries)-lb.maxSize:]
+ }
+
+ // Записываем в файл
+ data, err := json.Marshal(entry)
+ if err == nil {
+ lb.writer.Write(data)
+ lb.writer.Write([]byte("\n"))
+ lb.writer.Flush()
+ }
+}
+
+// GetEntries возвращает все записи лога
+func (lb *LogBuffer) GetEntries() []LogEntry {
+ lb.mu.RLock()
+ defer lb.mu.RUnlock()
+
+ result := make([]LogEntry, len(lb.entries))
+ copy(result, lb.entries)
+ return result
+}
+
+// Close закрывает буфер логов
+func (lb *LogBuffer) Close() error {
+ if lb.writer != nil {
+ lb.writer.Flush()
+ }
+ if lb.file != nil {
+ return lb.file.Close()
+ }
+ return nil
+}
+
+// WebUIServer представляет сервер веб-интерфейса
+type WebUIServer struct {
+ store *storage.Storage
+ coordinator *cluster.RaftCoordinator
+ aclManager *acl.ACLManager
+ logger *log.Logger
+ server *http.Server
+ port int
+ enabled bool
+ credentialMgr *CredentialManager
+ logBuffer *LogBuffer
+ pluginManager *plugin.PluginManager
+}
+
+// NewWebUIServer создаёт новый веб-сервер интерфейса
+func NewWebUIServer(port int, enabled bool, store *storage.Storage, coord *cluster.RaftCoordinator, aclMgr *acl.ACLManager, logger *log.Logger) *WebUIServer {
+ credMgr := NewCredentialManager()
+
+ // Загружаем или создаём учётные данные по умолчанию
+ if err := credMgr.Load(); err != nil {
+ logger.Warn(fmt.Sprintf("Failed to load credentials: %v, using defaults", err))
+ if err := credMgr.CreateDefault(); err != nil {
+ logger.Error(fmt.Sprintf("Failed to create default credentials: %v", err))
+ }
+ }
+
+ // Инициализируем буфер логов
+ logBuffer, err := NewLogBuffer("futriis/webui.log", 10000)
+ if err != nil {
+ logger.Warn(fmt.Sprintf("Failed to create log buffer: %v", err))
+ logBuffer = &LogBuffer{entries: make([]LogEntry, 0, 1000)}
+ }
+
+ // Инициализируем менеджер плагинов
+ pluginManager := plugin.NewPluginManager("futriis/plugins", logger, store, true)
+
+ return &WebUIServer{
+ store: store,
+ coordinator: coord,
+ aclManager: aclMgr,
+ logger: logger,
+ port: port,
+ enabled: enabled,
+ credentialMgr: credMgr,
+ logBuffer: logBuffer,
+ pluginManager: pluginManager,
+ }
+}
+
+// logOperation логирует операцию веб-интерфейса
+func (w *WebUIServer) logOperation(operation, target, status string, errMsg string, details map[string]interface{}) {
+ entry := LogEntry{
+ Timestamp: time.Now().UnixMilli(),
+ Operation: operation,
+ Target: target,
+ User: w.credentialMgr.GetCurrentUsername(),
+ Status: status,
+ ErrorMsg: errMsg,
+ Details: details,
+ }
+
+ if w.logBuffer != nil {
+ w.logBuffer.Add(entry)
+ }
+
+ // Также логируем в основной лог
+ if w.logger != nil {
+ if status == "error" {
+ w.logger.Error(fmt.Sprintf("[WEBUI] %s: %s - %s", operation, target, errMsg))
+ } else {
+ w.logger.Info(fmt.Sprintf("[WEBUI] %s: %s", operation, target))
+ }
+ }
+}
+
+// Start запускает веб-сервер интерфейса
+func (w *WebUIServer) Start() error {
+ if !w.enabled {
+ w.logger.Info("Web UI is disabled in configuration")
+ return nil
+ }
+
+ mux := http.NewServeMux()
+
+ // Статические файлы
+ staticFS, err := fs.Sub(staticFiles, "static")
+ if err != nil {
+ return fmt.Errorf("failed to load static files: %v", err)
+ }
+ mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
+
+ // Главная страница
+ mux.HandleFunc("/", w.handleWebIndex)
+
+ // API для веб-интерфейса
+ mux.HandleFunc("/api/webui/databases", w.handleGetDatabases)
+ mux.HandleFunc("/api/webui/collections/", w.handleGetCollections)
+ mux.HandleFunc("/api/webui/documents/", w.handleDocuments)
+ mux.HandleFunc("/api/webui/cluster/status", w.handleClusterStatus)
+ mux.HandleFunc("/api/webui/cluster/nodes", w.handleClusterNodes)
+ mux.HandleFunc("/api/webui/stats", w.handleStats)
+ mux.HandleFunc("/api/webui/login", w.handleWebLogin)
+ mux.HandleFunc("/api/webui/logout", w.handleWebLogout)
+ mux.HandleFunc("/api/webui/session", w.handleSessionCheck)
+ mux.HandleFunc("/api/webui/change-password", w.handleChangePassword)
+ mux.HandleFunc("/api/webui/user/avatar", w.handleUserAvatar)
+ mux.HandleFunc("/api/webui/user/info", w.handleUserInfo)
+
+ // API для управления пользователями (административные)
+ mux.HandleFunc("/api/webui/admin/users", w.handleAdminUsers)
+
+ // API для логов
+ mux.HandleFunc("/api/webui/logs", w.handleWebLogs)
+
+ // API для плагинов
+ mux.HandleFunc("/api/webui/plugins", w.handlePlugins)
+ mux.HandleFunc("/api/webui/plugin/", w.handlePluginOperation)
+
+ // API для ACL
+ mux.HandleFunc("/api/webui/acl/users", w.handleACLUsers)
+ mux.HandleFunc("/api/webui/acl/roles", w.handleACLRoles)
+ mux.HandleFunc("/api/webui/acl/permissions", w.handleACLPermissions)
+ mux.HandleFunc("/api/webui/acl/user/", w.handleACLUser)
+ mux.HandleFunc("/api/webui/acl/role/", w.handleACLRole)
+
+ // API для транзакций
+ mux.HandleFunc("/api/webui/transactions", w.handleTransactions)
+ mux.HandleFunc("/api/webui/transaction/", w.handleTransactionAction)
+
+ // API для индексов
+ mux.HandleFunc("/api/webui/indexes/", w.handleIndexesList)
+ mux.HandleFunc("/api/webui/index/", w.handleIndexOperation)
+
+ // API для импорта/экспорта
+ mux.HandleFunc("/api/webui/export", w.handleExportData)
+ mux.HandleFunc("/api/webui/import", w.handleImportData)
+
+ // API для триггеров
+ mux.HandleFunc("/api/webui/triggers/", w.handleTriggers)
+ mux.HandleFunc("/api/webui/trigger/", w.handleTriggerOperation)
+ mux.HandleFunc("/api/webui/trigger/log", w.handleTriggerLog)
+
+ // API для ограничений (constraints)
+ mux.HandleFunc("/api/webui/constraints/", w.handleConstraintsList)
+ mux.HandleFunc("/api/webui/constraint/", w.handleConstraintOperation)
+
+ w.server = &http.Server{
+ Addr: fmt.Sprintf(":%d", w.port),
+ Handler: mux,
+ ReadTimeout: 30 * time.Second,
+ WriteTimeout: 30 * time.Second,
+ }
+
+ w.logger.Info(fmt.Sprintf("Web UI started on port %d", w.port))
+ return w.server.ListenAndServe()
+}
+
+// Stop останавливает веб-сервер
+func (w *WebUIServer) Stop() error {
+ if w.logBuffer != nil {
+ w.logBuffer.Close()
+ }
+ if w.server != nil {
+ return w.server.Close()
+ }
+ return nil
+}
+
+// handleWebIndex возвращает главную HTML страницу
+func (w *WebUIServer) handleWebIndex(wr http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ http.NotFound(wr, r)
+ return
+ }
+
+ html := `
+
+
+
+
+ Futriis Database Management System
+
+
+
+
+
+
+
+
+
+
+ Панель управления futriis 3i² (02.04.2026)
+
+ Подключено
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+
+ wr.Header().Set("Content-Type", "text/html; charset=utf-8")
+ wr.Write([]byte(html))
+}
+
+// handleWebLogin обрабатывает вход в веб-интерфейс
+func (w *WebUIServer) handleWebLogin(wr http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var creds struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&creds); err != nil {
+ w.sendJSONError(wr, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ w.logger.Debug(fmt.Sprintf("Login attempt: username=%s", creds.Username))
+
+ // Проверяем учётные данные
+ if !w.credentialMgr.Validate(creds.Username, creds.Password) {
+ w.logger.Debug(fmt.Sprintf("Authentication failed for %s", creds.Username))
+ w.logOperation("LOGIN", creds.Username, "error", "Invalid credentials", nil)
+ w.sendJSONError(wr, "Неверный логин и/или пароль", http.StatusUnauthorized)
+ return
+ }
+
+ // Генерируем простую сессию
+ sessionID := fmt.Sprintf("web_session_%d_%s", time.Now().UnixNano(), creds.Username)
+
+ w.credentialMgr.SetCurrentUsername(creds.Username)
+ w.credentialMgr.UpdateLastLogin(creds.Username)
+
+ w.logger.Debug(fmt.Sprintf("Authentication successful for %s, sessionID=%s", creds.Username, sessionID))
+ w.logOperation("LOGIN", creds.Username, "success", "", nil)
+
+ http.SetCookie(wr, &http.Cookie{
+ Name: "session_id",
+ Value: sessionID,
+ Path: "/",
+ MaxAge: 86400,
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ })
+
+ // Загружаем аватар пользователя
+ avatarData := ""
+ if avatar, err := w.credentialMgr.GetAvatar(creds.Username); err == nil && avatar != "" {
+ avatarData = avatar
+ }
+
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "session_id": sessionID,
+ "username": creds.Username,
+ "avatar": avatarData,
+ "is_admin": w.credentialMgr.IsAdmin(creds.Username),
+ })
+}
+
+// handleWebLogout обрабатывает выход из веб-интерфейса
+func (w *WebUIServer) handleWebLogout(wr http.ResponseWriter, r *http.Request) {
+ username := w.credentialMgr.GetCurrentUsername()
+ w.logOperation("LOGOUT", username, "success", "", nil)
+
+ http.SetCookie(wr, &http.Cookie{
+ Name: "session_id",
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ })
+
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "logged out",
+ })
+}
+
+// handleSessionCheck проверяет активность сессии
+func (w *WebUIServer) handleSessionCheck(wr http.ResponseWriter, r *http.Request) {
+ sessionID := w.getSessionID(r)
+ if sessionID == "" {
+ w.sendJSONError(wr, "No session", http.StatusUnauthorized)
+ return
+ }
+
+ username := w.credentialMgr.GetCurrentUsername()
+ if username == "" {
+ username = "admin"
+ }
+
+ connectionStatus := "connected"
+ if w.coordinator == nil {
+ connectionStatus = "disconnected"
+ } else if status := w.coordinator.GetClusterStatus(); status.Health == "critical" {
+ connectionStatus = "disconnected"
+ }
+
+ avatarData := ""
+ if avatar, err := w.credentialMgr.GetAvatar(username); err == nil && avatar != "" {
+ avatarData = avatar
+ }
+
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "authenticated": true,
+ "username": username,
+ "avatar": avatarData,
+ "is_admin": w.credentialMgr.IsAdmin(username),
+ "connection_status": connectionStatus,
+ })
+}
+
+// handleWebLogs возвращает логи веб-интерфейса
+func (w *WebUIServer) handleWebLogs(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ // Только администраторы могут просматривать логи
+ username := w.credentialMgr.GetCurrentUsername()
+ if !w.credentialMgr.IsAdmin(username) {
+ w.sendJSONError(wr, "Forbidden", http.StatusForbidden)
+ return
+ }
+
+ limit := 100
+ if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
+ if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 5000 {
+ limit = l
+ }
+ }
+
+ entries := w.logBuffer.GetEntries()
+ if len(entries) > limit {
+ entries = entries[len(entries)-limit:]
+ }
+
+ w.sendJSONSuccess(wr, entries)
+}
+
+// handlePlugins возвращает список плагинов
+func (w *WebUIServer) handlePlugins(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ // Только администраторы могут управлять плагинами
+ username := w.credentialMgr.GetCurrentUsername()
+ if !w.credentialMgr.IsAdmin(username) {
+ w.sendJSONError(wr, "Forbidden", http.StatusForbidden)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ plugins := w.pluginManager.ListPlugins()
+ pluginList := make([]map[string]interface{}, 0, len(plugins))
+ for _, p := range plugins {
+ pluginList = append(pluginList, map[string]interface{}{
+ "name": p.Name,
+ "version": p.Version(),
+ "author": p.Author(),
+ "description": p.Description(),
+ "file_path": p.FilePath,
+ "loaded_at": p.LoadedAt(),
+ })
+ }
+ w.sendJSONSuccess(wr, pluginList)
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+// handlePluginOperation обрабатывает операции с плагинами
+func (w *WebUIServer) handlePluginOperation(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ // Только администраторы могут управлять плагинами
+ username := w.credentialMgr.GetCurrentUsername()
+ if !w.credentialMgr.IsAdmin(username) {
+ w.sendJSONError(wr, "Forbidden", http.StatusForbidden)
+ return
+ }
+
+ path := strings.TrimPrefix(r.URL.Path, "/api/webui/plugin/")
+ parts := strings.Split(path, "/")
+
+ if len(parts) < 2 {
+ w.sendJSONError(wr, "Plugin name and action required", http.StatusBadRequest)
+ return
+ }
+
+ pluginName := parts[0]
+ action := parts[1]
+
+ switch action {
+ case "start":
+ if r.Method != http.MethodPost {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if err := w.pluginManager.StartPlugin(pluginName); err != nil {
+ w.logOperation("PLUGIN_START", pluginName, "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ w.logOperation("PLUGIN_START", pluginName, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "started"})
+
+ case "stop":
+ if r.Method != http.MethodPost {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if err := w.pluginManager.StopPlugin(pluginName); err != nil {
+ w.logOperation("PLUGIN_STOP", pluginName, "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ w.logOperation("PLUGIN_STOP", pluginName, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "stopped"})
+
+ case "reload":
+ if r.Method != http.MethodPost {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if err := w.pluginManager.UnloadPlugin(pluginName); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ // Перезагружаем все плагины из директории
+ w.logOperation("PLUGIN_RELOAD", pluginName, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "reloaded"})
+
+ case "delete":
+ if r.Method != http.MethodDelete {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if err := w.pluginManager.UnloadPlugin(pluginName); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ // Удаляем файл плагина
+ pluginPath := filepath.Join(w.pluginManager.GetPluginsDir(), pluginName+".lua")
+ if err := os.Remove(pluginPath); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ w.logOperation("PLUGIN_DELETE", pluginName, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "deleted"})
+
+ case "upload":
+ if r.Method != http.MethodPost {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if err := r.ParseMultipartForm(1024 * 1024); err != nil { // 1MB max
+ w.sendJSONError(wr, "Failed to parse form", http.StatusBadRequest)
+ return
+ }
+
+ file, handler, err := r.FormFile("plugin")
+ if err != nil {
+ w.sendJSONError(wr, "Failed to get plugin file", http.StatusBadRequest)
+ return
+ }
+ defer file.Close()
+
+ if !strings.HasSuffix(handler.Filename, ".lua") {
+ w.sendJSONError(wr, "Only .lua files are allowed", http.StatusBadRequest)
+ return
+ }
+
+ pluginPath := filepath.Join(w.pluginManager.GetPluginsDir(), handler.Filename)
+
+ // Сохраняем файл
+ dst, err := os.Create(pluginPath)
+ if err != nil {
+ w.sendJSONError(wr, "Failed to create plugin file", http.StatusInternalServerError)
+ return
+ }
+ defer dst.Close()
+
+ if _, err := file.Seek(0, 0); err != nil {
+ w.sendJSONError(wr, "Failed to read file", http.StatusInternalServerError)
+ return
+ }
+
+ if _, err := dst.ReadFrom(file); err != nil {
+ w.sendJSONError(wr, "Failed to write plugin file", http.StatusInternalServerError)
+ return
+ }
+
+ w.logOperation("PLUGIN_UPLOAD", handler.Filename, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "uploaded", "filename": handler.Filename})
+
+ default:
+ w.sendJSONError(wr, "Unknown action", http.StatusBadRequest)
+ }
+}
+
+// handleAdminUsers обрабатывает административные операции с пользователями
+func (w *WebUIServer) handleAdminUsers(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ // Только администраторы могут управлять пользователями
+ username := w.credentialMgr.GetCurrentUsername()
+ if !w.credentialMgr.IsAdmin(username) {
+ w.sendJSONError(wr, "Forbidden", http.StatusForbidden)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ users := w.credentialMgr.ListUsers()
+ w.sendJSONSuccess(wr, users)
+
+ case http.MethodPost:
+ var req struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+ IsAdmin bool `json:"is_admin"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.sendJSONError(wr, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if err := w.credentialMgr.CreateUser(req.Username, req.Password, req.IsAdmin); err != nil {
+ w.logOperation("ADMIN_CREATE_USER", req.Username, "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ w.logOperation("ADMIN_CREATE_USER", req.Username, "success", "", map[string]interface{}{
+ "is_admin": req.IsAdmin,
+ })
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "created"})
+
+ case http.MethodDelete:
+ usernameToDelete := r.URL.Query().Get("username")
+ if usernameToDelete == "" {
+ w.sendJSONError(wr, "Username required", http.StatusBadRequest)
+ return
+ }
+
+ if err := w.credentialMgr.DeleteUser(usernameToDelete); err != nil {
+ w.logOperation("ADMIN_DELETE_USER", usernameToDelete, "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ w.logOperation("ADMIN_DELETE_USER", usernameToDelete, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "deleted"})
+
+ case http.MethodPut:
+ var req struct {
+ Username string `json:"username"`
+ NewPassword string `json:"new_password"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.sendJSONError(wr, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if err := w.credentialMgr.AdminChangePassword(req.Username, req.NewPassword); err != nil {
+ w.logOperation("ADMIN_CHANGE_PASSWORD", req.Username, "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ w.logOperation("ADMIN_CHANGE_PASSWORD", req.Username, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "password_changed"})
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+// handleChangePassword обрабатывает смену пароля
+func (w *WebUIServer) handleChangePassword(wr http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ sessionID := w.getSessionID(r)
+ if sessionID == "" {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ var req struct {
+ CurrentPassword string `json:"current_password"`
+ NewPassword string `json:"new_password"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.sendJSONError(wr, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.CurrentPassword == "" || req.NewPassword == "" {
+ w.sendJSONError(wr, "Current and new password are required", http.StatusBadRequest)
+ return
+ }
+
+ if len(req.NewPassword) < 4 {
+ w.sendJSONError(wr, "New password must be at least 4 characters", http.StatusBadRequest)
+ return
+ }
+
+ username := w.credentialMgr.GetCurrentUsername()
+ if username == "" {
+ username = "admin"
+ }
+
+ if err := w.credentialMgr.ChangePassword(username, req.CurrentPassword, req.NewPassword); err != nil {
+ w.logOperation("CHANGE_PASSWORD", username, "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ w.logger.Info(fmt.Sprintf("Password changed for user: %s", username))
+ w.logOperation("CHANGE_PASSWORD", username, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "password_changed",
+ "message": "Пароль успешно изменён",
+ })
+}
+
+// handleUserAvatar обрабатывает загрузку и получение аватара пользователя
+func (w *WebUIServer) handleUserAvatar(wr http.ResponseWriter, r *http.Request) {
+ sessionID := w.getSessionID(r)
+ if sessionID == "" {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ username := w.credentialMgr.GetCurrentUsername()
+ if username == "" {
+ username = "admin"
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ avatar, err := w.credentialMgr.GetAvatar(username)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "avatar": avatar,
+ })
+
+ case http.MethodPost:
+ if err := r.ParseMultipartForm(2 << 20); err != nil {
+ w.sendJSONError(wr, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ file, handler, err := r.FormFile("avatar")
+ if err != nil {
+ w.sendJSONError(wr, "Failed to get avatar file: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ defer file.Close()
+
+ if handler.Size > 2<<20 {
+ w.sendJSONError(wr, "Avatar file too large (max 2MB)", http.StatusBadRequest)
+ return
+ }
+
+ contentType := handler.Header.Get("Content-Type")
+ if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/gif" {
+ w.sendJSONError(wr, "Invalid image type. Use JPEG, PNG or GIF", http.StatusBadRequest)
+ return
+ }
+
+ fileData := make([]byte, handler.Size)
+ if _, err := file.Read(fileData); err != nil {
+ w.sendJSONError(wr, "Failed to read file: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ avatarBase64 := "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(fileData)
+
+ if err := w.credentialMgr.SetAvatar(username, avatarBase64); err != nil {
+ w.sendJSONError(wr, "Failed to save avatar: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.logger.Info(fmt.Sprintf("Avatar uploaded for user: %s", username))
+ w.logOperation("AVATAR_UPLOAD", username, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "avatar_uploaded",
+ "avatar": avatarBase64,
+ })
+
+ case http.MethodDelete:
+ if err := w.credentialMgr.DeleteAvatar(username); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+ w.logOperation("AVATAR_DELETE", username, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "avatar_deleted",
+ })
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+// handleUserInfo возвращает информацию о пользователе
+func (w *WebUIServer) handleUserInfo(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ username := w.credentialMgr.GetCurrentUsername()
+ if username == "" {
+ username = "admin"
+ }
+
+ avatar, _ := w.credentialMgr.GetAvatar(username)
+
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "username": username,
+ "avatar": avatar,
+ "is_admin": w.credentialMgr.IsAdmin(username),
+ })
+}
+
+// checkAuth проверяет аутентификацию
+func (w *WebUIServer) checkAuth(r *http.Request) bool {
+ sessionID := w.getSessionID(r)
+ if sessionID == "" {
+ return false
+ }
+ return sessionID != ""
+}
+
+// getSessionID возвращает ID сессии из cookie
+func (w *WebUIServer) getSessionID(r *http.Request) string {
+ if cookie, err := r.Cookie("session_id"); err == nil {
+ return cookie.Value
+ }
+ return ""
+}
+
+// sendJSONSuccess отправляет успешный JSON ответ
+func (w *WebUIServer) sendJSONSuccess(wr http.ResponseWriter, data interface{}) {
+ wr.Header().Set("Content-Type", "application/json")
+ wr.WriteHeader(http.StatusOK)
+ json.NewEncoder(wr).Encode(map[string]interface{}{
+ "success": true,
+ "data": data,
+ })
+}
+
+// sendJSONError отправляет JSON ответ с ошибкой
+func (w *WebUIServer) sendJSONError(wr http.ResponseWriter, errMsg string, statusCode int) {
+ wr.Header().Set("Content-Type", "application/json")
+ wr.WriteHeader(statusCode)
+ json.NewEncoder(wr).Encode(map[string]interface{}{
+ "success": false,
+ "error": errMsg,
+ })
+}
+
+// handleGetDatabases возвращает список баз данных
+func (w *WebUIServer) handleGetDatabases(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ databases := w.store.ListDatabases()
+
+ dbInfo := make([]map[string]interface{}, 0)
+ for _, dbName := range databases {
+ db, err := w.store.GetDatabase(dbName)
+ if err == nil {
+ collections := db.ListCollections()
+ dbInfo = append(dbInfo, map[string]interface{}{
+ "name": dbName,
+ "collections": len(collections),
+ "collections_list": collections,
+ })
+ }
+ }
+
+ w.sendJSONSuccess(wr, dbInfo)
+}
+
+// handleGetCollections возвращает список коллекций в базе данных
+func (w *WebUIServer) handleGetCollections(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ path := strings.TrimPrefix(r.URL.Path, "/api/webui/collections/")
+ parts := strings.Split(path, "/")
+
+ if len(parts) < 1 || parts[0] == "" {
+ w.sendJSONError(wr, "Database name required", http.StatusBadRequest)
+ return
+ }
+
+ dbName := parts[0]
+ db, err := w.store.GetDatabase(dbName)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ collections := db.ListCollections()
+ collectionsInfo := make([]map[string]interface{}, 0)
+
+ for _, collName := range collections {
+ coll, err := db.GetCollection(collName)
+ if err == nil {
+ collectionsInfo = append(collectionsInfo, map[string]interface{}{
+ "name": collName,
+ "count": coll.Count(),
+ "size": coll.Size(),
+ "indexes": coll.GetIndexes(),
+ })
+ }
+ }
+
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "database": dbName,
+ "collections": collectionsInfo,
+ })
+}
+
+// handleDocuments обрабатывает CRUD операции с документами
+func (w *WebUIServer) handleDocuments(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ path := strings.TrimPrefix(r.URL.Path, "/api/webui/documents/")
+ parts := strings.Split(path, "/")
+
+ if len(parts) < 2 {
+ w.sendJSONError(wr, "Invalid path. Use /api/webui/documents/{database}/{collection}", http.StatusBadRequest)
+ return
+ }
+
+ dbName := parts[0]
+ collName := parts[1]
+
+ db, err := w.store.GetDatabase(dbName)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ coll, err := db.GetCollection(collName)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ limit := 50
+ if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
+ if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 500 {
+ limit = l
+ }
+ }
+
+ offset := 0
+ if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
+ if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
+ offset = o
+ }
+ }
+
+ allDocs := coll.GetAllDocuments()
+ start := offset
+ end := offset + limit
+ if start > len(allDocs) {
+ start = len(allDocs)
+ }
+ if end > len(allDocs) {
+ end = len(allDocs)
+ }
+
+ docs := make([]map[string]interface{}, 0)
+ for _, doc := range allDocs[start:end] {
+ docs = append(docs, map[string]interface{}{
+ "id": doc.ID,
+ "fields": doc.GetFields(),
+ "created_at": doc.CreatedAt,
+ "updated_at": doc.UpdatedAt,
+ "version": doc.Version,
+ })
+ }
+
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "documents": docs,
+ "total": len(allDocs),
+ "limit": limit,
+ "offset": offset,
+ })
+
+ case http.MethodPost:
+ var docData map[string]interface{}
+ if err := json.NewDecoder(r.Body).Decode(&docData); err != nil {
+ w.sendJSONError(wr, "Invalid JSON", http.StatusBadRequest)
+ return
+ }
+
+ if err := coll.InsertFromMap(docData); err != nil {
+ w.logOperation("INSERT_DOCUMENT", fmt.Sprintf("%s.%s", dbName, collName), "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ w.logOperation("INSERT_DOCUMENT", fmt.Sprintf("%s.%s/%v", dbName, collName, docData["_id"]), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "inserted",
+ "id": docData["_id"],
+ })
+
+ case http.MethodPut:
+ docID := r.URL.Query().Get("id")
+ if docID == "" {
+ w.sendJSONError(wr, "Document ID required", http.StatusBadRequest)
+ return
+ }
+
+ var updates map[string]interface{}
+ if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
+ w.sendJSONError(wr, "Invalid JSON", http.StatusBadRequest)
+ return
+ }
+
+ if err := coll.Update(docID, updates); err != nil {
+ w.logOperation("UPDATE_DOCUMENT", fmt.Sprintf("%s.%s/%s", dbName, collName, docID), "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ w.logOperation("UPDATE_DOCUMENT", fmt.Sprintf("%s.%s/%s", dbName, collName, docID), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "updated",
+ "id": docID,
+ })
+
+ case http.MethodDelete:
+ docID := r.URL.Query().Get("id")
+ if docID == "" {
+ w.sendJSONError(wr, "Document ID required", http.StatusBadRequest)
+ return
+ }
+
+ if err := coll.Delete(docID); err != nil {
+ w.logOperation("DELETE_DOCUMENT", fmt.Sprintf("%s.%s/%s", dbName, collName, docID), "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ w.logOperation("DELETE_DOCUMENT", fmt.Sprintf("%s.%s/%s", dbName, collName, docID), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "deleted",
+ "id": docID,
+ })
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+// handleClusterStatus возвращает статус кластера
+func (w *WebUIServer) handleClusterStatus(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if w.coordinator == nil {
+ w.sendJSONError(wr, "Cluster not available", http.StatusServiceUnavailable)
+ return
+ }
+
+ status := w.coordinator.GetClusterStatus()
+ w.sendJSONSuccess(wr, status)
+}
+
+// handleClusterNodes возвращает список узлов кластера
+func (w *WebUIServer) handleClusterNodes(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if w.coordinator == nil {
+ w.sendJSONError(wr, "Cluster not available", http.StatusServiceUnavailable)
+ return
+ }
+
+ nodes := w.coordinator.GetAllNodes()
+ w.sendJSONSuccess(wr, nodes)
+}
+
+// handleStats возвращает статистику системы
+func (w *WebUIServer) handleStats(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ databases := w.store.ListDatabases()
+ totalDocs := int64(0)
+ totalCollections := 0
+
+ for _, dbName := range databases {
+ db, _ := w.store.GetDatabase(dbName)
+ if db != nil {
+ collections := db.ListCollections()
+ totalCollections += len(collections)
+ for _, collName := range collections {
+ coll, _ := db.GetCollection(collName)
+ if coll != nil {
+ totalDocs += coll.Count()
+ }
+ }
+ }
+ }
+
+ stats := map[string]interface{}{
+ "databases": len(databases),
+ "collections": totalCollections,
+ "documents": totalDocs,
+ "storage_used_mb": float64(w.store.GetPageSize()*int64(len(databases))) / (1024 * 1024),
+ "uptime_seconds": time.Now().Unix(),
+ "cluster_enabled": w.coordinator != nil,
+ "replication_factor": 0,
+ }
+
+ if w.coordinator != nil {
+ stats["replication_factor"] = w.coordinator.GetReplicationFactor()
+ stats["cluster_health"] = w.coordinator.GetClusterStatus().Health
+ }
+
+ w.sendJSONSuccess(wr, stats)
+}
+
+// handleACLUsers, handleACLRoles, handleACLPermissions, handleACLUser, handleACLRole
+// Эти методы остаются без изменений из оригинальной реализации
+// (сокращены для экономии места, но функционально идентичны)
+
+func (w *WebUIServer) handleACLUsers(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ users := w.aclManager.ListUsers()
+ usersInfo := make([]map[string]interface{}, 0)
+
+ for _, username := range users {
+ user, err := w.aclManager.GetUserInfo(username)
+ if err == nil {
+ usersInfo = append(usersInfo, map[string]interface{}{
+ "username": user.Username,
+ "roles": user.Roles,
+ "active": user.Active,
+ "created_at": user.CreatedAt,
+ "last_login": user.LastLogin,
+ })
+ }
+ }
+
+ w.sendJSONSuccess(wr, usersInfo)
+}
+
+func (w *WebUIServer) handleACLRoles(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ roles := w.aclManager.ListRoles()
+ rolesInfo := make([]map[string]interface{}, 0)
+
+ for _, roleName := range roles {
+ perms, err := w.aclManager.GetRolePermissions(roleName)
+ if err == nil {
+ rolesInfo = append(rolesInfo, map[string]interface{}{
+ "name": roleName,
+ "permissions": perms,
+ })
+ }
+ }
+
+ w.sendJSONSuccess(wr, rolesInfo)
+}
+
+func (w *WebUIServer) handleACLPermissions(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ roles := w.aclManager.ListRoles()
+ allPermissions := make(map[string][]string)
+
+ for _, roleName := range roles {
+ perms, _ := w.aclManager.GetRolePermissions(roleName)
+ allPermissions[roleName] = perms
+ }
+
+ w.sendJSONSuccess(wr, allPermissions)
+}
+
+func (w *WebUIServer) handleACLUser(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ path := strings.TrimPrefix(r.URL.Path, "/api/webui/acl/user/")
+ username := path
+
+ if username == "" {
+ w.sendJSONError(wr, "Username required", http.StatusBadRequest)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodPost:
+ var req struct {
+ Password string `json:"password"`
+ Roles []string `json:"roles"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.sendJSONError(wr, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if err := w.aclManager.CreateUser(username, req.Password, req.Roles); err != nil {
+ w.logOperation("ACL_CREATE_USER", username, "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ w.logOperation("ACL_CREATE_USER", username, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "created"})
+
+ case http.MethodPut:
+ var req struct {
+ AddRole string `json:"add_role"`
+ RemoveRole string `json:"remove_role"`
+ Password string `json:"password"`
+ Disable bool `json:"disable"`
+ Enable bool `json:"enable"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.sendJSONError(wr, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.AddRole != "" {
+ if err := w.aclManager.AddUserRole(username, req.AddRole); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ }
+
+ if req.RemoveRole != "" {
+ if err := w.aclManager.RemoveUserRole(username, req.RemoveRole); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ }
+
+ if req.Password != "" {
+ if err := w.aclManager.ChangePassword(username, req.Password); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ }
+
+ if req.Disable {
+ if err := w.aclManager.DisableUser(username); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ }
+
+ if req.Enable {
+ if err := w.aclManager.EnableUser(username); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ }
+
+ w.logOperation("ACL_UPDATE_USER", username, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "updated"})
+
+ case http.MethodDelete:
+ if err := w.aclManager.DeleteUser(username); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ w.logOperation("ACL_DELETE_USER", username, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "deleted"})
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (w *WebUIServer) handleACLRole(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ path := strings.TrimPrefix(r.URL.Path, "/api/webui/acl/role/")
+ parts := strings.Split(path, "/")
+
+ roleName := parts[0]
+ if roleName == "" {
+ w.sendJSONError(wr, "Role name required", http.StatusBadRequest)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodPost:
+ if err := w.aclManager.CreateRole(roleName); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ w.logOperation("ACL_CREATE_ROLE", roleName, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "created"})
+
+ case http.MethodPut:
+ if len(parts) < 3 {
+ w.sendJSONError(wr, "Action and permission required", http.StatusBadRequest)
+ return
+ }
+
+ action := parts[1]
+ permission := strings.Join(parts[2:], "/")
+
+ switch action {
+ case "grant":
+ if err := w.aclManager.GrantPermission(roleName, permission); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ case "revoke":
+ if err := w.aclManager.RevokePermission(roleName, permission); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ default:
+ w.sendJSONError(wr, "Unknown action", http.StatusBadRequest)
+ return
+ }
+ w.logOperation("ACL_UPDATE_ROLE", roleName, "success", "", map[string]interface{}{"action": action, "permission": permission})
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "updated"})
+
+ case http.MethodDelete:
+ if err := w.aclManager.DeleteRole(roleName); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ w.logOperation("ACL_DELETE_ROLE", roleName, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "deleted"})
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+// handleTransactions, handleTransactionAction, handleIndexesList, handleIndexOperation,
+// handleExportData, handleImportData, handleTriggers, handleTriggerOperation, handleTriggerLog,
+// handleConstraintsList, handleConstraintOperation
+// Эти методы остаются без изменений из оригинальной реализации (сокращены для экономии места)
+
+func (w *WebUIServer) handleTransactions(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ txList := storage.GetActiveTransactions()
+ w.sendJSONSuccess(wr, txList)
+
+ case http.MethodPost:
+ var req struct {
+ Action string `json:"action"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.sendJSONError(wr, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ switch req.Action {
+ case "start_session":
+ if err := storage.InitTransactionManager("futriis.wal"); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "session_started"})
+
+ case "start_transaction":
+ databases := w.store.ListDatabases()
+ if len(databases) == 0 {
+ w.sendJSONError(wr, "No database available", http.StatusBadRequest)
+ return
+ }
+
+ db, err := w.store.GetDatabase(databases[0])
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ collections := db.ListCollections()
+ if len(collections) == 0 {
+ w.sendJSONError(wr, "No collection available", http.StatusBadRequest)
+ return
+ }
+
+ coll, err := db.GetCollection(collections[0])
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ if err := storage.BeginTransactionOnCollection(coll); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.logOperation("TRANSACTION_START", "", "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "transaction_started"})
+
+ default:
+ w.sendJSONError(wr, "Unknown action", http.StatusBadRequest)
+ }
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (w *WebUIServer) handleTransactionAction(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ path := strings.TrimPrefix(r.URL.Path, "/api/webui/transaction/")
+ action := path
+
+ switch r.Method {
+ case http.MethodPost:
+ switch action {
+ case "commit":
+ if err := storage.CommitCurrentTransaction(); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.logOperation("TRANSACTION_COMMIT", "", "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "committed"})
+
+ case "abort":
+ if err := storage.AbortCurrentTransaction(); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.logOperation("TRANSACTION_ABORT", "", "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "aborted"})
+
+ default:
+ w.sendJSONError(wr, "Unknown action", http.StatusBadRequest)
+ }
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (w *WebUIServer) handleIndexesList(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ path := strings.TrimPrefix(r.URL.Path, "/api/webui/indexes/")
+ parts := strings.Split(path, "/")
+
+ if len(parts) < 2 {
+ w.sendJSONError(wr, "Database and collection required", http.StatusBadRequest)
+ return
+ }
+
+ dbName := parts[0]
+ collName := parts[1]
+
+ db, err := w.store.GetDatabase(dbName)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ coll, err := db.GetCollection(collName)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ indexes := coll.GetIndexesInfo()
+ w.sendJSONSuccess(wr, indexes)
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (w *WebUIServer) handleIndexOperation(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ path := strings.TrimPrefix(r.URL.Path, "/api/webui/index/")
+ parts := strings.Split(path, "/")
+
+ if len(parts) < 3 {
+ w.sendJSONError(wr, "Database, collection and action required", http.StatusBadRequest)
+ return
+ }
+
+ dbName := parts[0]
+ collName := parts[1]
+ action := parts[2]
+
+ db, err := w.store.GetDatabase(dbName)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ coll, err := db.GetCollection(collName)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ switch action {
+ case "create":
+ var req struct {
+ Name string `json:"name"`
+ Fields []string `json:"fields"`
+ Unique bool `json:"unique"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.sendJSONError(wr, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if err := coll.CreateIndex(req.Name, req.Fields, req.Unique); err != nil {
+ w.logOperation("INDEX_CREATE", fmt.Sprintf("%s.%s.%s", dbName, collName, req.Name), "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ w.logOperation("INDEX_CREATE", fmt.Sprintf("%s.%s.%s", dbName, collName, req.Name), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "created"})
+
+ case "drop":
+ if len(parts) < 4 {
+ w.sendJSONError(wr, "Index name required", http.StatusBadRequest)
+ return
+ }
+ indexName := parts[3]
+ if err := coll.DropIndex(indexName); err != nil {
+ w.logOperation("INDEX_DROP", fmt.Sprintf("%s.%s.%s", dbName, collName, indexName), "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ w.logOperation("INDEX_DROP", fmt.Sprintf("%s.%s.%s", dbName, collName, indexName), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "dropped"})
+
+ default:
+ w.sendJSONError(wr, "Unknown action", http.StatusBadRequest)
+ }
+}
+
+func (w *WebUIServer) handleExportData(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if r.Method != http.MethodPost {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var req struct {
+ Database string `json:"database"`
+ Filename string `json:"filename"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.sendJSONError(wr, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.Database == "" {
+ w.sendJSONError(wr, "Database name required", http.StatusBadRequest)
+ return
+ }
+
+ if req.Filename == "" {
+ req.Filename = req.Database + "_export_" + strconv.FormatInt(time.Now().Unix(), 10) + ".msgpack"
+ }
+
+ db, err := w.store.GetDatabase(req.Database)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ exportData := make(map[string]interface{})
+ collections := db.ListCollections()
+
+ for _, collName := range collections {
+ coll, err := db.GetCollection(collName)
+ if err != nil {
+ continue
+ }
+
+ docs := coll.GetAllDocuments()
+ collData := make([]map[string]interface{}, 0, len(docs))
+ for _, doc := range docs {
+ docData := map[string]interface{}{
+ "_id": doc.ID,
+ "fields": doc.GetFields(),
+ "created_at": doc.CreatedAt,
+ "updated_at": doc.UpdatedAt,
+ "version": doc.Version,
+ }
+ collData = append(collData, docData)
+ }
+ exportData[collName] = collData
+ }
+
+ exportData["_metadata"] = map[string]interface{}{
+ "database": req.Database,
+ "export_time": time.Now().Unix(),
+ "version": "1.0",
+ "collections": len(collections),
+ }
+
+ w.logOperation("EXPORT", req.Database, "success", "", map[string]interface{}{"collections": len(collections)})
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "export_prepared",
+ "database": req.Database,
+ "filename": req.Filename,
+ "collections": len(collections),
+ "data": exportData,
+ })
+}
+
+func (w *WebUIServer) handleImportData(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if r.Method != http.MethodPost {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var req struct {
+ Database string `json:"database"`
+ Data map[string]interface{} `json:"data"`
+ Overwrite bool `json:"overwrite"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.sendJSONError(wr, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.Database == "" {
+ w.sendJSONError(wr, "Database name required", http.StatusBadRequest)
+ return
+ }
+
+ if req.Data == nil {
+ w.sendJSONError(wr, "Import data required", http.StatusBadRequest)
+ return
+ }
+
+ if !w.store.ExistsDatabase(req.Database) {
+ if err := w.store.CreateDatabase(req.Database); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ }
+
+ db, err := w.store.GetDatabase(req.Database)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ importedCollections := 0
+ importedDocuments := 0
+
+ for key, value := range req.Data {
+ if key == "_metadata" {
+ continue
+ }
+
+ collName := key
+ collData, ok := value.([]interface{})
+ if !ok {
+ continue
+ }
+
+ if _, err := db.GetCollection(collName); err != nil {
+ if err := db.CreateCollection(collName); err != nil {
+ continue
+ }
+ }
+
+ coll, err := db.GetCollection(collName)
+ if err != nil {
+ continue
+ }
+
+ for _, docRaw := range collData {
+ docMap, ok := docRaw.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ var docID string
+ if id, ok := docMap["_id"].(string); ok {
+ docID = id
+ } else {
+ continue
+ }
+
+ if existingDoc, _ := coll.Find(docID); existingDoc != nil && !req.Overwrite {
+ continue
+ }
+
+ doc := storage.NewDocumentWithID(docID)
+
+ if fields, ok := docMap["fields"].(map[string]interface{}); ok {
+ for k, v := range fields {
+ doc.SetField(k, v)
+ }
+ }
+
+ if createdAt, ok := docMap["created_at"]; ok {
+ switch v := createdAt.(type) {
+ case float64:
+ doc.CreatedAt = int64(v)
+ case int64:
+ doc.CreatedAt = v
+ }
+ }
+
+ if updatedAt, ok := docMap["updated_at"]; ok {
+ switch v := updatedAt.(type) {
+ case float64:
+ doc.UpdatedAt = int64(v)
+ case int64:
+ doc.UpdatedAt = v
+ }
+ }
+
+ if version, ok := docMap["version"]; ok {
+ switch v := version.(type) {
+ case float64:
+ doc.Version = uint64(v)
+ case int64:
+ doc.Version = uint64(v)
+ case uint64:
+ doc.Version = v
+ }
+ }
+
+ if err := coll.Insert(doc); err != nil {
+ continue
+ }
+ importedDocuments++
+ }
+ importedCollections++
+ }
+
+ w.logOperation("IMPORT", req.Database, "success", "", map[string]interface{}{
+ "collections": importedCollections,
+ "documents": importedDocuments,
+ })
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "imported",
+ "database": req.Database,
+ "collections": importedCollections,
+ "documents": importedDocuments,
+ })
+}
+
+func (w *WebUIServer) handleTriggers(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ path := strings.TrimPrefix(r.URL.Path, "/api/webui/triggers/")
+ parts := strings.Split(path, "/")
+
+ tm := storage.GetTriggerManager()
+ if tm == nil {
+ storage.InitTriggerManager(w.logger)
+ tm = storage.GetTriggerManager()
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ if len(parts) >= 2 {
+ dbName := parts[0]
+ collName := parts[1]
+
+ db, err := w.store.GetDatabase(dbName)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ if _, err := db.GetCollection(collName); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ triggers := tm.ListTriggers(collName)
+
+ triggerList := make([]map[string]interface{}, 0, len(triggers))
+ for _, trigger := range triggers {
+ triggerList = append(triggerList, map[string]interface{}{
+ "name": trigger.Name,
+ "collection": trigger.Collection,
+ "event": trigger.Event,
+ "action": trigger.Action,
+ "enabled": trigger.Enabled,
+ "description": trigger.Description,
+ "created_at": trigger.CreatedAt,
+ "updated_at": trigger.UpdatedAt,
+ "has_condition": trigger.Condition != nil,
+ "operations_count": len(trigger.Operations),
+ })
+ }
+ w.sendJSONSuccess(wr, triggerList)
+ } else {
+ triggers := tm.ListTriggers("")
+ triggerList := make([]map[string]interface{}, 0, len(triggers))
+ for _, trigger := range triggers {
+ triggerList = append(triggerList, map[string]interface{}{
+ "name": trigger.Name,
+ "collection": trigger.Collection,
+ "event": trigger.Event,
+ "action": trigger.Action,
+ "enabled": trigger.Enabled,
+ "description": trigger.Description,
+ })
+ }
+ w.sendJSONSuccess(wr, triggerList)
+ }
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (w *WebUIServer) handleTriggerOperation(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ path := strings.TrimPrefix(r.URL.Path, "/api/webui/trigger/")
+ parts := strings.Split(path, "/")
+
+ if len(parts) < 3 {
+ w.sendJSONError(wr, "Invalid path. Use /api/webui/trigger/{db}/{collection}/{action}", http.StatusBadRequest)
+ return
+ }
+
+ dbName := parts[0]
+ collName := parts[1]
+ action := parts[2]
+
+ tm := storage.GetTriggerManager()
+ if tm == nil {
+ storage.InitTriggerManager(w.logger)
+ tm = storage.GetTriggerManager()
+ }
+
+ switch action {
+ case "create":
+ if r.Method != http.MethodPost {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var req struct {
+ Name string `json:"name"`
+ Event string `json:"event"`
+ Action string `json:"action"`
+ Condition map[string]interface{} `json:"condition"`
+ Operations []map[string]interface{} `json:"operations"`
+ Description string `json:"description"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.sendJSONError(wr, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.Name == "" {
+ w.sendJSONError(wr, "Trigger name required", http.StatusBadRequest)
+ return
+ }
+
+ config := make(map[string]interface{})
+ config["action"] = req.Action
+ config["description"] = req.Description
+
+ if req.Condition != nil {
+ config["condition"] = req.Condition
+ }
+
+ if len(req.Operations) > 0 {
+ ops := make([]interface{}, len(req.Operations))
+ for i, op := range req.Operations {
+ ops[i] = op
+ }
+ config["operations"] = ops
+ }
+
+ event := storage.TriggerEvent(req.Event)
+ if event == "" {
+ event = storage.TriggerAfterInsert
+ }
+
+ if err := tm.CreateTrigger(dbName, collName, req.Name, event, config); err != nil {
+ w.logOperation("TRIGGER_CREATE", fmt.Sprintf("%s.%s.%s", dbName, collName, req.Name), "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ w.logOperation("TRIGGER_CREATE", fmt.Sprintf("%s.%s.%s", dbName, collName, req.Name), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "created",
+ "name": req.Name,
+ })
+
+ case "enable":
+ if r.Method != http.MethodPost {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ if len(parts) < 4 {
+ w.sendJSONError(wr, "Trigger name required", http.StatusBadRequest)
+ return
+ }
+ triggerName := parts[3]
+
+ if err := tm.EnableTrigger(collName, "", triggerName); err != nil {
+ w.logOperation("TRIGGER_ENABLE", fmt.Sprintf("%s.%s.%s", dbName, collName, triggerName), "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+ w.logOperation("TRIGGER_ENABLE", fmt.Sprintf("%s.%s.%s", dbName, collName, triggerName), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "enabled", "name": triggerName})
+
+ case "disable":
+ if r.Method != http.MethodPost {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ if len(parts) < 4 {
+ w.sendJSONError(wr, "Trigger name required", http.StatusBadRequest)
+ return
+ }
+ triggerName := parts[3]
+
+ if err := tm.DisableTrigger(collName, "", triggerName); err != nil {
+ w.logOperation("TRIGGER_DISABLE", fmt.Sprintf("%s.%s.%s", dbName, collName, triggerName), "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+ w.logOperation("TRIGGER_DISABLE", fmt.Sprintf("%s.%s.%s", dbName, collName, triggerName), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "disabled", "name": triggerName})
+
+ case "delete":
+ if r.Method != http.MethodDelete {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ if len(parts) < 4 {
+ w.sendJSONError(wr, "Trigger name required", http.StatusBadRequest)
+ return
+ }
+ triggerName := parts[3]
+ triggerEvent := parts[4]
+
+ if err := tm.DropTrigger(collName, triggerEvent, triggerName); err != nil {
+ w.logOperation("TRIGGER_DELETE", fmt.Sprintf("%s.%s.%s", dbName, collName, triggerName), "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+ w.logOperation("TRIGGER_DELETE", fmt.Sprintf("%s.%s.%s", dbName, collName, triggerName), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "deleted", "name": triggerName})
+
+ default:
+ w.sendJSONError(wr, "Unknown action", http.StatusBadRequest)
+ }
+}
+
+func (w *WebUIServer) handleTriggerLog(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if r.Method != http.MethodGet {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ tm := storage.GetTriggerManager()
+ if tm == nil {
+ w.sendJSONSuccess(wr, []interface{}{})
+ return
+ }
+
+ log := tm.GetTriggerExecutionLog()
+
+ formattedLog := make([]map[string]interface{}, 0, len(log))
+ for _, entry := range log {
+ formattedLog = append(formattedLog, map[string]interface{}{
+ "trigger_name": entry.TriggerName,
+ "event": entry.Event,
+ "collection": entry.Collection,
+ "database": entry.Database,
+ "document_id": entry.DocumentID,
+ "operation": entry.Operation,
+ "timestamp": entry.Timestamp.UnixMilli(),
+ "user": entry.User,
+ "role": entry.Role,
+ "custom_data": entry.CustomData,
+ })
+ }
+
+ w.sendJSONSuccess(wr, formattedLog)
+}
+
+func (w *WebUIServer) handleConstraintsList(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if r.Method != http.MethodGet {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ path := strings.TrimPrefix(r.URL.Path, "/api/webui/constraints/")
+ parts := strings.Split(path, "/")
+
+ if len(parts) < 2 {
+ w.sendJSONError(wr, "Database and collection required", http.StatusBadRequest)
+ return
+ }
+
+ dbName := parts[0]
+ collName := parts[1]
+
+ db, err := w.store.GetDatabase(dbName)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ coll, err := db.GetCollection(collName)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ constraintsList := make([]map[string]interface{}, 0)
+
+ requiredFields := coll.GetRequiredFields()
+ for _, field := range requiredFields {
+ constraintsList = append(constraintsList, map[string]interface{}{
+ "type": "required",
+ "field": field,
+ })
+ }
+
+ uniqueFields := coll.GetUniqueConstraints()
+ for _, field := range uniqueFields {
+ constraintsList = append(constraintsList, map[string]interface{}{
+ "type": "unique",
+ "field": field,
+ })
+ }
+
+ minConstraints := coll.GetMinConstraints()
+ for field, value := range minConstraints {
+ constraintsList = append(constraintsList, map[string]interface{}{
+ "type": "min",
+ "field": field,
+ "value": value,
+ })
+ }
+
+ maxConstraints := coll.GetMaxConstraints()
+ for field, value := range maxConstraints {
+ constraintsList = append(constraintsList, map[string]interface{}{
+ "type": "max",
+ "field": field,
+ "value": value,
+ })
+ }
+
+ enumConstraints := coll.GetEnumConstraints()
+ for field, values := range enumConstraints {
+ constraintsList = append(constraintsList, map[string]interface{}{
+ "type": "enum",
+ "field": field,
+ "values": values,
+ })
+ }
+
+ regexConstraints := coll.GetRegexConstraints()
+ for field, pattern := range regexConstraints {
+ constraintsList = append(constraintsList, map[string]interface{}{
+ "type": "regex",
+ "field": field,
+ "pattern": pattern,
+ })
+ }
+
+ w.sendJSONSuccess(wr, constraintsList)
+}
+
+func (w *WebUIServer) handleConstraintOperation(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ path := strings.TrimPrefix(r.URL.Path, "/api/webui/constraint/")
+ parts := strings.Split(path, "/")
+
+ if len(parts) < 3 {
+ w.sendJSONError(wr, "Invalid path. Use /api/webui/constraint/{db}/{collection}/{action}", http.StatusBadRequest)
+ return
+ }
+
+ dbName := parts[0]
+ collName := parts[1]
+ action := parts[2]
+
+ db, err := w.store.GetDatabase(dbName)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ coll, err := db.GetCollection(collName)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodPost:
+ switch action {
+ case "required":
+ if len(parts) < 4 {
+ w.sendJSONError(wr, "Field name required", http.StatusBadRequest)
+ return
+ }
+ field := parts[3]
+ coll.AddRequiredField(field)
+ w.logOperation("CONSTRAINT_ADD", fmt.Sprintf("%s.%s.required.%s", dbName, collName, field), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "added",
+ "type": "required",
+ "field": field,
+ })
+
+ case "unique":
+ if len(parts) < 4 {
+ w.sendJSONError(wr, "Field name required", http.StatusBadRequest)
+ return
+ }
+ field := parts[3]
+ coll.AddUniqueConstraint(field)
+ w.logOperation("CONSTRAINT_ADD", fmt.Sprintf("%s.%s.unique.%s", dbName, collName, field), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "added",
+ "type": "unique",
+ "field": field,
+ })
+
+ case "min":
+ if len(parts) < 5 {
+ w.sendJSONError(wr, "Field name and value required", http.StatusBadRequest)
+ return
+ }
+ field := parts[3]
+ minVal, err := strconv.ParseFloat(parts[4], 64)
+ if err != nil {
+ w.sendJSONError(wr, "Invalid minimum value", http.StatusBadRequest)
+ return
+ }
+ coll.AddMinConstraint(field, minVal)
+ w.logOperation("CONSTRAINT_ADD", fmt.Sprintf("%s.%s.min.%s", dbName, collName, field), "success", "", map[string]interface{}{"value": minVal})
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "added",
+ "type": "min",
+ "field": field,
+ "value": minVal,
+ })
+
+ case "max":
+ if len(parts) < 5 {
+ w.sendJSONError(wr, "Field name and value required", http.StatusBadRequest)
+ return
+ }
+ field := parts[3]
+ maxVal, err := strconv.ParseFloat(parts[4], 64)
+ if err != nil {
+ w.sendJSONError(wr, "Invalid maximum value", http.StatusBadRequest)
+ return
+ }
+ coll.AddMaxConstraint(field, maxVal)
+ w.logOperation("CONSTRAINT_ADD", fmt.Sprintf("%s.%s.max.%s", dbName, collName, field), "success", "", map[string]interface{}{"value": maxVal})
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "added",
+ "type": "max",
+ "field": field,
+ "value": maxVal,
+ })
+
+ case "enum":
+ if len(parts) < 5 {
+ w.sendJSONError(wr, "Field name and values required", http.StatusBadRequest)
+ return
+ }
+ field := parts[3]
+ values := make([]interface{}, len(parts)-4)
+ for i := 4; i < len(parts); i++ {
+ if num, err := strconv.ParseFloat(parts[i], 64); err == nil {
+ values[i-4] = num
+ } else {
+ values[i-4] = parts[i]
+ }
+ }
+ coll.AddEnumConstraint(field, values)
+ w.logOperation("CONSTRAINT_ADD", fmt.Sprintf("%s.%s.enum.%s", dbName, collName, field), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "added",
+ "type": "enum",
+ "field": field,
+ "values": values,
+ })
+
+ case "regex":
+ if len(parts) < 5 {
+ w.sendJSONError(wr, "Field name and pattern required", http.StatusBadRequest)
+ return
+ }
+ field := parts[3]
+ pattern := parts[4]
+ coll.AddRegexConstraint(field, pattern)
+ w.logOperation("CONSTRAINT_ADD", fmt.Sprintf("%s.%s.regex.%s", dbName, collName, field), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "added",
+ "type": "regex",
+ "field": field,
+ "pattern": pattern,
+ })
+
+ default:
+ w.sendJSONError(wr, "Unknown constraint type", http.StatusBadRequest)
+ }
+
+ case http.MethodDelete:
+ if len(parts) < 4 {
+ w.sendJSONError(wr, "Constraint type and field required", http.StatusBadRequest)
+ return
+ }
+ constraintType := parts[2]
+ field := parts[3]
+
+ switch constraintType {
+ case "required":
+ coll.RemoveRequiredField(field)
+ case "unique":
+ coll.RemoveUniqueConstraint(field)
+ case "min":
+ coll.RemoveMinConstraint(field)
+ case "max":
+ coll.RemoveMaxConstraint(field)
+ case "enum":
+ coll.RemoveEnumConstraint(field)
+ case "regex":
+ coll.RemoveRegexConstraint(field)
+ default:
+ w.sendJSONError(wr, "Unknown constraint type", http.StatusBadRequest)
+ return
+ }
+
+ w.logOperation("CONSTRAINT_REMOVE", fmt.Sprintf("%s.%s.%s.%s", dbName, collName, constraintType, field), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "removed",
+ "type": constraintType,
+ "field": field,
+ })
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
diff --git a/internal/api/webui_credentials.go b/internal/api/webui_credentials.go
new file mode 100644
index 0000000..7a97f4f
--- /dev/null
+++ b/internal/api/webui_credentials.go
@@ -0,0 +1,425 @@
+/*
+ * Copyright 2026 Safronov Grigorii
+ *
+ * Licensed under the CDDL, Version 1.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ *
+ * You may obtain a copy of the License at
+ * https://opensource.org/licenses/CDDL-1.0
+ */
+
+// Файл: internal/api/webui_credentials.go
+// Назначение: Управление учётными данными для веб-интерфейса
+// Хранит логин/пароль в скрытом файле .credentials в директории futriis
+// Поддерживает множественных пользователей-администраторов
+
+package api
+
+import (
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+)
+
+// UserCredential представляет учётные данные одного пользователя
+type UserCredential struct {
+ Password string `json:"password"` // хранится в виде хеша
+ Avatar string `json:"avatar,omitempty"` // base64 encoded avatar
+ IsAdmin bool `json:"is_admin"` // является ли администратором
+ CreatedAt int64 `json:"created_at"` // время создания
+ LastLogin int64 `json:"last_login,omitempty"` // время последнего входа
+}
+
+// Credentials структура для хранения всех учётных данных
+type Credentials struct {
+ Users map[string]*UserCredential `json:"users"`
+ Settings map[string]interface{} `json:"settings,omitempty"`
+}
+
+// CredentialManager управляет учётными данными веб-интерфейса
+type CredentialManager struct {
+ mu sync.RWMutex
+ credentials *Credentials
+ credFile string
+ currentUser string
+}
+
+// NewCredentialManager создаёт новый менеджер учётных данных
+func NewCredentialManager() *CredentialManager {
+ // Определяем путь к директории futriis
+ execPath, err := os.Executable()
+ if err != nil {
+ execPath = "."
+ }
+ futriisDir := filepath.Dir(execPath)
+
+ // Ищем директорию futriis (поднимаемся вверх, если нужно)
+ for {
+ if _, err := os.Stat(filepath.Join(futriisDir, "futriis")); err == nil {
+ futriisDir = filepath.Join(futriisDir, "futriis")
+ break
+ }
+ parent := filepath.Dir(futriisDir)
+ if parent == futriisDir {
+ // Не нашли директорию futriis, создаём в текущей
+ futriisDir = filepath.Join(execPath, "futriis")
+ os.MkdirAll(futriisDir, 0700)
+ break
+ }
+ futriisDir = parent
+ }
+
+ credFile := filepath.Join(futriisDir, ".credentials")
+
+ return &CredentialManager{
+ credentials: &Credentials{
+ Users: make(map[string]*UserCredential),
+ Settings: make(map[string]interface{}),
+ },
+ credFile: credFile,
+ }
+}
+
+// hashPassword создаёт хеш пароля
+func (cm *CredentialManager) hashPassword(password string) string {
+ hash := sha256.Sum256([]byte(password))
+ return base64.StdEncoding.EncodeToString(hash[:])
+}
+
+// Load загружает учётные данные из файла
+func (cm *CredentialManager) Load() error {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ data, err := os.ReadFile(cm.credFile)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return cm.createDefaultFile()
+ }
+ return err
+ }
+
+ var creds Credentials
+ if err := json.Unmarshal(data, &creds); err != nil {
+ return err
+ }
+
+ cm.credentials = &creds
+ if cm.credentials.Users == nil {
+ cm.credentials.Users = make(map[string]*UserCredential)
+ }
+ if cm.credentials.Settings == nil {
+ cm.credentials.Settings = make(map[string]interface{})
+ }
+
+ return nil
+}
+
+// createDefaultFile создаёт файл с учётными данными по умолчанию
+func (cm *CredentialManager) createDefaultFile() error {
+ // Создаём пользователя admin по умолчанию
+ cm.credentials.Users["admin"] = &UserCredential{
+ Password: cm.hashPassword("admin"),
+ IsAdmin: true,
+ CreatedAt: time.Now().Unix(),
+ }
+ cm.credentials.Settings["version"] = "1.0"
+
+ return cm.save()
+}
+
+// CreateDefault создаёт учётные данные по умолчанию (если файл не существует)
+func (cm *CredentialManager) CreateDefault() error {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ // Проверяем, существует ли файл
+ if _, err := os.Stat(cm.credFile); err == nil {
+ return nil // файл уже существует
+ }
+
+ cm.credentials.Users["admin"] = &UserCredential{
+ Password: cm.hashPassword("admin"),
+ IsAdmin: true,
+ CreatedAt: time.Now().Unix(),
+ }
+ cm.credentials.Settings["version"] = "1.0"
+
+ return cm.save()
+}
+
+// save сохраняет учётные данные в файл
+func (cm *CredentialManager) save() error {
+ data, err := json.MarshalIndent(cm.credentials, "", " ")
+ if err != nil {
+ return err
+ }
+
+ // Устанавливаем права доступа 0600 (только владелец может читать/писать)
+ return os.WriteFile(cm.credFile, data, 0600)
+}
+
+// Validate проверяет учётные данные
+func (cm *CredentialManager) Validate(username, password string) bool {
+ cm.mu.RLock()
+ defer cm.mu.RUnlock()
+
+ if cm.credentials == nil || cm.credentials.Users == nil {
+ return false
+ }
+
+ user, exists := cm.credentials.Users[username]
+ if !exists {
+ return false
+ }
+
+ return user.Password == cm.hashPassword(password)
+}
+
+// IsAdmin проверяет, является ли пользователь администратором
+func (cm *CredentialManager) IsAdmin(username string) bool {
+ cm.mu.RLock()
+ defer cm.mu.RUnlock()
+
+ if cm.credentials == nil || cm.credentials.Users == nil {
+ return false
+ }
+
+ user, exists := cm.credentials.Users[username]
+ if !exists {
+ return false
+ }
+
+ return user.IsAdmin
+}
+
+// CreateUser создаёт нового пользователя (только для администраторов)
+func (cm *CredentialManager) CreateUser(username, password string, isAdmin bool) error {
+ if username == "" {
+ return fmt.Errorf("username cannot be empty")
+ }
+
+ if len(password) < 4 {
+ return fmt.Errorf("password must be at least 4 characters")
+ }
+
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ if cm.credentials == nil {
+ cm.credentials = &Credentials{
+ Users: make(map[string]*UserCredential),
+ Settings: make(map[string]interface{}),
+ }
+ }
+
+ if _, exists := cm.credentials.Users[username]; exists {
+ return fmt.Errorf("user %s already exists", username)
+ }
+
+ cm.credentials.Users[username] = &UserCredential{
+ Password: cm.hashPassword(password),
+ IsAdmin: isAdmin,
+ CreatedAt: time.Now().Unix(),
+ }
+
+ return cm.save()
+}
+
+// DeleteUser удаляет пользователя (только для администраторов)
+func (cm *CredentialManager) DeleteUser(username string) error {
+ if username == "admin" {
+ return fmt.Errorf("cannot delete default admin user")
+ }
+
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ if cm.credentials == nil || cm.credentials.Users == nil {
+ return fmt.Errorf("no users found")
+ }
+
+ if _, exists := cm.credentials.Users[username]; !exists {
+ return fmt.Errorf("user %s not found", username)
+ }
+
+ delete(cm.credentials.Users, username)
+ return cm.save()
+}
+
+// ListUsers возвращает список всех пользователей (только для администраторов)
+func (cm *CredentialManager) ListUsers() []map[string]interface{} {
+ cm.mu.RLock()
+ defer cm.mu.RUnlock()
+
+ users := make([]map[string]interface{}, 0)
+
+ for username, user := range cm.credentials.Users {
+ users = append(users, map[string]interface{}{
+ "username": username,
+ "is_admin": user.IsAdmin,
+ "created_at": user.CreatedAt,
+ "last_login": user.LastLogin,
+ "has_avatar": user.Avatar != "",
+ })
+ }
+
+ return users
+}
+
+// ChangePassword изменяет пароль пользователя
+func (cm *CredentialManager) ChangePassword(username, currentPassword, newPassword string) error {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ if cm.credentials == nil || cm.credentials.Users == nil {
+ return fmt.Errorf("credentials not loaded")
+ }
+
+ user, exists := cm.credentials.Users[username]
+ if !exists {
+ return fmt.Errorf("user not found")
+ }
+
+ if user.Password != cm.hashPassword(currentPassword) {
+ return fmt.Errorf("current password is incorrect")
+ }
+
+ if len(newPassword) < 4 {
+ return fmt.Errorf("new password must be at least 4 characters")
+ }
+
+ user.Password = cm.hashPassword(newPassword)
+
+ return cm.save()
+}
+
+// AdminChangePassword изменяет пароль пользователя (без проверки старого, только для админов)
+func (cm *CredentialManager) AdminChangePassword(username, newPassword string) error {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ if cm.credentials == nil || cm.credentials.Users == nil {
+ return fmt.Errorf("credentials not loaded")
+ }
+
+ user, exists := cm.credentials.Users[username]
+ if !exists {
+ return fmt.Errorf("user not found")
+ }
+
+ if len(newPassword) < 4 {
+ return fmt.Errorf("password must be at least 4 characters")
+ }
+
+ user.Password = cm.hashPassword(newPassword)
+
+ return cm.save()
+}
+
+// SetAvatar устанавливает аватар для пользователя
+func (cm *CredentialManager) SetAvatar(username, avatarBase64 string) error {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ if cm.credentials == nil || cm.credentials.Users == nil {
+ return fmt.Errorf("credentials not loaded")
+ }
+
+ user, exists := cm.credentials.Users[username]
+ if !exists {
+ return fmt.Errorf("user not found")
+ }
+
+ user.Avatar = avatarBase64
+
+ return cm.save()
+}
+
+// GetAvatar возвращает аватар пользователя
+func (cm *CredentialManager) GetAvatar(username string) (string, error) {
+ cm.mu.RLock()
+ defer cm.mu.RUnlock()
+
+ if cm.credentials == nil || cm.credentials.Users == nil {
+ return "", fmt.Errorf("credentials not loaded")
+ }
+
+ user, exists := cm.credentials.Users[username]
+ if !exists {
+ return "", fmt.Errorf("user not found")
+ }
+
+ return user.Avatar, nil
+}
+
+// DeleteAvatar удаляет аватар пользователя
+func (cm *CredentialManager) DeleteAvatar(username string) error {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ if cm.credentials == nil || cm.credentials.Users == nil {
+ return fmt.Errorf("credentials not loaded")
+ }
+
+ user, exists := cm.credentials.Users[username]
+ if !exists {
+ return fmt.Errorf("user not found")
+ }
+
+ user.Avatar = ""
+
+ return cm.save()
+}
+
+// UpdateLastLogin обновляет время последнего входа пользователя
+func (cm *CredentialManager) UpdateLastLogin(username string) {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ if cm.credentials == nil || cm.credentials.Users == nil {
+ return
+ }
+
+ user, exists := cm.credentials.Users[username]
+ if !exists {
+ return
+ }
+
+ user.LastLogin = time.Now().Unix()
+ cm.save() // игнорируем ошибку, т.к. это не критично
+}
+
+// GetCurrentUsername возвращает имя текущего пользователя
+func (cm *CredentialManager) GetCurrentUsername() string {
+ cm.mu.RLock()
+ defer cm.mu.RUnlock()
+
+ if cm.credentials == nil {
+ return ""
+ }
+
+ // Возвращаем последнего вошедшего пользователя или admin по умолчанию
+ if cm.currentUser != "" {
+ return cm.currentUser
+ }
+
+ return "admin"
+}
+
+// SetCurrentUsername устанавливает имя текущего пользователя
+func (cm *CredentialManager) SetCurrentUsername(username string) {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+ cm.currentUser = username
+}
+
+// GetCredentialsFile возвращает путь к файлу с учётными данными
+func (cm *CredentialManager) GetCredentialsFile() string {
+ return cm.credFile
+}