diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..9f263bd --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,85 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + ASTRO_TELEMETRY_DISABLED: '1' + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: { version: 9 } + - uses: actions/setup-node@v4 + with: { node-version: 22, cache: pnpm, cache-dependency-path: site/pnpm-lock.yaml } + - run: pnpm -C site install --frozen-lockfile + - run: pnpm -C site lint + - run: pnpm -C site check + - run: pnpm -C site test + - run: pnpm -C site build + + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: { version: 9 } + - uses: actions/setup-node@v4 + with: { node-version: 22, cache: pnpm } + - run: pnpm -C site install --frozen-lockfile + - run: pnpm -C tests install --frozen-lockfile + - run: pnpm -C tests exec playwright install --with-deps chromium + - run: pnpm -C tests test:e2e + + lighthouse: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: { version: 9 } + - uses: actions/setup-node@v4 + with: { node-version: 22, cache: pnpm } + - run: pnpm -C site install --frozen-lockfile + - run: pnpm -C site build + - run: pnpm dlx @lhci/cli@0.14.x autorun --config=site/lighthouserc.json + + relay: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: { dotnet-version: '9.0.x' } + - run: dotnet build relay/ContactRelay.csproj -c Release + + images: + needs: [quality, e2e, relay] + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/login-action@v3 + with: + registry: git.cesnimda.uk + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_TOKEN }} + - name: Build & push site image + uses: docker/build-push-action@v6 + with: + context: ./site + push: true + tags: | + git.cesnimda.uk/cesnimda/resumesite-site:latest + git.cesnimda.uk/cesnimda/resumesite-site:${{ github.sha }} + - name: Build & push relay image + uses: docker/build-push-action@v6 + with: + context: ./relay + push: true + tags: | + git.cesnimda.uk/cesnimda/resumesite-relay:latest + git.cesnimda.uk/cesnimda/resumesite-relay:${{ github.sha }} diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..8342322 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,18 @@ +# Copy to .env and fill in. Secrets never go in git or in the image (DOCKER_SPEC §3). + +# --- Contact relay: SMTP (submission endpoint of your mail provider) --- +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= # secret — provide via env file (chmod 600) or a Docker secret +RELAY_FROM= # optional From address; defaults to SMTP_USER / RELAY_TO +RELAY_TO=connor.babbington@cesnimda.co.uk +RELAY_ALLOWED_ORIGIN=https://cesnimda.co.uk +RELAY_RATE_LIMIT=5 # requests per window per IP +RELAY_WINDOW_SECONDS=600 + +# --- Site build --- +PUBLIC_SITE_URL=https://cesnimda.co.uk + +# --- Infra --- +PROXY_NETWORK=web # name of the existing external reverse-proxy docker network diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml new file mode 100644 index 0000000..72b1e57 --- /dev/null +++ b/deploy/docker-compose.dev.yml @@ -0,0 +1,32 @@ +# Development stack (DOCKER_SPEC §1). Contributors need only Docker — Astro HMR and +# the relay with dotnet watch, with /api/contact proxied by the Astro dev server. + +services: + site-dev: + image: node:22-alpine + working_dir: /app + command: sh -c "corepack enable && pnpm install && pnpm dev --host --port 4321" + environment: + - ASTRO_TELEMETRY_DISABLED=1 + volumes: + - ../site:/app + - site_node_modules:/app/node_modules + ports: + - '4321:4321' + + relay-dev: + image: mcr.microsoft.com/dotnet/sdk:9.0-alpine + working_dir: /src + command: sh -c "dotnet watch run --urls http://+:8081 --non-interactive" + environment: + - DOTNET_USE_POLLING_FILE_WATCHER=1 + - Smtp__Host=${SMTP_HOST:-} + - Relay__ToAddress=${RELAY_TO:-dev@example.com} + - Relay__AllowedOrigin=http://localhost:4321 + volumes: + - ../relay:/src + ports: + - '8081:8081' + +volumes: + site_node_modules: diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..7d9043b --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,47 @@ +# Production stack (DOCKER_SPEC §2). Publishes no host ports — the existing host +# reverse proxy routes cesnimda.co.uk -> site:8080 and /api/contact -> relay:8081 +# over the shared external proxy network. + +services: + site: + build: + context: ../site + dockerfile: Dockerfile + image: git.cesnimda.uk/cesnimda/resumesite-site:latest + restart: unless-stopped + read_only: true + tmpfs: + - /tmp + - /var/cache/nginx + - /var/run + networks: [web] + logging: + driver: json-file + options: { max-size: '10m', max-file: '3' } + + relay: + build: + context: ../relay + dockerfile: Dockerfile + image: git.cesnimda.uk/cesnimda/resumesite-relay:latest + restart: unless-stopped + read_only: true + environment: + - Smtp__Host=${SMTP_HOST} + - Smtp__Port=${SMTP_PORT:-587} + - Smtp__User=${SMTP_USER} + - Smtp__Password=${SMTP_PASSWORD} + - Relay__FromAddress=${RELAY_FROM:-} + - Relay__ToAddress=${RELAY_TO} + - Relay__AllowedOrigin=${RELAY_ALLOWED_ORIGIN:-https://cesnimda.co.uk} + - Relay__RateLimitPerWindow=${RELAY_RATE_LIMIT:-5} + - Relay__WindowSeconds=${RELAY_WINDOW_SECONDS:-600} + networks: [web] + logging: + driver: json-file + options: { max-size: '10m', max-file: '3' } + +networks: + web: + external: true + name: ${PROXY_NETWORK:-web} diff --git a/relay/Dockerfile b/relay/Dockerfile index b4d6be3..de4eb1b 100644 --- a/relay/Dockerfile +++ b/relay/Dockerfile @@ -11,8 +11,8 @@ RUN dotnet publish -c Release -o /app --no-restore # ---- runtime ---- FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS final WORKDIR /app -RUN addgroup -S app && adduser -S app -G app COPY --from=build /app . +# The .NET aspnet image ships a non-root `app` user. USER app ENV ASPNETCORE_URLS=http://+:8081 \ DOTNET_EnableDiagnostics=0 diff --git a/site/.dockerignore b/site/.dockerignore new file mode 100644 index 0000000..91c5b97 --- /dev/null +++ b/site/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +.astro +test +tests +.env +.env.* +*.log diff --git a/site/Dockerfile b/site/Dockerfile new file mode 100644 index 0000000..8dafb1c --- /dev/null +++ b/site/Dockerfile @@ -0,0 +1,18 @@ +# syntax=docker/dockerfile:1 + +# ---- build ---- +FROM node:22-alpine AS build +WORKDIR /app +RUN corepack enable +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile +COPY . . +ENV ASTRO_TELEMETRY_DISABLED=1 +RUN pnpm build + +# ---- runtime: static files behind unprivileged nginx (non-root, read-only capable) ---- +FROM nginxinc/nginx-unprivileged:1.27-alpine AS final +COPY --chown=nginx:nginx nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build --chown=nginx:nginx /app/dist /usr/share/nginx/html +EXPOSE 8080 +# runs as uid 101 (nginx) by default in this image diff --git a/site/lighthouserc.json b/site/lighthouserc.json new file mode 100644 index 0000000..720dd52 --- /dev/null +++ b/site/lighthouserc.json @@ -0,0 +1,20 @@ +{ + "ci": { + "collect": { + "staticDistDir": "./dist", + "url": ["http://localhost/index.html", "http://localhost/projects/jobtrack/index.html"], + "numberOfRuns": 1 + }, + "assert": { + "assertions": { + "categories:performance": ["error", { "minScore": 0.95 }], + "categories:accessibility": ["error", { "minScore": 1 }], + "categories:seo": ["error", { "minScore": 1 }], + "largest-contentful-paint": ["error", { "maxNumericValue": 1800 }], + "cumulative-layout-shift": ["error", { "maxNumericValue": 0.02 }], + "total-blocking-time": ["error", { "maxNumericValue": 100 }], + "unused-javascript": "off" + } + } + } +} diff --git a/site/nginx.conf b/site/nginx.conf new file mode 100644 index 0000000..5c4be50 --- /dev/null +++ b/site/nginx.conf @@ -0,0 +1,71 @@ +# Site nginx config (DOCKER_SPEC §1, ARCHITECTURE §6). Serves the static Astro build +# with security headers, immutable asset caching, revalidated HTML, canonical +# trailing slashes, the preserved /Linkedin redirect, and legacy-WP 410s. + +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + + charset utf-8; + sendfile on; + tcp_nopush on; + + # --- Security headers (applied to document responses) --- + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'; upgrade-insecure-requests" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), interest-cohort=()" always; + add_header X-Frame-Options "DENY" always; + add_header Cache-Control "no-cache" always; + + # --- Compression --- + gzip on; + gzip_vary on; + gzip_min_length 256; + gzip_proxied any; + gzip_types text/plain text/css application/javascript application/json image/svg+xml application/xml application/xml+rss; + + # --- Preserved LinkedIn redirect (printed on the CVs). Replace slug before cutover. --- + location = /Linkedin { + return 301 https://www.linkedin.com/in/REPLACE-WITH-REAL-SLUG/; + } + + # --- Legacy WordPress URLs -> 410 Gone (crawler cleanup) --- + location ~* ^/(wp-admin|wp-login|wp-content|wp-includes|wp-json|xmlrpc\.php|feed|comments/feed) { + return 410; + } + + # --- Immutable, content-hashed build assets --- + location /_astro/ { + expires 1y; + add_header Cache-Control "public, immutable" always; + } + + # --- Other static media (moderate cache) --- + location ~* \.(?:woff2?|ttf|png|jpe?g|webp|avif|svg|ico)$ { + expires 30d; + add_header Cache-Control "public" always; + } + location = /theme-init.js { + expires 1h; + add_header Cache-Control "public" always; + } + + # --- CVs update in place -> short cache so shared links fetch the newest --- + location /cv/ { + expires 1h; + add_header Cache-Control "public, must-revalidate" always; + } + + # --- Canonical trailing slash for extensionless paths (SEO) --- + rewrite ^([^.]*[^/])$ $1/ permanent; + + # --- HTML documents: revalidate so deploys are instant (inherits headers above) --- + location / { + try_files $uri $uri/ =404; + } + + error_page 404 /404.html; +} diff --git a/site/public/theme-init.js b/site/public/theme-init.js new file mode 100644 index 0000000..02220c5 --- /dev/null +++ b/site/public/theme-init.js @@ -0,0 +1,18 @@ +/* + No-flash theme init (external so a strict CSP can use script-src 'self' with no + inline hash). Loaded render-blocking in , runs before first paint: marks + that JS is available (reveal animations are gated behind html.js) and applies the + stored or system theme. First visit follows the system; once set, the choice persists. +*/ +(function () { + document.documentElement.classList.add('js'); + try { + var t = localStorage.getItem('theme'); + if (t !== 'light' && t !== 'dark') { + t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; + } + document.documentElement.dataset.theme = t; + } catch (e) { + document.documentElement.dataset.theme = 'dark'; + } +})(); diff --git a/site/src/components/core/ThemeScript.astro b/site/src/components/core/ThemeScript.astro index 4470de2..71333b9 100644 --- a/site/src/components/core/ThemeScript.astro +++ b/site/src/components/core/ThemeScript.astro @@ -1,24 +1,9 @@ --- /* - No-flash theme init. Runs before paint, sets data-theme from stored preference - or system (ANIMATION_SPEC — the one permitted inline script; CSP-hashed at the - nginx layer). First visit follows the system; once set, the choice persists. + No-flash theme init. Loaded as an external, render-blocking script (served from + /theme-init.js) so a strict CSP can allow script-src 'self' without inline hashes. + Runs before first paint. See public/theme-init.js. */ --- - +