Skip to content

Commit

Permalink
fix: add thumbnail generation for images in S3 file API
Browse files Browse the repository at this point in the history
- Implemented thumbnail generation for image files when requested via the S3 file API.
- Added utility functions to check if a file is an image and to generate a thumbnail using the sharp library.
- Enhanced error handling to fall back to the original image if thumbnail generation fails.
- Updated the image rendering component to request thumbnails by default for better performance and user experience.
- Improved error response to include details in development mode for easier debugging.
  • Loading branch information
blinko-space committed Dec 22, 2024
1 parent 145d20b commit 36632a4
Show file tree
Hide file tree
Showing 2 changed files with 99 additions and 10 deletions.
106 changes: 98 additions & 8 deletions app/api/s3file/[...filename]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,112 @@ import { NextResponse } from "next/server";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { FileService } from "@/server/plugins/files";
import sharp from "sharp";
import mime from "mime-types";

const MAX_PRESIGNED_URL_EXPIRY = 604800 - (60 * 60 * 24);
const MAX_PRESIGNED_URL_EXPIRY = 604800 - (60 * 60 * 24);
const CACHE_DURATION = MAX_PRESIGNED_URL_EXPIRY;
const MAX_THUMBNAIL_SIZE = 1024 * 1024; // 1MB

function isImage(filename: string): boolean {
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
return imageExtensions.some(ext => filename.toLowerCase().endsWith(ext));
}

async function generateThumbnail(s3ClientInstance: any, config: any, fullPath: string) {
try {
const command = new GetObjectCommand({
Bucket: config.s3Bucket,
Key: decodeURIComponent(fullPath),
Range: `bytes=0-${MAX_THUMBNAIL_SIZE - 1}`
});

const response = await s3ClientInstance.send(command);
const chunks: Uint8Array[] = [];
for await (const chunk of response.Body as any) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);

const metadata = await sharp(buffer).metadata();
const { width = 0, height = 0 } = metadata;

let resizeWidth = width;
let resizeHeight = height;
const maxDimension = 500;

if (width > height && width > maxDimension) {
resizeWidth = maxDimension;
resizeHeight = Math.round(height * (maxDimension / width));
} else if (height > maxDimension) {
resizeHeight = maxDimension;
resizeWidth = Math.round(width * (maxDimension / height));
}

const thumbnail = await sharp(buffer, {
failOnError: false,
limitInputPixels: false
})
.rotate()
.resize(resizeWidth, resizeHeight, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 },
withoutEnlargement: true
})
.toBuffer();

return thumbnail;
} catch (error) {
console.error('Thumbnail generation error:', error);
throw error;
}
}

export const GET = async (req: Request, { params }: any) => {
const { s3ClientInstance, config } = await FileService.getS3Client();
try {
const fullPath = decodeURIComponent(params.filename.join('/'));

const fullPath = params.filename.join('/');
const url = new URL(req.url);
const needThumbnail = url.searchParams.get('thumbnail') === 'true';

if (isImage(fullPath) && needThumbnail) {
try {
const thumbnail = await generateThumbnail(s3ClientInstance, config, fullPath);
const filename = decodeURIComponent(fullPath.split('/').pop() || '');
return new Response(thumbnail, {
headers: {
"Content-Type": mime.lookup(filename) || "image/jpeg",
"Cache-Control": "public, max-age=31536000",
"Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(filename)}`,
"X-Content-Type-Options": "nosniff",
}
});
} catch (error) {
console.error('Failed to generate thumbnail, falling back to original:', error);
const command = new GetObjectCommand({
Bucket: config.s3Bucket,
Key: decodeURIComponent(fullPath),
ResponseCacheControl: `public, max-age=${CACHE_DURATION}, immutable`,
});

const signedUrl = await getSignedUrl(s3ClientInstance, command, {
expiresIn: MAX_PRESIGNED_URL_EXPIRY,
});

return NextResponse.redirect(signedUrl);
}
}

const command = new GetObjectCommand({
Bucket: config.s3Bucket,
Key: fullPath,
Key: decodeURIComponent(fullPath),
ResponseCacheControl: `public, max-age=${CACHE_DURATION}, immutable`,
});
const signedUrl = await getSignedUrl(s3ClientInstance, command, {

const signedUrl = await getSignedUrl(s3ClientInstance, command, {
expiresIn: MAX_PRESIGNED_URL_EXPIRY,
});

return NextResponse.redirect(signedUrl, {
headers: {
'Cache-Control': `public, max-age=${CACHE_DURATION}, immutable`,
Expand All @@ -29,6 +116,9 @@ export const GET = async (req: Request, { params }: any) => {
});
} catch (error) {
console.error('S3 file access error:', error);
return NextResponse.json({ error: 'File not found' }, { status: 404 });
return NextResponse.json({
error: 'File not found',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
}, { status: 404 });
}
};
3 changes: 1 addition & 2 deletions src/components/Common/AttachmentRender/imageRender.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@ type IProps = {
const ImageThumbnailRender = ({ file, className }: { file: FileType, className?: string }) => {
const [isOriginalError, setIsOriginalError] = useState(false);
const [currentSrc, setCurrentSrc] = useState(
file.preview.includes('/api/s3file/') ? file.preview :
`${file.preview}?thumbnail=true`
`${file.preview}?thumbnail=true`
);

useEffect(() => {
Expand Down

0 comments on commit 36632a4

Please sign in to comment.