diff --git a/Client/GlobalUsings.cs b/Client/GlobalUsings.cs index fba3ce1..f691c5e 100644 --- a/Client/GlobalUsings.cs +++ b/Client/GlobalUsings.cs @@ -4,6 +4,7 @@ global using ICTAce.FileHub.Services; global using ICTAce.FileHub.Services.Common; global using Microsoft.AspNetCore.Components; +global using Microsoft.AspNetCore.Components.Forms; global using Microsoft.AspNetCore.Components.Web; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Localization; diff --git a/Client/Modules/FileHub/Edit.razor b/Client/Modules/FileHub/Edit.razor index df9b6f2..d88794e 100644 --- a/Client/Modules/FileHub/Edit.razor +++ b/Client/Modules/FileHub/Edit.razor @@ -1,20 +1,187 @@ @namespace ICTAce.FileHub @inherits ModuleBase + +
-
- +
+
+

@(PageState.Action == "Add" ? "Add New File" : "Edit File")

+
+
+ +
+ +
+
+ + +
+ @if (!string.IsNullOrEmpty(_uploadedFileName)) + { +
+ Uploaded: @_uploadedFileName +
+ } +
Maximum file size: 100MB
+
+
+ +
+ +
+ +
Name is required (max 100 characters)
+
+
+ +
+
- + +
File name is required (max 255 characters)
+
Auto-filled from uploaded file
+ +
+ +
+
+ + +
+ @if (!string.IsNullOrEmpty(_uploadedImageName)) + { +
+ Uploaded: @_uploadedImageName +
+
+ Thumbnail preview +
+ } + +
Optional - Upload thumbnail/preview image (JPG, PNG, GIF - Max 10MB)
+
+
+ +
+ +
+ +
File size is required (max 12 characters)
+
Auto-filled from uploaded file
+
+
+ +
+ +
+ +
Optional - max 1000 characters
+
+
+ +
+ +
+ +
Downloads must be 0 or greater
+
+
+ +
+ +
+ @if (_isLoadingCategories) + { +

Loading categories...

+ } + else + { +
+ + + + + +
+
+ @if (_selectedCategories != null && _selectedCategories.Any()) + { + Selected: @string.Join(", ", _selectedCategories.OfType().Where(c => c.Id > 0).Select(c => c.Name)) + } + else + { + No categories selected + } +
+ } +
+
+
+ +
+ + + @Localizer["Cancel"] +
- - @Localizer["Cancel"] +

