From 1a79ec5a6612f1b216641e233ebb846182005f71 Mon Sep 17 00:00:00 2001 From: Richard Bergsma Date: Fri, 9 May 2025 23:30:49 +0200 Subject: [PATCH] image optimazation --- src/components/common/Image.astro | 12 ++- src/components/ui/ImageModal.astro | 6 +- src/components/ui/LazyImage.astro | 52 +++++++++ .../widgets/CompactCertifications.astro | 8 +- src/middleware.ts | 93 ++++++++++++++++ src/utils/image.ts | 100 ++++++++++++++++++ 6 files changed, 267 insertions(+), 4 deletions(-) create mode 100644 src/components/ui/LazyImage.astro create mode 100644 src/middleware.ts create mode 100644 src/utils/image.ts diff --git a/src/components/common/Image.astro b/src/components/common/Image.astro index 75ad9ad..85e6a2e 100644 --- a/src/components/common/Image.astro +++ b/src/components/common/Image.astro @@ -1,6 +1,7 @@ --- import type { HTMLAttributes } from 'astro/types'; import { findImage } from '~/utils/images'; +import LazyImage from '~/components/ui/LazyImage.astro'; import { getImagesOptimized, astroAsseetsOptimizer, @@ -56,6 +57,15 @@ if ( !image ? ( ) : ( - + ) } diff --git a/src/components/ui/ImageModal.astro b/src/components/ui/ImageModal.astro index a139926..9a4cba0 100644 --- a/src/components/ui/ImageModal.astro +++ b/src/components/ui/ImageModal.astro @@ -2,6 +2,8 @@ // ImageModal.astro - A reusable modal component for displaying enlarged images --- +import LazyImage from './LazyImage.astro'; +
- Enlarged certificate
diff --git a/src/components/ui/LazyImage.astro b/src/components/ui/LazyImage.astro new file mode 100644 index 0000000..b30c337 --- /dev/null +++ b/src/components/ui/LazyImage.astro @@ -0,0 +1,52 @@ +import type { LazyImageProps } from '~/utils/image'; +import { optimizeImage, generateSrcset, generateImageSizes } from '~/utils/image'; + +interface Props extends LazyImageProps { + placeholder?: string; + blur?: boolean; +} + +const { + src, + alt, + width, + height, + class: className = '', + loading = 'lazy', + decoding = 'async', + placeholder = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9IiNlNWU3ZWYiLz48L3N2Zz4=', + blur = true, +} = Astro.props; + +// Optimize image +const optimizedImage = await optimizeImage({ + src, + width, + height, + format: 'webp', + quality: 80, +}); + +// Generate srcset for responsive images +const srcset = await generateSrcset(src); + +// Generate sizes attribute +const sizes = width ? generateImageSizes(width) : '100vw'; + +{alt} \ No newline at end of file diff --git a/src/components/widgets/CompactCertifications.astro b/src/components/widgets/CompactCertifications.astro index 65f747b..677bdf0 100644 --- a/src/components/widgets/CompactCertifications.astro +++ b/src/components/widgets/CompactCertifications.astro @@ -3,6 +3,7 @@ import Headline from '~/components/ui/Headline.astro'; import WidgetWrapper from '~/components/ui/WidgetWrapper.astro'; import Button from '~/components/ui/Button.astro'; import ImageModal from '~/components/ui/ImageModal.astro'; +import LazyImage from '~/components/ui/LazyImage.astro'; import type { Testimonials as Props } from '~/types'; import DefaultImage from '~/assets/images/default.png'; @@ -54,10 +55,13 @@ const { class="h-12 w-12 mr-3 flex-shrink-0 bg-gray-100 dark:bg-gray-800 rounded-md flex items-center justify-center overflow-hidden cursor-pointer" onclick={`window.openImageModal('${getImagePath(image)}', ${JSON.stringify(name || "Certification badge")})`} > - {getImageAlt(image, diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..9320344 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,93 @@ +import { RateLimiterMemory } from 'rate-limiter-flexible'; +import type { MiddlewareHandler } from 'astro'; + +// Rate limiter configuration +const rateLimiter = new RateLimiterMemory({ + points: 10, // Number of requests + duration: 60, // Per minute +}); + +// Security headers configuration +const securityHeaders = { + 'Content-Security-Policy': ` + default-src 'self'; + script-src 'self' 'unsafe-inline' 'unsafe-eval'; + style-src 'self' 'unsafe-inline'; + img-src 'self' data: https:; + font-src 'self'; + object-src 'none'; + base-uri 'self'; + form-action 'self'; + frame-ancestors 'none'; + block-all-mixed-content; + upgrade-insecure-requests; + `.replace(/\s+/g, ' ').trim(), + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'X-XSS-Protection': '1; mode=block', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()', + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', +}; + +// CORS configuration +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Max-Age': '86400', +}; + +export const onRequest: MiddlewareHandler = async ({ request, next }, response) => { + // Handle preflight requests + if (request.method === 'OPTIONS') { + return new Response(null, { + status: 204, + headers: corsHeaders, + }); + } + + try { + // Rate limiting + const ip = request.headers.get('x-forwarded-for') || 'unknown'; + await rateLimiter.consume(ip); + + // Add security headers + Object.entries(securityHeaders).forEach(([key, value]) => { + response.headers.set(key, value); + }); + + // Add CORS headers + Object.entries(corsHeaders).forEach(([key, value]) => { + response.headers.set(key, value); + }); + + // Add cache headers for static assets + const url = new URL(request.url); + if (url.pathname.match(/\.(jpg|jpeg|png|gif|ico|css|js)$/)) { + response.headers.set('Cache-Control', 'public, max-age=31536000'); + } else { + response.headers.set('Cache-Control', 'no-cache'); + } + + return next(); + } catch (error) { + // Handle rate limit exceeded + if (error.name === 'RateLimiterError') { + return new Response('Too Many Requests', { + status: 429, + headers: { + 'Retry-After': String(Math.ceil(error.msBeforeNext / 1000)), + ...corsHeaders, + }, + }); + } + + // Handle other errors + console.error('Middleware error:', error); + return new Response('Internal Server Error', { + status: 500, + headers: corsHeaders, + }); + } +}; \ No newline at end of file diff --git a/src/utils/image.ts b/src/utils/image.ts new file mode 100644 index 0000000..d6bb970 --- /dev/null +++ b/src/utils/image.ts @@ -0,0 +1,100 @@ +import sharp from 'sharp'; +import type { ImageMetadata } from 'astro'; + +interface OptimizeImageOptions { + src: string; + width?: number; + height?: number; + format?: 'webp' | 'avif' | 'jpeg' | 'png'; + quality?: number; + fit?: keyof sharp.FitEnum; +} + +export async function optimizeImage({ + src, + width, + height, + format = 'webp', + quality = 80, + fit = 'cover', +}: OptimizeImageOptions): Promise { + try { + // Load the image + const image = sharp(src); + + // Get original metadata + const metadata = await image.metadata(); + + // Resize if dimensions are provided + if (width || height) { + image.resize({ + width: width || undefined, + height: height || undefined, + fit, + withoutEnlargement: true, + }); + } + + // Convert to specified format + switch (format) { + case 'webp': + image.webp({ quality }); + break; + case 'avif': + image.avif({ quality }); + break; + case 'jpeg': + image.jpeg({ quality }); + break; + case 'png': + image.png({ quality }); + break; + } + + // Generate optimized buffer + const optimizedBuffer = await image.toBuffer(); + + // Return metadata + return { + src: `data:image/${format};base64,${optimizedBuffer.toString('base64')}`, + width: width || metadata.width || 0, + height: height || metadata.height || 0, + format, + }; + } catch (error) { + console.error('Image optimization error:', error); + throw new Error('Failed to optimize image'); + } +} + +// Lazy loading image component props +export interface LazyImageProps { + src: string; + alt: string; + width?: number; + height?: number; + class?: string; + loading?: 'lazy' | 'eager'; + decoding?: 'async' | 'sync' | 'auto'; +} + +// Generate responsive image sizes +export function generateImageSizes(width: number): string { + return `(max-width: 640px) ${Math.min(width, 640)}px, ${width}px`; +} + +// Generate srcset for responsive images +export async function generateSrcset( + src: string, + widths: number[] = [640, 768, 1024, 1280, 1536], + format: 'webp' | 'avif' = 'webp' +): Promise { + const srcset = await Promise.all( + widths.map(async (width) => { + const optimized = await optimizeImage({ src, width, format }); + return `${optimized.src} ${width}w`; + }) + ); + + return srcset.join(', '); +} \ No newline at end of file