File upload frontpage and download decryption

This commit is contained in:
2025-07-26 03:11:42 +08:00
parent 0486c0d0e5
commit f1867e7916
19 changed files with 1051 additions and 229 deletions

View File

@@ -8,6 +8,11 @@ const router = createRouter({
path: '/',
name: 'index',
component: () => import('../views/index.vue')
},
{
path: '/files',
name: 'files',
component: () => import('../views/files.vue'),
}
]
})

View File

@@ -15,7 +15,7 @@ export const useUserStore = defineStore('user', () => {
isLoading.value = true
error.value = null
try {
const response = await fetch('/api/accounts/me', {
const response = await fetch('/cgi/id/accounts/me', {
credentials: 'include',
})

View File

@@ -0,0 +1,36 @@
<template>
<section class="h-full relative flex items-center justify-center">
<n-card class="max-w-lg" title="Download file">
<div class="flex flex-col gap-3" v-if="!progress">
<n-input placeholder="File ID" v-model:value="fileId" />
<n-input placeholder="Password" v-model:value="filePass" type="password" />
<n-button @click="downloadFile">Download</n-button>
</div>
<div v-else>
<n-progress :percentage="progress" />
</div>
</n-card>
</section>
</template>
<script setup lang="ts">
import { NCard, NInput, NButton, NProgress, useMessage } from 'naive-ui'
import { ref } from 'vue'
import { downloadAndDecryptFile } from './secure'
const filePass = ref<string>('')
const fileId = ref<string>('')
const progress = ref<number | undefined>(0)
const messageDisplay = useMessage()
function downloadFile() {
downloadAndDecryptFile('/api/files/' + fileId.value, filePass.value, (p: number) => {
progress.value = p * 100
}).catch((err) => {
messageDisplay.error('Download failed: ' + err.message, { closable: true, duration: 10000 })
})
}
</script>

View File

@@ -14,29 +14,78 @@
</span>
</p>
</n-card>
<n-card class="max-w-2xl" v-else>
<dashboard
:uppy="uppy"
:props="{ theme: 'auto', height: '28rem', proudlyDisplayPoweredByUppy: false }"
/>
<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>
<div class="mb-3" v-if="modeAdvanced">
<n-input
v-model:value="filePass"
placeholder="Enter password to protect the file"
clearable
size="large"
type="password"
class="mb-2"
/>
</div>
<n-upload
multiple
directory-dnd
with-credentials
show-preview-button
list-type="image"
:custom-request="customRequest"
:create-thumbnail-url="createThumbnailUrl"
>
<n-upload-dragger>
<div style="margin-bottom: 12px">
<n-icon size="48" :depth="3">
<upload-outlined />
</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>
<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>
</section>
</template>
<script setup lang="ts">
import { useUserStore } from '@/stores/user'
import { NCard } from 'naive-ui'
import {
NCard,
NUpload,
NUploadDragger,
NIcon,
NText,
NP,
NInput,
NSwitch,
type UploadCustomRequestOptions,
type UploadSettledFileInfo,
} from 'naive-ui'
import { onMounted, ref } from 'vue'
import { Dashboard } from '@uppy/vue'
import { UploadOutlined } from '@vicons/material'
import { useUserStore } from '@/stores/user'
import Uppy from '@uppy/core'
import Tus from '@uppy/tus'
import '@uppy/core/dist/style.min.css'
import '@uppy/dashboard/dist/style.min.css'
const uppy = new Uppy()
uppy.use(Tus, { endpoint: '/api/tus' })
import * as tus from 'tus-js-client'
const userStore = useUserStore()
@@ -48,8 +97,62 @@ async function fetchVersion() {
}
onMounted(() => fetchVersion())
</script>
<style scoped>
/* Add any specific styles here if needed, though Tailwind should handle most. */
</style>
const modeAdvanced = ref(false)
const filePass = ref<string>('')
function customRequest({
file,
data,
headers,
withCredentials,
action,
onFinish,
onError,
onProgress,
}: UploadCustomRequestOptions) {
const upload = new tus.Upload(file.file, {
endpoint: '/api/tus',
retryDelays: [0, 3000, 5000, 10000, 20000],
metadata: {
filename: file.name,
filetype: file.type ?? 'application/octet-stream',
},
headers: {
'X-FilePass': filePass.value,
...headers,
},
onError: function (error) {
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
}
</script>

View File

@@ -0,0 +1,92 @@
export async function downloadAndDecryptFile(
url: string,
password: string,
onProgress?: (progress: number) => void
): Promise<void> {
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to fetch: ${response.status}`);
const contentLength = +(response.headers.get('Content-Length') || 0);
const reader = response.body!.getReader();
const chunks: Uint8Array[] = [];
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
chunks.push(value);
received += value.length;
if (contentLength && onProgress) {
onProgress(received / contentLength);
}
}
}
const fullBuffer = new Uint8Array(received);
let offset = 0;
for (const chunk of chunks) {
fullBuffer.set(chunk, offset);
offset += chunk.length;
}
const decryptedBytes = await decryptFile(fullBuffer, password);
// Create a blob and trigger a download
const blob = new Blob([decryptedBytes]);
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = 'decrypted_file'; // You may allow customization
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(downloadUrl);
}
export async function decryptFile(
fileBuffer: Uint8Array,
password: string
): Promise<Uint8Array> {
const salt = fileBuffer.slice(0, 16);
const nonce = fileBuffer.slice(16, 28);
const tag = fileBuffer.slice(28, 44);
const ciphertext = fileBuffer.slice(44);
const enc = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw', enc.encode(password), { name: 'PBKDF2' }, false, ['deriveKey']
);
const key = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt']
);
const fullCiphertext = new Uint8Array(ciphertext.length + tag.length);
fullCiphertext.set(ciphertext);
fullCiphertext.set(tag, ciphertext.length);
let decrypted: ArrayBuffer;
try {
decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: nonce, tagLength: 128 },
key,
fullCiphertext
);
} catch {
throw new Error("Incorrect password or corrupted file.");
}
const magic = new TextEncoder().encode("DYSON1");
const decryptedBytes = new Uint8Array(decrypted);
for (let i = 0; i < magic.length; i++) {
if (decryptedBytes[i] !== magic[i]) {
throw new Error("Incorrect password or corrupted file.");
}
}
return decryptedBytes.slice(magic.length);
}