@if (PageState.Action == "Edit") { - + } diff --git a/Client/Modules/FileHub/Edit.razor.cs b/Client/Modules/FileHub/Edit.razor.cs index 5dfd8bc..9f7c74a 100644 --- a/Client/Modules/FileHub/Edit.razor.cs +++ b/Client/Modules/FileHub/Edit.razor.cs @@ -1,10 +1,14 @@ // Licensed to ICTAce under the MIT license. +using Microsoft.AspNetCore.Components.Forms; +using Radzen; + namespace ICTAce.FileHub; public partial class Edit { - [Inject] protected ISampleModuleService FileHubService { get; set; } = default!; + [Inject] protected Services.IFileService FileService { get; set; } = default!; + [Inject] protected ICategoryService CategoryService { get; set; } = default!; [Inject] protected NavigationManager NavigationManager { get; set; } = default!; [Inject] protected IStringLocalizer Localizer { get; set; } = default!; @@ -12,49 +16,309 @@ public partial class Edit public override string Actions => "Add,Edit"; - public override string Title => "Manage FileHub"; + public override string Title => "Manage File"; public override List Resources => [ - new Stylesheet(ModulePath() + "Module.css") + new Stylesheet(ModulePath() + "Module.css"), + new Script("_content/Radzen.Blazor/Radzen.Blazor.js") ]; private ElementReference form; private bool _validated; + private bool _isUploading; + private int _uploadProgress; + private string? _uploadedFileName; + private bool _isUploadingImage; + private int _imageUploadProgress; + private string? _uploadedImageName; private int _id; private string _name = string.Empty; + private string _fileName = string.Empty; + private string _imageName = string.Empty; + private string? _description; + private string _fileSize = string.Empty; + private int _downloads; + private string _createdby = string.Empty; private DateTime _createdon; private string _modifiedby = string.Empty; private DateTime _modifiedon; + private List _treeData = []; + private ListCategoryDto _rootNode = new() { Name = "Categories" }; + private IEnumerable? _selectedCategories; + private bool _isLoadingCategories; + protected override async Task OnInitializedAsync() { try { + // Load categories + await LoadCategories(); + if (string.Equals(PageState.Action, "Edit", StringComparison.Ordinal)) { _id = Int32.Parse(PageState.QueryString["id"], System.Globalization.CultureInfo.InvariantCulture); - var filehub = await FileHubService.GetAsync(_id, ModuleState.ModuleId).ConfigureAwait(true); - if (filehub != null) + var file = await FileService.GetAsync(_id, ModuleState.ModuleId).ConfigureAwait(true); + if (file != null) { - _name = filehub.Name; - _createdby = filehub.CreatedBy; - _createdon = filehub.CreatedOn; - _modifiedby = filehub.ModifiedBy; - _modifiedon = filehub.ModifiedOn; + _name = file.Name; + _fileName = file.FileName; + _imageName = file.ImageName; + _uploadedImageName = file.ImageName; + _description = file.Description; + _fileSize = file.FileSize; + _downloads = file.Downloads; + _createdby = file.CreatedBy; + _createdon = file.CreatedOn; + _modifiedby = file.ModifiedBy; + _modifiedon = file.ModifiedOn; + + // Load selected categories + if (file.CategoryIds.Any()) + { + var selectedCats = GetAllCategories().Where(c => file.CategoryIds.Contains(c.Id)).ToList(); + _selectedCategories = selectedCats.Cast(); + } } } } catch (Exception ex) { - await logger.LogError(ex, "Error Loading FileHub {Id} {Error}", _id, ex.Message).ConfigureAwait(true); + await logger.LogError(ex, "Error Loading File {Id} {Error}", _id, ex.Message).ConfigureAwait(true); AddModuleMessage(Localizer["Message.LoadError"], MessageType.Error); } } - private async Task Save() + private async Task OnFileSelected(InputFileChangeEventArgs e) + { + try + { + _isUploading = true; + _uploadProgress = 0; + StateHasChanged(); + + var file = e.File; + + // Limit file size to 100MB + const long maxFileSize = 100 * 1024 * 1024; + if (file.Size > maxFileSize) + { + AddModuleMessage("File size exceeds 100MB limit", MessageType.Error); + _isUploading = false; + return; + } + + // Simulate progress for better UX + _uploadProgress = 10; + StateHasChanged(); + + // Upload the file + using var stream = file.OpenReadStream(maxFileSize); + + _uploadProgress = 30; + StateHasChanged(); + + _uploadedFileName = await FileService.UploadFileAsync(ModuleState.ModuleId, stream, file.Name).ConfigureAwait(true); + + _uploadProgress = 90; + StateHasChanged(); + + // Auto-fill form fields + if (string.IsNullOrEmpty(_name)) + { + _name = Path.GetFileNameWithoutExtension(file.Name); + } + if (string.IsNullOrEmpty(_fileName)) + { + _fileName = _uploadedFileName; + } + _fileSize = FormatFileSize(file.Size); + + _uploadProgress = 100; + AddModuleMessage("File uploaded successfully", MessageType.Success); + } + catch (Exception ex) + { + await logger.LogError(ex, "Error Uploading File {Error}", ex.Message).ConfigureAwait(true); + AddModuleMessage("Error uploading file", MessageType.Error); + } + finally + { + _isUploading = false; + StateHasChanged(); + } + } + + private async Task OnImageSelected(InputFileChangeEventArgs e) + { + try + { + _isUploadingImage = true; + _imageUploadProgress = 0; + StateHasChanged(); + + var file = e.File; + + // Limit image size to 10MB + const long maxImageSize = 10 * 1024 * 1024; + if (file.Size > maxImageSize) + { + AddModuleMessage("Image size exceeds 10MB limit", MessageType.Error); + _isUploadingImage = false; + return; + } + + // Validate image type + if (!file.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + AddModuleMessage("Please select a valid image file", MessageType.Error); + _isUploadingImage = false; + return; + } + + // Simulate progress for better UX + _imageUploadProgress = 10; + StateHasChanged(); + + // Upload the image + using var stream = file.OpenReadStream(maxImageSize); + + _imageUploadProgress = 30; + StateHasChanged(); + + _uploadedImageName = await FileService.UploadFileAsync(ModuleState.ModuleId, stream, file.Name).ConfigureAwait(true); + _imageName = _uploadedImageName; + + _imageUploadProgress = 100; + AddModuleMessage("Image uploaded successfully", MessageType.Success); + } + catch (Exception ex) + { + await logger.LogError(ex, "Error Uploading Image {Error}", ex.Message).ConfigureAwait(true); + AddModuleMessage("Error uploading image", MessageType.Error); + } + finally + { + _isUploadingImage = false; + StateHasChanged(); + } + } + + private string GetImageUrl() + { + if (string.IsNullOrEmpty(_uploadedImageName)) + { + return string.Empty; + } + + // Construct the URL to the uploaded image + // Path: Content/Tenants/{TenantId}/Sites/{SiteId}/FileHub/{ModuleId}/{filename} + return $"/Content/Tenants/{PageState.Alias.TenantId}/Sites/{PageState.Site.SiteId}/FileHub/{ModuleState.ModuleId}/{_uploadedImageName}"; + } + + private static string FormatFileSize(long bytes) + { + string[] sizes = ["B", "KB", "MB", "GB", "TB"]; + double len = bytes; + int order = 0; + while (len >= 1024 && order < sizes.Length - 1) + { + order++; + len = len / 1024; + } + return $"{len:0.##} {sizes[order]}"; + } + + private async Task LoadCategories() + { + try + { + _isLoadingCategories = true; + var categories = await CategoryService.ListAsync(ModuleState.ModuleId, pageNumber: 1, pageSize: int.MaxValue); + CreateTreeStructure(categories); + } + catch (Exception ex) + { + await logger.LogError(ex, "Error Loading Categories {Error}", ex.Message).ConfigureAwait(true); + AddModuleMessage("Failed to load categories", MessageType.Warning); + } + finally + { + _isLoadingCategories = false; + } + } + + private void CreateTreeStructure(PagedResult categories) + { + if (categories.Items is null || !categories.Items.Any()) + { + _treeData = []; + _rootNode = new ListCategoryDto + { + Id = 0, + Name = "Categories", + ParentId = -1, + ViewOrder = 0, + IsExpanded = true, + Children = [] + }; + return; + } + + foreach (var category in categories.Items) + { + category.Children.Clear(); + } + + var categoryDict = categories.Items.ToDictionary(c => c.Id, c => c); + + _treeData = categories.Items + .Where(c => c.ParentId is null || !categoryDict.ContainsKey(c.ParentId.Value)) + .OrderBy(c => c.ViewOrder) + .ThenBy(c => c.Name, StringComparer.Ordinal) + .ToList(); + + foreach (var category in categories.Items) + { + if (category.ParentId is not null && categoryDict.TryGetValue(category.ParentId.Value, out var parent)) + { + parent.Children.Add(category); + } + } + + SortChildren(_treeData); + + _rootNode = new ListCategoryDto + { + Id = 0, + Name = "Categories", + ParentId = null, + ViewOrder = 0, + IsExpanded = true, + Children = _treeData + }; + } + + private static void SortChildren(List categories) + { + foreach (var category in categories) + { + if (category.Children.Any()) + { + category.Children = category.Children + .OrderBy(c => c.ViewOrder) + .ThenBy(c => c.Name, StringComparer.Ordinal) + .ToList(); + + SortChildren(category.Children.ToList()); + } + } + } + + private async Task Save() { try { @@ -62,23 +326,41 @@ private async Task Save() var interop = new Oqtane.UI.Interop(JSRuntime); if (await interop.FormValid(form)) { + var selectedCategoryIds = _selectedCategories? + .OfType() + .Where(c => c.Id > 0) + .Select(c => c.Id) + .ToList() ?? []; + if (string.Equals(PageState.Action, "Add", StringComparison.Ordinal)) { - var dto = new CreateAndUpdateSampleModuleDto + var dto = new CreateAndUpdateFileDto { - Name = _name + Name = _name, + FileName = _fileName, + ImageName = _imageName, + Description = _description, + FileSize = _fileSize, + Downloads = _downloads, + CategoryIds = selectedCategoryIds }; - var id = await FileHubService.CreateAsync(ModuleState.ModuleId, dto).ConfigureAwait(true); - await logger.LogInformation("FileHub Created {Id}", id).ConfigureAwait(true); + var id = await FileService.CreateAsync(ModuleState.ModuleId, dto).ConfigureAwait(true); + await logger.LogInformation("File Created {Id}", id).ConfigureAwait(true); } else { - var dto = new CreateAndUpdateSampleModuleDto + var dto = new CreateAndUpdateFileDto { - Name = _name + Name = _name, + FileName = _fileName, + ImageName = _imageName, + Description = _description, + FileSize = _fileSize, + Downloads = _downloads, + CategoryIds = selectedCategoryIds }; - var id = await FileHubService.UpdateAsync(_id, ModuleState.ModuleId, dto).ConfigureAwait(true); - await logger.LogInformation("FileHub Updated {Id}", id).ConfigureAwait(true); + var id = await FileService.UpdateAsync(_id, ModuleState.ModuleId, dto).ConfigureAwait(true); + await logger.LogInformation("File Updated {Id}", id).ConfigureAwait(true); } NavigationManager.NavigateTo(NavigateUrl()); } @@ -89,8 +371,30 @@ private async Task Save() } catch (Exception ex) { - await logger.LogError(ex, "Error Saving FileHub {Error}", ex.Message).ConfigureAwait(true); + await logger.LogError(ex, "Error Saving File {Error}", ex.Message).ConfigureAwait(true); AddModuleMessage(Localizer["Message.SaveError"], MessageType.Error); } } + + private IEnumerable GetAllCategories() + { + var result = new List(); + AddCategoriesRecursive(_treeData, result); + return result; + } + + private static void AddCategoriesRecursive(IEnumerable categories, List result) + { + foreach (var category in categories) + { + if (category.Id > 0) + { + result.Add(category); + } + if (category.Children.Any()) + { + AddCategoriesRecursive(category.Children, result); + } + } + } } diff --git a/Client/Services/FileService.cs b/Client/Services/FileService.cs new file mode 100644 index 0000000..69ac83a --- /dev/null +++ b/Client/Services/FileService.cs @@ -0,0 +1,91 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Services; + +public record GetFileDto +{ + public int Id { get; set; } + public int ModuleId { get; set; } + public required string Name { get; set; } + public required string FileName { get; set; } + public required string ImageName { get; set; } + public string? Description { get; set; } + public required string FileSize { get; set; } + public int Downloads { get; set; } + public List CategoryIds { get; set; } = []; + + public required string CreatedBy { get; set; } + public required DateTime CreatedOn { get; set; } + public required string ModifiedBy { get; set; } + public required DateTime ModifiedOn { get; set; } +} + +public record ListFileDto +{ + public int Id { get; set; } + public required string Name { get; set; } + public required string FileName { get; set; } + public required string ImageName { get; set; } + public string? Description { get; set; } + public required string FileSize { get; set; } + public int Downloads { get; set; } +} + +public record CreateAndUpdateFileDto +{ + [Required(ErrorMessage = "Name is required")] + [StringLength(100, MinimumLength = 1, ErrorMessage = "Name must be between 1 and 100 characters")] + public string Name { get; set; } = string.Empty; + + [Required(ErrorMessage = "FileName is required")] + [StringLength(255, MinimumLength = 1, ErrorMessage = "FileName must be between 1 and 255 characters")] + public string FileName { get; set; } = string.Empty; + + [Required(ErrorMessage = "ImageName is required")] + [StringLength(255, MinimumLength = 1, ErrorMessage = "ImageName must be between 1 and 255 characters")] + public string ImageName { get; set; } = string.Empty; + + [StringLength(1000, ErrorMessage = "Description must not exceed 1000 characters")] + public string? Description { get; set; } + + [Required(ErrorMessage = "FileSize is required")] + [StringLength(12, MinimumLength = 1, ErrorMessage = "FileSize must be between 1 and 12 characters")] + public string FileSize { get; set; } = string.Empty; + + [Range(0, int.MaxValue, ErrorMessage = "Downloads must be greater than or equal to 0")] + public int Downloads { get; set; } + + public List CategoryIds { get; set; } = []; +} + +public interface IFileService +{ + Task GetAsync(int id, int moduleId); + Task> ListAsync(int moduleId, int pageNumber = 1, int pageSize = 10); + Task CreateAsync(int moduleId, CreateAndUpdateFileDto dto); + Task UpdateAsync(int id, int moduleId, CreateAndUpdateFileDto dto); + Task DeleteAsync(int id, int moduleId); + Task UploadFileAsync(int moduleId, Stream fileStream, string fileName); +} + +public class FileService(HttpClient http, SiteState siteState) + : ModuleService(http, siteState, "ictace/fileHub/files"), + IFileService +{ + private readonly HttpClient _http = http; + + public async Task UploadFileAsync(int moduleId, Stream fileStream, string fileName) + { + var url = CreateAuthorizationPolicyUrl($"{CreateApiUrl("ictace/fileHub/files")}/upload?moduleId={moduleId}", EntityNames.Module, moduleId); + + using var content = new MultipartFormDataContent(); + using var streamContent = new StreamContent(fileStream); + streamContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream"); + content.Add(streamContent, "file", fileName); + + var response = await _http.PostAsync(url, content).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadAsStringAsync().ConfigureAwait(false); + } +} diff --git a/Client/Startup/ClientStartup.cs b/Client/Startup/ClientStartup.cs index 5611d54..fe4fa25 100644 --- a/Client/Startup/ClientStartup.cs +++ b/Client/Startup/ClientStartup.cs @@ -18,6 +18,11 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); } + if (!services.Any(s => s.ServiceType == typeof(Services.IFileService))) + { + services.AddScoped(); + } + services.AddRadzenComponents(); } } diff --git a/Client/_Imports.razor b/Client/_Imports.razor index 49aa3c3..e6311ec 100644 --- a/Client/_Imports.razor +++ b/Client/_Imports.razor @@ -1,5 +1,6 @@ @using ICTAce.FileHub.Services @using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web @using Microsoft.Extensions.Localization diff --git a/Server/Features/Files/Controller.cs b/Server/Features/Files/Controller.cs new file mode 100644 index 0000000..d4bcf85 --- /dev/null +++ b/Server/Features/Files/Controller.cs @@ -0,0 +1,287 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Features.Files; + +[Route("api/ictace/fileHub/files")] +[ApiController] +public class ICTAceFileHubFilesController( + IMediator mediator, + ILogManager logger, + IHttpContextAccessor accessor, + IWebHostEnvironment environment, + ITenantManager tenantManager) + : ModuleControllerBase(logger, accessor) +{ + private readonly IMediator _mediator = mediator; + private readonly IWebHostEnvironment _environment = environment; + private readonly ITenantManager _tenantManager = tenantManager; + + [HttpGet("{id}")] + [Authorize(Policy = PolicyNames.ViewModule)] + [ProducesResponseType(typeof(GetFileDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetAsync( + int id, + [FromQuery] int moduleId, + CancellationToken cancellationToken = default) + { + if (!IsAuthorizedEntityId(EntityNames.Module, moduleId)) + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, + "Unauthorized FileHub File Get Attempt Id={Id} in ModuleId={ModuleId}", id, moduleId); + return Forbid(); + } + + if (id <= 0) + { + return BadRequest("Invalid File ID"); + } + + var query = new GetFileRequest + { + ModuleId = moduleId, + Id = id, + }; + + var file = await _mediator.Send(query, cancellationToken).ConfigureAwait(false); + + if (file is null) + { + _logger.Log(LogLevel.Warning, this, LogFunction.Read, + "File Not Found Id={Id} in ModuleId={ModuleId}", id, moduleId); + return NotFound(); + } + + return Ok(file); + } + + [HttpGet("")] + [Authorize(Policy = PolicyNames.ViewModule)] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task>> ListAsync( + [FromQuery] int moduleId, + [FromQuery] int pageNumber = 1, + [FromQuery] int pageSize = 10, + CancellationToken cancellationToken = default) + { + if (!IsAuthorizedEntityId(EntityNames.Module, moduleId)) + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, + "Unauthorized File List Attempt ModuleId={ModuleId}", moduleId); + return Forbid(); + } + + if (pageSize > 100) + { + pageSize = 100; + } + + if (pageNumber < 1) + { + pageNumber = 1; + } + + var query = new ListFileRequest + { + ModuleId = moduleId, + PageNumber = pageNumber, + PageSize = pageSize, + }; + + var result = await _mediator.Send(query, cancellationToken).ConfigureAwait(false); + + if (result is null) + { + return NotFound(); + } + + return Ok(result); + } + + [HttpPost("")] + [Authorize(Policy = PolicyNames.EditModule)] + [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> CreateAsync( + [FromQuery] int moduleId, + [FromBody] CreateAndUpdateFileDto dto, + CancellationToken cancellationToken = default) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + if (!IsAuthorizedEntityId(EntityNames.Module, moduleId)) + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, + "Unauthorized File Create Attempt ModuleId={ModuleId}", moduleId); + return Forbid(); + } + + var command = new CreateFileRequest + { + ModuleId = moduleId, + Name = dto.Name, + FileName = dto.FileName, + ImageName = dto.ImageName, + Description = dto.Description, + FileSize = dto.FileSize, + Downloads = dto.Downloads, + CategoryIds = dto.CategoryIds + }; + + var id = await _mediator.Send(command, cancellationToken).ConfigureAwait(false); + + return Created( + Url.Action(nameof(GetAsync), new { id, moduleId = command.ModuleId }) ?? string.Empty, + id); + } + + [HttpPut("{id}")] + [Authorize(Policy = PolicyNames.EditModule)] + [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> UpdateAsync( + int id, + [FromQuery] int moduleId, + [FromBody] CreateAndUpdateFileDto dto, + CancellationToken cancellationToken = default) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + if (!IsAuthorizedEntityId(EntityNames.Module, moduleId)) + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, + "Unauthorized File Update Attempt Id={Id} in ModuleId={ModuleId}", id, moduleId); + return Forbid(); + } + + var command = new UpdateFileRequest + { + Id = id, + ModuleId = moduleId, + Name = dto.Name, + FileName = dto.FileName, + ImageName = dto.ImageName, + Description = dto.Description, + FileSize = dto.FileSize, + Downloads = dto.Downloads, + CategoryIds = dto.CategoryIds + }; + + var result = await _mediator.Send(command, cancellationToken).ConfigureAwait(false); + + return Ok(result); + } + + [HttpDelete("{id}")] + [Authorize(Policy = PolicyNames.EditModule)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task DeleteAsync( + int id, + [FromQuery] int moduleId, + CancellationToken cancellationToken = default) + { + if (!IsAuthorizedEntityId(EntityNames.Module, moduleId)) + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, + "Unauthorized File Delete Attempt Id={Id} in ModuleId={ModuleId}", id, moduleId); + return Forbid(); + } + + if (id <= 0) + { + return BadRequest("Invalid File ID"); + } + + var command = new DeleteFileRequest + { + ModuleId = moduleId, + Id = id, + }; + + await _mediator.Send(command, cancellationToken).ConfigureAwait(false); + + return NoContent(); + } + + [HttpPost("upload")] + [Authorize(Policy = PolicyNames.EditModule)] + [ProducesResponseType(typeof(string), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> UploadFileAsync( + [FromQuery] int moduleId, + IFormFile file, + CancellationToken cancellationToken = default) + { + if (!IsAuthorizedEntityId(EntityNames.Module, moduleId)) + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, + "Unauthorized File Upload Attempt ModuleId={ModuleId}", moduleId); + return Forbid(); + } + + if (file is null || file.Length == 0) + { + return BadRequest("No file uploaded"); + } + + try + { + var alias = _tenantManager.GetAlias(); + var filePath = GetFileStoragePath(alias.TenantId, alias.SiteId, moduleId); + + // Ensure directory exists + if (!Directory.Exists(filePath)) + { + Directory.CreateDirectory(filePath); + } + + // Generate unique filename to prevent overwrites + var fileName = $"{Guid.NewGuid()}_{Path.GetFileName(file.FileName)}"; + var fullPath = Path.Combine(filePath, fileName); + + // Save the file + using (var stream = new FileStream(fullPath, FileMode.Create)) + { + await file.CopyToAsync(stream, cancellationToken).ConfigureAwait(false); + } + + _logger.Log(LogLevel.Information, this, LogFunction.Create, + "File Uploaded FileName={FileName} Size={Size} ModuleId={ModuleId}", + fileName, file.Length, moduleId); + + return Ok(fileName); + } + catch (Exception ex) + { + _logger.Log(LogLevel.Error, this, LogFunction.Create, + ex, "Error Uploading File ModuleId={ModuleId}", moduleId); + return StatusCode(StatusCodes.Status500InternalServerError, "Error uploading file"); + } + } + + private string GetFileStoragePath(int tenantId, int siteId, int moduleId) + { + // Content/Tenants/{TenantId}/Sites/{SiteId}/FileHub/{ModuleId}/ + return Path.Combine( + _environment.ContentRootPath, + "Content", + "Tenants", + tenantId.ToString(System.Globalization.CultureInfo.InvariantCulture), + "Sites", + siteId.ToString(System.Globalization.CultureInfo.InvariantCulture), + "FileHub", + moduleId.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } +} diff --git a/Server/Features/Files/Create.cs b/Server/Features/Files/Create.cs new file mode 100644 index 0000000..d699e9c --- /dev/null +++ b/Server/Features/Files/Create.cs @@ -0,0 +1,64 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Features.Files; + +public record CreateFileRequest : RequestBase, IRequest +{ + public string Name { get; set; } = string.Empty; + public string FileName { get; set; } = string.Empty; + public string ImageName { get; set; } = string.Empty; + public string? Description { get; set; } + public string FileSize { get; set; } = string.Empty; + public int Downloads { get; set; } + public List CategoryIds { get; set; } = []; +} + +public class CreateHandler(HandlerServices services) + : HandlerBase(services), IRequestHandler +{ + private static readonly CreateMapper _mapper = new(); + + public async Task Handle(CreateFileRequest request, CancellationToken cancellationToken) + { + var alias = GetAlias(); + + if (!IsAuthorized(alias.SiteId, request.ModuleId, PermissionNames.Edit)) + { + Logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized File Add Attempt {ModuleId}", request.ModuleId); + return -1; + } + + var entity = _mapper.ToEntity(request); + + using var db = CreateDbContext(); + db.Set().Add(entity); + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + // Save file-category relationships + if (request.CategoryIds.Any()) + { + foreach (var categoryId in request.CategoryIds) + { + var fileCategory = new Persistence.Entities.FileCategory + { + FileId = entity.Id, + CategoryId = categoryId, + ModuleId = entity.ModuleId, + CreatedBy = entity.CreatedBy, + CreatedOn = entity.CreatedOn + }; + db.Set().Add(fileCategory); + } + var result = await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + Logger.Log(LogLevel.Information, this, LogFunction.Create, "File Added {Entity}", entity); + return entity.Id; + } +} + +[Mapper] +internal sealed partial class CreateMapper +{ + internal partial ICTAce.FileHub.Persistence.Entities.File ToEntity(CreateFileRequest request); +} diff --git a/Server/Features/Files/Delete.cs b/Server/Features/Files/Delete.cs new file mode 100644 index 0000000..458b0b3 --- /dev/null +++ b/Server/Features/Files/Delete.cs @@ -0,0 +1,17 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Features.Files; + +public record DeleteFileRequest : EntityRequestBase, IRequest; + +public class DeleteHandler(HandlerServices services) + : HandlerBase(services), IRequestHandler +{ + public Task Handle(DeleteFileRequest request, CancellationToken cancellationToken) + { + return HandleDeleteAsync( + request: request, + cancellationToken: cancellationToken + ); + } +} diff --git a/Server/Features/Files/Get.cs b/Server/Features/Files/Get.cs new file mode 100644 index 0000000..47580d3 --- /dev/null +++ b/Server/Features/Files/Get.cs @@ -0,0 +1,63 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Features.Files; + +public record GetFileRequest : EntityRequestBase, IRequest; + +public record GetFileDto +{ + public int Id { get; set; } + public int ModuleId { get; set; } + public required string Name { get; set; } + public required string FileName { get; set; } + public required string ImageName { get; set; } + public string? Description { get; set; } + public required string FileSize { get; set; } + public int Downloads { get; set; } + public List CategoryIds { get; set; } = []; + + public required string CreatedBy { get; set; } + public required DateTime CreatedOn { get; set; } + public required string ModifiedBy { get; set; } + public required DateTime ModifiedOn { get; set; } +} + +public class GetHandler(HandlerServices services) + : HandlerBase(services), IRequestHandler +{ + private static readonly GetMapper _mapper = new(); + + public async Task Handle(GetFileRequest request, CancellationToken cancellationToken) + { + var alias = GetAlias(); + + if (!IsAuthorized(alias.SiteId, request.ModuleId, PermissionNames.View)) + { + Logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized File Get Attempt {Id} {ModuleId}", request.Id, request.ModuleId); + return null; + } + + using var db = CreateDbContext(); + var entity = await db.Set() + .Include(f => f.FileCategories) + .SingleOrDefaultAsync(e => e.Id == request.Id && e.ModuleId == request.ModuleId, cancellationToken) + .ConfigureAwait(false); + + if (entity is null) + { + Logger.Log(LogLevel.Error, this, LogFunction.Read, "File not found {Id} {ModuleId}", request.Id, request.ModuleId); + return null; + } + + var dto = _mapper.ToDto(entity); + dto.CategoryIds = entity.FileCategories.Select(fc => fc.CategoryId).ToList(); + + return dto; + } +} + +[Mapper] +internal sealed partial class GetMapper +{ + internal partial GetFileDto ToDto(Persistence.Entities.File entity); +} diff --git a/Server/Features/Files/List.cs b/Server/Features/Files/List.cs new file mode 100644 index 0000000..40310e5 --- /dev/null +++ b/Server/Features/Files/List.cs @@ -0,0 +1,27 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Features.Files; + +public record ListFileRequest : PagedRequestBase, IRequest>; + +public class ListHandler(HandlerServices services) + : HandlerBase(services), IRequestHandler?> +{ + private static readonly ListMapper _mapper = new(); + + public Task?> Handle(ListFileRequest request, CancellationToken cancellationToken) + { + return HandleListAsync( + request: request, + mapToResponse: _mapper.ToListResponse, + orderBy: query => query.OrderBy(f => f.Name), + cancellationToken: cancellationToken + ); + } +} + +[Mapper] +internal sealed partial class ListMapper +{ + public partial ListFileDto ToListResponse(ICTAce.FileHub.Persistence.Entities.File file); +} diff --git a/Server/Features/Files/Update.cs b/Server/Features/Files/Update.cs new file mode 100644 index 0000000..f9a8bed --- /dev/null +++ b/Server/Features/Files/Update.cs @@ -0,0 +1,75 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Features.Files; + +public record UpdateFileRequest : EntityRequestBase, IRequest +{ + public required string Name { get; set; } + public required string FileName { get; set; } + public required string ImageName { get; set; } + public string? Description { get; set; } + public required string FileSize { get; set; } + public int Downloads { get; set; } + public List CategoryIds { get; set; } = []; +} + +public class UpdateHandler(HandlerServices services) + : HandlerBase(services), IRequestHandler +{ + public async Task Handle(UpdateFileRequest request, CancellationToken cancellationToken) + { + var alias = GetAlias(); + + if (!IsAuthorized(alias.SiteId, request.ModuleId, PermissionNames.Edit)) + { + Logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized File Update Attempt {Id}", request.Id); + return -1; + } + + using var db = CreateDbContext(); + + // Update file properties + var rowsAffected = await db.Set() + .Where(e => e.Id == request.Id && e.ModuleId == request.ModuleId) + .ExecuteUpdateAsync(setter => setter + .SetProperty(e => e.Name, request.Name) + .SetProperty(e => e.FileName, request.FileName) + .SetProperty(e => e.ImageName, request.ImageName) + .SetProperty(e => e.Description, request.Description) + .SetProperty(e => e.FileSize, request.FileSize) + .SetProperty(e => e.Downloads, request.Downloads), + cancellationToken) + .ConfigureAwait(false); + + if (rowsAffected > 0) + { + // Update file-category relationships + // First, remove existing relationships + await db.Set() + .Where(fc => fc.FileId == request.Id) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + + // Then, add new relationships + if (request.CategoryIds.Any()) + { + foreach (var categoryId in request.CategoryIds) + { + var fileCategory = new Persistence.Entities.FileCategory + { + FileId = request.Id, + CategoryId = categoryId + }; + db.Set().Add(fileCategory); + } + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + Logger.Log(LogLevel.Information, this, LogFunction.Update, "File Updated {Id}", request.Id); + return request.Id; + } + + Logger.Log(LogLevel.Warning, this, LogFunction.Update, "File Not Found {Id}", request.Id); + return -1; + } +} diff --git a/Server/Persistence/ApplicationContext.cs b/Server/Persistence/ApplicationContext.cs index ff4af51..ceb245e 100644 --- a/Server/Persistence/ApplicationContext.cs +++ b/Server/Persistence/ApplicationContext.cs @@ -8,6 +8,8 @@ public class ApplicationContext( { public virtual DbSet SampleModule { get; set; } public virtual DbSet Category { get; set; } + public virtual DbSet File { get; set; } + public virtual DbSet FileCategory { get; set; } protected override void OnModelCreating(ModelBuilder builder) { @@ -24,5 +26,22 @@ protected override void OnModelCreating(ModelBuilder builder) .HasForeignKey(c => c.ParentId) .OnDelete(DeleteBehavior.Restrict); }); + + builder.Entity().ToTable(ActiveDatabase.RewriteName("ICTAce_FileHub_File")); + + builder.Entity(entity => + { + entity.ToTable(ActiveDatabase.RewriteName("ICTAce_FileHub_FileCategory")); + + entity.HasOne(fc => fc.FileHub) + .WithMany(f => f.FileCategories) + .HasForeignKey(fc => fc.FileId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(fc => fc.Category) + .WithMany(c => c.FileCategories) + .HasForeignKey(fc => fc.CategoryId) + .OnDelete(DeleteBehavior.Restrict); + }); } } diff --git a/Server/Persistence/Entities/Category.cs b/Server/Persistence/Entities/Category.cs index 2d16070..5c4b7eb 100644 --- a/Server/Persistence/Entities/Category.cs +++ b/Server/Persistence/Entities/Category.cs @@ -2,12 +2,6 @@ namespace ICTAce.FileHub.Persistence.Entities; -/// -/// Represents a category that can be organized hierarchically and ordered for display purposes. -/// -/// A category may have a parent category, allowing for the creation of nested category structures. The -/// display order of categories can be controlled using the ViewOrder property. Inherits auditing properties from -/// AuditableModuleBase. public class Category : AuditableModuleBase { [MaxLength(100)] @@ -20,4 +14,6 @@ public class Category : AuditableModuleBase public Category? ParentCategory { get; set; } public ICollection? Subcategories { get; set; } + + public ICollection FileCategories { get; set; } = []; } diff --git a/Server/Persistence/Entities/File.cs b/Server/Persistence/Entities/File.cs new file mode 100644 index 0000000..5c2ffb1 --- /dev/null +++ b/Server/Persistence/Entities/File.cs @@ -0,0 +1,26 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Persistence.Entities; + +public class File : AuditableModuleBase +{ + [Required] + [MaxLength(100)] + public required string Name { get; set; } + + [MaxLength(255)] + public required string FileName { get; set; } + + [MaxLength(255)] + public required string ImageName { get; set; } + + [MaxLength(1000)] + public string? Description { get; set; } + + [MaxLength(12)] + public required string FileSize { get; set; } + + public int Downloads { get; set; } + + public ICollection FileCategories { get; set; } = []; +} diff --git a/Server/Persistence/Entities/FileCategory.cs b/Server/Persistence/Entities/FileCategory.cs new file mode 100644 index 0000000..6d1b0d4 --- /dev/null +++ b/Server/Persistence/Entities/FileCategory.cs @@ -0,0 +1,15 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Persistence.Entities; + +public class FileCategory : AuditableModuleBase +{ + [Key] + public int Id { get; set; } + + public int FileId { get; set; } + public File FileHub { get; set; } = null!; + + public int CategoryId { get; set; } + public Category Category { get; set; } = null!; +} diff --git a/Server/Persistence/Entities/FileHub.cs b/Server/Persistence/Entities/FileHub.cs deleted file mode 100644 index 310e399..0000000 --- a/Server/Persistence/Entities/FileHub.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Licensed to ICTAce under the MIT license. - -namespace ICTAce.FileHub.Persistence.Entities; - -/// -/// Represents a file entry with associated metadata, including name, file name, description, file size, and download -/// count. -/// -/// This class is typically used to store and manage information about files within the application, such -/// as for file repositories or download modules. It includes auditing information inherited from the -/// AuditableModuleBase class. -public class FileHub : AuditableModuleBase -{ - [Required] - public required string Name { get; set; } - - [MaxLength(255)] - public required string FileName { get; set; } - - [MaxLength(1000)] - public string? Description { get; set; } - - [MaxLength(12)] - public required string FileSize { get; set; } - - public int Downloads { get; set; } -} diff --git a/Server/Persistence/Entities/FileHubCategory.cs b/Server/Persistence/Entities/FileHubCategory.cs deleted file mode 100644 index fa05c27..0000000 --- a/Server/Persistence/Entities/FileHubCategory.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Licensed to ICTAce under the MIT license. - -namespace ICTAce.FileHub.Persistence.Entities; - -/// -/// Represents the association between a file hub and a category. -/// -/// This class is typically used to model a many-to-many relationship between file hubs and categories -/// within the data model. Each instance links a specific file hub to a specific category. -public class FileHubCategory -{ - [Key] - public int Id { get; set; } - - public int FileHubId { get; set; } - public FileHub FileHub { get; set; } = null!; - - public int CategoryId { get; set; } - public Category Category { get; set; } = null!; -} diff --git a/Server/Persistence/Entities/SampleModule.cs b/Server/Persistence/Entities/SampleModule.cs index 0b8afae..e4a54e1 100644 --- a/Server/Persistence/Entities/SampleModule.cs +++ b/Server/Persistence/Entities/SampleModule.cs @@ -2,9 +2,6 @@ namespace ICTAce.FileHub.Persistence.Entities; -/// -/// Represents a module with auditable properties and a required name. -/// public class SampleModule : AuditableModuleBase { public required string Name { get; set; } diff --git a/Server/Persistence/Migrations/01000000_InitializeModule.cs b/Server/Persistence/Migrations/01000000_InitializeModule.cs index d839cc2..9096c01 100644 --- a/Server/Persistence/Migrations/01000000_InitializeModule.cs +++ b/Server/Persistence/Migrations/01000000_InitializeModule.cs @@ -15,11 +15,14 @@ protected override void Up(MigrationBuilder migrationBuilder) var sampleModuleEntityBuilder = new SampleModuleEntityBuilder(migrationBuilder, ActiveDatabase); sampleModuleEntityBuilder.Create(); - var fileHubEntityBuilder = new FileHubEntityBuilder(migrationBuilder, ActiveDatabase); - fileHubEntityBuilder.Create(); + var fileEntityBuilder = new EntityBuilders.FileEntityBuilder(migrationBuilder, ActiveDatabase); + fileEntityBuilder.Create(); var categoryEntityBuilder = new CategoryEntityBuilder(migrationBuilder, ActiveDatabase); categoryEntityBuilder.Create(); + + var fileCategoryEntityBuilder = new FileCategoryEntityBuilder(migrationBuilder, ActiveDatabase); + fileCategoryEntityBuilder.Create(); } protected override void Down(MigrationBuilder migrationBuilder) @@ -27,10 +30,13 @@ protected override void Down(MigrationBuilder migrationBuilder) var sampleModuleEntityBuilder = new SampleModuleEntityBuilder(migrationBuilder, ActiveDatabase); sampleModuleEntityBuilder.Drop(); - var fileHubEntityBuilder = new FileHubEntityBuilder(migrationBuilder, ActiveDatabase); - fileHubEntityBuilder.Drop(); + var fileEntityBuilder = new EntityBuilders.FileEntityBuilder(migrationBuilder, ActiveDatabase); + fileEntityBuilder.Drop(); var categoryEntityBuilder = new CategoryEntityBuilder(migrationBuilder, ActiveDatabase); categoryEntityBuilder.Drop(); + + var fileCategoryEntityBuilder = new FileCategoryEntityBuilder(migrationBuilder, ActiveDatabase); + fileCategoryEntityBuilder.Drop(); } } diff --git a/Server/Persistence/Migrations/EntityBuilders/FileCategoryEntityBuilder.cs b/Server/Persistence/Migrations/EntityBuilders/FileCategoryEntityBuilder.cs new file mode 100644 index 0000000..7029b3d --- /dev/null +++ b/Server/Persistence/Migrations/EntityBuilders/FileCategoryEntityBuilder.cs @@ -0,0 +1,34 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Persistence.Migrations.EntityBuilders; + +public class FileCategoryEntityBuilder : AuditableBaseEntityBuilder +{ + private const string _entityTableName = "ICTAce_FileHub_FileCategory"; + private readonly PrimaryKey _primaryKey = new("PK_ICTAce_FileHub_FileCategory", x => x.Id); + private readonly ForeignKey _fileForeignKey = new("FK_ICTAce_FileHub_FileCategory_File", x => x.FileId, "ICTAce_FileHub_File", "Id", ReferentialAction.Cascade); + private readonly ForeignKey _categoryForeignKey = new("FK_ICTAce_FileHub_FileCategory_Category", x => x.CategoryId, "ICTAce_FileHub_Category", "Id", ReferentialAction.Restrict); + + public FileCategoryEntityBuilder(MigrationBuilder migrationBuilder, IDatabase database) : base(migrationBuilder, database) + { + EntityTableName = _entityTableName; + PrimaryKey = _primaryKey; + ForeignKeys.Add(_fileForeignKey); + ForeignKeys.Add(_categoryForeignKey); + } + + protected override FileCategoryEntityBuilder BuildTable(ColumnsBuilder table) + { + Id = AddAutoIncrementColumn(table, "Id"); + ModuleId = AddIntegerColumn(table, "ModuleId"); + FileId = AddIntegerColumn(table, "FileId"); + CategoryId = AddIntegerColumn(table, "CategoryId"); + AddAuditableColumns(table); + return this; + } + + public OperationBuilder Id { get; set; } + public OperationBuilder ModuleId { get; set; } + public OperationBuilder FileId { get; set; } + public OperationBuilder CategoryId { get; set; } +} diff --git a/Server/Persistence/Migrations/EntityBuilders/FileHubEntityBuilder.cs b/Server/Persistence/Migrations/EntityBuilders/FileEntityBuilder.cs similarity index 59% rename from Server/Persistence/Migrations/EntityBuilders/FileHubEntityBuilder.cs rename to Server/Persistence/Migrations/EntityBuilders/FileEntityBuilder.cs index 6e55019..52fb9bc 100644 --- a/Server/Persistence/Migrations/EntityBuilders/FileHubEntityBuilder.cs +++ b/Server/Persistence/Migrations/EntityBuilders/FileEntityBuilder.cs @@ -2,25 +2,26 @@ namespace ICTAce.FileHub.Persistence.Migrations.EntityBuilders; -public class FileHubEntityBuilder : AuditableBaseEntityBuilder +public class FileEntityBuilder : AuditableBaseEntityBuilder { - private const string _entityTableName = "ICTAce_FileHub"; - private readonly PrimaryKey _primaryKey = new("PK_ICTAce_FileHub", x => x.Id); - private readonly ForeignKey _moduleForeignKey = new("FK_ICTAce_FileHub_Module", x => x.ModuleId, "Module", "ModuleId", ReferentialAction.Cascade); + private const string _entityTableName = "ICTAce_FileHub_File"; + private readonly PrimaryKey _primaryKey = new("PK_ICTAce_FileHub_File", x => x.Id); + private readonly ForeignKey _moduleForeignKey = new("FK_ICTAce_FileHub_File_Module", x => x.ModuleId, "Module", "ModuleId", ReferentialAction.Cascade); - public FileHubEntityBuilder(MigrationBuilder migrationBuilder, IDatabase database) : base(migrationBuilder, database) + public FileEntityBuilder(MigrationBuilder migrationBuilder, IDatabase database) : base(migrationBuilder, database) { EntityTableName = _entityTableName; PrimaryKey = _primaryKey; ForeignKeys.Add(_moduleForeignKey); } - protected override FileHubEntityBuilder BuildTable(ColumnsBuilder table) + protected override FileEntityBuilder BuildTable(ColumnsBuilder table) { Id = AddAutoIncrementColumn(table, "Id"); ModuleId = AddIntegerColumn(table, "ModuleId"); - Name = AddMaxStringColumn(table, "Name"); + Name = AddStringColumn(table, "Name", 100); FileName = AddStringColumn(table, "FileName", 255); + ImageName = AddStringColumn(table, "ImageName", 255); Description = AddStringColumn(table, "Description", 1000, nullable: true); FileSize = AddStringColumn(table, "FileSize", 12); Downloads = AddIntegerColumn(table, "Downloads"); @@ -32,6 +33,7 @@ protected override FileHubEntityBuilder BuildTable(ColumnsBuilder table) public OperationBuilder ModuleId { get; set; } public OperationBuilder Name { get; set; } public OperationBuilder FileName { get; set; } + public OperationBuilder ImageName { get; set; } public OperationBuilder Description { get; set; } public OperationBuilder FileSize { get; set; } public OperationBuilder Downloads { get; set; }