// src/pages/api/upload/media.ts import type { APIRoute } from 'astro'; import { getSessionFromRequest, verifySession, withAPIAuth, createAuthErrorResponse } from '../../../utils/auth.js'; import { NextcloudUploader, isNextcloudConfigured } from '../../../utils/nextcloud.js'; import { promises as fs } from 'fs'; import path from 'path'; import crypto from 'crypto'; export const prerender = false; interface UploadResult { success: boolean; url?: string; filename?: string; size?: number; error?: string; storage?: 'nextcloud' | 'local'; } // Configuration const UPLOAD_CONFIG = { maxFileSize: 50 * 1024 * 1024, // 50MB allowedTypes: new Set([ // Images 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', // Videos 'video/mp4', 'video/webm', 'video/ogg', 'video/avi', 'video/mov', // Documents 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'text/plain', 'text/csv', 'application/json' ]), localUploadPath: process.env.LOCAL_UPLOAD_PATH || './public/uploads', publicBaseUrl: process.env.PUBLIC_BASE_URL || 'http://localhost:4321' }; // Rate limiting for uploads const uploadRateLimit = new Map(); const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour const RATE_LIMIT_MAX = 100; // Max 100 uploads per hour per user function checkUploadRateLimit(userEmail: string): boolean { const now = Date.now(); const userLimit = uploadRateLimit.get(userEmail); if (!userLimit || now > userLimit.resetTime) { uploadRateLimit.set(userEmail, { count: 1, resetTime: now + RATE_LIMIT_WINDOW }); return true; } if (userLimit.count >= RATE_LIMIT_MAX) { return false; } userLimit.count++; return true; } function validateFile(file: File): { valid: boolean; error?: string } { // Check file size if (file.size > UPLOAD_CONFIG.maxFileSize) { return { valid: false, error: `File too large (max ${Math.round(UPLOAD_CONFIG.maxFileSize / 1024 / 1024)}MB)` }; } // Check file type if (!UPLOAD_CONFIG.allowedTypes.has(file.type)) { return { valid: false, error: `File type not allowed: ${file.type}` }; } // Check filename if (!file.name || file.name.trim().length === 0) { return { valid: false, error: 'Invalid filename' }; } return { valid: true }; } function sanitizeFilename(filename: string): string { // Remove or replace unsafe characters return filename .replace(/[^a-zA-Z0-9._-]/g, '_') // Replace unsafe chars with underscore .replace(/_{2,}/g, '_') // Replace multiple underscores with single .replace(/^_|_$/g, '') // Remove leading/trailing underscores .toLowerCase() .substring(0, 100); // Limit length } function generateUniqueFilename(originalName: string): string { const timestamp = Date.now(); const randomId = crypto.randomBytes(4).toString('hex'); const ext = path.extname(originalName); const base = path.basename(originalName, ext); const sanitizedBase = sanitizeFilename(base); return `${timestamp}_${randomId}_${sanitizedBase}${ext}`; } async function uploadToLocal(file: File, category: string): Promise { try { // Ensure upload directory exists const categoryDir = path.join(UPLOAD_CONFIG.localUploadPath, sanitizeFilename(category)); await fs.mkdir(categoryDir, { recursive: true }); // Generate unique filename const uniqueFilename = generateUniqueFilename(file.name); const filePath = path.join(categoryDir, uniqueFilename); // Convert file to buffer and write const arrayBuffer = await file.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); await fs.writeFile(filePath, buffer); // Generate public URL const relativePath = path.posix.join('/uploads', sanitizeFilename(category), uniqueFilename); const publicUrl = `${UPLOAD_CONFIG.publicBaseUrl}${relativePath}`; return { success: true, url: publicUrl, filename: uniqueFilename, size: file.size, storage: 'local' }; } catch (error) { console.error('Local upload error:', error); return { success: false, error: error instanceof Error ? error.message : 'Local upload failed', storage: 'local' }; } } async function uploadToNextcloud(file: File, category: string): Promise { try { const uploader = new NextcloudUploader(); const result = await uploader.uploadFile(file, category); return { ...result, storage: 'nextcloud' }; } catch (error) { console.error('Nextcloud upload error:', error); return { success: false, error: error instanceof Error ? error.message : 'Nextcloud upload failed', storage: 'nextcloud' }; } } export const POST: APIRoute = async ({ request }) => { try { // Check authentication const authResult = await withAPIAuth(request); if (authResult.authRequired && !authResult.authenticated) { return createAuthErrorResponse('Authentication required'); } const userEmail = authResult.session?.email || 'anonymous'; // Rate limiting if (!checkUploadRateLimit(userEmail)) { return new Response(JSON.stringify({ error: 'Upload rate limit exceeded. Please wait before uploading more files.' }), { status: 429, headers: { 'Content-Type': 'application/json' } }); } // Parse form data const formData = await request.formData(); const file = formData.get('file') as File; const type = formData.get('type') as string || 'general'; if (!file) { return new Response(JSON.stringify({ error: 'No file provided' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); } // Validate file const validation = validateFile(file); if (!validation.valid) { return new Response(JSON.stringify({ error: validation.error }), { status: 400, headers: { 'Content-Type': 'application/json' } }); } // Determine upload strategy const useNextcloud = isNextcloudConfigured(); let result: UploadResult; if (useNextcloud) { // Try Nextcloud first, fallback to local result = await uploadToNextcloud(file, type); if (!result.success) { console.warn('Nextcloud upload failed, falling back to local storage:', result.error); result = await uploadToLocal(file, type); } } else { // Use local storage result = await uploadToLocal(file, type); } if (result.success) { // Log successful upload console.log(`[MEDIA UPLOAD] ${file.name} (${file.size} bytes) by ${userEmail} -> ${result.storage}: ${result.url}`); return new Response(JSON.stringify(result), { status: 200, headers: { 'Content-Type': 'application/json' } }); } else { // Log failed upload console.error(`[MEDIA UPLOAD FAILED] ${file.name} by ${userEmail}: ${result.error}`); return new Response(JSON.stringify(result), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } catch (error) { console.error('Media upload API error:', error); return new Response(JSON.stringify({ success: false, error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } }; // GET endpoint for upload status/info export const GET: APIRoute = async ({ request }) => { try { // Check authentication const authResult = await withAPIAuth(request); if (authResult.authRequired && !authResult.authenticated) { return createAuthErrorResponse('Authentication required'); } // Return upload configuration and status const nextcloudConfigured = isNextcloudConfigured(); // Check local upload directory let localStorageAvailable = false; try { await fs.access(UPLOAD_CONFIG.localUploadPath); localStorageAvailable = true; } catch { try { await fs.mkdir(UPLOAD_CONFIG.localUploadPath, { recursive: true }); localStorageAvailable = true; } catch (error) { console.warn('Local upload directory not accessible:', error); } } const status = { storage: { nextcloud: { configured: nextcloudConfigured, primary: nextcloudConfigured }, local: { available: localStorageAvailable, fallback: nextcloudConfigured, primary: !nextcloudConfigured } }, limits: { maxFileSize: UPLOAD_CONFIG.maxFileSize, maxFileSizeMB: Math.round(UPLOAD_CONFIG.maxFileSize / 1024 / 1024), allowedTypes: Array.from(UPLOAD_CONFIG.allowedTypes), rateLimit: { maxPerHour: RATE_LIMIT_MAX, windowMs: RATE_LIMIT_WINDOW } }, paths: { uploadEndpoint: '/api/upload/media', localPath: localStorageAvailable ? '/uploads' : null } }; return new Response(JSON.stringify(status), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { console.error('Media upload status error:', error); return new Response(JSON.stringify({ error: 'Failed to get upload status' }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } };