feat(cdn): Add CDN URL retrieval for public files and enhance caching mechanisms

- Introduced a new endpoint to retrieve CDN URLs for public files, falling back to pre-signed URLs when necessary.
- Enhanced caching for file metadata retrieval in GetFileQueryHandler to improve performance.
- Updated file handling commands to invalidate relevant caches upon file operations.
- Added configuration settings for CDN in appsettings.json to manage CDN behavior.
- Implemented new data models for CDN URL responses and integrated them into the API response structure.
This commit is contained in:
Ho Ngoc Hai
2026-01-14 00:20:31 +07:00
parent 964e33bee6
commit 9c648c2d35
31 changed files with 2476 additions and 5 deletions

View File

@@ -133,6 +133,10 @@ public class ConfirmUploadCommandHandler : IRequestHandler<ConfirmUploadCommand,
if (Guid.TryParse(request.UserId, out var userId))
{
await _cache.DeleteAsync(CacheKeys.UserQuota(userId), cancellationToken);
// EN: Invalidate user files list cache (for search results refresh)
// VI: Invalidate cache danh sách file của user (để refresh kết quả search)
await _cache.DeleteByPatternAsync(CacheKeys.UserFilesPattern(userId), cancellationToken);
}
_logger.LogInformation(

View File

@@ -82,6 +82,9 @@ public class DeleteFileCommandHandler : IRequestHandler<DeleteFileCommand, Delet
await _cache.DeleteAsync(CacheKeys.UserQuota(userId), cancellationToken);
}
// EN: Invalidate file metadata cache / VI: Invalidate file metadata cache
await _cache.DeleteAsync(CacheKeys.FileMetadata(request.FileId), cancellationToken);
_logger.LogInformation("File deleted successfully: {FileId}", request.FileId);
return new DeleteFileResult(true, null);
}

View File

@@ -0,0 +1,36 @@
using MediatR;
using StorageService.Domain.AggregatesModel.FileShareAggregate;
namespace StorageService.API.Application.Commands.FileShare;
/// <summary>
/// EN: Command to create a file share link.
/// VI: Command để tạo link chia sẻ file.
/// </summary>
public record CreateFileShareCommand(
Guid FileId,
string UserId,
SharePermission Permission,
string? SharedWith = null,
string? Password = null,
DateTime? ExpiresAt = null,
int? MaxDownloads = null
) : IRequest<CreateFileShareResult>;
/// <summary>
/// EN: Result of create file share operation.
/// VI: Kết quả của thao tác tạo file share.
/// </summary>
public record CreateFileShareResult(
bool Success,
Guid? ShareId,
string? ShareToken,
string? ShareUrl,
string? Error)
{
public static CreateFileShareResult Ok(Guid shareId, string shareToken, string shareUrl)
=> new(true, shareId, shareToken, shareUrl, null);
public static CreateFileShareResult Fail(string error)
=> new(false, null, null, null, error);
}

View File

@@ -0,0 +1,86 @@
using MediatR;
using StorageService.Domain.AggregatesModel.FileAggregate;
using StorageService.Domain.AggregatesModel.FileShareAggregate;
// EN: Alias to avoid collision with System.IO.FileShare
// VI: Alias để tránh xung đột với System.IO.FileShare
using DomainFileShare = StorageService.Domain.AggregatesModel.FileShareAggregate.FileShare;
namespace StorageService.API.Application.Commands.FileShare;
/// <summary>
/// EN: Handler for CreateFileShareCommand.
/// VI: Handler cho CreateFileShareCommand.
/// </summary>
public class CreateFileShareCommandHandler : IRequestHandler<CreateFileShareCommand, CreateFileShareResult>
{
private readonly IFileRepository _fileRepository;
private readonly IFileShareRepository _fileShareRepository;
private readonly ILogger<CreateFileShareCommandHandler> _logger;
private readonly IConfiguration _configuration;
public CreateFileShareCommandHandler(
IFileRepository fileRepository,
IFileShareRepository fileShareRepository,
ILogger<CreateFileShareCommandHandler> logger,
IConfiguration configuration)
{
_fileRepository = fileRepository;
_fileShareRepository = fileShareRepository;
_logger = logger;
_configuration = configuration;
}
public async Task<CreateFileShareResult> Handle(CreateFileShareCommand request, CancellationToken cancellationToken)
{
try
{
_logger.LogInformation(
"Creating file share for file {FileId} by user {UserId}",
request.FileId, request.UserId);
// EN: Verify file exists and user owns it / VI: Kiểm tra file tồn tại và user sở hữu
var file = await _fileRepository.GetByIdAsync(request.FileId, cancellationToken);
if (file == null)
{
return CreateFileShareResult.Fail("File not found.");
}
if (file.UserId != request.UserId)
{
_logger.LogWarning(
"User {UserId} attempted to share file {FileId} owned by {OwnerId}",
request.UserId, request.FileId, file.UserId);
return CreateFileShareResult.Fail("You don't have permission to share this file.");
}
// EN: Create file share entity / VI: Tạo entity file share
var fileShare = new DomainFileShare(
fileId: request.FileId,
sharedBy: request.UserId,
permission: request.Permission,
sharedWith: request.SharedWith,
password: request.Password,
expiresAt: request.ExpiresAt,
maxDownloads: request.MaxDownloads);
await _fileShareRepository.AddAsync(fileShare, cancellationToken);
await _fileShareRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
// EN: Build share URL / VI: Xây dựng URL chia sẻ
var baseUrl = _configuration["App:BaseUrl"] ?? "http://localhost";
var shareUrl = $"{baseUrl}/api/v1/storage/shares/public/{fileShare.ShareToken}";
_logger.LogInformation(
"File share created successfully: shareId={ShareId}, token={Token}",
fileShare.Id, fileShare.ShareToken);
return CreateFileShareResult.Ok(fileShare.Id, fileShare.ShareToken, shareUrl);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating file share for file {FileId}", request.FileId);
return CreateFileShareResult.Fail("An error occurred while creating the share link.");
}
}
}

View File

@@ -0,0 +1,22 @@
using MediatR;
namespace StorageService.API.Application.Commands.FileShare;
/// <summary>
/// EN: Command to revoke a file share.
/// VI: Command để thu hồi file share.
/// </summary>
public record RevokeFileShareCommand(
Guid ShareId,
string UserId
) : IRequest<RevokeFileShareResult>;
/// <summary>
/// EN: Result of revoke file share operation.
/// VI: Kết quả của thao tác thu hồi file share.
/// </summary>
public record RevokeFileShareResult(bool Success, string? Error)
{
public static RevokeFileShareResult Ok() => new(true, null);
public static RevokeFileShareResult Fail(string error) => new(false, error);
}

View File

@@ -0,0 +1,68 @@
using MediatR;
using StorageService.Domain.AggregatesModel.FileShareAggregate;
namespace StorageService.API.Application.Commands.FileShare;
/// <summary>
/// EN: Handler for RevokeFileShareCommand.
/// VI: Handler cho RevokeFileShareCommand.
/// </summary>
public class RevokeFileShareCommandHandler : IRequestHandler<RevokeFileShareCommand, RevokeFileShareResult>
{
private readonly IFileShareRepository _fileShareRepository;
private readonly ILogger<RevokeFileShareCommandHandler> _logger;
public RevokeFileShareCommandHandler(
IFileShareRepository fileShareRepository,
ILogger<RevokeFileShareCommandHandler> logger)
{
_fileShareRepository = fileShareRepository;
_logger = logger;
}
public async Task<RevokeFileShareResult> Handle(RevokeFileShareCommand request, CancellationToken cancellationToken)
{
try
{
_logger.LogInformation(
"Revoking file share {ShareId} by user {UserId}",
request.ShareId, request.UserId);
// EN: Get the file share / VI: Lấy file share
var fileShare = await _fileShareRepository.GetByIdAsync(request.ShareId, cancellationToken);
if (fileShare == null)
{
return RevokeFileShareResult.Fail("Share not found.");
}
// EN: Verify user owns the share / VI: Kiểm tra user sở hữu share
if (fileShare.SharedBy != request.UserId)
{
_logger.LogWarning(
"User {UserId} attempted to revoke share {ShareId} created by {OwnerId}",
request.UserId, request.ShareId, fileShare.SharedBy);
return RevokeFileShareResult.Fail("You don't have permission to revoke this share.");
}
// EN: Check if already revoked / VI: Kiểm tra đã thu hồi chưa
if (fileShare.Status == FileShareStatus.Revoked)
{
return RevokeFileShareResult.Ok(); // EN: Idempotent / VI: Idempotent
}
// EN: Revoke the share / VI: Thu hồi share
fileShare.Revoke();
_fileShareRepository.Update(fileShare);
await _fileShareRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("File share {ShareId} revoked successfully", request.ShareId);
return RevokeFileShareResult.Ok();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error revoking file share {ShareId}", request.ShareId);
return RevokeFileShareResult.Fail("An error occurred while revoking the share.");
}
}
}

View File

