diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 506432c..db2ead2 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -117,6 +117,10 @@ jobs: with: name: site-output path: .output/public + # Required so .htaccess (and any other dotfile in the output) is + # packed into the artifact — upload-artifact strips hidden files + # by default. + include-hidden-files: true retention-days: 1 compression-level: 0 if-no-files-found: error diff --git a/app/pages/404.vue b/app/pages/404.vue new file mode 100644 index 0000000..838fa12 --- /dev/null +++ b/app/pages/404.vue @@ -0,0 +1,62 @@ + + + diff --git a/app/pages/410.vue b/app/pages/410.vue new file mode 100644 index 0000000..3b63b14 --- /dev/null +++ b/app/pages/410.vue @@ -0,0 +1,62 @@ + + + diff --git a/nuxt.config.ts b/nuxt.config.ts index 332d840..c08b930 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -34,12 +34,40 @@ export default defineNuxtConfig({ nitro: { prerender: { routes: [ - '/' + '/', + '/404', + '/410' ], crawlLinks: true } }, + hooks: { + 'nitro:init'(nitro) { + // Resolve __BASE__ in public/.htaccess.tpl using the runtime base URL + // and write the result to .output/public/.htaccess. Templating happens at + // generate time so production (/) and preview (/pr-/) builds each get + // the correct ErrorDocument and RewriteBase paths. + if (nitro.options.dev) return + nitro.hooks.hook('close', async () => { + const { promises: fs } = await import('node:fs') + const { resolve } = await import('node:path') + const baseURL = nitro.options.runtimeConfig.app.baseURL || '/' + const templatePath = resolve(nitro.options.rootDir, 'public/.htaccess.tpl') + const outputDir = nitro.options.output.publicDir + const outputPath = resolve(outputDir, '.htaccess') + const leakedTemplate = resolve(outputDir, '.htaccess.tpl') + try { + const tpl = await fs.readFile(templatePath, 'utf8') + await fs.writeFile(outputPath, tpl.replaceAll('__BASE__', baseURL)) + await fs.rm(leakedTemplate, { force: true }) + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err + } + }) + } + }, + eslint: { config: { stylistic: { @@ -49,6 +77,31 @@ export default defineNuxtConfig({ } }, + icon: { + // The build-time scanner only sees static literals. We need to cover: + // - dynamic names in templates (ColorModeButton builds `i-lucide-${...}`) + // - icons referenced inside @nuxt/ui (lives in node_modules, not scanned) + // The default scanner globs cover .vue / .yml / .md etc., but not .ts — + // and several status / nav icons (labs.ts, links.ts, app.config.ts) live + // there. Extend the globs to include .ts (and .mts for safety). + // `fallbackToApi: 'server-only'` keeps SSR/generate-time resolution but + // blocks the runtime `api.iconify.design` request that CSP `connect-src + // 'self'` would otherwise reject. + clientBundle: { + scan: { + globInclude: ['**/*.{vue,jsx,tsx,ts,mts,md,mdc,mdx,yml,yaml}'] + }, + icons: [ + 'lucide:sun', + 'lucide:moon', + 'lucide:arrow-right', + 'lucide:arrow-up-right', + 'lucide:chevron-down' + ] + }, + fallbackToApi: 'server-only' + }, + robots: { disallow: [], sitemap: '/sitemap.xml' @@ -75,6 +128,7 @@ export default defineNuxtConfig({ sitemap: { autoLastmod: true, - xsl: false + xsl: false, + exclude: ['/404', '/410'] } }) diff --git a/public/.htaccess b/public/.htaccess deleted file mode 100644 index 8c9638f..0000000 --- a/public/.htaccess +++ /dev/null @@ -1,11 +0,0 @@ -# HTTP security headers (FIND-01 of security-audit) -# Apache mod_headers must be enabled on the host. - - - Header always set X-Frame-Options "DENY" - Header always set X-Content-Type-Options "nosniff" - Header always set Referrer-Policy "strict-origin-when-cross-origin" - Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()" - Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" - Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" - diff --git a/public/.htaccess.tpl b/public/.htaccess.tpl new file mode 100644 index 0000000..408d2e6 --- /dev/null +++ b/public/.htaccess.tpl @@ -0,0 +1,30 @@ +# Generated by Nitro from public/.htaccess.tpl — __BASE__ is replaced with the +# app base URL at generate time so paths resolve correctly for both production +# (/) and preview (/pr-/) builds. + +# HTTP security headers (FIND-01 of security-audit) +# Apache mod_headers must be enabled on the host. + + + Header always set X-Frame-Options "DENY" + Header always set X-Content-Type-Options "nosniff" + Header always set Referrer-Policy "strict-origin-when-cross-origin" + Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()" + Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" + + +# Custom error pages (prerendered Nuxt routes). +ErrorDocument 404 __BASE__404/index.html +ErrorDocument 410 __BASE__410/index.html + +# Permanently removed URL ranges — return 410 Gone so search engines drop them +# from their index faster than they would for 404. The body is served from the +# ErrorDocument 410 above. + + RewriteEngine On + RewriteBase __BASE__ + RewriteRule ^tags(/.*)?$ - [G,L,NC] + RewriteRule ^en(/.*)?$ - [G,L,NC] + RewriteRule ^legal/?$ - [G,L,NC] +