Discord Storage
+LockLoad
Upload large files, encrypted and stored on Discord. 7-day retention.
diff --git a/.gitea/workflows/demo.yaml b/.gitea/workflows/demo.yaml new file mode 100644 index 0000000..271578a --- /dev/null +++ b/.gitea/workflows/demo.yaml @@ -0,0 +1,19 @@ +name: Gitea Actions Demo +run-name: ${{ gitea.actor }} is testing out Gitea Actions +on: [push] + +jobs: + Explore-Gitea-Actions: + runs-on: ubuntu-latest + steps: + - run: echo "The job was automatically triggered by a ${{ gitea.event_name }} event." + - run: echo "This job is now running on a ${{ runner.os }} server hosted by Gitea!" + - run: echo "The name of your branch is ${{ gitea.ref }} and your repository is ${{ gitea.repository }}." + - name: Check out repository code + uses: actions/checkout@v3 + - run: echo "The ${{ gitea.repository }} repository has been cloned to the runner." + - run: echo "The workflow is now ready to test your code on the runner." + - name: List files in the repository + run: | + ls ${{ gitea.workspace }} + - run: echo "This job's status is ${{ job.status }}." \ No newline at end of file diff --git a/.gitea/workflows/vercel-preview.yaml b/.gitea/workflows/vercel-preview.yaml new file mode 100644 index 0000000..0fdec74 --- /dev/null +++ b/.gitea/workflows/vercel-preview.yaml @@ -0,0 +1,23 @@ +name: Vercel Preview Deployment +env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} +on: + push: + branches-ignore: + - main +jobs: + Deploy-Preview: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install pnpm + run: npm install -g pnpm + - name: Install Vercel CLI + run: npm install --global vercel@latest + - name: Pull Vercel Environment Information + run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }} + - name: Build Project Artifacts + run: vercel build --token=${{ secrets.VERCEL_TOKEN }} + - name: Deploy Project Artifacts to Vercel + run: vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }} \ No newline at end of file diff --git a/.gitea/workflows/vercel-production.yaml b/.gitea/workflows/vercel-production.yaml new file mode 100644 index 0000000..b4a5feb --- /dev/null +++ b/.gitea/workflows/vercel-production.yaml @@ -0,0 +1,23 @@ +name: Vercel Production Deployment +env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} +on: + push: + branches: + - main +jobs: + Deploy-Production: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install pnpm + run: npm install -g pnpm + - name: Install Vercel CLI + run: npm install --global vercel@latest + - name: Pull Vercel Environment Information + run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }} + - name: Build Project Artifacts + run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }} + - name: Deploy Project Artifacts to Vercel + run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }} \ No newline at end of file diff --git a/package.json b/package.json index c18bc6a..2bf3bb1 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "@vercel/analytics": "^1.5.0", "bcrypt": "^6.0.0", "mysql2": "^3.15.2", "next": "15.5.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23684ef..1127493 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@vercel/analytics': + specifier: ^1.5.0 + version: 1.5.0(next@15.5.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) bcrypt: specifier: ^6.0.0 version: 6.0.0 @@ -623,6 +626,32 @@ packages: cpu: [x64] os: [win32] + '@vercel/analytics@1.5.0': + resolution: {integrity: sha512-MYsBzfPki4gthY5HnYN7jgInhAZ7Ac1cYDoRWFomwGHWEX7odTEzbtg9kf/QSo7XEsEAqlQugA6gJ2WS2DEa3g==} + peerDependencies: + '@remix-run/react': ^2 + '@sveltejs/kit': ^1 || ^2 + next: '>= 13' + react: ^18 || ^19 || ^19.0.0-rc + svelte: '>= 4' + vue: ^3 + vue-router: ^4 + peerDependenciesMeta: + '@remix-run/react': + optional: true + '@sveltejs/kit': + optional: true + next: + optional: true + react: + optional: true + svelte: + optional: true + vue: + optional: true + vue-router: + optional: true + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2360,6 +2389,11 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@vercel/analytics@1.5.0(next@15.5.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': + optionalDependencies: + next: 15.5.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 diff --git a/public/file.svg b/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/window.svg b/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/app/api/complete-upload/route.ts b/src/app/api/complete-upload/route.ts index c0f6e5a..97ad905 100644 --- a/src/app/api/complete-upload/route.ts +++ b/src/app/api/complete-upload/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import pool from '@/lib/db'; import bcrypt from 'bcrypt'; import crypto from 'crypto'; +import { ResultSetHeader } from 'mysql2/promise'; const SALT_ROUNDS = 10; @@ -28,7 +29,7 @@ export async function POST(request: Request) { const connection = await pool.getConnection(); try { - const [result]: any = await connection.query( + const [result]: [ResultSetHeader, unknown] = await connection.query( 'UPDATE files SET token_hash = ?, expires_at = ? WHERE id = ?', [tokenHash, expiresAt, file_id] ); diff --git a/src/app/api/cron/cleanup/route.ts b/src/app/api/cron/cleanup/route.ts index fcfd7af..6921367 100644 --- a/src/app/api/cron/cleanup/route.ts +++ b/src/app/api/cron/cleanup/route.ts @@ -1,5 +1,14 @@ import { NextResponse } from 'next/server'; import pool from '@/lib/db'; +import { RowDataPacket } from 'mysql2/promise'; + +interface FileId { + id: string; +} + +interface FilePart { + discord_message_id: string | null; +} if (!process.env.DISCORD_BOT_TOKEN || !process.env.DISCORD_CHANNEL_ID || !process.env.CRON_SECRET) { throw new Error('Discord or Cron secret environment variables are not configured'); @@ -21,19 +30,19 @@ export async function POST(request: Request) { const staleTime = new Date(); staleTime.setHours(staleTime.getHours() - 24); // Older than 24 hours - const [incompleteFiles]: any[] = await connection.query( + const [incompleteFiles]: [RowDataPacket[], unknown] = 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) { + if (incompleteFiles[0].length > 0) { + for (const file of incompleteFiles[0] as FileId[]) { const file_id = file.id; - const [parts]: any[] = await connection.query('SELECT discord_message_id FROM file_parts WHERE file_id = ?', [file_id]); + const [parts]: [RowDataPacket[], unknown] = await connection.query('SELECT discord_message_id FROM file_parts WHERE file_id = ?', [file_id]); - if (parts.length > 0) { - for (const part of parts) { + if (parts[0].length > 0) { + for (const part of parts[0] as FilePart[]) { if (!part.discord_message_id) continue; const deleteUrl = `https://discord.com/api/v10/channels/${process.env.DISCORD_CHANNEL_ID}/messages/${part.discord_message_id}`; try { @@ -58,21 +67,21 @@ export async function POST(request: Request) { } // --- 2. Cleanup for EXPIRED files --- - const [expiredFiles]: any[] = await connection.query( + const [expiredFiles]: [RowDataPacket[], unknown] = await connection.query( 'SELECT id FROM files WHERE expires_at <= NOW() AND deleted = 0' ); - if (expiredFiles.length > 0) { - for (const file of expiredFiles) { + if (expiredFiles[0].length > 0) { + for (const file of expiredFiles[0] as FileId[]) { const file_id = file.id; - const [partsRows]: any[] = await connection.query( + const [partsRows]: [RowDataPacket[], unknown] = await connection.query( 'SELECT discord_message_id FROM file_parts WHERE file_id = ?', [file_id] ); - if (partsRows.length > 0) { - for (const part of partsRows) { + if (partsRows[0].length > 0) { + for (const part of partsRows[0] as FilePart[]) { if (!part.discord_message_id) continue; const deleteUrl = `https://discord.com/api/v10/channels/${process.env.DISCORD_CHANNEL_ID}/messages/${part.discord_message_id}`; try { diff --git a/src/app/api/delete/route.ts b/src/app/api/delete/route.ts index aa12e6b..9acda5f 100644 --- a/src/app/api/delete/route.ts +++ b/src/app/api/delete/route.ts @@ -1,6 +1,15 @@ import { NextResponse } from 'next/server'; import pool from '@/lib/db'; import bcrypt from 'bcrypt'; +import { RowDataPacket } from 'mysql2/promise'; + +interface FileData extends RowDataPacket { + token_hash: string; +} + +interface FilePart extends RowDataPacket { + discord_message_id: string | null; +} if (!process.env.DISCORD_BOT_TOKEN || !process.env.DISCORD_CHANNEL_ID) { throw new Error('Discord bot token or channel ID is not configured'); @@ -17,8 +26,8 @@ export async function POST(request: Request) { 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]; + const [fileRows]: [RowDataPacket[], unknown] = await connection.query('SELECT * FROM files WHERE id = ? AND deleted = 0', [file_id]); + const file = fileRows[0] as FileData; if (!file) { return NextResponse.json({ error: 'File not found' }, { status: 404 }); @@ -30,13 +39,13 @@ export async function POST(request: Request) { } // 2. Fetch all message IDs for the file parts - const [partsRows]: any[] = await connection.query( + const [partsRows]: [RowDataPacket[], unknown] = await connection.query( 'SELECT discord_message_id FROM file_parts WHERE file_id = ?', [file_id] ); // 3. Delete each message from Discord sequentially to avoid rate limits - for (const part of partsRows) { + for (const part of partsRows[0] as FilePart[]) { if (!part.discord_message_id) continue; const deleteUrl = `https://discord.com/api/v10/channels/${process.env.DISCORD_CHANNEL_ID}/messages/${part.discord_message_id}`; diff --git a/src/app/api/download-part/[file_id]/[part_index]/route.ts b/src/app/api/download-part/[file_id]/[part_index]/route.ts index 5f334e0..ab54e54 100644 --- a/src/app/api/download-part/[file_id]/[part_index]/route.ts +++ b/src/app/api/download-part/[file_id]/[part_index]/route.ts @@ -1,15 +1,25 @@ -import { NextResponse } from 'next/server'; +import { NextResponse, NextRequest } from 'next/server'; import pool from '@/lib/db'; +import { RowDataPacket } from 'mysql2/promise'; + +interface FilePart extends RowDataPacket { + discord_message_id: string | null; +} + +interface RouteContext { + params: Promise<{ + file_id: string; + part_index: string; + }>; +} if (!process.env.DISCORD_BOT_TOKEN || !process.env.DISCORD_CHANNEL_ID) { throw new Error('Discord bot token or channel ID is not configured'); } -export async function GET(request: Request, context: any) { +export async function GET(request: NextRequest, context: RouteContext) { try { - const params = await context.params; - const file_id = params.file_id as string; - const part_index = params.part_index as string; + const { file_id, part_index } = await context.params; // NOTE: In a real-world scenario, you MUST validate if the user has permission to download this part. // This would involve checking the download_token against the hash in the `files` table. @@ -19,11 +29,11 @@ export async function GET(request: Request, context: any) { const connection = await pool.getConnection(); let part; try { - const [partsRows]: any[] = await connection.query( + const [partsRows]: [RowDataPacket[], unknown] = await connection.query( 'SELECT discord_message_id FROM file_parts WHERE file_id = ? AND part_index = ?', [file_id, part_index] ); - part = partsRows[0]; + part = partsRows[0] as FilePart; } finally { connection.release(); } diff --git a/src/app/api/file/[file_id]/route.ts b/src/app/api/file/[file_id]/route.ts index b99337d..e68e05b 100644 --- a/src/app/api/file/[file_id]/route.ts +++ b/src/app/api/file/[file_id]/route.ts @@ -1,11 +1,30 @@ -import { NextResponse } from 'next/server'; +import { NextResponse, NextRequest } from 'next/server'; import pool from '@/lib/db'; import bcrypt from 'bcrypt'; +import { RowDataPacket } from 'mysql2/promise'; -export async function GET(request: Request, context: any) { +interface FileData extends RowDataPacket { + filename: string; + size: number; + num_parts: number; + expires_at: Date | null; + token_hash: string; +} + +interface FilePartMetadata extends RowDataPacket { + part_index: number; + size: number; +} + +interface RouteContext { + params: Promise<{ + file_id: string; + }>; +} + +export async function GET(request: NextRequest, context: RouteContext) { try { - const params = await context.params; - const file_id = params.file_id as string; + const { file_id } = await context.params; const { searchParams } = new URL(request.url); const token = searchParams.get('token'); @@ -16,8 +35,8 @@ export async function GET(request: Request, context: any) { 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]; + const [fileRows]: [RowDataPacket[], unknown] = await connection.query('SELECT * FROM files WHERE id = ? AND deleted = 0', [file_id]); + const file = fileRows[0] as FileData; if (!file) { return NextResponse.json({ error: 'File not found' }, { status: 404 }); @@ -35,7 +54,7 @@ export async function GET(request: Request, context: any) { } // 4. Fetch file parts - const [partsRows]: any[] = await connection.query( + const [partsRows]: [RowDataPacket[], unknown] = await connection.query( 'SELECT part_index, size FROM file_parts WHERE file_id = ? ORDER BY part_index ASC', [file_id] ); @@ -45,7 +64,7 @@ export async function GET(request: Request, context: any) { filename: file.filename, size: file.size, num_parts: file.num_parts, - parts: partsRows, + parts: partsRows[0] as FilePartMetadata[], }; return NextResponse.json(response); diff --git a/src/app/download/[file_id]/page.tsx b/src/app/download/[file_id]/page.tsx index 4f2e7f1..8dde86d 100644 --- a/src/app/download/[file_id]/page.tsx +++ b/src/app/download/[file_id]/page.tsx @@ -3,6 +3,11 @@ import { useEffect, useState, useRef } from 'react'; import { useParams, useSearchParams } from 'next/navigation'; +interface MetadataPart { + part_index: number; + size: number; +} + // Helper to convert Base64 string back to ArrayBuffer function base64ToBuffer(base64: string) { const binary_string = window.atob(base64.replace(/-/g, '+').replace(/_/g, '/')); @@ -106,7 +111,7 @@ export default function DownloadPage() { setDownloadState('downloading'); const encryptedParts = new Array(metadata.num_parts); - const downloadPromises = metadata.parts.map(async (part: any) => { + const downloadPromises = metadata.parts.map(async (part: MetadataPart) => { 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 }; @@ -127,7 +132,7 @@ export default function DownloadPage() { ['decrypt'] ); - const decryptPromises = encryptedParts.map(async (part) => { + const decryptPromises = encryptedParts.map(async (part: { index: number; data: ArrayBuffer }) => { 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); @@ -148,9 +153,8 @@ export default function DownloadPage() { setDownloadState('complete'); - } catch (e: any) { - setError(e.message || 'An unknown error occurred.'); - setDownloadState('error'); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'An unknown error occurred.'); setDownloadState('error'); } }; @@ -181,9 +185,9 @@ export default function DownloadPage() { setDeleteState('deleted'); - } catch (e: any) { + } catch (e: unknown) { setDeleteState('error'); - setDeleteError(e.message || 'An unknown error occurred during deletion.'); + setDeleteError(e instanceof Error ? e.message : 'An unknown error occurred during deletion.'); } }; @@ -243,10 +247,10 @@ export default function DownloadPage() { {deleteState === 'idle' && ( )} {deleteState === 'deleting' && ( diff --git a/src/app/favicon.ico b/src/app/favicon.ico index 718d6fe..6e111ce 100644 Binary files a/src/app/favicon.ico and b/src/app/favicon.ico differ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..05b767d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import { Analytics } from "@vercel/analytics/next" const geistSans = Geist({ variable: "--font-geist-sans", @@ -13,8 +14,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "LockLoad", + description: "Securely upload and share files.", }; export default function RootLayout({ @@ -24,11 +25,10 @@ export default function RootLayout({ }>) { return ( -
+ {children} +Upload large files, encrypted and stored on Discord. 7-day retention.