diff --git a/app/pages/files/[id].vue b/app/pages/files/[id].vue new file mode 100644 index 0000000..464188e --- /dev/null +++ b/app/pages/files/[id].vue @@ -0,0 +1,246 @@ + + + diff --git a/app/pages/files/format.ts b/app/pages/files/format.ts new file mode 100644 index 0000000..34f0d4f --- /dev/null +++ b/app/pages/files/format.ts @@ -0,0 +1,8 @@ +export function formatBytes(bytes: number, decimals = 2): string { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const dm = decimals < 0 ? 0 : decimals + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i] +} diff --git a/app/pages/files/secure.ts b/app/pages/files/secure.ts new file mode 100644 index 0000000..1d8a352 --- /dev/null +++ b/app/pages/files/secure.ts @@ -0,0 +1,94 @@ +export async function downloadAndDecryptFile( + url: string, + password: string, + fileName: string, + onProgress?: (progress: number) => void, +): Promise { + 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 = fileName + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(downloadUrl) +} + +export async function decryptFile(fileBuffer: Uint8Array, password: string): Promise { + 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) +}