404 lines
13 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
}
|