More file operations

🐛 Bug fixes on file uploading
This commit is contained in:
2025-04-13 15:27:20 +08:00
parent cc185c6313
commit 8704305f5a
6 changed files with 175 additions and 30 deletions

View File

@ -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<Account>(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);
}
}

View File

@ -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<ActionResult<Challenge>> 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);
}
}

View File

@ -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<AppDatabase>();
@ -201,20 +205,28 @@ app.MapTus("/files/tus", (_) => Task.FromResult<DefaultTusConfiguration>(new()
var fileService = eventContext.HttpContext.RequestServices.GetRequiredService<FileService>();
var info = await fileService.AnalyzeFileAsync(account, file.Id, fileStream, fileName, contentType);
var jsonOptions = httpContext.RequestServices.GetRequiredService<IOptions<JsonOptions>>().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<IServiceScopeFactory>()
.CreateScope();
// Keep the service didn't be disposed
var fs = scope.ServiceProvider.GetRequiredService<FileService>();
// 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<string>("Storage:BaseUrl")!;
// eventContext.SetUploadUrl(new Uri($"{baseUrl}/files/{eventContext.FileId}"));
return Task.CompletedTask;
}
}
}));

View File

@ -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<ActionResult> 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<string>("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<ActionResult<CloudFile>> GetFileInfo(string id)
{
var file = await db.Files.FindAsync(id);
if (file is null) return NotFound();
return file;
}
[Authorize]
[HttpDelete("{id}")]
public async Task<ActionResult> 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();
}
}

View File

@ -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<List<RemoteStorageConfig>>()!;
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)