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:
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user