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';
+
-
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';
+
+
\ 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")})`}
>
-
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