Compare commits
	
		
			2 Commits
		
	
	
		
			7442b8416f
			...
			8b1bb7fcfd
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 8b1bb7fcfd | |||
| e31a5ea017 | 
| @@ -17,6 +17,8 @@ public class AppDatabase( | |||||||
| ) : DbContext(options) | ) : DbContext(options) | ||||||
| { | { | ||||||
|     public DbSet<FilePool> Pools { get; set; } = null!; |     public DbSet<FilePool> Pools { get; set; } = null!; | ||||||
|  |     public DbSet<FileBundle> Bundles { get; set; } = null!; | ||||||
|  |      | ||||||
|     public DbSet<QuotaRecord> QuotaRecords { get; set; } = null!; |     public DbSet<QuotaRecord> QuotaRecords { get; set; } = null!; | ||||||
|      |      | ||||||
|     public DbSet<CloudFile> Files { get; set; } = null!; |     public DbSet<CloudFile> Files { get; set; } = null!; | ||||||
|   | |||||||
| @@ -47,13 +47,12 @@ public class UsageService(AppDatabase db) | |||||||
|                            .Where(f => f.PoolId == p.Id) |                            .Where(f => f.PoolId == p.Id) | ||||||
|                            .Sum(f => f.Size) / 1024.0 / 1024.0 * |                            .Sum(f => f.Size) / 1024.0 / 1024.0 * | ||||||
|                        (p.BillingConfig.CostMultiplier ?? 1.0), |                        (p.BillingConfig.CostMultiplier ?? 1.0), | ||||||
|                 FileCount = db.Files |                 FileCount = fileQuery | ||||||
|                     .Count(f => f.PoolId == p.Id) |                     .Count(f => f.PoolId == p.Id) | ||||||
|             }) |             }) | ||||||
|             .ToListAsync(); |             .ToListAsync(); | ||||||
|  |  | ||||||
|         var totalUsage = poolUsages.Sum(p => p.UsageBytes); |         var totalUsage = poolUsages.Sum(p => p.UsageBytes); | ||||||
|         var totalCost = poolUsages.Sum(p => p.Cost); |  | ||||||
|         var totalFileCount = poolUsages.Sum(p => p.FileCount); |         var totalFileCount = poolUsages.Sum(p => p.FileCount); | ||||||
|  |  | ||||||
|         return new TotalUsageDetails |         return new TotalUsageDetails | ||||||
|   | |||||||
							
								
								
									
										50
									
								
								DysonNetwork.Drive/Client/src/components/BundleSelect.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								DysonNetwork.Drive/Client/src/components/BundleSelect.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | |||||||
