From e31a5ea017f5f212dc5e749d3bd83d01e3ba892c Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 27 Jul 2025 22:45:17 +0800 Subject: [PATCH] :sparkles: File bundle --- DysonNetwork.Drive/AppDatabase.cs | 2 + DysonNetwork.Drive/Billing/UsageService.cs | 3 +- .../Client/src/components/UploadArea.vue | 195 +++++++++ .../Client/src/components/form/BundleForm.vue | 75 ++++ .../Client/src/layouts/dashboard.vue | 19 +- DysonNetwork.Drive/Client/src/router/index.ts | 16 +- .../Client/src/views/dashboard/bundles.vue | 183 ++++++++ .../Client/src/views/dashboard/files.vue | 2 +- .../Client/src/views/dashboard/quotas.vue | 101 +++++ DysonNetwork.Drive/DysonNetwork.Drive.csproj | 1 + .../20250727130951_AddFileBundle.Designer.cs | 400 ++++++++++++++++++ .../20250727130951_AddFileBundle.cs | 79 ++++ .../Migrations/AppDatabaseModelSnapshot.cs | 78 ++++ .../Storage/BundleController.cs | 155 +++++++ DysonNetwork.Drive/Storage/CloudFile.cs | 2 + DysonNetwork.Drive/Storage/FileBundle.cs | 36 ++ DysonNetwork.Drive/Storage/FileController.cs | 9 +- DysonNetwork.Drive/Storage/FileService.cs | 25 ++ DysonNetwork.Drive/Storage/TusService.cs | 32 +- 19 files changed, 1397 insertions(+), 16 deletions(-) create mode 100644 DysonNetwork.Drive/Client/src/components/UploadArea.vue create mode 100644 DysonNetwork.Drive/Client/src/components/form/BundleForm.vue create mode 100644 DysonNetwork.Drive/Client/src/views/dashboard/bundles.vue create mode 100644 DysonNetwork.Drive/Client/src/views/dashboard/quotas.vue create mode 100644 DysonNetwork.Drive/Migrations/20250727130951_AddFileBundle.Designer.cs create mode 100644 DysonNetwork.Drive/Migrations/20250727130951_AddFileBundle.cs create mode 100644 DysonNetwork.Drive/Storage/BundleController.cs create mode 100644 DysonNetwork.Drive/Storage/FileBundle.cs diff --git a/DysonNetwork.Drive/AppDatabase.cs b/DysonNetwork.Drive/AppDatabase.cs index c680c58..f9095f1 100644 --- a/DysonNetwork.Drive/AppDatabase.cs +++ b/DysonNetwork.Drive/AppDatabase.cs @@ -17,6 +17,8 @@ public class AppDatabase( ) : DbContext(options) { public DbSet Pools { get; set; } = null!; + public DbSet Bundles { get; set; } = null!; + public DbSet QuotaRecords { get; set; } = null!; public DbSet Files { get; set; } = null!; diff --git a/DysonNetwork.Drive/Billing/UsageService.cs b/DysonNetwork.Drive/Billing/UsageService.cs index a612752..917c484 100644 --- a/DysonNetwork.Drive/Billing/UsageService.cs +++ b/DysonNetwork.Drive/Billing/UsageService.cs @@ -47,13 +47,12 @@ public class UsageService(AppDatabase db) .Where(f => f.PoolId == p.Id) .Sum(f => f.Size) / 1024.0 / 1024.0 * (p.BillingConfig.CostMultiplier ?? 1.0), - FileCount = db.Files + FileCount = fileQuery .Count(f => f.PoolId == p.Id) }) .ToListAsync(); var totalUsage = poolUsages.Sum(p => p.UsageBytes); - var totalCost = poolUsages.Sum(p => p.Cost); var totalFileCount = poolUsages.Sum(p => p.FileCount); return new TotalUsageDetails diff --git a/DysonNetwork.Drive/Client/src/components/UploadArea.vue b/DysonNetwork.Drive/Client/src/components/UploadArea.vue new file mode 100644 index 0000000..c399618 --- /dev/null +++ b/DysonNetwork.Drive/Client/src/components/UploadArea.vue @@ -0,0 +1,195 @@ + + + diff --git a/DysonNetwork.Drive/Client/src/components/form/BundleForm.vue b/DysonNetwork.Drive/Client/src/components/form/BundleForm.vue new file mode 100644 index 0000000..dd1e372 --- /dev/null +++ b/DysonNetwork.Drive/Client/src/components/form/BundleForm.vue @@ -0,0 +1,75 @@ + + + diff --git a/DysonNetwork.Drive/Client/src/layouts/dashboard.vue b/DysonNetwork.Drive/Client/src/layouts/dashboard.vue index 7cccf82..326a2f5 100644 --- a/DysonNetwork.Drive/Client/src/layouts/dashboard.vue +++ b/DysonNetwork.Drive/Client/src/layouts/dashboard.vue @@ -16,7 +16,12 @@ diff --git a/DysonNetwork.Drive/Client/src/views/dashboard/files.vue b/DysonNetwork.Drive/Client/src/views/dashboard/files.vue index fcfcc5b..dd68c2f 100644 --- a/DysonNetwork.Drive/Client/src/views/dashboard/files.vue +++ b/DysonNetwork.Drive/Client/src/views/dashboard/files.vue @@ -140,7 +140,7 @@ const tableColumns: DataTableColumns = [ title: 'Expired At', key: 'expired_at', render(row: any) { - if (!row.expired_at) return 'Keep-alive' + if (!row.expired_at) return 'Never' return new Date(row.expired_at).toLocaleString() }, }, diff --git a/DysonNetwork.Drive/Client/src/views/dashboard/quotas.vue b/DysonNetwork.Drive/Client/src/views/dashboard/quotas.vue new file mode 100644 index 0000000..01f2e72 --- /dev/null +++ b/DysonNetwork.Drive/Client/src/views/dashboard/quotas.vue @@ -0,0 +1,101 @@ + + + diff --git a/DysonNetwork.Drive/DysonNetwork.Drive.csproj b/DysonNetwork.Drive/DysonNetwork.Drive.csproj index 428258d..d51147c 100644 --- a/DysonNetwork.Drive/DysonNetwork.Drive.csproj +++ b/DysonNetwork.Drive/DysonNetwork.Drive.csproj @@ -8,6 +8,7 @@ + diff --git a/DysonNetwork.Drive/Migrations/20250727130951_AddFileBundle.Designer.cs b/DysonNetwork.Drive/Migrations/20250727130951_AddFileBundle.Designer.cs new file mode 100644 index 0000000..1a5879b --- /dev/null +++ b/DysonNetwork.Drive/Migrations/20250727130951_AddFileBundle.Designer.cs @@ -0,0 +1,400 @@ +// +using System; +using System.Collections.Generic; +using DysonNetwork.Drive; +using DysonNetwork.Drive.Storage; +using DysonNetwork.Shared.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DysonNetwork.Drive.Migrations +{ + [DbContext(typeof(AppDatabase))] + [Migration("20250727130951_AddFileBundle")] + partial class AddFileBundle + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Quota") + .HasColumnType("bigint") + .HasColumnName("quota"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_quota_records"); + + b.ToTable("quota_records", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => + { + b.Property("Id") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("BundleId") + .HasColumnType("uuid") + .HasColumnName("bundle_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("description"); + + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property>("FileMeta") + .HasColumnType("jsonb") + .HasColumnName("file_meta"); + + b.Property("HasCompression") + .HasColumnType("boolean") + .HasColumnName("has_compression"); + + b.Property("HasThumbnail") + .HasColumnType("boolean") + .HasColumnName("has_thumbnail"); + + b.Property("Hash") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("hash"); + + b.Property("IsEncrypted") + .HasColumnType("boolean") + .HasColumnName("is_encrypted"); + + b.Property("IsMarkedRecycle") + .HasColumnType("boolean") + .HasColumnName("is_marked_recycle"); + + b.Property("MimeType") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("mime_type"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("name"); + + b.Property("PoolId") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.Property>("SensitiveMarks") + .HasColumnType("jsonb") + .HasColumnName("sensitive_marks"); + + b.Property("Size") + .HasColumnType("bigint") + .HasColumnName("size"); + + b.Property("StorageId") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("storage_id"); + + b.Property("StorageUrl") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("storage_url"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("uploaded_at"); + + b.Property("UploadedTo") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("uploaded_to"); + + b.Property>("UserMeta") + .HasColumnType("jsonb") + .HasColumnName("user_meta"); + + b.HasKey("Id") + .HasName("pk_files"); + + b.HasIndex("BundleId") + .HasDatabaseName("ix_files_bundle_id"); + + b.HasIndex("PoolId") + .HasDatabaseName("ix_files_pool_id"); + + b.ToTable("files", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property("FileId") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("file_id"); + + b.Property("ResourceId") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("resource_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Usage") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("usage"); + + b.HasKey("Id") + .HasName("pk_file_references"); + + b.HasIndex("FileId") + .HasDatabaseName("ix_file_references_file_id"); + + b.ToTable("file_references", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .HasMaxLength(8192) + .HasColumnType("character varying(8192)") + .HasColumnName("description"); + + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("name"); + + b.Property("Passcode") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("passcode"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("slug"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_bundles"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_bundles_slug"); + + b.ToTable("bundles", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("BillingConfig") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("billing_config"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("character varying(8192)") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("name"); + + b.Property("PolicyConfig") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("policy_config"); + + b.Property("StorageConfig") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("storage_config"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_pools"); + + b.ToTable("pools", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => + { + b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle") + .WithMany("Files") + .HasForeignKey("BundleId") + .HasConstraintName("fk_files_bundles_bundle_id"); + + b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool") + .WithMany() + .HasForeignKey("PoolId") + .HasConstraintName("fk_files_pools_pool_id"); + + b.Navigation("Bundle"); + + b.Navigation("Pool"); + }); + + modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => + { + b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File") + .WithMany() + .HasForeignKey("FileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_file_references_files_file_id"); + + b.Navigation("File"); + }); + + modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b => + { + b.Navigation("Files"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DysonNetwork.Drive/Migrations/20250727130951_AddFileBundle.cs b/DysonNetwork.Drive/Migrations/20250727130951_AddFileBundle.cs new file mode 100644 index 0000000..c4d3130 --- /dev/null +++ b/DysonNetwork.Drive/Migrations/20250727130951_AddFileBundle.cs @@ -0,0 +1,79 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; + +#nullable disable + +namespace DysonNetwork.Drive.Migrations +{ + /// + public partial class AddFileBundle : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "bundle_id", + table: "files", + type: "uuid", + nullable: true); + + migrationBuilder.CreateTable( + name: "bundles", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + slug = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false), + name = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false), + description = table.Column(type: "character varying(8192)", maxLength: 8192, nullable: true), + passcode = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + expired_at = table.Column(type: "timestamp with time zone", nullable: true), + account_id = table.Column(type: "uuid", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_bundles", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_files_bundle_id", + table: "files", + column: "bundle_id"); + + migrationBuilder.CreateIndex( + name: "ix_bundles_slug", + table: "bundles", + column: "slug", + unique: true); + + migrationBuilder.AddForeignKey( + name: "fk_files_bundles_bundle_id", + table: "files", + column: "bundle_id", + principalTable: "bundles", + principalColumn: "id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "fk_files_bundles_bundle_id", + table: "files"); + + migrationBuilder.DropTable( + name: "bundles"); + + migrationBuilder.DropIndex( + name: "ix_files_bundle_id", + table: "files"); + + migrationBuilder.DropColumn( + name: "bundle_id", + table: "files"); + } + } +} diff --git a/DysonNetwork.Drive/Migrations/AppDatabaseModelSnapshot.cs b/DysonNetwork.Drive/Migrations/AppDatabaseModelSnapshot.cs index 7c180a3..3b70b15 100644 --- a/DysonNetwork.Drive/Migrations/AppDatabaseModelSnapshot.cs +++ b/DysonNetwork.Drive/Migrations/AppDatabaseModelSnapshot.cs @@ -85,6 +85,10 @@ namespace DysonNetwork.Drive.Migrations .HasColumnType("uuid") .HasColumnName("account_id"); + b.Property("BundleId") + .HasColumnType("uuid") + .HasColumnName("bundle_id"); + b.Property("CreatedAt") .HasColumnType("timestamp with time zone") .HasColumnName("created_at"); @@ -180,6 +184,9 @@ namespace DysonNetwork.Drive.Migrations b.HasKey("Id") .HasName("pk_files"); + b.HasIndex("BundleId") + .HasDatabaseName("ix_files_bundle_id"); + b.HasIndex("PoolId") .HasDatabaseName("ix_files_pool_id"); @@ -236,6 +243,65 @@ namespace DysonNetwork.Drive.Migrations b.ToTable("file_references", (string)null); }); + modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .HasMaxLength(8192) + .HasColumnType("character varying(8192)") + .HasColumnName("description"); + + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("name"); + + b.Property("Passcode") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("passcode"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("slug"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_bundles"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_bundles_slug"); + + b.ToTable("bundles", (string)null); + }); + modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b => { b.Property("Id") @@ -294,11 +360,18 @@ namespace DysonNetwork.Drive.Migrations modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => { + b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle") + .WithMany("Files") + .HasForeignKey("BundleId") + .HasConstraintName("fk_files_bundles_bundle_id"); + b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool") .WithMany() .HasForeignKey("PoolId") .HasConstraintName("fk_files_pools_pool_id"); + b.Navigation("Bundle"); + b.Navigation("Pool"); }); @@ -313,6 +386,11 @@ namespace DysonNetwork.Drive.Migrations b.Navigation("File"); }); + + modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b => + { + b.Navigation("Files"); + }); #pragma warning restore 612, 618 } } diff --git a/DysonNetwork.Drive/Storage/BundleController.cs b/DysonNetwork.Drive/Storage/BundleController.cs new file mode 100644 index 0000000..739f837 --- /dev/null +++ b/DysonNetwork.Drive/Storage/BundleController.cs @@ -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> 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>> 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> 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> 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 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(); + } +} \ No newline at end of file diff --git a/DysonNetwork.Drive/Storage/CloudFile.cs b/DysonNetwork.Drive/Storage/CloudFile.cs index f0e1854..7e8c8b7 100644 --- a/DysonNetwork.Drive/Storage/CloudFile.cs +++ b/DysonNetwork.Drive/Storage/CloudFile.cs @@ -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)] diff --git a/DysonNetwork.Drive/Storage/FileBundle.cs b/DysonNetwork.Drive/Storage/FileBundle.cs new file mode 100644 index 0000000..0ce43bf --- /dev/null +++ b/DysonNetwork.Drive/Storage/FileBundle.cs @@ -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 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); + } +} \ No newline at end of file diff --git a/DysonNetwork.Drive/Storage/FileController.cs b/DysonNetwork.Drive/Storage/FileController.cs index a5ac547..b3a4f1c 100644 --- a/DysonNetwork.Drive/Storage/FileController.cs +++ b/DysonNetwork.Drive/Storage/FileController.cs @@ -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."); diff --git a/DysonNetwork.Drive/Storage/FileService.cs b/DysonNetwork.Drive/Storage/FileService.cs index 3c87934..8e31706 100644 --- a/DysonNetwork.Drive/Storage/FileService.cs +++ b/DysonNetwork.Drive/Storage/FileService.cs @@ -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("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 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 GetPoolAsync(Guid destination) { var cacheKey = $"file:pool:{destination}"; diff --git a/DysonNetwork.Drive/Storage/TusService.cs b/DysonNetwork.Drive/Storage/TusService.cs index f9a58ba..60f17a9 100644 --- a/DysonNetwork.Drive/Storage/TusService.cs +++ b/DysonNetwork.Drive/Storage/TusService.cs @@ -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(); @@ -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(); - 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(); - var accountId = Guid.Parse(currentUser.Id); var (ok, billableUnit, quota) = await quotaService.IsFileAcceptable( accountId, pool.BillingConfig.CostMultiplier ?? 1.0,