import { NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from "@/app/api/auth/options"; import { prisma } from '@/lib/prisma'; import { S3Client, ListObjectsV2Command, GetObjectCommand, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; import { Readable } from 'stream'; // S3 Configuration for missions - uses environment variables const MISSIONS_S3_CONFIG = { endpoint: (process.env.MINIO_S3_UPLOAD_BUCKET_URL || process.env.S3_ENDPOINT || 'https://dome-api.slm-lab.net').replace(/\/$/, ''), region: process.env.MINIO_AWS_REGION || process.env.S3_REGION || 'us-east-1', accessKey: process.env.MINIO_ACCESS_KEY || process.env.S3_ACCESS_KEY || '', secretKey: process.env.MINIO_SECRET_KEY || process.env.S3_SECRET_KEY || '', bucket: 'missions' // Missions bucket is always 'missions' }; // Validate required S3 configuration if (!MISSIONS_S3_CONFIG.accessKey || !MISSIONS_S3_CONFIG.secretKey) { const errorMsg = '⚠️ S3 credentials are missing! Please set MINIO_ACCESS_KEY and MINIO_SECRET_KEY environment variables.'; console.error(errorMsg); if (process.env.NODE_ENV === 'production') { throw new Error('S3 credentials are required in production environment'); } } // Use the exact same S3 client configuration as mission-uploads.ts and image route // This ensures we use the same credentials and settings that work for other mission operations const missionsS3Client = new S3Client({ region: MISSIONS_S3_CONFIG.region, endpoint: MISSIONS_S3_CONFIG.endpoint, credentials: { accessKeyId: MISSIONS_S3_CONFIG.accessKey, secretAccessKey: MISSIONS_S3_CONFIG.secretKey }, forcePathStyle: true // Required for MinIO }); const MISSIONS_BUCKET = MISSIONS_S3_CONFIG.bucket; // Helper function to check if user has access to mission async function checkMissionAccess(userId: string, missionId: string): Promise { const mission = await prisma.mission.findFirst({ where: { id: missionId, OR: [ { creatorId: userId }, { missionUsers: { some: { userId } } } ] } }); return !!mission; } // Helper function to check if user can manage files (creator or gardien) // Also checks if mission is closed (closed missions cannot be modified) async function checkCanManage(userId: string, missionId: string): Promise<{ canManage: boolean; isClosed: boolean }> { const mission = await prisma.mission.findFirst({ where: { id: missionId }, select: { creatorId: true, isClosed: true, missionUsers: { where: { userId }, select: { role: true } } } }); if (!mission) return { canManage: false, isClosed: false }; // If mission is closed, no one can manage files if (mission.isClosed) { return { canManage: false, isClosed: true }; } // Creator can always manage if (mission.creatorId === userId) return { canManage: true, isClosed: false }; // Gardiens can manage const userRole = mission.missionUsers[0]?.role; const canManage = userRole === 'gardien-temps' || userRole === 'gardien-parole' || userRole === 'gardien-memoire'; return { canManage, isClosed: false }; } // Helper function to stream to string async function streamToString(stream: Readable): Promise { const chunks: Buffer[] = []; for await (const chunk of stream) { chunks.push(Buffer.from(chunk)); } return Buffer.concat(chunks).toString('utf-8'); } // GET endpoint to list files and folders in a mission export async function GET( request: Request, { params }: { params: Promise<{ missionId: string }> } ) { try { const session = await getServerSession(authOptions); if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const { missionId } = await params; const userId = session.user.id; // Check if user has access to this mission const hasAccess = await checkMissionAccess(userId, missionId); if (!hasAccess) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } const { searchParams } = new URL(request.url); const path = searchParams.get('path') || ''; // Subfolder path within mission // Get attachments from database first (like the missions page does) const attachments = await prisma.attachment.findMany({ where: { missionId }, select: { id: true, filename: true, filePath: true, fileType: true, fileSize: true, createdAt: true }, orderBy: { createdAt: 'desc' } }); // Try to list files from S3, but don't fail if it doesn't work let s3Folders: any[] = []; let s3Files: any[] = []; try { // Construct prefix for listing // Based on mission-uploads.ts, files are stored in MinIO without the "missions/" prefix // The filePath in DB is "missions/{missionId}/attachments/{filename}" // But in MinIO it's stored as "{missionId}/attachments/{filename}" const prefix = path ? `${missionId}/${path}/` : `${missionId}/`; console.log(`[GET /api/missions/${missionId}/files] Listing with prefix: "${prefix}" in bucket: "${MISSIONS_BUCKET}"`); const command = new ListObjectsV2Command({ Bucket: MISSIONS_BUCKET, Prefix: prefix, Delimiter: '/' }); const response = await missionsS3Client.send(command); // Extract folders (CommonPrefixes) s3Folders = (response.CommonPrefixes || []).map(commonPrefix => { const folderPath = commonPrefix.Prefix || ''; const pathParts = folderPath.replace(prefix, '').split('/').filter(Boolean); const folderName = pathParts[pathParts.length - 1] || folderPath; const fullPath = `missions/${folderPath}`; return { type: 'folder', name: folderName, path: fullPath, key: fullPath }; }); // Extract files (Contents, excluding folder markers) s3Files = (response.Contents || []) .filter(obj => { if (!obj.Key) return false; if (obj.Key.endsWith('/')) return false; if (obj.Key.includes('.placeholder')) return false; return true; }) .map(obj => { const key = obj.Key || ''; const fullPath = `missions/${key}`; return { type: 'file', name: key.split('/').pop() || key, path: fullPath, key: fullPath, size: obj.Size, lastModified: obj.LastModified }; }); } catch (s3Error: any) { console.warn(`[GET /api/missions/${missionId}/files] S3 listing failed, using database attachments only:`, { code: s3Error.Code, message: s3Error.message }); // Continue with database attachments only } // Convert database attachments to file format const dbFiles = attachments.map(att => ({ type: 'file' as const, name: att.filename, path: att.filePath, key: att.filePath, size: att.fileSize, lastModified: att.createdAt })); // Combine S3 files and database attachments, removing duplicates const allFiles = [...s3Files, ...dbFiles]; const uniqueFiles = allFiles.filter((file, index, self) => index === self.findIndex(f => f.key === file.key) ); // Combine folders and files const allItems = [...s3Folders, ...uniqueFiles].sort((a, b) => { if (a.type !== b.type) { return a.type === 'folder' ? -1 : 1; } return a.name.localeCompare(b.name); }); return NextResponse.json({ folders: s3Folders, files: allItems }); } catch (error) { console.error('Error listing mission files:', error); return NextResponse.json( { error: 'Failed to list files' }, { status: 500 } ); } } // GET endpoint to get file content export async function POST( request: Request, { params }: { params: Promise<{ missionId: string }> } ) { try { const session = await getServerSession(authOptions); if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const { missionId } = await params; const userId = session.user.id; // Check if user has access to this mission const hasAccess = await checkMissionAccess(userId, missionId); if (!hasAccess) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } const body = await request.json(); const { key } = body; if (!key) { return NextResponse.json({ error: 'File key is required' }, { status: 400 }); } // Ensure the key is within the mission folder if (!key.startsWith(`missions/${missionId}/`)) { return NextResponse.json({ error: 'Invalid file path' }, { status: 400 }); } // Remove missions/ prefix for MinIO (files are stored without it) const minioKey = key.replace(/^missions\//, ''); const command = new GetObjectCommand({ Bucket: MISSIONS_BUCKET, Key: minioKey }); const response = await missionsS3Client.send(command); const content = await streamToString(response.Body as Readable); return NextResponse.json({ content }); } catch (error) { console.error('Error fetching file content:', error); return NextResponse.json( { error: 'Failed to fetch file content' }, { status: 500 } ); } } // PUT endpoint to save file content export async function PUT( request: Request, { params }: { params: Promise<{ missionId: string }> } ) { try { const session = await getServerSession(authOptions); if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const { missionId } = await params; const userId = session.user.id; // Check if user has access to this mission const hasAccess = await checkMissionAccess(userId, missionId); if (!hasAccess) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } const body = await request.json(); const { key, content } = body; if (!key || content === undefined) { return NextResponse.json({ error: 'File key and content are required' }, { status: 400 }); } // Ensure the key is within the mission folder if (!key.startsWith(`missions/${missionId}/`)) { return NextResponse.json({ error: 'Invalid file path' }, { status: 400 }); } // Remove missions/ prefix for MinIO (files are stored without it) const minioKey = key.replace(/^missions\//, ''); const command = new PutObjectCommand({ Bucket: MISSIONS_BUCKET, Key: minioKey, Body: content || Buffer.alloc(0), ContentType: 'text/plain' }); await missionsS3Client.send(command); return NextResponse.json({ success: true }); } catch (error) { console.error('Error saving file:', error); return NextResponse.json( { error: 'Failed to save file' }, { status: 500 } ); } } // DELETE endpoint to delete a file or folder export async function DELETE( request: Request, { params }: { params: Promise<{ missionId: string }> } ) { try { const session = await getServerSession(authOptions); if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const { missionId } = await params; const userId = session.user.id; // Check if user can manage files and if mission is closed const { canManage, isClosed } = await checkCanManage(userId, missionId); if (isClosed) { return NextResponse.json({ error: 'Mission is closed: files cannot be deleted from closed missions' }, { status: 403 }); } if (!canManage) { return NextResponse.json({ error: 'Forbidden: You do not have permission to delete files' }, { status: 403 }); } const body = await request.json(); const { key } = body; if (!key) { return NextResponse.json({ error: 'File key is required' }, { status: 400 }); } // Ensure the key is within the mission folder if (!key.startsWith(`missions/${missionId}/`)) { return NextResponse.json({ error: 'Invalid file path' }, { status: 400 }); } // Remove missions/ prefix for MinIO (files are stored without it) const minioKey = key.replace(/^missions\//, ''); // Delete from S3 await missionsS3Client.send(new DeleteObjectCommand({ Bucket: MISSIONS_BUCKET, Key: minioKey })); // Try to delete from database if it's an attachment try { await prisma.attachment.deleteMany({ where: { missionId: missionId, filePath: key } }); } catch (dbError) { // Ignore database errors (file might not be in DB) console.warn('Could not delete attachment from database:', dbError); } return NextResponse.json({ success: true }); } catch (error: any) { console.error('Error deleting file:', error); return NextResponse.json( { error: 'Failed to delete file', details: error.message }, { status: 500 } ); } }