diff --git a/cmd/server/main.go b/cmd/server/main.go index 6195676..d102fe4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,6 +1,6 @@ // Fail2ban UI - A Swiss made, management interface for Fail2ban. // -// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch) +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) // // Licensed under the GNU General Public License, Version 3 (GPL-3.0) // You may not use this file except in compliance with the License. @@ -40,6 +40,7 @@ import ( func main() { settings := config.GetSettings() + // Initialize storage if err := storage.Init(""); err != nil { log.Fatalf("Failed to initialise storage: %v", err) } @@ -49,11 +50,12 @@ func main() { } }() + // Initialize Fail2ban connectors if err := fail2ban.GetManager().ReloadFromSettings(settings); err != nil { log.Fatalf("failed to initialise fail2ban connectors: %v", err) } - // OIDC authentication (optional) + // Initialize OIDC authentication oidcConfig, err := config.GetOIDCConfigFromEnv() if err != nil { log.Fatalf("failed to load OIDC configuration: %v", err) @@ -68,18 +70,20 @@ func main() { log.Println("OIDC authentication enabled") } + // Set Gin mode if settings.Debug { gin.SetMode(gin.DebugMode) } else { gin.SetMode(gin.ReleaseMode) } + // Initialize router router := gin.Default() serverPort := strconv.Itoa(int(settings.Port)) bindAddress, _ := config.GetBindAddressFromEnv() serverAddr := net.JoinHostPort(bindAddress, serverPort) - // Container vs local: different paths for templates and static assets + // Load templates and static assets based on running in container or locally (compiled binary) _, container := os.LookupEnv("CONTAINER") if container { router.LoadHTMLGlob("/app/templates/*") @@ -91,7 +95,7 @@ func main() { router.Static("/static", "./pkg/web/static") } - // WebSocket hub and console log capture + // Initialize WebSocket hub and console log capture wsHub := web.NewHub() go wsHub.Run() web.SetupConsoleLogWriter(wsHub) @@ -100,6 +104,7 @@ func main() { web.SetConsoleLogEnabled(enabled) }) + // Register routes web.RegisterRoutes(router, wsHub) isLOTRMode := isLOTRModeActive(settings.AlertCountries) printWelcomeBanner(bindAddress, serverPort, isLOTRMode) @@ -127,7 +132,7 @@ func isLOTRModeActive(alertCountries []string) bool { return false } -// printWelcomeBanner prints the Tux banner with startup info. +// Print welcome banner. func printWelcomeBanner(bindAddress, appPort string, isLOTRMode bool) { greeting := getGreeting() @@ -173,7 +178,6 @@ Listening on: http://%s:%s } } -// getGreeting returns a friendly greeting based on the time of day. func getGreeting() string { hour := time.Now().Hour() switch { diff --git a/internal/fail2ban/logparse.go b/internal/fail2ban/logparse.go index e77913b..6001672 100644 --- a/internal/fail2ban/logparse.go +++ b/internal/fail2ban/logparse.go @@ -14,6 +14,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +// LEGACY, WILL BE REMOVED IN FUTURE VERSIONS. package fail2ban import ( @@ -24,13 +25,13 @@ import ( "time" ) -var ( - // Typical fail2ban log line: - // 2023-01-20 10:15:30,123 fail2ban.actions [1234]: NOTICE [sshd] Ban 192.168.0.101 - logRegex = regexp.MustCompile(`^(\S+\s+\S+) fail2ban\.actions.*?\[\d+\]: NOTICE\s+\[(\S+)\]\s+Ban\s+(\S+)`) -) +// ========================================================================= +// Types +// ========================================================================= -// BanEvent holds details about a ban +var logRegex = regexp.MustCompile(`^(\S+\s+\S+) fail2ban\.actions.*?\[\d+\]: NOTICE\s+\[(\S+)\]\s+Ban\s+(\S+)`) + +// This is a single ban event from the fail2ban log. REMOVE THIS TYPE. type BanEvent struct { Time time.Time Jail string @@ -38,7 +39,11 @@ type BanEvent struct { LogLine string } -// ParseBanLog returns a map[jailName]BanEvents and also the last 5 ban events overall. +// ========================================================================= +// Log Parsing +// ========================================================================= + +// ParseBanLog reads the fail2ban log and returns events grouped by jail. func ParseBanLog(logPath string) (map[string][]BanEvent, error) { file, err := os.Open(logPath) if err != nil { @@ -54,17 +59,12 @@ func ParseBanLog(logPath string) (map[string][]BanEvent, error) { matches := logRegex.FindStringSubmatch(line) if len(matches) == 4 { - // matches[1] -> "2023-01-20 10:15:30,123" - // matches[2] -> jail name, e.g. "sshd" - // matches[3] -> IP, e.g. "192.168.0.101" timestampStr := matches[1] jail := matches[2] ip := matches[3] - // parse "2023-01-20 10:15:30,123" -> time.Time parsedTime, err := time.Parse("2006-01-02 15:04:05,000", timestampStr) if err != nil { - // If parse fails, skip or set parsedTime=zero continue } diff --git a/internal/fail2ban/manager.go b/internal/fail2ban/manager.go index e2aa2e2..4e564ea 100644 --- a/internal/fail2ban/manager.go +++ b/internal/fail2ban/manager.go @@ -8,7 +8,11 @@ import ( "github.com/swissmakers/fail2ban-ui/internal/config" ) -// Connector describes a communication backend for a Fail2ban server. +// ========================================================================= +// Connector Interface +// ========================================================================= + +// Connector is the communication backend for a Fail2ban server. type Connector interface { ID() string Server() config.Fail2banServer @@ -19,7 +23,7 @@ type Connector interface { BanIP(ctx context.Context, jail, ip string) error Reload(ctx context.Context) error Restart(ctx context.Context) error - GetFilterConfig(ctx context.Context, jail string) (string, string, error) // Returns (config, filePath, error) + GetFilterConfig(ctx context.Context, jail string) (string, string, error) SetFilterConfig(ctx context.Context, jail, content string) error FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) @@ -32,7 +36,7 @@ type Connector interface { TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (output string, filterPath string, err error) // Jail configuration operations - GetJailConfig(ctx context.Context, jail string) (string, string, error) // Returns (config, filePath, error) + GetJailConfig(ctx context.Context, jail string) (string, string, error) SetJailConfig(ctx context.Context, jail, content string) error TestLogpath(ctx context.Context, logpath string) ([]string, error) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) @@ -54,7 +58,11 @@ type Connector interface { DeleteFilter(ctx context.Context, filterName string) error } -// Manager orchestrates all connectors for configured Fail2ban servers. +// ========================================================================= +// Manager +// ========================================================================= + +// Manager holds connectors for all configured Fail2ban servers. type Manager struct { mu sync.RWMutex connectors map[string]Connector @@ -65,7 +73,6 @@ var ( managerInst *Manager ) -// GetManager returns the singleton connector manager. func GetManager() *Manager { managerOnce.Do(func() { managerInst = &Manager{ @@ -75,7 +82,6 @@ func GetManager() *Manager { return managerInst } -// ReloadFromSettings rebuilds connectors using the provided settings. func (m *Manager) ReloadFromSettings(settings config.AppSettings) error { m.mu.Lock() defer m.mu.Unlock() @@ -96,7 +102,7 @@ func (m *Manager) ReloadFromSettings(settings config.AppSettings) error { return nil } -// Connector returns the connector for the specified server ID. +// Returns the connector for the specified server ID. func (m *Manager) Connector(serverID string) (Connector, error) { m.mu.RLock() defer m.mu.RUnlock() @@ -111,7 +117,7 @@ func (m *Manager) Connector(serverID string) (Connector, error) { return conn, nil } -// DefaultConnector returns the default connector as defined in settings. +// Returns the default connector as defined in settings. func (m *Manager) DefaultConnector() (Connector, error) { server := config.GetDefaultServer() if server.ID == "" { @@ -120,7 +126,7 @@ func (m *Manager) DefaultConnector() (Connector, error) { return m.Connector(server.ID) } -// Connectors returns all connectors. +// Returns all connectors. func (m *Manager) Connectors() []Connector { m.mu.RLock() defer m.mu.RUnlock() @@ -131,7 +137,11 @@ func (m *Manager) Connectors() []Connector { return result } -// UpdateActionFiles updates action files for all active remote connectors (SSH and Agent). +// ========================================================================= +// Action File Management +// ========================================================================= + +// Updates action files for all active remote connectors (SSH and Agent). func (m *Manager) UpdateActionFiles(ctx context.Context) error { m.mu.RLock() connectors := make([]Connector, 0, len(m.connectors)) @@ -154,7 +164,7 @@ func (m *Manager) UpdateActionFiles(ctx context.Context) error { return lastErr } -// UpdateActionFileForServer updates the action file for a specific server by ID. +// Updates the action file for a single server. func (m *Manager) UpdateActionFileForServer(ctx context.Context, serverID string) error { m.mu.RLock() conn, ok := m.connectors[serverID] @@ -165,7 +175,6 @@ func (m *Manager) UpdateActionFileForServer(ctx context.Context, serverID string return updateConnectorAction(ctx, conn) } -// updateConnectorAction updates the action file for a specific connector. func updateConnectorAction(ctx context.Context, conn Connector) error { switch c := conn.(type) { case *SSHConnector: @@ -173,15 +182,18 @@ func updateConnectorAction(ctx context.Context, conn Connector) error { case *AgentConnector: return c.ensureAction(ctx) default: - return nil // Local connectors are handled separately + return nil } } +// ========================================================================= +// Connector Factory +// ========================================================================= + func newConnectorForServer(server config.Fail2banServer) (Connector, error) { switch server.Type { case "local": - // Run migration FIRST before ensuring structure — but only when - // the experimental JAIL_AUTOMIGRATION=true env var is set. + // Run migration before ensuring structure, but only when the experimental JAIL_AUTOMIGRATION=true env var is set. if isJailAutoMigrationEnabled() { config.DebugLog("JAIL_AUTOMIGRATION=true: running experimental jail.local → jail.d/ migration for local server %s", server.Name) if err := MigrateJailsFromJailLocal(); err != nil { diff --git a/internal/fail2ban/variable_resolver.go b/internal/fail2ban/variable_resolver.go index ecd588f..d272de3 100644 --- a/internal/fail2ban/variable_resolver.go +++ b/internal/fail2ban/variable_resolver.go @@ -27,13 +27,12 @@ import ( "github.com/swissmakers/fail2ban-ui/internal/config" ) -var ( - // Variable pattern: %(variable_name)s - variablePattern = regexp.MustCompile(`%\(([^)]+)\)s`) -) +// ========================================================================= +// Pattern Matching +// ========================================================================= + +var variablePattern = regexp.MustCompile(`%\(([^)]+)\)s`) -// extractVariablesFromString extracts all variable names from a string. -// Returns a list of variable names found in the pattern %(name)s. func extractVariablesFromString(s string) []string { matches := variablePattern.FindAllStringSubmatch(s, -1) if len(matches) == 0 { @@ -49,8 +48,10 @@ func extractVariablesFromString(s string) []string { return variables } -// searchVariableInFile searches for a variable definition in a single file. -// Returns the value if found, empty string if not found, and error on file read error. +// ========================================================================= +// Variable Lookup +// ========================================================================= + func searchVariableInFile(filePath, varName string) (string, error) { file, err := os.Open(filePath) if err != nil { @@ -162,19 +163,16 @@ func searchVariableInFile(filePath, varName string) (string, error) { return "", nil } -// findVariableDefinition searches for a variable definition in all .local files first, -// then .conf files under /etc/fail2ban/ and subdirectories. +// Searches for a variable definition in all .local files first, then .conf files under /etc/fail2ban/ and subdirectories. // Returns the FIRST value found (prioritizing .local over .conf). func findVariableDefinition(varName string) (string, error) { fail2banPath := "/etc/fail2ban" - config.DebugLog("findVariableDefinition: searching for variable '%s'", varName) if _, err := os.Stat(fail2banPath); os.IsNotExist(err) { return "", fmt.Errorf("variable '%s' not found: /etc/fail2ban directory does not exist", varName) } - // First pass: search .local files (higher priority) var foundValue string err := filepath.Walk(fail2banPath, func(path string, info os.FileInfo, err error) error { if err != nil { @@ -187,14 +185,13 @@ func findVariableDefinition(varName string) (string, error) { value, err := searchVariableInFile(path, varName) if err != nil { - return nil // Skip files we can't read + return nil } if value != "" { foundValue = value - return filepath.SkipAll // Stop walking when found + return filepath.SkipAll } - return nil }) @@ -207,7 +204,6 @@ func findVariableDefinition(varName string) (string, error) { return "", err } - // Second pass: search .conf files (only if not found in .local) config.DebugLog("findVariableDefinition: variable '%s' not found in .local files, searching .conf files", varName) err = filepath.Walk(fail2banPath, func(path string, info os.FileInfo, err error) error { if err != nil { @@ -225,9 +221,8 @@ func findVariableDefinition(varName string) (string, error) { if value != "" { foundValue = value - return filepath.SkipAll // Stop walking when found + return filepath.SkipAll } - return nil }) @@ -244,9 +239,10 @@ func findVariableDefinition(varName string) (string, error) { return "", fmt.Errorf("variable '%s' not found in Fail2Ban configuration files", varName) } -// resolveVariableRecursive resolves a variable recursively, handling nested variables. -// visited map tracks visited variables to detect circular references. -// This function fully resolves all nested variables until no variables remain. +// ========================================================================= +// Resolution +// ========================================================================= + func resolveVariableRecursive(varName string, visited map[string]bool) (string, error) { if visited[varName] { return "", fmt.Errorf("circular reference detected for variable '%s'", varName) @@ -260,7 +256,6 @@ func resolveVariableRecursive(varName string, visited map[string]bool) (string, return "", err } - // Keep resolving until no more variables are found resolved := value maxIterations := 10 iteration := 0 @@ -268,16 +263,12 @@ func resolveVariableRecursive(varName string, visited map[string]bool) (string, for iteration < maxIterations { variables := extractVariablesFromString(resolved) if len(variables) == 0 { - // No more variables, fully resolved config.DebugLog("resolveVariableRecursive: '%s' fully resolved to '%s'", varName, resolved) break } - config.DebugLog("resolveVariableRecursive: iteration %d for '%s', found %d variables in '%s': %v", iteration+1, varName, len(variables), resolved, variables) - // Resolve all nested variables for _, nestedVar := range variables { - // Check for circular reference if visited[nestedVar] { return "", fmt.Errorf("circular reference detected: '%s' -> '%s'", varName, nestedVar) } @@ -289,70 +280,50 @@ func resolveVariableRecursive(varName string, visited map[string]bool) (string, } config.DebugLog("resolveVariableRecursive: resolved '%s' to '%s' for '%s'", nestedVar, nestedValue, varName) - - // Replace ALL occurrences of the nested variable - // Pattern: %(varName)s - need to escape parentheses for regex - // The pattern %(varName)s needs to be escaped as %\(varName\)s in regex pattern := fmt.Sprintf("%%\\(%s\\)s", regexp.QuoteMeta(nestedVar)) re := regexp.MustCompile(pattern) beforeReplace := resolved resolved = re.ReplaceAllString(resolved, nestedValue) config.DebugLog("resolveVariableRecursive: replaced pattern '%s' in '%s' with '%s', result: '%s'", pattern, beforeReplace, nestedValue, resolved) - // Verify the replacement actually happened if beforeReplace == resolved { config.DebugLog("resolveVariableRecursive: WARNING - replacement did not change string! Pattern: '%s', Before: '%s', After: '%s'", pattern, beforeReplace, resolved) - // If replacement didn't work, this is a critical error return "", fmt.Errorf("failed to replace variable '%s' in '%s': pattern '%s' did not match", nestedVar, beforeReplace, pattern) } } - - // After replacing all variables in this iteration, check if we're done - // Verify no variables remain before continuing remainingVars := extractVariablesFromString(resolved) if len(remainingVars) == 0 { - // No more variables, fully resolved config.DebugLog("resolveVariableRecursive: '%s' fully resolved to '%s' after replacements", varName, resolved) break } - - // If we still have variables after replacement, continue to next iteration - // But check if we made progress (resolved should be different from before) iteration++ } if iteration >= maxIterations { return "", fmt.Errorf("maximum resolution iterations reached for variable '%s', possible circular reference. Last resolved value: '%s'", varName, resolved) } - return resolved, nil } -// ResolveLogpathVariables resolves all variables in a logpath string. -// Returns the fully resolved path. If no variables are present, returns the original path. -// Keeps resolving until no more variables are found (handles nested variables). +// Expands %(var)s patterns in logpath using fail2ban config. func ResolveLogpathVariables(logpath string) (string, error) { if logpath == "" { return "", nil } - logpath = strings.TrimSpace(logpath) - // Keep resolving until no more variables are found resolved := logpath - maxIterations := 10 // Prevent infinite loops + maxIterations := 10 iteration := 0 for iteration < maxIterations { variables := extractVariablesFromString(resolved) if len(variables) == 0 { - // No more variables, we're done break } config.DebugLog("ResolveLogpathVariables: iteration %d, found %d variables in '%s'", iteration+1, len(variables), resolved) - // Resolve all variables found in the current string visited := make(map[string]bool) for _, varName := range variables { config.DebugLog("ResolveLogpathVariables: resolving variable '%s' from string '%s'", varName, resolved) @@ -363,9 +334,6 @@ func ResolveLogpathVariables(logpath string) (string, error) { config.DebugLog("ResolveLogpathVariables: resolved variable '%s' to '%s'", varName, varValue) - // Replace ALL occurrences of the variable in the resolved string - // Pattern: %(varName)s - need to escape parentheses for regex - // The pattern %(varName)s needs to be escaped as %\(varName\)s in regex pattern := fmt.Sprintf("%%\\(%s\\)s", regexp.QuoteMeta(varName)) re := regexp.MustCompile(pattern) beforeReplace := resolved diff --git a/pkg/web/static/js/validation.js b/pkg/web/static/js/validation.js index e70846d..6c4350a 100644 --- a/pkg/web/static/js/validation.js +++ b/pkg/web/static/js/validation.js @@ -1,8 +1,11 @@ -// Validation functions for Fail2ban UI +// Validation for Fail2ban UI settings and forms. + +// ========================================================================= +// Field Validators +// ========================================================================= function validateTimeFormat(value, fieldName) { - if (!value || !value.trim()) return { valid: true }; // Empty is OK - // Support: s (seconds), m (minutes), h (hours), d (days), w (weeks), mo (months), y (years) + if (!value || !value.trim()) return { valid: true }; const timePattern = /^\d+([smhdwy]|mo)$/i; if (!timePattern.test(value.trim())) { return { @@ -14,7 +17,7 @@ function validateTimeFormat(value, fieldName) { } function validateMaxRetry(value) { - if (!value || value.trim() === '') return { valid: true }; // Empty is OK + if (!value || value.trim() === '') return { valid: true }; const num = parseInt(value, 10); if (isNaN(num) || num < 1) { return { @@ -26,7 +29,7 @@ function validateMaxRetry(value) { } function validateEmail(value) { - if (!value || !value.trim()) return { valid: true }; // Empty is OK + if (!value || !value.trim()) return { valid: true }; const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailPattern.test(value.trim())) { return { @@ -37,24 +40,18 @@ function validateEmail(value) { return { valid: true }; } -// Validate IP address (IPv4, IPv6, CIDR, or hostname) function isValidIP(ip) { if (!ip || !ip.trim()) return false; ip = ip.trim(); - - // Allow hostnames (fail2ban supports DNS hostnames) - // Basic hostname validation: alphanumeric, dots, hyphens + // fail2ban accepts hostnames in addition to IPs const hostnamePattern = /^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?)*$/; - // IPv4 with optional CIDR const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/; - - // IPv6 with optional CIDR (simplified - allows various IPv6 formats) + // IPv6 with optional CIDR const ipv6Pattern = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}(\/\d{1,3})?$/; const ipv6CompressedPattern = /^::([0-9a-fA-F]{0,4}:){0,6}[0-9a-fA-F]{0,4}(\/\d{1,3})?$/; const ipv6FullPattern = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}(\/\d{1,3})?$/; - - // Check IPv4 + if (ipv4Pattern.test(ip)) { const parts = ip.split('/'); const octets = parts[0].split('.'); @@ -68,8 +65,6 @@ function isValidIP(ip) { } return true; } - - // Check IPv6 if (ipv6Pattern.test(ip) || ipv6CompressedPattern.test(ip) || ipv6FullPattern.test(ip)) { if (ip.includes('/')) { const parts = ip.split('/'); @@ -78,41 +73,40 @@ function isValidIP(ip) { } return true; } - - // Check hostname if (hostnamePattern.test(ip)) { return true; } - return false; } function validateIgnoreIPs() { if (typeof getIgnoreIPsArray !== 'function') { console.error('getIgnoreIPsArray function not found'); - return { valid: true }; // Skip validation if function not available + return { valid: true }; } - const ignoreIPs = getIgnoreIPsArray(); const invalidIPs = []; - + for (let i = 0; i < ignoreIPs.length; i++) { const ip = ignoreIPs[i]; if (!isValidIP(ip)) { invalidIPs.push(ip); } } - + if (invalidIPs.length > 0) { return { valid: false, message: 'Invalid IP addresses, CIDR notation, or hostnames: ' + invalidIPs.join(', ') }; } - return { valid: true }; } +// ========================================================================= +// Error Display +// ========================================================================= + function showFieldError(fieldId, message) { const errorElement = document.getElementById(fieldId + 'Error'); const inputElement = document.getElementById(fieldId); @@ -139,10 +133,12 @@ function clearFieldError(fieldId) { } } +// ========================================================================= +// Form Validation +// ========================================================================= + function validateAllSettings() { let isValid = true; - - // Validate bantime const banTime = document.getElementById('banTime'); if (banTime) { const banTimeValidation = validateTimeFormat(banTime.value, 'bantime'); @@ -153,8 +149,7 @@ function validateAllSettings() { clearFieldError('banTime'); } } - - // Validate findtime + const findTime = document.getElementById('findTime'); if (findTime) { const findTimeValidation = validateTimeFormat(findTime.value, 'findtime'); @@ -165,8 +160,7 @@ function validateAllSettings() { clearFieldError('findTime'); } } - - // Validate max retry + const maxRetry = document.getElementById('maxRetry'); if (maxRetry) { const maxRetryValidation = validateMaxRetry(maxRetry.value); @@ -177,8 +171,7 @@ function validateAllSettings() { clearFieldError('maxRetry'); } } - - // Validate email + const destEmail = document.getElementById('destEmail'); if (destEmail) { const emailValidation = validateEmail(destEmail.value); @@ -189,11 +182,9 @@ function validateAllSettings() { clearFieldError('destEmail'); } } - - // Validate IgnoreIPs + const ignoreIPsValidation = validateIgnoreIPs(); if (!ignoreIPsValidation.valid) { - // Show error for ignoreIPs field const errorContainer = document.getElementById('ignoreIPsError'); if (errorContainer) { errorContainer.textContent = ignoreIPsValidation.message; @@ -210,11 +201,9 @@ function validateAllSettings() { errorContainer.textContent = ''; } } - return isValid; } -// Setup validation on blur for all fields function setupFormValidation() { const banTimeInput = document.getElementById('banTime'); const findTimeInput = document.getElementById('findTime'); @@ -231,7 +220,7 @@ function setupFormValidation() { } }); } - + if (findTimeInput) { findTimeInput.addEventListener('blur', function() { const validation = validateTimeFormat(this.value, 'findtime'); @@ -242,7 +231,7 @@ function setupFormValidation() { } }); } - + if (maxRetryInput) { maxRetryInput.addEventListener('blur', function() { const validation = validateMaxRetry(this.value); @@ -253,7 +242,7 @@ function setupFormValidation() { } }); } - + if (destEmailInput) { destEmailInput.addEventListener('blur', function() { const validation = validateEmail(this.value); @@ -265,4 +254,3 @@ function setupFormValidation() { }); } } - diff --git a/pkg/web/static/lotr.css b/pkg/web/static/lotr.css index 4236f96..a95f90e 100644 --- a/pkg/web/static/lotr.css +++ b/pkg/web/static/lotr.css @@ -1,10 +1,10 @@ -/* ============================================ - LOTR Easter Egg Theme - Middle-earth Styling - ============================================ */ +/* LOTR Easter Egg Theme */ + +/* ========================================================================= + Color Variables + ========================================================================= */ -/* Only apply when body has lotr-mode class */ body.lotr-mode { - /* Enhanced Color Variables - Better Contrast */ --lotr-forest-green: #1a4d2e; --lotr-dark-green: #0d2818; --lotr-deep-green: #051a0f; @@ -26,25 +26,25 @@ body.lotr-mode { --lotr-text-light: #faf8f3; } -/* Base Theme Overrides */ +/* ========================================================================= + Base Theme + ========================================================================= */ + body.lotr-mode { background: url(/static/images/bg-overlay); background-size: cover; background-position: center; background-repeat: no-repeat; backdrop-filter: blur(5px); - /*background: - radial-gradient(ellipse at top, rgba(26, 77, 46, 0.3) 0%, transparent 50%), - radial-gradient(ellipse at bottom, rgba(45, 10, 79, 0.2) 0%, transparent 50%), - linear-gradient(135deg, #001f0d 0%, #1f2622 30%, #000000 70%, #36370d 100%); - background-attachment: fixed; - background-size: 100% 100%;*/ color: var(--lotr-text-light); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; min-height: 100vh; } -/* Typography */ +/* ========================================================================= + Typography + ========================================================================= */ + body.lotr-mode h1, body.lotr-mode h2, body.lotr-mode h3, @@ -52,7 +52,7 @@ body.lotr-mode .text-2xl, body.lotr-mode .text-xl { font-family: 'Cinzel', serif; color: var(--lotr-bright-gold); - text-shadow: + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8), 0 0 10px rgba(212, 175, 55, 0.3); letter-spacing: 0.05em; @@ -61,7 +61,7 @@ body.lotr-mode .text-xl { body.lotr-mode h1 { font-size: 2.5rem; - text-shadow: + text-shadow: 3px 3px 6px rgba(0, 0, 0, 0.9), 0 0 15px rgba(212, 175, 55, 0.4); } @@ -71,12 +71,15 @@ body.lotr-mode h3 { font-weight: 600; } -/* Cards */ +/* ========================================================================= + Cards and Panels + ========================================================================= */ + body.lotr-mode .bg-white { background: linear-gradient(135deg, var(--lotr-parchment) 0%, var(--lotr-warm-parchment) 100%) !important; border: 4px solid var(--lotr-gold); border-radius: 12px; - box-shadow: + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.3), @@ -92,7 +95,7 @@ body.lotr-mode .bg-white::before { left: 0; right: 0; bottom: 0; - background: + background: repeating-linear-gradient( 0deg, transparent, @@ -113,10 +116,10 @@ body.lotr-mode .bg-white::after { left: -2px; right: -2px; bottom: -2px; - background: linear-gradient(45deg, - var(--lotr-gold) 0%, - transparent 25%, - transparent 75%, + background: linear-gradient(45deg, + var(--lotr-gold) 0%, + transparent 25%, + transparent 75%, var(--lotr-gold) 100%); border-radius: 12px; z-index: -1; @@ -127,7 +130,6 @@ body.lotr-mode footer.bg-gray-100 { background-color: var(--lotr-dark-brown) !important; } -/* Text Colors with High Contrast */ body.lotr-mode .bg-white .text-gray-800, body.lotr-mode .bg-white .text-gray-900 { color: var(--lotr-text-dark) !important; @@ -143,7 +145,10 @@ body.lotr-mode .bg-white .text-gray-600 { color: var(--lotr-brown); } -/* Buttons - Medieval Shield Style */ +/* ========================================================================= + Buttons + ========================================================================= */ + body.lotr-mode button:not(.toast), body.lotr-mode .bg-blue-500:not(.toast), body.lotr-mode .bg-blue-600:not(.toast) { @@ -153,7 +158,7 @@ body.lotr-mode .bg-blue-600:not(.toast) { color: var(--lotr-text-dark) !important; font-weight: 700; text-shadow: 1px 1px 2px rgba(255, 255, 255, 0.5); - box-shadow: + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4), inset 0 2px 4px rgba(255, 255, 255, 0.3), inset 0 -2px 4px rgba(0, 0, 0, 0.2); @@ -171,9 +176,9 @@ body.lotr-mode .bg-blue-600::before { left: -100%; width: 100%; height: 100%; - background: linear-gradient(90deg, - transparent, - rgba(255, 255, 255, 0.3), + background: linear-gradient(90deg, + transparent, + rgba(255, 255, 255, 0.3), transparent); transition: left 0.5s ease; } @@ -188,7 +193,7 @@ body.lotr-mode button:hover, body.lotr-mode .bg-blue-500:hover, body.lotr-mode .bg-blue-600:hover { background: linear-gradient(135deg, var(--lotr-gold) 0%, var(--lotr-bright-gold) 50%, var(--lotr-gold) 100%) !important; - box-shadow: + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.5), inset 0 2px 6px rgba(255, 255, 255, 0.4), inset 0 -2px 6px rgba(0, 0, 0, 0.3); @@ -198,7 +203,7 @@ body.lotr-mode .bg-blue-600:hover { body.lotr-mode button:active { transform: translateY(0); - box-shadow: + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4), inset 0 2px 4px rgba(0, 0, 0, 0.2); } @@ -206,7 +211,62 @@ body.lotr-mode button:active { body.lotr-mode nav.bg-blue-600:not(.toast), body.lotr-mode nav.bg-blue-600:hover { background: linear-gradient(135deg, #342e14 0%, #000000 50%, #916f00 100%) !important; } -/* Input Fields - Scroll Style */ + +/* ========================================================================= + Navigation + ========================================================================= */ + +body.lotr-mode nav { + background: linear-gradient(135deg, + var(--lotr-dark-brown) 0%, + var(--lotr-brown) 30%, + var(--lotr-dark-green) 70%, + var(--lotr-deep-green) 100%) !important; + border-bottom: 4px solid var(--lotr-gold); + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.5), + inset 0 1px 0 rgba(212, 175, 55, 0.2); + position: relative; +} + +body.lotr-mode nav::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, + transparent, + var(--lotr-bright-gold) 20%, + var(--lotr-bright-gold) 80%, + transparent); +} + +body.lotr-mode nav .text-gray-700, +body.lotr-mode nav .text-gray-800, +body.lotr-mode nav .text-white { + color: var(--lotr-bright-gold) !important; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7); +} + +body.lotr-mode nav a { + color: var(--lotr-bright-gold) !important; + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7); + transition: all 0.3s ease; +} + +body.lotr-mode nav a:hover { + background: rgba(212, 175, 55, 0.2) !important; + text-shadow: + 1px 1px 2px rgba(0, 0, 0, 0.7), + 0 0 10px rgba(212, 175, 55, 0.5); +} + +/* ========================================================================= + Form Elements + ========================================================================= */ + body.lotr-mode input:not([type="checkbox"]):not([type="radio"]), body.lotr-mode select, body.lotr-mode textarea { @@ -229,61 +289,16 @@ body.lotr-mode select:focus, body.lotr-mode textarea:focus { background: var(--lotr-parchment) !important; border-color: var(--lotr-bright-gold) !important; - box-shadow: + box-shadow: 0 0 0 4px rgba(212, 175, 55, 0.3), inset 0 2px 4px rgba(0, 0, 0, 0.1) !important; outline: none; } -/* Navigation */ -body.lotr-mode nav { - background: linear-gradient(135deg, - var(--lotr-dark-brown) 0%, - var(--lotr-brown) 30%, - var(--lotr-dark-green) 70%, - var(--lotr-deep-green) 100%) !important; - border-bottom: 4px solid var(--lotr-gold); - box-shadow: - 0 4px 12px rgba(0, 0, 0, 0.5), - inset 0 1px 0 rgba(212, 175, 55, 0.2); - position: relative; -} +/* ========================================================================= + Loading Overlay + ========================================================================= */ -body.lotr-mode nav::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 2px; - background: linear-gradient(90deg, - transparent, - var(--lotr-bright-gold) 20%, - var(--lotr-bright-gold) 80%, - transparent); -} - -body.lotr-mode nav .text-gray-700, -body.lotr-mode nav .text-gray-800, -body.lotr-mode nav .text-white { - color: var(--lotr-bright-gold) !important; - text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7); -} - -body.lotr-mode nav a { - color: var(--lotr-bright-gold) !important; - text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7); - transition: all 0.3s ease; -} - -body.lotr-mode nav a:hover { - background: rgba(212, 175, 55, 0.2) !important; - text-shadow: - 1px 1px 2px rgba(0, 0, 0, 0.7), - 0 0 10px rgba(212, 175, 55, 0.5); -} - -/* Loading Spinner - One Ring Animation */ body.lotr-mode #loading-overlay { background: url("/static/images/loading-overlay"); background-size: cover; @@ -311,7 +326,7 @@ body.lotr-mode #loading-overlay .animate-spin::before { height: 50px; border: 4px solid var(--lotr-bright-gold); border-radius: 50%; - box-shadow: + box-shadow: 0 0 30px var(--lotr-gold), 0 0 60px var(--lotr-gold), inset 0 0 30px var(--lotr-gold); @@ -333,7 +348,7 @@ body.lotr-mode #loading-overlay .animate-spin::after { @keyframes ringGlow { 0%, 100% { opacity: 0.7; - box-shadow: + box-shadow: 0 0 30px var(--lotr-gold), 0 0 60px var(--lotr-gold), inset 0 0 30px var(--lotr-gold); @@ -341,7 +356,7 @@ body.lotr-mode #loading-overlay .animate-spin::after { } 50% { opacity: 1; - box-shadow: + box-shadow: 0 0 50px var(--lotr-gold), 0 0 100px var(--lotr-gold), 0 0 150px var(--lotr-gold), @@ -361,12 +376,15 @@ body.lotr-mode #loading-overlay .animate-spin::after { } } -/* Toast Notifications - Scroll Style */ +/* ========================================================================= + Toast Notifications + ========================================================================= */ + body.lotr-mode .toast { background: linear-gradient(135deg, var(--lotr-parchment) 0%, var(--lotr-warm-parchment) 100%) !important; border: 3px solid var(--lotr-gold); color: var(--lotr-text-dark) !important; - box-shadow: + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.5), inset 0 0 30px rgba(212, 175, 55, 0.15); position: relative; @@ -410,7 +428,10 @@ body.lotr-mode .toast-info { border-color: var(--lotr-gold); } -/* Dashboard Cards - Medieval Banners */ +/* ========================================================================= + Utility Classes + ========================================================================= */ + body.lotr-mode .bg-blue-50, body.lotr-mode .bg-gray-50 { background: linear-gradient(135deg, var(--lotr-warm-parchment) 0%, var(--lotr-dark-parchment) 100%) !important; @@ -423,14 +444,12 @@ body.lotr-mode .text-blue-600 { font-weight: 600; } -/* Text colors in LOTR mode for better visibility */ body.lotr-mode .bg-gray-50 .text-gray-900, body.lotr-mode .bg-gray-50 .text-sm, body.lotr-mode .bg-gray-50 .text-sm.font-medium { color: var(--lotr-text-dark) !important; } -/* Open insights button styling */ body.lotr-mode .border-blue-200 { border-color: var(--lotr-gold) !important; } @@ -439,7 +458,6 @@ body.lotr-mode .hover\:bg-blue-50:hover { background-color: rgba(212, 175, 55, 0.1) !important; } -/* Logpath results styling */ body.lotr-mode #logpathResults, body.lotr-mode pre#logpathResults { background: var(--lotr-warm-parchment) !important; @@ -455,7 +473,10 @@ body.lotr-mode #logpathResults.text-yellow-600 { color: var(--lotr-dark-gold) !important; } -/* Tables */ +/* ========================================================================= + Tables + ========================================================================= */ + body.lotr-mode table { border-collapse: separate; border-spacing: 0; @@ -493,12 +514,15 @@ body.lotr-mode table tr:nth-child(even):hover td { background: var(--lotr-warm-parchment); } -/* Modal - Parchment Scroll */ +/* ========================================================================= + Modals + ========================================================================= */ + body.lotr-mode .modal-content { background: linear-gradient(135deg, var(--lotr-parchment) 0%, var(--lotr-warm-parchment) 100%) !important; border: 5px solid var(--lotr-gold); border-radius: 16px; - box-shadow: + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.6), inset 0 0 60px rgba(212, 175, 55, 0.15); position: relative; @@ -512,7 +536,7 @@ body.lotr-mode .modal-content::before { left: 0; right: 0; bottom: 0; - background: + background: repeating-linear-gradient( 0deg, transparent, @@ -541,7 +565,10 @@ body.lotr-mode .modal-content h3::after { opacity: 0.5; } -/* Badges and Labels */ +/* ========================================================================= + Badges and Labels + ========================================================================= */ + body.lotr-mode .bg-green-100, body.lotr-mode .bg-green-500 { background: linear-gradient(135deg, var(--lotr-forest-green) 0%, var(--lotr-dark-green) 100%) !important; @@ -568,10 +595,13 @@ body.lotr-mode .bg-yellow-400 { text-shadow: 1px 1px 2px rgba(255, 255, 255, 0.5); } -/* Restart Banner */ +/* ========================================================================= + Restart Banner + ========================================================================= */ + body.lotr-mode #restartBanner { - background: linear-gradient(135deg, - var(--lotr-bright-gold) 0%, + background: linear-gradient(135deg, + var(--lotr-bright-gold) 0%, var(--lotr-gold) 30%, var(--lotr-dark-gold) 70%, var(--lotr-gold) 100%) !important; @@ -583,7 +613,10 @@ body.lotr-mode #restartBanner { box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4); } -/* Select2 Styling */ +/* ========================================================================= + Select2 Overrides + ========================================================================= */ + body.lotr-mode .select2-container--default .select2-selection { background: var(--lotr-warm-parchment) !important; border: 3px solid var(--lotr-stone-gray) !important; @@ -613,7 +646,6 @@ body.lotr-mode .select2-container--default .select2-selection--multiple .select2 margin-right: 6px; } -/* Select2 Dropdown Results Styling */ body.lotr-mode .select2-results__options { background: var(--lotr-warm-parchment) !important; border: 2px solid var(--lotr-stone-gray) !important; @@ -643,7 +675,10 @@ body.lotr-mode .select2-results__option--highlighted[aria-selected="true"] { background: linear-gradient(135deg, var(--lotr-bright-gold) 0%, var(--lotr-gold) 100%) !important; } -/* Ignore IP Tags Styling */ +/* ========================================================================= + Ignore IPs Tags + ========================================================================= */ + body.lotr-mode .ignore-ip-tag { background: linear-gradient(135deg, var(--lotr-warm-parchment) 0%, var(--lotr-dark-parchment) 100%) !important; color: var(--lotr-text-dark) !important; @@ -667,7 +702,10 @@ body.lotr-mode .ignore-ip-tag button:hover { text-shadow: 0 0 4px rgba(193, 18, 31, 0.5); } -/* Scrollbar Styling */ +/* ========================================================================= + Scrollbar + ========================================================================= */ + body.lotr-mode ::-webkit-scrollbar { width: 14px; height: 14px; @@ -683,24 +721,27 @@ body.lotr-mode ::-webkit-scrollbar-thumb { background: linear-gradient(135deg, var(--lotr-bright-gold) 0%, var(--lotr-gold) 50%, var(--lotr-dark-gold) 100%); border: 3px solid var(--lotr-brown); border-radius: 8px; - box-shadow: + box-shadow: inset 0 2px 4px rgba(255, 255, 255, 0.2), inset 0 -2px 4px rgba(0, 0, 0, 0.2); } body.lotr-mode ::-webkit-scrollbar-thumb:hover { background: linear-gradient(135deg, var(--lotr-gold) 0%, var(--lotr-bright-gold) 50%, var(--lotr-gold) 100%); - box-shadow: + box-shadow: inset 0 2px 4px rgba(255, 255, 255, 0.3), inset 0 -2px 4px rgba(0, 0, 0, 0.3), 0 0 10px rgba(212, 175, 55, 0.5); } -/* Decorative Elements */ +/* ========================================================================= + Decorative Elements + ========================================================================= */ + body.lotr-mode .lotr-divider { height: 4px; - background: linear-gradient(90deg, - transparent 0%, + background: linear-gradient(90deg, + transparent 0%, var(--lotr-gold) 15%, var(--lotr-bright-gold) 50%, var(--lotr-gold) 85%, @@ -720,7 +761,7 @@ body.lotr-mode .lotr-divider::after { color: var(--lotr-bright-gold); background: #2c1810; padding: 0 15px; - text-shadow: + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5), 0 0 10px rgba(212, 175, 55, 0.8); animation: swordGlow 2s ease-in-out infinite; @@ -728,12 +769,12 @@ body.lotr-mode .lotr-divider::after { @keyframes swordGlow { 0%, 100% { - text-shadow: + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5), 0 0 10px rgba(212, 175, 55, 0.8); } 50% { - text-shadow: + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5), 0 0 20px rgba(212, 175, 55, 1), 0 0 30px rgba(212, 175, 55, 0.8); @@ -748,9 +789,8 @@ body.lotr-mode .lotr-divider::after { right: 20%; } -/* Glow Effects */ body.lotr-mode .lotr-glow { - text-shadow: + text-shadow: 0 0 15px var(--lotr-gold), 0 0 30px var(--lotr-gold), 0 0 45px var(--lotr-gold); @@ -759,22 +799,22 @@ body.lotr-mode .lotr-glow { @keyframes gentleGlow { 0%, 100% { - text-shadow: + text-shadow: 0 0 15px var(--lotr-gold), 0 0 30px var(--lotr-gold), 0 0 45px var(--lotr-gold); } 50% { - text-shadow: + text-shadow: 0 0 25px var(--lotr-bright-gold), 0 0 50px var(--lotr-gold), 0 0 75px var(--lotr-gold); } } -/* Fire Effect (for email headers) */ +/* Fire effect for email headers */ body.lotr-mode .lotr-fire { - background: linear-gradient(180deg, + background: linear-gradient(180deg, var(--lotr-fire-red) 0%, var(--lotr-fire-orange) 30%, var(--lotr-bright-gold) 70%, @@ -803,16 +843,18 @@ body.lotr-mode .lotr-fire { } } -/* Smooth Theme Transition */ body.lotr-mode, body.lotr-mode * { - transition: background-color 0.6s ease, - color 0.6s ease, + transition: background-color 0.6s ease, + color 0.6s ease, border-color 0.6s ease, box-shadow 0.6s ease; } -/* Links */ +/* ========================================================================= + Links and Checkboxes + ========================================================================= */ + body.lotr-mode a { color: var(--lotr-bright-gold) !important; text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7); @@ -821,12 +863,11 @@ body.lotr-mode a { body.lotr-mode a:hover { color: var(--lotr-gold); - text-shadow: + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7), 0 0 10px rgba(212, 175, 55, 0.6); } -/* Checkboxes and Radio Buttons */ body.lotr-mode input[type="checkbox"], body.lotr-mode input[type="radio"] { width: 20px; @@ -835,13 +876,11 @@ body.lotr-mode input[type="radio"] { cursor: pointer; } -/* Toggle Switch Styling for LOTR Mode */ body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer { position: relative; align-items: center; } -/* Toggle switch track - default state */ body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer > div.w-11 { background: var(--lotr-stone-gray) !important; border: 2px solid var(--lotr-brown) !important; @@ -849,27 +888,24 @@ body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer > div.w-11 position: relative; } -/* Toggle switch track - checked state (using peer-checked) */ body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer input.peer:checked ~ div { background: linear-gradient(135deg, var(--lotr-bright-gold) 0%, var(--lotr-gold) 100%) !important; border-color: var(--lotr-gold) !important; - box-shadow: + box-shadow: 0 0 10px rgba(212, 175, 55, 0.5), inset 0 2px 4px rgba(255, 255, 255, 0.3) !important; } -/* Toggle switch focus ring */ body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer input.peer:focus ~ div { - box-shadow: + box-shadow: 0 0 0 4px rgba(212, 175, 55, 0.3), inset 0 2px 4px rgba(0, 0, 0, 0.3) !important; } -/* Toggle switch thumb (the circle) - properly centered */ body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer > span.absolute { background: var(--lotr-parchment) !important; border: 2px solid var(--lotr-brown) !important; - box-shadow: + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3), inset 0 1px 2px rgba(255, 255, 255, 0.5) !important; top: 50% !important; @@ -881,7 +917,6 @@ body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer input.peer transform: translateX(1.25rem) translateY(-50%) !important; } -/* Labels */ body.lotr-mode label { color: var(--lotr-text-dark); font-weight: 600; @@ -891,23 +926,25 @@ body.lotr-mode .bg-white label { color: var(--lotr-text-dark) !important; } -/* Main Content Area */ body.lotr-mode main { background: transparent; } -/* Mobile Responsive */ +/* ========================================================================= + Responsive + ========================================================================= */ + @media (max-width: 768px) { body.lotr-mode .bg-white { border-width: 3px; } - + body.lotr-mode h1, body.lotr-mode h2, body.lotr-mode h3 { font-size: 1.8em; } - + body.lotr-mode .lotr-divider::before, body.lotr-mode .lotr-divider::after { font-size: 20px;