Quota and better drive dashboard

This commit is contained in:
2025-07-27 18:08:39 +08:00
parent 4a0117906a
commit c875c82bdc
16 changed files with 758 additions and 46 deletions

View File

@@ -1,5 +1,6 @@
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Reflection; using System.Reflection;
using DysonNetwork.Drive.Billing;
using DysonNetwork.Drive.Storage; using DysonNetwork.Drive.Storage;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -16,6 +17,7 @@ public class AppDatabase(
) : DbContext(options) ) : DbContext(options)
{ {
public DbSet<FilePool> Pools { get; set; } = null!; public DbSet<FilePool> Pools { get; set; } = null!;
public DbSet<QuotaRecord> QuotaRecords { get; set; } = null!;
public DbSet<CloudFile> Files { get; set; } = null!; public DbSet<CloudFile> Files { get; set; } = null!;
public DbSet<CloudFileReference> FileReferences { get; set; } = null!; public DbSet<CloudFileReference> FileReferences { get; set; } = null!;

View File

@@ -0,0 +1,28 @@
using DysonNetwork.Shared.Data;
using NodaTime;
namespace DysonNetwork.Drive.Billing;
/// <summary>
/// The quota record stands for the extra quota that a user has.
/// For normal users, the quota is 1GiB.
/// For stellar program t1 users, the quota is 5GiB
/// For stellar program t2 users, the quota is 10GiB
/// For stellar program t3 users, the quota is 15GiB
///
/// If users want to increase the quota, they need to pay for it.
/// Each 1NSD they paid for one GiB.
///
/// But the quota record unit is MiB, the minimal billable unit.
/// </summary>
public class QuotaRecord : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid AccountId { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public long Quota { get; set; }
public Instant? ExpiredAt { get; set; }
}

View File

@@ -0,0 +1,66 @@
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Drive.Billing;
[ApiController]
[Route("/api/billing/quota")]
public class QuotaController(AppDatabase db, QuotaService quota) : ControllerBase
{
public class QuotaDetails
{
public long BasedQuota { get; set; }
public long ExtraQuota { get; set; }
public long TotalQuota { get; set; }
}
[HttpGet]
[Authorize]
public async Task<ActionResult<QuotaDetails>> GetQuota()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var (based, extra) = await quota.GetQuotaVerbose(accountId);
return Ok(new QuotaDetails
{
BasedQuota = based,
ExtraQuota = extra,
TotalQuota = based + extra
});
}
[HttpGet("records")]
[Authorize]
public async Task<ActionResult<List<QuotaRecord>>> GetQuotaRecords(
[FromQuery] bool expired = false,
[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 now = SystemClock.Instance.GetCurrentInstant();
var query = db.QuotaRecords
.Where(r => r.AccountId == accountId)
.AsQueryable();
if (!expired)
query = query
.Where(r => !r.ExpiredAt.HasValue || r.ExpiredAt > now);
var total = await query.CountAsync();
Response.Headers.Append("X-Total", total.ToString());
var records = await query
.OrderByDescending(r => r.CreatedAt)
.Skip(offset)
.Take(take)
.ToListAsync();
return Ok(records);
}
}

View File

@@ -0,0 +1,69 @@
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Drive.Billing;
public class QuotaService(
AppDatabase db,
UsageService usage,
AccountService.AccountServiceClient accounts,
ICacheService cache
)
{
public async Task<(bool ok, long billable, long quota)> IsFileAcceptable(Guid accountId, double costMultiplier, long newFileSize)
{
// The billable unit is MiB
var billableUnit = (long)Math.Ceiling(newFileSize / 1024.0 / 1024.0 * costMultiplier);
var totalBillableUsage = await usage.GetTotalBillableUsage();
var quota = await GetQuota(accountId);
return (totalBillableUsage + billableUnit <= quota, billableUnit, quota);
}
public async Task<long> GetQuota(Guid accountId)
{
var cacheKey = $"file:quota:{accountId}";
var cachedResult = await cache.GetAsync<long?>(cacheKey);
if (cachedResult.HasValue) return cachedResult.Value;
var (based, extra) = await GetQuotaVerbose(accountId);
var quota = based + extra;
await cache.SetAsync(cacheKey, quota);
return quota;
}
public async Task<(long based, long extra)> GetQuotaVerbose(Guid accountId)
{
var response = await accounts.GetAccountAsync(new GetAccountRequest { Id = accountId.ToString() });
var perkSubscription = response.PerkSubscription;
// The base quota is 1GiB, T1 is 5GiB, T2 is 10GiB, T3 is 15GiB
var basedQuota = 1L;
if (perkSubscription != null)
{
var privilege = PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(perkSubscription.Identifier);
basedQuota = privilege switch
{
1 => 5L,
2 => 10L,
3 => 15L,
_ => basedQuota
};
}
// The based quota is in GiB, we need to convert it to MiB
basedQuota *= 1024L;
var now = SystemClock.Instance.GetCurrentInstant();
var extraQuota = await db.QuotaRecords
.Where(e => e.AccountId == accountId)
.Where(e => !e.ExpiredAt.HasValue || e.ExpiredAt > now)
.SumAsync(e => e.Quota);
return (basedQuota, extraQuota);
}
}

View File

@@ -1,25 +1,49 @@
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Drive.Billing; namespace DysonNetwork.Drive.Billing;
[ApiController] [ApiController]
[Route("api/billing/usage")] [Route("api/billing/usage")]
public class UsageController(UsageService usageService) : ControllerBase public class UsageController(UsageService usage, QuotaService quota, ICacheService cache) : ControllerBase
{ {
[HttpGet] [HttpGet]
[Authorize]
public async Task<ActionResult<TotalUsageDetails>> GetTotalUsage() public async Task<ActionResult<TotalUsageDetails>> GetTotalUsage()
{ {
return await usageService.GetTotalUsage(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var cacheKey = $"file:usage:{accountId}";
// Try to get from cache first
var (found, cachedResult) = await cache.GetAsyncWithStatus<TotalUsageDetails>(cacheKey);
if (found && cachedResult != null)
return Ok(cachedResult);
// If not in cache, get from services
var result = await usage.GetTotalUsage(accountId);
var totalQuota = await quota.GetQuota(accountId);
result.TotalQuota = totalQuota;
// Cache the result for 5 minutes
await cache.SetAsync(cacheKey, result, TimeSpan.FromMinutes(5));
return Ok(result);
} }
[Authorize]
[HttpGet("{poolId:guid}")] [HttpGet("{poolId:guid}")]
public async Task<ActionResult<UsageDetails>> GetPoolUsage(Guid poolId) public async Task<ActionResult<UsageDetails>> GetPoolUsage(Guid poolId)
{ {
var usageDetails = await usageService.GetPoolUsage(poolId); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var usageDetails = await usage.GetPoolUsage(poolId, accountId);
if (usageDetails == null) if (usageDetails == null)
{
return NotFound(); return NotFound();
}
return usageDetails; return usageDetails;
} }
} }

View File

@@ -12,27 +12,27 @@ public class UsageDetails
public required long FileCount { get; set; } public required long FileCount { get; set; }
} }
public class UsageDetailsWithPercentage : UsageDetails
{
public required double Percentage { get; set; }
}
public class TotalUsageDetails public class TotalUsageDetails
{ {
public required List<UsageDetailsWithPercentage> PoolUsages { get; set; } public required List<UsageDetails> PoolUsages { get; set; }
public required long TotalUsageBytes { get; set; } public required long TotalUsageBytes { get; set; }
public required double TotalCost { get; set; }
public required long TotalFileCount { get; set; } public required long TotalFileCount { get; set; }
// Quota, cannot be loaded in the service, cause circular dependency
// Let the controller do the calculation
public long? TotalQuota { get; set; }
public long? UsedQuota { get; set; }
} }
public class UsageService(AppDatabase db) public class UsageService(AppDatabase db)
{ {
public async Task<TotalUsageDetails> GetTotalUsage() public async Task<TotalUsageDetails> GetTotalUsage(Guid accountId)
{ {
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
var fileQuery = db.Files var fileQuery = db.Files
.Where(f => !f.IsMarkedRecycle) .Where(f => !f.IsMarkedRecycle)
.Where(f => !f.ExpiredAt.HasValue || f.ExpiredAt > now) .Where(f => !f.ExpiredAt.HasValue || f.ExpiredAt > now)
.Where(f => f.AccountId == accountId)
.AsQueryable(); .AsQueryable();
var poolUsages = await db.Pools var poolUsages = await db.Pools
@@ -56,26 +56,16 @@ public class UsageService(AppDatabase db)
var totalCost = poolUsages.Sum(p => p.Cost); var totalCost = poolUsages.Sum(p => p.Cost);
var totalFileCount = poolUsages.Sum(p => p.FileCount); 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 return new TotalUsageDetails
{ {
PoolUsages = poolUsagesWithPercentage, PoolUsages = poolUsages,
TotalUsageBytes = totalUsage, TotalUsageBytes = totalUsage,
TotalCost = totalCost, TotalFileCount = totalFileCount,
TotalFileCount = totalFileCount UsedQuota = await GetTotalBillableUsage()
}; };
} }
public async Task<UsageDetails?> GetPoolUsage(Guid poolId) public async Task<UsageDetails?> GetPoolUsage(Guid poolId, Guid accountId)
{ {
var pool = await db.Pools.FindAsync(poolId); var pool = await db.Pools.FindAsync(poolId);
if (pool == null) if (pool == null)
@@ -87,6 +77,7 @@ public class UsageService(AppDatabase db)
var fileQuery = db.Files var fileQuery = db.Files
.Where(f => !f.IsMarkedRecycle) .Where(f => !f.IsMarkedRecycle)
.Where(f => f.ExpiredAt.HasValue && f.ExpiredAt > now) .Where(f => f.ExpiredAt.HasValue && f.ExpiredAt > now)
.Where(f => f.AccountId == accountId)
.AsQueryable(); .AsQueryable();
var usageBytes = await fileQuery var usageBytes = await fileQuery
@@ -107,4 +98,24 @@ public class UsageService(AppDatabase db)
FileCount = fileCount FileCount = fileCount
}; };
} }
public async Task<long> GetTotalBillableUsage()
{
var now = SystemClock.Instance.GetCurrentInstant();
var files = await db.Files
.Where(f => f.PoolId.HasValue)
.Where(f => !f.IsMarkedRecycle)
.Include(f => f.Pool)
.Where(f => !f.ExpiredAt.HasValue || f.ExpiredAt > now)
.Select(f => new
{
f.Size,
Multiplier = f.Pool!.BillingConfig.CostMultiplier ?? 1.0
})
.ToListAsync();
var totalCost = files.Sum(f => f.Size * f.Multiplier) / 1024.0 / 1024.0;
return (long)Math.Ceiling(totalCost);
}
} }

