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:
2025-10-16 09:16:06 +02:00
parent 762f42a20e
commit 2f07f75088
12 changed files with 4573 additions and 103 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

22
schema.sql Normal file
View 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)
);

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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>
);
}

View File

@@ -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
View 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;