feat: add API routes (POST /downloads, GET /downloads/[uuid], GET /download/[token])
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
Reference in New Issue
Block a user