View File

@@ -122,7 +122,7 @@ function renderPoolSelectLabel(option: SelectOption & SnFilePool) {
), ),
policy.require_privilege && policy.require_privilege &&
h('span', `Require ${perkPrivilegeList[policy.require_privilege - 1]} Program`), h('span', `Require ${perkPrivilegeList[policy.require_privilege - 1]} Program`),
h('span', `Cost x${option.billing_config.cost_multiplier.toFixed(1)} NSD`), h('span', `Cost x${option.billing_config.cost_multiplier.toFixed(1)}`),
] ]
.filter((el) => el) .filter((el) => el)
.flatMap((el, idx, arr) => .flatMap((el, idx, arr) =>

View File

@@ -64,7 +64,7 @@ const guestOptions = [
const userOptions = computed(() => [ const userOptions = computed(() => [
{ {
label: 'Usage', label: 'Dashboard',
key: 'dashboardUsage', key: 'dashboardUsage',
icon: () => icon: () =>
h(NIcon, null, { h(NIcon, null, {

View File

@@ -3,7 +3,16 @@
<div class="h-full flex justify-center items-center" v-if="!usage"> <div class="h-full flex justify-center items-center" v-if="!usage">
<n-spin /> <n-spin />
</div> </div>
<n-grid cols="1 s:2 m:3 l:4" responsive="screen" :x-gap="16" :y-gap="16" v-else> <n-grid cols="1 s:2 l:4" responsive="screen" :x-gap="16" :y-gap="16" v-else>
<n-gi span="4">
<n-alert title="Billing Tips" size="small" type="info" closable>
<p>
The minimal billable unit is MiB, if your file is not enough 1 MiB it will be counted as
1 MiB.
</p>
<p>The <b>1 MiB = 1024 KiB = 1,048,576 B</b></p>
</n-alert>
</n-gi>
<n-gi> <n-gi>
<n-card class="h-stats"> <n-card class="h-stats">
<n-statistic label="All Uploads" tabular-nums> <n-statistic label="All Uploads" tabular-nums>
@@ -25,23 +34,45 @@
</n-gi> </n-gi>
<n-gi> <n-gi>
<n-card class="h-stats"> <n-card class="h-stats">
<n-statistic label="Cost" tabular-nums> <n-statistic label="Quota" tabular-nums>
<n-number-animation :from="0" :to="usage.total_cost" :precision="2" /> <n-number-animation :from="0" :to="usage.total_quota" />
<template #suffix>NSD</template> <template #suffix>MiB</template>
</n-statistic> </n-statistic>
</n-card> </n-card>
</n-gi> </n-gi>
<n-gi> <n-gi>
<n-card class="h-stats"> <n-card class="h-stats">
<n-statistic label="Pools" tabular-nums> <div class="flex gap-2 justify-between items-end">
<n-number-animation :from="0" :to="usage.pool_usages.length" /> <n-statistic label="Used Quota" tabular-nums>
</n-statistic> <n-number-animation :from="0" :to="quotaUsagePercentage" :precision="2" />
<template #suffix>%</template>
</n-statistic>
<n-progress
type="circle"
:percentage="quotaUsagePercentage"
:show-indicator="false"
:stroke-width="16"
style="width: 40px"
/>
</div>
</n-card> </n-card>
</n-gi> </n-gi>
<n-gi span="2"> <n-gi span="2">
<n-card class="ratio-video" title="Pool Usage"> <n-card class="aspect-video" title="Pool Usage">
<pie <pie
:data="chartData" :data="poolChartData"
:options="{
maintainAspectRatio: false,
responsive: true,
plugins: { legend: { position: isDesktop ? 'right' : 'bottom' } },
}"
/>
</n-card>
</n-gi>
<n-gi span="2">
<n-card class="aspect-video h-full" title="Verbose Quota">
<pie
:data="quotaChartData"
:options="{ :options="{
maintainAspectRatio: false, maintainAspectRatio: false,
responsive: true, responsive: true,
@@ -55,7 +86,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { NSpin, NCard, NStatistic, NGrid, NGi, NNumberAnimation } from 'naive-ui' import { NSpin, NCard, NStatistic, NGrid, NGi, NNumberAnimation, NAlert, NProgress } from 'naive-ui'
import { Chart as ChartJS, Title, Tooltip, Legend, ArcElement } from 'chart.js' import { Chart as ChartJS, Title, Tooltip, Legend, ArcElement } from 'chart.js'
import { Pie } from 'vue-chartjs' import { Pie } from 'vue-chartjs'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
@@ -66,7 +97,7 @@ ChartJS.register(Title, Tooltip, Legend, ArcElement)
const breakpoints = useBreakpoints(breakpointsTailwind) const breakpoints = useBreakpoints(breakpointsTailwind)
const isDesktop = breakpoints.greaterOrEqual('md') const isDesktop = breakpoints.greaterOrEqual('md')
const chartData = computed(() => ({ const poolChartData = computed(() => ({
labels: usage.value.pool_usages.map((pool: any) => pool.pool_name), labels: usage.value.pool_usages.map((pool: any) => pool.pool_name),
datasets: [ datasets: [
{ {
@@ -74,7 +105,7 @@ const chartData = computed(() => ({
backgroundColor: '#7D80BAFF', backgroundColor: '#7D80BAFF',
data: usage.value.pool_usages.map((pool: any) => pool.usage_bytes), data: usage.value.pool_usages.map((pool: any) => pool.usage_bytes),
}, },
] ],
})) }))
const usage = ref<any>() const usage = ref<any>()
@@ -91,6 +122,36 @@ async function fetchUsage() {
} }
onMounted(() => fetchUsage()) onMounted(() => fetchUsage())
const verboseQuota = ref<
{ based_quota: number; extra_quota: number; total_quota: number } | undefined
>()
async function fetchVerboseQuota() {
try {
const response = await fetch('/api/billing/quota')
if (!response.ok) {
throw new Error('Network response was not ok')
}
verboseQuota.value = await response.json()
} catch (error) {
console.error('Failed to fetch verbose data:', error)
}
}
onMounted(() => fetchVerboseQuota())
const quotaChartData = computed(() => ({
labels: ['Base Quota', 'Extra Quota'],
datasets: [
{
label: 'Verbose Quota',
backgroundColor: '#7D80BAFF',
data: [verboseQuota.value?.based_quota ?? 0, verboseQuota.value?.extra_quota ?? 0],
},
],
}))
const quotaUsagePercentage = computed(
() => (usage.value.used_quota / usage.value.total_quota) * 100,
)
function toGigabytes(bytes: number): number { function toGigabytes(bytes: number): number {
return bytes / (1024 * 1024 * 1024) return bytes / (1024 * 1024 * 1024)
} }

View File

@@ -1,6 +1,6 @@
<template> <template>
<section class="h-full relative flex items-center justify-center"> <section class="h-full relative flex items-center justify-center">
<n-card class="max-w-lg" title="About" v-if="!userStore.user"> <n-card class="max-w-lg my-4 mx-8" title="About" v-if="!userStore.user">
<p>Welcome to the <b>Solar Drive</b></p> <p>Welcome to the <b>Solar Drive</b></p>
<p>We help you upload, collect, and share files with ease in mind.</p> <p>We help you upload, collect, and share files with ease in mind.</p>
<p>To continue, login first.</p> <p>To continue, login first.</p>
@@ -22,6 +22,13 @@
</div> </div>
</template> </template>
<n-collapse-transition :show="showRecycleHint">
<n-alert size="small" type="warning" title="Recycle Enabled" class="mb-3">
You're uploading to a pool which enabled recycle. If the file you uploaded didn't
referenced from the Solar Network. It will be marked and will be deleted some while later.
</n-alert>
</n-collapse-transition>
<div class="mb-3"> <div class="mb-3">
<file-pool-select v-model="filePool" @update:pool="currentFilePool = $event" /> <file-pool-select v-model="filePool" @update:pool="currentFilePool = $event" />
</div> </div>
@@ -107,6 +114,7 @@ import {
NSwitch, NSwitch,
NCollapseTransition, NCollapseTransition,
NDatePicker, NDatePicker,
NAlert,
type UploadCustomRequestOptions, type UploadCustomRequestOptions,
type UploadSettledFileInfo, type UploadSettledFileInfo,
type UploadFileInfo, type UploadFileInfo,
@@ -149,6 +157,10 @@ const currentFilePool = computed(() => {
if (!filePool.value) return null if (!filePool.value) return null
return pools.value?.find((pool) => pool.id === filePool.value) ?? null return pools.value?.find((pool) => pool.id === filePool.value) ?? null
}) })
const showRecycleHint = computed(() => {
if (!filePool.value) return true
return currentFilePool.value.policy_config?.enable_recycle || false
})
const messageDisplay = useMessage() const messageDisplay = useMessage()

View File

@@ -0,0 +1,322 @@
// <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("20250727092028_AddQuotaRecord")]
partial class AddQuotaRecord
{
/// <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.Billing.QuotaRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.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")
.IsRequired()
.HasColumnType("text")
.HasColumnName("description");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<long>("Quota")
.HasColumnType("bigint")
.HasColumnName("quota");
b.Property<Instant>("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<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<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
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,42 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Drive.Migrations
{
/// <inheritdoc />
public partial class AddQuotaRecord : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "quota_records",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
name = table.Column<string>(type: "text", nullable: false),
description = table.Column<string>(type: "text", nullable: false),
quota = table.Column<long>(type: "bigint", nullable: false),
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_quota_records", x => x.id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "quota_records");
}
}
}

View File

@@ -27,6 +27,53 @@ namespace DysonNetwork.Drive.Migrations
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.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")
.IsRequired()
.HasColumnType("text")
.HasColumnName("description");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<long>("Quota")
.HasColumnType("bigint")
.HasColumnName("quota");
b.Property<Instant>("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 => modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")

View File

@@ -20,6 +20,7 @@ builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication(); builder.Services.AddAppAuthentication();
builder.Services.AddAppSwagger(); builder.Services.AddAppSwagger();
builder.Services.AddDysonAuth(); builder.Services.AddDysonAuth();
builder.Services.AddAccountService();
builder.Services.AddAppFileStorage(builder.Configuration); builder.Services.AddAppFileStorage(builder.Configuration);

View File

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

View File

@@ -1,6 +1,7 @@
using System.Net; using System.Net;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Drive.Billing;
using DysonNetwork.Shared.Auth; using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -9,7 +10,6 @@ using NodaTime;
using tusdotnet.Interfaces; using tusdotnet.Interfaces;
using tusdotnet.Models; using tusdotnet.Models;
using tusdotnet.Models.Configuration; using tusdotnet.Models.Configuration;
using tusdotnet.Stores;
namespace DysonNetwork.Drive.Storage; namespace DysonNetwork.Drive.Storage;
@@ -86,7 +86,6 @@ public abstract class TusService
HttpStatusCode.Forbidden, HttpStatusCode.Forbidden,
$"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use this pool, you are tier {privilege}" $"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use this pool, you are tier {privilege}"
); );
return;
} }
} }
}, },
@@ -140,8 +139,10 @@ public abstract class TusService
} }
catch (Exception ex) catch (Exception ex)
{ {
var logger = services.GetRequiredService<ILogger<TusService>>();
eventContext.HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest; eventContext.HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await eventContext.HttpContext.Response.WriteAsync(ex.Message); await eventContext.HttpContext.Response.WriteAsync(ex.Message);
logger.LogError(ex, "Error handling file upload...");
} }
finally finally
{ {
@@ -151,6 +152,13 @@ public abstract class TusService
}, },
OnBeforeCreateAsync = async eventContext => OnBeforeCreateAsync = async eventContext =>
{ {
var httpContext = eventContext.HttpContext;
if (httpContext.Items["CurrentUser"] is not Account currentUser)
{
eventContext.FailRequest(HttpStatusCode.Unauthorized);
return;
}
var filePool = eventContext.HttpContext.Request.Headers["X-FilePool"].FirstOrDefault(); var filePool = eventContext.HttpContext.Request.Headers["X-FilePool"].FirstOrDefault();
if (string.IsNullOrEmpty(filePool)) filePool = configuration["Storage:PreferredRemote"]; if (string.IsNullOrEmpty(filePool)) filePool = configuration["Storage:PreferredRemote"];
if (!Guid.TryParse(filePool, out _)) if (!Guid.TryParse(filePool, out _))
@@ -223,6 +231,25 @@ 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,
eventContext.UploadLength
);
if (!ok)
{
eventContext.FailRequest(
HttpStatusCode.Forbidden,
$"File size {billableUnit} MiB is exceed than the user's quota {quota} MiB"
);
rejected = true;
}
}
if (rejected) if (rejected)
logger.LogInformation("File rejected #{FileId}", eventContext.FileId); logger.LogInformation("File rejected #{FileId}", eventContext.FileId);
}, },
@@ -230,7 +257,7 @@ public abstract class TusService
{ {
var directUpload = eventContext.HttpContext.Request.Headers["X-DirectUpload"].FirstOrDefault(); var directUpload = eventContext.HttpContext.Request.Headers["X-DirectUpload"].FirstOrDefault();
if (!string.IsNullOrEmpty(directUpload)) return Task.CompletedTask; if (!string.IsNullOrEmpty(directUpload)) return Task.CompletedTask;
var gatewayUrl = configuration["GatewayUrl"]; var gatewayUrl = configuration["GatewayUrl"];
if (gatewayUrl is not null) if (gatewayUrl is not null)
eventContext.SetUploadUrl(new Uri(gatewayUrl + "/drive/tus/" + eventContext.FileId)); eventContext.SetUploadUrl(new Uri(gatewayUrl + "/drive/tus/" + eventContext.FileId));