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:
184
FabWorks.Api/Controllers/FileBrowserController.cs
Normal file
184
FabWorks.Api/Controllers/FileBrowserController.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
93
FabWorks.Api/Controllers/FilesController.cs
Normal file
93
FabWorks.Api/Controllers/FilesController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user