@@ -8,6 +8,7 @@ namespace StorageService.API.Application.Queries;
/// </summary>
public record FileDto(
Guid Id,
string UserId,
string FileName,
string ContentType,
long FileSizeBytes,
@@ -57,6 +58,7 @@ public static class FileDtoMapper
{
public static FileDto ToDto(this StorageFile file) => new(
file.Id,
file.UserId,
file.FileName,
file.ContentType,
file.FileSizeBytes,

View File

@@ -12,20 +12,46 @@ namespace StorageService.API.Application.Queries;
public class GetFileQueryHandler : IRequestHandler<GetFileQuery, FileDto?>
{
private readonly IFileRepository _fileRepository;
private readonly Infrastructure.Caching.IRedisCacheService _cache;
private readonly ILogger<GetFileQueryHandler> _logger;
public GetFileQueryHandler(IFileRepository fileRepository)
// EN: Cache TTL for file metadata (10 minutes)
// VI: TTL cache cho file metadata (10 phút)
private static readonly TimeSpan FileCacheTtl = TimeSpan.FromMinutes(10);
public GetFileQueryHandler(
IFileRepository fileRepository,
Infrastructure.Caching.IRedisCacheService cache,
ILogger<GetFileQueryHandler> logger)
{
_fileRepository = fileRepository;
_cache = cache;
_logger = logger;
}
public async Task<FileDto?> Handle(GetFileQuery request, CancellationToken cancellationToken)
{
var file = await _fileRepository.GetByIdAsync(request.FileId, cancellationToken);
// EN: Try to get from cache first
// VI: Thử lấy từ cache trước
var cacheKey = Infrastructure.Caching.CacheKeys.FileMetadata(request.FileId);
if (file == null || file.UserId != request.UserId)
var cachedResult = await _cache.GetOrSetAsync(
cacheKey,
async () =>
{
_logger.LogDebug("Cache miss for file {FileId}, fetching from database", request.FileId);
var file = await _fileRepository.GetByIdAsync(request.FileId, cancellationToken);
return file?.ToDto();
},
FileCacheTtl,
cancellationToken);
// EN: Check ownership after cache retrieval
// VI: Kiểm tra quyền sở hữu sau khi lấy từ cache
if (cachedResult == null || cachedResult.UserId != request.UserId)
return null;
return file.ToDto();
return cachedResult;
}
}
@@ -36,14 +62,24 @@ public class GetFileQueryHandler : IRequestHandler<GetFileQuery, FileDto?>
public class GetUserFilesQueryHandler : IRequestHandler<GetUserFilesQuery, UserFilesResult>
{
private readonly IFileRepository _fileRepository;
private readonly ILogger<GetUserFilesQueryHandler> _logger;
public GetUserFilesQueryHandler(IFileRepository fileRepository)
public GetUserFilesQueryHandler(
IFileRepository fileRepository,
ILogger<GetUserFilesQueryHandler> logger)
{
_fileRepository = fileRepository;
_logger = logger;
}
public async Task<UserFilesResult> Handle(GetUserFilesQuery request, CancellationToken cancellationToken)
{
// EN: Note: Not caching this query because search/pagination params vary too much
// VI: Lưu ý: Không cache query này vì search/pagination params thay đổi quá nhiều
_logger.LogDebug(
"Fetching files for user {UserId}, skip={Skip}, take={Take}, search={SearchTerm}",
request.UserId, request.Skip, request.Take, request.SearchTerm);
IEnumerable<StorageFile> files;
if (!string.IsNullOrWhiteSpace(request.SearchTerm))

View File

@@ -0,0 +1,61 @@
using MediatR;
using StorageService.Domain.AggregatesModel.FileShareAggregate;
namespace StorageService.API.Application.Queries;
#region DTOs
/// <summary>
/// EN: DTO for file share.
/// VI: DTO cho file share.
/// </summary>
public record FileShareDto(
Guid Id,
Guid FileId,
string SharedBy,
string? SharedWith,
string Permission,
string ShareToken,
bool HasPassword,
DateTime? ExpiresAt,
int? MaxDownloads,
int DownloadCount,
string Status,
DateTime CreatedAt);
/// <summary>
/// EN: Result of accessing a shared file.
/// VI: Kết quả truy cập file được chia sẻ.
/// </summary>
public record SharedFileAccessResult(
bool Success,
string? DownloadUrl,
string? FileName,
string? ContentType,
long? FileSizeBytes,
string? Error)
{
public static SharedFileAccessResult Ok(string downloadUrl, string fileName, string contentType, long fileSizeBytes)
=> new(true, downloadUrl, fileName, contentType, fileSizeBytes, null);
public static SharedFileAccessResult Fail(string error)
=> new(false, null, null, null, null, error);
}
#endregion
#region Queries
/// <summary>
/// EN: Query to get all shares for a file.
/// VI: Query để lấy tất cả shares của một file.
/// </summary>
public record GetFileSharesQuery(Guid FileId, string UserId) : IRequest<IEnumerable<FileShareDto>>;
/// <summary>
/// EN: Query to access a shared file by token.
/// VI: Query để truy cập file được chia sẻ bằng token.
/// </summary>
public record AccessSharedFileQuery(string Token, string? Password = null) : IRequest<SharedFileAccessResult>;
#endregion

View File

@@ -0,0 +1,143 @@
using MediatR;
using StorageService.Domain.AggregatesModel.FileAggregate;
using StorageService.Domain.AggregatesModel.FileShareAggregate;
using StorageService.Infrastructure.Storage;
namespace StorageService.API.Application.Queries;
/// <summary>
/// EN: Handler for GetFileSharesQuery.
/// VI: Handler cho GetFileSharesQuery.
/// </summary>
public class GetFileSharesQueryHandler : IRequestHandler<GetFileSharesQuery, IEnumerable<FileShareDto>>
{
private readonly IFileShareRepository _fileShareRepository;
private readonly IFileRepository _fileRepository;
private readonly ILogger<GetFileSharesQueryHandler> _logger;
public GetFileSharesQueryHandler(
IFileShareRepository fileShareRepository,
IFileRepository fileRepository,
ILogger<GetFileSharesQueryHandler> logger)
{
_fileShareRepository = fileShareRepository;
_fileRepository = fileRepository;
_logger = logger;
}
public async Task<IEnumerable<FileShareDto>> Handle(GetFileSharesQuery request, CancellationToken cancellationToken)
{
_logger.LogDebug("Getting shares for file {FileId} by user {UserId}", request.FileId, request.UserId);
// EN: Verify user owns the file / VI: Kiểm tra user sở hữu file
var file = await _fileRepository.GetByIdAsync(request.FileId, cancellationToken);
if (file == null || file.UserId != request.UserId)
{
return Enumerable.Empty<FileShareDto>();
}
var shares = await _fileShareRepository.GetByFileIdAsync(request.FileId, cancellationToken);
return shares.Select(s => new FileShareDto(
Id: s.Id,
FileId: s.FileId,
SharedBy: s.SharedBy,
SharedWith: s.SharedWith,
Permission: s.Permission.ToString(),
ShareToken: s.ShareToken,
HasPassword: !string.IsNullOrEmpty(s.PasswordHash),
ExpiresAt: s.ExpiresAt,
MaxDownloads: s.MaxDownloads,
DownloadCount: s.DownloadCount,
Status: s.Status.ToString(),
CreatedAt: s.CreatedAt));
}
}
/// <summary>
/// EN: Handler for AccessSharedFileQuery - public access via share token.
/// VI: Handler cho AccessSharedFileQuery - truy cập công khai qua share token.
/// </summary>
public class AccessSharedFileQueryHandler : IRequestHandler<AccessSharedFileQuery, SharedFileAccessResult>
{
private readonly IFileShareRepository _fileShareRepository;
private readonly IFileRepository _fileRepository;
private readonly IStorageProviderFactory _storageProviderFactory;
private readonly ILogger<AccessSharedFileQueryHandler> _logger;
public AccessSharedFileQueryHandler(
IFileShareRepository fileShareRepository,
IFileRepository fileRepository,
IStorageProviderFactory storageProviderFactory,
ILogger<AccessSharedFileQueryHandler> logger)
{
_fileShareRepository = fileShareRepository;
_fileRepository = fileRepository;
_storageProviderFactory = storageProviderFactory;
_logger = logger;
}
public async Task<SharedFileAccessResult> Handle(AccessSharedFileQuery request, CancellationToken cancellationToken)
{
try
{
_logger.LogInformation("Accessing shared file with token: {Token}", request.Token[..Math.Min(8, request.Token.Length)] + "...");
// EN: Get file share by token / VI: Lấy file share bằng token
var fileShare = await _fileShareRepository.GetByTokenAsync(request.Token, cancellationToken);
if (fileShare == null)
{
_logger.LogWarning("Share token not found: {Token}", request.Token[..Math.Min(8, request.Token.Length)] + "...");
return SharedFileAccessResult.Fail("Share link not found or has expired.");
}
// EN: Validate share is still valid / VI: Kiểm tra share còn hợp lệ
if (!fileShare.IsValid())
{
_logger.LogWarning("Share {ShareId} is no longer valid, status: {Status}", fileShare.Id, fileShare.Status);
return SharedFileAccessResult.Fail($"Share link is {fileShare.Status.ToString().ToLower()}.");
}
// EN: Validate password if required / VI: Kiểm tra mật khẩu nếu cần
if (!fileShare.ValidatePassword(request.Password))
{
return SharedFileAccessResult.Fail("Invalid password.");
}
// EN: Get the file / VI: Lấy file
var file = await _fileRepository.GetByIdAsync(fileShare.FileId, cancellationToken);
if (file == null)
{
return SharedFileAccessResult.Fail("File not found.");
}
// EN: Generate pre-signed download URL / VI: Tạo pre-signed URL để tải xuống
var provider = _storageProviderFactory.GetProvider(file.Provider);
var downloadUrl = await provider.GetPreSignedDownloadUrlAsync(
file.BucketName,
file.ObjectKey,
3600, // EN: 1 hour expiration / VI: Hết hạn sau 1 giờ
cancellationToken);
// EN: Increment download count / VI: Tăng số lần tải xuống
fileShare.IncrementDownloadCount();
_fileShareRepository.Update(fileShare);
await _fileShareRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation(
"Shared file accessed successfully: shareId={ShareId}, fileId={FileId}, downloadCount={Count}",
fileShare.Id, file.Id, fileShare.DownloadCount);
return SharedFileAccessResult.Ok(
downloadUrl,
file.FileName,
file.ContentType,
file.FileSizeBytes);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error accessing shared file with token");
return SharedFileAccessResult.Fail("An error occurred while accessing the shared file.");
}
}
}

View File

@@ -0,0 +1,208 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using StorageService.API.Application.Commands.FileShare;
using StorageService.API.Application.Queries;
using StorageService.Domain.AggregatesModel.FileShareAggregate;
using Swashbuckle.AspNetCore.Annotations;
using System.Security.Claims;
namespace StorageService.API.Controllers;
/// <summary>
/// EN: Controller for file sharing operations.
/// VI: Controller cho các thao tác chia sẻ file.
/// </summary>
[ApiController]
[Route("api/v1/storage")]
[Produces("application/json")]
public class FileSharingController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<FileSharingController> _logger;
public FileSharingController(IMediator mediator, ILogger<FileSharingController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Create a share link for a file.
/// VI: Tạo link chia sẻ cho file.
/// </summary>
[HttpPost("files/{fileId:guid}/shares")]
[Authorize]
[SwaggerOperation(Summary = "Create file share", Description = "Create a share link for a file")]
[SwaggerResponse(201, "Share created successfully", typeof(ApiResponse<CreateShareResponse>))]
[SwaggerResponse(400, "Bad request")]
[SwaggerResponse(401, "Unauthorized")]
[SwaggerResponse(404, "File not found")]
public async Task<ActionResult<ApiResponse<CreateShareResponse>>> CreateShare(
[FromRoute] Guid fileId,
[FromBody] CreateShareRequest request)
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
{
return Unauthorized(new ApiResponse<CreateShareResponse> { Success = false, Error = "User ID not found" });
}
// EN: Parse permission / VI: Parse permission
if (!Enum.TryParse<SharePermission>(request.Permission, true, out var permission))
{
return BadRequest(new ApiResponse<CreateShareResponse>
{
Success = false,
Error = "Invalid permission. Valid values: View, Download, Edit, Admin"
});
}
var command = new CreateFileShareCommand(
FileId: fileId,
UserId: userId,
Permission: permission,
SharedWith: request.SharedWith,
Password: request.Password,
ExpiresAt: request.ExpiresAt,
MaxDownloads: request.MaxDownloads);
var result = await _mediator.Send(command);
if (!result.Success)
{
if (result.Error == "File not found.")
return NotFound(new ApiResponse<CreateShareResponse> { Success = false, Error = result.Error });
return BadRequest(new ApiResponse<CreateShareResponse> { Success = false, Error = result.Error });
}
return CreatedAtAction(
nameof(GetFileShares),
new { fileId },
new ApiResponse<CreateShareResponse>
{
Success = true,
Data = new CreateShareResponse(
result.ShareId!.Value,
result.ShareToken!,
result.ShareUrl!)
});
}
/// <summary>
/// EN: Get all shares for a file.
/// VI: Lấy tất cả shares của một file.
/// </summary>
[HttpGet("files/{fileId:guid}/shares")]
[Authorize]
[SwaggerOperation(Summary = "Get file shares", Description = "Get all share links for a file")]
[SwaggerResponse(200, "Shares retrieved", typeof(ApiResponse<IEnumerable<FileShareDto>>))]
[SwaggerResponse(401, "Unauthorized")]
public async Task<ActionResult<ApiResponse<IEnumerable<FileShareDto>>>> GetFileShares([FromRoute] Guid fileId)
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
{
return Unauthorized(new ApiResponse<IEnumerable<FileShareDto>> { Success = false, Error = "User ID not found" });
}
var query = new GetFileSharesQuery(fileId, userId);
var shares = await _mediator.Send(query);
return Ok(new ApiResponse<IEnumerable<FileShareDto>>
{
Success = true,
Data = shares
});
}
/// <summary>
/// EN: Revoke a file share.
/// VI: Thu hồi một file share.
/// </summary>
[HttpDelete("shares/{shareId:guid}")]
[Authorize]
[SwaggerOperation(Summary = "Revoke share", Description = "Revoke a share link")]
[SwaggerResponse(204, "Share revoked")]
[SwaggerResponse(401, "Unauthorized")]
[SwaggerResponse(404, "Share not found")]
public async Task<IActionResult> RevokeShare([FromRoute] Guid shareId)
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
{
return Unauthorized(new ApiResponse<object> { Success = false, Error = "User ID not found" });
}
var command = new RevokeFileShareCommand(shareId, userId);
var result = await _mediator.Send(command);
if (!result.Success)
{
if (result.Error == "Share not found.")
return NotFound(new ApiResponse<object> { Success = false, Error = result.Error });
return BadRequest(new ApiResponse<object> { Success = false, Error = result.Error });
}
return NoContent();
}
/// <summary>
/// EN: Access a shared file by token (public endpoint).
/// VI: Truy cập file được chia sẻ bằng token (endpoint công khai).
/// </summary>
[HttpGet("shares/public/{token}")]
[AllowAnonymous]
[SwaggerOperation(Summary = "Access shared file", Description = "Access a shared file using share token (public)")]
[SwaggerResponse(200, "File access granted", typeof(ApiResponse<SharedFileAccessResult>))]
[SwaggerResponse(400, "Invalid password")]
[SwaggerResponse(404, "Share not found or expired")]
public async Task<ActionResult<ApiResponse<SharedFileAccessResult>>> AccessSharedFile(
[FromRoute] string token,
[FromQuery] string? password = null)
{
var query = new AccessSharedFileQuery(token, password);
var result = await _mediator.Send(query);
if (!result.Success)
{
if (result.Error!.Contains("not found") || result.Error.Contains("expired"))
return NotFound(new ApiResponse<SharedFileAccessResult> { Success = false, Error = result.Error });
return BadRequest(new ApiResponse<SharedFileAccessResult> { Success = false, Error = result.Error });
}
return Ok(new ApiResponse<SharedFileAccessResult>
{
Success = true,
Data = result
});
}
}
#region Request/Response DTOs
/// <summary>
/// EN: Request to create a file share.
/// VI: Request tạo file share.
/// </summary>
public record CreateShareRequest(
string Permission = "Download",
string? SharedWith = null,
string? Password = null,
DateTime? ExpiresAt = null,
int? MaxDownloads = null);
/// <summary>
/// EN: Response for created share.
/// VI: Response cho share đã tạo.
/// </summary>
public record CreateShareResponse(
Guid ShareId,
string ShareToken,
string ShareUrl);
#endregion

View File

@@ -0,0 +1,223 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using StorageService.Domain.AggregatesModel.FileAggregate;
using StorageService.Domain.AggregatesModel.FileVersionAggregate;
using StorageService.Infrastructure.Storage;
using Swashbuckle.AspNetCore.Annotations;
using System.Security.Claims;
namespace StorageService.API.Controllers;
/// <summary>
/// EN: Controller for file versioning operations.
/// VI: Controller cho các thao tác versioning file.
/// </summary>
[ApiController]
[Route("api/v1/storage/files/{fileId:guid}/versions")]
[Authorize]
[Produces("application/json")]
public class FileVersioningController : ControllerBase
{
private readonly IFileRepository _fileRepository;
private readonly IFileVersionRepository _fileVersionRepository;
private readonly IStorageProviderFactory _storageProviderFactory;
private readonly ILogger<FileVersioningController> _logger;
public FileVersioningController(
IFileRepository fileRepository,
IFileVersionRepository fileVersionRepository,
IStorageProviderFactory storageProviderFactory,
ILogger<FileVersioningController> logger)
{
_fileRepository = fileRepository;
_fileVersionRepository = fileVersionRepository;
_storageProviderFactory = storageProviderFactory;
_logger = logger;
}
/// <summary>
/// EN: Get all versions of a file.
/// VI: Lấy tất cả phiên bản của file.
/// </summary>
[HttpGet]
[SwaggerOperation(Summary = "Get file versions", Description = "Get all versions of a file")]
[SwaggerResponse(200, "Versions retrieved", typeof(ApiResponse<IEnumerable<FileVersionDto>>))]
[SwaggerResponse(404, "File not found")]
public async Task<ActionResult<ApiResponse<IEnumerable<FileVersionDto>>>> GetVersions([FromRoute] Guid fileId)
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
{
return Unauthorized(new ApiResponse<IEnumerable<FileVersionDto>> { Success = false, Error = "User ID not found" });
}
// EN: Verify file exists and user owns it / VI: Kiểm tra file tồn tại và user sở hữu
var file = await _fileRepository.GetByIdAsync(fileId);
if (file == null || file.UserId != userId)
{
return NotFound(new ApiResponse<IEnumerable<FileVersionDto>> { Success = false, Error = "File not found" });
}
var versions = await _fileVersionRepository.GetByFileIdAsync(fileId);
var versionDtos = versions.Select(v => new FileVersionDto(
v.Id,
v.FileId,
v.VersionNumber,
v.SizeBytes,
v.ContentType,
v.Checksum,
v.CreatedAt,
v.CreatedBy,
v.Comment,
v.IsCurrent));
return Ok(new ApiResponse<IEnumerable<FileVersionDto>>
{
Success = true,
Data = versionDtos
});
}
/// <summary>
/// EN: Get download URL for a specific version.
/// VI: Lấy URL tải xuống cho phiên bản cụ thể.
/// </summary>
[HttpGet("{versionId:guid}/download")]
[SwaggerOperation(Summary = "Download version", Description = "Get download URL for a specific version")]
[SwaggerResponse(200, "Download URL generated", typeof(ApiResponse<DownloadVersionResponse>))]
[SwaggerResponse(404, "Version not found")]
public async Task<ActionResult<ApiResponse<DownloadVersionResponse>>> DownloadVersion(
[FromRoute] Guid fileId,
[FromRoute] Guid versionId)
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
{
return Unauthorized(new ApiResponse<DownloadVersionResponse> { Success = false, Error = "User ID not found" });
}
// EN: Verify file exists and user owns it / VI: Kiểm tra file tồn tại và user sở hữu
var file = await _fileRepository.GetByIdAsync(fileId);
if (file == null || file.UserId != userId)
{
return NotFound(new ApiResponse<DownloadVersionResponse> { Success = false, Error = "File not found" });
}
var version = await _fileVersionRepository.GetByIdAsync(versionId);
if (version == null || version.FileId != fileId)
{
return NotFound(new ApiResponse<DownloadVersionResponse> { Success = false, Error = "Version not found" });
}
// EN: Generate pre-signed URL / VI: Tạo pre-signed URL
var provider = _storageProviderFactory.GetProvider(file.Provider);
var downloadUrl = await provider.GetPreSignedDownloadUrlAsync(
file.BucketName,
version.ObjectKey,
3600); // 1 hour
return Ok(new ApiResponse<DownloadVersionResponse>
{
Success = true,
Data = new DownloadVersionResponse(downloadUrl, version.VersionNumber, file.FileName)
});
}
/// <summary>
/// EN: Restore a file to a specific version.
/// VI: Khôi phục file về phiên bản cụ thể.
/// </summary>
[HttpPost("{versionId:guid}/restore")]
[SwaggerOperation(Summary = "Restore version", Description = "Restore file to a specific version")]
[SwaggerResponse(200, "Version restored", typeof(ApiResponse<RestoreVersionResponse>))]
[SwaggerResponse(404, "Version not found")]
public async Task<ActionResult<ApiResponse<RestoreVersionResponse>>> RestoreVersion(
[FromRoute] Guid fileId,
[FromRoute] Guid versionId)
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
{
return Unauthorized(new ApiResponse<RestoreVersionResponse> { Success = false, Error = "User ID not found" });
}
// EN: Verify file exists and user owns it / VI: Kiểm tra file tồn tại và user sở hữu
var file = await _fileRepository.GetByIdAsync(fileId);
if (file == null || file.UserId != userId)
{
return NotFound(new ApiResponse<RestoreVersionResponse> { Success = false, Error = "File not found" });
}
var versionToRestore = await _fileVersionRepository.GetByIdAsync(versionId);
if (versionToRestore == null || versionToRestore.FileId != fileId)
{
return NotFound(new ApiResponse<RestoreVersionResponse> { Success = false, Error = "Version not found" });
}
// EN: Unmark current version / VI: Bỏ đánh dấu version hiện tại
var currentVersion = await _fileVersionRepository.GetCurrentVersionAsync(fileId);
if (currentVersion != null)
{
currentVersion.UnmarkAsCurrent();
_fileVersionRepository.Update(currentVersion);
}
// EN: Mark restored version as current / VI: Đánh dấu version được khôi phục là hiện tại
versionToRestore.MarkAsCurrent();
_fileVersionRepository.Update(versionToRestore);
// EN: Update file metadata with restored version's data / VI: Cập nhật metadata file với dữ liệu của version được khôi phục
file.UpdateFromVersion(versionToRestore.ObjectKey, versionToRestore.SizeBytes, versionToRestore.ContentType);
_fileRepository.Update(file);
await _fileVersionRepository.UnitOfWork.SaveEntitiesAsync();
_logger.LogInformation(
"File {FileId} restored to version {VersionNumber}",
fileId, versionToRestore.VersionNumber);
return Ok(new ApiResponse<RestoreVersionResponse>
{
Success = true,
Data = new RestoreVersionResponse(versionToRestore.VersionNumber, "File restored successfully")
});
}
}
#region DTOs
/// <summary>
/// EN: DTO for file version.
/// VI: DTO cho phiên bản file.
/// </summary>
public record FileVersionDto(
Guid Id,
Guid FileId,
int VersionNumber,
long SizeBytes,
string ContentType,
string? Checksum,
DateTime CreatedAt,
string CreatedBy,
string? Comment,
bool IsCurrent);
/// <summary>
/// EN: Response for version download.
/// VI: Response cho tải xuống version.
/// </summary>
public record DownloadVersionResponse(
string DownloadUrl,
int VersionNumber,
string FileName);
/// <summary>
/// EN: Response for version restore.
/// VI: Response cho khôi phục version.
/// </summary>
public record RestoreVersionResponse(
int RestoredVersionNumber,
string Message);
#endregion

