using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using PowderCoating.Shared.Constants; using System.Reflection; using System.Security.Principal; namespace PowderCoating.Web.Controllers; [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public class DiagnosticsController : Controller { private readonly ILogger _logger; private readonly IWebHostEnvironment _environment; public DiagnosticsController(ILogger logger, IWebHostEnvironment environment) { _logger = logger; _environment = environment; } /// /// Renders the diagnostics overview page with environment, path, logging, and /// log-file metadata (restricted to SuperAdmin and Administrator roles). /// /// Probes write access to both the application root and the logs/ /// subdirectory by creating and immediately deleting a temporary file via /// . This catches permission problems (e.g. the /// process identity has read-only access) before they silently break Serilog. /// A live LogInformation call is also made and the success/failure is /// surfaced in the view so administrators can verify the logging pipeline end-to-end. /// /// public IActionResult Index() { var diagnostics = new DiagnosticsInfo { CurrentTime = DateTime.Now, ApplicationPath = _environment.ContentRootPath, LogsPath = Path.Combine(_environment.ContentRootPath, "logs"), LogsDirectoryExists = Directory.Exists(Path.Combine(_environment.ContentRootPath, "logs")), EnvironmentName = _environment.EnvironmentName, UserIdentity = OperatingSystem.IsWindows() ? WindowsIdentity.GetCurrent().Name : Environment.UserName, CanWriteToAppPath = CanWriteToDirectory(_environment.ContentRootPath), CanWriteToLogsPath = CanWriteToDirectory(Path.Combine(_environment.ContentRootPath, "logs")) }; // Try to test logging try { _logger.LogInformation("Diagnostics page accessed by {User} at {Time}", User.Identity?.Name, DateTime.Now); diagnostics.LoggingTestSuccess = true; diagnostics.LoggingTestMessage = "Successfully called logger.LogInformation"; } catch (Exception ex) { diagnostics.LoggingTestSuccess = false; diagnostics.LoggingTestMessage = $"Logging failed: {ex.Message}"; } // Get log files if they exist var logsPath = Path.Combine(_environment.ContentRootPath, "logs"); if (Directory.Exists(logsPath)) { try { diagnostics.LogFiles = Directory.GetFiles(logsPath, "*.txt") .Select(f => new FileInfo(f)) .OrderByDescending(f => f.LastWriteTime) .Select(f => new LogFileInfo { Name = f.Name, Size = f.Length, LastModified = f.LastWriteTime, FullPath = f.FullName }) .ToList(); } catch (Exception ex) { diagnostics.LogFilesError = ex.Message; } } return View(diagnostics); } /// /// Displays the contents of a specific Serilog log file (or the most recent error /// log by default), with optional keyword search and line-count trimming. /// /// Security hardening applied in this action: /// /// Filename is validated against a strict regex (alphanumeric, hyphens, /// underscores, .txt extension only) to block directory-traversal /// payloads like ../../appsettings.json. /// The resolved full path is checked with StartsWith(basePath) /// after calling Path.GetFullPath so OS path-normalisation cannot /// be abused to escape the logs directory. /// Files are read with FileShare.ReadWrite so that Serilog, which /// holds the active log file open, is not blocked. /// /// Only the last lines are returned to avoid sending /// megabytes of text to the browser; if a search filter is active it is applied /// first and then the tail is taken from the filtered set. /// /// public IActionResult ViewLogs(string? fileName = null, int lines = 500, string? search = null) { var logsPath = Path.Combine(_environment.ContentRootPath, "logs"); if (!Directory.Exists(logsPath)) { return View(new LogViewerModel { Error = "Logs directory does not exist." }); } var model = new LogViewerModel { LogsPath = logsPath, SelectedLines = lines }; // Get all log files try { model.AvailableLogFiles = Directory.GetFiles(logsPath, "*.txt") .Select(f => new FileInfo(f)) .OrderByDescending(f => f.LastWriteTime) .Select(f => new LogFileInfo { Name = f.Name, Size = f.Length, LastModified = f.LastWriteTime, FullPath = f.FullName }) .ToList(); } catch (Exception ex) { model.Error = $"Error loading log files: {ex.Message}"; return View(model); } // If no file specified, use the most recent error log if (string.IsNullOrEmpty(fileName)) { fileName = model.AvailableLogFiles .FirstOrDefault(f => f.Name.StartsWith("errors-"))?.Name ?? model.AvailableLogFiles.FirstOrDefault()?.Name; } if (string.IsNullOrEmpty(fileName)) { model.Error = "No log files found."; return View(model); } model.SelectedFileName = fileName; // SECURITY: Sanitize filename - only allow alphanumeric, hyphens, underscores, and .txt extension if (!System.Text.RegularExpressions.Regex.IsMatch(fileName, @"^[a-zA-Z0-9\-_]+\.txt$")) { _logger.LogWarning("SECURITY: Invalid log filename requested: {FileName} by {User}", fileName, User.Identity?.Name); model.Error = "Invalid file name. Only .txt log files are allowed."; return View(model); } var filePath = Path.Combine(logsPath, fileName); // SECURITY: Enhanced path traversal protection var fullPath = Path.GetFullPath(filePath); var basePath = Path.GetFullPath(logsPath); // Check if resolved path starts with base path AND ensure no path traversal if (!fullPath.StartsWith(basePath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) && fullPath != basePath) { _logger.LogWarning("SECURITY: Path traversal attempt detected: {FilePath} by {User}", fullPath, User.Identity?.Name); model.Error = "Invalid file path."; return View(model); } // Verify file extension if (Path.GetExtension(fullPath) != ".txt") { _logger.LogWarning("SECURITY: Non-txt file access attempted: {FilePath} by {User}", fullPath, User.Identity?.Name); model.Error = "Only .txt files are allowed."; return View(model); } if (!System.IO.File.Exists(filePath)) { model.Error = $"Log file '{fileName}' not found."; return View(model); } try { // Read the file with shared access to handle active log files string[] allLines; using (var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) using (var streamReader = new StreamReader(fileStream)) { var content = streamReader.ReadToEnd(); allLines = content.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); } model.TotalLines = allLines.Length; // Apply search filter if provided if (!string.IsNullOrWhiteSpace(search)) { allLines = allLines.Where(line => line.Contains(search, StringComparison.OrdinalIgnoreCase)).ToArray(); model.SearchTerm = search; model.FilteredLines = allLines.Length; } // Take last N lines (most recent) model.LogContent = string.Join(Environment.NewLine, allLines.TakeLast(lines)); model.DisplayedLines = Math.Min(lines, allLines.Length); } catch (Exception ex) { model.Error = $"Error reading log file: {ex.Message}"; } return View(model); } /// /// Serves a Serilog log file as a downloadable text/plain response so /// administrators can pull log archives from the server without direct file-system /// access. /// /// Applies the same path-containment security check as /// (resolved full path must start with the logs directory path) to prevent /// arbitrary file downloads. The file is read into a byte array with /// FileShare.ReadWrite to avoid locking Serilog's active sink. /// /// public IActionResult DownloadLog(string fileName) { var logsPath = Path.Combine(_environment.ContentRootPath, "logs"); var filePath = Path.Combine(logsPath, fileName); // Security check - ensure the file is in the logs directory var fullPath = Path.GetFullPath(filePath); if (!fullPath.StartsWith(Path.GetFullPath(logsPath))) { return BadRequest("Invalid file path."); } if (!System.IO.File.Exists(filePath)) { return NotFound($"Log file '{fileName}' not found."); } try { // Read file with shared access to handle active log files byte[] fileBytes; using (var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) using (var memoryStream = new MemoryStream()) { fileStream.CopyTo(memoryStream); fileBytes = memoryStream.ToArray(); } return File(fileBytes, "text/plain", fileName); } catch (Exception ex) { _logger.LogError(ex, "Error downloading log file {FileName}", fileName); return StatusCode(500, $"Error downloading log file: {ex.Message}"); } } /// /// Permanently deletes Serilog .txt log files whose last-write timestamp /// is older than days (defaults to 30). /// /// This is a destructive, irreversible file-system operation. Only the /// Administrator / SuperAdmin roles can reach this action (enforced /// at controller level). The deletion is logged at Warning level so there is an /// audit trail in any newer log files that survive the purge. /// /// public IActionResult ClearOldLogs(int daysToKeep = 30) { var logsPath = Path.Combine(_environment.ContentRootPath, "logs"); if (!Directory.Exists(logsPath)) { TempData["ErrorMessage"] = "Logs directory does not exist."; return RedirectToAction(nameof(ViewLogs)); } try { var cutoffDate = DateTime.Now.AddDays(-daysToKeep); var files = Directory.GetFiles(logsPath, "*.txt") .Select(f => new FileInfo(f)) .Where(f => f.LastWriteTime < cutoffDate) .ToList(); var deletedCount = 0; foreach (var file in files) { file.Delete(); deletedCount++; } _logger.LogInformation("Cleared {Count} old log files (older than {Days} days) by {User}", deletedCount, daysToKeep, User.Identity?.Name); TempData["SuccessMessage"] = $"Deleted {deletedCount} old log file(s)."; } catch (Exception ex) { _logger.LogError(ex, "Error clearing old logs"); TempData["ErrorMessage"] = $"Error clearing logs: {ex.Message}"; } return RedirectToAction(nameof(ViewLogs)); } /// /// Tests whether the current process identity can write files to /// by creating and immediately deleting a uniquely-named /// temporary file. /// /// Creates the directory if it does not yet exist so that a missing logs/ /// folder does not produce a false negative on fresh deployments. /// Any exception (UnauthorizedAccess, IOException, etc.) is swallowed and returns /// false — this is intentional because the method is purely diagnostic and /// must not throw. /// /// private bool CanWriteToDirectory(string path) { try { if (!Directory.Exists(path)) { Directory.CreateDirectory(path); } var testFile = Path.Combine(path, $"write_test_{Guid.NewGuid()}.tmp"); System.IO.File.WriteAllText(testFile, "test"); System.IO.File.Delete(testFile); return true; } catch { return false; } } } public class DiagnosticsInfo { public DateTime CurrentTime { get; set; } public string ApplicationPath { get; set; } = string.Empty; public string LogsPath { get; set; } = string.Empty; public bool LogsDirectoryExists { get; set; } public string EnvironmentName { get; set; } = string.Empty; public string UserIdentity { get; set; } = string.Empty; public bool CanWriteToAppPath { get; set; } public bool CanWriteToLogsPath { get; set; } public bool LoggingTestSuccess { get; set; } public string LoggingTestMessage { get; set; } = string.Empty; public List LogFiles { get; set; } = new(); public string? LogFilesError { get; set; } } public class LogFileInfo { public string Name { get; set; } = string.Empty; public long Size { get; set; } public DateTime LastModified { get; set; } public string FullPath { get; set; } = string.Empty; } public class LogViewerModel { public string LogsPath { get; set; } = string.Empty; public List AvailableLogFiles { get; set; } = new(); public string? SelectedFileName { get; set; } public string LogContent { get; set; } = string.Empty; public int TotalLines { get; set; } public int DisplayedLines { get; set; } public int FilteredLines { get; set; } public int SelectedLines { get; set; } = 500; public string? SearchTerm { get; set; } public string? Error { get; set; } }