From 8704305f5ad3d280f488bc79ac5982120b9e4a13 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 13 Apr 2025 15:27:20 +0800 Subject: [PATCH] :sparkles: More file operations :bug: Bug fixes on file uploading --- .../Account/AccountController.cs | 6 +- DysonNetwork.Sphere/Auth/AuthController.cs | 39 ++++--- DysonNetwork.Sphere/Program.cs | 28 +++-- DysonNetwork.Sphere/Storage/FileController.cs | 103 ++++++++++++++++++ DysonNetwork.Sphere/Storage/FileService.cs | 23 +++- DysonNetwork.sln.DotSettings.user | 6 + 6 files changed, 175 insertions(+), 30 deletions(-) create mode 100644 DysonNetwork.Sphere/Storage/FileController.cs diff --git a/DysonNetwork.Sphere/Account/AccountController.cs b/DysonNetwork.Sphere/Account/AccountController.cs index 9fd1cbe..5ee64ad 100644 --- a/DysonNetwork.Sphere/Account/AccountController.cs +++ b/DysonNetwork.Sphere/Account/AccountController.cs @@ -7,7 +7,7 @@ namespace DysonNetwork.Sphere.Account; [ApiController] [Route("/accounts")] -public class AccountController(AppDatabase db, IHttpContextAccessor httpContext) +public class AccountController(AppDatabase db, IHttpContextAccessor httpContext) : ControllerBase { [HttpGet("{name}")] [ProducesResponseType(StatusCodes.Status200OK)] @@ -37,7 +37,7 @@ public class AccountController(AppDatabase db, IHttpContextAccessor httpContext) { var dupeNameCount = await db.Accounts.Where(a => a.Name == request.Name).CountAsync(); if (dupeNameCount > 0) - return new BadRequestObjectResult("The name is already taken."); + return BadRequest("The name is already taken."); var account = new Account { @@ -77,6 +77,6 @@ public class AccountController(AppDatabase db, IHttpContextAccessor httpContext) var account = await db.Accounts.FindAsync(userId); - return new OkObjectResult(account); + return Ok(account); } } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Auth/AuthController.cs b/DysonNetwork.Sphere/Auth/AuthController.cs index 416235f..26f7b2c 100644 --- a/DysonNetwork.Sphere/Auth/AuthController.cs +++ b/DysonNetwork.Sphere/Auth/AuthController.cs @@ -10,7 +10,12 @@ namespace DysonNetwork.Sphere.Auth; [ApiController] [Route("/auth")] -public class AuthController(AppDatabase db, AccountService accounts, AuthService auth, IHttpContextAccessor httpContext) +public class AuthController( + AppDatabase db, + AccountService accounts, + AuthService auth, + IHttpContextAccessor httpContext +) : ControllerBase { public class ChallengeRequest { @@ -24,7 +29,7 @@ public class AuthController(AppDatabase db, AccountService accounts, AuthService public async Task> StartChallenge([FromBody] ChallengeRequest request) { var account = await accounts.LookupAccount(request.Account); - if (account is null) return new NotFoundObjectResult("Account was not found."); + if (account is null) return NotFound("Account was not found."); var ipAddress = httpContext.HttpContext?.Connection.RemoteIpAddress?.ToString(); var userAgent = httpContext.HttpContext?.Request.Headers.UserAgent.ToString(); @@ -66,7 +71,7 @@ public class AuthController(AppDatabase db, AccountService accounts, AuthService .Include(e => e.Account.AuthFactors) .Where(e => e.Id == id).FirstOrDefaultAsync(); return challenge is null - ? new NotFoundObjectResult("Auth challenge was not found.") + ? NotFound("Auth challenge was not found.") : challenge.Account.AuthFactors.ToList(); } @@ -83,14 +88,14 @@ public class AuthController(AppDatabase db, AccountService accounts, AuthService ) { var challenge = await db.AuthChallenges.FindAsync(id); - if (challenge is null) return new NotFoundObjectResult("Auth challenge was not found."); + if (challenge is null) return NotFound("Auth challenge was not found."); var factor = await db.AccountAuthFactors.FindAsync(request.FactorId); - if (factor is null) return new NotFoundObjectResult("Auth factor was not found."); + if (factor is null) return NotFound("Auth factor was not found."); if (challenge.StepRemain == 0) return challenge; if (challenge.ExpiredAt.HasValue && challenge.ExpiredAt.Value < Instant.FromDateTimeUtc(DateTime.UtcNow)) - return new BadRequestResult(); + return BadRequest(); try { @@ -102,7 +107,7 @@ public class AuthController(AppDatabase db, AccountService accounts, AuthService } catch { - return new BadRequestResult(); + return BadRequest(); } await db.SaveChangesAsync(); @@ -125,21 +130,21 @@ public class AuthController(AppDatabase db, AccountService accounts, AuthService case "authorization_code": var code = Guid.TryParse(request.Code, out var codeId) ? codeId : Guid.Empty; if (code == Guid.Empty) - return new BadRequestObjectResult("Invalid or missing authorization code."); + return BadRequest("Invalid or missing authorization code."); var challenge = await db.AuthChallenges .Include(e => e.Account) .Where(e => e.Id == code) .FirstOrDefaultAsync(); if (challenge is null) - return new NotFoundObjectResult("Authorization code not found or expired."); + return BadRequest("Authorization code not found or expired."); if (challenge.StepRemain != 0) - return new BadRequestObjectResult("Challenge not yet completed."); + return BadRequest("Challenge not yet completed."); session = await db.AuthSessions .Where(e => e.Challenge == challenge) .FirstOrDefaultAsync(); if (session is not null) - return new BadRequestObjectResult("Session already exists for this challenge."); + return BadRequest("Session already exists for this challenge."); session = new Session { @@ -159,21 +164,21 @@ public class AuthController(AppDatabase db, AccountService accounts, AuthService var sessionIdClaim = token.Claims.FirstOrDefault(c => c.Type == "session_id")?.Value; if (!Guid.TryParse(sessionIdClaim, out var sessionId)) - return new UnauthorizedObjectResult("Invalid or missing session_id claim in refresh token."); + return Unauthorized("Invalid or missing session_id claim in refresh token."); session = await db.AuthSessions .Include(e => e.Account) .Include(e => e.Challenge) .FirstOrDefaultAsync(s => s.Id == sessionId); if (session is null) - return new NotFoundObjectResult("Session not found or expired."); + return NotFound("Session not found or expired."); session.LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow); await db.SaveChangesAsync(); return auth.CreateToken(session); default: - return new BadRequestObjectResult("Unsupported grant type."); + return BadRequest("Unsupported grant type."); } } @@ -183,11 +188,11 @@ public class AuthController(AppDatabase db, AccountService accounts, AuthService { var sessionIdClaim = httpContext.HttpContext?.User.FindFirst("session_id")?.Value; if (!Guid.TryParse(sessionIdClaim, out var sessionId)) - return new UnauthorizedResult(); + return Unauthorized(); var session = await db.AuthSessions.FirstOrDefaultAsync(s => s.Id == sessionId); - if (session is null) return new NotFoundResult(); + if (session is null) return NotFound(); - return new OkObjectResult(session); + return Ok(session); } } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Program.cs b/DysonNetwork.Sphere/Program.cs index 91d3ade..1a66c11 100644 --- a/DysonNetwork.Sphere/Program.cs +++ b/DysonNetwork.Sphere/Program.cs @@ -10,7 +10,9 @@ using DysonNetwork.Sphere.Auth; using DysonNetwork.Sphere.Storage; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; using NodaTime; @@ -21,6 +23,8 @@ using File = System.IO.File; var builder = WebApplication.CreateBuilder(args); +builder.Host.UseContentRoot(Directory.GetCurrentDirectory()); + // Add services to the container. builder.Services.AddDbContext(); @@ -201,20 +205,28 @@ app.MapTus("/files/tus", (_) => Task.FromResult(new() var fileService = eventContext.HttpContext.RequestServices.GetRequiredService(); var info = await fileService.AnalyzeFileAsync(account, file.Id, fileStream, fileName, contentType); + + var jsonOptions = httpContext.RequestServices.GetRequiredService>().Value + .JsonSerializerOptions; + var infoJson = JsonSerializer.Serialize(info, jsonOptions); + eventContext.HttpContext.Response.Headers.Append("X-FileInfo", infoJson); + #pragma warning disable CS4014 Task.Run(async () => { - await fileService.UploadFileToRemoteAsync(info, fileStream, null); - await tusDiskStore.DeleteFileAsync(file.Id, eventContext.CancellationToken); + using var scope = eventContext.HttpContext.RequestServices + .GetRequiredService() + .CreateScope(); + // Keep the service didn't be disposed + var fs = scope.ServiceProvider.GetRequiredService(); + // Keep the file stream opened + var fileData = await tusDiskStore.GetFileAsync(file.Id, CancellationToken.None); + var newStream = await fileData.GetContentAsync(CancellationToken.None); + await fs.UploadFileToRemoteAsync(info, newStream, null); + await tusDiskStore.DeleteFileAsync(file.Id, CancellationToken.None); }); #pragma warning restore CS4014 }, - OnCreateCompleteAsync = eventContext => - { - // var baseUrl = builder.Configuration.GetValue("Storage:BaseUrl")!; - // eventContext.SetUploadUrl(new Uri($"{baseUrl}/files/{eventContext.FileId}")); - return Task.CompletedTask; - } } })); diff --git a/DysonNetwork.Sphere/Storage/FileController.cs b/DysonNetwork.Sphere/Storage/FileController.cs new file mode 100644 index 0000000..4dab257 --- /dev/null +++ b/DysonNetwork.Sphere/Storage/FileController.cs @@ -0,0 +1,103 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Minio.DataModel.Args; + +namespace DysonNetwork.Sphere.Storage; + +[ApiController] +[Route("/files")] +public class FileController( + AppDatabase db, + FileService fs, + IConfiguration configuration, + IWebHostEnvironment env +) : ControllerBase +{ + [HttpGet("{id}")] + public async Task OpenFile(string id) + { + var file = await db.Files.FindAsync(id); + if (file is null) return NotFound(); + + if (file.UploadedTo is null) + { + var tusStorePath = configuration.GetValue("Tus:StorePath")!; + var filePath = Path.Combine(env.ContentRootPath, tusStorePath, file.Id); + if (!System.IO.File.Exists(filePath)) return new NotFoundResult(); + return PhysicalFile(filePath, file.MimeType ?? "application/octet-stream", file.Name); + } + + var dest = fs.GetRemoteStorageConfig(file.UploadedTo); + + if (dest.ImageProxy is not null && (file.MimeType?.StartsWith("image/") ?? false)) + { + var proxyUrl = dest.ImageProxy; + var baseUri = new Uri(proxyUrl.EndsWith('/') ? proxyUrl : $"{proxyUrl}/"); + var fullUri = new Uri(baseUri, file.Id); + return Redirect(fullUri.ToString()); + } + + if (dest.AccessProxy is not null) + { + var proxyUrl = dest.AccessProxy; + var baseUri = new Uri(proxyUrl.EndsWith('/') ? proxyUrl : $"{proxyUrl}/"); + var fullUri = new Uri(baseUri, file.Id); + return Redirect(fullUri.ToString()); + } + + if (dest.EnableSigned) + { + var client = fs.CreateMinioClient(dest); + if (client is null) + return BadRequest( + "Failed to configure client for remote destination, file got an invalid storage remote."); + + var bucket = dest.Bucket; + var openUrl = await client.PresignedGetObjectAsync( + new PresignedGetObjectArgs() + .WithBucket(bucket) + .WithObject(file.Id) + .WithExpiry(3600) + ); + + return Redirect(openUrl); + } + + // Fallback redirect to the S3 endpoint (public read) + var protocol = dest.EnableSsl ? "https" : "http"; + // Use the path bucket lookup mode + return Redirect($"{protocol}://{dest.Endpoint}/{dest.Bucket}/{file.Id}"); + } + + [HttpGet("{id}/info")] + public async Task> GetFileInfo(string id) + { + var file = await db.Files.FindAsync(id); + if (file is null) return NotFound(); + + return file; + } + + [Authorize] + [HttpDelete("{id}")] + public async Task DeleteFile(string id) + { + var userIdClaim = User.FindFirst("user_id")?.Value; + if (userIdClaim is null) return Unauthorized(); + var userId = long.Parse(userIdClaim); + + var file = await db.Files + .Where(e => e.Id == id) + .Where(e => e.Account.Id == userId) + .FirstOrDefaultAsync(); + if (file is null) return NotFound(); + + await fs.DeleteFileDataAsync(file); + + db.Files.Remove(file); + await db.SaveChangesAsync(); + + return NoContent(); + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Storage/FileService.cs b/DysonNetwork.Sphere/Storage/FileService.cs index 2858a92..f801432 100644 --- a/DysonNetwork.Sphere/Storage/FileService.cs +++ b/DysonNetwork.Sphere/Storage/FileService.cs @@ -117,11 +117,30 @@ public class FileService(AppDatabase db, IConfiguration configuration) ); file.UploadedAt = Instant.FromDateTimeUtc(DateTime.UtcNow); + db.Update(file); await db.SaveChangesAsync(); return file; } - private RemoteStorageConfig GetRemoteStorageConfig(string destination) + public async Task DeleteFileDataAsync(CloudFile file) + { + if (file.UploadedTo is null) return; + var dest = GetRemoteStorageConfig(file.UploadedTo); + var client = CreateMinioClient(dest); + if (client is null) + throw new InvalidOperationException( + $"Failed to configure client for remote destination '{file.UploadedTo}'" + ); + + var bucket = dest.Bucket; + await client.RemoveObjectAsync( + new RemoveObjectArgs().WithBucket(bucket).WithObject(file.Id) + ); + + return; + } + + public RemoteStorageConfig GetRemoteStorageConfig(string destination) { var destinations = configuration.GetSection("Storage:Remote").Get>()!; var dest = destinations.FirstOrDefault(d => d.Id == destination); @@ -129,7 +148,7 @@ public class FileService(AppDatabase db, IConfiguration configuration) return dest; } - private IMinioClient? CreateMinioClient(RemoteStorageConfig dest) + public IMinioClient? CreateMinioClient(RemoteStorageConfig dest) { var client = new MinioClient() .WithEndpoint(dest.Endpoint) diff --git a/DysonNetwork.sln.DotSettings.user b/DysonNetwork.sln.DotSettings.user index 2fefbe6..90eb646 100644 --- a/DysonNetwork.sln.DotSettings.user +++ b/DysonNetwork.sln.DotSettings.user @@ -1,14 +1,17 @@  ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded @@ -17,9 +20,12 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded \ No newline at end of file