View File

@@ -179,6 +179,69 @@ public class FilesController : ControllerBase
return Ok(new ApiResponse<DeleteFileResult> { Success = true, Data = result });
}
/// <summary>
/// EN: Get CDN URL for public files.
/// VI: Lấy URL CDN cho public files.
/// </summary>
/// <remarks>
/// EN: Returns CDN URL if file is public and CDN is enabled, otherwise returns pre-signed URL.
/// VI: Trả về CDN URL nếu file là public và CDN đang bật, ngược lại trả về pre-signed URL.
/// </remarks>
[HttpGet("{fileId:guid}/cdn-url")]
[Authorize]
[SwaggerOperation(Summary = "Get CDN URL", Description = "Get CDN URL for public file or fallback to pre-signed URL")]
[SwaggerResponse(200, "URL generated successfully")]
[SwaggerResponse(404, "File not found")]
public async Task<ActionResult<ApiResponse<CDNUrlResponse>>> GetCDNUrl(
Guid fileId,
CancellationToken cancellationToken = default)
{
var userId = GetUserId();
if (string.IsNullOrEmpty(userId))
return Unauthorized(new ApiResponse<CDNUrlResponse> { Success = false, Error = "User ID not found" });
// EN: Get file metadata / VI: Lấy metadata file
var query = new GetFileQuery(fileId, userId);
var file = await _mediator.Send(query, cancellationToken);
if (file == null)
return NotFound(new ApiResponse<CDNUrlResponse> { Success = false, Error = "File not found" });
// EN: Try to get CDN URL for public files / VI: Thử lấy CDN URL cho public files
var cdnService = HttpContext.RequestServices.GetService<Infrastructure.CDN.ICDNService>();
if (cdnService?.IsEnabled == true && file.AccessLevel == "Public")
{
// EN: Need to get object key - for now use file ID pattern
// VI: Cần lấy object key - tạm thời dùng pattern từ file ID
// Note: In production, we should store object_key in FileDto
var cdnUrl = cdnService.GetCDNUrl($"public/{file.UserId}/{file.Id}",
Domain.AggregatesModel.FileAggregate.FileAccessLevel.Public);
if (cdnUrl != null)
{
return Ok(new ApiResponse<CDNUrlResponse>
{
Success = true,
Data = new CDNUrlResponse(cdnUrl, true, "CDN URL for public file")
});
}
}
// EN: Fallback to pre-signed URL / VI: Fallback sang pre-signed URL
var downloadQuery = new GetDownloadUrlQuery(fileId, userId, 3600);
var downloadResult = await _mediator.Send(downloadQuery, cancellationToken);
if (!downloadResult.Success)
return BadRequest(new ApiResponse<CDNUrlResponse> { Success = false, Error = downloadResult.Error });
return Ok(new ApiResponse<CDNUrlResponse>
{
Success = true,
Data = new CDNUrlResponse(downloadResult.Url!, false, "Pre-signed URL (CDN not available or file not public)")
});
}
private string? GetUserId() => User.FindFirstValue(ClaimTypes.NameIdentifier);
}
@@ -192,3 +255,12 @@ public class ApiResponse<T>
public T? Data { get; set; }
public string? Error { get; set; }
}
/// <summary>
/// EN: CDN URL response.
/// VI: Response URL CDN.
/// </summary>
public record CDNUrlResponse(
string Url,
bool IsCDN,
string Description);

