feat: Implement file upload and management system
- Created database schema for files and file parts. - Added API endpoint to create a new file and generate an upload token. - Implemented API endpoint to complete file uploads and generate a download token. - Developed cron job for cleaning up expired files and deleting associated Discord messages. - Added API endpoint for soft deletion of files and their parts. - Implemented API endpoint to fetch file metadata and parts for download. - Created API endpoint to upload file parts to Discord. - Developed a client-side download page to handle file decryption and assembly. - Established database connection pooling for efficient database operations.
This commit is contained in:
14
package.json
14
package.json
@@ -9,19 +9,23 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
"mysql2": "^3.15.2",
|
||||
"next": "15.5.5",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"next": "15.5.5"
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.5",
|
||||
"@eslint/eslintrc": "^3"
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
3877
pnpm-lock.yaml
generated
Normal file
3877
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
schema.sql
Normal file
22
schema.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE files (
|
||||
id CHAR(36) PRIMARY KEY, -- UUID
|
||||
filename VARCHAR(512),
|
||||
size BIGINT,
|
||||
num_parts INT,
|
||||
upload_at DATETIME,
|
||||
expires_at DATETIME,
|
||||
token_hash CHAR(64), -- pl. SHA256(token)
|
||||
encrypted_key VARBINARY(512), -- opcionális: ha szerver old. titkosít egy kulcsot
|
||||
deleted TINYINT(1) DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE file_parts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
file_id CHAR(36),
|
||||
part_index INT,
|
||||
discord_message_id VARCHAR(64), -- üzenet id, vagy webhook meta
|
||||
discord_attachment_url VARCHAR(1024),
|
||||
size INT,
|
||||
checksum CHAR(64),
|
||||
FOREIGN KEY (file_id) REFERENCES files(id)
|
||||
);
|
||||
51
src/app/api/complete-upload/route.ts
Normal file
51
src/app/api/complete-upload/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import pool from '@/lib/db';
|
||||
import bcrypt from 'bcrypt';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { file_id } = await request.json();
|
||||
|
||||
// Here you would typically validate the upload_token provided by the client
|
||||
// to ensure only the original uploader can complete the upload.
|
||||
|
||||
if (!file_id) {
|
||||
return NextResponse.json({ error: 'File ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Generate a secure, user-facing download token
|
||||
const downloadToken = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
// Hash the token for storage
|
||||
const tokenHash = await bcrypt.hash(downloadToken, SALT_ROUNDS);
|
||||
|
||||
// Set the expiration date to 7 days from now
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 7);
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const [result]: any = await connection.query(
|
||||
'UPDATE files SET token_hash = ?, expires_at = ? WHERE id = ?',
|
||||
[tokenHash, expiresAt, file_id]
|
||||
);
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
|
||||
// Return the raw token to the user ONCE.
|
||||
return NextResponse.json({ download_token: downloadToken });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error completing upload:', error);
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
38
src/app/api/create-file/route.ts
Normal file
38
src/app/api/create-file/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import pool from '@/lib/db';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { filename, size, num_parts } = await request.json();
|
||||
|
||||
if (!filename || !size || !num_parts) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const fileId = uuidv4();
|
||||
// Simple token for uploading parts, not the final download token
|
||||
const uploadToken = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.query(
|
||||
'INSERT INTO files (id, filename, size, num_parts, upload_at) VALUES (?, ?, ?, ?, ?)',
|
||||
[fileId, filename, size, num_parts, new Date()]
|
||||
);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
|
||||
// In a real app, you might want to store the uploadToken temporarily
|
||||
// (e.g., in Redis or another table) with an expiry to validate part uploads.
|
||||
// For this MVP, we'll return it directly. The client must send it with each part.
|
||||
|
||||
return NextResponse.json({ file_id: fileId, upload_token: uploadToken });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating file:', error);
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
59
src/app/api/cron/cleanup/route.ts
Normal file
59
src/app/api/cron/cleanup/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import pool from '@/lib/db';
|
||||
|
||||
if (!process.env.DISCORD_WEBHOOK_URL || !process.env.CRON_SECRET) {
|
||||
throw new Error('Please define DISCORD_WEBHOOK_URL and CRON_SECRET in .env.local');
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
// 2. Find 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.' });
|
||||
}
|
||||
|
||||
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 => {
|
||||
const deleteUrl = `${process.env.DISCORD_WEBHOOK_URL}/messages/${part.discord_message_id}`;
|
||||
return fetch(deleteUrl, { method: 'DELETE' });
|
||||
});
|
||||
|
||||
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.` });
|
||||
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error during cron cleanup:', error);
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
64
src/app/api/delete/route.ts
Normal file
64
src/app/api/delete/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import pool from '@/lib/db';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
if (!process.env.DISCORD_WEBHOOK_URL) {
|
||||
throw new Error('Please define DISCORD_WEBHOOK_URL in .env.local');
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { file_id, token } = await request.json();
|
||||
|
||||
if (!file_id || !token) {
|
||||
return NextResponse.json({ error: 'File ID and token are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
// 1. Fetch file and validate token
|
||||
const [fileRows]: any[] = await connection.query('SELECT * FROM files WHERE id = ? AND deleted = 0', [file_id]);
|
||||
const file = fileRows[0];
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isTokenValid = await bcrypt.compare(token, file.token_hash);
|
||||
if (!isTokenValid) {
|
||||
return NextResponse.json({ error: 'Invalid download token' }, { status: 403 });
|
||||
}
|
||||
|
||||
// 2. Fetch all message IDs for the file parts
|
||||
const [partsRows]: any[] = await connection.query(
|
||||
'SELECT discord_message_id FROM file_parts WHERE file_id = ?',
|
||||
[file_id]
|
||||
);
|
||||
|
||||
// 3. Delete each message from Discord
|
||||
const deletePromises = partsRows.map(part => {
|
||||
const deleteUrl = `${process.env.DISCORD_WEBHOOK_URL}/messages/${part.discord_message_id}`;
|
||||
return fetch(deleteUrl, { method: 'DELETE' });
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(deletePromises);
|
||||
results.forEach(result => {
|
||||
if (result.status === 'rejected') {
|
||||
console.warn('Failed to delete a message from Discord:', result.reason);
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Mark the file as deleted in the database (soft delete)
|
||||
await connection.query('UPDATE files SET deleted = 1 WHERE id = ?', [file_id]);
|
||||
|
||||
return NextResponse.json({ success: true, message: 'File marked for deletion.' });
|
||||
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting file:', error);
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
60
src/app/api/file/[file_id]/route.ts
Normal file
60
src/app/api/file/[file_id]/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import pool from '@/lib/db';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
export async function GET(request: Request, { params }: { params: { file_id: string } }) {
|
||||
try {
|
||||
const file_id = params.file_id;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const token = searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Download token is required' }, { status: 401 });
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
// 1. Fetch file metadata and token hash
|
||||
const [fileRows]: any[] = await connection.query('SELECT * FROM files WHERE id = ? AND deleted = 0', [file_id]);
|
||||
const file = fileRows[0];
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 2. Check for expiration
|
||||
if (file.expires_at && new Date(file.expires_at) < new Date()) {
|
||||
return NextResponse.json({ error: 'File has expired' }, { status: 410 });
|
||||
}
|
||||
|
||||
// 3. Verify the download token
|
||||
const isTokenValid = await bcrypt.compare(token, file.token_hash);
|
||||
if (!isTokenValid) {
|
||||
return NextResponse.json({ error: 'Invalid download token' }, { status: 403 });
|
||||
}
|
||||
|
||||
// 4. Fetch file parts
|
||||
const [partsRows]: any[] = await connection.query(
|
||||
'SELECT part_index, discord_attachment_url, size FROM file_parts WHERE file_id = ? ORDER BY part_index ASC',
|
||||
[file_id]
|
||||
);
|
||||
|
||||
// 5. Return metadata and parts list
|
||||
const response = {
|
||||
filename: file.filename,
|
||||
size: file.size,
|
||||
num_parts: file.num_parts,
|
||||
parts: partsRows,
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching file data:', error);
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
64
src/app/api/upload-part/route.ts
Normal file
64
src/app/api/upload-part/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import pool from '@/lib/db';
|
||||
|
||||
if (!process.env.DISCORD_WEBHOOK_URL) {
|
||||
throw new Error('Please define DISCORD_WEBHOOK_URL in .env.local');
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const fileId = formData.get('file_id') as string;
|
||||
const partIndex = formData.get('part_index') as string;
|
||||
const chunk = formData.get('encrypted_chunk') as Blob;
|
||||
|
||||
if (!fileId || !partIndex || !chunk) {
|
||||
return NextResponse.json({ error: 'Missing required form fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Forward the chunk to Discord
|
||||
const discordFormData = new FormData();
|
||||
discordFormData.append('file', chunk, `chunk-${partIndex}.bin`);
|
||||
// You can add content to the message if you want
|
||||
// discordFormData.append('content', `File chunk for ${fileId}, part ${partIndex}`);
|
||||
|
||||
const discordRes = await fetch(process.env.DISCORD_WEBHOOK_URL, {
|
||||
method: 'POST',
|
||||
body: discordFormData,
|
||||
});
|
||||
|
||||
if (!discordRes.ok) {
|
||||
const errorBody = await discordRes.json();
|
||||
console.error('Discord API Error:', errorBody);
|
||||
return NextResponse.json({ error: 'Failed to upload to Discord' }, { status: 500 });
|
||||
}
|
||||
|
||||
const discordData = await discordRes.json();
|
||||
const attachment = discordData.attachments[0];
|
||||
|
||||
if (!attachment) {
|
||||
return NextResponse.json({ error: 'No attachment found in Discord response' }, { status: 500 });
|
||||
}
|
||||
|
||||
const discordMessageId = discordData.id;
|
||||
const discordAttachmentUrl = attachment.url;
|
||||
const partSize = attachment.size;
|
||||
|
||||
// Save part metadata to the database
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.query(
|
||||
'INSERT INTO file_parts (file_id, part_index, discord_message_id, discord_attachment_url, size) VALUES (?, ?, ?, ?, ?)',
|
||||
[fileId, parseInt(partIndex), discordMessageId, discordAttachmentUrl, partSize]
|
||||
);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, part_index: partIndex });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error uploading part:', error);
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
138
src/app/download/[file_id]/page.tsx
Normal file
138
src/app/download/[file_id]/page.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
|
||||
// Helper to convert Base64 string back to ArrayBuffer
|
||||
function base64ToBuffer(base64: string) {
|
||||
const binary_string = window.atob(base64.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
const len = binary_string.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binary_string.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
export default function DownloadPage() {
|
||||
const [downloadState, setDownloadState] = useState<'idle' | 'downloading' | 'decrypting' | 'complete' | 'error'>('idle');
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [error, setError] = useState('');
|
||||
const [filename, setFilename] = useState('');
|
||||
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
const startDownload = async () => {
|
||||
const file_id = params.file_id as string;
|
||||
const token = searchParams.get('token');
|
||||
const keyString = window.location.hash.substring(1);
|
||||
|
||||
if (!file_id || !token || !keyString) {
|
||||
setError('Invalid download link. Information missing.');
|
||||
setDownloadState('error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Fetch file metadata
|
||||
const metaRes = await fetch(`/api/file/${file_id}?token=${token}`);
|
||||
if (!metaRes.ok) {
|
||||
const err = await metaRes.json();
|
||||
throw new Error(err.error || 'Failed to fetch file metadata.');
|
||||
}
|
||||
const metadata = await metaRes.json();
|
||||
setFilename(metadata.filename);
|
||||
|
||||
// 2. Download all parts in parallel
|
||||
setDownloadState('downloading');
|
||||
let downloadedCount = 0;
|
||||
const downloadPromises = metadata.parts.map((part: any) =>
|
||||
fetch(part.discord_attachment_url).then(res => res.arrayBuffer()).then(buffer => {
|
||||
downloadedCount++;
|
||||
setProgress(Math.round((downloadedCount / metadata.num_parts) * 50));
|
||||
return { index: part.part_index, data: buffer };
|
||||
})
|
||||
);
|
||||
const encryptedParts = await Promise.all(downloadPromises);
|
||||
encryptedParts.sort((a, b) => a.index - b.index);
|
||||
|
||||
// 3. Import key and decrypt parts
|
||||
setDownloadState('decrypting');
|
||||
const fileKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
base64ToBuffer(keyString),
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true,
|
||||
['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));
|
||||
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');
|
||||
a.href = url;
|
||||
a.download = metadata.filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
setDownloadState('complete');
|
||||
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'An unknown error occurred.');
|
||||
setDownloadState('error');
|
||||
}
|
||||
};
|
||||
|
||||
startDownload();
|
||||
}, [params, searchParams]);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center p-24 bg-gray-900 text-white">
|
||||
<div className="w-full max-w-md text-center">
|
||||
{downloadState !== 'error' && downloadState !== 'complete' && (
|
||||
<>
|
||||
<h1 className="text-3xl font-bold mb-4">Downloading File</h1>
|
||||
<p className="mb-2 truncate">{filename}</p>
|
||||
<div className="bg-gray-700 rounded-full h-6 w-full">
|
||||
<div className="bg-green-600 h-6 rounded-full transition-width duration-300 flex items-center justify-center" style={{ width: `${progress}%` }}>
|
||||
{progress}%
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-400">
|
||||
{downloadState === 'downloading' && 'Downloading parts...'}
|
||||
{downloadState === 'decrypting' && 'Decrypting and assembling file...'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{downloadState === 'complete' && (
|
||||
<>
|
||||
<h1 className="text-3xl font-bold mb-4 text-green-400">Download Started!</h1>
|
||||
<p>Your file <span className="font-mono bg-gray-800 px-1 rounded">{filename}</span> should be in your downloads folder.</p>
|
||||
<p className="mt-4 text-sm text-gray-500">You can now close this page.</p>
|
||||
</>
|
||||
)}
|
||||
{downloadState === 'error' && (
|
||||
<>
|
||||
<h1 className="text-3xl font-bold mb-4 text-red-500">Download Failed</h1>
|
||||
<p className="text-red-400 bg-red-900/50 p-3 rounded-md">{error}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
272
src/app/page.tsx
272
src/app/page.tsx
@@ -1,103 +1,179 @@
|
||||
import Image from "next/image";
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
const CHUNK_SIZE = 8 * 1024 * 1024; // 8 MB
|
||||
const PARALLEL_UPLOADS = 4;
|
||||
|
||||
// Helper to convert ArrayBuffer to Base64 string
|
||||
function bufferToBase64(buffer: ArrayBuffer) {
|
||||
let binary = '';
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const len = bytes.byteLength;
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
|
||||
src/app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [uploadState, setUploadState] = useState<'idle' | 'processing' | 'uploading' | 'complete'>('idle');
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [downloadUrl, setDownloadUrl] = useState('');
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (files && files.length > 0) {
|
||||
handleFileSelect(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const files = event.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
handleFileSelect(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (file: File) => {
|
||||
setSelectedFile(file);
|
||||
startUploadProcess(file).catch(console.error);
|
||||
}
|
||||
|
||||
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const startUploadProcess = async (file: File) => {
|
||||
if (!file) return;
|
||||
|
||||
setUploadState('processing');
|
||||
setProgress(0);
|
||||
setDownloadUrl('');
|
||||
|
||||
// 1. Generate encryption key
|
||||
const fileKey = await window.crypto.subtle.generateKey(
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
|
||||
// 2. Call create-file API
|
||||
const numParts = Math.ceil(file.size / CHUNK_SIZE);
|
||||
const createResponse = await fetch('/api/create-file', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filename: file.name, size: file.size, num_parts: numParts }),
|
||||
});
|
||||
const { file_id } = await createResponse.json();
|
||||
|
||||
// 3. Chunk and encrypt file
|
||||
setUploadState('uploading');
|
||||
const chunks: { index: number; data: Blob }[] = [];
|
||||
for (let i = 0; i < numParts; i++) {
|
||||
const start = i * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||||
const chunk = file.slice(start, end);
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||
const encryptedChunk = await window.crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: iv },
|
||||
fileKey,
|
||||
await chunk.arrayBuffer()
|
||||
);
|
||||
// Prepend IV to the encrypted data
|
||||
const combinedData = new Blob([iv, new Uint8Array(encryptedChunk)]);
|
||||
chunks.push({ index: i, data: combinedData });
|
||||
}
|
||||
|
||||
// 4. Upload parts with concurrency limit
|
||||
let uploadedCount = 0;
|
||||
const uploadChunk = async (chunk: { index: number; data: Blob }) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file_id', file_id);
|
||||
formData.append('part_index', chunk.index.toString());
|
||||
formData.append('encrypted_chunk', chunk.data);
|
||||
await fetch('/api/upload-part', { method: 'POST', body: formData });
|
||||
uploadedCount++;
|
||||
setProgress(Math.round((uploadedCount / numParts) * 100));
|
||||
};
|
||||
|
||||
const queue = [...chunks];
|
||||
const active = [];
|
||||
while (queue.length > 0 || active.length > 0) {
|
||||
while (active.length < PARALLEL_UPLOADS && queue.length > 0) {
|
||||
const task = queue.shift()!;
|
||||
const promise = uploadChunk(task).finally(() => {
|
||||
const index = active.indexOf(promise);
|
||||
if (index > -1) active.splice(index, 1);
|
||||
});
|
||||
active.push(promise);
|
||||
}
|
||||
await Promise.race(active);
|
||||
}
|
||||
|
||||
// 5. Complete upload
|
||||
const completeResponse = await fetch('/api/complete-upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file_id }),
|
||||
});
|
||||
const { download_token } = await completeResponse.json();
|
||||
|
||||
// 6. Generate and show download link
|
||||
const exportedKey = await window.crypto.subtle.exportKey('raw', fileKey);
|
||||
const keyString = bufferToBase64(exportedKey);
|
||||
setUploadState('complete');
|
||||
setDownloadUrl(`${window.location.origin}/download/${file_id}?token=${download_token}#${keyString}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center p-24 bg-gray-900 text-white">
|
||||
<div className="w-full max-w-3xl text-center">
|
||||
<h1 className="text-4xl font-bold mb-4">Discord Storage</h1>
|
||||
<p className="text-lg text-gray-400 mb-8">Upload large files, encrypted and stored on Discord. 7-day retention.</p>
|
||||
|
||||
{uploadState === 'idle' && (
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
className="bg-gray-800 border-2 border-dashed border-gray-600 rounded-lg p-12 cursor-pointer hover:border-gray-400 transition-colors"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
<input type="file" className="hidden" id="file-upload" onChange={handleFileChange} />
|
||||
<label htmlFor="file-upload" className="flex flex-col items-center cursor-pointer">
|
||||
<svg className="w-16 h-16 text-gray-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path></svg>
|
||||
<p className="text-xl font-semibold">Drag & drop your file here</p>
|
||||
<p className="text-gray-500">or click to select a file</p>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(uploadState === 'processing' || uploadState === 'uploading') && (
|
||||
<div className="mt-8 w-full">
|
||||
<p className="mb-2 truncate">{selectedFile?.name}</p>
|
||||
<div className="bg-gray-700 rounded-full h-4 w-full">
|
||||
<div className="bg-blue-600 h-4 rounded-full transition-width duration-300" style={{ width: `${progress}%` }}></div>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-400">{uploadState === 'processing' ? 'Processing file...' : `Uploading... ${progress}%`}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadState === 'complete' && (
|
||||
<div className="mt-8 w-full text-left bg-gray-800 rounded-lg p-4">
|
||||
<p className="text-lg font-semibold">Upload Complete!</p>
|
||||
<p className="text-sm text-gray-400 mb-2">Save this link to download your file. The link contains the decryption key.</p>
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={downloadUrl}
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
className="w-full p-2 bg-gray-900 rounded border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
17
src/lib/db.ts
Normal file
17
src/lib/db.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
if (!process.env.DB_HOST || !process.env.DB_USER || !process.env.DB_PASSWORD || !process.env.DB_DATABASE) {
|
||||
throw new Error('Please define all database environment variables in .env.local');
|
||||
}
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_DATABASE,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0
|
||||
});
|
||||
|
||||
export default pool;
|
||||
Reference in New Issue
Block a user