Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 118 additions & 63 deletions apps/web/src/app/api/files/[id]/download/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAuth } from '@/lib/auth';
import { db, pages, eq } from '@pagespace/db';
import { PageType, canUserViewPage, isFilePage, createPageServiceToken } from '@pagespace/lib';
import { db, pages, files, driveMembers, eq, and } from '@pagespace/db';
import { PageType, canUserViewPage, isFilePage, createPageServiceToken, createDriveServiceToken } from '@pagespace/lib';
import { sanitizeFilenameForHeader } from '@pagespace/lib/utils/file-security';

interface RouteParams {
Expand All @@ -10,98 +10,153 @@ interface RouteParams {
}>;
}

const PROCESSOR_URL = process.env.PROCESSOR_URL || 'http://processor:3003';

/**
* Fetch a file from the processor and return it as a download
*/
async function fetchAndDownloadFile(
contentHash: string,
serviceToken: string,
filename: string,
mimeType: string,
fileSize?: number
): Promise<NextResponse> {
const fileResponse = await fetch(`${PROCESSOR_URL}/cache/${contentHash}/original`, {
headers: {
'Authorization': `Bearer ${serviceToken}`
},
signal: AbortSignal.timeout(30000),
});

if (!fileResponse.ok) {
throw new Error(`Processor returned ${fileResponse.status}: ${fileResponse.statusText}`);
}

const fileBuffer = Buffer.from(await fileResponse.arrayBuffer());
const sanitizedFilename = sanitizeFilenameForHeader(filename);

const headers = new Headers();
headers.set('Content-Type', mimeType || 'application/octet-stream');
headers.set('Content-Length', fileSize?.toString() || fileBuffer.length.toString());
headers.set('Content-Disposition', `attachment; filename="${sanitizedFilename}"`);
headers.set('X-Content-Type-Options', 'nosniff');
headers.set('X-Frame-Options', 'DENY');
headers.set('Content-Security-Policy', "default-src 'none';");

return new NextResponse(fileBuffer, { status: 200, headers });
}

export async function GET(
request: NextRequest,
context: RouteParams
) {
try {
// Await the params as required in Next.js 15
const { id } = await context.params;
const { searchParams } = new URL(request.url);
const filenameParam = searchParams.get('filename');

// Verify authentication
const user = await verifyAuth(request);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

// Fetch the page/file metadata
// First, try to find as a FILE-type page (existing behavior)
const page = await db.query.pages.findFirst({
where: eq(pages.id, id),
});

if (!page) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
if (page && isFilePage(page.type as PageType)) {
// Handle FILE-type page
const canView = await canUserViewPage(user.id, page.id);
if (!canView) {
return NextResponse.json({ error: 'You do not have access to this file' }, { status: 403 });
}

// Verify it's a FILE type
if (!isFilePage(page.type as PageType)) {
return NextResponse.json({ error: 'Not a file' }, { status: 400 });
}
if (!page.filePath) {
return NextResponse.json({ error: 'File path not found' }, { status: 500 });
}

const canView = await canUserViewPage(user.id, page.id);
if (!canView) {
return NextResponse.json({ error: 'You do not have access to this file' }, { status: 403 });
const contentHash = page.filePath;

try {
const { token: serviceToken } = await createPageServiceToken(
user.id,
page.id,
['files:read'],
'5m'
);

return await fetchAndDownloadFile(
contentHash,
serviceToken,
page.originalFileName || page.title,
page.mimeType || 'application/octet-stream',
page.fileSize ?? undefined
);
} catch (fileError) {
const isTimeout = fileError instanceof Error && fileError.name === 'TimeoutError';
console.error('Error downloading file page:', {
pageId: page.id,
contentHash,
isTimeout,
error: fileError instanceof Error ? fileError.message : 'Unknown error',
});
return NextResponse.json({
error: isTimeout ? 'Request timed out' : 'File not accessible'
}, { status: isTimeout ? 504 : 500 });
}
}

if (!page.filePath) {
return NextResponse.json({ error: 'File path not found' }, { status: 500 });
}
// If not a FILE-type page, try to find in the files table (for channel attachments)
const file = await db.query.files.findFirst({
where: eq(files.id, id),
});

// Fetch file from processor service using content hash
const PROCESSOR_URL = process.env.PROCESSOR_URL || 'http://processor:3003';
const contentHash = page.filePath; // filePath stores the content hash
if (!file) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}

console.log('[Download] Fetching file from processor:', {
pageId: page.id,
contentHash,
processorUrl: `${PROCESSOR_URL}/cache/${contentHash}/original`,
// Check if user has access to the drive that owns this file
const membership = await db.query.driveMembers.findFirst({
where: and(
eq(driveMembers.driveId, file.driveId),
eq(driveMembers.userId, user.id)
),
});

if (!membership) {
return NextResponse.json({ error: 'You do not have access to this file' }, { status: 403 });
}

const contentHash = file.storagePath || file.id;

try {
// Create service JWT token for processor authentication (validates page permissions)
const { token: serviceToken } = await createPageServiceToken(
const { token: serviceToken } = await createDriveServiceToken(
user.id,
page.id,
file.driveId,
['files:read'],
'5m'
);

// Request the original file from processor service
const fileResponse = await fetch(`${PROCESSOR_URL}/cache/${contentHash}/original`, {
headers: {
'Authorization': `Bearer ${serviceToken}`
},
signal: AbortSignal.timeout(30000), // 30 second timeout for downloads
});

if (!fileResponse.ok) {
throw new Error(`Processor returned ${fileResponse.status}: ${fileResponse.statusText}`);
}

// Get the file buffer from response
const fileBuffer = Buffer.from(await fileResponse.arrayBuffer());
console.log('[Download] Successfully fetched from processor, size:', fileBuffer.length);

// Sanitize filename to prevent header injection
const sanitizedFilename = sanitizeFilenameForHeader(page.originalFileName || page.title);

// Set appropriate headers for file download
const headers = new Headers();
headers.set('Content-Type', page.mimeType || 'application/octet-stream');
headers.set('Content-Length', page.fileSize?.toString() || fileBuffer.length.toString());
headers.set('Content-Disposition', `attachment; filename="${sanitizedFilename}"`);
headers.set('X-Content-Type-Options', 'nosniff'); // Prevent MIME sniffing
headers.set('X-Frame-Options', 'DENY'); // Prevent clickjacking
headers.set('Content-Security-Policy', "default-src 'none';"); // Extra protection

// Return the file
return new NextResponse(fileBuffer, {
status: 200,
headers,
});
return await fetchAndDownloadFile(
contentHash,
serviceToken,
filenameParam || contentHash,
file.mimeType || 'application/octet-stream',
file.sizeBytes
);
} catch (fileError) {
console.error('Error reading file:', fileError);
return NextResponse.json({ error: 'File not accessible' }, { status: 500 });
const isTimeout = fileError instanceof Error && fileError.name === 'TimeoutError';
console.error('Error downloading file:', {
fileId: file.id,
contentHash,
isTimeout,
error: fileError instanceof Error ? fileError.message : 'Unknown error',
});
return NextResponse.json({
error: isTimeout ? 'Request timed out' : 'File not accessible'
}, { status: isTimeout ? 504 : 500 });
}

} catch (error) {
Expand Down
Loading