diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 000000000..2ec0f2f0a --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,126 @@ +import type { Handle } from '@sveltejs/kit'; +import redirects from './redirects.json'; +import { sequence } from '@sveltejs/kit/hooks'; +import { BANNER_KEY } from '$lib/constants'; +import { dev } from '$app/environment'; + +const redirectMap = new Map(redirects.map(({ link, redirect }) => [link, redirect])); + +const redirecter: Handle = async ({ event, resolve }) => { + const currentPath = event.url.pathname; + if (redirectMap.has(currentPath)) { + return new Response(null, { + status: 308, + headers: { + location: redirectMap.get(currentPath) ?? '' + } + }); + } + + return await resolve(event); +}; + +const securityheaders: Handle = async ({ event, resolve }) => { + const nonce = Buffer.from(crypto.randomUUID()).toString('base64'); + (event.locals as { nonce: string }).nonce = nonce; + + const response = await resolve(event, { + transformPageChunk: ({ html }) => { + return html.replace(/%sveltekit.nonce%/g, nonce); + } + }); + + // `true` if deployed via Coolify. + const isPreview = !!process.env.COOLIFY_FQDN; + // COOLIFY_FQDN already includes `http`. + const previewDomain = isPreview ? `${process.env.COOLIFY_FQDN}` : null; + const join = (arr: string[]) => arr.join(' '); + + const cspDirectives: Record = { + 'default-src': "'self'", + 'script-src': join([ + "'self'", + 'blob:', + "'unsafe-inline'", + "'unsafe-eval'", + 'https://*.posthog.com', + 'https://*.plausible.io', + 'https://*.reo.dev', + 'https://plausible.io', + 'https://js.zi-scripts.com', + 'https://ws.zoominfo.com' + ]), + 'style-src': "'self' 'unsafe-inline'", + 'img-src': "'self' data: https:", + 'font-src': "'self'", + 'object-src': "'none'", + 'base-uri': "'self'", + 'form-action': "'self'", + 'frame-ancestors': join(["'self'", 'https://www.youtube.com', 'https://*.vimeo.com']), + 'block-all-mixed-content': '', + 'upgrade-insecure-requests': '', + 'connect-src': join([ + "'self'", + 'https://*.appwrite.io', + 'https://*.appwrite.org', + 'https://*.posthog.com', + 'https://*.sentry.io', + 'https://*.plausible.io', + 'https://plausible.io', + 'https://*.reo.dev', + 'https://js.zi-scripts.com', + 'https://aorta.clickagy.com', + 'https://hemsync.clickagy.com', + 'https://ws.zoominfo.com ' + ]), + 'frame-src': join([ + "'self'", + 'https://www.youtube.com', + 'https://status.appwrite.online', + 'https://www.youtube-nocookie.com', + 'https://player.vimeo.com', + 'https://hemsync.clickagy.com' + ]) + }; + + if (isPreview) { + delete cspDirectives['block-all-mixed-content']; + delete cspDirectives['upgrade-insecure-requests']; + ['default-src', 'script-src', 'style-src', 'img-src', 'font-src', 'connect-src'].forEach( + (key) => { + cspDirectives[key] += ` ${previewDomain}`; + } + ); + } + + const cspDirectivesString = Object.entries(cspDirectives) + .map(([key, value]) => `${key} ${value}`.trim()) + .join('; '); + + // Set security headers + response.headers.set('Content-Security-Policy', cspDirectivesString); + + // HTTP Strict Transport Security + // max-age is set to 1 year in seconds + response.headers.set( + 'Strict-Transport-Security', + 'max-age=31536000; includeSubDomains; preload' + ); + + // X-Content-Type-Options + response.headers.set('X-Content-Type-Options', 'nosniff'); + + // X-Frame-Options + response.headers.set('X-Frame-Options', 'DENY'); + + return response; +}; + +const bannerRewriter: Handle = async ({ event, resolve }) => { + const response = await resolve(event, { + transformPageChunk: ({ html }) => html.replace('%aw_banner_key%', BANNER_KEY) + }); + return response; +}; + +export const handle = sequence(redirecter, bannerRewriter, securityheaders);