using Models.Settings; using Api.Services.Contracts; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; using Swashbuckle.AspNetCore.Annotations; using Shared.Models.Responses; 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?}")] [SwaggerOperation(Summary = "Download file", Description = "Downloads a file with support for full and ranged (resumable) transfers.")] [SwaggerResponse(StatusCodes.Status200OK, "Full file content returned")] [SwaggerResponse(StatusCodes.Status206PartialContent, "Partial file content returned for a range request")] [SwaggerResponse(StatusCodes.Status400BadRequest, "No file name provided and no default configured")] [SwaggerResponse(StatusCodes.Status404NotFound, "Requested file was not found")] [SwaggerResponse(StatusCodes.Status416RangeNotSatisfiable, "Requested byte range is invalid")] [SwaggerResponse(StatusCodes.Status500InternalServerError, "Unexpected server error while downloading")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status206PartialContent)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status416RangeNotSatisfiable)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)] public async Task DownloadFile(string? fileName = null) { try { 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 ErrorResponse { Error = "File name is required", Code = "file_name_required" }); } _logger.LogInformation("Using default file name from settings: {FileName}", fileName); } var fileStoragePath = _fileStorageSettings.Path; if (!Path.IsPathRooted(fileStoragePath)) { var solutionRoot = Directory.GetCurrentDirectory(); if (solutionRoot.EndsWith("api", StringComparison.OrdinalIgnoreCase)) solutionRoot = Directory.GetParent(solutionRoot)?.FullName ?? solutionRoot; fileStoragePath = Path.Combine(solutionRoot, fileStoragePath); } var sanitizedFileName = Path.GetFileName(fileName); var filePath = Path.Combine(fileStoragePath, sanitizedFileName); if (!System.IO.File.Exists(filePath)) { _logger.LogWarning("File not found: {FilePath}", filePath); return NotFound(new ErrorResponse { Error = "File not found", Code = "file_not_found" }); } var fileLength = new FileInfo(filePath).Length; if (!_contentTypeProvider.TryGetContentType(filePath, out var contentType)) contentType = "application/octet-stream"; 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); } }); var rangeHeader = Request.Headers[HeaderNames.Range].ToString(); if (!string.IsNullOrEmpty(rangeHeader)) return await HandleRangeRequest(filePath, fileLength, contentType, rangeHeader, sanitizedFileName); _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 ErrorResponse { Error = "An error occurred while downloading the file", Code = "download_failed" }); } } /// /// 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 { 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]); if (startByte > endByte || startByte >= fileLength) { _logger.LogWarning("Invalid range request: {Range} for file size {FileLength}", rangeHeader, fileLength); return StatusCode(StatusCodes.Status416RangeNotSatisfiable); } 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); var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, useAsync: true); stream.Seek(startByte, SeekOrigin.Begin); 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; 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; await destination.WriteAsync(buffer.AsMemory(0, bytesRead)); totalBytesRead += bytesRead; } await destination.FlushAsync(); } } }