diff --git a/DysonNetwork.Drive/.gitignore b/DysonNetwork.Drive/.gitignore index 0d7ff5f..c783c91 100644 --- a/DysonNetwork.Drive/.gitignore +++ b/DysonNetwork.Drive/.gitignore @@ -1,2 +1,3 @@ /Uploads/ -/wwwroot/dist \ No newline at end of file +/Client/node_modules/ +/wwwroot/dist diff --git a/DysonNetwork.Drive/Client/.gitignore b/DysonNetwork.Drive/Client/.gitignore index 8ee54e8..f6ab678 100644 --- a/DysonNetwork.Drive/Client/.gitignore +++ b/DysonNetwork.Drive/Client/.gitignore @@ -8,6 +8,7 @@ pnpm-debug.log* lerna-debug.log* node_modules +node_modules/highlight.js .DS_Store dist dist-ssr diff --git a/DysonNetwork.Drive/Client/src/router/index.ts b/DysonNetwork.Drive/Client/src/router/index.ts index db85fec..8c8d1d5 100644 --- a/DysonNetwork.Drive/Client/src/router/index.ts +++ b/DysonNetwork.Drive/Client/src/router/index.ts @@ -10,7 +10,7 @@ const router = createRouter({ component: () => import('../views/index.vue') }, { - path: '/files', + path: '/files/:fileId', name: 'files', component: () => import('../views/files.vue'), } diff --git a/DysonNetwork.Drive/Client/src/types/pool.ts b/DysonNetwork.Drive/Client/src/types/pool.ts new file mode 100644 index 0000000..740668a --- /dev/null +++ b/DysonNetwork.Drive/Client/src/types/pool.ts @@ -0,0 +1,35 @@ +export interface SnFilePool { + id: string; + name: string; + storage_config: StorageConfig; + billing_config: BillingConfig; + public_indexable: boolean; + public_usable: boolean; + no_optimization: boolean; + no_metadata: boolean; + allow_encryption: boolean; + allow_anonymous: boolean; + require_privilege: number; + account_id: null; + resource_identifier: string; + created_at: Date; + updated_at: Date; + deleted_at: null; +} + +export interface BillingConfig { + cost_multiplier: number; +} + +export interface StorageConfig { + region: string; + bucket: string; + endpoint: string; + secret_id: string; + secret_key: string; + enable_signed: boolean; + enable_ssl: boolean; + image_proxy: null; + access_proxy: null; + expiration: null; +} diff --git a/DysonNetwork.Drive/Client/src/views/files.vue b/DysonNetwork.Drive/Client/src/views/files.vue index d971dd2..01925e0 100644 --- a/DysonNetwork.Drive/Client/src/views/files.vue +++ b/DysonNetwork.Drive/Client/src/views/files.vue @@ -1,36 +1,182 @@ diff --git a/DysonNetwork.Drive/Client/src/views/index.vue b/DysonNetwork.Drive/Client/src/views/index.vue index 360f7d2..6d694ab 100644 --- a/DysonNetwork.Drive/Client/src/views/index.vue +++ b/DysonNetwork.Drive/Client/src/views/index.vue @@ -22,17 +22,38 @@ -
- +
+ + +
+

File Password

+ +

Only available for Stellar Program and certian file pool.

+
+
+
+
- +
Click or drag a file to this area to upload @@ -78,30 +99,131 @@ import { NP, NInput, NSwitch, + NSelect, + NTag, + NCollapseTransition, + NFormItem, type UploadCustomRequestOptions, type UploadSettledFileInfo, + type SelectOption, + type SelectRenderTag, } from 'naive-ui' -import { onMounted, ref } from 'vue' -import { UploadOutlined } from '@vicons/material' +import { computed, h, onMounted, ref } from 'vue' +import { CloudUploadRound } from '@vicons/material' import { useUserStore } from '@/stores/user' +import type { SnFilePool } from '@/types/pool' import * as tus from 'tus-js-client' const userStore = useUserStore() const version = ref(null) - async function fetchVersion() { const resp = await fetch('/api/version') version.value = await resp.json() } - onMounted(() => fetchVersion()) +type SnFilePoolOption = SnFilePool & any + +const pools = ref() +async function fetchPools() { + const resp = await fetch('/api/pools') + pools.value = await resp.json() +} +onMounted(() => fetchPools()) + +const renderSingleSelectTag: SelectRenderTag = ({ option }) => { + return h( + 'div', + { + style: { + display: 'flex', + alignItems: 'center', + }, + }, + [option.name as string], + ) +} + +function renderPoolSelectLabel(option: SelectOption & SnFilePool, selected: boolean) { + return h( + 'div', + { + style: { + padding: '8px 2px', + }, + }, + [ + h('div', null, [option.name as string]), + h( + 'div', + { + style: { + display: 'flex', + gap: '0.25rem', + marginTop: '2px', + marginLeft: '-2px', + marginRight: '-2px', + }, + }, + [ + option.public_usable && + h( + NTag, + { + type: 'info', + size: 'small', + round: true, + }, + { default: () => 'Public Shared' }, + ), + option.public_indexable && + h( + NTag, + { + type: 'success', + size: 'small', + round: true, + }, + { default: () => 'Public Indexable' }, + ), + option.allow_encryption && + h( + NTag, + { + type: 'warning', + size: 'small', + round: true, + }, + { default: () => 'Allow Encryption' }, + ), + option.allow_anonymous && + h( + NTag, + { + type: 'info', + size: 'small', + round: true, + }, + { default: () => 'Allow Anonymous' }, + ), + ], + ), + ], + ) +} + const modeAdvanced = ref(false) +const filePool = ref(null) const filePass = ref('') +const currentFilePool = computed(() => { + if (!filePool.value) return null + return pools.value?.find((pool) => pool.id === filePool.value) ?? null +}) + function customRequest({ file, data, @@ -112,6 +234,9 @@ function customRequest({ onError, onProgress, }: UploadCustomRequestOptions) { + const requestHeaders: Record = {} + if (filePool.value) requestHeaders['X-FilePool'] = filePool.value + if (filePass.value) requestHeaders['X-FilePass'] = filePass.value const upload = new tus.Upload(file.file, { endpoint: '/api/tus', retryDelays: [0, 3000, 5000, 10000, 20000], @@ -120,7 +245,7 @@ function customRequest({ filetype: file.type ?? 'application/octet-stream', }, headers: { - 'X-FilePass': filePass.value, + ...requestHeaders, ...headers, }, onError: function (error) { @@ -151,7 +276,10 @@ function customRequest({ }) } -function createThumbnailUrl(_file: File | null, fileInfo: UploadSettledFileInfo): string | undefined { +function createThumbnailUrl( + _file: File | null, + fileInfo: UploadSettledFileInfo, +): string | undefined { if (!fileInfo) return undefined return fileInfo.url ?? undefined } diff --git a/DysonNetwork.Drive/Migrations/20250726034305_FilePoolAuthorize.Designer.cs b/DysonNetwork.Drive/Migrations/20250726034305_FilePoolAuthorize.Designer.cs new file mode 100644 index 0000000..0232deb --- /dev/null +++ b/DysonNetwork.Drive/Migrations/20250726034305_FilePoolAuthorize.Designer.cs @@ -0,0 +1,288 @@ +// +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("20250726034305_FilePoolAuthorize")] + partial class FilePoolAuthorize + { + /// + 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.Storage.CloudFile", b => + { + b.Property("Id") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .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(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("description"); + + 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("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.FilePool", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("AllowAnonymous") + .HasColumnType("boolean") + .HasColumnName("allow_anonymous"); + + b.Property("AllowEncryption") + .HasColumnType("boolean") + .HasColumnName("allow_encryption"); + + 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("Name") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("name"); + + b.Property("NoMetadata") + .HasColumnType("boolean") + .HasColumnName("no_metadata"); + + b.Property("NoOptimization") + .HasColumnType("boolean") + .HasColumnName("no_optimization"); + + b.Property("PublicIndexable") + .HasColumnType("boolean") + .HasColumnName("public_indexable"); + + b.Property("PublicUsable") + .HasColumnType("boolean") + .HasColumnName("public_usable"); + + b.Property("RequirePrivilege") + .HasColumnType("integer") + .HasColumnName("require_privilege"); + + 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.FilePool", "Pool") + .WithMany() + .HasForeignKey("PoolId") + .HasConstraintName("fk_files_pools_pool_id"); + + 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"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DysonNetwork.Drive/Migrations/20250726034305_FilePoolAuthorize.cs b/DysonNetwork.Drive/Migrations/20250726034305_FilePoolAuthorize.cs new file mode 100644 index 0000000..cae21fc --- /dev/null +++ b/DysonNetwork.Drive/Migrations/20250726034305_FilePoolAuthorize.cs @@ -0,0 +1,51 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DysonNetwork.Drive.Migrations +{ + /// + public partial class FilePoolAuthorize : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "account_id", + table: "pools", + type: "uuid", + nullable: true); + + migrationBuilder.AddColumn( + name: "public_indexable", + table: "pools", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "public_usable", + table: "pools", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "account_id", + table: "pools"); + + migrationBuilder.DropColumn( + name: "public_indexable", + table: "pools"); + + migrationBuilder.DropColumn( + name: "public_usable", + table: "pools"); + } + } +} diff --git a/DysonNetwork.Drive/Migrations/AppDatabaseModelSnapshot.cs b/DysonNetwork.Drive/Migrations/AppDatabaseModelSnapshot.cs index 853ca45..4328b3b 100644 --- a/DysonNetwork.Drive/Migrations/AppDatabaseModelSnapshot.cs +++ b/DysonNetwork.Drive/Migrations/AppDatabaseModelSnapshot.cs @@ -192,6 +192,10 @@ namespace DysonNetwork.Drive.Migrations .HasColumnType("uuid") .HasColumnName("id"); + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + b.Property("AllowAnonymous") .HasColumnType("boolean") .HasColumnName("allow_anonymous"); @@ -227,6 +231,14 @@ namespace DysonNetwork.Drive.Migrations .HasColumnType("boolean") .HasColumnName("no_optimization"); + b.Property("PublicIndexable") + .HasColumnType("boolean") + .HasColumnName("public_indexable"); + + b.Property("PublicUsable") + .HasColumnType("boolean") + .HasColumnName("public_usable"); + b.Property("RequirePrivilege") .HasColumnType("integer") .HasColumnName("require_privilege"); diff --git a/DysonNetwork.Drive/Storage/FilePool.cs b/DysonNetwork.Drive/Storage/FilePool.cs index d918c6b..0054984 100644 --- a/DysonNetwork.Drive/Storage/FilePool.cs +++ b/DysonNetwork.Drive/Storage/FilePool.cs @@ -30,11 +30,15 @@ public class FilePool : ModelBase, IIdentifiedResource [MaxLength(1024)] public string Name { get; set; } = string.Empty; [Column(TypeName = "jsonb")] public RemoteStorageConfig StorageConfig { get; set; } = new(); [Column(TypeName = "jsonb")] public BillingConfig BillingConfig { get; set; } = new(); + public bool PublicIndexable { get; set; } = false; + public bool PublicUsable { get; set; } = false; public bool NoOptimization { get; set; } = false; public bool NoMetadata { get; set; } = false; public bool AllowEncryption { get; set; } = true; public bool AllowAnonymous { get; set; } = true; public int RequirePrivilege { get; set; } = 0; + + public Guid? AccountId { get; set; } public string ResourceIdentifier => $"file-pool/{Id}"; } \ No newline at end of file diff --git a/DysonNetwork.Drive/Storage/FilePoolController.cs b/DysonNetwork.Drive/Storage/FilePoolController.cs new file mode 100644 index 0000000..515f054 --- /dev/null +++ b/DysonNetwork.Drive/Storage/FilePoolController.cs @@ -0,0 +1,25 @@ +using DysonNetwork.Shared.Proto; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace DysonNetwork.Drive.Storage; + +[ApiController] +[Route("/api/pools")] +public class FilePoolController(AppDatabase db) : ControllerBase +{ + [HttpGet] + [Authorize] + public async Task>> ListUsablePools() + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var accountId = Guid.Parse(currentUser.Id); + var pools = await db.Pools + .Where(p => p.PublicUsable || p.AccountId == accountId) + .ToListAsync(); + + return Ok(pools); + } +} \ No newline at end of file diff --git a/DysonNetwork.Drive/bun.lock b/DysonNetwork.Drive/bun.lock new file mode 100644 index 0000000..d7a853d --- /dev/null +++ b/DysonNetwork.Drive/bun.lock @@ -0,0 +1,13 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "dependencies": { + "highlight.js": "^11.11.1", + }, + }, + }, + "packages": { + "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], + } +} diff --git a/DysonNetwork.Drive/package.json b/DysonNetwork.Drive/package.json new file mode 100644 index 0000000..318dde6 --- /dev/null +++ b/DysonNetwork.Drive/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "highlight.js": "^11.11.1" + } +} \ No newline at end of file diff --git a/DysonNetwork.Shared/PageData/Startup.cs b/DysonNetwork.Shared/PageData/Startup.cs index c163968..ee597a4 100644 --- a/DysonNetwork.Shared/PageData/Startup.cs +++ b/DysonNetwork.Shared/PageData/Startup.cs @@ -13,6 +13,13 @@ public static class PageStartup #pragma warning disable ASP0016 app.MapFallback(async context => { + if (context.Request.Path.StartsWithSegments("/api") || context.Request.Path.StartsWithSegments("/cgi")) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + await context.Response.WriteAsync("Not found"); + return; + } + var html = await File.ReadAllTextAsync(defaultFile); using var scope = app.Services.CreateScope();