View File

@@ -0,0 +1,253 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using StorageService.Domain.AggregatesModel.FolderAggregate;
using Swashbuckle.AspNetCore.Annotations;
using System.Security.Claims;
namespace StorageService.API.Controllers;
/// <summary>
/// EN: Controller for folder operations.
/// VI: Controller cho các thao tác folder.
/// </summary>
/// <remarks>
/// EN: CRITICAL: Folders are LOGICAL only. Storage uses flat UUID keys.
/// VI: QUAN TRỌNG: Folders chỉ là LOGIC. Storage dùng flat UUID keys.
/// </remarks>
[ApiController]
[Route("api/v1/storage/folders")]
[Authorize]
[Produces("application/json")]
public class FoldersController : ControllerBase
{
private readonly IFolderRepository _folderRepository;
private readonly ILogger<FoldersController> _logger;
public FoldersController(
IFolderRepository folderRepository,
ILogger<FoldersController> logger)
{
_folderRepository = folderRepository;
_logger = logger;
}
/// <summary>
/// EN: Create a new folder.
/// VI: Tạo folder mới.
/// </summary>
[HttpPost]
[SwaggerOperation(Summary = "Create folder", Description = "Create a new folder")]
[SwaggerResponse(201, "Folder created", typeof(ApiResponse<FolderDto>))]
[SwaggerResponse(400, "Bad request")]
[SwaggerResponse(409, "Folder already exists")]
public async Task<ActionResult<ApiResponse<FolderDto>>> CreateFolder([FromBody] CreateFolderRequest request)
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
{
return Unauthorized(new ApiResponse<FolderDto> { Success = false, Error = "User ID not found" });
}
// EN: Check if folder name already exists / VI: Kiểm tra tên folder đã tồn tại
if (await _folderRepository.ExistsAsync(userId, request.ParentId, request.Name))
{
return Conflict(new ApiResponse<FolderDto>
{
Success = false,
Error = "A folder with this name already exists in the selected location"
});
}
Folder folder;
if (request.ParentId == null)
{
folder = Folder.CreateRoot(userId, request.Name);
}
else
{
var parent = await _folderRepository.GetByIdAsync(request.ParentId.Value);
if (parent == null || parent.UserId != userId)
{
return NotFound(new ApiResponse<FolderDto> { Success = false, Error = "Parent folder not found" });
}
folder = parent.CreateChild(request.Name);
}
await _folderRepository.AddAsync(folder);
await _folderRepository.UnitOfWork.SaveEntitiesAsync();
_logger.LogInformation("Folder created: {FolderId} by user {UserId}", folder.Id, userId);
return CreatedAtAction(
nameof(GetFolder),
new { folderId = folder.Id },
new ApiResponse<FolderDto> { Success = true, Data = ToDto(folder) });
}
/// <summary>
/// EN: Get all root folders.
/// VI: Lấy tất cả root folders.
/// </summary>
[HttpGet]
[SwaggerOperation(Summary = "Get folders", Description = "Get all root folders or children of a parent")]
[SwaggerResponse(200, "Folders retrieved", typeof(ApiResponse<IEnumerable<FolderDto>>))]
public async Task<ActionResult<ApiResponse<IEnumerable<FolderDto>>>> GetFolders([FromQuery] Guid? parentId = null)
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
{
return Unauthorized(new ApiResponse<IEnumerable<FolderDto>> { Success = false, Error = "User ID not found" });
}
IEnumerable<Folder> folders;
if (parentId == null)
{
folders = await _folderRepository.GetRootFoldersAsync(userId);
}
else
{
var parent = await _folderRepository.GetByIdAsync(parentId.Value);
if (parent == null || parent.UserId != userId)
{
return NotFound(new ApiResponse<IEnumerable<FolderDto>> { Success = false, Error = "Parent folder not found" });
}
folders = await _folderRepository.GetChildFoldersAsync(parentId.Value);
}
return Ok(new ApiResponse<IEnumerable<FolderDto>>
{
Success = true,
Data = folders.Select(ToDto)
});
}
/// <summary>
/// EN: Get folder by ID.
/// VI: Lấy folder theo ID.
/// </summary>
[HttpGet("{folderId:guid}")]
[SwaggerOperation(Summary = "Get folder", Description = "Get folder details by ID")]
[SwaggerResponse(200, "Folder retrieved", typeof(ApiResponse<FolderDto>))]
[SwaggerResponse(404, "Folder not found")]
public async Task<ActionResult<ApiResponse<FolderDto>>> GetFolder([FromRoute] Guid folderId)
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
{
return Unauthorized(new ApiResponse<FolderDto> { Success = false, Error = "User ID not found" });
}
var folder = await _folderRepository.GetByIdAsync(folderId);
if (folder == null || folder.UserId != userId)
{
return NotFound(new ApiResponse<FolderDto> { Success = false, Error = "Folder not found" });
}
return Ok(new ApiResponse<FolderDto> { Success = true, Data = ToDto(folder) });
}
/// <summary>
/// EN: Rename folder. O(1) operation.
/// VI: Đổi tên folder. O(1).
/// </summary>
[HttpPut("{folderId:guid}")]
[SwaggerOperation(Summary = "Rename folder", Description = "Rename a folder (O(1) operation - database only)")]
[SwaggerResponse(200, "Folder renamed", typeof(ApiResponse<FolderDto>))]
[SwaggerResponse(404, "Folder not found")]
[SwaggerResponse(409, "Folder name already exists")]
public async Task<ActionResult<ApiResponse<FolderDto>>> RenameFolder(
[FromRoute] Guid folderId,
[FromBody] RenameFolderRequest request)
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
{
return Unauthorized(new ApiResponse<FolderDto> { Success = false, Error = "User ID not found" });
}
var folder = await _folderRepository.GetByIdAsync(folderId);
if (folder == null || folder.UserId != userId)
{
return NotFound(new ApiResponse<FolderDto> { Success = false, Error = "Folder not found" });
}
// EN: Check if new name already exists / VI: Kiểm tra tên mới đã tồn tại
if (folder.Name != request.Name &&
await _folderRepository.ExistsAsync(userId, folder.ParentId, request.Name))
{
return Conflict(new ApiResponse<FolderDto>
{
Success = false,
Error = "A folder with this name already exists"
});
}
folder.Rename(request.Name);
_folderRepository.Update(folder);
await _folderRepository.UnitOfWork.SaveEntitiesAsync();
_logger.LogInformation("Folder renamed: {FolderId} to {NewName}", folderId, request.Name);
return Ok(new ApiResponse<FolderDto> { Success = true, Data = ToDto(folder) });
}
/// <summary>
/// EN: Delete folder.
/// VI: Xóa folder.
/// </summary>
[HttpDelete("{folderId:guid}")]
[SwaggerOperation(Summary = "Delete folder", Description = "Soft delete a folder")]
[SwaggerResponse(204, "Folder deleted")]
[SwaggerResponse(404, "Folder not found")]
public async Task<IActionResult> DeleteFolder([FromRoute] Guid folderId)
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
{
return Unauthorized(new ApiResponse<object> { Success = false, Error = "User ID not found" });
}
var folder = await _folderRepository.GetByIdAsync(folderId);
if (folder == null || folder.UserId != userId)
{
return NotFound(new ApiResponse<object> { Success = false, Error = "Folder not found" });
}
folder.Delete();
_folderRepository.Update(folder);
await _folderRepository.UnitOfWork.SaveEntitiesAsync();
_logger.LogInformation("Folder deleted: {FolderId}", folderId);
return NoContent();
}
private static FolderDto ToDto(Folder folder) => new(
folder.Id,
folder.ParentId,
folder.Name,
folder.Path,
folder.Level,
folder.CreatedAt,
folder.UpdatedAt);
}
#region DTOs
/// <summary>EN: Folder DTO / VI: DTO Folder</summary>
public record FolderDto(
Guid Id,
Guid? ParentId,
string Name,
string Path,
int Level,
DateTime CreatedAt,
DateTime UpdatedAt);
/// <summary>EN: Request to create folder / VI: Request tạo folder</summary>
public record CreateFolderRequest(string Name, Guid? ParentId = null);
/// <summary>EN: Request to rename folder / VI: Request đổi tên folder</summary>
public record RenameFolderRequest(string Name);
#endregion

