Compare commits
	
		
			2 Commits
		
	
	
		
			7442b8416f
			...
			8b1bb7fcfd
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 8b1bb7fcfd | |||
| e31a5ea017 | 
| @@ -17,6 +17,8 @@ public class AppDatabase( | ||||
| ) : DbContext(options) | ||||
| { | ||||
|     public DbSet<FilePool> Pools { get; set; } = null!; | ||||
|     public DbSet<FileBundle> Bundles { get; set; } = null!; | ||||
|      | ||||
|     public DbSet<QuotaRecord> QuotaRecords { 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) | ||||
|                            .Sum(f => f.Size) / 1024.0 / 1024.0 * | ||||
|                        (p.BillingConfig.CostMultiplier ?? 1.0), | ||||
|                 FileCount = db.Files | ||||
|                 FileCount = fileQuery | ||||
|                     .Count(f => f.PoolId == p.Id) | ||||
|             }) | ||||
|             .ToListAsync(); | ||||
|  | ||||
|         var totalUsage = poolUsages.Sum(p => p.UsageBytes); | ||||
|         var totalCost = poolUsages.Sum(p => p.Cost); | ||||
|         var totalFileCount = poolUsages.Sum(p => p.FileCount); | ||||
|  | ||||
|         return new TotalUsageDetails | ||||
|   | ||||
							
								
								
									
										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> | ||||
