diff --git a/FabWorks.Api/Configuration/FileStorageOptions.cs b/FabWorks.Api/Configuration/FileStorageOptions.cs new file mode 100644 index 0000000..8204f99 --- /dev/null +++ b/FabWorks.Api/Configuration/FileStorageOptions.cs @@ -0,0 +1,9 @@ +namespace FabWorks.Api.Configuration +{ + public class FileStorageOptions + { + public const string SectionName = "FileStorage"; + + public string OutputFolder { get; set; } = @"C:\ExportDXF\Output"; + } +} diff --git a/FabWorks.Api/Controllers/FileBrowserController.cs b/FabWorks.Api/Controllers/FileBrowserController.cs new file mode 100644 index 0000000..895b20f --- /dev/null +++ b/FabWorks.Api/Controllers/FileBrowserController.cs @@ -0,0 +1,184 @@ +using FabWorks.Api.Services; +using FabWorks.Core.Data; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.EntityFrameworkCore; + +namespace FabWorks.Api.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class FileBrowserController : ControllerBase + { + private readonly IFileStorageService _fileStorage; + private readonly FabWorksDbContext _db; + private readonly FileExtensionContentTypeProvider _contentTypeProvider = new(); + + public FileBrowserController(IFileStorageService fileStorage, FabWorksDbContext db) + { + _fileStorage = fileStorage; + _db = db; + } + + [HttpGet("files")] + public async Task> ListFiles( + [FromQuery] string search = null, + [FromQuery] string type = null) + { + var files = new List(); + + // Query DXF files from CutTemplates + if (type == null || type.Equals("dxf", StringComparison.OrdinalIgnoreCase)) + { + var dxfQuery = _db.CutTemplates + .Where(c => c.ContentHash != null) + .Select(c => new + { + c.Id, + c.DxfFilePath, + c.ContentHash, + c.Thickness, + DrawingNumber = c.BomItem.ExportRecord.DrawingNumber, + CreatedAt = c.BomItem.ExportRecord.ExportedAt + }); + + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.Trim().ToLower(); + dxfQuery = dxfQuery.Where(c => + c.DxfFilePath.ToLower().Contains(term) || + c.DrawingNumber.ToLower().Contains(term)); + } + + var dxfResults = await dxfQuery + .OrderByDescending(c => c.CreatedAt) + .Take(500) + .ToListAsync(); + + // Deduplicate by content hash (keep latest) + var seenDxf = new HashSet(); + foreach (var c in dxfResults) + { + if (seenDxf.Contains(c.ContentHash)) continue; + seenDxf.Add(c.ContentHash); + + var fileName = c.DxfFilePath?.Split(new[] { '/', '\\' }).LastOrDefault() ?? c.DxfFilePath; + files.Add(new StoredFileEntry + { + FileName = fileName, + ContentHash = c.ContentHash, + FileType = "dxf", + DrawingNumber = c.DrawingNumber, + Thickness = c.Thickness, + CreatedAt = c.CreatedAt + }); + } + } + + // Query PDF files from ExportRecords + if (type == null || type.Equals("pdf", StringComparison.OrdinalIgnoreCase)) + { + var pdfQuery = _db.ExportRecords + .Where(r => r.PdfContentHash != null) + .Select(r => new + { + r.Id, + r.DrawingNumber, + r.PdfContentHash, + r.ExportedAt + }); + + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.Trim().ToLower(); + pdfQuery = pdfQuery.Where(r => + r.DrawingNumber.ToLower().Contains(term)); + } + + var pdfResults = await pdfQuery + .OrderByDescending(r => r.ExportedAt) + .Take(500) + .ToListAsync(); + + // Deduplicate by content hash + var seenPdf = new HashSet(); + foreach (var r in pdfResults) + { + if (seenPdf.Contains(r.PdfContentHash)) continue; + seenPdf.Add(r.PdfContentHash); + + files.Add(new StoredFileEntry + { + FileName = $"{r.DrawingNumber}.pdf", + ContentHash = r.PdfContentHash, + FileType = "pdf", + DrawingNumber = r.DrawingNumber, + CreatedAt = r.ExportedAt + }); + } + } + + return new FileListResult + { + Total = files.Count, + Files = files.OrderByDescending(f => f.CreatedAt).ToList() + }; + } + + [HttpGet("preview")] + public IActionResult PreviewFile([FromQuery] string hash, [FromQuery] string ext = "dxf") + { + if (string.IsNullOrEmpty(hash) || hash.Length < 4) + return BadRequest("Invalid hash."); + + if (!_fileStorage.BlobExists(hash, ext)) + return NotFound("File not found."); + + var stream = _fileStorage.OpenBlob(hash, ext); + if (stream == null) + return NotFound("File not found."); + + var virtualName = $"file.{ext}"; + if (!_contentTypeProvider.TryGetContentType(virtualName, out var contentType)) + contentType = "application/octet-stream"; + + return File(stream, contentType); + } + + [HttpGet("download")] + public IActionResult DownloadFile([FromQuery] string hash, [FromQuery] string ext = "dxf", [FromQuery] string name = null) + { + if (string.IsNullOrEmpty(hash) || hash.Length < 4) + return BadRequest("Invalid hash."); + + if (!_fileStorage.BlobExists(hash, ext)) + return NotFound("File not found."); + + var stream = _fileStorage.OpenBlob(hash, ext); + if (stream == null) + return NotFound("File not found."); + + var fileName = name ?? $"{hash[..8]}.{ext}"; + if (!_contentTypeProvider.TryGetContentType(fileName, out var contentType)) + contentType = "application/octet-stream"; + + return File(stream, contentType, fileName); + } + } + + public class FileListResult + { + public int Total { get; set; } + public List Files { get; set; } + } + + public class StoredFileEntry + { + public string FileName { get; set; } + public string ContentHash { get; set; } + public string FileType { get; set; } + public string DrawingNumber { get; set; } + public double? Thickness { get; set; } + public DateTime CreatedAt { get; set; } + } +} diff --git a/FabWorks.Api/Controllers/FilesController.cs b/FabWorks.Api/Controllers/FilesController.cs new file mode 100644 index 0000000..b8263f1 --- /dev/null +++ b/FabWorks.Api/Controllers/FilesController.cs @@ -0,0 +1,93 @@ +using FabWorks.Api.DTOs; +using FabWorks.Api.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.StaticFiles; + +namespace FabWorks.Api.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class FilesController : ControllerBase + { + private readonly IFileStorageService _fileStorage; + private readonly FileExtensionContentTypeProvider _contentTypeProvider = new(); + + public FilesController(IFileStorageService fileStorage) + { + _fileStorage = fileStorage; + } + + [HttpPost("dxf")] + [RequestSizeLimit(50_000_000)] // 50 MB + public async Task> UploadDxf( + IFormFile file, + [FromForm] string equipment, + [FromForm] string drawingNo, + [FromForm] string itemNo, + [FromForm] string contentHash) + { + if (file == null || file.Length == 0) + return BadRequest("No file uploaded."); + + using var stream = file.OpenReadStream(); + var result = await _fileStorage.StoreDxfAsync(stream, equipment, drawingNo, itemNo, contentHash); + + return Ok(new FileUploadResponse + { + StoredFilePath = result.FileName, + ContentHash = result.ContentHash, + FileName = result.FileName, + WasUnchanged = result.WasUnchanged, + IsNewFile = result.IsNewFile + }); + } + + [HttpPost("pdf")] + [RequestSizeLimit(100_000_000)] // 100 MB + public async Task> UploadPdf( + IFormFile file, + [FromForm] string equipment, + [FromForm] string drawingNo, + [FromForm] string contentHash, + [FromForm] int? exportRecordId = null) + { + if (file == null || file.Length == 0) + return BadRequest("No file uploaded."); + + using var stream = file.OpenReadStream(); + var result = await _fileStorage.StorePdfAsync(stream, equipment, drawingNo, contentHash, exportRecordId); + + return Ok(new FileUploadResponse + { + StoredFilePath = result.FileName, + ContentHash = result.ContentHash, + FileName = result.FileName, + WasUnchanged = result.WasUnchanged, + IsNewFile = result.IsNewFile + }); + } + + [HttpGet("blob/{hash}")] + public IActionResult GetBlob(string hash, [FromQuery] string ext = "dxf", [FromQuery] bool download = false, [FromQuery] string name = null) + { + if (string.IsNullOrEmpty(hash) || hash.Length < 4) + return BadRequest("Invalid hash."); + + if (!_fileStorage.BlobExists(hash, ext)) + return NotFound("Blob not found."); + + var stream = _fileStorage.OpenBlob(hash, ext); + if (stream == null) + return NotFound("Blob not found."); + + var fileName = !string.IsNullOrEmpty(name) ? name : $"{hash[..8]}.{ext}"; + if (!_contentTypeProvider.TryGetContentType(fileName, out var contentType)) + contentType = "application/octet-stream"; + + if (download) + return File(stream, contentType, fileName); + + return File(stream, contentType); + } + } +} diff --git a/FabWorks.Api/DTOs/FileUploadResponse.cs b/FabWorks.Api/DTOs/FileUploadResponse.cs new file mode 100644 index 0000000..7a1fad0 --- /dev/null +++ b/FabWorks.Api/DTOs/FileUploadResponse.cs @@ -0,0 +1,11 @@ +namespace FabWorks.Api.DTOs +{ + public class FileUploadResponse + { + public string StoredFilePath { get; set; } // kept for client compat, contains logical filename + public string ContentHash { get; set; } + public string FileName { get; set; } + public bool WasUnchanged { get; set; } + public bool IsNewFile { get; set; } + } +} diff --git a/FabWorks.Api/Properties/launchSettings.json b/FabWorks.Api/Properties/launchSettings.json index b41988c..65e9483 100644 --- a/FabWorks.Api/Properties/launchSettings.json +++ b/FabWorks.Api/Properties/launchSettings.json @@ -13,7 +13,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "weatherforecast", + "launchUrl": "", "applicationUrl": "http://localhost:5206", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -23,7 +23,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "weatherforecast", + "launchUrl": "", "applicationUrl": "https://localhost:7182;http://localhost:5206", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -32,7 +32,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "weatherforecast", + "launchUrl": "", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/FabWorks.Api/Services/FileStorageService.cs b/FabWorks.Api/Services/FileStorageService.cs new file mode 100644 index 0000000..f55231e --- /dev/null +++ b/FabWorks.Api/Services/FileStorageService.cs @@ -0,0 +1,148 @@ +using FabWorks.Api.Configuration; +using FabWorks.Core.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace FabWorks.Api.Services +{ + public class FileUploadResult + { + public string ContentHash { get; set; } + public string FileName { get; set; } + public bool WasUnchanged { get; set; } + public bool IsNewFile { get; set; } + } + + public interface IFileStorageService + { + string OutputFolder { get; } + Task StoreDxfAsync(Stream stream, string equipment, string drawingNo, string itemNo, string contentHash); + Task StorePdfAsync(Stream stream, string equipment, string drawingNo, string contentHash, int? exportRecordId = null); + Stream OpenBlob(string contentHash, string extension); + bool BlobExists(string contentHash, string extension); + } + + public class FileStorageService : IFileStorageService + { + private readonly FileStorageOptions _options; + private readonly FabWorksDbContext _db; + + public string OutputFolder => _options.OutputFolder; + + public FileStorageService(IOptions options, FabWorksDbContext db) + { + _options = options.Value; + _db = db; + + var blobRoot = Path.Combine(_options.OutputFolder, "blobs"); + if (!Directory.Exists(blobRoot)) + Directory.CreateDirectory(blobRoot); + } + + public async Task StoreDxfAsync(Stream stream, string equipment, string drawingNo, string itemNo, string contentHash) + { + var fileName = BuildDxfFileName(drawingNo, equipment, itemNo); + + // Look up previous hash by drawing number + item number + var drawingNumber = BuildDrawingNumber(equipment, drawingNo); + var previousHash = await _db.CutTemplates + .Where(c => c.BomItem.ExportRecord.DrawingNumber == drawingNumber + && c.BomItem.ItemNo == itemNo + && c.ContentHash != null) + .OrderByDescending(c => c.Id) + .Select(c => c.ContentHash) + .FirstOrDefaultAsync(); + + var wasUnchanged = previousHash != null && previousHash == contentHash; + var isNewFile = await StoreBlobAsync(stream, contentHash, "dxf"); + + return new FileUploadResult + { + ContentHash = contentHash, + FileName = fileName, + WasUnchanged = wasUnchanged, + IsNewFile = isNewFile + }; + } + + public async Task StorePdfAsync(Stream stream, string equipment, string drawingNo, string contentHash, int? exportRecordId = null) + { + var drawingNumber = BuildDrawingNumber(equipment, drawingNo); + var fileName = $"{drawingNumber}.pdf"; + + // Look up previous PDF hash + var previousHash = await _db.ExportRecords + .Where(r => r.DrawingNumber == drawingNumber + && r.PdfContentHash != null + && (exportRecordId == null || r.Id != exportRecordId)) + .OrderByDescending(r => r.Id) + .Select(r => r.PdfContentHash) + .FirstOrDefaultAsync(); + + var wasUnchanged = previousHash != null && previousHash == contentHash; + var isNewFile = await StoreBlobAsync(stream, contentHash, "pdf"); + + return new FileUploadResult + { + ContentHash = contentHash, + FileName = fileName, + WasUnchanged = wasUnchanged, + IsNewFile = isNewFile + }; + } + + public Stream OpenBlob(string contentHash, string extension) + { + var path = GetBlobPath(contentHash, extension); + if (!File.Exists(path)) + return null; + return new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + } + + public bool BlobExists(string contentHash, string extension) + { + return File.Exists(GetBlobPath(contentHash, extension)); + } + + private async Task StoreBlobAsync(Stream stream, string contentHash, string extension) + { + var blobPath = GetBlobPath(contentHash, extension); + + if (File.Exists(blobPath)) + return false; // blob already exists (dedup) + + var dir = Path.GetDirectoryName(blobPath); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + using var fileStream = new FileStream(blobPath, FileMode.Create, FileAccess.Write); + await stream.CopyToAsync(fileStream); + return true; // new blob written + } + + private string GetBlobPath(string contentHash, string extension) + { + var prefix1 = contentHash[..2]; + var prefix2 = contentHash[2..4]; + return Path.Combine(_options.OutputFolder, "blobs", prefix1, prefix2, $"{contentHash}.{extension}"); + } + + private static string BuildDrawingNumber(string equipment, string drawingNo) + { + if (!string.IsNullOrEmpty(equipment) && !string.IsNullOrEmpty(drawingNo)) + return $"{equipment} {drawingNo}"; + if (!string.IsNullOrEmpty(equipment)) + return equipment; + return drawingNo ?? ""; + } + + private static string BuildDxfFileName(string drawingNo, string equipment, string itemNo) + { + var drawingNumber = BuildDrawingNumber(equipment, drawingNo); + var paddedItem = (itemNo ?? "").PadLeft(2, '0'); + if (!string.IsNullOrEmpty(drawingNumber) && !string.IsNullOrEmpty(itemNo)) + return $"{drawingNumber} PT{paddedItem}.dxf"; + return $"PT{paddedItem}.dxf"; + } + } +} diff --git a/FabWorks.Api/appsettings.json b/FabWorks.Api/appsettings.json index 7326dc1..b9b4456 100644 --- a/FabWorks.Api/appsettings.json +++ b/FabWorks.Api/appsettings.json @@ -8,5 +8,8 @@ "AllowedHosts": "*", "ConnectionStrings": { "FabWorksDb": "Server=localhost;Database=ExportDxfDb;Trusted_Connection=True;TrustServerCertificate=True;" + }, + "FileStorage": { + "OutputFolder": "C:\\ExportDXF\\Output" } }