|  | <template> | ||||||
|  |   <n-select | ||||||
|  |     v-model:value="selectedBundle" | ||||||
|  |     :options="options" | ||||||
|  |     placeholder="Select a bundle" | ||||||
|  |     @update:value="handleBundleChange" | ||||||
|  |     filterable | ||||||
|  |     remote | ||||||
|  |     :loading="loading" | ||||||
|  |     @search="handleSearch" | ||||||
|  |     clearable | ||||||
|  |   /> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup lang="ts"> | ||||||
|  | import { NSelect } from 'naive-ui' | ||||||
|  | import { ref, onMounted } from 'vue' | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['update:bundle']) | ||||||
|  |  | ||||||
|  | const selectedBundle = ref<string | null>(null) | ||||||
|  | const loading = ref(false) | ||||||
|  | const options = ref<any[]>([]) | ||||||
|  |  | ||||||
|  | async function fetchBundles(term: string | null = null) { | ||||||
|  |   loading.value = true | ||||||
|  |   try { | ||||||
|  |     const resp = await fetch(`/api/bundles/me?${term ? `term=${term}` : ''}`) | ||||||
|  |     const data = await resp.json() | ||||||
|  |     options.value = data.map((bundle: any) => ({ | ||||||
|  |       label: bundle.name, | ||||||
|  |       value: bundle.id, | ||||||
|  |     })) | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Failed to fetch bundles:', error) | ||||||
|  |   } finally { | ||||||
|  |     loading.value = false | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function handleSearch(query: string) { | ||||||
|  |   fetchBundles(query) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function handleBundleChange(value: string) { | ||||||
|  |   emit('update:bundle', value) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | onMounted(() => fetchBundles()) | ||||||
|  | </script> | ||||||
							
								
								
									
										196
									
								
								DysonNetwork.Drive/Client/src/components/UploadArea.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										196
									
								
								DysonNetwork.Drive/Client/src/components/UploadArea.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,196 @@ | |||||||
|  | <template> | ||||||
|  |   <div> | ||||||
|  |     <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> | ||||||
|  |  | ||||||
|  |     <n-collapse-transition :show="modeAdvanced"> | ||||||
|  |       <n-card title="Advance Options" size="small" class="mb-3"> | ||||||
|  |         <div class="flex flex-col gap-3"> | ||||||
|  |           <div> | ||||||
|  |             <p class="pl-1 mb-0.5">File Password</p> | ||||||
|  |             <n-input | ||||||
|  |               v-model:value="filePass" | ||||||
|  |               :disabled="!currentFilePool?.allow_encryption" | ||||||
|  |               placeholder="Enter password to protect the file" | ||||||
|  |               show-password-toggle | ||||||
|  |               size="large" | ||||||
|  |               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> | ||||||
|  |           </div> | ||||||
|  |           <div> | ||||||
|  |             <p class="pl-1 mb-0.5">File Expiration Date</p> | ||||||
|  |             <n-date-picker | ||||||
|  |               v-model:value="fileExpire" | ||||||
|  |               type="datetime" | ||||||
|  |               clearable | ||||||
|  |               :is-date-disabled="disablePreviousDate" | ||||||
|  |             /> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </n-card> | ||||||
|  |     </n-collapse-transition> | ||||||
|  |  | ||||||
|  |     <n-upload | ||||||
|  |       multiple | ||||||
|  |       directory-dnd | ||||||
|  |       with-credentials | ||||||
|  |       show-preview-button | ||||||
|  |       list-type="image" | ||||||
|  |       show-download-button | ||||||
|  |       :custom-request="customRequest" | ||||||
|  |       :custom-download="customDownload" | ||||||
|  |       :create-thumbnail-url="createThumbnailUrl" | ||||||
|  |       @preview="customPreview" | ||||||
|  |     > | ||||||
|  |       <n-upload-dragger> | ||||||
|  |         <div style="margin-bottom: 12px"> | ||||||
|  |           <n-icon size="48" :depth="3"> | ||||||
|  |             <cloud-upload-round /> | ||||||
|  |           </n-icon> | ||||||
|  |         </div> | ||||||
|  |         <n-text style="font-size: 16px"> Click or drag a file to this area to upload </n-text> | ||||||
|  |         <n-p depth="3" style="margin: 8px 0 0 0"> | ||||||
|  |           Strictly prohibit from uploading sensitive information. For example, your bank card PIN or | ||||||
|  |           your credit card expiry date. | ||||||
|  |         </n-p> | ||||||
|  |       </n-upload-dragger> | ||||||
|  |     </n-upload> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup lang="ts"> | ||||||
|  | import { | ||||||
|  |   NUpload, | ||||||
|  |   NUploadDragger, | ||||||
|  |   NIcon, | ||||||
|  |   NText, | ||||||
|  |   NP, | ||||||
|  |   NInput, | ||||||
|  |   NCollapseTransition, | ||||||
|  |   NDatePicker, | ||||||
|  |   NAlert, | ||||||
|  |   NCard, | ||||||
|  |   type UploadCustomRequestOptions, | ||||||
|  |   type UploadSettledFileInfo, | ||||||
|  |   type UploadFileInfo, | ||||||
|  |   useMessage, | ||||||
|  | } from 'naive-ui' | ||||||
|  | import { computed, ref } from 'vue' | ||||||
|  | import { CloudUploadRound } from '@vicons/material' | ||||||
|  | import type { SnFilePool } from '@/types/pool' | ||||||
|  |  | ||||||
|  | import * as tus from 'tus-js-client' | ||||||
|  |  | ||||||
|  | const props = defineProps<{ filePool: string | null; modeAdvanced: boolean; pools: SnFilePool[]; bundleId?: string }>() | ||||||
|  |  | ||||||
|  | const filePass = ref<string>('') | ||||||
|  | const fileExpire = ref<number | null>(null) | ||||||
|  |  | ||||||
|  | const currentFilePool = computed(() => { | ||||||
|  |   if (!props.filePool) return null | ||||||
|  |   return props.pools?.find((pool) => pool.id === props.filePool) ?? null | ||||||
|  | }) | ||||||
|  | const showRecycleHint = computed(() => { | ||||||
|  |   if (!props.filePool) return true | ||||||
|  |   return currentFilePool.value.policy_config?.enable_recycle || false | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | const messageDisplay = useMessage() | ||||||
|  |  | ||||||
|  | function customRequest({ | ||||||
|  |   file, | ||||||
|  |   headers, | ||||||
|  |   withCredentials, | ||||||
|  |   onFinish, | ||||||
|  |   onError, | ||||||
|  |   onProgress, | ||||||
|  | }: UploadCustomRequestOptions) { | ||||||
|  |   const requestHeaders: Record<string, string> = {} | ||||||
|  |   if (props.filePool) requestHeaders['X-FilePool'] = props.filePool | ||||||
|  |   if (filePass.value) requestHeaders['X-FilePass'] = filePass.value | ||||||
|  |   if (fileExpire.value) requestHeaders['X-FileExpire'] = fileExpire.value.toString() | ||||||
|  |   if (props.bundleId) requestHeaders['X-FileBundle'] = props.bundleId | ||||||
|  |   const upload = new tus.Upload(file.file, { | ||||||
|  |     endpoint: '/api/tus', | ||||||
|  |     retryDelays: [0, 3000, 5000, 10000, 20000], | ||||||
|  |     removeFingerprintOnSuccess: false, | ||||||
|  |     uploadDataDuringCreation: false, | ||||||
|  |     metadata: { | ||||||
|  |       filename: file.name, | ||||||
|  |       'content-type': file.type ?? 'application/octet-stream', | ||||||
|  |     }, | ||||||
|  |     headers: { | ||||||
|  |       'X-DirectUpload': 'true', | ||||||
|  |       ...requestHeaders, | ||||||
|  |       ...headers, | ||||||
|  |     }, | ||||||
|  |     onShouldRetry: () => false, | ||||||
|  |     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() | ||||||
|  |     }, | ||||||
|  |     onProgress: function (bytesUploaded, bytesTotal) { | ||||||
|  |       onProgress({ percent: (bytesUploaded / bytesTotal) * 100 }) | ||||||
|  |     }, | ||||||
|  |     onSuccess: function (payload) { | ||||||
|  |       const rawInfo = payload.lastResponse.getHeader('x-fileinfo') | ||||||
|  |       const jsonInfo = JSON.parse(rawInfo as string) | ||||||
|  |       console.log('[DRIVE] Upload successful: ', jsonInfo) | ||||||
|  |       file.url = `/api/files/${jsonInfo.id}` | ||||||
|  |       file.type = jsonInfo.mime_type | ||||||
|  |       onFinish() | ||||||
|  |     }, | ||||||
|  |     onBeforeRequest: function (req) { | ||||||
|  |       const xhr = req.getUnderlyingObject() | ||||||
|  |       xhr.withCredentials = withCredentials | ||||||
|  |     }, | ||||||
|  |   }) | ||||||
|  |   upload.findPreviousUploads().then(function (previousUploads) { | ||||||
|  |     if (previousUploads.length) { | ||||||
|  |       upload.resumeFromPreviousUpload(previousUploads[0]) | ||||||
|  |     } | ||||||
|  |     upload.start() | ||||||
|  |   }) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function createThumbnailUrl( | ||||||
|  |   _file: File | null, | ||||||
|  |   fileInfo: UploadSettledFileInfo, | ||||||
|  | ): string | undefined { | ||||||
|  |   if (!fileInfo) return undefined | ||||||
|  |   return fileInfo.url ?? undefined | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function customDownload(file: UploadFileInfo) { | ||||||
|  |   const { url } = file | ||||||
|  |   if (!url) return | ||||||
|  |   window.open(url.replace('/api', ''), '_blank') | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function customPreview(file: UploadFileInfo, detail: { event: MouseEvent }) { | ||||||
|  |   detail.event.preventDefault() | ||||||
|  |   const { url } = file | ||||||
|  |   if (!url) return | ||||||
|  |   window.open(url.replace('/api', ''), '_blank') | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function disablePreviousDate(ts: number) { | ||||||
|  |   return ts <= Date.now() | ||||||
|  | } | ||||||
|  | </script> | ||||||
							
								
								
									
										75
									
								
								DysonNetwork.Drive/Client/src/components/form/BundleForm.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								DysonNetwork.Drive/Client/src/components/form/BundleForm.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | |||||||
|  | <template> | ||||||
|  |   <n-form :model="formValue" :rules="rules" ref="formRef"> | ||||||
|  |     <n-form-item label="Slug" path="slug"> | ||||||
|  |       <n-input v-model:value="formValue.slug" placeholder="Input Slug" /> | ||||||
|  |     </n-form-item> | ||||||
|  |     <n-form-item label="Name" path="name"> | ||||||
|  |       <n-input v-model:value="formValue.name" placeholder="Input Name" /> | ||||||
|  |     </n-form-item> | ||||||
|  |     <n-form-item label="Description" path="description"> | ||||||
|  |       <n-input | ||||||
|  |         v-model:value="formValue.description" | ||||||
|  |         placeholder="Input Description" | ||||||
|  |         type="textarea" | ||||||
|  |       /> | ||||||
|  |     </n-form-item> | ||||||
|  |     <n-form-item label="Passcode" path="passcode"> | ||||||
|  |       <n-input | ||||||
|  |         v-model:value="formValue.passcode" | ||||||
|  |         placeholder="Input Passcode" | ||||||
|  |         type="password" | ||||||
|  |       /> | ||||||
|  |     </n-form-item> | ||||||
|  |     <n-form-item label="Expired At" path="expiredAt"> | ||||||
|  |       <n-date-picker v-model:value="formValue.expiredAt" type="datetime" /> | ||||||
|  |     </n-form-item> | ||||||
|  |   </n-form> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script lang="ts" setup> | ||||||
|  | import { | ||||||
|  |   NForm, | ||||||
|  |   NFormItem, | ||||||
|  |   NInput, | ||||||
|  |   NDatePicker, | ||||||
|  |   type FormInst, | ||||||
|  |   type FormRules, | ||||||
|  | } from 'naive-ui' | ||||||
|  | import { ref } from 'vue' | ||||||
|  |  | ||||||
|  | const formRef = ref<FormInst | null>(null) | ||||||
|  |  | ||||||
|  | const props = defineProps<{ value: any }>() | ||||||
|  | const formValue = ref(props.value) | ||||||
|  |  | ||||||
|  | const rules: FormRules = { | ||||||
|  |   slug: [ | ||||||
|  |     { | ||||||
|  |       max: 1024, | ||||||
|  |       message: 'Slug can be at most 1024 characters long', | ||||||
|  |     }, | ||||||
|  |   ], | ||||||
|  |   name: [ | ||||||
|  |     { | ||||||
|  |       max: 1024, | ||||||
|  |       message: 'Name can be at most 1024 characters long', | ||||||
|  |     }, | ||||||
|  |   ], | ||||||
|  |   description: [ | ||||||
|  |     { | ||||||
|  |       max: 8192, | ||||||
|  |       message: 'Description can be at most 8192 characters long', | ||||||
|  |     }, | ||||||
|  |   ], | ||||||
|  |   passcode: [ | ||||||
|  |     { | ||||||
|  |       max: 256, | ||||||
|  |       message: 'Passcode can be at most 256 characters long', | ||||||
|  |     }, | ||||||
|  |   ], | ||||||
|  | } | ||||||
|  |  | ||||||
|  | defineExpose({ | ||||||
|  |   formRef, | ||||||
|  | }) | ||||||
|  | </script> | ||||||
| @@ -16,7 +16,12 @@ | |||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
| import { DataUsageRound, AllInboxFilled } from '@vicons/material' | import { | ||||||
|  |   DataUsageRound, | ||||||
|  |   AllInboxFilled, | ||||||
|  |   PermDataSettingRound, | ||||||
|  |   ShoppingBagRound, | ||||||
|  | } from '@vicons/material' | ||||||
| import { NIcon, NLayout, NLayoutSider, NMenu, type MenuOption } from 'naive-ui' | import { NIcon, NLayout, NLayoutSider, NMenu, type MenuOption } from 'naive-ui' | ||||||
| import { h, type Component } from 'vue' | import { h, type Component } from 'vue' | ||||||
| import { RouterView, useRoute, useRouter } from 'vue-router' | import { RouterView, useRoute, useRouter } from 'vue-router' | ||||||
| @@ -38,7 +43,17 @@ const menuOptions: MenuOption[] = [ | |||||||
|     label: 'Files', |     label: 'Files', | ||||||
|     key: 'dashboardFiles', |     key: 'dashboardFiles', | ||||||
|     icon: renderIcon(AllInboxFilled), |     icon: renderIcon(AllInboxFilled), | ||||||
|   } |   }, | ||||||
|  |   { | ||||||
|  |     label: 'Bundles', | ||||||
|  |     key: 'dashboardBundles', | ||||||
|  |     icon: renderIcon(ShoppingBagRound), | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     label: 'Quota', | ||||||
|  |     key: 'dashboardQuota', | ||||||
|  |     icon: renderIcon(PermDataSettingRound), | ||||||
|  |   }, | ||||||
| ] | ] | ||||||
|  |  | ||||||
| function updateMenuSelect(key: string) { | function updateMenuSelect(key: string) { | ||||||
|   | |||||||
| @@ -15,6 +15,11 @@ const router = createRouter({ | |||||||
|       name: 'files', |       name: 'files', | ||||||
|       component: () => import('../views/files.vue'), |       component: () => import('../views/files.vue'), | ||||||
|     }, |     }, | ||||||
|  |     { | ||||||
|  |       path: '/bundles/:bundleId', | ||||||
|  |       name: 'bundleDetails', | ||||||
|  |       component: () => import('../views/bundles.vue'), | ||||||
|  |     }, | ||||||
|     { |     { | ||||||
|       path: '/dashboard', |       path: '/dashboard', | ||||||
|       name: 'dashboard', |       name: 'dashboard', | ||||||
| @@ -32,7 +37,19 @@ const router = createRouter({ | |||||||
|           name: 'dashboardFiles', |           name: 'dashboardFiles', | ||||||
|           component: () => import('../views/dashboard/files.vue'), |           component: () => import('../views/dashboard/files.vue'), | ||||||
|           meta: { requiresAuth: true }, |           meta: { requiresAuth: true }, | ||||||
|         } |         }, | ||||||
|  |         { | ||||||
|  |           path: 'bundles', | ||||||
|  |           name: 'dashboardBundles', | ||||||
|  |           component: () => import('../views/dashboard/bundles.vue'), | ||||||
|  |           meta: { requiresAuth: true }, | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           path: 'quotas', | ||||||
|  |           name: 'dashboardQuota', | ||||||
|  |           component: () => import('../views/dashboard/quotas.vue'), | ||||||
|  |           meta: { requiresAuth: true }, | ||||||
|  |         }, | ||||||
|       ], |       ], | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|   | |||||||
| @@ -19,7 +19,7 @@ export const useServicesStore = defineStore('services', () => { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   function getSerivceUrl(serviceName: string, ...parts: string[]): string | null { |   function getSerivceUrl(serviceName: string, ...parts: string[]): string | null { | ||||||
|     let baseUrl = services.value[serviceName] || null |     const baseUrl = services.value[serviceName] || null | ||||||
|     return baseUrl ? `${baseUrl}/${parts.join('/')}` : null |     return baseUrl ? `${baseUrl}/${parts.join('/')}` : null | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										142
									
								
								DysonNetwork.Drive/Client/src/views/bundles.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								DysonNetwork.Drive/Client/src/views/bundles.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,142 @@ | |||||||
|  | <template> | ||||||
|  |   <section class="min-h-full relative flex items-center justify-center"> | ||||||
|  |     <n-spin v-if="!bundleInfo && !error" /> | ||||||
|  |     <n-result | ||||||
|  |       status="404" | ||||||
|  |       title="No bundle was found" | ||||||
|  |       :description="error" | ||||||
|  |       v-else-if="error === '404'" | ||||||
|  |     /> | ||||||
|  |     <n-card class="max-w-md my-4 mx-8" v-else-if="error === '403'"> | ||||||
|  |       <n-result | ||||||
|  |         status="403" | ||||||
|  |         title="Access Denied" | ||||||
|  |         description="This bundle is protected by a passcode" | ||||||
|  |         class="mt-5 mb-2" | ||||||
|  |       > | ||||||
|  |         <template #footer> | ||||||
|  |           <n-alert v-if="passcodeError" type="error" class="mb-3"> | ||||||
|  |             {{ passcodeError }} | ||||||
|  |           </n-alert> | ||||||
|  |           <n-input | ||||||
|  |             v-model:value="passcode" | ||||||
|  |             type="password" | ||||||
|  |             show-password-on="mousedown" | ||||||
|  |             placeholder="Passcode" | ||||||
|  |             @keyup.enter="fetchBundleInfo" | ||||||
|  |             class="mb-3" | ||||||
|  |           /> | ||||||
|  |           <n-button type="primary" block @click="fetchBundleInfo">Access Bundle</n-button> | ||||||
|  |         </template> | ||||||
|  |       </n-result> | ||||||
|  |     </n-card> | ||||||
|  |     <n-card class="max-w-4xl my-4 mx-8" v-else> | ||||||
|  |       <n-grid cols="1 m:2" x-gap="16" y-gap="16" responsive="screen"> | ||||||
|  |         <n-gi> | ||||||
|  |           <n-card title="Content" size="small"> | ||||||
|  |             <n-list size="small" v-if="bundleInfo.files && bundleInfo.files.length > 0" no-padding> | ||||||
|  |               <n-list-item v-for="file in bundleInfo.files" :key="file.id"> | ||||||
|  |                 <n-thing :title="file.name" :description="formatBytes(file.size)"> | ||||||
|  |                   <template #header-extra> | ||||||
|  |                     <n-button text type="primary" @click="goToFileDetails(file.id)">View</n-button> | ||||||
|  |                   </template> | ||||||
|  |                 </n-thing> | ||||||
|  |               </n-list-item> | ||||||
|  |             </n-list> | ||||||
|  |             <n-empty v-else description="No files in this bundle" /> | ||||||
|  |           </n-card> | ||||||
|  |         </n-gi> | ||||||
|  |  | ||||||
|  |         <n-gi> | ||||||
|  |           <n-card size="small"> | ||||||
|  |             <h3 class="text-lg">{{ bundleInfo.name }}</h3> | ||||||
|  |             <p class="mb-3" v-if="bundleInfo.description">{{ bundleInfo.description }}</p> | ||||||
|  |             <div class="flex gap-2"> | ||||||
|  |               <span class="flex-grow-1 flex items-center gap-2"> | ||||||
|  |                 <n-icon> | ||||||
|  |                   <calendar-today-round /> | ||||||
|  |                 </n-icon> | ||||||
|  |                 Expires At | ||||||
|  |               </span> | ||||||
|  |               <span>{{ | ||||||
|  |                 bundleInfo.expiredAt ? new Date(bundleInfo.expiredAt).toLocaleString() : 'Never' | ||||||
|  |               }}</span> | ||||||
|  |             </div> | ||||||
|  |             <div class="flex gap-2"> | ||||||
|  |               <span class="flex-grow-1 flex items-center gap-2"> | ||||||
|  |                 <n-icon> | ||||||
|  |                   <lock-round /> | ||||||
|  |                 </n-icon> | ||||||
|  |                 Passcode Protected | ||||||
|  |               </span> | ||||||
|  |               <span>{{ bundleInfo.passcode ? 'Yes' : 'No' }}</span> | ||||||
|  |             </div> | ||||||
|  |           </n-card> | ||||||
|  |         </n-gi> | ||||||
|  |       </n-grid> | ||||||
|  |     </n-card> | ||||||
|  |   </section> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup lang="ts"> | ||||||
|  | import { | ||||||
|  |   NCard, | ||||||
|  |   NResult, | ||||||
|  |   NSpin, | ||||||
|  |   NIcon, | ||||||
|  |   NGrid, | ||||||
|  |   NGi, | ||||||
|  |   NList, | ||||||
|  |   NListItem, | ||||||
|  |   NThing, | ||||||
|  |   NButton, | ||||||
|  |   NEmpty, | ||||||
|  |   NInput, | ||||||
|  |   NAlert, | ||||||
|  | } from 'naive-ui' | ||||||
|  | import { CalendarTodayRound, LockRound } from '@vicons/material' | ||||||
|  | import { useRoute, useRouter } from 'vue-router' | ||||||
|  | import { onMounted, ref } from 'vue' | ||||||
|  |  | ||||||
|  | import { formatBytes } from './format' // Assuming format.ts is in the same directory | ||||||
|  |  | ||||||
|  | const route = useRoute() | ||||||
|  | const router = useRouter() | ||||||
|  |  | ||||||
|  | const error = ref<string | null>(null) | ||||||
|  | const bundleId = route.params.bundleId | ||||||
|  | const passcode = ref<string>('') | ||||||
|  | const passcodeError = ref<string | null>(null) | ||||||
|  |  | ||||||
|  | const bundleInfo = ref<any>(null) | ||||||
|  | async function fetchBundleInfo() { | ||||||
|  |   try { | ||||||
|  |     let url = '/api/bundles/' + bundleId | ||||||
|  |     if (passcode.value) { | ||||||
|  |       url += `?passcode=${passcode.value}` | ||||||
|  |     } | ||||||
|  |     const resp = await fetch(url) | ||||||
|  |     if (resp.status === 403) { | ||||||
|  |       error.value = '403' | ||||||
|  |       bundleInfo.value = null | ||||||
|  |       if (passcode.value) { | ||||||
|  |         passcodeError.value = 'Incorrect passcode.' | ||||||
|  |       } | ||||||
|  |       return | ||||||
|  |     } | ||||||
|  |     if (!resp.ok) { | ||||||
|  |       throw new Error('Failed to fetch bundle info: ' + resp.statusText) | ||||||
|  |     } | ||||||
|  |     bundleInfo.value = await resp.json() | ||||||
|  |     error.value = null | ||||||
|  |     passcodeError.value = null | ||||||
|  |   } catch (err) { | ||||||
|  |     error.value = (err as Error).message | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | onMounted(() => fetchBundleInfo()) | ||||||
|  |  | ||||||
|  | function goToFileDetails(fileId: string) { | ||||||
|  |   router.push({ path: `/files/${fileId}`, query: { passcode: passcode.value } }) | ||||||
|  | } | ||||||
|  | </script> | ||||||
							
								
								
									
										178
									
								
								DysonNetwork.Drive/Client/src/views/dashboard/bundles.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										178
									
								
								DysonNetwork.Drive/Client/src/views/dashboard/bundles.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,178 @@ | |||||||
|  | <template> | ||||||
|  |   <section class="h-full px-5 py-4"> | ||||||
|  |     <n-data-table | ||||||
|  |       remote | ||||||
|  |       :row-key="(row) => row.id" | ||||||
|  |       :columns="tableColumns" | ||||||
|  |       :data="bundles" | ||||||
|  |       :loading="loading" | ||||||
|  |       :pagination="tablePagination" | ||||||
|  |       @page-change="handlePageChange" | ||||||
|  |     /> | ||||||
|  |   </section> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script lang="ts" setup> | ||||||
|  | import { | ||||||
|  |   NDataTable, | ||||||
|  |   type DataTableColumns, | ||||||
|  |   type PaginationProps, | ||||||
|  |   useMessage, | ||||||
|  |   useLoadingBar, | ||||||
|  |   NButton, | ||||||
|  |   NIcon, | ||||||
|  |   NSpace, | ||||||
|  |   useDialog, | ||||||
|  | } from 'naive-ui' | ||||||
|  | import { h, onMounted, ref } from 'vue' | ||||||
|  | import { useRouter } from 'vue-router' | ||||||
|  | import { DeleteRound } from '@vicons/material' | ||||||
|  |  | ||||||
|  | const router = useRouter() | ||||||
|  |  | ||||||
|  | const bundles = ref<any[]>([]) | ||||||
|  |  | ||||||
|  | const tableColumns: DataTableColumns<any> = [ | ||||||
|  |   { | ||||||
|  |     title: 'Name', | ||||||
|  |     key: 'name', | ||||||
|  |     render(row: any) { | ||||||
|  |       return h( | ||||||
|  |         NButton, | ||||||
|  |         { | ||||||
|  |           text: true, | ||||||
|  |           onClick: () => { | ||||||
|  |             router.push(`/bundles/${row.id}`) | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           default: () => row.name, | ||||||
|  |         }, | ||||||
|  |       ) | ||||||
|  |     }, | ||||||
|  |     maxWidth: 80, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     title: 'Description', | ||||||
|  |     key: 'description', | ||||||
|  |     maxWidth: 180, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     title: 'Expired At', | ||||||
|  |     key: 'expired_at', | ||||||
|  |     render(row: any) { | ||||||
|  |       if (!row.expired_at) return 'Never' | ||||||
|  |       return new Date(row.expired_at).toLocaleString() | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     title: 'Created At', | ||||||
|  |     key: 'created_at', | ||||||
|  |     render(row: any) { | ||||||
|  |       return new Date(row.created_at).toLocaleString() | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     title: 'Updated At', | ||||||
|  |     key: 'updated_at', | ||||||
|  |     render(row: any) { | ||||||
|  |       return new Date(row.updated_at).toLocaleString() | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     title: 'Action', | ||||||
|  |     key: 'action', | ||||||
|  |     render(row: any) { | ||||||
|  |       return h(NSpace, {}, [ | ||||||
|  |         h( | ||||||
|  |           NButton, | ||||||
|  |           { | ||||||
|  |             circle: true, | ||||||
|  |             text: true, | ||||||
|  |             type: 'error', | ||||||
|  |             onClick: () => { | ||||||
|  |               askDeleteBundle(row) | ||||||
|  |             }, | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             icon: () => h(NIcon, {}, { default: () => h(DeleteRound) }), | ||||||
|  |           }, | ||||||
|  |         ), | ||||||
|  |       ]) | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  | ] | ||||||
|  |  | ||||||
|  | const tablePagination = ref<PaginationProps>({ | ||||||
|  |   page: 1, | ||||||
|  |   itemCount: 0, | ||||||
|  |   pageSize: 10, | ||||||
|  |   showSizePicker: true, | ||||||
|  |   pageSizes: [10, 20, 30, 40, 50], | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | async function fetchBundles() { | ||||||
|  |   if (loading.value) return | ||||||
|  |   try { | ||||||
|  |     loading.value = true | ||||||
|  |     const pag = tablePagination.value | ||||||
|  |     const response = await fetch( | ||||||
|  |       `/api/bundles/me?take=${pag.pageSize}&offset=${(pag.page! - 1) * pag.pageSize!}`, | ||||||
|  |     ) | ||||||
|  |     if (!response.ok) { | ||||||
|  |       throw new Error('Network response was not ok') | ||||||
|  |     } | ||||||
|  |     const data = await response.json() | ||||||
|  |     bundles.value = data | ||||||
|  |     tablePagination.value.itemCount = parseInt(response.headers.get('x-total') ?? '0') | ||||||
|  |   } catch (error) { | ||||||
|  |     messageDialog.error('Failed to fetch bundles: ' + (error as Error).message) | ||||||
|  |     console.error('Failed to fetch bundles:', error) | ||||||
|  |   } finally { | ||||||
|  |     loading.value = false | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | onMounted(() => fetchBundles()) | ||||||
|  |  | ||||||
|  | function handlePageChange(page: number) { | ||||||
|  |   tablePagination.value.page = page | ||||||
|  |   fetchBundles() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const loading = ref(false) | ||||||
|  |  | ||||||
|  | const messageDialog = useMessage() | ||||||
|  | const loadingBar = useLoadingBar() | ||||||
|  | const dialog = useDialog() | ||||||
|  |  | ||||||
|  | function askDeleteBundle(bundle: any) { | ||||||
|  |   dialog.warning({ | ||||||
|  |     title: 'Confirm', | ||||||
|  |     content: `Are you sure you want to delete the bundle ${bundle.name}?`, | ||||||
|  |     positiveText: 'Sure', | ||||||
|  |     negativeText: 'Not Sure', | ||||||
|  |     onPositiveClick: () => { | ||||||
|  |       deleteBundle(bundle) | ||||||
|  |     }, | ||||||
|  |   }) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | async function deleteBundle(bundle: any) { | ||||||
|  |   try { | ||||||
|  |     loadingBar.start() | ||||||
|  |     const response = await fetch(`/api/bundles/${bundle.id}`, { | ||||||
|  |       method: 'DELETE', | ||||||
|  |     }) | ||||||
|  |     if (!response.ok) { | ||||||
|  |       throw new Error('Network response was not ok') | ||||||
|  |     } | ||||||
|  |     tablePagination.value.page = 1 | ||||||
|  |     await fetchBundles() | ||||||
|  |     loadingBar.finish() | ||||||
|  |     messageDialog.success('Bundle deleted successfully') | ||||||
|  |   } catch (error) { | ||||||
|  |     loadingBar.error() | ||||||
|  |     messageDialog.error('Failed to delete bundle: ' + (error as Error).message) | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | </script> | ||||||
| @@ -99,6 +99,7 @@ const tableColumns: DataTableColumns<any> = [ | |||||||
|   { |   { | ||||||
|     title: 'Name', |     title: 'Name', | ||||||
|     key: 'name', |     key: 'name', | ||||||
|  |     maxWidth: 180, | ||||||
|     render(row: any) { |     render(row: any) { | ||||||
|       return h( |       return h( | ||||||
|         NButton, |         NButton, | ||||||
| @@ -140,7 +141,7 @@ const tableColumns: DataTableColumns<any> = [ | |||||||
|     title: 'Expired At', |     title: 'Expired At', | ||||||
|     key: 'expired_at', |     key: 'expired_at', | ||||||
|     render(row: any) { |     render(row: any) { | ||||||
|       if (!row.expired_at) return 'Keep-alive' |       if (!row.expired_at) return 'Never' | ||||||
|       return new Date(row.expired_at).toLocaleString() |       return new Date(row.expired_at).toLocaleString() | ||||||
|     }, |     }, | ||||||
|   }, |   }, | ||||||
|   | |||||||
							
								
								
									
										101
									
								
								DysonNetwork.Drive/Client/src/views/dashboard/quotas.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								DysonNetwork.Drive/Client/src/views/dashboard/quotas.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,101 @@ | |||||||
|  | <template> | ||||||
|  |   <section class="h-full px-5 py-4"> | ||||||
|  |     <n-data-table | ||||||
|  |       remote | ||||||
|  |       :row-key="(row) => row.id" | ||||||
|  |       :columns="tableColumns" | ||||||
|  |       :data="quotas" | ||||||
|  |       :loading="loading" | ||||||
|  |       :pagination="tablePagination" | ||||||
|  |       @page-change="handlePageChange" | ||||||
|  |     /> | ||||||
|  |   </section> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script lang="ts" setup> | ||||||
|  | import { NDataTable, type DataTableColumns, type PaginationProps, useMessage } from 'naive-ui' | ||||||
|  | import { onMounted, ref } from 'vue' | ||||||
|  | import { formatBytes } from '../format' | ||||||
|  |  | ||||||
|  | const quotas = ref<any[]>([]) | ||||||
|  |  | ||||||
|  | const tableColumns: DataTableColumns<any> = [ | ||||||
|  |   { | ||||||
|  |     title: 'Name', | ||||||
|  |     key: 'name', | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     title: 'Description', | ||||||
|  |     key: 'description', | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     title: 'Quota', | ||||||
|  |     key: 'quota', | ||||||
|  |     render(row: any) { | ||||||
|  |       return formatBytes(row.quota * 1024 * 1024) | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     title: 'Expired At', | ||||||
|  |     key: 'expired_at', | ||||||
|  |     render(row: any) { | ||||||
|  |       if (!row.expired_at) return 'Never' | ||||||
|  |       return new Date(row.expired_at).toLocaleString() | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     title: 'Created At', | ||||||
|  |     key: 'created_at', | ||||||
|  |     render(row: any) { | ||||||
|  |       return new Date(row.created_at).toLocaleString() | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     title: 'Updated At', | ||||||
|  |     key: 'updated_at', | ||||||
|  |     render(row: any) { | ||||||
|  |       return new Date(row.updated_at).toLocaleString() | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  | ] | ||||||
|  |  | ||||||
|  | const tablePagination = ref<PaginationProps>({ | ||||||
|  |   page: 1, | ||||||
|  |   itemCount: 0, | ||||||
|  |   pageSize: 10, | ||||||
|  |   showSizePicker: true, | ||||||
|  |   pageSizes: [10, 20, 30, 40, 50], | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | async function fetchQuotas() { | ||||||
|  |   if (loading.value) return | ||||||
|  |   try { | ||||||
|  |     loading.value = true | ||||||
|  |     const pag = tablePagination.value | ||||||
|  |     const response = await fetch( | ||||||
|  |       `/api/billing/quota/records?take=${pag.pageSize}&offset=${(pag.page! - 1) * pag.pageSize!}`, | ||||||
|  |     ) | ||||||
|  |     if (!response.ok) { | ||||||
|  |       throw new Error('Network response was not ok') | ||||||
|  |     } | ||||||
|  |     const data = await response.json() | ||||||
|  |     quotas.value = data | ||||||
|  |     tablePagination.value.itemCount = parseInt(response.headers.get('x-total') ?? '0') | ||||||
|  |   } catch (error) { | ||||||
|  |     messageDialog.error('Failed to fetch quotas: ' + (error as Error).message) | ||||||
|  |     console.error('Failed to fetch quotas:', error) | ||||||
|  |   } finally { | ||||||
|  |     loading.value = false | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | onMounted(() => fetchQuotas()) | ||||||
|  |  | ||||||
|  | function handlePageChange(page: number) { | ||||||
|  |   tablePagination.value.page = page | ||||||
|  |   fetchQuotas() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const loading = ref(false) | ||||||
|  |  | ||||||
|  | const messageDialog = useMessage() | ||||||
|  | </script> | ||||||
| @@ -165,6 +165,7 @@ const error = ref<string | null>(null) | |||||||
|  |  | ||||||
| const filePass = ref<string>('') | const filePass = ref<string>('') | ||||||
| const fileId = route.params.fileId | const fileId = route.params.fileId | ||||||
|  | const passcode = route.query.passcode as string | undefined | ||||||
|  |  | ||||||
| const progress = ref<number | undefined>(0) | const progress = ref<number | undefined>(0) | ||||||
|  |  | ||||||
| @@ -177,7 +178,11 @@ const currentUrl = window.location.href | |||||||
| const fileInfo = ref<any>(null) | const fileInfo = ref<any>(null) | ||||||
| async function fetchFileInfo() { | async function fetchFileInfo() { | ||||||
|   try { |   try { | ||||||
|     const resp = await fetch('/api/files/' + fileId + '/info') |     let url = '/api/files/' + fileId + '/info' | ||||||
|  |     if (passcode) { | ||||||
|  |       url += `?passcode=${passcode}` | ||||||
|  |     } | ||||||
|  |     const resp = await fetch(url) | ||||||
|     if (!resp.ok) { |     if (!resp.ok) { | ||||||
|       throw new Error('Failed to fetch file info: ' + resp.statusText) |       throw new Error('Failed to fetch file info: ' + resp.statusText) | ||||||
|     } |     } | ||||||
| @@ -192,7 +197,13 @@ const fileType = computed(() => { | |||||||
|   if (!fileInfo.value) return 'unknown' |   if (!fileInfo.value) return 'unknown' | ||||||
|   return fileInfo.value.mime_type?.split('/')[0] || 'unknown' |   return fileInfo.value.mime_type?.split('/')[0] || 'unknown' | ||||||
| }) | }) | ||||||
| const fileSource = computed(() => `/api/files/${fileId}`) | const fileSource = computed(() => { | ||||||
|  |   let url = `/api/files/${fileId}` | ||||||
|  |   if (passcode) { | ||||||
|  |     url += `?passcode=${passcode}` | ||||||
|  |   } | ||||||
|  |   return url | ||||||
|  | }) | ||||||
|  |  | ||||||
| function downloadFile() { | function downloadFile() { | ||||||
|   if (fileInfo.value.is_encrypted && !filePass.value) { |   if (fileInfo.value.is_encrypted && !filePass.value) { | ||||||
|   | |||||||
| @@ -1,94 +1,67 @@ | |||||||
| <template> | <template> | ||||||
|   <section class="h-full relative flex items-center justify-center"> |   <section class="h-full relative flex flex-col items-center justify-center"> | ||||||
|     <n-card class="max-w-lg my-4 mx-8" 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> | ||||||
|  |  | ||||||
|       <p class="mt-4 opacity-75 text-xs"> |  | ||||||
|         <span v-if="version == null">Loading...</span> |  | ||||||
|         <span v-else> |  | ||||||
|           v{{ version.version }} @ |  | ||||||
|           {{ version.commit.substring(0, 6) }} |  | ||||||
|           {{ version.updatedAt }} |  | ||||||
|         </span> |  | ||||||
|       </p> |  | ||||||
|     </n-card> |     </n-card> | ||||||
|     <n-card class="max-w-2xl" title="Upload to Solar Network" v-else> |  | ||||||
|       <template #header-extra> |     <n-card class="max-w-2xl" v-else content-style="padding: 0;"> | ||||||
|         <div class="flex gap-2 items-center"> |       <n-tabs type="line" animated :tabs-padding="20" pane-style="padding: 20px"> | ||||||
|  |         <template #suffix> | ||||||
|  |           <div class="flex gap-2 items-center me-4"> | ||||||
|             <p>Advance Mode</p> |             <p>Advance Mode</p> | ||||||
|             <n-switch v-model:value="modeAdvanced" size="small" /> |             <n-switch v-model:value="modeAdvanced" size="small" /> | ||||||
|           </div> |           </div> | ||||||
|         </template> |         </template> | ||||||
|  |  | ||||||
|       <n-collapse-transition :show="showRecycleHint"> |         <n-tab-pane name="direct" tab="Direct Upload" :disabled="isBundleMode"> | ||||||
|         <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> | ||||||
|  |           <upload-area | ||||||
|       <n-collapse-transition :show="modeAdvanced"> |             :filePool="filePool" | ||||||
|         <n-card title="Advance Options" size="small" class="mb-3"> |             :pools="pools as SnFilePool[]" | ||||||
|           <div class="flex flex-col gap-3"> |             :modeAdvanced="modeAdvanced" | ||||||
|             <div> |  | ||||||
|               <p class="pl-1 mb-0.5">File Password</p> |  | ||||||
|               <n-input |  | ||||||
|                 v-model:value="filePass" |  | ||||||
|                 :disabled="!currentFilePool?.allow_encryption" |  | ||||||
|                 placeholder="Enter password to protect the file" |  | ||||||
|                 show-password-toggle |  | ||||||
|                 size="large" |  | ||||||
|                 type="password" |  | ||||||
|                 class="mb-2" |  | ||||||
|           /> |           /> | ||||||
|               <p class="pl-1 text-xs opacity-75 mt-[-4px]"> |         </n-tab-pane> | ||||||
|                 Only available for Stellar Program and certian file pool. |         <n-tab-pane name="bundle" tab="Bundle Upload"> | ||||||
|               </p> |           <div class="mb-3"> | ||||||
|  |             <bundle-select v-model:bundle="selectedBundleId" :disabled="isBundleMode" /> | ||||||
|           </div> |           </div> | ||||||
|             <div> |  | ||||||
|               <p class="pl-1 mb-0.5">File Expiration Date</p> |  | ||||||
|               <n-date-picker |  | ||||||
|                 v-model:value="fileExpire" |  | ||||||
|                 type="datetime" |  | ||||||
|                 clearable |  | ||||||
|                 :is-date-disabled="disablePreviousDate" |  | ||||||
|               /> |  | ||||||
|             </div> |  | ||||||
|           </div> |  | ||||||
|         </n-card> |  | ||||||
|       </n-collapse-transition> |  | ||||||
|  |  | ||||||
|       <n-upload |           <n-modal v-model:show="showCreateBundleModal" preset="dialog" title="Create New Bundle"> | ||||||
|         multiple |             <bundle-form ref="bundleFormRef" :value="newBundle" /> | ||||||
|         directory-dnd |             <template #action> | ||||||
|         with-credentials |               <n-button @click="showCreateBundleModal = false">Cancel</n-button> | ||||||
|         show-preview-button |               <n-button type="primary" @click="createBundle">Create</n-button> | ||||||
|         list-type="image" |             </template> | ||||||
|         show-download-button |           </n-modal> | ||||||
|         :custom-request="customRequest" |  | ||||||
|         :custom-download="customDownload" |           <div class="flex justify-between"> | ||||||
|         :create-thumbnail-url="createThumbnailUrl" |             <n-button @click="showCreateBundleModal = true" class="mb-3" :disabled="isBundleMode"> | ||||||
|         @preview="customPreview" |               Create New Bundle | ||||||
|  |             </n-button> | ||||||
|  |             <n-button | ||||||
|  |               type="primary" | ||||||
|  |               :disabled="!selectedBundleId && !newBundleId && !isBundleMode" | ||||||
|  |               @click="isBundleMode ? cancelBundleUpload() : proceedToBundleUpload()" | ||||||
|             > |             > | ||||||
|         <n-upload-dragger> |               {{ isBundleMode ? 'Cancel' : 'Proceed to Upload' }} | ||||||
|           <div style="margin-bottom: 12px"> |             </n-button> | ||||||
|             <n-icon size="48" :depth="3"> |  | ||||||
|               <cloud-upload-round /> |  | ||||||
|             </n-icon> |  | ||||||
|           </div> |           </div> | ||||||
|           <n-text style="font-size: 16px"> Click or drag a file to this area to upload </n-text> |  | ||||||
|           <n-p depth="3" style="margin: 8px 0 0 0"> |           <div v-if="bundleUploadMode" class="mt-3"> | ||||||
|             Strictly prohibit from uploading sensitive information. For example, your bank card PIN |             <upload-area | ||||||
|             or your credit card expiry date. |               :filePool="filePool" | ||||||
|           </n-p> |               :pools="pools as SnFilePool[]" | ||||||
|         </n-upload-dragger> |               :modeAdvanced="modeAdvanced" | ||||||
|       </n-upload> |               :bundleId="currentBundleId!" | ||||||
|  |             /> | ||||||
|  |           </div> | ||||||
|  |         </n-tab-pane> | ||||||
|  |       </n-tabs> | ||||||
|  |     </n-card> | ||||||
|  |  | ||||||
|     <p class="mt-4 opacity-75 text-xs"> |     <p class="mt-4 opacity-75 text-xs"> | ||||||
|       <span v-if="version == null">Loading...</span> |       <span v-if="version == null">Loading...</span> | ||||||
| @@ -98,36 +71,18 @@ | |||||||
|         {{ version.updatedAt }} |         {{ version.updatedAt }} | ||||||
|       </span> |       </span> | ||||||
|     </p> |     </p> | ||||||
|     </n-card> |  | ||||||
|   </section> |   </section> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
| import { | import { NCard, NSwitch, NTabs, NTabPane, NButton, NModal } from 'naive-ui' | ||||||
|   NCard, |  | ||||||
|   NUpload, |  | ||||||
|   NUploadDragger, |  | ||||||
|   NIcon, |  | ||||||
|   NText, |  | ||||||
|   NP, |  | ||||||
|   NInput, |  | ||||||
|   NSwitch, |  | ||||||
|   NCollapseTransition, |  | ||||||
|   NDatePicker, |  | ||||||
|   NAlert, |  | ||||||
|   type UploadCustomRequestOptions, |  | ||||||
|   type UploadSettledFileInfo, |  | ||||||
|   type UploadFileInfo, |  | ||||||
|   useMessage, |  | ||||||
| } from 'naive-ui' |  | ||||||
| import { computed, onMounted, ref } from 'vue' | import { computed, onMounted, ref } from 'vue' | ||||||
| import { CloudUploadRound } from '@vicons/material' |  | ||||||
| import { useUserStore } from '@/stores/user' | import { useUserStore } from '@/stores/user' | ||||||
| import type { SnFilePool } from '@/types/pool' | import type { SnFilePool } from '@/types/pool' | ||||||
|  |  | ||||||
| import FilePoolSelect from '@/components/FilePoolSelect.vue' | import FilePoolSelect from '@/components/FilePoolSelect.vue' | ||||||
|  | import UploadArea from '@/components/UploadArea.vue' | ||||||
| import * as tus from 'tus-js-client' | import BundleSelect from '@/components/BundleSelect.vue' | ||||||
|  | import BundleForm from '@/components/form/BundleForm.vue' | ||||||
|  |  | ||||||
| const userStore = useUserStore() | const userStore = useUserStore() | ||||||
|  |  | ||||||
| @@ -150,105 +105,57 @@ onMounted(() => fetchPools()) | |||||||
| const modeAdvanced = ref(false) | const modeAdvanced = ref(false) | ||||||
|  |  | ||||||
| const filePool = ref<string | null>(null) | const filePool = ref<string | null>(null) | ||||||
| const filePass = ref<string>('') |  | ||||||
| const fileExpire = ref<number | null>(null) |  | ||||||
|  |  | ||||||
| const currentFilePool = computed(() => { | 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 bundles = ref<any>([]) | ||||||
|  | const selectedBundleId = ref<string | null>(null) | ||||||
|  | const showCreateBundleModal = ref(false) | ||||||
|  | const newBundle = ref<any>({}) | ||||||
|  | const bundleFormRef = ref<any>(null) | ||||||
|  | const bundleUploadMode = ref(false) | ||||||
|  | const currentBundleId = ref<string | null>(null) | ||||||
|  | const newBundleId = ref<string | null>(null) | ||||||
|  | const isBundleMode = ref(false) | ||||||
|  |  | ||||||
| function customRequest({ | async function createBundle() { | ||||||
|   file, |   try { | ||||||
|   headers, |     await bundleFormRef.value?.formRef?.validate() | ||||||
|   withCredentials, |     const resp = await fetch('/api/bundles', { | ||||||
|   onFinish, |       method: 'POST', | ||||||
|   onError, |  | ||||||
|   onProgress, |  | ||||||
| }: UploadCustomRequestOptions) { |  | ||||||
|   const requestHeaders: Record<string, string> = {} |  | ||||||
|   if (filePool.value) requestHeaders['X-FilePool'] = filePool.value |  | ||||||
|   if (filePass.value) requestHeaders['X-FilePass'] = filePass.value |  | ||||||
|   if (fileExpire.value) requestHeaders['X-FileExpire'] = fileExpire.value.toString() |  | ||||||
|   const upload = new tus.Upload(file.file, { |  | ||||||
|     endpoint: '/api/tus', |  | ||||||
|     retryDelays: [0, 3000, 5000, 10000, 20000], |  | ||||||
|     removeFingerprintOnSuccess: false, |  | ||||||
|     uploadDataDuringCreation: false, |  | ||||||
|     metadata: { |  | ||||||
|       filename: file.name, |  | ||||||
|       'content-type': file.type ?? 'application/octet-stream', |  | ||||||
|     }, |  | ||||||
|       headers: { |       headers: { | ||||||
|       'X-DirectUpload': 'true', |         'Content-Type': 'application/json', | ||||||
|       ...requestHeaders, |  | ||||||
|       ...headers, |  | ||||||
|       }, |       }, | ||||||
|     onShouldRetry: () => false, |       body: JSON.stringify(newBundle.value), | ||||||
|     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, |  | ||||||
|     }) |     }) | ||||||
|  |     if (!resp.ok) { | ||||||
|  |       throw new Error('Failed to create bundle') | ||||||
|     } |     } | ||||||
|       console.error('[DRIVE] Upload failed:', error) |     const createdBundle = await resp.json() | ||||||
|       onError() |     bundles.value.push(createdBundle) | ||||||
|     }, |     selectedBundleId.value = createdBundle.id | ||||||
|     onProgress: function (bytesUploaded, bytesTotal) { |     newBundleId.value = createdBundle.id | ||||||
|       onProgress({ percent: (bytesUploaded / bytesTotal) * 100 }) |     showCreateBundleModal.value = false | ||||||
|     }, |     newBundle.value = {} | ||||||
|     onSuccess: function (payload) { |   } catch (error) { | ||||||
|       const rawInfo = payload.lastResponse.getHeader('x-fileinfo') |     console.error('Failed to create bundle:', error) | ||||||
|       const jsonInfo = JSON.parse(rawInfo as string) |  | ||||||
|       console.log('[DRIVE] Upload successful: ', jsonInfo) |  | ||||||
|       file.url = `/api/files/${jsonInfo.id}` |  | ||||||
|       file.type = jsonInfo.mime_type |  | ||||||
|       onFinish() |  | ||||||
|     }, |  | ||||||
|     onBeforeRequest: function (req) { |  | ||||||
|       const xhr = req.getUnderlyingObject() |  | ||||||
|       xhr.withCredentials = withCredentials |  | ||||||
|     }, |  | ||||||
|   }) |  | ||||||
|   upload.findPreviousUploads().then(function (previousUploads) { |  | ||||||
|     if (previousUploads.length) { |  | ||||||
|       upload.resumeFromPreviousUpload(previousUploads[0]) |  | ||||||
|   } |   } | ||||||
|     upload.start() |  | ||||||
|   }) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| function createThumbnailUrl( | function proceedToBundleUpload() { | ||||||
|   _file: File | null, |   currentBundleId.value = selectedBundleId.value || newBundleId.value | ||||||
|   fileInfo: UploadSettledFileInfo, |   bundleUploadMode.value = true | ||||||
| ): string | undefined { |   isBundleMode.value = true | ||||||
|   if (!fileInfo) return undefined |  | ||||||
|   return fileInfo.url ?? undefined |  | ||||||
| } | } | ||||||
|  |  | ||||||
| function customDownload(file: UploadFileInfo) { | function cancelBundleUpload() { | ||||||
|   const { url } = file |   bundleUploadMode.value = false | ||||||
|   if (!url) return |   isBundleMode.value = false | ||||||
|   window.open(url.replace('/api', ''), '_blank') |   currentBundleId.value = null | ||||||
| } |   selectedBundleId.value = null | ||||||
|  |   newBundleId.value = null | ||||||
| function customPreview(file: UploadFileInfo, detail: { event: MouseEvent }) { |  | ||||||
|   detail.event.preventDefault() |  | ||||||
|   const { url } = file |  | ||||||
|   if (!url) return |  | ||||||
|   window.open(url.replace('/api', ''), '_blank') |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function disablePreviousDate(ts: number) { |  | ||||||
|   return ts <= Date.now() |  | ||||||
| } | } | ||||||
| </script> | </script> | ||||||
|   | |||||||
| @@ -8,6 +8,7 @@ | |||||||
|     </PropertyGroup> |     </PropertyGroup> | ||||||
|  |  | ||||||
|     <ItemGroup> |     <ItemGroup> | ||||||
|  |         <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> | ||||||
|         <PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" /> |         <PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" /> | ||||||
|         <PackageReference Include="FFMpegCore" Version="5.2.0" /> |         <PackageReference Include="FFMpegCore" Version="5.2.0" /> | ||||||
|         <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" /> |         <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" /> | ||||||
|   | |||||||
							
								
								
									
										400
									
								
								DysonNetwork.Drive/Migrations/20250727130951_AddFileBundle.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										400
									
								
								DysonNetwork.Drive/Migrations/20250727130951_AddFileBundle.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,400 @@ | |||||||
|  | // <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("20250727130951_AddFileBundle")] | ||||||
|  |     partial class AddFileBundle | ||||||
|  |     { | ||||||
|  |         /// <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<Guid?>("BundleId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("bundle_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("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<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.FileBundle", 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") | ||||||
|  |                         .HasMaxLength(8192) | ||||||
|  |                         .HasColumnType("character varying(8192)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Passcode") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("passcode"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Slug") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("slug"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("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<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.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 | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,79 @@ | |||||||
|  | using System; | ||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  | using NodaTime; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Migrations | ||||||
|  | { | ||||||
|  |     /// <inheritdoc /> | ||||||
|  |     public partial class AddFileBundle : Migration | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Up(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.AddColumn<Guid>( | ||||||
|  |                 name: "bundle_id", | ||||||
|  |                 table: "files", | ||||||
|  |                 type: "uuid", | ||||||
|  |                 nullable: true); | ||||||
|  |  | ||||||
|  |             migrationBuilder.CreateTable( | ||||||
|  |                 name: "bundles", | ||||||
|  |                 columns: table => new | ||||||
|  |                 { | ||||||
|  |                     id = table.Column<Guid>(type: "uuid", nullable: false), | ||||||
|  |                     slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false), | ||||||
|  |                     name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false), | ||||||
|  |                     description = table.Column<string>(type: "character varying(8192)", maxLength: 8192, nullable: true), | ||||||
|  |                     passcode = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true), | ||||||
|  |                     expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true), | ||||||
|  |                     account_id = table.Column<Guid>(type: "uuid", nullable: false), | ||||||
|  |                     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_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"); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         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"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -85,6 +85,10 @@ namespace DysonNetwork.Drive.Migrations | |||||||
|                         .HasColumnType("uuid") |                         .HasColumnType("uuid") | ||||||
|                         .HasColumnName("account_id"); |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("BundleId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("bundle_id"); | ||||||
|  |  | ||||||
|                     b.Property<Instant>("CreatedAt") |                     b.Property<Instant>("CreatedAt") | ||||||
|                         .HasColumnType("timestamp with time zone") |                         .HasColumnType("timestamp with time zone") | ||||||
|                         .HasColumnName("created_at"); |                         .HasColumnName("created_at"); | ||||||
| @@ -180,6 +184,9 @@ namespace DysonNetwork.Drive.Migrations | |||||||
|                     b.HasKey("Id") |                     b.HasKey("Id") | ||||||
|                         .HasName("pk_files"); |                         .HasName("pk_files"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("BundleId") | ||||||
|  |                         .HasDatabaseName("ix_files_bundle_id"); | ||||||
|  |  | ||||||
|                     b.HasIndex("PoolId") |                     b.HasIndex("PoolId") | ||||||
|                         .HasDatabaseName("ix_files_pool_id"); |                         .HasDatabaseName("ix_files_pool_id"); | ||||||
|  |  | ||||||
| @@ -236,6 +243,65 @@ namespace DysonNetwork.Drive.Migrations | |||||||
|                     b.ToTable("file_references", (string)null); |                     b.ToTable("file_references", (string)null); | ||||||
|                 }); |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", 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") | ||||||
|  |                         .HasMaxLength(8192) | ||||||
|  |                         .HasColumnType("character varying(8192)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Passcode") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("passcode"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Slug") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("slug"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("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 => |             modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b => | ||||||
|                 { |                 { | ||||||
|                     b.Property<Guid>("Id") |                     b.Property<Guid>("Id") | ||||||
| @@ -294,11 +360,18 @@ namespace DysonNetwork.Drive.Migrations | |||||||
|  |  | ||||||
|             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => |             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") |                     b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool") | ||||||
|                         .WithMany() |                         .WithMany() | ||||||
|                         .HasForeignKey("PoolId") |                         .HasForeignKey("PoolId") | ||||||
|                         .HasConstraintName("fk_files_pools_pool_id"); |                         .HasConstraintName("fk_files_pools_pool_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Bundle"); | ||||||
|  |  | ||||||
|                     b.Navigation("Pool"); |                     b.Navigation("Pool"); | ||||||
|                 }); |                 }); | ||||||
|  |  | ||||||
| @@ -313,6 +386,11 @@ namespace DysonNetwork.Drive.Migrations | |||||||
|  |  | ||||||
|                     b.Navigation("File"); |                     b.Navigation("File"); | ||||||
|                 }); |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b => | ||||||
|  |                 { | ||||||
|  |                     b.Navigation("Files"); | ||||||
|  |                 }); | ||||||
| #pragma warning restore 612, 618 | #pragma warning restore 612, 618 | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|   | |||||||
							
								
								
									
										158
									
								
								DysonNetwork.Drive/Storage/BundleController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										158
									
								
								DysonNetwork.Drive/Storage/BundleController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,158 @@ | |||||||
|  | using System.ComponentModel.DataAnnotations; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using Microsoft.AspNetCore.Authorization; | ||||||
|  | using Microsoft.AspNetCore.Mvc; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using NodaTime; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Storage; | ||||||
|  |  | ||||||
|  | [ApiController] | ||||||
|  | [Route("/api/bundles")] | ||||||
|  | public class BundleController(AppDatabase db) : ControllerBase | ||||||
|  | { | ||||||
|  |     public class BundleRequest | ||||||
|  |     { | ||||||
|  |         [MaxLength(1024)] public string? Slug { get; set; } | ||||||
|  |         [MaxLength(1024)] public string? Name { get; set; } | ||||||
|  |         [MaxLength(8192)] public string? Description { get; set; } | ||||||
|  |         [MaxLength(256)] public string? Passcode { get; set; } | ||||||
|  |  | ||||||
|  |         public Instant? ExpiredAt { get; set; } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpGet("{id:guid}")] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<ActionResult<FileBundle>> GetBundle([FromRoute] Guid id, [FromQuery] string? passcode) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |  | ||||||
|  |         var bundle = await db.Bundles | ||||||
|  |             .Where(e => e.Id == id) | ||||||
|  |             .Where(e => e.AccountId == accountId) | ||||||
|  |             .Include(e => e.Files) | ||||||
|  |             .FirstOrDefaultAsync(); | ||||||
|  |         if (bundle is null) return NotFound(); | ||||||
|  |         if (!bundle.VerifyPasscode(passcode)) return Forbid(); | ||||||
|  |  | ||||||
|  |         return Ok(bundle); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpGet("me")] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<ActionResult<List<FileBundle>>> ListBundles( | ||||||
|  |         [FromQuery] string? term, | ||||||
|  |         [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(); | ||||||
|  |         if (!string.IsNullOrEmpty(term)) | ||||||
|  |             query = query.Where(e => EF.Functions.ILike(e.Name, $"%{term}%")); | ||||||
|  |  | ||||||
|  |         var total = await query.CountAsync(); | ||||||
|  |         Response.Headers.Append("X-Total", total.ToString()); | ||||||
|  |  | ||||||
|  |         var bundles = await query | ||||||
|  |             .Skip(offset) | ||||||
|  |             .Take(take) | ||||||
|  |             .ToListAsync(); | ||||||
|  |  | ||||||
|  |         return Ok(bundles); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpPost] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<ActionResult<FileBundle>> CreateBundle([FromBody] BundleRequest request) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |  | ||||||
|  |         if (currentUser.PerkSubscription is null && !string.IsNullOrEmpty(request.Slug)) | ||||||
|  |             return StatusCode(403, "You must have a subscription to create a bundle with a custom slug"); | ||||||
|  |         if (string.IsNullOrEmpty(request.Slug)) | ||||||
|  |             request.Slug = Guid.NewGuid().ToString("N")[..6]; | ||||||
|  |         if (string.IsNullOrEmpty(request.Name)) | ||||||
|  |             request.Name = "Unnamed Bundle"; | ||||||
|  |  | ||||||
|  |         var bundle = new FileBundle | ||||||
|  |         { | ||||||
|  |             Slug = request.Slug, | ||||||
|  |             Name = request.Name, | ||||||
|  |             Description = request.Description, | ||||||
|  |             Passcode = request.Passcode, | ||||||
|  |             ExpiredAt = request.ExpiredAt, | ||||||
|  |             AccountId = accountId | ||||||
|  |         }.HashPasscode(); | ||||||
|  |  | ||||||
|  |         db.Bundles.Add(bundle); | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |  | ||||||
|  |         return Ok(bundle); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpPut("{id:guid}")] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<ActionResult<FileBundle>> UpdateBundle([FromRoute] Guid id, [FromBody] BundleRequest request) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |  | ||||||
|  |         var bundle = await db.Bundles | ||||||
|  |             .Where(e => e.Id == id) | ||||||
|  |             .Where(e => e.AccountId == accountId) | ||||||
|  |             .FirstOrDefaultAsync(); | ||||||
|  |         if (bundle is null) return NotFound(); | ||||||
|  |  | ||||||
|  |         if (request.Slug != null && request.Slug != bundle.Slug) | ||||||
|  |         { | ||||||
|  |             if (currentUser.PerkSubscription is null) | ||||||
|  |                 return StatusCode(403, "You must have a subscription to change the slug of a bundle"); | ||||||
|  |             bundle.Slug = request.Slug; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (request.Name != null) bundle.Name = request.Name; | ||||||
|  |         if (request.Description != null) bundle.Description = request.Description; | ||||||
|  |         if (request.ExpiredAt != null) bundle.ExpiredAt = request.ExpiredAt; | ||||||
|  |  | ||||||
|  |         if (request.Passcode != null) | ||||||
|  |         { | ||||||
|  |             bundle.Passcode = request.Passcode; | ||||||
|  |             bundle = bundle.HashPasscode(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |  | ||||||
|  |         return Ok(bundle); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpDelete("{id:guid}")] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<ActionResult> DeleteBundle([FromRoute] Guid id) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |  | ||||||
|  |         var bundle = await db.Bundles | ||||||
|  |             .Where(e => e.Id == id) | ||||||
|  |             .Where(e => e.AccountId == accountId) | ||||||
|  |             .FirstOrDefaultAsync(); | ||||||
|  |         if (bundle is null) return NotFound(); | ||||||
|  |  | ||||||
|  |         db.Bundles.Remove(bundle); | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |  | ||||||
|  |         await db.Files | ||||||
|  |             .Where(e => e.BundleId == id) | ||||||
|  |             .ExecuteUpdateAsync(s => s.SetProperty(e => e.IsMarkedRecycle, true)); | ||||||
|  |  | ||||||
|  |         return NoContent(); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,9 +1,9 @@ | |||||||
| using System.ComponentModel.DataAnnotations; | using System.ComponentModel.DataAnnotations; | ||||||
| using System.ComponentModel.DataAnnotations.Schema; | using System.ComponentModel.DataAnnotations.Schema; | ||||||
|  | using System.Text.Json.Serialization; | ||||||
| using DysonNetwork.Shared.Data; | using DysonNetwork.Shared.Data; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
| using Google.Protobuf; | using Google.Protobuf; | ||||||
| using Newtonsoft.Json; |  | ||||||
| using NodaTime; | using NodaTime; | ||||||
| using NodaTime.Serialization.Protobuf; | using NodaTime.Serialization.Protobuf; | ||||||
|  |  | ||||||
| @@ -47,6 +47,8 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource | |||||||
|  |  | ||||||
|     [JsonIgnore] public FilePool? Pool { get; set; } |     [JsonIgnore] public FilePool? Pool { get; set; } | ||||||
|     public Guid? PoolId { 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.")] |     [Obsolete("Deprecated, use PoolId instead. For database migration only.")] | ||||||
|     [MaxLength(128)] |     [MaxLength(128)] | ||||||
|   | |||||||
							
								
								
									
										36
									
								
								DysonNetwork.Drive/Storage/FileBundle.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								DysonNetwork.Drive/Storage/FileBundle.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | |||||||
|  | using System.ComponentModel.DataAnnotations; | ||||||
|  | using DysonNetwork.Shared.Data; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using NodaTime; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Storage; | ||||||
|  |  | ||||||
|  | [Index(nameof(Slug), IsUnique = true)] | ||||||
|  | public class FileBundle : ModelBase | ||||||
|  | { | ||||||
|  |     public Guid Id { get; set; } = Guid.NewGuid(); | ||||||
|  |     [MaxLength(1024)] public string Slug { get; set; } = null!; | ||||||
|  |     [MaxLength(1024)] public string Name { get; set; } = null!; | ||||||
|  |     [MaxLength(8192)] public string? Description { get; set; } | ||||||
|  |     [MaxLength(256)] public string? Passcode { get; set; } | ||||||
|  |  | ||||||
|  |     public List<CloudFile> Files { get; set; } = new(); | ||||||
|  |  | ||||||
|  |     public Instant? ExpiredAt { get; set; } | ||||||
|  |  | ||||||
|  |     public Guid AccountId { get; set; } | ||||||
|  |  | ||||||
|  |     public FileBundle HashPasscode() | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrEmpty(Passcode)) return this; | ||||||
|  |         Passcode = BCrypt.Net.BCrypt.HashPassword(Passcode); | ||||||
|  |         return this; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public bool VerifyPasscode(string? passcode) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrEmpty(Passcode)) return true; | ||||||
|  |         if (string.IsNullOrEmpty(passcode)) return false; | ||||||
|  |         return BCrypt.Net.BCrypt.Verify(passcode, Passcode); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -22,7 +22,8 @@ public class FileController( | |||||||
|         string id, |         string id, | ||||||
|         [FromQuery] bool download = false, |         [FromQuery] bool download = false, | ||||||
|         [FromQuery] bool original = 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 |         // Support the file extension for client side data recognize | ||||||
| @@ -36,6 +37,10 @@ public class FileController( | |||||||
|  |  | ||||||
|         var file = await fs.GetFileAsync(id); |         var file = await fs.GetFileAsync(id); | ||||||
|         if (file is null) return NotFound(); |         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); |         if (!string.IsNullOrWhiteSpace(file.StorageUrl)) return Redirect(file.StorageUrl); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -46,6 +46,7 @@ public class FileService( | |||||||
|         var file = await db.Files |         var file = await db.Files | ||||||
|             .Where(f => f.Id == fileId) |             .Where(f => f.Id == fileId) | ||||||
|             .Include(f => f.Pool) |             .Include(f => f.Pool) | ||||||
|  |             .Include(f => f.Bundle) | ||||||
|             .FirstOrDefaultAsync(); |             .FirstOrDefaultAsync(); | ||||||
|  |  | ||||||
|         if (file != null) |         if (file != null) | ||||||
| @@ -105,6 +106,7 @@ public class FileService( | |||||||
|         Account account, |         Account account, | ||||||
|         string fileId, |         string fileId, | ||||||
|         string filePool, |         string filePool, | ||||||
|  |         string? fileBundleId, | ||||||
|         Stream stream, |         Stream stream, | ||||||
|         string fileName, |         string fileName, | ||||||
|         string? contentType, |         string? contentType, | ||||||
| @@ -112,6 +114,8 @@ public class FileService( | |||||||
|         Instant? expiredAt |         Instant? expiredAt | ||||||
|     ) |     ) | ||||||
|     { |     { | ||||||
|  |         var accountId = Guid.Parse(account.Id); | ||||||
|  |          | ||||||
|         var pool = await GetPoolAsync(Guid.Parse(filePool)); |         var pool = await GetPoolAsync(Guid.Parse(filePool)); | ||||||
|         if (pool is null) throw new InvalidOperationException("Pool not found"); |         if (pool is null) throw new InvalidOperationException("Pool not found"); | ||||||
|  |  | ||||||
| @@ -124,6 +128,17 @@ public class FileService( | |||||||
|             expiredAt = SystemClock.Instance.GetCurrentInstant() + effectiveExpiration; |             expiredAt = SystemClock.Instance.GetCurrentInstant() + effectiveExpiration; | ||||||
|         } |         } | ||||||
|          |          | ||||||
|  |         var bundle = fileBundleId is not null | ||||||
|  |             ? await GetBundleAsync(Guid.Parse(fileBundleId), accountId) | ||||||
|  |             : null; | ||||||
|  |         if (fileBundleId is not null && bundle is null) | ||||||
|  |         { | ||||||
|  |             throw new InvalidOperationException("Bundle not found"); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         if (bundle?.ExpiredAt != null)  | ||||||
|  |             expiredAt = bundle.ExpiredAt.Value; | ||||||
|  |  | ||||||
|         var ogFilePath = Path.GetFullPath(Path.Join(configuration.GetValue<string>("Tus:StorePath"), fileId)); |         var ogFilePath = Path.GetFullPath(Path.Join(configuration.GetValue<string>("Tus:StorePath"), fileId)); | ||||||
|         var fileSize = stream.Length; |         var fileSize = stream.Length; | ||||||
|         contentType ??= !fileName.Contains('.') ? "application/octet-stream" : MimeTypes.GetMimeType(fileName); |         contentType ??= !fileName.Contains('.') ? "application/octet-stream" : MimeTypes.GetMimeType(fileName); | ||||||
| @@ -149,6 +164,7 @@ public class FileService( | |||||||
|             Size = fileSize, |             Size = fileSize, | ||||||
|             Hash = hash, |             Hash = hash, | ||||||
|             ExpiredAt = expiredAt, |             ExpiredAt = expiredAt, | ||||||
|  |             BundleId = bundle?.Id, | ||||||
|             AccountId = Guid.Parse(account.Id), |             AccountId = Guid.Parse(account.Id), | ||||||
|             IsEncrypted = !string.IsNullOrWhiteSpace(encryptPassword) && pool.PolicyConfig.AllowEncryption |             IsEncrypted = !string.IsNullOrWhiteSpace(encryptPassword) && pool.PolicyConfig.AllowEncryption | ||||||
|         }; |         }; | ||||||
| @@ -613,6 +629,15 @@ public class FileService( | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     public async Task<FileBundle?> GetBundleAsync(Guid id, Guid accountId) | ||||||
|  |     { | ||||||
|  |         var bundle = await db.Bundles | ||||||
|  |             .Where(e => e.Id == id) | ||||||
|  |             .Where(e => e.AccountId == accountId) | ||||||
|  |             .FirstOrDefaultAsync(); | ||||||
|  |         return bundle; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     public async Task<FilePool?> GetPoolAsync(Guid destination) |     public async Task<FilePool?> GetPoolAsync(Guid destination) | ||||||
|     { |     { | ||||||
|         var cacheKey = $"file:pool:{destination}"; |         var cacheKey = $"file:pool:{destination}"; | ||||||
|   | |||||||
| @@ -15,7 +15,10 @@ namespace DysonNetwork.Drive.Storage; | |||||||
|  |  | ||||||
| public abstract class TusService | public abstract class TusService | ||||||
| { | { | ||||||
|     public static DefaultTusConfiguration BuildConfiguration(ITusStore store, IConfiguration configuration) => new() |     public static DefaultTusConfiguration BuildConfiguration( | ||||||
|  |         ITusStore store, | ||||||
|  |         IConfiguration configuration | ||||||
|  |     ) => new() | ||||||
|     { |     { | ||||||
|         Store = store, |         Store = store, | ||||||
|         Events = new Events |         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 => |             OnFileCompleteAsync = async eventContext => | ||||||
|             { |             { | ||||||
| @@ -107,6 +116,7 @@ public abstract class TusService | |||||||
|                 var fileStream = await file.GetContentAsync(eventContext.CancellationToken); |                 var fileStream = await file.GetContentAsync(eventContext.CancellationToken); | ||||||
|  |  | ||||||
|                 var filePool = httpContext.Request.Headers["X-FilePool"].FirstOrDefault(); |                 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(); |                 var encryptPassword = httpContext.Request.Headers["X-FilePass"].FirstOrDefault(); | ||||||
|  |  | ||||||
|                 if (string.IsNullOrEmpty(filePool)) |                 if (string.IsNullOrEmpty(filePool)) | ||||||
| @@ -124,6 +134,7 @@ public abstract class TusService | |||||||
|                         user, |                         user, | ||||||
|                         file.Id, |                         file.Id, | ||||||
|                         filePool!, |                         filePool!, | ||||||
|  |                         bundleId, | ||||||
|                         fileStream, |                         fileStream, | ||||||
|                         fileName, |                         fileName, | ||||||
|                         contentType, |                         contentType, | ||||||
| @@ -158,15 +169,23 @@ public abstract class TusService | |||||||
|                     eventContext.FailRequest(HttpStatusCode.Unauthorized); |                     eventContext.FailRequest(HttpStatusCode.Unauthorized); | ||||||
|                     return; |                     return; | ||||||
|                 } |                 } | ||||||
|  |                 var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |  | ||||||
|                 var filePool = eventContext.HttpContext.Request.Headers["X-FilePool"].FirstOrDefault(); |                 var poolId = eventContext.HttpContext.Request.Headers["X-FilePool"].FirstOrDefault(); | ||||||
|                 if (string.IsNullOrEmpty(filePool)) filePool = configuration["Storage:PreferredRemote"]; |                 if (string.IsNullOrEmpty(poolId)) poolId = configuration["Storage:PreferredRemote"]; | ||||||
|                 if (!Guid.TryParse(filePool, out _)) |                 if (!Guid.TryParse(poolId, out _)) | ||||||
|                 { |                 { | ||||||
|                     eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file pool id"); |                     eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file pool id"); | ||||||
|                     return; |                     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 metadata = eventContext.Metadata; | ||||||
|                 var contentType = metadata.TryGetValue("content-type", out var ct) ? ct.GetString(Encoding.UTF8) : null; |                 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 rejected = false; | ||||||
|  |  | ||||||
|                 var fs = scope.ServiceProvider.GetRequiredService<FileService>(); |                 var fs = scope.ServiceProvider.GetRequiredService<FileService>(); | ||||||
|                 var pool = await fs.GetPoolAsync(Guid.Parse(filePool!)); |                 var pool = await fs.GetPoolAsync(Guid.Parse(poolId!)); | ||||||
|                 if (pool is null) |                 if (pool is null) | ||||||
|                 { |                 { | ||||||
|                     eventContext.FailRequest(HttpStatusCode.BadRequest, "Pool not found"); |                     eventContext.FailRequest(HttpStatusCode.BadRequest, "Pool not found"); | ||||||
| @@ -234,7 +253,6 @@ public abstract class TusService | |||||||
|                 if (!rejected) |                 if (!rejected) | ||||||
|                 { |                 { | ||||||
|                     var quotaService = scope.ServiceProvider.GetRequiredService<QuotaService>(); |                     var quotaService = scope.ServiceProvider.GetRequiredService<QuotaService>(); | ||||||
|                     var accountId = Guid.Parse(currentUser.Id); |  | ||||||
|                     var (ok, billableUnit, quota) = await quotaService.IsFileAcceptable( |                     var (ok, billableUnit, quota) = await quotaService.IsFileAcceptable( | ||||||
|                         accountId, |                         accountId, | ||||||
|                         pool.BillingConfig.CostMultiplier ?? 1.0, |                         pool.BillingConfig.CostMultiplier ?? 1.0, | ||||||
|   | |||||||
| @@ -22,7 +22,6 @@ | |||||||
|         <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" /> |         <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" /> | ||||||
|         <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7" /> |         <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7" /> | ||||||
|         <PackageReference Include="NetTopologySuite" Version="2.6.0" /> |         <PackageReference Include="NetTopologySuite" Version="2.6.0" /> | ||||||
|         <PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> |  | ||||||
|         <PackageReference Include="NodaTime" Version="3.2.2" /> |         <PackageReference Include="NodaTime" Version="3.2.2" /> | ||||||
|         <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" /> |         <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" /> | ||||||
|         <PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" /> |         <PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" /> | ||||||
|   | |||||||
| @@ -34,7 +34,6 @@ | |||||||
|             <PrivateAssets>all</PrivateAssets> |             <PrivateAssets>all</PrivateAssets> | ||||||
|             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> |             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||||
|         </PackageReference> |         </PackageReference> | ||||||
|         <PackageReference Include="Newtonsoft.Json" Version="13.0.3"/> |  | ||||||
|         <PackageReference Include="NodaTime" Version="3.2.2"/> |         <PackageReference Include="NodaTime" Version="3.2.2"/> | ||||||
|         <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0"/> |         <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0"/> | ||||||
|         <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/> |         <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/> | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user