|
| 1 | +package logger |
| 2 | + |
| 3 | +import ( |
| 4 | + "fmt" |
| 5 | + "os" |
| 6 | + "path/filepath" |
| 7 | + "regexp" |
| 8 | + "strings" |
| 9 | + "sync" |
| 10 | +) |
| 11 | + |
| 12 | +// MarkdownLogger manages logging to a markdown file for GitHub workflow previews |
| 13 | +type MarkdownLogger struct { |
| 14 | + logFile *os.File |
| 15 | + mu sync.Mutex |
| 16 | + logDir string |
| 17 | + fileName string |
| 18 | + useFallback bool |
| 19 | + initialized bool |
| 20 | +} |
| 21 | + |
| 22 | +var ( |
| 23 | + globalMarkdownLogger *MarkdownLogger |
| 24 | + globalMarkdownMu sync.RWMutex |
| 25 | + // Patterns for detecting potential secrets (simple heuristics) |
| 26 | + secretPatterns = []*regexp.Regexp{ |
| 27 | + regexp.MustCompile(`(?i)(token|key|secret|password|auth)[=:]\s*[^\s]{8,}`), |
| 28 | + regexp.MustCompile(`ghp_[a-zA-Z0-9]{36,}`), // GitHub PATs |
| 29 | + regexp.MustCompile(`github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}`), // GitHub fine-grained PATs |
| 30 | + regexp.MustCompile(`(?i)bearer\s+[a-zA-Z0-9\-._~+/]+=*`), // Bearer tokens |
| 31 | + regexp.MustCompile(`(?i)authorization:\s*[a-zA-Z0-9\-._~+/]+=*`), // Auth headers |
| 32 | + regexp.MustCompile(`[a-f0-9]{32,}`), // Long hex strings (API keys) |
| 33 | + regexp.MustCompile(`(?i)(apikey|api_key|access_key)[=:]\s*[^\s]{8,}`), // API keys |
| 34 | + regexp.MustCompile(`(?i)(client_secret|client_id)[=:]\s*[^\s]{8,}`), // OAuth secrets |
| 35 | + regexp.MustCompile(`[a-zA-Z0-9_-]{20,}\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+`), // JWT tokens |
| 36 | + } |
| 37 | +) |
| 38 | + |
| 39 | +// InitMarkdownLogger initializes the global markdown logger |
| 40 | +func InitMarkdownLogger(logDir, fileName string) error { |
| 41 | + globalMarkdownMu.Lock() |
| 42 | + defer globalMarkdownMu.Unlock() |
| 43 | + |
| 44 | + if globalMarkdownLogger != nil { |
| 45 | + // Close existing logger |
| 46 | + globalMarkdownLogger.Close() |
| 47 | + } |
| 48 | + |
| 49 | + ml := &MarkdownLogger{ |
| 50 | + logDir: logDir, |
| 51 | + fileName: fileName, |
| 52 | + } |
| 53 | + |
| 54 | + // Try to create the log directory if it doesn't exist |
| 55 | + if err := os.MkdirAll(logDir, 0755); err != nil { |
| 56 | + ml.useFallback = true |
| 57 | + globalMarkdownLogger = ml |
| 58 | + return nil |
| 59 | + } |
| 60 | + |
| 61 | + // Try to open the log file |
| 62 | + logPath := filepath.Join(logDir, fileName) |
| 63 | + file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) |
| 64 | + if err != nil { |
| 65 | + ml.useFallback = true |
| 66 | + globalMarkdownLogger = ml |
| 67 | + return nil |
| 68 | + } |
| 69 | + |
| 70 | + ml.logFile = file |
| 71 | + ml.initialized = false // Will be initialized on first write |
| 72 | + |
| 73 | + globalMarkdownLogger = ml |
| 74 | + return nil |
| 75 | +} |
| 76 | + |
| 77 | +// initializeFile writes the HTML details header on first write |
| 78 | +func (ml *MarkdownLogger) initializeFile() error { |
| 79 | + if ml.initialized { |
| 80 | + return nil |
| 81 | + } |
| 82 | + |
| 83 | + if ml.logFile != nil { |
| 84 | + header := "<details>\n<summary>MCP Gateway</summary>\n\n" |
| 85 | + if _, err := ml.logFile.WriteString(header); err != nil { |
| 86 | + return err |
| 87 | + } |
| 88 | + ml.initialized = true |
| 89 | + } |
| 90 | + return nil |
| 91 | +} |
| 92 | + |
| 93 | +// Close closes the log file and writes the closing details tag |
| 94 | +func (ml *MarkdownLogger) Close() error { |
| 95 | + ml.mu.Lock() |
| 96 | + defer ml.mu.Unlock() |
| 97 | + |
| 98 | + if ml.logFile != nil { |
| 99 | + // Write closing details tag |
| 100 | + footer := "\n</details>\n" |
| 101 | + if _, err := ml.logFile.WriteString(footer); err != nil { |
| 102 | + // Sync any remaining buffered data before closing |
| 103 | + _ = ml.logFile.Sync() // Continue with close even if sync fails |
| 104 | + return ml.logFile.Close() |
| 105 | + } |
| 106 | + |
| 107 | + // Sync and close |
| 108 | + _ = ml.logFile.Sync() // Continue with close even if sync fails |
| 109 | + return ml.logFile.Close() |
| 110 | + } |
| 111 | + return nil |
| 112 | +} |
| 113 | + |
| 114 | +// sanitizeSecrets replaces potential secrets with [REDACTED] |
| 115 | +func sanitizeSecrets(message string) string { |
| 116 | + result := message |
| 117 | + for _, pattern := range secretPatterns { |
| 118 | + result = pattern.ReplaceAllStringFunc(result, func(match string) string { |
| 119 | + // Keep the prefix (key name) but redact the value |
| 120 | + if strings.Contains(match, "=") || strings.Contains(match, ":") { |
| 121 | + parts := regexp.MustCompile(`[=:]\s*`).Split(match, 2) |
| 122 | + if len(parts) == 2 { |
| 123 | + return parts[0] + "=[REDACTED]" |
| 124 | + } |
| 125 | + } |
| 126 | + // For tokens without key=value format, redact entirely |
| 127 | + return "[REDACTED]" |
| 128 | + }) |
| 129 | + } |
| 130 | + return result |
| 131 | +} |
| 132 | + |
| 133 | +// getEmojiForLevel returns the appropriate emoji for the log level |
| 134 | +func getEmojiForLevel(level LogLevel) string { |
| 135 | + switch level { |
| 136 | + case LogLevelInfo: |
| 137 | + return "✓" |
| 138 | + case LogLevelWarn: |
| 139 | + return "⚠️" |
| 140 | + case LogLevelError: |
| 141 | + return "✗" |
| 142 | + case LogLevelDebug: |
| 143 | + return "🔍" |
| 144 | + default: |
| 145 | + return "•" |
| 146 | + } |
| 147 | +} |
| 148 | + |
| 149 | +// Log writes a log message in markdown format with emoji bullet points |
| 150 | +func (ml *MarkdownLogger) Log(level LogLevel, category, format string, args ...interface{}) { |
| 151 | + ml.mu.Lock() |
| 152 | + defer ml.mu.Unlock() |
| 153 | + |
| 154 | + if ml.useFallback { |
| 155 | + return |
| 156 | + } |
| 157 | + |
| 158 | + // Initialize file with header on first write |
| 159 | + if err := ml.initializeFile(); err != nil { |
| 160 | + return |
| 161 | + } |
| 162 | + |
| 163 | + message := fmt.Sprintf(format, args...) |
| 164 | + |
| 165 | + // Sanitize potential secrets |
| 166 | + message = sanitizeSecrets(message) |
| 167 | + |
| 168 | + emoji := getEmojiForLevel(level) |
| 169 | + |
| 170 | + // Format as markdown bullet point with emoji |
| 171 | + // Use code blocks for multi-line content or technical details |
| 172 | + var logLine string |
| 173 | + if strings.Contains(message, "\n") || strings.Contains(message, "command=") || strings.Contains(message, "args=") { |
| 174 | + // Multi-line or technical content - use code block |
| 175 | + logLine = fmt.Sprintf("- %s **%s**\n ```\n %s\n ```\n", emoji, category, message) |
| 176 | + } else { |
| 177 | + // Simple single-line message |
| 178 | + logLine = fmt.Sprintf("- %s **%s** %s\n", emoji, category, message) |
| 179 | + } |
| 180 | + |
| 181 | + if ml.logFile != nil { |
| 182 | + if _, err := ml.logFile.WriteString(logLine); err != nil { |
| 183 | + return |
| 184 | + } |
| 185 | + // Flush immediately |
| 186 | + _ = ml.logFile.Sync() // Ignore sync errors |
| 187 | + } |
| 188 | +} |
| 189 | + |
| 190 | +// Global logging functions that also write to markdown logger |
| 191 | + |
| 192 | +// LogInfoMd logs to both regular and markdown loggers |
| 193 | +func LogInfoMd(category, format string, args ...interface{}) { |
| 194 | + // Log to regular logger |
| 195 | + LogInfo(category, format, args...) |
| 196 | + |
| 197 | + // Log to markdown logger |
| 198 | + globalMarkdownMu.RLock() |
| 199 | + defer globalMarkdownMu.RUnlock() |
| 200 | + |
| 201 | + if globalMarkdownLogger != nil { |
| 202 | + globalMarkdownLogger.Log(LogLevelInfo, category, format, args...) |
| 203 | + } |
| 204 | +} |
| 205 | + |
| 206 | +// LogWarnMd logs to both regular and markdown loggers |
| 207 | +func LogWarnMd(category, format string, args ...interface{}) { |
| 208 | + // Log to regular logger |
| 209 | + LogWarn(category, format, args...) |
| 210 | + |
| 211 | + // Log to markdown logger |
| 212 | + globalMarkdownMu.RLock() |
| 213 | + defer globalMarkdownMu.RUnlock() |
| 214 | + |
| 215 | + if globalMarkdownLogger != nil { |
| 216 | + globalMarkdownLogger.Log(LogLevelWarn, category, format, args...) |
| 217 | + } |
| 218 | +} |
| 219 | + |
| 220 | +// LogErrorMd logs to both regular and markdown loggers |
| 221 | +func LogErrorMd(category, format string, args ...interface{}) { |
| 222 | + // Log to regular logger |
| 223 | + LogError(category, format, args...) |
| 224 | + |
| 225 | + // Log to markdown logger |
| 226 | + globalMarkdownMu.RLock() |
| 227 | + defer globalMarkdownMu.RUnlock() |
| 228 | + |
| 229 | + if globalMarkdownLogger != nil { |
| 230 | + globalMarkdownLogger.Log(LogLevelError, category, format, args...) |
| 231 | + } |
| 232 | +} |
| 233 | + |
| 234 | +// LogDebugMd logs to both regular and markdown loggers |
| 235 | +func LogDebugMd(category, format string, args ...interface{}) { |
| 236 | + // Log to regular logger |
| 237 | + LogDebug(category, format, args...) |
| 238 | + |
| 239 | + // Log to markdown logger |
| 240 | + globalMarkdownMu.RLock() |
| 241 | + defer globalMarkdownMu.RUnlock() |
| 242 | + |
| 243 | + if globalMarkdownLogger != nil { |
| 244 | + globalMarkdownLogger.Log(LogLevelDebug, category, format, args...) |
| 245 | + } |
| 246 | +} |
| 247 | + |
| 248 | +// CloseMarkdownLogger closes the global markdown logger |
| 249 | +func CloseMarkdownLogger() error { |
| 250 | + globalMarkdownMu.Lock() |
| 251 | + defer globalMarkdownMu.Unlock() |
| 252 | + |
| 253 | + if globalMarkdownLogger != nil { |
| 254 | + err := globalMarkdownLogger.Close() |
| 255 | + globalMarkdownLogger = nil |
| 256 | + return err |
| 257 | + } |
| 258 | + return nil |
| 259 | +} |
0 commit comments