✨ File bundle
This commit is contained in:
155
DysonNetwork.Drive/Storage/BundleController.cs
Normal file
155
DysonNetwork.Drive/Storage/BundleController.cs
Normal file
@@ -0,0 +1,155 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Drive.Storage;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/bundles")]
|
||||
public class BundleController(AppDatabase db) : ControllerBase
|
||||
{
|
||||
public class BundleRequest
|
||||
{
|
||||
[MaxLength(1024)] public string? Slug { get; set; }
|
||||
[MaxLength(1024)] public string? Name { get; set; }
|
||||
[MaxLength(8192)] public string? Description { get; set; }
|
||||
[MaxLength(256)] public string? Passcode { get; set; }
|
||||
|
||||
public Instant? ExpiredAt { get; set; }
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<FileBundle>> GetBundle([FromRoute] Guid id, [FromQuery] string? passcode)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
var bundle = await db.Bundles
|
||||
.Where(e => e.Id == id)
|
||||
.Where(e => e.AccountId == accountId)
|
||||
.Include(e => e.Files)
|
||||
.FirstOrDefaultAsync();
|
||||
if (bundle is null) return NotFound();
|
||||
if (!bundle.VerifyPasscode(passcode)) return Forbid();
|
||||
|
||||
return Ok(bundle);
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<FileBundle>>> ListBundles(
|
||||
[FromQuery] int offset = 0,
|
||||
[FromQuery] int take = 20
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
var query = db.Bundles
|
||||
.Where(e => e.AccountId == accountId)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.AsQueryable();
|
||||
|
||||
var total = await query.CountAsync();
|
||||
Response.Headers.Append("X-Total", total.ToString());
|
||||
|
||||
var bundles = await query
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(bundles);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<FileBundle>> CreateBundle([FromBody] BundleRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
if (currentUser.PerkSubscription is null && !string.IsNullOrEmpty(request.Slug))
|
||||
return StatusCode(403, "You must have a subscription to create a bundle with a custom slug");
|
||||
if (string.IsNullOrEmpty(request.Slug))
|
||||
request.Slug = Guid.NewGuid().ToString("N")[..6];
|
||||
if (string.IsNullOrEmpty(request.Name))
|
||||
request.Name = "Unnamed Bundle";
|
||||
|
||||
var bundle = new FileBundle
|
||||
{
|
||||
Slug = request.Slug,
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
Passcode = request.Passcode,
|
||||
ExpiredAt = request.ExpiredAt,
|
||||
AccountId = accountId
|
||||
}.HashPasscode();
|
||||
|
||||
db.Bundles.Add(bundle);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(bundle);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<FileBundle>> UpdateBundle([FromRoute] Guid id, [FromBody] BundleRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
var bundle = await db.Bundles
|
||||
.Where(e => e.Id == id)
|
||||
.Where(e => e.AccountId == accountId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (bundle is null) return NotFound();
|
||||
|
||||
if (request.Slug != null && request.Slug != bundle.Slug)
|
||||
{
|
||||
if (currentUser.PerkSubscription is null)
|
||||
return StatusCode(403, "You must have a subscription to change the slug of a bundle");
|
||||
bundle.Slug = request.Slug;
|
||||
}
|
||||
|
||||
if (request.Name != null) bundle.Name = request.Name;
|
||||
if (request.Description != null) bundle.Description = request.Description;
|
||||
if (request.ExpiredAt != null) bundle.ExpiredAt = request.ExpiredAt;
|
||||
|
||||
if (request.Passcode != null)
|
||||
{
|
||||
bundle.Passcode = request.Passcode;
|
||||
bundle = bundle.HashPasscode();
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(bundle);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> DeleteBundle([FromRoute] Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
var bundle = await db.Bundles
|
||||
.Where(e => e.Id == id)
|
||||
.Where(e => e.AccountId == accountId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (bundle is null) return NotFound();
|
||||
|
||||
db.Bundles.Remove(bundle);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await db.Files
|
||||
.Where(e => e.BundleId == id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(e => e.IsMarkedRecycle, true));
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
@@ -47,6 +47,8 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
|
||||
|
||||
[JsonIgnore] public FilePool? Pool { get; set; }
|
||||
public Guid? PoolId { get; set; }
|
||||
[JsonIgnore] public FileBundle? Bundle { get; set; }
|
||||
public Guid? BundleId { get; set; }
|
||||
|
||||
[Obsolete("Deprecated, use PoolId instead. For database migration only.")]
|
||||
[MaxLength(128)]
|
||||
|
36
DysonNetwork.Drive/Storage/FileBundle.cs
Normal file
36
DysonNetwork.Drive/Storage/FileBundle.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Drive.Storage;
|
||||
|
||||
[Index(nameof(Slug), IsUnique = true)]
|
||||
public class FileBundle : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(1024)] public string Slug { get; set; } = null!;
|
||||
[MaxLength(1024)] public string Name { get; set; } = null!;
|
||||
[MaxLength(8192)] public string? Description { get; set; }
|
||||
[MaxLength(256)] public string? Passcode { get; set; }
|
||||
|
||||
public List<CloudFile> Files { get; set; } = new();
|
||||
|
||||
public Instant? ExpiredAt { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
|
||||
public FileBundle HashPasscode()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Passcode)) return this;
|
||||
Passcode = BCrypt.Net.BCrypt.HashPassword(Passcode);
|
||||
return this;
|
||||
}
|
||||
|
||||
public bool VerifyPasscode(string? passcode)
|
||||
{
|
||||
if (string.IsNullOrEmpty(Passcode)) return true;
|
||||
if (string.IsNullOrEmpty(passcode)) return false;
|
||||
return BCrypt.Net.BCrypt.Verify(passcode, Passcode);
|
||||
}
|
||||
}
|
@@ -22,7 +22,8 @@ public class FileController(
|
||||
string id,
|
||||
[FromQuery] bool download = false,
|
||||
[FromQuery] bool original = false,
|
||||
[FromQuery] string? overrideMimeType = null
|
||||
[FromQuery] string? overrideMimeType = null,
|
||||
[FromQuery] string? passcode = null
|
||||
)
|
||||
{
|
||||
// Support the file extension for client side data recognize
|
||||
@@ -36,6 +37,10 @@ public class FileController(
|
||||
|
||||
var file = await fs.GetFileAsync(id);
|
||||
if (file is null) return NotFound();
|
||||
if (file.IsMarkedRecycle) return StatusCode(StatusCodes.Status410Gone, "The file has been recycled.");
|
||||
|
||||
if (file.Bundle is not null && !file.Bundle.VerifyPasscode(passcode))
|
||||
return StatusCode(StatusCodes.Status403Forbidden, "The passcode is incorrect.");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(file.StorageUrl)) return Redirect(file.StorageUrl);
|
||||
|
||||
@@ -46,7 +51,7 @@ public class FileController(
|
||||
if (!System.IO.File.Exists(filePath)) return new NotFoundResult();
|
||||
return PhysicalFile(filePath, file.MimeType ?? "application/octet-stream", file.Name);
|
||||
}
|
||||
|
||||
|
||||
var pool = await fs.GetPoolAsync(file.PoolId.Value);
|
||||
if (pool is null)
|
||||
return StatusCode(StatusCodes.Status410Gone, "The pool of the file no longer exists or not accessible.");
|
||||
|
@@ -46,6 +46,7 @@ public class FileService(
|
||||
var file = await db.Files
|
||||
.Where(f => f.Id == fileId)
|
||||
.Include(f => f.Pool)
|
||||
.Include(f => f.Bundle)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (file != null)
|
||||
@@ -105,6 +106,7 @@ public class FileService(
|
||||
Account account,
|
||||
string fileId,
|
||||
string filePool,
|
||||
string? fileBundleId,
|
||||
Stream stream,
|
||||
string fileName,
|
||||
string? contentType,
|
||||
@@ -112,6 +114,8 @@ public class FileService(
|
||||
Instant? expiredAt
|
||||
)
|
||||
{
|
||||
var accountId = Guid.Parse(account.Id);
|
||||
|
||||
var pool = await GetPoolAsync(Guid.Parse(filePool));
|
||||
if (pool is null) throw new InvalidOperationException("Pool not found");
|
||||
|
||||
@@ -123,6 +127,17 @@ public class FileService(
|
||||
: expectedExpiration;
|
||||
expiredAt = SystemClock.Instance.GetCurrentInstant() + effectiveExpiration;
|
||||
}
|
||||
|
||||
var bundle = fileBundleId is not null
|
||||
? await GetBundleAsync(Guid.Parse(fileBundleId), accountId)
|
||||
: null;
|
||||
if (fileBundleId is not null && bundle is null)
|
||||
{
|
||||
throw new InvalidOperationException("Bundle not found");
|
||||
}
|
||||
|
||||
if (bundle?.ExpiredAt != null)
|
||||
expiredAt = bundle.ExpiredAt.Value;
|
||||
|
||||
var ogFilePath = Path.GetFullPath(Path.Join(configuration.GetValue<string>("Tus:StorePath"), fileId));
|
||||
var fileSize = stream.Length;
|
||||
@@ -149,6 +164,7 @@ public class FileService(
|
||||
Size = fileSize,
|
||||
Hash = hash,
|
||||
ExpiredAt = expiredAt,
|
||||
BundleId = bundle?.Id,
|
||||
AccountId = Guid.Parse(account.Id),
|
||||
IsEncrypted = !string.IsNullOrWhiteSpace(encryptPassword) && pool.PolicyConfig.AllowEncryption
|
||||
};
|
||||
@@ -613,6 +629,15 @@ public class FileService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<FileBundle?> GetBundleAsync(Guid id, Guid accountId)
|
||||
{
|
||||
var bundle = await db.Bundles
|
||||
.Where(e => e.Id == id)
|
||||
.Where(e => e.AccountId == accountId)
|
||||
.FirstOrDefaultAsync();
|
||||
return bundle;
|
||||
}
|
||||
|
||||
public async Task<FilePool?> GetPoolAsync(Guid destination)
|
||||
{
|
||||
var cacheKey = $"file:pool:{destination}";
|
||||
|
@@ -15,7 +15,10 @@ namespace DysonNetwork.Drive.Storage;
|
||||
|
||||
public abstract class TusService
|
||||
{
|
||||
public static DefaultTusConfiguration BuildConfiguration(ITusStore store, IConfiguration configuration) => new()
|
||||
public static DefaultTusConfiguration BuildConfiguration(
|
||||
ITusStore store,
|
||||
IConfiguration configuration
|
||||
) => new()
|
||||
{
|
||||
Store = store,
|
||||
Events = new Events
|
||||
@@ -88,6 +91,12 @@ public abstract class TusService
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
var bundleId = eventContext.HttpContext.Request.Headers["X-FileBundle"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(bundleId) && !Guid.TryParse(bundleId, out _))
|
||||
{
|
||||
eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file bundle id");
|
||||
}
|
||||
},
|
||||
OnFileCompleteAsync = async eventContext =>
|
||||
{
|
||||
@@ -107,6 +116,7 @@ public abstract class TusService
|
||||
var fileStream = await file.GetContentAsync(eventContext.CancellationToken);
|
||||
|
||||
var filePool = httpContext.Request.Headers["X-FilePool"].FirstOrDefault();
|
||||
var bundleId = eventContext.HttpContext.Request.Headers["X-FileBundle"].FirstOrDefault();
|
||||
var encryptPassword = httpContext.Request.Headers["X-FilePass"].FirstOrDefault();
|
||||
|
||||
if (string.IsNullOrEmpty(filePool))
|
||||
@@ -116,7 +126,7 @@ public abstract class TusService
|
||||
var expiredString = httpContext.Request.Headers["X-FileExpire"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(expiredString) && int.TryParse(expiredString, out var expired))
|
||||
expiredAt = Instant.FromUnixTimeSeconds(expired);
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
var fileService = services.GetRequiredService<FileService>();
|
||||
@@ -124,6 +134,7 @@ public abstract class TusService
|
||||
user,
|
||||
file.Id,
|
||||
filePool!,
|
||||
bundleId,
|
||||
fileStream,
|
||||
fileName,
|
||||
contentType,
|
||||
@@ -158,15 +169,23 @@ public abstract class TusService
|
||||
eventContext.FailRequest(HttpStatusCode.Unauthorized);
|
||||
return;
|
||||
}
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
var filePool = eventContext.HttpContext.Request.Headers["X-FilePool"].FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(filePool)) filePool = configuration["Storage:PreferredRemote"];
|
||||
if (!Guid.TryParse(filePool, out _))
|
||||
var poolId = eventContext.HttpContext.Request.Headers["X-FilePool"].FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(poolId)) poolId = configuration["Storage:PreferredRemote"];
|
||||
if (!Guid.TryParse(poolId, out _))
|
||||
{
|
||||
eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file pool id");
|
||||
return;
|
||||
}
|
||||
|
||||
var bundleId = eventContext.HttpContext.Request.Headers["X-FileBundle"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(bundleId) && !Guid.TryParse(bundleId, out _))
|
||||
{
|
||||
eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file bundle id");
|
||||
return;
|
||||
}
|
||||
|
||||
var metadata = eventContext.Metadata;
|
||||
var contentType = metadata.TryGetValue("content-type", out var ct) ? ct.GetString(Encoding.UTF8) : null;
|
||||
|
||||
@@ -175,7 +194,7 @@ public abstract class TusService
|
||||
var rejected = false;
|
||||
|
||||
var fs = scope.ServiceProvider.GetRequiredService<FileService>();
|
||||
var pool = await fs.GetPoolAsync(Guid.Parse(filePool!));
|
||||
var pool = await fs.GetPoolAsync(Guid.Parse(poolId!));
|
||||
if (pool is null)
|
||||
{
|
||||
eventContext.FailRequest(HttpStatusCode.BadRequest, "Pool not found");
|
||||
@@ -234,7 +253,6 @@ public abstract class TusService
|
||||
if (!rejected)
|
||||
{
|
||||
var quotaService = scope.ServiceProvider.GetRequiredService<QuotaService>();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var (ok, billableUnit, quota) = await quotaService.IsFileAcceptable(
|
||||
accountId,
|
||||
pool.BillingConfig.CostMultiplier ?? 1.0,
|
||||
|
Reference in New Issue
Block a user