From f21089cf765e8331934fcb7a856312a8cf2ebf8d Mon Sep 17 00:00:00 2001 From: Anthony G <1@anthony.sh> Date: Mon, 10 Aug 2026 14:40:44 +0200 Subject: [PATCH] feat: add API routes (POST /downloads, GET /downloads/[uuid], GET /download/[token]) --- .../download/[token]/__tests__/get.test.ts | 32 ++++++++++ src/app/api/download/[token]/route.ts | 42 +++++++++++++ .../downloads/[uuid]/__tests__/get.test.ts | 62 +++++++++++++++++++ src/app/api/downloads/[uuid]/route.ts | 39 ++++++++++++ src/app/api/downloads/__tests__/post.test.ts | 61 ++++++++++++++++++ src/app/api/downloads/route.ts | 39 ++++++++++++ 6 files changed, 275 insertions(+) create mode 100644 src/app/api/download/[token]/__tests__/get.test.ts create mode 100644 src/app/api/download/[token]/route.ts create mode 100644 src/app/api/downloads/[uuid]/__tests__/get.test.ts create mode 100644 src/app/api/downloads/[uuid]/route.ts create mode 100644 src/app/api/downloads/__tests__/post.test.ts create mode 100644 src/app/api/downloads/route.ts diff --git a/src/app/api/download/[token]/__tests__/get.test.ts b/src/app/api/download/[token]/__tests__/get.test.ts new file mode 100644 index 0000000..6afd005 --- /dev/null +++ b/src/app/api/download/[token]/__tests__/get.test.ts @@ -0,0 +1,32 @@ +import { GET } from '../route' +import * as tokenLib from '@/lib/token' +import { prisma } from '@/lib/prisma' + +jest.mock('@/lib/token') +jest.mock('@/lib/prisma', () => ({ + prisma: { downloadToken: { update: jest.fn() } }, +})) + +const mockValidate = tokenLib.validateToken as jest.Mock + +function req(token: string) { + return new Request(`http://localhost/api/download/${token}`) +} + +describe('GET /api/download/[token]', () => { + it('returns 404 for invalid or expired token', async () => { + mockValidate.mockResolvedValue(null) + const res = await GET(req('bad'), { params: Promise.resolve({ token: 'bad' }) }) + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error).toBe('Invalid or expired token') + }) + + it('returns 404 when file does not exist on disk', async () => { + mockValidate.mockResolvedValue({ downloadId: 'dl-1', filePath: '/nonexistent/path/abc.mp4' }) + const res = await GET(req('ok-tok'), { params: Promise.resolve({ token: 'ok-tok' }) }) + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error).toBe('File not found on server') + }) +}) diff --git a/src/app/api/download/[token]/route.ts b/src/app/api/download/[token]/route.ts new file mode 100644 index 0000000..8064df0 --- /dev/null +++ b/src/app/api/download/[token]/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server' +import { createReadStream, statSync } from 'fs' +import path from 'path' +import { validateToken } from '@/lib/token' +import { prisma } from '@/lib/prisma' + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ token: string }> } +) { + const { token } = await params + const result = await validateToken(token) + + if (!result) { + return NextResponse.json({ error: 'Invalid or expired token' }, { status: 404 }) + } + + const { filePath } = result + + let stat: ReturnType + try { + stat = statSync(filePath) + } catch { + return NextResponse.json({ error: 'File not found on server' }, { status: 404 }) + } + + await prisma.downloadToken.update({ + where: { token }, + data: { usedAt: new Date() }, + }) + + const fileName = path.basename(filePath) + const stream = createReadStream(filePath) + + return new NextResponse(stream as unknown as ReadableStream, { + headers: { + 'Content-Disposition': `attachment; filename="${fileName}"`, + 'Content-Length': String(stat.size), + 'Content-Type': 'application/octet-stream', + }, + }) +} diff --git a/src/app/api/downloads/[uuid]/__tests__/get.test.ts b/src/app/api/downloads/[uuid]/__tests__/get.test.ts new file mode 100644 index 0000000..ddc30dc --- /dev/null +++ b/src/app/api/downloads/[uuid]/__tests__/get.test.ts @@ -0,0 +1,62 @@ +import { GET } from '../route' +import { prisma } from '@/lib/prisma' + +jest.mock('@/lib/prisma', () => ({ + prisma: { download: { findUnique: jest.fn() } }, +})) + +const mockFindUnique = prisma.download.findUnique as jest.Mock + +function req(uuid: string) { + return new Request(`http://localhost/api/downloads/${uuid}`) +} + +const baseDownload = { + uuid: 'abc', + status: 'DONE', + format: 'mp4', + quality: 'best', + subtitles: false, + fileName: 'video.mp4', + fileSize: BigInt(1_048_576), + errorMsg: null, + submittedAt: new Date('2026-01-01'), + completedAt: new Date('2026-01-01'), +} + +describe('GET /api/downloads/[uuid]', () => { + it('returns 404 when not found', async () => { + mockFindUnique.mockResolvedValue(null) + const res = await GET(req('missing'), { params: Promise.resolve({ uuid: 'missing' }) }) + expect(res.status).toBe(404) + }) + + it('returns 200 with download fields', async () => { + mockFindUnique.mockResolvedValue({ ...baseDownload, tokens: [] }) + const res = await GET(req('abc'), { params: Promise.resolve({ uuid: 'abc' }) }) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.uuid).toBe('abc') + expect(body.fileSize).toBe('1048576') + }) + + it('returns downloadToken when status is DONE and token is valid', async () => { + mockFindUnique.mockResolvedValue({ + ...baseDownload, + tokens: [{ token: 'valid-tok', expiresAt: new Date(Date.now() + 3_600_000) }], + }) + const res = await GET(req('abc'), { params: Promise.resolve({ uuid: 'abc' }) }) + const body = await res.json() + expect(body.downloadToken).toBe('valid-tok') + }) + + it('returns null downloadToken when token is expired', async () => { + mockFindUnique.mockResolvedValue({ + ...baseDownload, + tokens: [{ token: 'expired-tok', expiresAt: new Date(Date.now() - 1000) }], + }) + const res = await GET(req('abc'), { params: Promise.resolve({ uuid: 'abc' }) }) + const body = await res.json() + expect(body.downloadToken).toBeNull() + }) +}) diff --git a/src/app/api/downloads/[uuid]/route.ts b/src/app/api/downloads/[uuid]/route.ts new file mode 100644 index 0000000..59bf7d4 --- /dev/null +++ b/src/app/api/downloads/[uuid]/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ uuid: string }> } +) { + const { uuid } = await params + + const download = await prisma.download.findUnique({ + where: { uuid }, + include: { + tokens: { orderBy: { createdAt: 'desc' }, take: 1 }, + }, + }) + + if (!download) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + const latestToken = download.tokens[0] + const validToken = + latestToken && latestToken.expiresAt > new Date() ? latestToken : null + + return NextResponse.json({ + uuid: download.uuid, + status: download.status, + format: download.format, + quality: download.quality, + subtitles: download.subtitles, + fileName: download.fileName, + fileSize: download.fileSize?.toString() ?? null, + errorMsg: download.errorMsg, + submittedAt: download.submittedAt, + completedAt: download.completedAt, + downloadToken: download.status === 'DONE' ? (validToken?.token ?? null) : null, + tokenExpiresAt: download.status === 'DONE' ? (validToken?.expiresAt ?? null) : null, + }) +} diff --git a/src/app/api/downloads/__tests__/post.test.ts b/src/app/api/downloads/__tests__/post.test.ts new file mode 100644 index 0000000..3035822 --- /dev/null +++ b/src/app/api/downloads/__tests__/post.test.ts @@ -0,0 +1,61 @@ +import { POST } from '../route' +import { prisma } from '@/lib/prisma' +import * as rateLimit from '@/lib/rate-limit' + +jest.mock('@/lib/prisma', () => ({ + prisma: { download: { create: jest.fn() } }, +})) +jest.mock('@/lib/rate-limit') + +const mockCreate = prisma.download.create as jest.Mock +const mockIsRateLimited = rateLimit.isRateLimited as jest.Mock + +function req(body: object, ip = '1.2.3.4') { + return new Request('http://localhost/api/downloads', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-forwarded-for': ip }, + body: JSON.stringify(body), + }) +} + +describe('POST /api/downloads', () => { + beforeEach(() => { + mockIsRateLimited.mockReturnValue(false) + mockCreate.mockResolvedValue({ uuid: 'test-uuid' }) + }) + + it('returns 429 when rate limited', async () => { + mockIsRateLimited.mockReturnValue(true) + const res = await POST(req({ url: 'https://y.com', format: 'mp4', quality: 'best', subtitles: false })) + expect(res.status).toBe(429) + }) + + it('returns 400 when url is missing', async () => { + const res = await POST(req({ format: 'mp4', quality: 'best', subtitles: false })) + expect(res.status).toBe(400) + }) + + it('returns 400 when format is missing', async () => { + const res = await POST(req({ url: 'https://y.com', quality: 'best', subtitles: false })) + expect(res.status).toBe(400) + }) + + it('returns 201 with uuid on success', async () => { + const res = await POST(req({ url: 'https://youtube.com/watch?v=abc', format: 'mp4', quality: 'best', subtitles: false })) + expect(res.status).toBe(201) + expect(await res.json()).toEqual({ uuid: 'test-uuid' }) + }) + + it('passes correct fields to prisma', async () => { + await POST(req({ url: 'https://y.com/watch?v=x', format: 'mp3', quality: '720p', subtitles: true }, '9.9.9.9')) + expect(mockCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + url: 'https://y.com/watch?v=x', + format: 'mp3', + quality: '720p', + subtitles: true, + ipAddress: '9.9.9.9', + }), + }) + }) +}) diff --git a/src/app/api/downloads/route.ts b/src/app/api/downloads/route.ts new file mode 100644 index 0000000..5088dcb --- /dev/null +++ b/src/app/api/downloads/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { isRateLimited } from '@/lib/rate-limit' + +export async function POST(req: NextRequest) { + const ip = + req.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? '0.0.0.0' + + if (isRateLimited(ip)) { + return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 }) + } + + const body = await req.json().catch(() => null) + + if ( + !body?.url || + !body?.format || + !body?.quality || + body?.subtitles === undefined + ) { + return NextResponse.json( + { error: 'Missing required fields: url, format, quality, subtitles' }, + { status: 400 } + ) + } + + const download = await prisma.download.create({ + data: { + url: String(body.url), + format: String(body.format), + quality: String(body.quality), + subtitles: Boolean(body.subtitles), + extraArgs: body.extraArgs ?? null, + ipAddress: ip, + }, + }) + + return NextResponse.json({ uuid: download.uuid }, { status: 201 }) +}