✨ File Persistent Task
This commit is contained in:
@@ -10,6 +10,8 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NanoidDotNet;
|
||||
using NodaTime;
|
||||
using TaskStatus = DysonNetwork.Drive.Storage.Model.TaskStatus;
|
||||
|
||||
namespace DysonNetwork.Drive.Storage;
|
||||
|
||||
@@ -21,7 +23,8 @@ public class FileUploadController(
|
||||
FileService fileService,
|
||||
AppDatabase db,
|
||||
PermissionService.PermissionServiceClient permission,
|
||||
QuotaService quotaService
|
||||
QuotaService quotaService,
|
||||
PersistentUploadService persistentUploadService
|
||||
)
|
||||
: ControllerBase
|
||||
{
|
||||
@@ -68,13 +71,18 @@ public class FileUploadController(
|
||||
});
|
||||
}
|
||||
|
||||
var (taskId, task) = await CreateUploadTaskInternal(request);
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var taskId = await Nanoid.GenerateAsync();
|
||||
|
||||
// Create persistent upload task
|
||||
var persistentTask = await persistentUploadService.CreateUploadTaskAsync(taskId, request, accountId);
|
||||
|
||||
return Ok(new CreateUploadTaskResponse
|
||||
{
|
||||
FileExists = false,
|
||||
TaskId = taskId,
|
||||
ChunkSize = task.ChunkSize,
|
||||
ChunksCount = task.ChunksCount
|
||||
ChunkSize = persistentTask.ChunkSize,
|
||||
ChunksCount = persistentTask.ChunksCount
|
||||
});
|
||||
}
|
||||
|
||||
@@ -221,65 +229,86 @@ public class FileUploadController(
|
||||
public async Task<IActionResult> UploadChunk(string taskId, int chunkIndex, [FromForm] UploadChunkRequest request)
|
||||
{
|
||||
var chunk = request.Chunk;
|
||||
|
||||
// Check if chunk is already uploaded (resumable upload)
|
||||
if (await persistentUploadService.IsChunkUploadedAsync(taskId, chunkIndex))
|
||||
{
|
||||
return Ok(new { message = "Chunk already uploaded" });
|
||||
}
|
||||
|
||||
var taskPath = Path.Combine(_tempPath, taskId);
|
||||
if (!Directory.Exists(taskPath))
|
||||
{
|
||||
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
||||
Directory.CreateDirectory(taskPath);
|
||||
}
|
||||
|
||||
var chunkPath = Path.Combine(taskPath, $"{chunkIndex}.chunk");
|
||||
await using var stream = new FileStream(chunkPath, FileMode.Create);
|
||||
await chunk.CopyToAsync(stream);
|
||||
|
||||
// Update persistent task progress
|
||||
await persistentUploadService.UpdateChunkProgressAsync(taskId, chunkIndex);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpPost("complete/{taskId}")]
|
||||
public async Task<IActionResult> CompleteUpload(string taskId)
|
||||
{
|
||||
var taskPath = Path.Combine(_tempPath, taskId);
|
||||
if (!Directory.Exists(taskPath))
|
||||
// Get persistent task
|
||||
var persistentTask = await persistentUploadService.GetUploadTaskAsync(taskId);
|
||||
if (persistentTask is null)
|
||||
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 currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||
if (currentUser is null)
|
||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||
|
||||
// Verify ownership
|
||||
if (persistentTask.AccountId != Guid.Parse(currentUser.Id))
|
||||
return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
|
||||
|
||||
var taskPath = Path.Combine(_tempPath, taskId);
|
||||
if (!Directory.Exists(taskPath))
|
||||
return new ObjectResult(ApiError.NotFound("Upload task directory")) { StatusCode = 404 };
|
||||
|
||||
var mergedFilePath = Path.Combine(_tempPath, taskId + ".tmp");
|
||||
|
||||
try
|
||||
{
|
||||
await MergeChunks(taskPath, mergedFilePath, task.ChunksCount);
|
||||
await MergeChunks(taskPath, mergedFilePath, persistentTask.ChunksCount);
|
||||
|
||||
var fileId = await Nanoid.GenerateAsync();
|
||||
var cloudFile = await fileService.ProcessNewFileAsync(
|
||||
currentUser,
|
||||
fileId,
|
||||
task.PoolId.ToString(),
|
||||
task.BundleId?.ToString(),
|
||||
persistentTask.PoolId.ToString(),
|
||||
persistentTask.BundleId?.ToString(),
|
||||
mergedFilePath,
|
||||
task.FileName,
|
||||
task.ContentType,
|
||||
task.EncryptPassword,
|
||||
task.ExpiredAt
|
||||
persistentTask.FileName,
|
||||
persistentTask.ContentType,
|
||||
persistentTask.EncryptPassword,
|
||||
persistentTask.ExpiredAt
|
||||
);
|
||||
|
||||
// Mark task as completed
|
||||
await persistentUploadService.MarkTaskCompletedAsync(taskId);
|
||||
|
||||
// Send completion notification
|
||||
await persistentUploadService.SendUploadCompletedNotificationAsync(persistentTask, fileId);
|
||||
|
||||
return Ok(cloudFile);
|
||||
}
|
||||
catch (Exception)
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log the error and clean up
|
||||
// (Assuming you have a logger - you might want to inject ILogger)
|
||||
// Mark task as failed
|
||||
await persistentUploadService.MarkTaskFailedAsync(taskId);
|
||||
|
||||
// Send failure notification
|
||||
await persistentUploadService.SendUploadFailedNotificationAsync(persistentTask, ex.Message);
|
||||
|
||||
await CleanupTempFiles(taskPath, mergedFilePath);
|
||||
|
||||
return new ObjectResult(new ApiError
|
||||
{
|
||||
Code = "UPLOAD_FAILED",
|
||||
@@ -326,4 +355,292 @@ public class FileUploadController(
|
||||
// Ignore cleanup errors to avoid masking the original exception
|
||||
}
|
||||
}
|
||||
|
||||
// New endpoints for resumable uploads
|
||||
|
||||
[HttpGet("tasks")]
|
||||
public async Task<IActionResult> GetMyUploadTasks(
|
||||
[FromQuery] UploadTaskStatus? status = null,
|
||||
[FromQuery] string? sortBy = "lastActivity",
|
||||
[FromQuery] bool sortDescending = true,
|
||||
[FromQuery] int offset = 0,
|
||||
[FromQuery] int limit = 50
|
||||
)
|
||||
{
|
||||
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||
if (currentUser is null)
|
||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var tasks = await persistentUploadService.GetUserTasksAsync(accountId, status, sortBy, sortDescending, offset, limit);
|
||||
|
||||
Response.Headers.Append("X-Total", tasks.TotalCount.ToString());
|
||||
|
||||
return Ok(tasks.Items.Select(t => new
|
||||
{
|
||||
t.TaskId,
|
||||
t.FileName,
|
||||
t.FileSize,
|
||||
t.ContentType,
|
||||
t.ChunkSize,
|
||||
t.ChunksCount,
|
||||
t.ChunksUploaded,
|
||||
Progress = t.ChunksCount > 0 ? (double)t.ChunksUploaded / t.ChunksCount * 100 : 0,
|
||||
t.Status,
|
||||
t.LastActivity,
|
||||
t.CreatedAt,
|
||||
t.UpdatedAt,
|
||||
UploadedChunks = t.UploadedChunks,
|
||||
Pool = new { t.PoolId, Name = "Pool Name" }, // Could be expanded to include pool details
|
||||
Bundle = t.BundleId.HasValue ? new { t.BundleId } : null
|
||||
}));
|
||||
}
|
||||
|
||||
[HttpGet("progress/{taskId}")]
|
||||
public async Task<IActionResult> GetUploadProgress(string taskId)
|
||||
{
|
||||
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||
if (currentUser is null)
|
||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||
|
||||
var task = await persistentUploadService.GetUploadTaskAsync(taskId);
|
||||
if (task is null)
|
||||
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
||||
|
||||
// Verify ownership
|
||||
if (task.AccountId != Guid.Parse(currentUser.Id))
|
||||
return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
|
||||
|
||||
var progress = await persistentUploadService.GetUploadProgressAsync(taskId);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
task.TaskId,
|
||||
task.FileName,
|
||||
task.FileSize,
|
||||
task.ChunksCount,
|
||||
task.ChunksUploaded,
|
||||
Progress = progress,
|
||||
task.Status,
|
||||
task.LastActivity,
|
||||
task.UploadedChunks
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("resume/{taskId}")]
|
||||
public async Task<IActionResult> ResumeUploadTask(string taskId)
|
||||
{
|
||||
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||
if (currentUser is null)
|
||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||
|
||||
var task = await persistentUploadService.GetUploadTaskAsync(taskId);
|
||||
if (task is null)
|
||||
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
||||
|
||||
// Verify ownership
|
||||
if (task.AccountId != Guid.Parse(currentUser.Id))
|
||||
return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
|
||||
|
||||
// Ensure temp directory exists
|
||||
var taskPath = Path.Combine(_tempPath, taskId);
|
||||
if (!Directory.Exists(taskPath))
|
||||
{
|
||||
Directory.CreateDirectory(taskPath);
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
task.TaskId,
|
||||
task.FileName,
|
||||
task.FileSize,
|
||||
task.ContentType,
|
||||
task.ChunkSize,
|
||||
task.ChunksCount,
|
||||
task.ChunksUploaded,
|
||||
UploadedChunks = task.UploadedChunks,
|
||||
Progress = task.ChunksCount > 0 ? (double)task.ChunksUploaded / task.ChunksCount * 100 : 0
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("task/{taskId}")]
|
||||
public async Task<IActionResult> CancelUploadTask(string taskId)
|
||||
{
|
||||
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||
if (currentUser is null)
|
||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||
|
||||
var task = await persistentUploadService.GetUploadTaskAsync(taskId);
|
||||
if (task is null)
|
||||
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
||||
|
||||
// Verify ownership
|
||||
if (task.AccountId != Guid.Parse(currentUser.Id))
|
||||
return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
|
||||
|
||||
// Mark as failed (cancelled)
|
||||
await persistentUploadService.MarkTaskFailedAsync(taskId);
|
||||
|
||||
// Clean up temp files
|
||||
var taskPath = Path.Combine(_tempPath, taskId);
|
||||
await CleanupTempFiles(taskPath, string.Empty);
|
||||
|
||||
return Ok(new { message = "Upload task cancelled" });
|
||||
}
|
||||
|
||||
[HttpGet("stats")]
|
||||
public async Task<IActionResult> GetUploadStats()
|
||||
{
|
||||
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||
if (currentUser is null)
|
||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var stats = await persistentUploadService.GetUserUploadStatsAsync(accountId);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
TotalTasks = stats.TotalTasks,
|
||||
InProgressTasks = stats.InProgressTasks,
|
||||
CompletedTasks = stats.CompletedTasks,
|
||||
FailedTasks = stats.FailedTasks,
|
||||
ExpiredTasks = stats.ExpiredTasks,
|
||||
TotalUploadedBytes = stats.TotalUploadedBytes,
|
||||
AverageProgress = stats.AverageProgress,
|
||||
RecentActivity = stats.RecentActivity
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("tasks/cleanup")]
|
||||
public async Task<IActionResult> CleanupFailedTasks()
|
||||
{
|
||||
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||
if (currentUser is null)
|
||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var cleanedCount = await persistentUploadService.CleanupUserFailedTasksAsync(accountId);
|
||||
|
||||
return Ok(new { message = $"Cleaned up {cleanedCount} failed tasks" });
|
||||
}
|
||||
|
||||
[HttpGet("tasks/recent")]
|
||||
public async Task<IActionResult> GetRecentTasks([FromQuery] int limit = 10)
|
||||
{
|
||||
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||
if (currentUser is null)
|
||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var tasks = await persistentUploadService.GetRecentUserTasksAsync(accountId, limit);
|
||||
|
||||
return Ok(tasks.Select(t => new
|
||||
{
|
||||
t.TaskId,
|
||||
t.FileName,
|
||||
t.FileSize,
|
||||
t.ContentType,
|
||||
Progress = t.ChunksCount > 0 ? (double)t.ChunksUploaded / t.ChunksCount * 100 : 0,
|
||||
t.Status,
|
||||
t.LastActivity,
|
||||
t.CreatedAt
|
||||
}));
|
||||
}
|
||||
|
||||
[HttpGet("tasks/{taskId}/details")]
|
||||
public async Task<IActionResult> GetTaskDetails(string taskId)
|
||||
{
|
||||
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||
if (currentUser is null)
|
||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||
|
||||
var task = await persistentUploadService.GetUploadTaskAsync(taskId);
|
||||
if (task is null)
|
||||
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
||||
|
||||
// Verify ownership
|
||||
if (task.AccountId != Guid.Parse(currentUser.Id))
|
||||
return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
|
||||
|
||||
// Get pool information
|
||||
var pool = await fileService.GetPoolAsync(task.PoolId);
|
||||
var bundle = task.BundleId.HasValue
|
||||
? await db.Bundles.FirstOrDefaultAsync(b => b.Id == task.BundleId.Value)
|
||||
: null;
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Task = new
|
||||
{
|
||||
task.TaskId,
|
||||
task.FileName,
|
||||
task.FileSize,
|
||||
task.ContentType,
|
||||
task.ChunkSize,
|
||||
task.ChunksCount,
|
||||
task.ChunksUploaded,
|
||||
Progress = task.ChunksCount > 0 ? (double)task.ChunksUploaded / task.ChunksCount * 100 : 0,
|
||||
task.Status,
|
||||
task.LastActivity,
|
||||
task.CreatedAt,
|
||||
task.UpdatedAt,
|
||||
task.ExpiredAt,
|
||||
task.Hash,
|
||||
UploadedChunks = task.UploadedChunks
|
||||
},
|
||||
Pool = pool != null ? new
|
||||
{
|
||||
pool.Id,
|
||||
pool.Name,
|
||||
pool.Description
|
||||
} : null,
|
||||
Bundle = bundle != null ? new
|
||||
{
|
||||
bundle.Id,
|
||||
bundle.Name,
|
||||
bundle.Description
|
||||
} : null,
|
||||
EstimatedTimeRemaining = CalculateEstimatedTime(task),
|
||||
UploadSpeed = CalculateUploadSpeed(task)
|
||||
});
|
||||
}
|
||||
|
||||
private string? CalculateEstimatedTime(PersistentUploadTask task)
|
||||
{
|
||||
if (task.Status != Model.TaskStatus.InProgress || task.ChunksUploaded == 0)
|
||||
return null;
|
||||
|
||||
var elapsed = NodaTime.SystemClock.Instance.GetCurrentInstant() - task.CreatedAt;
|
||||
var elapsedSeconds = elapsed.TotalSeconds;
|
||||
var chunksPerSecond = task.ChunksUploaded / elapsedSeconds;
|
||||
var remainingChunks = task.ChunksCount - task.ChunksUploaded;
|
||||
|
||||
if (chunksPerSecond <= 0)
|
||||
return null;
|
||||
|
||||
var remainingSeconds = remainingChunks / chunksPerSecond;
|
||||
|
||||
if (remainingSeconds < 60)
|
||||
return $"{remainingSeconds:F0} seconds";
|
||||
if (remainingSeconds < 3600)
|
||||
return $"{remainingSeconds / 60:F0} minutes";
|
||||
return $"{remainingSeconds / 3600:F1} hours";
|
||||
}
|
||||
|
||||
private string? CalculateUploadSpeed(PersistentUploadTask task)
|
||||
{
|
||||
if (task.ChunksUploaded == 0)
|
||||
return null;
|
||||
|
||||
var elapsed = NodaTime.SystemClock.Instance.GetCurrentInstant() - task.CreatedAt;
|
||||
var elapsedSeconds = elapsed.TotalSeconds;
|
||||
var bytesUploaded = (long)task.ChunksUploaded * task.ChunkSize;
|
||||
var bytesPerSecond = bytesUploaded / elapsedSeconds;
|
||||
|
||||
if (bytesPerSecond < 1024)
|
||||
return $"{bytesPerSecond:F0} B/s";
|
||||
if (bytesPerSecond < 1024 * 1024)
|
||||
return $"{bytesPerSecond / 1024:F0} KB/s";
|
||||
return $"{bytesPerSecond / (1024 * 1024):F1} MB/s";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user