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:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -66,5 +66,13 @@
|
||||
"AccessTokenExpiryMinutes": 15,
|
||||
"RefreshTokenExpiryDays": 7
|
||||
},
|
||||
"CDN": {
|
||||
"Enabled": false,
|
||||
"BaseUrl": "",
|
||||
"ImageCacheTtlSeconds": 2592000,
|
||||
"VideoCacheTtlSeconds": 2592000,
|
||||
"DocumentCacheTtlSeconds": 604800,
|
||||
"DefaultCacheTtlSeconds": 86400
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user