NeahStable/app/api/missions/[missionId]/files/route.ts
2026-01-21 11:40:40 +01:00

404 lines
13 KiB
TypeScript

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<boolean> {
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<string> {
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 }
);
}
}