image optimazation
Some checks failed
GitHub Actions / build (18) (push) Has been cancelled
GitHub Actions / build (20) (push) Has been cancelled
GitHub Actions / build (22) (push) Has been cancelled
GitHub Actions / check (push) Has been cancelled

This commit is contained in:
2025-05-09 23:30:49 +02:00
parent b28ffd16d0
commit 1a79ec5a66
6 changed files with 267 additions and 4 deletions

View File

@@ -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 ? (
<Fragment />
) : (
<img src={image.src} crossorigin="anonymous" referrerpolicy="no-referrer" {...image.attributes} />
<LazyImage
src={image.src}
alt={props.alt}
width={props.width}
height={props.height}
class={props.class}
loading={props.loading}
decoding={props.decoding}
{...image.attributes}
/>
)
}

View File

@@ -2,6 +2,8 @@
// ImageModal.astro - A reusable modal component for displaying enlarged images
---
import LazyImage from './LazyImage.astro';
<div
id="image-modal"
class="fixed inset-0 z-50 flex items-center justify-center opacity-0 pointer-events-none transition-opacity duration-300 ease-in-out"
@@ -41,12 +43,14 @@
style="min-height: 300px; max-height: 75vh;"
>
<div class="flex items-center justify-center h-full w-full">
<img
<LazyImage
id="modal-image"
src=""
alt="Enlarged certificate"
class="w-auto object-contain"
style="max-height: var(--cert-max-height, 75%); vertical-align: middle;"
blur={false}
loading="eager"
/>
</div>
</div>

View File

@@ -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';
<img
src={optimizedImage.src}
srcset={srcset}
sizes={sizes}
alt={alt}
width={optimizedImage.width}
height={optimizedImage.height}
loading={loading}
decoding={decoding}
class:list={[
className,
'transition-opacity duration-300',
{ 'blur-sm': blur },
]}
onload="this.classList.remove('blur-sm')"
style={`background-image: url('${placeholder}'); background-size: cover;`}
/>

View File

@@ -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")})`}
>
<img
<LazyImage
src={getImagePath(image)}
alt={getImageAlt(image, name || "Certification badge")}
class="h-10 w-10 object-contain transition-transform duration-300 hover:scale-110"
width={40}
height={40}
class="object-contain transition-transform duration-300 hover:scale-110"
blur={false}
/>
</div>
<a href={linkUrl} target="_blank" rel="noopener noreferrer" class="flex-1">

93
src/middleware.ts Normal file
View File

@@ -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,
});
}
};

100
src/utils/image.ts Normal file
View File

@@ -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<ImageMetadata> {
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<string> {
const srcset = await Promise.all(
widths.map(async (width) => {
const optimized = await optimizeImage({ src, width, format });
return `${optimized.src} ${width}w`;
})
);
return srcset.join(', ');
}