Compare commits

..

2 Commits

Author SHA1 Message Date
02af78ca99 Usage drive 2025-07-26 23:21:57 +08:00
f40d1dc1b2 Setup drive dashboard 2025-07-26 22:01:17 +08:00
22 changed files with 781 additions and 66 deletions

View File

@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Drive.Billing;
[ApiController]
[Route("api/billing/usage")]
public class UsageController(UsageService usageService) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<TotalUsageDetails>> GetTotalUsage()
{
return await usageService.GetTotalUsage();
}
[HttpGet("{poolId:guid}")]
public async Task<ActionResult<UsageDetails>> GetPoolUsage(Guid poolId)
{
var usageDetails = await usageService.GetPoolUsage(poolId);
if (usageDetails == null)
{
return NotFound();
}
return usageDetails;
}
}

View File

@@ -1,6 +1,99 @@
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Drive.Billing;
public class UsageService
public class UsageDetails
{
public required Guid PoolId { get; set; }
public required string PoolName { get; set; }
public required long UsageBytes { get; set; }
public required double Cost { get; set; }
public required long FileCount { get; set; }
}
public class UsageDetailsWithPercentage : UsageDetails
{
public required double Percentage { get; set; }
}
public class TotalUsageDetails
{
public required List<UsageDetailsWithPercentage> PoolUsages { get; set; }
public required long TotalUsageBytes { get; set; }
public required double TotalCost { get; set; }
public required long TotalFileCount { get; set; }
}
public class UsageService(AppDatabase db)
{
public async Task<TotalUsageDetails> GetTotalUsage()
{
var poolUsages = await db.Pools
.Select(p => new UsageDetails
{
PoolId = p.Id,
PoolName = p.Name,
UsageBytes = db.Files
.Where(f => f.PoolId == p.Id)
.Sum(f => f.Size),
Cost = db.Files
.Where(f => f.PoolId == p.Id)
.Sum(f => f.Size) / 1024.0 / 1024.0 *
(p.BillingConfig.CostMultiplier ?? 1.0),
FileCount = db.Files
.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);
var poolUsagesWithPercentage = poolUsages.Select(p => new UsageDetailsWithPercentage
{
PoolId = p.PoolId,
PoolName = p.PoolName,
UsageBytes = p.UsageBytes,
Cost = p.Cost,
FileCount = p.FileCount,
Percentage = totalUsage > 0 ? (double)p.UsageBytes / totalUsage : 0
}).ToList();
return new TotalUsageDetails
{
PoolUsages = poolUsagesWithPercentage,
TotalUsageBytes = totalUsage,
TotalCost = totalCost,
TotalFileCount = totalFileCount
};
}
public async Task<UsageDetails?> GetPoolUsage(Guid poolId)
{
var pool = await db.Pools.FindAsync(poolId);
if (pool == null)
{
return null;
}
var usageBytes = await db.Files
.Where(f => f.PoolId == poolId)
.SumAsync(f => f.Size);
var fileCount = await db.Files
.Where(f => f.PoolId == poolId)
.CountAsync();
var cost = usageBytes / 1024.0 / 1024.0 *
(pool.BillingConfig.CostMultiplier ?? 1.0);
return new UsageDetails
{
PoolId = pool.Id,
PoolName = pool.Name,
UsageBytes = usageBytes,
Cost = cost,
FileCount = fileCount
};
}
}

View File

@@ -11,10 +11,12 @@
"@vueuse/core": "^13.5.0",
"aspnet-prerendering": "^3.0.1",
"cfturnstile-vue3": "^2.0.0",
"chart.js": "^4.5.0",
"pinia": "^3.0.3",
"tailwindcss": "^4.1.11",
"tus-js-client": "^4.3.1",
"vue": "^3.5.17",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.5.1",
},
"devDependencies": {
@@ -162,6 +164,8 @@
"@juggle/resize-observer": ["@juggle/resize-observer@3.4.0", "", {}, "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="],
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
@@ -400,6 +404,8 @@
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chart.js": ["chart.js@4.5.0", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ=="],
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@@ -854,6 +860,8 @@
"vue": ["vue@3.5.17", "", { "dependencies": { "@vue/compiler-dom": "3.5.17", "@vue/compiler-sfc": "3.5.17", "@vue/runtime-dom": "3.5.17", "@vue/server-renderer": "3.5.17", "@vue/shared": "3.5.17" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-LbHV3xPN9BeljML+Xctq4lbz2lVHCR6DtbpTf5XIO6gugpXUN49j2QQPcMj086r9+AkJ0FfUT8xjulKKBkkr9g=="],
"vue-chartjs": ["vue-chartjs@5.3.2", "", { "peerDependencies": { "chart.js": "^4.1.1", "vue": "^3.0.0-0 || ^2.7.0" } }, "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw=="],
"vue-eslint-parser": ["vue-eslint-parser@10.2.0", "", { "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "esquery": "^1.6.0", "semver": "^7.6.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0" } }, "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw=="],
"vue-router": ["vue-router@4.5.1", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.2.0" } }, "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw=="],

View File

@@ -22,10 +22,12 @@
"@vueuse/core": "^13.5.0",
"aspnet-prerendering": "^3.0.1",
"cfturnstile-vue3": "^2.0.0",
"chart.js": "^4.5.0",
"pinia": "^3.0.3",
"tailwindcss": "^4.1.11",
"tus-js-client": "^4.3.1",
"vue": "^3.5.17",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.5.1"
},
"devDependencies": {

View File

@@ -0,0 +1,42 @@
<template>
<n-layout has-sider class="h-full">
<n-layout-sider bordered collapse-mode="width" :collapsed-width="64" :width="240" show-trigger>
<n-menu
:collapsed-width="64"
:collapsed-icon-size="22"
:options="menuOptions"
:value="route.name as string"
@update:value="updateMenuSelect"
/>
</n-layout-sider>
<n-layout>
<router-view />
</n-layout>
</n-layout>
</template>
<script setup lang="ts">
import { DataUsageRound } from '@vicons/material'
import { NIcon, NLayout, NLayoutSider, NMenu, type MenuOption } from 'naive-ui'
import { h, type Component } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
function renderIcon(icon: Component) {
return () => h(NIcon, null, { default: () => h(icon) })
}
const menuOptions: MenuOption[] = [
{
label: 'Usage',
key: 'dashboardUsage',
icon: renderIcon(DataUsageRound),
},
]
function updateMenuSelect(key: string) {
router.push({ name: key })
}
</script>

View File

@@ -15,7 +15,7 @@
</n-dropdown>
</div>
</n-layout-header>
<n-layout-content embedded content-style="padding: 24px;">
<n-layout-content embedded>
<router-view />
</n-layout-content>
</n-layout>
@@ -28,12 +28,15 @@ import {
LogInOutlined,
PersonAddAlt1Outlined,
PersonOutlineRound,
DataUsageRound,
} from '@vicons/material'
import { useUserStore } from '@/stores/user'
import { useRoute, useRouter } from 'vue-router'
import { useServicesStore } from '@/stores/services'
const userStore = useUserStore()
const router = useRouter()
const route = useRoute()
const hideUserMenu = computed(() => {
@@ -60,6 +63,14 @@ const guestOptions = [
]
const userOptions = computed(() => [
{
label: 'Usage',
key: 'dashboardUsage',
icon: () =>
h(NIcon, null, {
default: () => h(DataUsageRound),
}),
},
{
label: 'Profile',
key: 'profile',
@@ -67,7 +78,7 @@ const userOptions = computed(() => [
h(NIcon, null, {
default: () => h(PersonOutlineRound),
}),
}
},
])
const servicesStore = useServicesStore()
@@ -83,6 +94,8 @@ function handleGuestMenuSelect(key: string) {
function handleUserMenuSelect(key: string) {
if (key === 'profile') {
window.open(servicesStore.getSerivceUrl('DysonNetwork.Pass', 'accounts/me')!, '_blank')
} else {
router.push({ name: key })
}
}
</script>

View File

@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useServicesStore } from '@/stores/services'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -7,26 +8,53 @@ const router = createRouter({
{
path: '/',
name: 'index',
component: () => import('../views/index.vue')
component: () => import('../views/index.vue'),
},
{
path: '/files/:fileId',
name: 'files',
component: () => import('../views/files.vue'),
}
]
},
{
path: '/dashboard',
name: 'dashboard',
component: () => import('../layouts/dashboard.vue'),
meta: { requiresAuth: true },
children: [
{
path: 'usage',
name: 'dashboardUsage',
component: () => import('../views/dashboard/usage.vue'),
meta: { requiresAuth: true },
},
],
},
{
path: '/:notFound(.*)',
name: 'errorNotFound',
component: () => import('../views/not-found.vue'),
},
],
})
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
const servicesStore = useServicesStore()
// Initialize user state if not already initialized
if (!userStore.user && localStorage.getItem('authToken')) {
await userStore.initialize()
await userStore.fetchUser()
}
if (to.matched.some((record) => record.meta.requiresAuth) && !userStore.isAuthenticated) {
next({ name: 'login', query: { redirect: to.fullPath } })
window.open(
servicesStore.getSerivceUrl(
'DysonNetwork.Pass',
'login?redirect=' + encodeURIComponent(window.location.href),
)!,
'_blank',
)
next('/')
} else {
next()
}

View File

@@ -11,7 +11,8 @@ export const useUserStore = defineStore('user', () => {
const isAuthenticated = computed(() => !!user.value)
// Actions
async function fetchUser() {
async function fetchUser(reload = true) {
if (!reload && user.value) return
isLoading.value = true
error.value = null
try {
@@ -21,9 +22,6 @@ export const useUserStore = defineStore('user', () => {
if (!response.ok) {
// If the token is invalid, clear it and the user state
if (response.status === 401) {
logout()
}
throw new Error('Failed to fetch user information.')
}
@@ -36,13 +34,6 @@ export const useUserStore = defineStore('user', () => {
}
}
function logout() {
user.value = null
localStorage.removeItem('authToken')
// Optionally, redirect to login page
// router.push('/login')
}
function initialize() {
const allowedOrigin = import.meta.env.DEV ? window.location.origin : 'https://id.solian.app'
window.addEventListener('message', (event) => {
@@ -69,7 +60,6 @@ export const useUserStore = defineStore('user', () => {
error,
isAuthenticated,
fetchUser,
logout,
initialize,
}
})

View File

@@ -1,35 +1,36 @@
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;
id: string
name: string
description: 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;
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;
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
}

View File

@@ -0,0 +1,103 @@
<template>
<section class="h-full container-fluid mx-auto py-4 px-5">
<div class="h-full flex justify-center items-center" v-if="!usage">
<n-spin />
</div>
<n-grid cols="1 s:2 m:3 l:4" responsive="screen" :x-gap="16" :y-gap="16" v-else>
<n-gi>
<n-card class="h-stats">
<n-statistic label="All Uploads" tabular-nums>
<n-number-animation
:from="0"
:to="toGigabytes(usage.total_usage_bytes)"
:precision="3"
/>
<template #suffix>GiB</template>
</n-statistic>
</n-card>
</n-gi>
<n-gi>
<n-card class="h-stats">
<n-statistic label="All Files" tabular-nums>
<n-number-animation :from="0" :to="usage.total_file_count" />
</n-statistic>
</n-card>
</n-gi>
<n-gi>
<n-card class="h-stats">
<n-statistic label="Cost" tabular-nums>
<n-number-animation :from="0" :to="usage.total_cost" :precision="2" />
<template #suffix>NSD</template>
</n-statistic>
</n-card>
</n-gi>
<n-gi>
<n-card class="h-stats">
<n-statistic label="Pools" tabular-nums>
<n-number-animation :from="0" :to="usage.pool_usages.length" />
</n-statistic>
</n-card>
</n-gi>
<n-gi span="2">
<n-card class="ratio-video" title="Pool Usage">
<pie
:data="chartData"
:options="{
maintainAspectRatio: false,
responsive: true,
plugins: { legend: { position: isDesktop ? 'right' : 'bottom' } },
}"
/>
</n-card>
</n-gi>
</n-grid>
</section>
</template>
<script setup lang="ts">
import { NSpin, NCard, NStatistic, NGrid, NGi, NNumberAnimation } from 'naive-ui'
import { Chart as ChartJS, Title, Tooltip, Legend, ArcElement } from 'chart.js'
import { Pie } from 'vue-chartjs'
import { computed, onMounted, ref } from 'vue'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
ChartJS.register(Title, Tooltip, Legend, ArcElement)
const breakpoints = useBreakpoints(breakpointsTailwind)
const isDesktop = breakpoints.greaterOrEqual('md')
const chartData = computed(() => ({
labels: usage.value.pool_usages.map((pool: any) => pool.pool_name),
datasets: [
{
label: 'Pool Usage',
backgroundColor: '#7D80BAFF',
data: usage.value.pool_usages.map((pool: any) => pool.usage_bytes),
},
]
}))
const usage = ref<any>()
async function fetchUsage() {
try {
const response = await fetch('/api/billing/usage')
if (!response.ok) {
throw new Error('Network response was not ok')
}
usage.value = await response.json()
} catch (error) {
console.error('Failed to fetch usage data:', error)
}
}
onMounted(() => fetchUsage())
function toGigabytes(bytes: number): number {
return bytes / (1024 * 1024 * 1024)
}
</script>
<style scoped>
.h-stats {
height: 105px;
}
</style>

View File

@@ -143,6 +143,7 @@ import { useRoute } from 'vue-router'
import { computed, onMounted, ref } from 'vue'
import { downloadAndDecryptFile } from './secure'
import { formatBytes } from './format'
import hljs from 'highlight.js/lib/core'
import json from 'highlight.js/lib/languages/json'
@@ -200,13 +201,4 @@ function downloadFile() {
window.open(fileSource.value, '_blank')
}
}
function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return '0 Bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
</script>

View File

@@ -0,0 +1,8 @@
export function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return '0 Bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}

View File

@@ -49,7 +49,9 @@
type="password"
class="mb-2"
/>
<p class="pl-1 text-xs opacity-75 mt-[-4px]">Only available for Stellar Program and certian file pool.</p>
<p class="pl-1 text-xs opacity-75 mt-[-4px]">
Only available for Stellar Program and certian file pool.
</p>
</div>
</n-card>
</n-collapse-transition>
@@ -110,11 +112,15 @@ import {
type SelectOption,
type SelectRenderTag,
type UploadFileInfo,
useMessage,
NDivider,
NTooltip,
} from 'naive-ui'
import { computed, h, onMounted, ref } from 'vue'
import { CloudUploadRound } from '@vicons/material'
import { useUserStore } from '@/stores/user'
import type { SnFilePool } from '@/types/pool'
import { formatBytes } from './format'
import * as tus from 'tus-js-client'
@@ -160,6 +166,42 @@ function renderPoolSelectLabel(option: SelectOption & SnFilePool) {
},
[
h('div', null, [option.name as string]),
option.description &&
h(
'div',
{
style: {
fontSize: '0.875rem',
opacity: '0.75',
},
},
option.description,
),
h(
'div',
{
style: {
display: 'flex',
marginBottom: '4px',
fontSize: '0.75rem',
opacity: '0.75',
},
},
[
policy.max_file_size && h('span', `Max ${formatBytes(policy.max_file_size)}`),
policy.accept_types &&
h(
NTooltip,
{},
{
trigger: () => h('span', `Accept limited types`),
default: () => h('span', policy.accept_types.join(', ')),
},
),
].flatMap((el, idx, arr) =>
idx < arr.length - 1 ? [el, h(NDivider, { vertical: true })] : [el],
),
),
h(
'div',
{
@@ -228,6 +270,8 @@ const currentFilePool = computed(() => {
return pools.value?.find((pool) => pool.id === filePool.value) ?? null
})
const messageDisplay = useMessage()
function customRequest({
file,
data,
@@ -246,13 +290,21 @@ function customRequest({
retryDelays: [0, 3000, 5000, 10000, 20000],
metadata: {
filename: file.name,
filetype: file.type ?? 'application/octet-stream',
'content-type': file.type ?? 'application/octet-stream',
},
headers: {
...requestHeaders,
...headers,
},
onError: function (error) {
if (error instanceof tus.DetailedError) {
const failedBody = error.originalResponse?.getBody()
if (failedBody != null)
messageDisplay.error(`Upload failed: ${failedBody}`, {
duration: 10000,
closable: true,
})
}
console.error('[DRIVE] Upload failed:', error)
onError()
},
@@ -290,8 +342,7 @@ function createThumbnailUrl(
function customDownload(file: UploadFileInfo) {
const { url, name } = file
if (!url)
return
if (!url) return
window.open(url.replace('/api', ''), '_blank')
}

View File

@@ -0,0 +1,16 @@
<template>
<section class="h-full flex items-center justify-center">
<n-result status="404" title="404" description="Page not found">
<template #footer>
<n-button @click="router.push('/')">Go to Home</n-button>
</template>
</n-result>
</section>
</template>
<script lang="ts" setup>
import { NResult, NButton } from 'naive-ui'
import { useRouter } from 'vue-router';
const router = useRouter()
</script>

View File

@@ -0,0 +1,271 @@
// <auto-generated />
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("20250726120323_AddFilePoolDescription")]
partial class AddFilePoolDescription
{
/// <inheritdoc />
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<string>("Id")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<Dictionary<string, object>>("FileMeta")
.HasColumnType("jsonb")
.HasColumnName("file_meta");
b.Property<bool>("HasCompression")
.HasColumnType("boolean")
.HasColumnName("has_compression");
b.Property<bool>("HasThumbnail")
.HasColumnType("boolean")
.HasColumnName("has_thumbnail");
b.Property<string>("Hash")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("hash");
b.Property<bool>("IsEncrypted")
.HasColumnType("boolean")
.HasColumnName("is_encrypted");
b.Property<bool>("IsMarkedRecycle")
.HasColumnType("boolean")
.HasColumnName("is_marked_recycle");
b.Property<string>("MimeType")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("mime_type");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<Guid?>("PoolId")
.HasColumnType("uuid")
.HasColumnName("pool_id");
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
.HasColumnType("jsonb")
.HasColumnName("sensitive_marks");
b.Property<long>("Size")
.HasColumnType("bigint")
.HasColumnName("size");
b.Property<string>("StorageId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("storage_id");
b.Property<string>("StorageUrl")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("storage_url");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<Instant?>("UploadedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("uploaded_at");
b.Property<string>("UploadedTo")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("uploaded_to");
b.Property<Dictionary<string, object>>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("FileId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("file_id");
b.Property<string>("ResourceId")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("resource_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid?>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<BillingConfig>("BillingConfig")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("billing_config");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("description");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<PolicyConfig>("PolicyConfig")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("policy_config");
b.Property<RemoteStorageConfig>("StorageConfig")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("storage_config");
b.Property<Instant>("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
}
}
}

View File

@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Drive.Migrations
{
/// <inheritdoc />
public partial class AddFilePoolDescription : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "description",
table: "pools",
type: "character varying(8192)",
maxLength: 8192,
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "description",
table: "pools");
}
}
}

View File

@@ -209,6 +209,12 @@ namespace DysonNetwork.Drive.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("description");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)

View File

@@ -139,6 +139,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<Storage.FileService>();
services.AddScoped<Storage.FileReferenceService>();
services.AddScoped<Billing.UsageService>();
services.AddScoped<Billing.UsageService>();
return services;
}

View File

@@ -21,7 +21,7 @@ public class RemoteStorageConfig
public class BillingConfig
{
public double CostMultiplier { get; set; } = 1.0;
public double? CostMultiplier { get; set; } = 1.0;
}
public class PolicyConfig
@@ -41,6 +41,7 @@ public class FilePool : ModelBase, IIdentifiedResource
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Name { get; set; } = string.Empty;
[MaxLength(8192)] public string Description { get; set; } = string.Empty;
[Column(TypeName = "jsonb")] public RemoteStorageConfig StorageConfig { get; set; } = new();
[Column(TypeName = "jsonb")] public BillingConfig BillingConfig { get; set; } = new();
[Column(TypeName = "jsonb")] public PolicyConfig PolicyConfig { get; set; } = new();

View File

@@ -156,9 +156,9 @@ public abstract class TusService
var metadata = eventContext.Metadata;
var contentType = metadata.TryGetValue("content-type", out var ct) ? ct.GetString(Encoding.UTF8) : null;
var scope = eventContext.HttpContext.RequestServices.CreateScope();
var rejected = false;
var fs = scope.ServiceProvider.GetRequiredService<FileService>();
@@ -173,9 +173,22 @@ public abstract class TusService
// Do the policy check
var policy = pool!.PolicyConfig;
if (!rejected && !pool.PolicyConfig.AllowEncryption)
{
var encryptPassword = eventContext.HttpContext.Request.Headers["X-FilePass"].FirstOrDefault();
if (!string.IsNullOrEmpty(encryptPassword))
{
eventContext.FailRequest(
HttpStatusCode.Forbidden,
"File encryption is not allowed in this pool"
);
rejected = true;
}
}
if (!rejected && policy.AcceptTypes is not null)
{
if (contentType is null)
if (string.IsNullOrEmpty(contentType))
{
eventContext.FailRequest(
HttpStatusCode.BadRequest,

View File

@@ -34,7 +34,12 @@ const router = createRouter({
name: 'me',
component: () => import('../views/accounts/me.vue'),
meta: { requiresAuth: true }
}
},
{
path: '/:notFound(.*)',
name: 'errorNotFound',
component: () => import('../views/not-found.vue'),
},
]
})

View File

@@ -0,0 +1,16 @@
<template>
<section class="h-full flex items-center justify-center">
<n-result status="404" title="404" description="Page not found">
<template #footer>
<n-button @click="router.push('/')">Go to Home</n-button>
</template>
</n-result>
</section>
</template>
<script lang="ts" setup>
import { NResult, NButton } from 'naive-ui'
import { useRouter } from 'vue-router';
const router = useRouter()
</script>