feat: add API routes (POST /downloads, GET /downloads/[uuid], GET /download/[token])

This commit is contained in:
2026-08-10 14:40:44 +02:00
parent ab9235fe57
commit f21089cf76
6 changed files with 275 additions and 0 deletions
@@ -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')
})
})
+42
View File
@@ -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<typeof statSync>
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',
},
})
}
@@ -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()
})
})
+39
View File
@@ -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,
})
}
@@ -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',
}),
})
})
})
+39
View File
@@ -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 })
}