2025-07-24 13:46:50 +02:00

332 lines
9.7 KiB
TypeScript

// 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<string, { count: number; resetTime: number }>();
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<UploadResult> {
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<UploadResult> {
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' }
});
}
};