diff --git a/src/app/api/cron/cleanup/route.ts b/src/app/api/cron/cleanup/route.ts index 026b401..e8b661f 100644 --- a/src/app/api/cron/cleanup/route.ts +++ b/src/app/api/cron/cleanup/route.ts @@ -7,7 +7,6 @@ if (!process.env.DISCORD_BOT_TOKEN || !process.env.DISCORD_CHANNEL_ID || !proces export async function POST(request: Request) { try { - // 1. Authenticate the cron job request const authHeader = request.headers.get('authorization'); if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); @@ -15,43 +14,75 @@ export async function POST(request: Request) { const connection = await pool.getConnection(); try { - // 2. Find expired files + let totalCleanedExpired = 0; + let totalCleanedIncomplete = 0; + + // --- 1. Cleanup for INCOMPLETE uploads --- + const staleTime = new Date(); + staleTime.setHours(staleTime.getHours() - 24); // Older than 24 hours + + const [incompleteFiles]: any[] = await connection.query( + 'SELECT id FROM files WHERE token_hash IS NULL AND upload_at <= ?', + [staleTime] + ); + + if (incompleteFiles.length > 0) { + for (const file of incompleteFiles) { + const file_id = file.id; + + const [parts]: any[] = await connection.query('SELECT discord_message_id FROM file_parts WHERE file_id = ?', [file_id]); + + if (parts.length > 0) { + const deletePromises = parts.map((part: { discord_message_id: string }) => { + if (!part.discord_message_id) return Promise.resolve(); + const deleteUrl = `https://discord.com/api/v10/channels/${process.env.DISCORD_CHANNEL_ID}/messages/${part.discord_message_id}`; + return fetch(deleteUrl, { + method: 'DELETE', + headers: { 'Authorization': `Bot ${process.env.DISCORD_BOT_TOKEN}` }, + }); + }); + await Promise.allSettled(deletePromises); + } + + await connection.query('DELETE FROM file_parts WHERE file_id = ?', [file_id]); + await connection.query('DELETE FROM files WHERE id = ?', [file_id]); + totalCleanedIncomplete++; + } + } + + // --- 2. Cleanup for EXPIRED files --- const [expiredFiles]: any[] = await connection.query( 'SELECT id FROM files WHERE expires_at <= NOW() AND deleted = 0' ); - if (expiredFiles.length === 0) { - return NextResponse.json({ success: true, message: 'No expired files to clean up.' }); + if (expiredFiles.length > 0) { + for (const file of expiredFiles) { + const file_id = file.id; + + const [partsRows]: any[] = await connection.query( + 'SELECT discord_message_id FROM file_parts WHERE file_id = ?', + [file_id] + ); + + if (partsRows.length > 0) { + const deletePromises = partsRows.map((part: { discord_message_id: string }) => { + if (!part.discord_message_id) return Promise.resolve(); + const deleteUrl = `https://discord.com/api/v10/channels/${process.env.DISCORD_CHANNEL_ID}/messages/${part.discord_message_id}`; + return fetch(deleteUrl, { + method: 'DELETE', + headers: { 'Authorization': `Bot ${process.env.DISCORD_BOT_TOKEN}` }, + }); + }); + await Promise.allSettled(deletePromises); + } + + await connection.query('UPDATE files SET deleted = 1 WHERE id = ?', [file_id]); + totalCleanedExpired++; + } } - let totalCleaned = 0; - - // 3. For each expired file, delete parts and mark as deleted - for (const file of expiredFiles) { - const file_id = file.id; - - const [partsRows]: any[] = await connection.query( - 'SELECT discord_message_id FROM file_parts WHERE file_id = ?', - [file_id] - ); - - const deletePromises = partsRows.map((part: { discord_message_id: string }) => { - const deleteUrl = `https://discord.com/api/v10/channels/${process.env.DISCORD_CHANNEL_ID}/messages/${part.discord_message_id}`; - return fetch(deleteUrl, { - method: 'DELETE', - headers: { - 'Authorization': `Bot ${process.env.DISCORD_BOT_TOKEN}`, - } - }); - }); - - await Promise.allSettled(deletePromises); - - await connection.query('UPDATE files SET deleted = 1 WHERE id = ?', [file_id]); - totalCleaned++; - } - - return NextResponse.json({ success: true, message: `Cleaned up ${totalCleaned} expired files.` }); + const message = `Cleanup complete. Incomplete cleaned: ${totalCleanedIncomplete}. Expired cleaned: ${totalCleanedExpired}.`; + return NextResponse.json({ success: true, message }); } finally { connection.release(); @@ -61,4 +92,4 @@ export async function POST(request: Request) { console.error('Error during cron cleanup:', error); return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); } -} +} \ No newline at end of file diff --git a/src/app/download/[file_id]/page.tsx b/src/app/download/[file_id]/page.tsx index 3f3e60d..ecbbfda 100644 --- a/src/app/download/[file_id]/page.tsx +++ b/src/app/download/[file_id]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { useParams, useSearchParams } from 'next/navigation'; // Helper to convert Base64 string back to ArrayBuffer @@ -14,6 +14,17 @@ function base64ToBuffer(base64: string) { return bytes.buffer; } +// Helper to format bytes into a human-readable string +function formatBytes(bytes: number, decimals = 2) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +} + + // New helper function to handle retries for failed fetches async function fetchWithRetry(url: string, retries: number = 3, delay: number = 1000): Promise { for (let i = 0; i < retries; i++) { @@ -22,14 +33,11 @@ async function fetchWithRetry(url: string, retries: number = 3, delay: number = if (response.ok) { return response; } - // Don't retry on client errors (e.g., 404), but do on server errors (5xx) if (response.status >= 400 && response.status < 500) { throw new Error(`Client error: ${response.status} for URL ${url}`); } - // For server errors or network issues, log and prepare to retry console.warn(`Attempt ${i + 1} failed for ${url} with status ${response.status}. Retrying in ${delay}ms...`); } catch (error) { - // This catches network errors (e.g., no internet) and the thrown client error console.warn(`Attempt ${i + 1} failed for ${url}. Error: ${error}. Retrying in ${delay}ms...`); } await new Promise(resolve => setTimeout(resolve, delay * (i + 1))); // Exponential backoff @@ -39,13 +47,40 @@ async function fetchWithRetry(url: string, retries: number = 3, delay: number = export default function DownloadPage() { const [downloadState, setDownloadState] = useState<'idle' | 'downloading' | 'decrypting' | 'complete' | 'error'>('idle'); + const [deleteState, setDeleteState] = useState<'idle' | 'deleting' | 'deleted' | 'error'>('idle'); const [progress, setProgress] = useState(0); const [error, setError] = useState(''); + const [deleteError, setDeleteError] = useState(''); const [filename, setFilename] = useState(''); + const [downloadedBytes, setDownloadedBytes] = useState(0); + const [totalBytes, setTotalBytes] = useState(0); + const [downloadSpeed, setDownloadSpeed] = useState(0); // bytes per second + + const downloadedBytesRef = useRef(downloadedBytes); const params = useParams(); const searchParams = useSearchParams(); + useEffect(() => { + downloadedBytesRef.current = downloadedBytes; + }, [downloadedBytes]); + + useEffect(() => { + if (downloadState !== 'downloading') { + setDownloadSpeed(0); + return; + } + let lastBytes = downloadedBytesRef.current; + const interval = setInterval(() => { + const currentBytes = downloadedBytesRef.current; + const speed = currentBytes - lastBytes; + setDownloadSpeed(speed); + lastBytes = currentBytes; + }, 1000); + + return () => clearInterval(interval); + }, [downloadState]); + useEffect(() => { const startDownload = async () => { const file_id = params.file_id as string; @@ -59,7 +94,6 @@ export default function DownloadPage() { } try { - // 1. Fetch file metadata const metaRes = await fetch(`/api/file/${file_id}?token=${token}`); if (!metaRes.ok) { const err = await metaRes.json(); @@ -67,24 +101,24 @@ export default function DownloadPage() { } const metadata = await metaRes.json(); setFilename(metadata.filename); + setTotalBytes(metadata.size); - // 2. Download all parts in parallel setDownloadState('downloading'); const encryptedParts = new Array(metadata.num_parts); - let downloadedCount = 0; const downloadPromises = metadata.parts.map(async (part: any) => { const response = await fetchWithRetry(`/api/download-part/${file_id}/${part.part_index}`); const buffer = await response.arrayBuffer(); encryptedParts[part.part_index] = { index: part.part_index, data: buffer }; - downloadedCount++; - setProgress(Math.round((downloadedCount / metadata.num_parts) * 50)); + setDownloadedBytes(prev => prev + buffer.byteLength); + setProgress(prev => prev + (1 / metadata.num_parts) * 50); }); await Promise.all(downloadPromises); - // 3. Import key and decrypt parts setDownloadState('decrypting'); + setProgress(50); + const fileKey = await window.crypto.subtle.importKey( 'raw', base64ToBuffer(keyString), @@ -93,18 +127,15 @@ export default function DownloadPage() { ['decrypt'] ); - let decryptedCount = 0; const decryptPromises = encryptedParts.map(async (part) => { const iv = part.data.slice(0, 12); const data = part.data.slice(12); const decrypted = await window.crypto.subtle.decrypt({ name: 'AES-GCM', iv }, fileKey, data); - decryptedCount++; - setProgress(50 + Math.round((decryptedCount / metadata.num_parts) * 50)); + setProgress(prev => prev + (1 / metadata.num_parts) * 50); return decrypted; }); const decryptedParts = await Promise.all(decryptPromises); - // 4. Combine parts and trigger download const finalBlob = new Blob(decryptedParts); const url = window.URL.createObjectURL(finalBlob); const a = document.createElement('a'); @@ -123,39 +154,118 @@ export default function DownloadPage() { } }; + if(filename) return; startDownload(); - }, [params, searchParams]); + }, [params, searchParams, filename]); + + const handleDelete = async () => { + if (!window.confirm(`Are you sure you want to permanently delete this file? This action cannot be undone.`)) { + return; + } + + setDeleteState('deleting'); + const file_id = params.file_id as string; + const token = searchParams.get('token'); + + try { + const res = await fetch('/api/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_id, token }), + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || 'Failed to delete file.'); + } + + setDeleteState('deleted'); + + } catch (e: any) { + setDeleteState('error'); + setDeleteError(e.message || 'An unknown error occurred during deletion.'); + } + }; return ( -
-
- {downloadState !== 'error' && downloadState !== 'complete' && ( +
+
+ {deleteState !== 'deleted' ? ( <> -

Downloading File

-

{filename}

-
-
- {progress}% + {downloadState !== 'error' && downloadState !== 'complete' && ( + <> +
+

Downloading File

+ + + +
+

{filename || "Fetching filename..."}

+ +
+
+
+
+
+ {Math.round(progress)}% + {formatBytes(downloadedBytes)} / {formatBytes(totalBytes)} +
+
+ +
+

{downloadState === 'downloading' && `Downloading... (${formatBytes(downloadSpeed)}/s)`}

+

{downloadState === 'decrypting' && 'Decrypting and assembling file...'}

+
+ + )} + {downloadState === 'complete' && ( +
+ + + +

Download Started!

+

Your file {filename} should be in your downloads folder.

+

You can now close this page.

-
-

- {downloadState === 'downloading' && 'Downloading parts...'} - {downloadState === 'decrypting' && 'Decrypting and assembling file...'} -

- - )} - {downloadState === 'complete' && ( - <> -

Download Started!

-

Your file {filename} should be in your downloads folder.

-

You can now close this page.

- - )} - {downloadState === 'error' && ( - <> -

Download Failed

-

{error}

+ )} + {downloadState === 'error' && ( +
+ + + +

Download Failed

+

{error}

+
+ )} + + {filename && ( +
+ {deleteState === 'idle' && ( + + )} + {deleteState === 'deleting' && ( +

Deleting file from servers...

+ )} + {deleteState === 'error' && ( +

{deleteError}

+ )} +
+ )} + ) : ( +
+ + + +

File Deleted

+

The file {filename} has been permanently deleted.

+
)}