View File

@@ -66,5 +66,13 @@
"AccessTokenExpiryMinutes": 15,
"RefreshTokenExpiryDays": 7
},
"CDN": {
"Enabled": false,
"BaseUrl": "",
"ImageCacheTtlSeconds": 2592000,
"VideoCacheTtlSeconds": 2592000,
"DocumentCacheTtlSeconds": 604800,
"DefaultCacheTtlSeconds": 86400
},
"AllowedHosts": "*"
}

View File

@@ -137,4 +137,18 @@ public class StorageFile : Entity, IAggregateRoot
ExpiresAt = expiresAt;
}
/// <summary>
/// EN: Update file from a restored version.
/// VI: Cập nhật file từ version được khôi phục.
/// </summary>
public void UpdateFromVersion(string objectKey, long sizeBytes, string contentType)
{
if (IsDeleted)
throw new InvalidOperationException("Cannot update deleted file");
ObjectKey = objectKey;
FileSizeBytes = sizeBytes;
ContentType = contentType;
}
}

View File

@@ -0,0 +1,190 @@
using System.Security.Cryptography;
using StorageService.Domain.SeedWork;
namespace StorageService.Domain.AggregatesModel.FileShareAggregate;
/// <summary>
/// EN: File share aggregate root - represents a share link for a file.
/// VI: FileShare aggregate root - đại diện cho link chia sẻ của file.
/// </summary>
public class FileShare : Entity, IAggregateRoot
{
/// <summary>EN: File being shared / VI: File được chia sẻ</summary>
public Guid FileId { get; private set; }
/// <summary>EN: User who created the share / VI: User tạo share</summary>
public string SharedBy { get; private set; } = default!;
/// <summary>EN: User the file is shared with (null = public link) / VI: User được share (null = link công khai)</summary>
public string? SharedWith { get; private set; }
/// <summary>EN: Share permission level / VI: Mức quyền chia sẻ</summary>
public SharePermission Permission { get; private set; }
/// <summary>EN: Unique share token / VI: Token chia sẻ duy nhất</summary>
public string ShareToken { get; private set; } = default!;
/// <summary>EN: Optional password hash / VI: Hash mật khẩu (tùy chọn)</summary>
public string? PasswordHash { get; private set; }
/// <summary>EN: Share expiration date / VI: Ngày hết hạn chia sẻ</summary>
public DateTime? ExpiresAt { get; private set; }
/// <summary>EN: Maximum download count / VI: Số lần tải xuống tối đa</summary>
public int? MaxDownloads { get; private set; }
/// <summary>EN: Current download count / VI: Số lần đã tải xuống</summary>
public int DownloadCount { get; private set; }
/// <summary>EN: Share status / VI: Trạng thái chia sẻ</summary>
public FileShareStatus Status { get; private set; }
/// <summary>EN: Created timestamp / VI: Thời gian tạo</summary>
public DateTime CreatedAt { get; private set; }
/// <summary>EN: Revoked timestamp / VI: Thời gian thu hồi</summary>
public DateTime? RevokedAt { get; private set; }
// EN: For EF Core / VI: Cho EF Core
protected FileShare() { }
/// <summary>
/// EN: Create a new file share.
/// VI: Tạo file share mới.
/// </summary>
public FileShare(
Guid fileId,
string sharedBy,
SharePermission permission,
string? sharedWith = null,
string? password = null,
DateTime? expiresAt = null,
int? maxDownloads = null)
{
FileId = fileId;
SharedBy = sharedBy;
SharedWith = sharedWith;
Permission = permission;
ShareToken = GenerateShareToken();
ExpiresAt = expiresAt;
MaxDownloads = maxDownloads;
DownloadCount = 0;
Status = FileShareStatus.Active;
CreatedAt = DateTime.UtcNow;
if (!string.IsNullOrEmpty(password))
{
PasswordHash = HashPassword(password);
}
}
/// <summary>
/// EN: Check if share is valid (not expired, not revoked, not limit reached).
/// VI: Kiểm tra share có hợp lệ (chưa hết hạn, chưa bị thu hồi, chưa đạt giới hạn).
/// </summary>
public bool IsValid()
{
if (Status != FileShareStatus.Active)
return false;
if (ExpiresAt.HasValue && DateTime.UtcNow > ExpiresAt.Value)
{
Status = FileShareStatus.Expired;
return false;
}
if (MaxDownloads.HasValue && DownloadCount >= MaxDownloads.Value)
{
Status = FileShareStatus.LimitReached;
return false;
}
return true;
}
/// <summary>
/// EN: Validate password if share is password-protected.
/// VI: Xác thực mật khẩu nếu share có bảo vệ bằng mật khẩu.
/// </summary>
public bool ValidatePassword(string? password)
{
if (string.IsNullOrEmpty(PasswordHash))
return true; // EN: No password required / VI: Không yêu cầu mật khẩu
if (string.IsNullOrEmpty(password))
return false;
return VerifyPassword(password, PasswordHash);
}
/// <summary>
/// EN: Increment download count.
/// VI: Tăng số lần tải xuống.
/// </summary>
public void IncrementDownloadCount()
{
DownloadCount++;
if (MaxDownloads.HasValue && DownloadCount >= MaxDownloads.Value)
{
Status = FileShareStatus.LimitReached;
}
}
/// <summary>
/// EN: Revoke the share.
/// VI: Thu hồi chia sẻ.
/// </summary>
public void Revoke()
{
Status = FileShareStatus.Revoked;
RevokedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Generate a unique share token.
/// VI: Tạo token chia sẻ duy nhất.
/// </summary>
private static string GenerateShareToken()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToBase64String(bytes)
.Replace("+", "-")
.Replace("/", "_")
.Replace("=", "");
}
/// <summary>
/// EN: Hash password using PBKDF2.
/// VI: Hash mật khẩu sử dụng PBKDF2.
/// </summary>
private static string HashPassword(string password)
{
var salt = RandomNumberGenerator.GetBytes(16);
var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, 100000, HashAlgorithmName.SHA256, 32);
var combined = new byte[salt.Length + hash.Length];
Buffer.BlockCopy(salt, 0, combined, 0, salt.Length);
Buffer.BlockCopy(hash, 0, combined, salt.Length, hash.Length);
return Convert.ToBase64String(combined);
}
/// <summary>
/// EN: Verify password against hash.
/// VI: Xác thực mật khẩu với hash.
/// </summary>
private static bool VerifyPassword(string password, string storedHash)
{
var combined = Convert.FromBase64String(storedHash);
var salt = new byte[16];
var storedHashBytes = new byte[32];
Buffer.BlockCopy(combined, 0, salt, 0, 16);
Buffer.BlockCopy(combined, 16, storedHashBytes, 0, 32);
var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, 100000, HashAlgorithmName.SHA256, 32);
return CryptographicOperations.FixedTimeEquals(hash, storedHashBytes);
}
}

View File

