Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,397 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Reflection;
using System.Security.Principal;
namespace PowderCoating.Web.Controllers;
[Authorize(Roles = "SuperAdmin,Administrator")]
public class DiagnosticsController : Controller
{
private readonly ILogger<DiagnosticsController> _logger;
private readonly IWebHostEnvironment _environment;
public DiagnosticsController(ILogger<DiagnosticsController> logger, IWebHostEnvironment environment)
{
_logger = logger;
_environment = environment;
}
/// <summary>
/// Renders the diagnostics overview page with environment, path, logging, and
/// log-file metadata (restricted to SuperAdmin and Administrator roles).
/// <para>
/// Probes write access to both the application root and the <c>logs/</c>
/// subdirectory by creating and immediately deleting a temporary file via
/// <see cref="CanWriteToDirectory"/>. This catches permission problems (e.g. the
/// process identity has read-only access) before they silently break Serilog.
/// A live <c>LogInformation</c> call is also made and the success/failure is
/// surfaced in the view so administrators can verify the logging pipeline end-to-end.
/// </para>
/// </summary>
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);
}
/// <summary>
/// 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.
/// <para>
/// Security hardening applied in this action:
/// <list type="bullet">
/// <item>Filename is validated against a strict regex (alphanumeric, hyphens,
/// underscores, <c>.txt</c> extension only) to block directory-traversal
/// payloads like <c>../../appsettings.json</c>.</item>
/// <item>The resolved full path is checked with <c>StartsWith(basePath)</c>
/// after calling <c>Path.GetFullPath</c> so OS path-normalisation cannot
/// be abused to escape the logs directory.</item>
/// <item>Files are read with <c>FileShare.ReadWrite</c> so that Serilog, which
/// holds the active log file open, is not blocked.</item>
/// </list>
/// Only the last <paramref name="lines"/> 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.
/// </para>
/// </summary>
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);
}
/// <summary>
/// Serves a Serilog log file as a downloadable <c>text/plain</c> response so
/// administrators can pull log archives from the server without direct file-system
/// access.
/// <para>
/// Applies the same path-containment security check as <see cref="ViewLogs"/>
/// (resolved full path must start with the logs directory path) to prevent
/// arbitrary file downloads. The file is read into a byte array with
/// <c>FileShare.ReadWrite</c> to avoid locking Serilog's active sink.
/// </para>
/// </summary>
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}");
}
}
/// <summary>
/// Permanently deletes Serilog <c>.txt</c> log files whose last-write timestamp
/// is older than <paramref name="daysToKeep"/> days (defaults to 30).
/// <para>
/// This is a destructive, irreversible file-system operation. Only the
/// <c>Administrator</c> / <c>SuperAdmin</c> 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.
/// </para>
/// </summary>
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));
}
/// <summary>
/// Tests whether the current process identity can write files to
/// <paramref name="path"/> by creating and immediately deleting a uniquely-named
/// temporary file.
/// <para>
/// Creates the directory if it does not yet exist so that a missing <c>logs/</c>
/// folder does not produce a false negative on fresh deployments.
/// Any exception (UnauthorizedAccess, IOException, etc.) is swallowed and returns
/// <c>false</c> — this is intentional because the method is purely diagnostic and
/// must not throw.
/// </para>
/// </summary>
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<LogFileInfo> 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<LogFileInfo> 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; }
}