/** * Logging utility for consistent log formatting and levels * Provides standardized logging across the plugin */ export enum LogLevel { DEBUG = 'debug', INFO = 'info', WARN = 'warn', ERROR = 'error', } /** * Centralized logging utility with consistent formatting */ export class Logger { private static currentLevel: LogLevel = LogLevel.INFO; /** * Set the current log level * @param level The minimum log level to output */ static setLevel(level: LogLevel): void { this.currentLevel = level; } /** * Get the current log level * @returns The current log level */ static getLevel(): LogLevel { return this.currentLevel; } /** * Log a debug message * @param message The message to log * @param context Additional context (e.g., component name) */ static debug(message: string, context?: string): void { if (this.currentLevel <= LogLevel.DEBUG) { this.log(LogLevel.DEBUG, message, context); } } /** * Log an info message * @param message The message to log * @param context Additional context (e.g., component name) */ static info(message: string, context?: string): void { if (this.currentLevel <= LogLevel.INFO) { this.log(LogLevel.INFO, message, context); } } /** * Log a warning message * @param message The message to log * @param context Additional context (e.g., component name) */ static warn(message: string, context?: string): void { if (this.currentLevel <= LogLevel.WARN) { this.log(LogLevel.WARN, message, context); } } /** * Log an error message * @param message The message to log * @param context Additional context (e.g., component name) */ static error(message: string, context?: string, error?: unknown): void { if (this.currentLevel <= LogLevel.ERROR) { this.log(LogLevel.ERROR, message, context); if (error) { console.error('[Error Details]', error instanceof Error ? error.stack : error); } } } /** * Internal log method with consistent formatting * @param level The log level * @param message The message to log * @param context Additional context */ private static log(level: LogLevel, message: string, context?: string): void { const timestamp = new Date().toISOString(); const levelStr = level.toUpperCase(); const contextStr = context ? `[${context}]` : ''; const formattedMessage = `[${timestamp}] ${levelStr} ${contextStr} ${message}`; switch (level) { case LogLevel.DEBUG: console.debug(formattedMessage); break; case LogLevel.INFO: console.info(formattedMessage); break; case LogLevel.WARN: console.warn(formattedMessage); break; case LogLevel.ERROR: console.error(formattedMessage); break; } } /** * Log a performance measurement * @param operation The operation being measured * @param durationMs The duration in milliseconds * @param context Additional context */ static perf(operation: string, durationMs: number, context?: string): void { this.info(`Performance: ${operation} took ${durationMs.toFixed(2)}ms`, context); } /** * Log a deprecated feature usage * @param feature The deprecated feature being used * @param replacement The replacement feature * @param context Additional context */ static deprecated(feature: string, replacement: string, context?: string): void { this.warn(`Deprecated: ${feature} is deprecated. Use ${replacement} instead.`, context); } } /** * Normalizes file paths for browser/ Obsidian environment * Replaces multiple slashes with single slash and handles forward/backward slashes */ /** * Validates the Ollama URL configuration * @param url The URL to validate * @returns Object with validation result and error message if invalid */ export function validateOllamaUrl(url: string): { valid: boolean; error?: string } { if (!url || typeof url !== 'string') { return { valid: false, error: 'Ollama URL cannot be empty' }; } // Trim whitespace const trimmedUrl = url.trim(); if (trimmedUrl.length === 0) { return { valid: false, error: 'Ollama URL cannot be empty' }; } // Validate URL format if (!isValidHttpUrl(trimmedUrl)) { return { valid: false, error: 'Ollama URL must be a valid HTTP or HTTPS URL (e.g., http://localhost:11434)', }; } // Check for common mistakes if (trimmedUrl.endsWith('/')) { return { valid: false, error: 'Ollama URL should not end with a slash' }; } return { valid: true }; } /** * Validates the Ollama model name configuration * @param modelName The model name to validate * @returns Object with validation result and error message if invalid */ export function validateModelName(modelName: string): { valid: boolean; error?: string } { if (!modelName || typeof modelName !== 'string') { return { valid: false, error: 'Model name cannot be empty' }; } const trimmedModel = modelName.trim(); if (trimmedModel.length === 0) { return { valid: false, error: 'Model name cannot be empty' }; } // Model names should be alphanumeric with optional dots, dashes, and underscores const modelNameRegex = /^[a-zA-Z0-9._-]+$/; if (!modelNameRegex.test(trimmedModel)) { return { valid: false, error: 'Model name can only contain letters, numbers, dots, dashes, and underscores', }; } // Check length constraints if (trimmedModel.length < 2) { return { valid: false, error: 'Model name must be at least 2 characters long' }; } if (trimmedModel.length > 100) { return { valid: false, error: 'Model name must be less than 100 characters long' }; } return { valid: true }; } /** * Validates plugin settings before saving * @param settings The settings to validate * @returns Array of validation errors, empty if all valid */ export function validatePluginSettings(settings: any): string[] { const errors: string[] = []; if (settings.ollamaUrl) { const urlValidation = validateOllamaUrl(settings.ollamaUrl); if (!urlValidation.valid) { errors.push(`Ollama URL: ${urlValidation.error}`); } } if (settings.model) { const modelValidation = validateModelName(settings.model); if (!modelValidation.valid) { errors.push(`Model: ${modelValidation.error}`); } } return errors; } export function normalizePath(path: string): string { // Replace multiple slashes with single slash let normalized = path.replace(/[\\\/]+/g, '/'); // Remove trailing slash unless it's the root if (normalized.length > 1 && normalized.endsWith('/')) { normalized = normalized.slice(0, -1); } return normalized; } /** * Validates a path string for safety (no traversal, no absolute paths, no invalid chars) */ /** * Validates a URL string to ensure it's a proper HTTP or HTTPS URL */ export function isValidHttpUrl(string: string): boolean { let url; try { url = new URL(string); } catch (_) { return false; } return url.protocol === 'http:' || url.protocol === 'https:'; } /** * Safely parses JSON with validation to prevent code injection * @param text The JSON string to parse * @param maxDepth Maximum allowed nesting depth (prevents DoS via deeply nested JSON) * @param maxSize Maximum allowed size in characters (prevents DoS via very large JSON) * @returns Parsed object or throws error */ export function safeParseJson(text: string, maxDepth: number = 20, maxSize: number = 1000000): any { if (typeof text !== 'string') { throw new Error('Input must be a string'); } // Check size limit if (text.length > maxSize) { throw new Error(`JSON input too large (${text.length} characters, max ${maxSize})`); } // Check for potentially dangerous patterns const dangerousPatterns = [ /\bconstructor\b/i, /\bprototype\b/i, /\b__proto__\b/i, /\bfunction\b/i, /\brequire\b/i, /\brequire\b/i, /\bprocess\b/i, /\bchild_process\b/i, /\bglobal\b/i, /\bwindow\b/i, /\bdangerouslySetInnerHTML\b/i, ]; for (const pattern of dangerousPatterns) { if (pattern.test(text)) { throw new Error('Potentially dangerous JSON pattern detected'); } } try { const result = JSON.parse(text); // Recursive depth check function checkDepth(obj: any, depth: number): boolean { if (depth > maxDepth) { return false; } if (Array.isArray(obj)) { for (const item of obj) { if (!checkDepth(item, depth + 1)) { return false; } } } else if (obj && typeof obj === 'object') { for (const key in obj) { if (!checkDepth(obj[key], depth + 1)) { return false; } } } return true; } if (!checkDepth(result, 0)) { throw new Error(`JSON nesting too deep (max ${maxDepth} levels)`); } // Validate that the result is a safe object (not a function, etc.) if (typeof result === 'function' || result instanceof Function) { throw new Error('JSON parsing resulted in a function - potential code injection'); } return result; } catch (error) { // Re-throw JSON parse errors with additional context if (error instanceof Error && error.message.includes('JSON')) { throw new Error(`Invalid JSON: ${error.message}`); } throw new Error( `Failed to parse JSON: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } export function validatePath(path: string): { valid: boolean; error?: string } { const normalized = normalizePath(path); // Check for path traversal by looking for .. as a path segment (not just substring in filenames) const segments = normalized.split('/'); if (segments.includes('..')) { return { valid: false, error: 'Path traversal not allowed' }; } // Check if absolute path if (normalized.startsWith('/') || normalized.startsWith('\\')) { return { valid: false, error: 'Absolute paths not allowed' }; } // Check for windows drive letters if (/^[a-zA-Z]:/.test(normalized)) { return { valid: false, error: 'Absolute paths not allowed' }; } // Check for invalid characters const invalidChars = /[\<\>\:\"\|\\\?\*~]/; if (invalidChars.test(path)) { return { valid: false, error: 'Path contains illegal characters' }; } // Check path length const MAX_PATH_LENGTH = 200; if (path.length > MAX_PATH_LENGTH) { return { valid: false, error: 'Path too long' }; } return { valid: true }; }