279 lines
		
	
	
		
			9.8 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			279 lines
		
	
	
		
			9.8 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
| using System.ComponentModel.DataAnnotations;
 | |
| using System.Text.Json;
 | |
| using DysonNetwork.Drive.Billing;
 | |
| using DysonNetwork.Drive.Storage.Model;
 | |
| using DysonNetwork.Shared.Auth;
 | |
| using DysonNetwork.Shared.Http;
 | |
| using DysonNetwork.Shared.Proto;
 | |
| using Microsoft.AspNetCore.Authorization;
 | |
| using Microsoft.AspNetCore.Mvc;
 | |
| using Microsoft.EntityFrameworkCore;
 | |
| using NanoidDotNet;
 | |
| 
 | |
| namespace DysonNetwork.Drive.Storage;
 | |
| 
 | |
| [ApiController]
 | |
| [Route("/api/files/upload")]
 | |
| [Authorize]
 | |
| public class FileUploadController(
 | |
|     IConfiguration configuration,
 | |
|     FileService fileService,
 | |
|     AppDatabase db,
 | |
|     PermissionService.PermissionServiceClient permission,
 | |
|     QuotaService quotaService
 | |
| )
 | |
|     : ControllerBase
 | |
| {
 | |
|     private readonly string _tempPath =
 | |
|         configuration.GetValue<string>("Storage:Uploads") ?? Path.Combine(Path.GetTempPath(), "multipart-uploads");
 | |
| 
 | |
|     private const long DefaultChunkSize = 1024 * 1024 * 5; // 5MB
 | |
| 
 | |
|     [HttpPost("create")]
 | |
|     public async Task<IActionResult> CreateUploadTask([FromBody] CreateUploadTaskRequest request)
 | |
|     {
 | |
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | |
|         {
 | |
|             return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
 | |
|         }
 | |
| 
 | |
|         if (!currentUser.IsSuperuser)
 | |
|         {
 | |
|             var allowed = await permission.HasPermissionAsync(new HasPermissionRequest
 | |
|             { Actor = $"user:{currentUser.Id}", Area = "global", Key = "files.create" });
 | |
|             if (!allowed.HasPermission)
 | |
|             {
 | |
|                 return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         request.PoolId ??= Guid.Parse(configuration["Storage:PreferredRemote"]!);
 | |
| 
 | |
|         var pool = await fileService.GetPoolAsync(request.PoolId.Value);
 | |
|         if (pool is null)
 | |
|         {
 | |
|             return new ObjectResult(ApiError.NotFound("Pool")) { StatusCode = 404 };
 | |
|         }
 | |
| 
 | |
|         if (pool.PolicyConfig.RequirePrivilege is > 0)
 | |
|         {
 | |
|             var privilege =
 | |
|                 currentUser.PerkSubscription is null ? 0 :
 | |
|                 PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier);
 | |
|             if (privilege < pool.PolicyConfig.RequirePrivilege)
 | |
|             {
 | |
|                 return new ObjectResult(ApiError.Unauthorized(
 | |
|                     $"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use pool {pool.Name}, you are tier {privilege}",
 | |
|                     forbidden: true))
 | |
|                 {
 | |
|                     StatusCode = 403
 | |
|                 };
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         var policy = pool.PolicyConfig;
 | |
|         if (!policy.AllowEncryption && !string.IsNullOrEmpty(request.EncryptPassword))
 | |
|         {
 | |
|             return new ObjectResult(ApiError.Unauthorized("File encryption is not allowed in this pool", true))
 | |
|             { StatusCode = 403 };
 | |
|         }
 | |
| 
 | |
|         if (policy.AcceptTypes is { Count: > 0 })
 | |
|         {
 | |
|             if (string.IsNullOrEmpty(request.ContentType))
 | |
|             {
 | |
|                 return new ObjectResult(ApiError.Validation(new Dictionary<string, string[]>
 | |
|                 {
 | |
|                     { "contentType", new[] { "Content type is required by the pool's policy" } }
 | |
|                 }))
 | |
|                 { StatusCode = 400 };
 | |
|             }
 | |
| 
 | |
|             var foundMatch = policy.AcceptTypes.Any(acceptType =>
 | |
|             {
 | |
|                 if (acceptType.EndsWith("/*", StringComparison.OrdinalIgnoreCase))
 | |
|                 {
 | |
|                     var type = acceptType[..^2];
 | |
|                     return request.ContentType.StartsWith($"{type}/", StringComparison.OrdinalIgnoreCase);
 | |
|                 }
 | |
| 
 | |
|                 return acceptType.Equals(request.ContentType, StringComparison.OrdinalIgnoreCase);
 | |
|             });
 | |
| 
 | |
|             if (!foundMatch)
 | |
|             {
 | |
|                 return new ObjectResult(
 | |
|                     ApiError.Unauthorized($"Content type {request.ContentType} is not allowed by the pool's policy",
 | |
|                         true))
 | |
|                 { StatusCode = 403 };
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         if (policy.MaxFileSize is not null && request.FileSize > policy.MaxFileSize)
 | |
|         {
 | |
|             return new ObjectResult(ApiError.Unauthorized(
 | |
|                 $"File size {request.FileSize} is larger than the pool's maximum file size {policy.MaxFileSize}",
 | |
|                 true))
 | |
|             {
 | |
|                 StatusCode = 403
 | |
|             };
 | |
|         }
 | |
| 
 | |
|         var (ok, billableUnit, quota) = await quotaService.IsFileAcceptable(
 | |
|             Guid.Parse(currentUser.Id),
 | |
|             pool.BillingConfig.CostMultiplier ?? 1.0,
 | |
|             request.FileSize
 | |
|         );
 | |
|         if (!ok)
 | |
|         {
 | |
|             return new ObjectResult(
 | |
|                 ApiError.Unauthorized($"File size {billableUnit} MiB is exceeded the user's quota {quota} MiB",
 | |
|                     true))
 | |
|             { StatusCode = 403 };
 | |
|         }
 | |
| 
 | |
|         if (!Directory.Exists(_tempPath))
 | |
|         {
 | |
|             Directory.CreateDirectory(_tempPath);
 | |
|         }
 | |
| 
 | |
|         // Check if a file with the same hash already exists
 | |
|         var existingFile = await db.Files.FirstOrDefaultAsync(f => f.Hash == request.Hash);
 | |
|         if (existingFile != null)
 | |
|         {
 | |
|             return Ok(new CreateUploadTaskResponse
 | |
|             {
 | |
|                 FileExists = true,
 | |
|                 File = existingFile
 | |
|             });
 | |
|         }
 | |
| 
 | |
|         var taskId = await Nanoid.GenerateAsync();
 | |
|         var taskPath = Path.Combine(_tempPath, taskId);
 | |
|         Directory.CreateDirectory(taskPath);
 | |
| 
 | |
|         var chunkSize = request.ChunkSize ?? DefaultChunkSize;
 | |
|         var chunksCount = (int)Math.Ceiling((double)request.FileSize / chunkSize);
 | |
| 
 | |
|         var task = new UploadTask
 | |
|         {
 | |
|             TaskId = taskId,
 | |
|             FileName = request.FileName,
 | |
|             FileSize = request.FileSize,
 | |
|             ContentType = request.ContentType,
 | |
|             ChunkSize = chunkSize,
 | |
|             ChunksCount = chunksCount,
 | |
|             PoolId = request.PoolId.Value,
 | |
|             BundleId = request.BundleId,
 | |
|             EncryptPassword = request.EncryptPassword,
 | |
|             ExpiredAt = request.ExpiredAt,
 | |
|             Hash = request.Hash,
 | |
|         };
 | |
| 
 | |
|         await System.IO.File.WriteAllTextAsync(Path.Combine(taskPath, "task.json"), JsonSerializer.Serialize(task));
 | |
| 
 | |
|         return Ok(new CreateUploadTaskResponse
 | |
|         {
 | |
|             FileExists = false,
 | |
|             TaskId = taskId,
 | |
|             ChunkSize = chunkSize,
 | |
|             ChunksCount = chunksCount
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     public class UploadChunkRequest
 | |
|     {
 | |
|         [Required]
 | |
|         public IFormFile Chunk { get; set; } = null!;
 | |
|     }
 | |
| 
 | |
|     [HttpPost("chunk/{taskId}/{chunkIndex}")]
 | |
|     [RequestSizeLimit(DefaultChunkSize + 1024 * 1024)] // 6MB to be safe
 | |
|     [RequestFormLimits(MultipartBodyLengthLimit = DefaultChunkSize + 1024 * 1024)]
 | |
|     public async Task<IActionResult> UploadChunk(string taskId, int chunkIndex, [FromForm] UploadChunkRequest request)
 | |
|     {
 | |
|         var chunk = request.Chunk;
 | |
|         var taskPath = Path.Combine(_tempPath, taskId);
 | |
|         if (!Directory.Exists(taskPath))
 | |
|         {
 | |
|             return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
 | |
|         }
 | |
| 
 | |
|         var chunkPath = Path.Combine(taskPath, $"{chunkIndex}.chunk");
 | |
|         await using var stream = new FileStream(chunkPath, FileMode.Create);
 | |
|         await chunk.CopyToAsync(stream);
 | |
| 
 | |
|         return Ok();
 | |
|     }
 | |
| 
 | |
|     [HttpPost("complete/{taskId}")]
 | |
|     public async Task<IActionResult> CompleteUpload(string taskId)
 | |
|     {
 | |
|         var taskPath = Path.Combine(_tempPath, taskId);
 | |
|         if (!Directory.Exists(taskPath))
 | |
|         {
 | |
|             return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
 | |
|         }
 | |
| 
 | |
|         var taskJsonPath = Path.Combine(taskPath, "task.json");
 | |
|         if (!System.IO.File.Exists(taskJsonPath))
 | |
|         {
 | |
|             return new ObjectResult(ApiError.NotFound("Upload task metadata")) { StatusCode = 404 };
 | |
|         }
 | |
| 
 | |
|         var task = JsonSerializer.Deserialize<UploadTask>(await System.IO.File.ReadAllTextAsync(taskJsonPath));
 | |
|         if (task == null)
 | |
|         {
 | |
|             return new ObjectResult(new ApiError { Code = "BAD_REQUEST", Message = "Invalid task metadata.", Status = 400 })
 | |
|             { StatusCode = 400 };
 | |
|         }
 | |
| 
 | |
|         var mergedFilePath = Path.Combine(_tempPath, taskId + ".tmp");
 | |
|         await using (var mergedStream = new FileStream(mergedFilePath, FileMode.Create))
 | |
|         {
 | |
|             for (var i = 0; i < task.ChunksCount; i++)
 | |
|             {
 | |
|                 var chunkPath = Path.Combine(taskPath, $"{i}.chunk");
 | |
|                 if (!System.IO.File.Exists(chunkPath))
 | |
|                 {
 | |
|                     // Clean up partially uploaded file
 | |
|                     mergedStream.Close();
 | |
|                     System.IO.File.Delete(mergedFilePath);
 | |
|                     Directory.Delete(taskPath, true);
 | |
|                     return new ObjectResult(new ApiError
 | |
|                     { Code = "CHUNK_MISSING", Message = $"Chunk {i} is missing.", Status = 400 })
 | |
|                     { StatusCode = 400 };
 | |
|                 }
 | |
| 
 | |
|                 await using var chunkStream = new FileStream(chunkPath, FileMode.Open);
 | |
|                 await chunkStream.CopyToAsync(mergedStream);
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | |
|         {
 | |
|             return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
 | |
|         }
 | |
| 
 | |
|         var fileId = await Nanoid.GenerateAsync();
 | |
| 
 | |
|         var cloudFile = await fileService.ProcessNewFileAsync(
 | |
|             currentUser,
 | |
|             fileId,
 | |
|             task.PoolId.ToString(),
 | |
|             task.BundleId?.ToString(),
 | |
|             mergedFilePath,
 | |
|             task.FileName,
 | |
|             task.ContentType,
 | |
|             task.EncryptPassword,
 | |
|             task.ExpiredAt
 | |
|         );
 | |
| 
 | |
|         // Clean up
 | |
|         Directory.Delete(taskPath, true);
 | |
|         System.IO.File.Delete(mergedFilePath);
 | |
| 
 | |
|         return Ok(cloudFile);
 | |
|     }
 | |
| }
 |