|  | ||||
| <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 { h, type Component } from 'vue' | ||||
| import { RouterView, useRoute, useRouter } from 'vue-router' | ||||
| @@ -38,7 +43,17 @@ const menuOptions: MenuOption[] = [ | ||||
|     label: 'Files', | ||||
|     key: 'dashboardFiles', | ||||
|     icon: renderIcon(AllInboxFilled), | ||||
|   } | ||||
|   }, | ||||
|   { | ||||
|     label: 'Bundles', | ||||
|     key: 'dashboardBundles', | ||||
|     icon: renderIcon(ShoppingBagRound), | ||||
|   }, | ||||
|   { | ||||
|     label: 'Quota', | ||||
|     key: 'dashboardQuota', | ||||
|     icon: renderIcon(PermDataSettingRound), | ||||
|   }, | ||||
| ] | ||||
|  | ||||
| function updateMenuSelect(key: string) { | ||||
|   | ||||
| @@ -15,6 +15,11 @@ const router = createRouter({ | ||||
|       name: 'files', | ||||
|       component: () => import('../views/files.vue'), | ||||
|     }, | ||||
|     { | ||||
|       path: '/bundles/:bundleId', | ||||
|       name: 'bundleDetails', | ||||
|       component: () => import('../views/bundles.vue'), | ||||
|     }, | ||||
|     { | ||||
|       path: '/dashboard', | ||||
|       name: 'dashboard', | ||||
| @@ -32,7 +37,19 @@ const router = createRouter({ | ||||
|           name: 'dashboardFiles', | ||||
|           component: () => import('../views/dashboard/files.vue'), | ||||
|           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 { | ||||
|     let baseUrl = services.value[serviceName] || null | ||||
|     const baseUrl = services.value[serviceName] || 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', | ||||
|     key: 'name', | ||||
|     maxWidth: 180, | ||||
|     render(row: any) { | ||||
|       return h( | ||||
|         NButton, | ||||
| @@ -140,7 +141,7 @@ const tableColumns: DataTableColumns<any> = [ | ||||
|     title: 'Expired At', | ||||
|     key: 'expired_at', | ||||
|     render(row: any) { | ||||
|       if (!row.expired_at) return 'Keep-alive' | ||||
|       if (!row.expired_at) return 'Never' | ||||
|       return new Date(row.expired_at).toLocaleString() | ||||
|     }, | ||||
|   }, | ||||
|   | ||||
							
								
								
									
										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 fileId = route.params.fileId | ||||
| const passcode = route.query.passcode as string | undefined | ||||
|  | ||||
| const progress = ref<number | undefined>(0) | ||||
|  | ||||
| @@ -177,7 +178,11 @@ const currentUrl = window.location.href | ||||
| const fileInfo = ref<any>(null) | ||||
| async function fetchFileInfo() { | ||||
|   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) { | ||||
|       throw new Error('Failed to fetch file info: ' + resp.statusText) | ||||
|     } | ||||
| @@ -192,7 +197,13 @@ const fileType = computed(() => { | ||||
|   if (!fileInfo.value) return '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() { | ||||
|   if (fileInfo.value.is_encrypted && !filePass.value) { | ||||
|   | ||||
| @@ -1,133 +1,88 @@ | ||||
| <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"> | ||||
|       <p>Welcome to the <b>Solar Drive</b></p> | ||||
|       <p>We help you upload, collect, and share files with ease in mind.</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 class="max-w-2xl" title="Upload to Solar Network" v-else> | ||||
|       <template #header-extra> | ||||
|         <div class="flex gap-2 items-center"> | ||||
|           <p>Advance Mode</p> | ||||
|           <n-switch v-model:value="modeAdvanced" size="small" /> | ||||
|         </div> | ||||
|       </template> | ||||
|  | ||||
|       <n-collapse-transition :show="showRecycleHint"> | ||||
|         <n-alert size="small" type="warning" title="Recycle Enabled" class="mb-3"> | ||||
|           You're uploading to a pool which enabled recycle. If the file you uploaded didn't | ||||
|           referenced from the Solar Network. It will be marked and will be deleted some while later. | ||||
|         </n-alert> | ||||
|       </n-collapse-transition> | ||||
|  | ||||
|       <div class="mb-3"> | ||||
|         <file-pool-select v-model="filePool" @update:pool="currentFilePool = $event" /> | ||||
|       </div> | ||||
|  | ||||
|       <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> | ||||
|     <n-card class="max-w-2xl" v-else content-style="padding: 0;"> | ||||
|       <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> | ||||
|             <n-switch v-model:value="modeAdvanced" size="small" /> | ||||
|           </div> | ||||
|         </n-card> | ||||
|       </n-collapse-transition> | ||||
|         </template> | ||||
|  | ||||
|       <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> | ||||
|         <n-tab-pane name="direct" tab="Direct Upload" :disabled="isBundleMode"> | ||||
|           <div class="mb-3"> | ||||
|             <file-pool-select v-model="filePool" @update:pool="currentFilePool = $event" /> | ||||
|           </div> | ||||
|           <upload-area | ||||
|             :filePool="filePool" | ||||
|             :pools="pools as SnFilePool[]" | ||||
|             :modeAdvanced="modeAdvanced" | ||||
|           /> | ||||
|         </n-tab-pane> | ||||
|         <n-tab-pane name="bundle" tab="Bundle Upload"> | ||||
|           <div class="mb-3"> | ||||
|             <bundle-select v-model:bundle="selectedBundleId" :disabled="isBundleMode" /> | ||||
|           </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> | ||||
|  | ||||
|       <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-modal v-model:show="showCreateBundleModal" preset="dialog" title="Create New Bundle"> | ||||
|             <bundle-form ref="bundleFormRef" :value="newBundle" /> | ||||
|             <template #action> | ||||
|               <n-button @click="showCreateBundleModal = false">Cancel</n-button> | ||||
|               <n-button type="primary" @click="createBundle">Create</n-button> | ||||
|             </template> | ||||
|           </n-modal> | ||||
|  | ||||
|           <div class="flex justify-between"> | ||||
|             <n-button @click="showCreateBundleModal = true" class="mb-3" :disabled="isBundleMode"> | ||||
|               Create New Bundle | ||||
|             </n-button> | ||||
|             <n-button | ||||
|               type="primary" | ||||
|               :disabled="!selectedBundleId && !newBundleId && !isBundleMode" | ||||
|               @click="isBundleMode ? cancelBundleUpload() : proceedToBundleUpload()" | ||||
|             > | ||||
|               {{ isBundleMode ? 'Cancel' : 'Proceed to Upload' }} | ||||
|             </n-button> | ||||
|           </div> | ||||
|  | ||||
|           <div v-if="bundleUploadMode" class="mt-3"> | ||||
|             <upload-area | ||||
|               :filePool="filePool" | ||||
|               :pools="pools as SnFilePool[]" | ||||
|               :modeAdvanced="modeAdvanced" | ||||
|               :bundleId="currentBundleId!" | ||||
|             /> | ||||
|           </div> | ||||
|         </n-tab-pane> | ||||
|       </n-tabs> | ||||
|     </n-card> | ||||
|  | ||||
|     <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> | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { | ||||
|   NCard, | ||||
|   NUpload, | ||||
|   NUploadDragger, | ||||
|   NIcon, | ||||
|   NText, | ||||
|   NP, | ||||
|   NInput, | ||||
|   NSwitch, | ||||
|   NCollapseTransition, | ||||
|   NDatePicker, | ||||
|   NAlert, | ||||
|   type UploadCustomRequestOptions, | ||||
|   type UploadSettledFileInfo, | ||||
|   type UploadFileInfo, | ||||
|   useMessage, | ||||
| } from 'naive-ui' | ||||
| import { NCard, NSwitch, NTabs, NTabPane, NButton, NModal } from 'naive-ui' | ||||
| import { computed, onMounted, ref } from 'vue' | ||||
| import { CloudUploadRound } from '@vicons/material' | ||||
| import { useUserStore } from '@/stores/user' | ||||
| import type { SnFilePool } from '@/types/pool' | ||||
|  | ||||
| import FilePoolSelect from '@/components/FilePoolSelect.vue' | ||||
|  | ||||
| import * as tus from 'tus-js-client' | ||||
| import UploadArea from '@/components/UploadArea.vue' | ||||
| import BundleSelect from '@/components/BundleSelect.vue' | ||||
| import BundleForm from '@/components/form/BundleForm.vue' | ||||
|  | ||||
| const userStore = useUserStore() | ||||
|  | ||||
| @@ -150,105 +105,57 @@ onMounted(() => fetchPools()) | ||||
| const modeAdvanced = ref(false) | ||||
|  | ||||
| const filePool = ref<string | null>(null) | ||||
| const filePass = ref<string>('') | ||||
| const fileExpire = ref<number | null>(null) | ||||
|  | ||||
| const currentFilePool = computed(() => { | ||||
|   if (!filePool.value) return 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({ | ||||
|   file, | ||||
|   headers, | ||||
|   withCredentials, | ||||
|   onFinish, | ||||
|   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: { | ||||
|       '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]) | ||||
| async function createBundle() { | ||||
|   try { | ||||
|     await bundleFormRef.value?.formRef?.validate() | ||||
|     const resp = await fetch('/api/bundles', { | ||||
|       method: 'POST', | ||||
|       headers: { | ||||
|         'Content-Type': 'application/json', | ||||
|       }, | ||||
|       body: JSON.stringify(newBundle.value), | ||||
|     }) | ||||
|     if (!resp.ok) { | ||||
|       throw new Error('Failed to create bundle') | ||||
|     } | ||||
|     upload.start() | ||||
|   }) | ||||
|     const createdBundle = await resp.json() | ||||
|     bundles.value.push(createdBundle) | ||||
|     selectedBundleId.value = createdBundle.id | ||||
|     newBundleId.value = createdBundle.id | ||||
|     showCreateBundleModal.value = false | ||||
|     newBundle.value = {} | ||||
|   } catch (error) { | ||||
|     console.error('Failed to create bundle:', error) | ||||
|   } | ||||
| } | ||||
|  | ||||
| function createThumbnailUrl( | ||||
|   _file: File | null, | ||||
|   fileInfo: UploadSettledFileInfo, | ||||
| ): string | undefined { | ||||
|   if (!fileInfo) return undefined | ||||
|   return fileInfo.url ?? undefined | ||||
| function proceedToBundleUpload() { | ||||
|   currentBundleId.value = selectedBundleId.value || newBundleId.value | ||||
|   bundleUploadMode.value = true | ||||
|   isBundleMode.value = true | ||||
| } | ||||
|  | ||||
| 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() | ||||
| function cancelBundleUpload() { | ||||
|   bundleUploadMode.value = false | ||||
|   isBundleMode.value = false | ||||
|   currentBundleId.value = null | ||||
|   selectedBundleId.value = null | ||||
|   newBundleId.value = null | ||||
| } | ||||
| </script> | ||||
|   | ||||
| @@ -8,6 +8,7 @@ | ||||
|     </PropertyGroup> | ||||
|  | ||||
|     <ItemGroup> | ||||
|         <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> | ||||
|         <PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" /> | ||||
|         <PackageReference Include="FFMpegCore" Version="5.2.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") | ||||
|                         .HasColumnName("account_id"); | ||||
|  | ||||
|                     b.Property<Guid?>("BundleId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("bundle_id"); | ||||
|  | ||||
|                     b.Property<Instant>("CreatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("created_at"); | ||||
| @@ -180,6 +184,9 @@ namespace DysonNetwork.Drive.Migrations | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_files"); | ||||
|  | ||||
|                     b.HasIndex("BundleId") | ||||
|                         .HasDatabaseName("ix_files_bundle_id"); | ||||
|  | ||||
|                     b.HasIndex("PoolId") | ||||
|                         .HasDatabaseName("ix_files_pool_id"); | ||||
|  | ||||
| @@ -236,6 +243,65 @@ namespace DysonNetwork.Drive.Migrations | ||||
|                     b.ToTable("file_references", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b => | ||||
|                 { | ||||
|                     b.Property<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") | ||||
| @@ -294,11 +360,18 @@ namespace DysonNetwork.Drive.Migrations | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||
|                 { | ||||
|                     b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle") | ||||
|                         .WithMany("Files") | ||||
|                         .HasForeignKey("BundleId") | ||||
|                         .HasConstraintName("fk_files_bundles_bundle_id"); | ||||
|  | ||||
|                     b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("PoolId") | ||||
|                         .HasConstraintName("fk_files_pools_pool_id"); | ||||
|  | ||||
|                     b.Navigation("Bundle"); | ||||
|  | ||||
|                     b.Navigation("Pool"); | ||||
|                 }); | ||||
|  | ||||
| @@ -313,6 +386,11 @@ namespace DysonNetwork.Drive.Migrations | ||||
|  | ||||
|                     b.Navigation("File"); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b => | ||||
|                 { | ||||
|                     b.Navigation("Files"); | ||||
|                 }); | ||||
| #pragma warning restore 612, 618 | ||||
|         } | ||||
|     } | ||||
|   | ||||
							
								
								
									
										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.Schema; | ||||
| using System.Text.Json.Serialization; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using Google.Protobuf; | ||||
| using Newtonsoft.Json; | ||||
| using NodaTime; | ||||
| using NodaTime.Serialization.Protobuf; | ||||
|  | ||||
| @@ -47,6 +47,8 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource | ||||
|  | ||||
|     [JsonIgnore] public FilePool? Pool { get; set; } | ||||
|     public Guid? PoolId { get; set; } | ||||
|     [JsonIgnore] public FileBundle? Bundle { get; set; } | ||||
|     public Guid? BundleId { get; set; } | ||||
|  | ||||
|     [Obsolete("Deprecated, use PoolId instead. For database migration only.")] | ||||
|     [MaxLength(128)] | ||||
|   | ||||
							
								
								
									
										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, | ||||
|         [FromQuery] bool download = false, | ||||
|         [FromQuery] bool original = false, | ||||
|         [FromQuery] string? overrideMimeType = null | ||||
|         [FromQuery] string? overrideMimeType = null, | ||||
|         [FromQuery] string? passcode = null | ||||
|     ) | ||||
|     { | ||||
|         // Support the file extension for client side data recognize | ||||
| @@ -36,6 +37,10 @@ public class FileController( | ||||
|  | ||||
|         var file = await fs.GetFileAsync(id); | ||||
|         if (file is null) return NotFound(); | ||||
|         if (file.IsMarkedRecycle) return StatusCode(StatusCodes.Status410Gone, "The file has been recycled."); | ||||
|  | ||||
|         if (file.Bundle is not null && !file.Bundle.VerifyPasscode(passcode)) | ||||
|             return StatusCode(StatusCodes.Status403Forbidden, "The passcode is incorrect."); | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(file.StorageUrl)) return Redirect(file.StorageUrl); | ||||
|  | ||||
| @@ -46,7 +51,7 @@ public class FileController( | ||||
|             if (!System.IO.File.Exists(filePath)) return new NotFoundResult(); | ||||
|             return PhysicalFile(filePath, file.MimeType ?? "application/octet-stream", file.Name); | ||||
|         } | ||||
|  | ||||
|          | ||||
|         var pool = await fs.GetPoolAsync(file.PoolId.Value); | ||||
|         if (pool is null) | ||||
|             return StatusCode(StatusCodes.Status410Gone, "The pool of the file no longer exists or not accessible."); | ||||
|   | ||||
| @@ -46,6 +46,7 @@ public class FileService( | ||||
|         var file = await db.Files | ||||
|             .Where(f => f.Id == fileId) | ||||
|             .Include(f => f.Pool) | ||||
|             .Include(f => f.Bundle) | ||||
|             .FirstOrDefaultAsync(); | ||||
|  | ||||
|         if (file != null) | ||||
| @@ -105,6 +106,7 @@ public class FileService( | ||||
|         Account account, | ||||
|         string fileId, | ||||
|         string filePool, | ||||
|         string? fileBundleId, | ||||
|         Stream stream, | ||||
|         string fileName, | ||||
|         string? contentType, | ||||
| @@ -112,6 +114,8 @@ public class FileService( | ||||
|         Instant? expiredAt | ||||
|     ) | ||||
|     { | ||||
|         var accountId = Guid.Parse(account.Id); | ||||
|          | ||||
|         var pool = await GetPoolAsync(Guid.Parse(filePool)); | ||||
|         if (pool is null) throw new InvalidOperationException("Pool not found"); | ||||
|  | ||||
| @@ -123,6 +127,17 @@ public class FileService( | ||||
|                 : expectedExpiration; | ||||
|             expiredAt = SystemClock.Instance.GetCurrentInstant() + effectiveExpiration; | ||||
|         } | ||||
|          | ||||
|         var bundle = fileBundleId is not null | ||||
|             ? await GetBundleAsync(Guid.Parse(fileBundleId), accountId) | ||||
|             : null; | ||||
|         if (fileBundleId is not null && bundle is null) | ||||
|         { | ||||
|             throw new InvalidOperationException("Bundle not found"); | ||||
|         } | ||||
|          | ||||
|         if (bundle?.ExpiredAt != null)  | ||||
|             expiredAt = bundle.ExpiredAt.Value; | ||||
|  | ||||
|         var ogFilePath = Path.GetFullPath(Path.Join(configuration.GetValue<string>("Tus:StorePath"), fileId)); | ||||
|         var fileSize = stream.Length; | ||||
| @@ -149,6 +164,7 @@ public class FileService( | ||||
|             Size = fileSize, | ||||
|             Hash = hash, | ||||
|             ExpiredAt = expiredAt, | ||||
|             BundleId = bundle?.Id, | ||||
|             AccountId = Guid.Parse(account.Id), | ||||
|             IsEncrypted = !string.IsNullOrWhiteSpace(encryptPassword) && pool.PolicyConfig.AllowEncryption | ||||
|         }; | ||||
| @@ -613,6 +629,15 @@ public class FileService( | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public async Task<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) | ||||
|     { | ||||
|         var cacheKey = $"file:pool:{destination}"; | ||||
|   | ||||
| @@ -15,7 +15,10 @@ namespace DysonNetwork.Drive.Storage; | ||||
|  | ||||
| public abstract class TusService | ||||
| { | ||||
|     public static DefaultTusConfiguration BuildConfiguration(ITusStore store, IConfiguration configuration) => new() | ||||
|     public static DefaultTusConfiguration BuildConfiguration( | ||||
|         ITusStore store, | ||||
|         IConfiguration configuration | ||||
|     ) => new() | ||||
|     { | ||||
|         Store = store, | ||||
|         Events = new Events | ||||
| @@ -88,6 +91,12 @@ public abstract class TusService | ||||
|                         ); | ||||
|                     } | ||||
|                 } | ||||
|                  | ||||
|                 var bundleId = eventContext.HttpContext.Request.Headers["X-FileBundle"].FirstOrDefault(); | ||||
|                 if (!string.IsNullOrEmpty(bundleId) && !Guid.TryParse(bundleId, out _)) | ||||
|                 { | ||||
|                     eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file bundle id"); | ||||
|                 } | ||||
|             }, | ||||
|             OnFileCompleteAsync = async eventContext => | ||||
|             { | ||||
| @@ -107,6 +116,7 @@ public abstract class TusService | ||||
|                 var fileStream = await file.GetContentAsync(eventContext.CancellationToken); | ||||
|  | ||||
|                 var filePool = httpContext.Request.Headers["X-FilePool"].FirstOrDefault(); | ||||
|                 var bundleId = eventContext.HttpContext.Request.Headers["X-FileBundle"].FirstOrDefault(); | ||||
|                 var encryptPassword = httpContext.Request.Headers["X-FilePass"].FirstOrDefault(); | ||||
|  | ||||
|                 if (string.IsNullOrEmpty(filePool)) | ||||
| @@ -116,7 +126,7 @@ public abstract class TusService | ||||
|                 var expiredString = httpContext.Request.Headers["X-FileExpire"].FirstOrDefault(); | ||||
|                 if (!string.IsNullOrEmpty(expiredString) && int.TryParse(expiredString, out var expired)) | ||||
|                     expiredAt = Instant.FromUnixTimeSeconds(expired); | ||||
|  | ||||
|                  | ||||
|                 try | ||||
|                 { | ||||
|                     var fileService = services.GetRequiredService<FileService>(); | ||||
| @@ -124,6 +134,7 @@ public abstract class TusService | ||||
|                         user, | ||||
|                         file.Id, | ||||
|                         filePool!, | ||||
|                         bundleId, | ||||
|                         fileStream, | ||||
|                         fileName, | ||||
|                         contentType, | ||||
| @@ -158,15 +169,23 @@ public abstract class TusService | ||||
|                     eventContext.FailRequest(HttpStatusCode.Unauthorized); | ||||
|                     return; | ||||
|                 } | ||||
|                 var accountId = Guid.Parse(currentUser.Id); | ||||
|  | ||||
|                 var filePool = eventContext.HttpContext.Request.Headers["X-FilePool"].FirstOrDefault(); | ||||
|                 if (string.IsNullOrEmpty(filePool)) filePool = configuration["Storage:PreferredRemote"]; | ||||
|                 if (!Guid.TryParse(filePool, out _)) | ||||
|                 var poolId = eventContext.HttpContext.Request.Headers["X-FilePool"].FirstOrDefault(); | ||||
|                 if (string.IsNullOrEmpty(poolId)) poolId = configuration["Storage:PreferredRemote"]; | ||||
|                 if (!Guid.TryParse(poolId, out _)) | ||||
|                 { | ||||
|                     eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file pool id"); | ||||
|                     return; | ||||
|                 } | ||||
|  | ||||
|                 var bundleId = eventContext.HttpContext.Request.Headers["X-FileBundle"].FirstOrDefault(); | ||||
|                 if (!string.IsNullOrEmpty(bundleId) && !Guid.TryParse(bundleId, out _)) | ||||
|                 { | ||||
|                     eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file bundle id"); | ||||
|                     return; | ||||
|                 } | ||||
|  | ||||
|                 var metadata = eventContext.Metadata; | ||||
|                 var contentType = metadata.TryGetValue("content-type", out var ct) ? ct.GetString(Encoding.UTF8) : null; | ||||
|  | ||||
| @@ -175,7 +194,7 @@ public abstract class TusService | ||||
|                 var rejected = false; | ||||
|  | ||||
|                 var fs = scope.ServiceProvider.GetRequiredService<FileService>(); | ||||
|                 var pool = await fs.GetPoolAsync(Guid.Parse(filePool!)); | ||||
|                 var pool = await fs.GetPoolAsync(Guid.Parse(poolId!)); | ||||
|                 if (pool is null) | ||||
|                 { | ||||
|                     eventContext.FailRequest(HttpStatusCode.BadRequest, "Pool not found"); | ||||
| @@ -234,7 +253,6 @@ public abstract class TusService | ||||
|                 if (!rejected) | ||||
|                 { | ||||
|                     var quotaService = scope.ServiceProvider.GetRequiredService<QuotaService>(); | ||||
|                     var accountId = Guid.Parse(currentUser.Id); | ||||
|                     var (ok, billableUnit, quota) = await quotaService.IsFileAcceptable( | ||||
|                         accountId, | ||||
|                         pool.BillingConfig.CostMultiplier ?? 1.0, | ||||
|   | ||||
| @@ -22,7 +22,6 @@ | ||||
|         <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" /> | ||||
|         <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7" /> | ||||
|         <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.Serialization.JsonNet" Version="3.2.0" /> | ||||
|         <PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" /> | ||||
|   | ||||
| @@ -34,7 +34,6 @@ | ||||
|             <PrivateAssets>all</PrivateAssets> | ||||
|             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||
|         </PackageReference> | ||||
|         <PackageReference Include="Newtonsoft.Json" Version="13.0.3"/> | ||||
|         <PackageReference Include="NodaTime" Version="3.2.2"/> | ||||
|         <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0"/> | ||||
|         <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user