Initial commit
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* Check if a URL is from Reddit.
|
||||
*/
|
||||
function isRedditUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.hostname.endsWith('reddit.com') || parsed.hostname === 'redd.it';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch preview for Reddit URLs using their oEmbed API.
|
||||
*/
|
||||
async function fetchRedditPreview(url: string): Promise<{
|
||||
url: string;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
image: string | null;
|
||||
} | null> {
|
||||
try {
|
||||
const oembedUrl = `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}`;
|
||||
|
||||
const response = await fetch(oembedUrl, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Extract title - try title field first, then parse from HTML
|
||||
let title = data.title || null;
|
||||
if (!title && data.html) {
|
||||
const titleMatch = data.html.match(/href="[^"]+">([^<]+)<\/a>/);
|
||||
if (titleMatch && titleMatch[1] && titleMatch[1] !== 'Comment') {
|
||||
title = titleMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Build description from subreddit info
|
||||
let description = null;
|
||||
if (data.author_name) {
|
||||
description = `Posted by ${data.author_name}`;
|
||||
} else if (data.html) {
|
||||
const subredditMatch = data.html.match(/r\/([a-zA-Z0-9_]+)/);
|
||||
if (subredditMatch) {
|
||||
description = `r/${subredditMatch[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
image: data.thumbnail_url || null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
let url = searchParams.get('url');
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: 'No URL provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Normalize URL
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = 'https://' + url;
|
||||
}
|
||||
|
||||
// Use Reddit-specific handler
|
||||
if (isRedditUrl(url)) {
|
||||
const preview = await fetchRedditPreview(url);
|
||||
if (preview) {
|
||||
return NextResponse.json(preview);
|
||||
}
|
||||
// Fall back to URL-only response if oEmbed fails
|
||||
return NextResponse.json({
|
||||
url,
|
||||
title: 'Reddit',
|
||||
description: null,
|
||||
image: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Generic OG tag scraping for other sites
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; SynapsisBot/1.0; +https://synapsis.social)',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
},
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
} catch (fetchError) {
|
||||
console.warn(`Fetch failed for URL: ${url}`, fetchError);
|
||||
return NextResponse.json({ error: 'Could not reach the URL' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: `URL returned status ${response.status}` }, { status: 404 });
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
const getMeta = (property: string) => {
|
||||
const regex = new RegExp(`<meta[^>]+(?:property|name)=["'](?:og:)?${property}["'][^>]+content=["']([^"']+)["']`, 'i');
|
||||
const match = html.match(regex);
|
||||
if (match) return match[1];
|
||||
|
||||
const regexRev = new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["'](?:og:)?${property}["']`, 'i');
|
||||
const matchRev = html.match(regexRev);
|
||||
return matchRev ? matchRev[1] : null;
|
||||
};
|
||||
|
||||
const title = getMeta('title') || html.match(/<title>([^<]+)<\/title>/i)?.[1];
|
||||
const description = getMeta('description');
|
||||
const image = getMeta('image');
|
||||
|
||||
return NextResponse.json({
|
||||
url,
|
||||
title: title?.trim() || url,
|
||||
description: description?.trim() || null,
|
||||
image: image?.trim() || null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Link preview error:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch preview' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, media } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB for images
|
||||
const MAX_VIDEO_SIZE = 100 * 1024 * 1024; // 100MB for videos
|
||||
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime'];
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const altText = (formData.get('alt') as string | null) || null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const isImage = ALLOWED_IMAGE_TYPES.includes(file.type);
|
||||
const isVideo = ALLOWED_VIDEO_TYPES.includes(file.type);
|
||||
|
||||
if (!isImage && !isVideo) {
|
||||
return NextResponse.json({
|
||||
error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP, MP4, WebM, MOV'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate file size based on type
|
||||
const maxSize = isVideo ? MAX_VIDEO_SIZE : MAX_IMAGE_SIZE;
|
||||
if (file.size > maxSize) {
|
||||
return NextResponse.json({
|
||||
error: `File too large. Maximum size: ${isVideo ? '100MB' : '10MB'}`
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
// Sanitize filename to be safe for S3 keys
|
||||
const filename = `${uuidv4()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '')}`;
|
||||
|
||||
// S3 Configuration
|
||||
const s3 = new S3Client({
|
||||
region: process.env.STORAGE_REGION || 'us-east-1',
|
||||
endpoint: process.env.STORAGE_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: process.env.STORAGE_ACCESS_KEY || '',
|
||||
secretAccessKey: process.env.STORAGE_SECRET_KEY || '',
|
||||
},
|
||||
forcePathStyle: true, // Needed for many S3-compatible providers
|
||||
});
|
||||
|
||||
const bucket = process.env.STORAGE_BUCKET || 'synapsis';
|
||||
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: filename,
|
||||
Body: buffer,
|
||||
ContentType: file.type,
|
||||
ACL: 'public-read',
|
||||
}));
|
||||
|
||||
// Construct Public URL
|
||||
let url = '';
|
||||
if (process.env.STORAGE_PUBLIC_BASE_URL) {
|
||||
url = `${process.env.STORAGE_PUBLIC_BASE_URL}/${filename}`;
|
||||
} else if (process.env.STORAGE_ENDPOINT) {
|
||||
url = `${process.env.STORAGE_ENDPOINT}/${bucket}/${filename}`;
|
||||
} else {
|
||||
return NextResponse.json({ error: 'Storage not configured - missing STORAGE_PUBLIC_BASE_URL or STORAGE_ENDPOINT' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Store media record
|
||||
if (db) {
|
||||
const [mediaRecord] = await db.insert(media).values({
|
||||
userId: user.id,
|
||||
postId: null,
|
||||
url,
|
||||
altText,
|
||||
mimeType: file.type,
|
||||
width: 0, // TODO: Get actual dimensions
|
||||
height: 0,
|
||||
}).returning();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
media: mediaRecord,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
url,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
console.error('Upload error:', error);
|
||||
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user