using Api.Services.Contracts; using Api.Settings; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; namespace Api.Controllers { /// /// Controller for handling file downloads with support for resume and chunked transfers. /// Routes are prefixed with "api/filedownload". /// [ApiController] [Route("api/[controller]")] [EnableCors("FrontendOnly")] public sealed class FileDownloadController : ControllerBase { private readonly ILogger _logger; private readonly FileStorageSettings _fileStorageSettings; private readonly IContentTypeProvider _contentTypeProvider; private readonly IEmailSender _emailSender; private const int BufferSize = 81920; // 80 KB buffer for optimal streaming performance public FileDownloadController( ILogger logger, IOptions fileStorageSettings, IContentTypeProvider contentTypeProvider, IEmailSender emailSender) { _logger = logger; _fileStorageSettings = fileStorageSettings.Value; _contentTypeProvider = contentTypeProvider; _emailSender = emailSender; } /// /// Downloads a file with support for resume (range requests) and chunked transfer. /// Supports HTTP 206 Partial Content for efficient downloads and resume capability. /// Sends email notification when download starts. /// /// The name of the file to download (optional - uses default from settings if not provided) /// File stream with appropriate headers for resumable downloads /// Full file content /// Partial file content (range request) /// File not found /// Requested range not satisfiable [HttpGet("{fileName?}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status206PartialContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status416RangeNotSatisfiable)] public async Task DownloadFile(string? fileName = null) { try { // Use default file name from settings if not provided if (string.IsNullOrWhiteSpace(fileName)) { fileName = _fileStorageSettings.DefaultFileName; if (string.IsNullOrWhiteSpace(fileName)) { _logger.LogWarning("No file name provided and no default file name configured"); return BadRequest(new { error = "File name is required" }); } _logger.LogInformation("Using default file name from settings: {FileName}", fileName); } // Get the file storage path (relative to solution folder) var fileStoragePath = _fileStorageSettings.Path; // If path is not absolute, make it relative to the solution root if (!Path.IsPathRooted(fileStoragePath)) { var solutionRoot = Directory.GetCurrentDirectory(); // Go up from api folder to solution root if needed if (solutionRoot.EndsWith("api", StringComparison.OrdinalIgnoreCase)) { solutionRoot = Directory.GetParent(solutionRoot)?.FullName ?? solutionRoot; } fileStoragePath = Path.Combine(solutionRoot, fileStoragePath); } // Sanitize fileName to prevent directory traversal attacks var sanitizedFileName = Path.GetFileName(fileName); var filePath = Path.Combine(fileStoragePath, sanitizedFileName); // Verify file exists if (!System.IO.File.Exists(filePath)) { _logger.LogWarning("File not found: {FilePath}", filePath); return NotFound(new { error = "File not found" }); } var fileInfo = new FileInfo(filePath); var fileLength = fileInfo.Length; // Determine content type if (!_contentTypeProvider.TryGetContentType(filePath, out var contentType)) { contentType = "application/octet-stream"; } // Send email notification asynchronously (fire and forget with error handling) // This is done before streaming to ensure notification is sent for both full and range downloads var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); _ = Task.Run(async () => { try { await _emailSender.SendFileDownloadNotificationAsync(sanitizedFileName, userIp, CancellationToken.None); } catch (Exception ex) { _logger.LogError(ex, "Failed to send file download notification for {FileName}", sanitizedFileName); } }); // Check if this is a range request var rangeHeader = Request.Headers[HeaderNames.Range].ToString(); if (!string.IsNullOrEmpty(rangeHeader)) { return await HandleRangeRequest(filePath, fileLength, contentType, rangeHeader, sanitizedFileName); } // Full file download _logger.LogInformation("Starting full file download: {FileName} ({FileSize} bytes)", sanitizedFileName, fileLength); var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, useAsync: true); Response.Headers.Append(HeaderNames.AcceptRanges, "bytes"); Response.Headers.Append(HeaderNames.ContentLength, fileLength.ToString()); return File(stream, contentType, sanitizedFileName, enableRangeProcessing: true); } catch (Exception ex) { _logger.LogError(ex, "Error downloading file: {FileName}", fileName); return StatusCode(StatusCodes.Status500InternalServerError, new { error = "An error occurred while downloading the file" }); } } /// /// Handles HTTP range requests for partial content downloads and resume support. /// private async Task HandleRangeRequest( string filePath, long fileLength, string contentType, string rangeHeader, string fileName) { try { // Parse range header (format: "bytes=start-end") var range = rangeHeader.Replace("bytes=", "").Split('-'); long startByte = 0; long endByte = fileLength - 1; if (!string.IsNullOrEmpty(range[0])) { startByte = long.Parse(range[0]); } if (range.Length > 1 && !string.IsNullOrEmpty(range[1])) { endByte = long.Parse(range[1]); } // Validate range if (startByte > endByte || startByte >= fileLength) { _logger.LogWarning("Invalid range request: {Range} for file size {FileLength}", rangeHeader, fileLength); return StatusCode(StatusCodes.Status416RangeNotSatisfiable); } // Adjust end byte if it exceeds file length if (endByte >= fileLength) { endByte = fileLength - 1; } var contentLength = endByte - startByte + 1; _logger.LogInformation( "Range request for {FileName}: bytes {Start}-{End}/{Total} ({ContentLength} bytes)", fileName, startByte, endByte, fileLength, contentLength); // Open file stream and seek to start position var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, useAsync: true); stream.Seek(startByte, SeekOrigin.Begin); // Set response headers for partial content Response.StatusCode = StatusCodes.Status206PartialContent; Response.Headers.Append(HeaderNames.AcceptRanges, "bytes"); Response.Headers.Append(HeaderNames.ContentRange, $"bytes {startByte}-{endByte}/{fileLength}"); Response.Headers.Append(HeaderNames.ContentLength, contentLength.ToString()); Response.ContentType = contentType; // Stream the requested range await StreamRangeAsync(stream, Response.Body, contentLength); await stream.DisposeAsync(); return new EmptyResult(); } catch (Exception ex) { _logger.LogError(ex, "Error processing range request for file: {FileName}", fileName); return StatusCode(StatusCodes.Status500InternalServerError); } } /// /// Efficiently streams a specific byte range from source to destination. /// private static async Task StreamRangeAsync(Stream source, Stream destination, long bytesToRead) { var buffer = new byte[BufferSize]; long totalBytesRead = 0; while (totalBytesRead < bytesToRead) { var bytesToReadThisIteration = (int)Math.Min(BufferSize, bytesToRead - totalBytesRead); var bytesRead = await source.ReadAsync(buffer.AsMemory(0, bytesToReadThisIteration)); if (bytesRead == 0) { break; // End of stream } await destination.WriteAsync(buffer.AsMemory(0, bytesRead)); totalBytesRead += bytesRead; } await destination.FlushAsync(); } } }