332 lines
9.7 KiB
TypeScript
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' }
|
|
});
|
|
}
|
|
}; |