@@ -0,0 +1,52 @@
using StorageService.Domain.SeedWork;
namespace StorageService.Domain.AggregatesModel.FileShareAggregate;
/// <summary>
/// EN: Repository interface for FileShare aggregate.
/// VI: Interface repository cho FileShare aggregate.
/// </summary>
public interface IFileShareRepository : IRepository<FileShare>
{
/// <summary>
/// EN: Get file share by ID.
/// VI: Lấy file share theo ID.
/// </summary>
Task<FileShare?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get file share by token.
/// VI: Lấy file share theo token.
/// </summary>
Task<FileShare?> GetByTokenAsync(string token, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get all shares for a file.
/// VI: Lấy tất cả shares cho một file.
/// </summary>
Task<IEnumerable<FileShare>> GetByFileIdAsync(Guid fileId, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get all shares by a user.
/// VI: Lấy tất cả shares bởi một user.
/// </summary>
Task<IEnumerable<FileShare>> GetBySharedByAsync(string userId, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get all shares with a user.
/// VI: Lấy tất cả shares với một user.
/// </summary>
Task<IEnumerable<FileShare>> GetSharedWithAsync(string userId, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Add a new file share.
/// VI: Thêm file share mới.
/// </summary>
Task<FileShare> AddAsync(FileShare fileShare, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Update a file share.
/// VI: Cập nhật file share.
/// </summary>
void Update(FileShare fileShare);
}

View File

@@ -0,0 +1,39 @@
namespace StorageService.Domain.AggregatesModel.FileShareAggregate;
/// <summary>
/// EN: Share permission levels.
/// VI: Các mức quyền chia sẻ.
/// </summary>
public enum SharePermission
{
/// <summary>EN: View only / VI: Chỉ xem</summary>
View = 0,
/// <summary>EN: Download allowed / VI: Được phép tải xuống</summary>
Download = 1,
/// <summary>EN: Edit allowed / VI: Được phép chỉnh sửa</summary>
Edit = 2,
/// <summary>EN: Full admin access / VI: Quyền admin đầy đủ</summary>
Admin = 3
}
/// <summary>
/// EN: File share status.
/// VI: Trạng thái chia sẻ file.
/// </summary>
public enum FileShareStatus
{
/// <summary>EN: Active share / VI: Chia sẻ đang hoạt động</summary>
Active = 0,
/// <summary>EN: Expired share / VI: Chia sẻ đã hết hạn</summary>
Expired = 1,
/// <summary>EN: Revoked share / VI: Chia sẻ đã bị thu hồi</summary>
Revoked = 2,
/// <summary>EN: Download limit reached / VI: Đã đạt giới hạn tải xuống</summary>
LimitReached = 3
}

View File

@@ -0,0 +1,88 @@
using StorageService.Domain.SeedWork;
namespace StorageService.Domain.AggregatesModel.FileVersionAggregate;
/// <summary>
/// EN: File version entity - represents a historical version of a file.
/// VI: Entity phiên bản file - đại diện cho một phiên bản lịch sử của file.
/// </summary>
public class FileVersion : Entity
{
/// <summary>EN: File this version belongs to / VI: File mà version này thuộc về</summary>
public Guid FileId { get; private set; }
/// <summary>EN: Version number (1, 2, 3...) / VI: Số phiên bản (1, 2, 3...)</summary>
public int VersionNumber { get; private set; }
/// <summary>EN: Object key in storage / VI: Object key trong storage</summary>
public string ObjectKey { get; private set; } = default!;
/// <summary>EN: File size in bytes / VI: Kích thước file tính bằng bytes</summary>
public long SizeBytes { get; private set; }
/// <summary>EN: Content type / VI: Loại nội dung</summary>
public string ContentType { get; private set; } = default!;
/// <summary>EN: Checksum/Hash of the file / VI: Checksum/Hash của file</summary>
public string? Checksum { get; private set; }
/// <summary>EN: Version creation timestamp / VI: Thời gian tạo phiên bản</summary>
public DateTime CreatedAt { get; private set; }
/// <summary>EN: User who created this version / VI: User tạo phiên bản này</summary>
public string CreatedBy { get; private set; } = default!;
/// <summary>EN: Optional comment for version / VI: Ghi chú tùy chọn cho phiên bản</summary>
public string? Comment { get; private set; }
/// <summary>EN: Whether this is the current version / VI: Có phải phiên bản hiện tại không</summary>
public bool IsCurrent { get; private set; }
// EN: For EF Core / VI: Cho EF Core
protected FileVersion() { }
/// <summary>
/// EN: Create a new file version.
/// VI: Tạo phiên bản file mới.
/// </summary>
public FileVersion(
Guid fileId,
int versionNumber,
string objectKey,
long sizeBytes,
string contentType,
string createdBy,
string? checksum = null,
string? comment = null,
bool isCurrent = true)
{
FileId = fileId;
VersionNumber = versionNumber;
ObjectKey = objectKey;
SizeBytes = sizeBytes;
ContentType = contentType;
CreatedBy = createdBy;
Checksum = checksum;
Comment = comment;
IsCurrent = isCurrent;
CreatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Mark this version as current.
/// VI: Đánh dấu phiên bản này là hiện tại.
/// </summary>
public void MarkAsCurrent()
{
IsCurrent = true;
}
/// <summary>
/// EN: Unmark this version as current.
/// VI: Bỏ đánh dấu phiên bản này là hiện tại.
/// </summary>
public void UnmarkAsCurrent()
{
IsCurrent = false;
}
}

View File

@@ -0,0 +1,53 @@
using StorageService.Domain.SeedWork;
namespace StorageService.Domain.AggregatesModel.FileVersionAggregate;
/// <summary>
/// EN: Repository interface for FileVersion entity.
/// VI: Interface repository cho entity FileVersion.
/// </summary>
public interface IFileVersionRepository
{
IUnitOfWork UnitOfWork { get; }
/// <summary>
/// EN: Get version by ID.
/// VI: Lấy version theo ID.
/// </summary>
Task<FileVersion?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get all versions of a file ordered by version number descending.
/// VI: Lấy tất cả versions của file sắp xếp theo số version giảm dần.
/// </summary>
Task<IEnumerable<FileVersion>> GetByFileIdAsync(Guid fileId, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get specific version of a file.
/// VI: Lấy version cụ thể của file.
/// </summary>
Task<FileVersion?> GetByFileIdAndVersionAsync(Guid fileId, int versionNumber, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get current version of a file.
/// VI: Lấy version hiện tại của file.
/// </summary>
Task<FileVersion?> GetCurrentVersionAsync(Guid fileId, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get latest version number for a file.
/// VI: Lấy số version mới nhất của file.
/// </summary>
Task<int> GetLatestVersionNumberAsync(Guid fileId, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Add new version.
/// VI: Thêm version mới.
/// </summary>
Task<FileVersion> AddAsync(FileVersion version, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Update version.
/// VI: Cập nhật version.
/// </summary>
void Update(FileVersion version);
}

View File

@@ -0,0 +1,138 @@
using StorageService.Domain.SeedWork;
namespace StorageService.Domain.AggregatesModel.FolderAggregate;
/// <summary>
/// EN: Folder aggregate root - represents a logical folder for organizing files.
/// VI: Folder aggregate root - đại diện cho folder logic để tổ chức files.
/// </summary>
/// <remarks>
/// EN: CRITICAL: Folders are LOGICAL only (database). Storage uses flat UUID keys.
/// Folder rename/move is O(1) - only updates database, not storage.
/// VI: QUAN TRỌNG: Folders chỉ là LOGIC (database). Storage dùng flat UUID keys.
/// Đổi tên/di chuyển folder là O(1) - chỉ update database, không động storage.
/// </remarks>
public class Folder : Entity, IAggregateRoot
{
/// <summary>EN: Owner user ID / VI: ID user sở hữu</summary>
public string UserId { get; private set; } = default!;
/// <summary>EN: Parent folder ID (null = root) / VI: ID folder cha (null = root)</summary>
public Guid? ParentId { get; private set; }
/// <summary>EN: Folder name / VI: Tên folder</summary>
public string Name { get; private set; } = default!;
/// <summary>EN: Materialized path (e.g., /parent/child/) / VI: Đường dẫn (vd: /parent/child/)</summary>
public string Path { get; private set; } = default!;
/// <summary>EN: Nesting level (0 = root level) / VI: Cấp độ lồng (0 = cấp root)</summary>
public int Level { get; private set; }
/// <summary>EN: Created timestamp / VI: Thời gian tạo</summary>
public DateTime CreatedAt { get; private set; }
/// <summary>EN: Updated timestamp / VI: Thời gian cập nhật</summary>
public DateTime UpdatedAt { get; private set; }
/// <summary>EN: Soft delete flag / VI: Cờ xóa mềm</summary>
public bool IsDeleted { get; private set; }
/// <summary>EN: Deleted timestamp / VI: Thời gian xóa</summary>
public DateTime? DeletedAt { get; private set; }
// EN: For EF Core / VI: Cho EF Core
protected Folder() { }
/// <summary>
/// EN: Create a new root folder.
/// VI: Tạo folder root mới.
/// </summary>
public static Folder CreateRoot(string userId, string name)
{
return new Folder
{
Id = Guid.NewGuid(),
UserId = userId,
ParentId = null,
Name = name,
Path = $"/{name}/",
Level = 0,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
IsDeleted = false
};
}
/// <summary>
/// EN: Create a child folder.
/// VI: Tạo folder con.
/// </summary>
public Folder CreateChild(string name)
{
return new Folder
{
Id = Guid.NewGuid(),
UserId = UserId,
ParentId = Id,
Name = name,
Path = $"{Path}{name}/",
Level = Level + 1,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
IsDeleted = false
};
}
/// <summary>
/// EN: Rename folder. O(1) operation - only database update.
/// VI: Đổi tên folder. O(1) - chỉ update database.
/// </summary>
public void Rename(string newName)
{
if (IsDeleted)
throw new InvalidOperationException("Cannot rename deleted folder");
var oldPath = Path;
Name = newName;
// EN: Update path - need to update children paths too (via repository)
// VI: Update path - cần update paths của children (qua repository)
Path = ParentId == null
? $"/{newName}/"
: oldPath.Substring(0, oldPath.LastIndexOf('/', oldPath.Length - 2) + 1) + $"{newName}/";
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Move folder to new parent.
/// VI: Di chuyển folder sang parent mới.
/// </summary>
public void MoveTo(Folder? newParent)
{
if (IsDeleted)
throw new InvalidOperationException("Cannot move deleted folder");
if (newParent != null && newParent.UserId != UserId)
throw new InvalidOperationException("Cannot move folder to different user");
ParentId = newParent?.Id;
Level = newParent == null ? 0 : newParent.Level + 1;
Path = newParent == null ? $"/{Name}/" : $"{newParent.Path}{Name}/";
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Soft delete folder.
/// VI: Xóa mềm folder.
/// </summary>
public void Delete()
{
if (IsDeleted)
return;
IsDeleted = true;
DeletedAt = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,34 @@
using StorageService.Domain.SeedWork;
namespace StorageService.Domain.AggregatesModel.FolderAggregate;
/// <summary>
/// EN: Repository interface for Folder aggregate.
/// VI: Interface repository cho Folder aggregate.
/// </summary>
public interface IFolderRepository : IRepository<Folder>
{
/// <summary>EN: Get folder by ID / VI: Lấy folder theo ID</summary>
Task<Folder?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>EN: Get all root folders for user / VI: Lấy tất cả root folders của user</summary>
Task<IEnumerable<Folder>> GetRootFoldersAsync(string userId, CancellationToken cancellationToken = default);
/// <summary>EN: Get child folders / VI: Lấy folders con</summary>
Task<IEnumerable<Folder>> GetChildFoldersAsync(Guid parentId, CancellationToken cancellationToken = default);
/// <summary>EN: Get folder by path / VI: Lấy folder theo path</summary>
Task<Folder?> GetByPathAsync(string userId, string path, CancellationToken cancellationToken = default);
/// <summary>EN: Get all descendant folders / VI: Lấy tất cả folders con cháu</summary>
Task<IEnumerable<Folder>> GetDescendantsAsync(Guid folderId, CancellationToken cancellationToken = default);
/// <summary>EN: Add new folder / VI: Thêm folder mới</summary>
Task<Folder> AddAsync(Folder folder, CancellationToken cancellationToken = default);
/// <summary>EN: Update folder / VI: Cập nhật folder</summary>
void Update(Folder folder);
/// <summary>EN: Check if folder name exists in parent / VI: Kiểm tra tên folder đã tồn tại trong parent</summary>
Task<bool> ExistsAsync(string userId, Guid? parentId, string name, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,77 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StorageService.Domain.AggregatesModel.FileAggregate;
using StorageService.Infrastructure.Configuration;
namespace StorageService.Infrastructure.CDN;
/// <summary>
/// EN: CDN service implementation for generating CDN URLs.
/// VI: Implementation CDN service để tạo CDN URLs.
/// </summary>
public class CDNService : ICDNService
{
private readonly CDNSettings _settings;
private readonly ILogger<CDNService> _logger;
public CDNService(
IOptions<CDNSettings> settings,
ILogger<CDNService> logger)
{
_settings = settings.Value;
_logger = logger;
}
/// <inheritdoc />
public bool IsEnabled => _settings.Enabled && !string.IsNullOrEmpty(_settings.BaseUrl);
/// <inheritdoc />
public string? GetCDNUrl(string objectKey, FileAccessLevel accessLevel)
{
// EN: CDN only applies to public files
// VI: CDN chỉ áp dụng cho public files
if (!IsEnabled)
{
_logger.LogDebug("CDN is disabled, returning null");
return null;
}
if (accessLevel != FileAccessLevel.Public)
{
_logger.LogDebug("CDN not applicable for {AccessLevel} files", accessLevel);
return null;
}
// EN: Generate CDN URL by combining base URL with object key
// VI: Tạo CDN URL bằng cách kết hợp base URL với object key
var cdnUrl = CombineUrl(_settings.BaseUrl, objectKey);
_logger.LogDebug("Generated CDN URL: {Url} for object: {ObjectKey}", cdnUrl, objectKey);
return cdnUrl;
}
/// <inheritdoc />
public CDNUrlResult? GetCDNUrlWithCacheInfo(string objectKey, FileAccessLevel accessLevel, string contentType)
{
var url = GetCDNUrl(objectKey, accessLevel);
if (url == null)
return null;
var cacheTtl = _settings.GetCacheTtl(contentType);
var cacheControl = $"public, max-age={cacheTtl}";
return new CDNUrlResult(url, cacheTtl, cacheControl);
}
/// <summary>
/// EN: Combine base URL with path.
/// VI: Kết hợp base URL với path.
/// </summary>
private static string CombineUrl(string baseUrl, string path)
{
var trimmedBase = baseUrl.TrimEnd('/');
var trimmedPath = path.TrimStart('/');
return $"{trimmedBase}/{trimmedPath}";
}
}

View File

@@ -0,0 +1,44 @@
using StorageService.Domain.AggregatesModel.FileAggregate;
namespace StorageService.Infrastructure.CDN;
/// <summary>
/// EN: CDN service interface for generating CDN URLs.
/// VI: Interface CDN service để tạo CDN URLs.
/// </summary>
public interface ICDNService
{
/// <summary>
/// EN: Check if CDN is enabled.
/// VI: Kiểm tra CDN có được bật hay không.
/// </summary>
bool IsEnabled { get; }
/// <summary>
/// EN: Generate CDN URL for a file.
/// VI: Tạo CDN URL cho file.
/// </summary>
/// <param name="objectKey">Storage object key / Key của object trong storage</param>
/// <param name="accessLevel">File access level / Mức truy cập file</param>
/// <returns>CDN URL or null if CDN not applicable / URL CDN hoặc null nếu không áp dụng CDN</returns>
string? GetCDNUrl(string objectKey, FileAccessLevel accessLevel);
/// <summary>
/// EN: Generate CDN URL with cache headers.
/// VI: Tạo CDN URL với cache headers.
/// </summary>
/// <param name="objectKey">Storage object key / Key của object trong storage</param>
/// <param name="accessLevel">File access level / Mức truy cập file</param>
/// <param name="contentType">Content type for cache TTL / Content type để xác định TTL cache</param>
/// <returns>CDN URL result with cache info / Kết quả URL CDN với thông tin cache</returns>
CDNUrlResult? GetCDNUrlWithCacheInfo(string objectKey, FileAccessLevel accessLevel, string contentType);
}
/// <summary>
/// EN: CDN URL generation result.
/// VI: Kết quả tạo CDN URL.
/// </summary>
public record CDNUrlResult(
string Url,
int CacheTtlSeconds,
string CacheControl);

View File

@@ -0,0 +1,49 @@
namespace StorageService.Infrastructure.Configuration;
/// <summary>
/// EN: CDN configuration settings.
/// VI: Cấu hình CDN.
/// </summary>
public class CDNSettings
{
/// <summary>EN: Enable CDN for public files / VI: Bật CDN cho public files</summary>
public bool Enabled { get; set; } = false;
/// <summary>EN: CDN base URL / VI: URL gốc CDN</summary>
public string BaseUrl { get; set; } = string.Empty;
/// <summary>EN: Cache TTL for images (seconds) / VI: TTL cache cho images (giây)</summary>
public int ImageCacheTtlSeconds { get; set; } = 2592000; // 30 days
/// <summary>EN: Cache TTL for videos (seconds) / VI: TTL cache cho videos (giây)</summary>
public int VideoCacheTtlSeconds { get; set; } = 2592000; // 30 days
/// <summary>EN: Cache TTL for documents (seconds) / VI: TTL cache cho documents (giây)</summary>
public int DocumentCacheTtlSeconds { get; set; } = 604800; // 7 days
/// <summary>EN: Cache TTL for other files (seconds) / VI: TTL cache cho files khác (giây)</summary>
public int DefaultCacheTtlSeconds { get; set; } = 86400; // 1 day
/// <summary>
/// EN: Get cache TTL based on content type.
/// VI: Lấy TTL cache theo content type.
/// </summary>
public int GetCacheTtl(string contentType)
{
if (string.IsNullOrEmpty(contentType))
return DefaultCacheTtlSeconds;
if (contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
return ImageCacheTtlSeconds;
if (contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase))
return VideoCacheTtlSeconds;
if (contentType.Contains("pdf", StringComparison.OrdinalIgnoreCase) ||
contentType.Contains("document", StringComparison.OrdinalIgnoreCase) ||
contentType.Contains("spreadsheet", StringComparison.OrdinalIgnoreCase))
return DocumentCacheTtlSeconds;
return DefaultCacheTtlSeconds;
}
}

View File

@@ -34,10 +34,14 @@ public static class DependencyInjection
services.Configure<StorageSettings>(configuration.GetSection(StorageSettings.SectionName));
services.Configure<IamServiceSettings>(configuration.GetSection(IamServiceSettings.SectionName));
services.Configure<RedisSettings>(configuration.GetSection(RedisSettings.SectionName));
services.Configure<CDNSettings>(configuration.GetSection("CDN"));
// EN: Register Redis cache service / VI: Đăng ký Redis cache service
services.AddSingleton<IRedisCacheService, RedisCacheService>();
// EN: Register CDN service / VI: Đăng ký CDN service
services.AddSingleton<CDN.ICDNService, CDN.CDNService>();
// EN: Add DbContext with PostgreSQL / VI: Thêm DbContext với PostgreSQL
if (!string.Equals(environmentName, "Testing", StringComparison.OrdinalIgnoreCase))
{
@@ -69,6 +73,9 @@ public static class DependencyInjection
// EN: Register repositories / VI: Đăng ký repositories
services.AddScoped<IFileRepository, FileRepository>();
services.AddScoped<IQuotaRepository, QuotaRepository>();
services.AddScoped<Domain.AggregatesModel.FileShareAggregate.IFileShareRepository, FileShareRepository>();
services.AddScoped<Domain.AggregatesModel.FileVersionAggregate.IFileVersionRepository, FileVersionRepository>();
services.AddScoped<Domain.AggregatesModel.FolderAggregate.IFolderRepository, FolderRepository>();
// EN: Register storage providers / VI: Đăng ký storage providers
services.AddSingleton<MinioStorageProvider>();

View File

@@ -0,0 +1,79 @@
using Microsoft.EntityFrameworkCore;
using StorageService.Domain.SeedWork;
// EN: Alias to avoid collision with System.IO.FileShare
// VI: Alias để tránh xung đột với System.IO.FileShare
using DomainFileShare = StorageService.Domain.AggregatesModel.FileShareAggregate.FileShare;
using StorageService.Domain.AggregatesModel.FileShareAggregate;
namespace StorageService.Infrastructure.Persistence.Repositories;
/// <summary>
/// EN: Repository implementation for FileShare aggregate.
/// VI: Implementation repository cho FileShare aggregate.
/// </summary>
public class FileShareRepository : IFileShareRepository
{
private readonly StorageServiceContext _context;
public FileShareRepository(StorageServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public IUnitOfWork UnitOfWork => _context;
/// <inheritdoc />
public async Task<DomainFileShare?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.FileShares
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
}
/// <inheritdoc />
public async Task<DomainFileShare?> GetByTokenAsync(string token, CancellationToken cancellationToken = default)
{
return await _context.FileShares
.FirstOrDefaultAsync(x => x.ShareToken == token, cancellationToken);
}
/// <inheritdoc />
public async Task<IEnumerable<DomainFileShare>> GetByFileIdAsync(Guid fileId, CancellationToken cancellationToken = default)
{
return await _context.FileShares
.Where(x => x.FileId == fileId && x.Status == FileShareStatus.Active)
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IEnumerable<DomainFileShare>> GetBySharedByAsync(string userId, CancellationToken cancellationToken = default)
{
return await _context.FileShares
.Where(x => x.SharedBy == userId)
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IEnumerable<DomainFileShare>> GetSharedWithAsync(string userId, CancellationToken cancellationToken = default)
{
return await _context.FileShares
.Where(x => x.SharedWith == userId && x.Status == FileShareStatus.Active)
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<DomainFileShare> AddAsync(DomainFileShare fileShare, CancellationToken cancellationToken = default)
{
await _context.FileShares.AddAsync(fileShare, cancellationToken);
return fileShare;
}
/// <inheritdoc />
public void Update(DomainFileShare fileShare)
{
_context.Entry(fileShare).State = EntityState.Modified;
}
}

View File

@@ -0,0 +1,74 @@
using Microsoft.EntityFrameworkCore;
using StorageService.Domain.AggregatesModel.FileVersionAggregate;
using StorageService.Domain.SeedWork;
namespace StorageService.Infrastructure.Persistence.Repositories;
/// <summary>
/// EN: Repository implementation for FileVersion entity.
/// VI: Implementation repository cho entity FileVersion.
/// </summary>
public class FileVersionRepository : IFileVersionRepository
{
private readonly StorageServiceContext _context;
public FileVersionRepository(StorageServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public IUnitOfWork UnitOfWork => _context;
/// <inheritdoc />
public async Task<FileVersion?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.FileVersions
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
}
/// <inheritdoc />
public async Task<IEnumerable<FileVersion>> GetByFileIdAsync(Guid fileId, CancellationToken cancellationToken = default)
{
return await _context.FileVersions
.Where(x => x.FileId == fileId)
.OrderByDescending(x => x.VersionNumber)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<FileVersion?> GetByFileIdAndVersionAsync(Guid fileId, int versionNumber, CancellationToken cancellationToken = default)
{
return await _context.FileVersions
.FirstOrDefaultAsync(x => x.FileId == fileId && x.VersionNumber == versionNumber, cancellationToken);
}
/// <inheritdoc />
public async Task<FileVersion?> GetCurrentVersionAsync(Guid fileId, CancellationToken cancellationToken = default)
{
return await _context.FileVersions
.FirstOrDefaultAsync(x => x.FileId == fileId && x.IsCurrent, cancellationToken);
}
/// <inheritdoc />
public async Task<int> GetLatestVersionNumberAsync(Guid fileId, CancellationToken cancellationToken = default)
{
var latestVersion = await _context.FileVersions
.Where(x => x.FileId == fileId)
.MaxAsync(x => (int?)x.VersionNumber, cancellationToken);
return latestVersion ?? 0;
}
/// <inheritdoc />
public async Task<FileVersion> AddAsync(FileVersion version, CancellationToken cancellationToken = default)
{
await _context.FileVersions.AddAsync(version, cancellationToken);
return version;
}
/// <inheritdoc />
public void Update(FileVersion version)
{
_context.Entry(version).State = EntityState.Modified;
}
}

View File

@@ -0,0 +1,87 @@
using Microsoft.EntityFrameworkCore;
using StorageService.Domain.AggregatesModel.FolderAggregate;
using StorageService.Domain.SeedWork;
namespace StorageService.Infrastructure.Persistence.Repositories;
/// <summary>
/// EN: Repository implementation for Folder aggregate.
/// VI: Implementation repository cho Folder aggregate.
/// </summary>
public class FolderRepository : IFolderRepository
{
private readonly StorageServiceContext _context;
public FolderRepository(StorageServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public IUnitOfWork UnitOfWork => _context;
/// <inheritdoc />
public async Task<Folder?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.Folders
.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted, cancellationToken);
}
/// <inheritdoc />
public async Task<IEnumerable<Folder>> GetRootFoldersAsync(string userId, CancellationToken cancellationToken = default)
{
return await _context.Folders
.Where(x => x.UserId == userId && x.ParentId == null && !x.IsDeleted)
.OrderBy(x => x.Name)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IEnumerable<Folder>> GetChildFoldersAsync(Guid parentId, CancellationToken cancellationToken = default)
{
return await _context.Folders
.Where(x => x.ParentId == parentId && !x.IsDeleted)
.OrderBy(x => x.Name)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<Folder?> GetByPathAsync(string userId, string path, CancellationToken cancellationToken = default)
{
return await _context.Folders
.FirstOrDefaultAsync(x => x.UserId == userId && x.Path == path && !x.IsDeleted, cancellationToken);
}
/// <inheritdoc />
public async Task<IEnumerable<Folder>> GetDescendantsAsync(Guid folderId, CancellationToken cancellationToken = default)
{
var folder = await GetByIdAsync(folderId, cancellationToken);
if (folder == null) return Enumerable.Empty<Folder>();
// EN: Use path prefix to find all descendants / VI: Dùng path prefix để tìm tất cả con cháu
return await _context.Folders
.Where(x => x.Path.StartsWith(folder.Path) && x.Id != folderId && !x.IsDeleted)
.OrderBy(x => x.Level)
.ThenBy(x => x.Name)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<Folder> AddAsync(Folder folder, CancellationToken cancellationToken = default)
{
await _context.Folders.AddAsync(folder, cancellationToken);
return folder;
}
/// <inheritdoc />
public void Update(Folder folder)
{
_context.Entry(folder).State = EntityState.Modified;
}
/// <inheritdoc />
public async Task<bool> ExistsAsync(string userId, Guid? parentId, string name, CancellationToken cancellationToken = default)
{
return await _context.Folders
.AnyAsync(x => x.UserId == userId && x.ParentId == parentId && x.Name == name && !x.IsDeleted, cancellationToken);
}
}

View File

@@ -4,9 +4,16 @@ using Microsoft.EntityFrameworkCore.Storage;
using StorageService.Domain.AggregatesModel.FileAggregate;
using StorageService.Domain.AggregatesModel.QuotaAggregate;
using StorageService.Domain.AggregatesModel.MultipartUploadAggregate;
using StorageService.Domain.AggregatesModel.FileShareAggregate;
using StorageService.Domain.AggregatesModel.FileVersionAggregate;
using StorageService.Domain.AggregatesModel.FolderAggregate;
using StorageService.Domain.SeedWork;
using System.Data;
// EN: Alias to avoid collision with System.IO.FileShare
// VI: Alias để tránh xung đột với System.IO.FileShare
using DomainFileShare = StorageService.Domain.AggregatesModel.FileShareAggregate.FileShare;
namespace StorageService.Infrastructure.Persistence;
/// <summary>
@@ -22,6 +29,9 @@ public class StorageServiceContext : DbContext, IUnitOfWork
public DbSet<UserStorageQuota> UserStorageQuotas => Set<UserStorageQuota>();
public DbSet<MultipartUpload> MultipartUploads => Set<MultipartUpload>();
public DbSet<MultipartUploadPart> MultipartUploadParts => Set<MultipartUploadPart>();
public DbSet<DomainFileShare> FileShares => Set<DomainFileShare>();
public DbSet<FileVersion> FileVersions => Set<FileVersion>();
public DbSet<Folder> Folders => Set<Folder>();
public IDbContextTransaction? CurrentTransaction => _currentTransaction;
public bool HasActiveTransaction => _currentTransaction != null;
@@ -301,6 +311,217 @@ public class StorageServiceContext : DbContext, IUnitOfWork
// EN: Ignore domain events / VI: Bỏ qua domain events
entity.Ignore(e => e.DomainEvents);
});
// EN: Configure FileShare entity / VI: Cấu hình entity FileShare
modelBuilder.Entity<DomainFileShare>(entity =>
{
entity.ToTable("file_shares");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id)
.HasColumnName("id");
entity.Property(e => e.FileId)
.HasColumnName("file_id")
.IsRequired();
entity.Property(e => e.SharedBy)
.HasColumnName("shared_by")
.HasMaxLength(255)
.IsRequired();
entity.Property(e => e.SharedWith)
.HasColumnName("shared_with")
.HasMaxLength(255);
entity.Property(e => e.Permission)
.HasColumnName("permission")
.HasConversion<string>()
.HasMaxLength(50)
.IsRequired();
entity.Property(e => e.ShareToken)
.HasColumnName("share_token")
.HasMaxLength(255)
.IsRequired();
entity.Property(e => e.PasswordHash)
.HasColumnName("password_hash")
.HasMaxLength(255);
entity.Property(e => e.ExpiresAt)
.HasColumnName("expires_at");
entity.Property(e => e.MaxDownloads)
.HasColumnName("max_downloads");
entity.Property(e => e.DownloadCount)
.HasColumnName("download_count")
.HasDefaultValue(0);
entity.Property(e => e.Status)
.HasColumnName("status")
.HasConversion<string>()
.HasMaxLength(50)
.IsRequired();
entity.Property(e => e.CreatedAt)
.HasColumnName("created_at")
.IsRequired();
entity.Property(e => e.RevokedAt)
.HasColumnName("revoked_at");
// EN: Indexes / VI: Indexes
entity.HasIndex(e => e.FileId);
entity.HasIndex(e => e.ShareToken).IsUnique();
entity.HasIndex(e => e.SharedWith);
entity.HasIndex(e => e.SharedBy);
// EN: Relationship with StorageFile / VI: Mối quan hệ với StorageFile
entity.HasOne<StorageFile>()
.WithMany()
.HasForeignKey(e => e.FileId)
.OnDelete(DeleteBehavior.Cascade);
// EN: Ignore domain events / VI: Bỏ qua domain events
entity.Ignore(e => e.DomainEvents);
});
// EN: Configure FileVersion entity / VI: Cấu hình entity FileVersion
modelBuilder.Entity<FileVersion>(entity =>
{
entity.ToTable("file_versions");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id)
.HasColumnName("id");
entity.Property(e => e.FileId)
.HasColumnName("file_id")
.IsRequired();
entity.Property(e => e.VersionNumber)
.HasColumnName("version_number")
.IsRequired();
entity.Property(e => e.ObjectKey)
.HasColumnName("object_key")
.HasMaxLength(500)
.IsRequired();
entity.Property(e => e.SizeBytes)
.HasColumnName("size_bytes")
.IsRequired();
entity.Property(e => e.ContentType)
.HasColumnName("content_type")
.HasMaxLength(100)
.IsRequired();
entity.Property(e => e.Checksum)
.HasColumnName("checksum")
.HasMaxLength(100);
entity.Property(e => e.CreatedAt)
.HasColumnName("created_at")
.IsRequired();
entity.Property(e => e.CreatedBy)
.HasColumnName("created_by")
.HasMaxLength(255)
.IsRequired();
entity.Property(e => e.Comment)
.HasColumnName("comment")
.HasMaxLength(500);
entity.Property(e => e.IsCurrent)
.HasColumnName("is_current")
.HasDefaultValue(false);
// EN: Indexes / VI: Indexes
entity.HasIndex(e => e.FileId);
entity.HasIndex(e => new { e.FileId, e.VersionNumber }).IsUnique();
entity.HasIndex(e => new { e.FileId, e.IsCurrent });
// EN: Relationship with StorageFile / VI: Mối quan hệ với StorageFile
entity.HasOne<StorageFile>()
.WithMany()
.HasForeignKey(e => e.FileId)
.OnDelete(DeleteBehavior.Cascade);
// EN: Ignore domain events / VI: Bỏ qua domain events
entity.Ignore(e => e.DomainEvents);
});
// EN: Configure Folder entity / VI: Cấu hình entity Folder
modelBuilder.Entity<Folder>(entity =>
{
entity.ToTable("folders");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id)
.HasColumnName("id");
entity.Property(e => e.UserId)
.HasColumnName("user_id")
.HasMaxLength(255)
.IsRequired();
entity.Property(e => e.ParentId)
.HasColumnName("parent_id");
entity.Property(e => e.Name)
.HasColumnName("name")
.HasMaxLength(255)
.IsRequired();
entity.Property(e => e.Path)
.HasColumnName("path")
.HasMaxLength(1000)
.IsRequired();
entity.Property(e => e.Level)
.HasColumnName("level")
.IsRequired();
entity.Property(e => e.CreatedAt)
.HasColumnName("created_at")
.IsRequired();
entity.Property(e => e.UpdatedAt)
.HasColumnName("updated_at")
.IsRequired();
entity.Property(e => e.IsDeleted)
.HasColumnName("is_deleted")
.HasDefaultValue(false);
entity.Property(e => e.DeletedAt)
.HasColumnName("deleted_at");
// EN: Indexes / VI: Indexes
entity.HasIndex(e => e.UserId);
entity.HasIndex(e => e.ParentId);
entity.HasIndex(e => new { e.UserId, e.ParentId, e.Name }).IsUnique().HasFilter("is_deleted = false");
entity.HasIndex(e => e.Path);
// EN: Self-referencing relationship / VI: Quan hệ tự tham chiếu
entity.HasOne<Folder>()
.WithMany()
.HasForeignKey(e => e.ParentId)
.OnDelete(DeleteBehavior.Restrict);
// EN: Ignore domain events / VI: Bỏ qua domain events
entity.Ignore(e => e.DomainEvents);
// EN: Filter for soft delete / VI: Filter cho soft delete
entity.HasQueryFilter(e => !e.IsDeleted);
});
}
/// <inheritdoc />