feat: add file storage service with content-addressed blob store

Add FileStorageService for DXF/PDF storage using content hashing,
FileStorageOptions config, FilesController for uploads, and
FileBrowserController for browsing stored files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 20:36:18 -05:00
parent f75b83d483
commit dba68ecc71
7 changed files with 451 additions and 3 deletions

View File

@@ -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<ActionResult<FileListResult>> ListFiles(
[FromQuery] string search = null,
[FromQuery] string type = null)
{
var files = new List<StoredFileEntry>();
// 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<string>();
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<string>();
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<StoredFileEntry> 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; }
}
}

View File

@@ -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<ActionResult<FileUploadResponse>> 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<ActionResult<FileUploadResponse>> 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);
}
}
}