feat: user-owned S3-compatible storage with credential verification

This commit is contained in:
Christomatt
2026-02-01 02:51:52 +01:00
parent a8252a1809
commit f55dec9a60
38 changed files with 1845 additions and 902 deletions
+40 -38
View File
@@ -1,7 +1,7 @@
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 { uploadToUserStorage } from '@/lib/storage/s3';
import { v4 as uuidv4 } from 'uuid';
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB for images
@@ -16,6 +16,7 @@ export async function POST(req: NextRequest) {
const formData = await req.formData();
const file = formData.get('file') as File | null;
const altText = (formData.get('alt') as string | null) || null;
const password = formData.get('password') as string | null;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
@@ -39,47 +40,43 @@ export async function POST(req: NextRequest) {
}, { 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 });
// Check if user has S3 storage configured
if (!user.storageProvider || !user.storageAccessKeyEncrypted || !user.storageSecretKeyEncrypted) {
return NextResponse.json({
error: 'Storage not configured. Please set up S3-compatible storage in your settings.'
}, { status: 400 });
}
// Store media record
// Require password to decrypt storage credentials
if (!password) {
return NextResponse.json({
error: 'Password required to upload media. Your storage credentials are encrypted and need your password to decrypt.'
}, { status: 401 });
}
const buffer = Buffer.from(await file.arrayBuffer());
const filename = `${uuidv4()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '')}`;
// Upload to user's own S3-compatible storage
const uploadResult = await uploadToUserStorage(
buffer,
filename,
file.type,
user.storageProvider as any,
user.storageEndpoint,
user.storageRegion || 'us-east-1',
user.storageBucket || '',
user.storageAccessKeyEncrypted,
user.storageSecretKeyEncrypted,
password
);
// Store media record with S3 URL
if (db) {
const [mediaRecord] = await db.insert(media).values({
userId: user.id,
postId: null,
url,
url: uploadResult.url,
altText,
mimeType: file.type,
width: 0, // TODO: Get actual dimensions
@@ -89,19 +86,24 @@ export async function POST(req: NextRequest) {
return NextResponse.json({
success: true,
media: mediaRecord,
url,
url: uploadResult.url,
key: uploadResult.key,
});
}
return NextResponse.json({
success: true,
url,
url: uploadResult.url,
key: uploadResult.key,
});
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
if (error instanceof Error && error.message.includes('Storage')) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
console.error('Upload error:', error);
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
}