Skip to content

Commit

Permalink
feat/soundcloud-api: initial creation
Browse files Browse the repository at this point in the history
  • Loading branch information
byigitt committed Nov 7, 2024
1 parent 96417ab commit a6c1d36
Show file tree
Hide file tree
Showing 3 changed files with 235 additions and 0 deletions.
113 changes: 113 additions & 0 deletions src/app/api/soundcloud/route.ts
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) }
);
}
}
29 changes: 29 additions & 0 deletions src/app/api/soundcloud/types.ts
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;
}
93 changes: 93 additions & 0 deletions src/app/api/soundcloud/utils.ts
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);
}

0 comments on commit a6c1d36

Please sign in to comment.