diff --git a/services/storage-service-net/src/StorageService.API/Application/Commands/ConfirmUploadCommandHandler.cs b/services/storage-service-net/src/StorageService.API/Application/Commands/ConfirmUploadCommandHandler.cs index bfb9b717..696ce583 100644 --- a/services/storage-service-net/src/StorageService.API/Application/Commands/ConfirmUploadCommandHandler.cs +++ b/services/storage-service-net/src/StorageService.API/Application/Commands/ConfirmUploadCommandHandler.cs @@ -133,6 +133,10 @@ public class ConfirmUploadCommandHandler : IRequestHandler +/// EN: Command to create a file share link. +/// VI: Command để tạo link chia sẻ file. +/// +public record CreateFileShareCommand( + Guid FileId, + string UserId, + SharePermission Permission, + string? SharedWith = null, + string? Password = null, + DateTime? ExpiresAt = null, + int? MaxDownloads = null +) : IRequest; + +/// +/// EN: Result of create file share operation. +/// VI: Kết quả của thao tác tạo file share. +/// +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); +} diff --git a/services/storage-service-net/src/StorageService.API/Application/Commands/FileShare/CreateFileShareCommandHandler.cs b/services/storage-service-net/src/StorageService.API/Application/Commands/FileShare/CreateFileShareCommandHandler.cs new file mode 100644 index 00000000..33f0b2f6 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Commands/FileShare/CreateFileShareCommandHandler.cs @@ -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; + +/// +/// EN: Handler for CreateFileShareCommand. +/// VI: Handler cho CreateFileShareCommand. +/// +public class CreateFileShareCommandHandler : IRequestHandler +{ + private readonly IFileRepository _fileRepository; + private readonly IFileShareRepository _fileShareRepository; + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + + public CreateFileShareCommandHandler( + IFileRepository fileRepository, + IFileShareRepository fileShareRepository, + ILogger logger, + IConfiguration configuration) + { + _fileRepository = fileRepository; + _fileShareRepository = fileShareRepository; + _logger = logger; + _configuration = configuration; + } + + public async Task 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."); + } + } +} diff --git a/services/storage-service-net/src/StorageService.API/Application/Commands/FileShare/RevokeFileShareCommand.cs b/services/storage-service-net/src/StorageService.API/Application/Commands/FileShare/RevokeFileShareCommand.cs new file mode 100644 index 00000000..752a0bc8 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Commands/FileShare/RevokeFileShareCommand.cs @@ -0,0 +1,22 @@ +using MediatR; + +namespace StorageService.API.Application.Commands.FileShare; + +/// +/// EN: Command to revoke a file share. +/// VI: Command để thu hồi file share. +/// +public record RevokeFileShareCommand( + Guid ShareId, + string UserId +) : IRequest; + +/// +/// EN: Result of revoke file share operation. +/// VI: Kết quả của thao tác thu hồi file share. +/// +public record RevokeFileShareResult(bool Success, string? Error) +{ + public static RevokeFileShareResult Ok() => new(true, null); + public static RevokeFileShareResult Fail(string error) => new(false, error); +} diff --git a/services/storage-service-net/src/StorageService.API/Application/Commands/FileShare/RevokeFileShareCommandHandler.cs b/services/storage-service-net/src/StorageService.API/Application/Commands/FileShare/RevokeFileShareCommandHandler.cs new file mode 100644 index 00000000..46a2a387 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Commands/FileShare/RevokeFileShareCommandHandler.cs @@ -0,0 +1,68 @@ +using MediatR; +using StorageService.Domain.AggregatesModel.FileShareAggregate; + +namespace StorageService.API.Application.Commands.FileShare; + +/// +/// EN: Handler for RevokeFileShareCommand. +/// VI: Handler cho RevokeFileShareCommand. +/// +public class RevokeFileShareCommandHandler : IRequestHandler +{ + private readonly IFileShareRepository _fileShareRepository; + private readonly ILogger _logger; + + public RevokeFileShareCommandHandler( + IFileShareRepository fileShareRepository, + ILogger logger) + { + _fileShareRepository = fileShareRepository; + _logger = logger; + } + + public async Task 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."); + } + } +} diff --git a/services/storage-service-net/src/StorageService.API/Application/Queries/FileDtos.cs b/services/storage-service-net/src/StorageService.API/Application/Queries/FileDtos.cs index 5172a567..05a44437 100644 --- a/services/storage-service-net/src/StorageService.API/Application/Queries/FileDtos.cs +++ b/services/storage-service-net/src/StorageService.API/Application/Queries/FileDtos.cs @@ -8,6 +8,7 @@ namespace StorageService.API.Application.Queries; /// 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, diff --git a/services/storage-service-net/src/StorageService.API/Application/Queries/FileQueryHandlers.cs b/services/storage-service-net/src/StorageService.API/Application/Queries/FileQueryHandlers.cs index e7cfdaa6..a2a004ce 100644 --- a/services/storage-service-net/src/StorageService.API/Application/Queries/FileQueryHandlers.cs +++ b/services/storage-service-net/src/StorageService.API/Application/Queries/FileQueryHandlers.cs @@ -12,20 +12,46 @@ namespace StorageService.API.Application.Queries; public class GetFileQueryHandler : IRequestHandler { private readonly IFileRepository _fileRepository; + private readonly Infrastructure.Caching.IRedisCacheService _cache; + private readonly ILogger _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 logger) { _fileRepository = fileRepository; + _cache = cache; + _logger = logger; } public async Task 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 public class GetUserFilesQueryHandler : IRequestHandler { private readonly IFileRepository _fileRepository; + private readonly ILogger _logger; - public GetUserFilesQueryHandler(IFileRepository fileRepository) + public GetUserFilesQueryHandler( + IFileRepository fileRepository, + ILogger logger) { _fileRepository = fileRepository; + _logger = logger; } public async Task 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 files; if (!string.IsNullOrWhiteSpace(request.SearchTerm)) diff --git a/services/storage-service-net/src/StorageService.API/Application/Queries/FileShareQueries.cs b/services/storage-service-net/src/StorageService.API/Application/Queries/FileShareQueries.cs new file mode 100644 index 00000000..33613d8f --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Queries/FileShareQueries.cs @@ -0,0 +1,61 @@ +using MediatR; +using StorageService.Domain.AggregatesModel.FileShareAggregate; + +namespace StorageService.API.Application.Queries; + +#region DTOs + +/// +/// EN: DTO for file share. +/// VI: DTO cho file share. +/// +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); + +/// +/// EN: Result of accessing a shared file. +/// VI: Kết quả truy cập file được chia sẻ. +/// +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 + +/// +/// EN: Query to get all shares for a file. +/// VI: Query để lấy tất cả shares của một file. +/// +public record GetFileSharesQuery(Guid FileId, string UserId) : IRequest>; + +/// +/// EN: Query to access a shared file by token. +/// VI: Query để truy cập file được chia sẻ bằng token. +/// +public record AccessSharedFileQuery(string Token, string? Password = null) : IRequest; + +#endregion diff --git a/services/storage-service-net/src/StorageService.API/Application/Queries/FileShareQueryHandlers.cs b/services/storage-service-net/src/StorageService.API/Application/Queries/FileShareQueryHandlers.cs new file mode 100644 index 00000000..50f86abd --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Queries/FileShareQueryHandlers.cs @@ -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; + +/// +/// EN: Handler for GetFileSharesQuery. +/// VI: Handler cho GetFileSharesQuery. +/// +public class GetFileSharesQueryHandler : IRequestHandler> +{ + private readonly IFileShareRepository _fileShareRepository; + private readonly IFileRepository _fileRepository; + private readonly ILogger _logger; + + public GetFileSharesQueryHandler( + IFileShareRepository fileShareRepository, + IFileRepository fileRepository, + ILogger logger) + { + _fileShareRepository = fileShareRepository; + _fileRepository = fileRepository; + _logger = logger; + } + + public async Task> 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(); + } + + 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)); + } +} + +/// +/// EN: Handler for AccessSharedFileQuery - public access via share token. +/// VI: Handler cho AccessSharedFileQuery - truy cập công khai qua share token. +/// +public class AccessSharedFileQueryHandler : IRequestHandler +{ + private readonly IFileShareRepository _fileShareRepository; + private readonly IFileRepository _fileRepository; + private readonly IStorageProviderFactory _storageProviderFactory; + private readonly ILogger _logger; + + public AccessSharedFileQueryHandler( + IFileShareRepository fileShareRepository, + IFileRepository fileRepository, + IStorageProviderFactory storageProviderFactory, + ILogger logger) + { + _fileShareRepository = fileShareRepository; + _fileRepository = fileRepository; + _storageProviderFactory = storageProviderFactory; + _logger = logger; + } + + public async Task 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."); + } + } +} diff --git a/services/storage-service-net/src/StorageService.API/Controllers/FileSharingController.cs b/services/storage-service-net/src/StorageService.API/Controllers/FileSharingController.cs new file mode 100644 index 00000000..5def440a --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Controllers/FileSharingController.cs @@ -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; + +/// +/// EN: Controller for file sharing operations. +/// VI: Controller cho các thao tác chia sẻ file. +/// +[ApiController] +[Route("api/v1/storage")] +[Produces("application/json")] +public class FileSharingController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public FileSharingController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Create a share link for a file. + /// VI: Tạo link chia sẻ cho file. + /// + [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))] + [SwaggerResponse(400, "Bad request")] + [SwaggerResponse(401, "Unauthorized")] + [SwaggerResponse(404, "File not found")] + public async Task>> CreateShare( + [FromRoute] Guid fileId, + [FromBody] CreateShareRequest request) + { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized(new ApiResponse { Success = false, Error = "User ID not found" }); + } + + // EN: Parse permission / VI: Parse permission + if (!Enum.TryParse(request.Permission, true, out var permission)) + { + return BadRequest(new ApiResponse + { + 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 { Success = false, Error = result.Error }); + + return BadRequest(new ApiResponse { Success = false, Error = result.Error }); + } + + return CreatedAtAction( + nameof(GetFileShares), + new { fileId }, + new ApiResponse + { + Success = true, + Data = new CreateShareResponse( + result.ShareId!.Value, + result.ShareToken!, + result.ShareUrl!) + }); + } + + /// + /// EN: Get all shares for a file. + /// VI: Lấy tất cả shares của một file. + /// + [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>))] + [SwaggerResponse(401, "Unauthorized")] + public async Task>>> GetFileShares([FromRoute] Guid fileId) + { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized(new ApiResponse> { Success = false, Error = "User ID not found" }); + } + + var query = new GetFileSharesQuery(fileId, userId); + var shares = await _mediator.Send(query); + + return Ok(new ApiResponse> + { + Success = true, + Data = shares + }); + } + + /// + /// EN: Revoke a file share. + /// VI: Thu hồi một file share. + /// + [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 RevokeShare([FromRoute] Guid shareId) + { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized(new ApiResponse { 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 { Success = false, Error = result.Error }); + + return BadRequest(new ApiResponse { Success = false, Error = result.Error }); + } + + return NoContent(); + } + + /// + /// EN: Access a shared file by token (public endpoint). + /// VI: Truy cập file được chia sẻ bằng token (endpoint công khai). + /// + [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))] + [SwaggerResponse(400, "Invalid password")] + [SwaggerResponse(404, "Share not found or expired")] + public async Task>> 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 { Success = false, Error = result.Error }); + + return BadRequest(new ApiResponse { Success = false, Error = result.Error }); + } + + return Ok(new ApiResponse + { + Success = true, + Data = result + }); + } +} + +#region Request/Response DTOs + +/// +/// EN: Request to create a file share. +/// VI: Request tạo file share. +/// +public record CreateShareRequest( + string Permission = "Download", + string? SharedWith = null, + string? Password = null, + DateTime? ExpiresAt = null, + int? MaxDownloads = null); + +/// +/// EN: Response for created share. +/// VI: Response cho share đã tạo. +/// +public record CreateShareResponse( + Guid ShareId, + string ShareToken, + string ShareUrl); + +#endregion + diff --git a/services/storage-service-net/src/StorageService.API/Controllers/FileVersioningController.cs b/services/storage-service-net/src/StorageService.API/Controllers/FileVersioningController.cs new file mode 100644 index 00000000..889ac9c0 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Controllers/FileVersioningController.cs @@ -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; + +/// +/// EN: Controller for file versioning operations. +/// VI: Controller cho các thao tác versioning file. +/// +[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 _logger; + + public FileVersioningController( + IFileRepository fileRepository, + IFileVersionRepository fileVersionRepository, + IStorageProviderFactory storageProviderFactory, + ILogger logger) + { + _fileRepository = fileRepository; + _fileVersionRepository = fileVersionRepository; + _storageProviderFactory = storageProviderFactory; + _logger = logger; + } + + /// + /// EN: Get all versions of a file. + /// VI: Lấy tất cả phiên bản của file. + /// + [HttpGet] + [SwaggerOperation(Summary = "Get file versions", Description = "Get all versions of a file")] + [SwaggerResponse(200, "Versions retrieved", typeof(ApiResponse>))] + [SwaggerResponse(404, "File not found")] + public async Task>>> GetVersions([FromRoute] Guid fileId) + { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized(new ApiResponse> { 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> { 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> + { + Success = true, + Data = versionDtos + }); + } + + /// + /// EN: Get download URL for a specific version. + /// VI: Lấy URL tải xuống cho phiên bản cụ thể. + /// + [HttpGet("{versionId:guid}/download")] + [SwaggerOperation(Summary = "Download version", Description = "Get download URL for a specific version")] + [SwaggerResponse(200, "Download URL generated", typeof(ApiResponse))] + [SwaggerResponse(404, "Version not found")] + public async Task>> DownloadVersion( + [FromRoute] Guid fileId, + [FromRoute] Guid versionId) + { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized(new ApiResponse { 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 { Success = false, Error = "File not found" }); + } + + var version = await _fileVersionRepository.GetByIdAsync(versionId); + if (version == null || version.FileId != fileId) + { + return NotFound(new ApiResponse { 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 + { + Success = true, + Data = new DownloadVersionResponse(downloadUrl, version.VersionNumber, file.FileName) + }); + } + + /// + /// EN: Restore a file to a specific version. + /// VI: Khôi phục file về phiên bản cụ thể. + /// + [HttpPost("{versionId:guid}/restore")] + [SwaggerOperation(Summary = "Restore version", Description = "Restore file to a specific version")] + [SwaggerResponse(200, "Version restored", typeof(ApiResponse))] + [SwaggerResponse(404, "Version not found")] + public async Task>> RestoreVersion( + [FromRoute] Guid fileId, + [FromRoute] Guid versionId) + { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized(new ApiResponse { 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 { Success = false, Error = "File not found" }); + } + + var versionToRestore = await _fileVersionRepository.GetByIdAsync(versionId); + if (versionToRestore == null || versionToRestore.FileId != fileId) + { + return NotFound(new ApiResponse { 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 + { + Success = true, + Data = new RestoreVersionResponse(versionToRestore.VersionNumber, "File restored successfully") + }); + } +} + +#region DTOs + +/// +/// EN: DTO for file version. +/// VI: DTO cho phiên bản file. +/// +public record FileVersionDto( + Guid Id, + Guid FileId, + int VersionNumber, + long SizeBytes, + string ContentType, + string? Checksum, + DateTime CreatedAt, + string CreatedBy, + string? Comment, + bool IsCurrent); + +/// +/// EN: Response for version download. +/// VI: Response cho tải xuống version. +/// +public record DownloadVersionResponse( + string DownloadUrl, + int VersionNumber, + string FileName); + +/// +/// EN: Response for version restore. +/// VI: Response cho khôi phục version. +/// +public record RestoreVersionResponse( + int RestoredVersionNumber, + string Message); + +#endregion diff --git a/services/storage-service-net/src/StorageService.API/Controllers/FilesController.cs b/services/storage-service-net/src/StorageService.API/Controllers/FilesController.cs index db04d722..65d5b048 100644 --- a/services/storage-service-net/src/StorageService.API/Controllers/FilesController.cs +++ b/services/storage-service-net/src/StorageService.API/Controllers/FilesController.cs @@ -179,6 +179,69 @@ public class FilesController : ControllerBase return Ok(new ApiResponse { Success = true, Data = result }); } + /// + /// EN: Get CDN URL for public files. + /// VI: Lấy URL CDN cho public files. + /// + /// + /// 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. + /// + [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>> GetCDNUrl( + Guid fileId, + CancellationToken cancellationToken = default) + { + var userId = GetUserId(); + if (string.IsNullOrEmpty(userId)) + return Unauthorized(new ApiResponse { 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 { 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(); + + 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 + { + 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 { Success = false, Error = downloadResult.Error }); + + return Ok(new ApiResponse + { + 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 public T? Data { get; set; } public string? Error { get; set; } } + +/// +/// EN: CDN URL response. +/// VI: Response URL CDN. +/// +public record CDNUrlResponse( + string Url, + bool IsCDN, + string Description); diff --git a/services/storage-service-net/src/StorageService.API/Controllers/FoldersController.cs b/services/storage-service-net/src/StorageService.API/Controllers/FoldersController.cs new file mode 100644 index 00000000..3e0e4dae --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Controllers/FoldersController.cs @@ -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; + +/// +/// EN: Controller for folder operations. +/// VI: Controller cho các thao tác folder. +/// +/// +/// 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. +/// +[ApiController] +[Route("api/v1/storage/folders")] +[Authorize] +[Produces("application/json")] +public class FoldersController : ControllerBase +{ + private readonly IFolderRepository _folderRepository; + private readonly ILogger _logger; + + public FoldersController( + IFolderRepository folderRepository, + ILogger logger) + { + _folderRepository = folderRepository; + _logger = logger; + } + + /// + /// EN: Create a new folder. + /// VI: Tạo folder mới. + /// + [HttpPost] + [SwaggerOperation(Summary = "Create folder", Description = "Create a new folder")] + [SwaggerResponse(201, "Folder created", typeof(ApiResponse))] + [SwaggerResponse(400, "Bad request")] + [SwaggerResponse(409, "Folder already exists")] + public async Task>> CreateFolder([FromBody] CreateFolderRequest request) + { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized(new ApiResponse { 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 + { + 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 { 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 { Success = true, Data = ToDto(folder) }); + } + + /// + /// EN: Get all root folders. + /// VI: Lấy tất cả root folders. + /// + [HttpGet] + [SwaggerOperation(Summary = "Get folders", Description = "Get all root folders or children of a parent")] + [SwaggerResponse(200, "Folders retrieved", typeof(ApiResponse>))] + public async Task>>> GetFolders([FromQuery] Guid? parentId = null) + { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized(new ApiResponse> { Success = false, Error = "User ID not found" }); + } + + IEnumerable 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> { Success = false, Error = "Parent folder not found" }); + } + folders = await _folderRepository.GetChildFoldersAsync(parentId.Value); + } + + return Ok(new ApiResponse> + { + Success = true, + Data = folders.Select(ToDto) + }); + } + + /// + /// EN: Get folder by ID. + /// VI: Lấy folder theo ID. + /// + [HttpGet("{folderId:guid}")] + [SwaggerOperation(Summary = "Get folder", Description = "Get folder details by ID")] + [SwaggerResponse(200, "Folder retrieved", typeof(ApiResponse))] + [SwaggerResponse(404, "Folder not found")] + public async Task>> GetFolder([FromRoute] Guid folderId) + { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized(new ApiResponse { Success = false, Error = "User ID not found" }); + } + + var folder = await _folderRepository.GetByIdAsync(folderId); + if (folder == null || folder.UserId != userId) + { + return NotFound(new ApiResponse { Success = false, Error = "Folder not found" }); + } + + return Ok(new ApiResponse { Success = true, Data = ToDto(folder) }); + } + + /// + /// EN: Rename folder. O(1) operation. + /// VI: Đổi tên folder. O(1). + /// + [HttpPut("{folderId:guid}")] + [SwaggerOperation(Summary = "Rename folder", Description = "Rename a folder (O(1) operation - database only)")] + [SwaggerResponse(200, "Folder renamed", typeof(ApiResponse))] + [SwaggerResponse(404, "Folder not found")] + [SwaggerResponse(409, "Folder name already exists")] + public async Task>> RenameFolder( + [FromRoute] Guid folderId, + [FromBody] RenameFolderRequest request) + { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized(new ApiResponse { Success = false, Error = "User ID not found" }); + } + + var folder = await _folderRepository.GetByIdAsync(folderId); + if (folder == null || folder.UserId != userId) + { + return NotFound(new ApiResponse { 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 + { + 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 { Success = true, Data = ToDto(folder) }); + } + + /// + /// EN: Delete folder. + /// VI: Xóa folder. + /// + [HttpDelete("{folderId:guid}")] + [SwaggerOperation(Summary = "Delete folder", Description = "Soft delete a folder")] + [SwaggerResponse(204, "Folder deleted")] + [SwaggerResponse(404, "Folder not found")] + public async Task DeleteFolder([FromRoute] Guid folderId) + { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized(new ApiResponse { Success = false, Error = "User ID not found" }); + } + + var folder = await _folderRepository.GetByIdAsync(folderId); + if (folder == null || folder.UserId != userId) + { + return NotFound(new ApiResponse { 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 + +/// EN: Folder DTO / VI: DTO Folder +public record FolderDto( + Guid Id, + Guid? ParentId, + string Name, + string Path, + int Level, + DateTime CreatedAt, + DateTime UpdatedAt); + +/// EN: Request to create folder / VI: Request tạo folder +public record CreateFolderRequest(string Name, Guid? ParentId = null); + +/// EN: Request to rename folder / VI: Request đổi tên folder +public record RenameFolderRequest(string Name); + +#endregion diff --git a/services/storage-service-net/src/StorageService.API/appsettings.json b/services/storage-service-net/src/StorageService.API/appsettings.json index d810db16..1c39db80 100644 --- a/services/storage-service-net/src/StorageService.API/appsettings.json +++ b/services/storage-service-net/src/StorageService.API/appsettings.json @@ -66,5 +66,13 @@ "AccessTokenExpiryMinutes": 15, "RefreshTokenExpiryDays": 7 }, + "CDN": { + "Enabled": false, + "BaseUrl": "", + "ImageCacheTtlSeconds": 2592000, + "VideoCacheTtlSeconds": 2592000, + "DocumentCacheTtlSeconds": 604800, + "DefaultCacheTtlSeconds": 86400 + }, "AllowedHosts": "*" } \ No newline at end of file diff --git a/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileAggregate/StorageFile.cs b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileAggregate/StorageFile.cs index 458d4a12..595d0d6c 100644 --- a/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileAggregate/StorageFile.cs +++ b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileAggregate/StorageFile.cs @@ -137,4 +137,18 @@ public class StorageFile : Entity, IAggregateRoot ExpiresAt = expiresAt; } + + /// + /// EN: Update file from a restored version. + /// VI: Cập nhật file từ version được khôi phục. + /// + public void UpdateFromVersion(string objectKey, long sizeBytes, string contentType) + { + if (IsDeleted) + throw new InvalidOperationException("Cannot update deleted file"); + + ObjectKey = objectKey; + FileSizeBytes = sizeBytes; + ContentType = contentType; + } } diff --git a/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileShareAggregate/FileShare.cs b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileShareAggregate/FileShare.cs new file mode 100644 index 00000000..af20f261 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileShareAggregate/FileShare.cs @@ -0,0 +1,190 @@ +using System.Security.Cryptography; +using StorageService.Domain.SeedWork; + +namespace StorageService.Domain.AggregatesModel.FileShareAggregate; + +/// +/// 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. +/// +public class FileShare : Entity, IAggregateRoot +{ + /// EN: File being shared / VI: File được chia sẻ + public Guid FileId { get; private set; } + + /// EN: User who created the share / VI: User tạo share + public string SharedBy { get; private set; } = default!; + + /// EN: User the file is shared with (null = public link) / VI: User được share (null = link công khai) + public string? SharedWith { get; private set; } + + /// EN: Share permission level / VI: Mức quyền chia sẻ + public SharePermission Permission { get; private set; } + + /// EN: Unique share token / VI: Token chia sẻ duy nhất + public string ShareToken { get; private set; } = default!; + + /// EN: Optional password hash / VI: Hash mật khẩu (tùy chọn) + public string? PasswordHash { get; private set; } + + /// EN: Share expiration date / VI: Ngày hết hạn chia sẻ + public DateTime? ExpiresAt { get; private set; } + + /// EN: Maximum download count / VI: Số lần tải xuống tối đa + public int? MaxDownloads { get; private set; } + + /// EN: Current download count / VI: Số lần đã tải xuống + public int DownloadCount { get; private set; } + + /// EN: Share status / VI: Trạng thái chia sẻ + public FileShareStatus Status { get; private set; } + + /// EN: Created timestamp / VI: Thời gian tạo + public DateTime CreatedAt { get; private set; } + + /// EN: Revoked timestamp / VI: Thời gian thu hồi + public DateTime? RevokedAt { get; private set; } + + // EN: For EF Core / VI: Cho EF Core + protected FileShare() { } + + /// + /// EN: Create a new file share. + /// VI: Tạo file share mới. + /// + 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); + } + } + + /// + /// 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). + /// + 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; + } + + /// + /// 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. + /// + 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); + } + + /// + /// EN: Increment download count. + /// VI: Tăng số lần tải xuống. + /// + public void IncrementDownloadCount() + { + DownloadCount++; + + if (MaxDownloads.HasValue && DownloadCount >= MaxDownloads.Value) + { + Status = FileShareStatus.LimitReached; + } + } + + /// + /// EN: Revoke the share. + /// VI: Thu hồi chia sẻ. + /// + public void Revoke() + { + Status = FileShareStatus.Revoked; + RevokedAt = DateTime.UtcNow; + } + + /// + /// EN: Generate a unique share token. + /// VI: Tạo token chia sẻ duy nhất. + /// + private static string GenerateShareToken() + { + var bytes = RandomNumberGenerator.GetBytes(32); + return Convert.ToBase64String(bytes) + .Replace("+", "-") + .Replace("/", "_") + .Replace("=", ""); + } + + /// + /// EN: Hash password using PBKDF2. + /// VI: Hash mật khẩu sử dụng PBKDF2. + /// + 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); + } + + /// + /// EN: Verify password against hash. + /// VI: Xác thực mật khẩu với hash. + /// + 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); + } +} diff --git a/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileShareAggregate/IFileShareRepository.cs b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileShareAggregate/IFileShareRepository.cs new file mode 100644 index 00000000..d7de77f1 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileShareAggregate/IFileShareRepository.cs @@ -0,0 +1,52 @@ +using StorageService.Domain.SeedWork; + +namespace StorageService.Domain.AggregatesModel.FileShareAggregate; + +/// +/// EN: Repository interface for FileShare aggregate. +/// VI: Interface repository cho FileShare aggregate. +/// +public interface IFileShareRepository : IRepository +{ + /// + /// EN: Get file share by ID. + /// VI: Lấy file share theo ID. + /// + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// EN: Get file share by token. + /// VI: Lấy file share theo token. + /// + Task GetByTokenAsync(string token, CancellationToken cancellationToken = default); + + /// + /// EN: Get all shares for a file. + /// VI: Lấy tất cả shares cho một file. + /// + Task> GetByFileIdAsync(Guid fileId, CancellationToken cancellationToken = default); + + /// + /// EN: Get all shares by a user. + /// VI: Lấy tất cả shares bởi một user. + /// + Task> GetBySharedByAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// EN: Get all shares with a user. + /// VI: Lấy tất cả shares với một user. + /// + Task> GetSharedWithAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// EN: Add a new file share. + /// VI: Thêm file share mới. + /// + Task AddAsync(FileShare fileShare, CancellationToken cancellationToken = default); + + /// + /// EN: Update a file share. + /// VI: Cập nhật file share. + /// + void Update(FileShare fileShare); +} diff --git a/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileShareAggregate/SharePermission.cs b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileShareAggregate/SharePermission.cs new file mode 100644 index 00000000..dc6d2650 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileShareAggregate/SharePermission.cs @@ -0,0 +1,39 @@ +namespace StorageService.Domain.AggregatesModel.FileShareAggregate; + +/// +/// EN: Share permission levels. +/// VI: Các mức quyền chia sẻ. +/// +public enum SharePermission +{ + /// EN: View only / VI: Chỉ xem + View = 0, + + /// EN: Download allowed / VI: Được phép tải xuống + Download = 1, + + /// EN: Edit allowed / VI: Được phép chỉnh sửa + Edit = 2, + + /// EN: Full admin access / VI: Quyền admin đầy đủ + Admin = 3 +} + +/// +/// EN: File share status. +/// VI: Trạng thái chia sẻ file. +/// +public enum FileShareStatus +{ + /// EN: Active share / VI: Chia sẻ đang hoạt động + Active = 0, + + /// EN: Expired share / VI: Chia sẻ đã hết hạn + Expired = 1, + + /// EN: Revoked share / VI: Chia sẻ đã bị thu hồi + Revoked = 2, + + /// EN: Download limit reached / VI: Đã đạt giới hạn tải xuống + LimitReached = 3 +} diff --git a/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileVersionAggregate/FileVersion.cs b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileVersionAggregate/FileVersion.cs new file mode 100644 index 00000000..a337a8a6 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileVersionAggregate/FileVersion.cs @@ -0,0 +1,88 @@ +using StorageService.Domain.SeedWork; + +namespace StorageService.Domain.AggregatesModel.FileVersionAggregate; + +/// +/// 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. +/// +public class FileVersion : Entity +{ + /// EN: File this version belongs to / VI: File mà version này thuộc về + public Guid FileId { get; private set; } + + /// EN: Version number (1, 2, 3...) / VI: Số phiên bản (1, 2, 3...) + public int VersionNumber { get; private set; } + + /// EN: Object key in storage / VI: Object key trong storage + public string ObjectKey { get; private set; } = default!; + + /// EN: File size in bytes / VI: Kích thước file tính bằng bytes + public long SizeBytes { get; private set; } + + /// EN: Content type / VI: Loại nội dung + public string ContentType { get; private set; } = default!; + + /// EN: Checksum/Hash of the file / VI: Checksum/Hash của file + public string? Checksum { get; private set; } + + /// EN: Version creation timestamp / VI: Thời gian tạo phiên bản + public DateTime CreatedAt { get; private set; } + + /// EN: User who created this version / VI: User tạo phiên bản này + public string CreatedBy { get; private set; } = default!; + + /// EN: Optional comment for version / VI: Ghi chú tùy chọn cho phiên bản + public string? Comment { get; private set; } + + /// EN: Whether this is the current version / VI: Có phải phiên bản hiện tại không + public bool IsCurrent { get; private set; } + + // EN: For EF Core / VI: Cho EF Core + protected FileVersion() { } + + /// + /// EN: Create a new file version. + /// VI: Tạo phiên bản file mới. + /// + 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; + } + + /// + /// EN: Mark this version as current. + /// VI: Đánh dấu phiên bản này là hiện tại. + /// + public void MarkAsCurrent() + { + IsCurrent = true; + } + + /// + /// EN: Unmark this version as current. + /// VI: Bỏ đánh dấu phiên bản này là hiện tại. + /// + public void UnmarkAsCurrent() + { + IsCurrent = false; + } +} diff --git a/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileVersionAggregate/IFileVersionRepository.cs b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileVersionAggregate/IFileVersionRepository.cs new file mode 100644 index 00000000..6ca67d25 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileVersionAggregate/IFileVersionRepository.cs @@ -0,0 +1,53 @@ +using StorageService.Domain.SeedWork; + +namespace StorageService.Domain.AggregatesModel.FileVersionAggregate; + +/// +/// EN: Repository interface for FileVersion entity. +/// VI: Interface repository cho entity FileVersion. +/// +public interface IFileVersionRepository +{ + IUnitOfWork UnitOfWork { get; } + /// + /// EN: Get version by ID. + /// VI: Lấy version theo ID. + /// + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// 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. + /// + Task> GetByFileIdAsync(Guid fileId, CancellationToken cancellationToken = default); + + /// + /// EN: Get specific version of a file. + /// VI: Lấy version cụ thể của file. + /// + Task GetByFileIdAndVersionAsync(Guid fileId, int versionNumber, CancellationToken cancellationToken = default); + + /// + /// EN: Get current version of a file. + /// VI: Lấy version hiện tại của file. + /// + Task GetCurrentVersionAsync(Guid fileId, CancellationToken cancellationToken = default); + + /// + /// EN: Get latest version number for a file. + /// VI: Lấy số version mới nhất của file. + /// + Task GetLatestVersionNumberAsync(Guid fileId, CancellationToken cancellationToken = default); + + /// + /// EN: Add new version. + /// VI: Thêm version mới. + /// + Task AddAsync(FileVersion version, CancellationToken cancellationToken = default); + + /// + /// EN: Update version. + /// VI: Cập nhật version. + /// + void Update(FileVersion version); +} diff --git a/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FolderAggregate/Folder.cs b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FolderAggregate/Folder.cs new file mode 100644 index 00000000..09d72ff7 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FolderAggregate/Folder.cs @@ -0,0 +1,138 @@ +using StorageService.Domain.SeedWork; + +namespace StorageService.Domain.AggregatesModel.FolderAggregate; + +/// +/// 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. +/// +/// +/// 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. +/// +public class Folder : Entity, IAggregateRoot +{ + /// EN: Owner user ID / VI: ID user sở hữu + public string UserId { get; private set; } = default!; + + /// EN: Parent folder ID (null = root) / VI: ID folder cha (null = root) + public Guid? ParentId { get; private set; } + + /// EN: Folder name / VI: Tên folder + public string Name { get; private set; } = default!; + + /// EN: Materialized path (e.g., /parent/child/) / VI: Đường dẫn (vd: /parent/child/) + public string Path { get; private set; } = default!; + + /// EN: Nesting level (0 = root level) / VI: Cấp độ lồng (0 = cấp root) + public int Level { get; private set; } + + /// EN: Created timestamp / VI: Thời gian tạo + public DateTime CreatedAt { get; private set; } + + /// EN: Updated timestamp / VI: Thời gian cập nhật + public DateTime UpdatedAt { get; private set; } + + /// EN: Soft delete flag / VI: Cờ xóa mềm + public bool IsDeleted { get; private set; } + + /// EN: Deleted timestamp / VI: Thời gian xóa + public DateTime? DeletedAt { get; private set; } + + // EN: For EF Core / VI: Cho EF Core + protected Folder() { } + + /// + /// EN: Create a new root folder. + /// VI: Tạo folder root mới. + /// + 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 + }; + } + + /// + /// EN: Create a child folder. + /// VI: Tạo folder con. + /// + 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 + }; + } + + /// + /// EN: Rename folder. O(1) operation - only database update. + /// VI: Đổi tên folder. O(1) - chỉ update database. + /// + 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; + } + + /// + /// EN: Move folder to new parent. + /// VI: Di chuyển folder sang parent mới. + /// + 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; + } + + /// + /// EN: Soft delete folder. + /// VI: Xóa mềm folder. + /// + public void Delete() + { + if (IsDeleted) + return; + + IsDeleted = true; + DeletedAt = DateTime.UtcNow; + } +} diff --git a/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FolderAggregate/IFolderRepository.cs b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FolderAggregate/IFolderRepository.cs new file mode 100644 index 00000000..dad5a3f8 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FolderAggregate/IFolderRepository.cs @@ -0,0 +1,34 @@ +using StorageService.Domain.SeedWork; + +namespace StorageService.Domain.AggregatesModel.FolderAggregate; + +/// +/// EN: Repository interface for Folder aggregate. +/// VI: Interface repository cho Folder aggregate. +/// +public interface IFolderRepository : IRepository +{ + /// EN: Get folder by ID / VI: Lấy folder theo ID + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// EN: Get all root folders for user / VI: Lấy tất cả root folders của user + Task> GetRootFoldersAsync(string userId, CancellationToken cancellationToken = default); + + /// EN: Get child folders / VI: Lấy folders con + Task> GetChildFoldersAsync(Guid parentId, CancellationToken cancellationToken = default); + + /// EN: Get folder by path / VI: Lấy folder theo path + Task GetByPathAsync(string userId, string path, CancellationToken cancellationToken = default); + + /// EN: Get all descendant folders / VI: Lấy tất cả folders con cháu + Task> GetDescendantsAsync(Guid folderId, CancellationToken cancellationToken = default); + + /// EN: Add new folder / VI: Thêm folder mới + Task AddAsync(Folder folder, CancellationToken cancellationToken = default); + + /// EN: Update folder / VI: Cập nhật folder + void Update(Folder folder); + + /// EN: Check if folder name exists in parent / VI: Kiểm tra tên folder đã tồn tại trong parent + Task ExistsAsync(string userId, Guid? parentId, string name, CancellationToken cancellationToken = default); +} diff --git a/services/storage-service-net/src/StorageService.Infrastructure/CDN/CDNService.cs b/services/storage-service-net/src/StorageService.Infrastructure/CDN/CDNService.cs new file mode 100644 index 00000000..f80effb8 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Infrastructure/CDN/CDNService.cs @@ -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; + +/// +/// EN: CDN service implementation for generating CDN URLs. +/// VI: Implementation CDN service để tạo CDN URLs. +/// +public class CDNService : ICDNService +{ + private readonly CDNSettings _settings; + private readonly ILogger _logger; + + public CDNService( + IOptions settings, + ILogger logger) + { + _settings = settings.Value; + _logger = logger; + } + + /// + public bool IsEnabled => _settings.Enabled && !string.IsNullOrEmpty(_settings.BaseUrl); + + /// + 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; + } + + /// + 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); + } + + /// + /// EN: Combine base URL with path. + /// VI: Kết hợp base URL với path. + /// + private static string CombineUrl(string baseUrl, string path) + { + var trimmedBase = baseUrl.TrimEnd('/'); + var trimmedPath = path.TrimStart('/'); + return $"{trimmedBase}/{trimmedPath}"; + } +} diff --git a/services/storage-service-net/src/StorageService.Infrastructure/CDN/ICDNService.cs b/services/storage-service-net/src/StorageService.Infrastructure/CDN/ICDNService.cs new file mode 100644 index 00000000..b1ab6064 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Infrastructure/CDN/ICDNService.cs @@ -0,0 +1,44 @@ +using StorageService.Domain.AggregatesModel.FileAggregate; + +namespace StorageService.Infrastructure.CDN; + +/// +/// EN: CDN service interface for generating CDN URLs. +/// VI: Interface CDN service để tạo CDN URLs. +/// +public interface ICDNService +{ + /// + /// EN: Check if CDN is enabled. + /// VI: Kiểm tra CDN có được bật hay không. + /// + bool IsEnabled { get; } + + /// + /// EN: Generate CDN URL for a file. + /// VI: Tạo CDN URL cho file. + /// + /// Storage object key / Key của object trong storage + /// File access level / Mức truy cập file + /// CDN URL or null if CDN not applicable / URL CDN hoặc null nếu không áp dụng CDN + string? GetCDNUrl(string objectKey, FileAccessLevel accessLevel); + + /// + /// EN: Generate CDN URL with cache headers. + /// VI: Tạo CDN URL với cache headers. + /// + /// Storage object key / Key của object trong storage + /// File access level / Mức truy cập file + /// Content type for cache TTL / Content type để xác định TTL cache + /// CDN URL result with cache info / Kết quả URL CDN với thông tin cache + CDNUrlResult? GetCDNUrlWithCacheInfo(string objectKey, FileAccessLevel accessLevel, string contentType); +} + +/// +/// EN: CDN URL generation result. +/// VI: Kết quả tạo CDN URL. +/// +public record CDNUrlResult( + string Url, + int CacheTtlSeconds, + string CacheControl); diff --git a/services/storage-service-net/src/StorageService.Infrastructure/Configuration/CDNSettings.cs b/services/storage-service-net/src/StorageService.Infrastructure/Configuration/CDNSettings.cs new file mode 100644 index 00000000..b2063070 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Infrastructure/Configuration/CDNSettings.cs @@ -0,0 +1,49 @@ +namespace StorageService.Infrastructure.Configuration; + +/// +/// EN: CDN configuration settings. +/// VI: Cấu hình CDN. +/// +public class CDNSettings +{ + /// EN: Enable CDN for public files / VI: Bật CDN cho public files + public bool Enabled { get; set; } = false; + + /// EN: CDN base URL / VI: URL gốc CDN + public string BaseUrl { get; set; } = string.Empty; + + /// EN: Cache TTL for images (seconds) / VI: TTL cache cho images (giây) + public int ImageCacheTtlSeconds { get; set; } = 2592000; // 30 days + + /// EN: Cache TTL for videos (seconds) / VI: TTL cache cho videos (giây) + public int VideoCacheTtlSeconds { get; set; } = 2592000; // 30 days + + /// EN: Cache TTL for documents (seconds) / VI: TTL cache cho documents (giây) + public int DocumentCacheTtlSeconds { get; set; } = 604800; // 7 days + + /// EN: Cache TTL for other files (seconds) / VI: TTL cache cho files khác (giây) + public int DefaultCacheTtlSeconds { get; set; } = 86400; // 1 day + + /// + /// EN: Get cache TTL based on content type. + /// VI: Lấy TTL cache theo content type. + /// + 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; + } +} diff --git a/services/storage-service-net/src/StorageService.Infrastructure/DependencyInjection.cs b/services/storage-service-net/src/StorageService.Infrastructure/DependencyInjection.cs index ecef0b83..8bc0602a 100644 --- a/services/storage-service-net/src/StorageService.Infrastructure/DependencyInjection.cs +++ b/services/storage-service-net/src/StorageService.Infrastructure/DependencyInjection.cs @@ -34,10 +34,14 @@ public static class DependencyInjection services.Configure(configuration.GetSection(StorageSettings.SectionName)); services.Configure(configuration.GetSection(IamServiceSettings.SectionName)); services.Configure(configuration.GetSection(RedisSettings.SectionName)); + services.Configure(configuration.GetSection("CDN")); // EN: Register Redis cache service / VI: Đăng ký Redis cache service services.AddSingleton(); + // EN: Register CDN service / VI: Đăng ký CDN service + services.AddSingleton(); + // 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(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // EN: Register storage providers / VI: Đăng ký storage providers services.AddSingleton(); diff --git a/services/storage-service-net/src/StorageService.Infrastructure/Persistence/Repositories/FileShareRepository.cs b/services/storage-service-net/src/StorageService.Infrastructure/Persistence/Repositories/FileShareRepository.cs new file mode 100644 index 00000000..73380f2a --- /dev/null +++ b/services/storage-service-net/src/StorageService.Infrastructure/Persistence/Repositories/FileShareRepository.cs @@ -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; + +/// +/// EN: Repository implementation for FileShare aggregate. +/// VI: Implementation repository cho FileShare aggregate. +/// +public class FileShareRepository : IFileShareRepository +{ + private readonly StorageServiceContext _context; + + public FileShareRepository(StorageServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public IUnitOfWork UnitOfWork => _context; + + /// + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.FileShares + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + } + + /// + public async Task GetByTokenAsync(string token, CancellationToken cancellationToken = default) + { + return await _context.FileShares + .FirstOrDefaultAsync(x => x.ShareToken == token, cancellationToken); + } + + /// + public async Task> 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); + } + + /// + public async Task> GetBySharedByAsync(string userId, CancellationToken cancellationToken = default) + { + return await _context.FileShares + .Where(x => x.SharedBy == userId) + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + } + + /// + public async Task> 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); + } + + /// + public async Task AddAsync(DomainFileShare fileShare, CancellationToken cancellationToken = default) + { + await _context.FileShares.AddAsync(fileShare, cancellationToken); + return fileShare; + } + + /// + public void Update(DomainFileShare fileShare) + { + _context.Entry(fileShare).State = EntityState.Modified; + } +} diff --git a/services/storage-service-net/src/StorageService.Infrastructure/Persistence/Repositories/FileVersionRepository.cs b/services/storage-service-net/src/StorageService.Infrastructure/Persistence/Repositories/FileVersionRepository.cs new file mode 100644 index 00000000..ab08a4a6 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Infrastructure/Persistence/Repositories/FileVersionRepository.cs @@ -0,0 +1,74 @@ +using Microsoft.EntityFrameworkCore; +using StorageService.Domain.AggregatesModel.FileVersionAggregate; +using StorageService.Domain.SeedWork; + +namespace StorageService.Infrastructure.Persistence.Repositories; + +/// +/// EN: Repository implementation for FileVersion entity. +/// VI: Implementation repository cho entity FileVersion. +/// +public class FileVersionRepository : IFileVersionRepository +{ + private readonly StorageServiceContext _context; + + public FileVersionRepository(StorageServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public IUnitOfWork UnitOfWork => _context; + + /// + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.FileVersions + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + } + + /// + public async Task> GetByFileIdAsync(Guid fileId, CancellationToken cancellationToken = default) + { + return await _context.FileVersions + .Where(x => x.FileId == fileId) + .OrderByDescending(x => x.VersionNumber) + .ToListAsync(cancellationToken); + } + + /// + public async Task GetByFileIdAndVersionAsync(Guid fileId, int versionNumber, CancellationToken cancellationToken = default) + { + return await _context.FileVersions + .FirstOrDefaultAsync(x => x.FileId == fileId && x.VersionNumber == versionNumber, cancellationToken); + } + + /// + public async Task GetCurrentVersionAsync(Guid fileId, CancellationToken cancellationToken = default) + { + return await _context.FileVersions + .FirstOrDefaultAsync(x => x.FileId == fileId && x.IsCurrent, cancellationToken); + } + + /// + public async Task 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; + } + + /// + public async Task AddAsync(FileVersion version, CancellationToken cancellationToken = default) + { + await _context.FileVersions.AddAsync(version, cancellationToken); + return version; + } + + /// + public void Update(FileVersion version) + { + _context.Entry(version).State = EntityState.Modified; + } +} diff --git a/services/storage-service-net/src/StorageService.Infrastructure/Persistence/Repositories/FolderRepository.cs b/services/storage-service-net/src/StorageService.Infrastructure/Persistence/Repositories/FolderRepository.cs new file mode 100644 index 00000000..7131eddb --- /dev/null +++ b/services/storage-service-net/src/StorageService.Infrastructure/Persistence/Repositories/FolderRepository.cs @@ -0,0 +1,87 @@ +using Microsoft.EntityFrameworkCore; +using StorageService.Domain.AggregatesModel.FolderAggregate; +using StorageService.Domain.SeedWork; + +namespace StorageService.Infrastructure.Persistence.Repositories; + +/// +/// EN: Repository implementation for Folder aggregate. +/// VI: Implementation repository cho Folder aggregate. +/// +public class FolderRepository : IFolderRepository +{ + private readonly StorageServiceContext _context; + + public FolderRepository(StorageServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public IUnitOfWork UnitOfWork => _context; + + /// + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Folders + .FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted, cancellationToken); + } + + /// + public async Task> 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); + } + + /// + public async Task> GetChildFoldersAsync(Guid parentId, CancellationToken cancellationToken = default) + { + return await _context.Folders + .Where(x => x.ParentId == parentId && !x.IsDeleted) + .OrderBy(x => x.Name) + .ToListAsync(cancellationToken); + } + + /// + public async Task GetByPathAsync(string userId, string path, CancellationToken cancellationToken = default) + { + return await _context.Folders + .FirstOrDefaultAsync(x => x.UserId == userId && x.Path == path && !x.IsDeleted, cancellationToken); + } + + /// + public async Task> GetDescendantsAsync(Guid folderId, CancellationToken cancellationToken = default) + { + var folder = await GetByIdAsync(folderId, cancellationToken); + if (folder == null) return Enumerable.Empty(); + + // 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); + } + + /// + public async Task AddAsync(Folder folder, CancellationToken cancellationToken = default) + { + await _context.Folders.AddAsync(folder, cancellationToken); + return folder; + } + + /// + public void Update(Folder folder) + { + _context.Entry(folder).State = EntityState.Modified; + } + + /// + public async Task 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); + } +} diff --git a/services/storage-service-net/src/StorageService.Infrastructure/Persistence/StorageServiceContext.cs b/services/storage-service-net/src/StorageService.Infrastructure/Persistence/StorageServiceContext.cs index 93cbbedf..c2d64f6b 100644 --- a/services/storage-service-net/src/StorageService.Infrastructure/Persistence/StorageServiceContext.cs +++ b/services/storage-service-net/src/StorageService.Infrastructure/Persistence/StorageServiceContext.cs @@ -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; /// @@ -22,6 +29,9 @@ public class StorageServiceContext : DbContext, IUnitOfWork public DbSet UserStorageQuotas => Set(); public DbSet MultipartUploads => Set(); public DbSet MultipartUploadParts => Set(); + public DbSet FileShares => Set(); + public DbSet FileVersions => Set(); + public DbSet Folders => Set(); 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(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() + .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() + .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() + .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(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() + .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(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() + .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); + }); } ///