-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat/soundcloud-api: initial creation
- Loading branch information
Showing
3 changed files
with
235 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
import { NextResponse } from 'next/server'; | ||
import { headers } from 'next/headers'; | ||
import scdl from 'soundcloud-downloader'; | ||
import { SoundcloudInfo, TrackResponse } from './types'; | ||
import { | ||
streamToUrl, | ||
validateTrackInfo, | ||
sanitizeString, | ||
checkRateLimit, | ||
corsHeaders, | ||
validateSoundcloudUrl | ||
} from './utils'; | ||
|
||
// Main API handler | ||
export async function OPTIONS() { | ||
const headersList = await headers(); | ||
const origin = headersList.get('origin'); | ||
|
||
return new NextResponse(null, { | ||
status: 204, | ||
headers: corsHeaders(origin), | ||
}); | ||
} | ||
|
||
export async function POST(request: Request) { | ||
try { | ||
const headersList = await headers(); | ||
const origin = headersList.get('origin'); | ||
const ip = headersList.get('x-forwarded-for') || 'unknown'; | ||
|
||
|
||
if (!checkRateLimit(ip)) { | ||
return NextResponse.json( | ||
{ error: 'Rate limit exceeded. Please try again later.' }, | ||
{ status: 429, headers: corsHeaders(origin) } | ||
); | ||
} | ||
|
||
if (!process.env.SOUNDCLOUD_CLIENT_ID) { | ||
console.error('Soundcloud client ID not configured'); | ||
return NextResponse.json( | ||
{ error: 'Server configuration error' }, | ||
{ status: 500, headers: corsHeaders(origin) } | ||
); | ||
} | ||
|
||
// Request validation | ||
let body; | ||
try { | ||
body = await request.json(); | ||
} catch { | ||
return NextResponse.json( | ||
{ error: 'Invalid request body' }, | ||
{ status: 400, headers: corsHeaders(origin) } | ||
); | ||
} | ||
|
||
const { url } = body; | ||
|
||
if (!url || typeof url !== 'string' || !validateSoundcloudUrl(url)) { | ||
return NextResponse.json( | ||
{ error: 'Invalid Soundcloud URL format' }, | ||
{ status: 400, headers: corsHeaders(origin) } | ||
); | ||
} | ||
|
||
try { | ||
const info = await scdl.getInfo(url) as SoundcloudInfo; | ||
validateTrackInfo(info); | ||
|
||
const stream = await scdl.download(url); | ||
const audioUrl = await streamToUrl(stream); | ||
|
||
const response: TrackResponse = { | ||
streamUrl: audioUrl, | ||
title: sanitizeString(info.title), | ||
duration: info.duration, | ||
thumbnail: info.artwork_url || null, | ||
artist: sanitizeString(info.user.username), | ||
description: sanitizeString(info.description), | ||
genre: sanitizeString(info.genre) || 'Unknown', | ||
likes: info.likes_count || 0, | ||
plays: info.playback_count || 0 | ||
}; | ||
|
||
return NextResponse.json({ | ||
success: true, | ||
track: response, | ||
}, { | ||
headers: { | ||
...corsHeaders(origin), | ||
'Cache-Control': 'private, max-age=3600', | ||
'Content-Security-Policy': "default-src 'self'", | ||
} | ||
}); | ||
|
||
} catch (songError) { | ||
console.error('Soundcloud track error:', songError); | ||
const errorMessage = songError instanceof Error ? songError.message : 'Unknown error'; | ||
return NextResponse.json( | ||
{ error: `Could not fetch track: ${errorMessage}` }, | ||
{ status: 400, headers: corsHeaders(origin) } | ||
); | ||
} | ||
|
||
} catch (error) { | ||
console.error('Soundcloud API error:', error); | ||
return NextResponse.json( | ||
{ error: 'Internal server error' }, | ||
{ status: 500, headers: corsHeaders(origin) } | ||
); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
export interface SoundcloudInfo { | ||
title: string; | ||
duration: number; | ||
artwork_url?: string; | ||
user: { | ||
username: string; | ||
}; | ||
description?: string; | ||
genre?: string; | ||
likes_count?: number; | ||
playback_count?: number; | ||
} | ||
|
||
export interface TrackResponse { | ||
streamUrl: string; | ||
title: string; | ||
duration: number; | ||
thumbnail: string | null; | ||
artist: string; | ||
description: string; | ||
genre: string; | ||
likes: number; | ||
plays: number; | ||
} | ||
|
||
export interface RateLimit { | ||
count: number; | ||
timestamp: number; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
import { Readable } from 'stream'; | ||
import { SoundcloudInfo, RateLimit } from './types'; | ||
|
||
// Constants | ||
export const MAX_DURATION = 600; // 10 minutes in seconds | ||
export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB in bytes | ||
|
||
// Simple in-memory rate limiting | ||
export const rateLimits = new Map<string, RateLimit>(); | ||
|
||
export async function streamToUrl(stream: Readable): Promise<string> { | ||
const chunks: Buffer[] = []; | ||
let totalSize = 0; | ||
|
||
for await (const chunk of stream) { | ||
totalSize += chunk.length; | ||
if (totalSize > MAX_FILE_SIZE) { | ||
throw new Error('File size exceeds the 50MB limit'); | ||
} | ||
chunks.push(Buffer.from(chunk)); | ||
} | ||
|
||
const buffer = Buffer.concat(chunks); | ||
return `data:audio/mpeg;base64,${buffer.toString('base64')}`; | ||
} | ||
|
||
export function validateTrackInfo(info: SoundcloudInfo): void { | ||
if (!info.title?.trim()) { | ||
throw new Error('Invalid track: missing title'); | ||
} | ||
|
||
if (!info.duration || info.duration > MAX_DURATION * 1000) { | ||
throw new Error('Track duration exceeds 10 minutes limit'); | ||
} | ||
|
||
if (!info.user?.username?.trim()) { | ||
throw new Error('Invalid track: missing artist information'); | ||
} | ||
} | ||
|
||
export function sanitizeString(str: string | undefined | null): string { | ||
return (str || '').replace(/[<>]/g, '').trim(); | ||
} | ||
|
||
export function checkRateLimit(ip: string): boolean { | ||
const now = Date.now(); | ||
const windowMs = 60 * 1000; // 1 minute | ||
const maxRequests = 5; | ||
|
||
const current = rateLimits.get(ip) || { count: 0, timestamp: now }; | ||
|
||
if (now - current.timestamp > windowMs) { | ||
// Reset if window has passed | ||
current.count = 1; | ||
current.timestamp = now; | ||
} else if (current.count >= maxRequests) { | ||
return false; | ||
} else { | ||
current.count++; | ||
} | ||
|
||
rateLimits.set(ip, current); | ||
return true; | ||
} | ||
|
||
export function corsHeaders(origin: string | null): HeadersInit { | ||
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '').split(','); | ||
|
||
// In development, allow all origins | ||
if (process.env.NODE_ENV !== 'production') { | ||
return { | ||
'Access-Control-Allow-Origin': '*', | ||
'Access-Control-Allow-Methods': 'POST, OPTIONS', | ||
'Access-Control-Allow-Headers': 'Content-Type, x-api-key', | ||
}; | ||
} | ||
|
||
// In production, check against allowed origins | ||
if (origin && allowedOrigins.includes(origin)) { | ||
return { | ||
'Access-Control-Allow-Origin': origin, | ||
'Access-Control-Allow-Methods': 'POST, OPTIONS', | ||
'Access-Control-Allow-Headers': 'Content-Type, x-api-key', | ||
}; | ||
} | ||
|
||
return {}; | ||
} | ||
|
||
export function validateSoundcloudUrl(url: string): boolean { | ||
const soundcloudUrlPattern = /^https?:\/\/(www\.)?soundcloud\.com\/[\w-]+\/[\w-]+(\?.*)?$/; | ||
return soundcloudUrlPattern.test(url); | ||
} |