diff --git a/.dockerignore b/.dockerignore index 4c17717..aa4495d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,7 +3,10 @@ dist .git .gitignore *.md -!README.md -.DS_Store -.vscode -*.log +.env +.env.example +nginx/ +webhook/ +scripts/ +docker-compose.yml +.hermes/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1ed141e --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Environment variables for the webhook server +# Copy this to .env and fill in the secret: +# cp .env.example .env + +# Gitea webhook secret — generate with: openssl rand -hex 32 +WEBHOOK_SECRET= + +# Path to the repo containing docker-compose.yml +REPO_DIR=/home/djoser/webgpu-portfolio + +# Bind address and port for the webhook listener +# Must be 127.0.0.1 — the webhook is served through system nginx proxy +LISTEN_HOST=127.0.0.1 +LISTEN_PORT=9000 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..ee2f44a --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test-and-build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Type-check + run: npm run typecheck + + - name: Tests + run: npm test + + - name: Build + run: npm run build diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..db61efe --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,53 @@ +# Gitea Actions workflow — build, test, deploy +# Shows deployment status on the repo page, Actions tab, and commits. +name: Build & Deploy + +on: + push: + branches: [master] + +jobs: + # ---- Job 1: Typecheck & Build ---- + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + # Vite build is done inside Docker, so this is just a fast sanity check. + # Full build + test happens in the Docker multi-stage build below. + + # ---- Job 2: Docker build + deploy ---- + deploy: + needs: build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image (includes npm test gate) + run: docker compose build + + - name: Deploy via docker compose + run: | + docker compose up -d --remove-orphans + sleep 3 + docker compose ps + + - name: Smoke test + run: | + curl -sf --retry 5 --retry-delay 2 http://localhost:80/ && echo "OK — site is live" diff --git a/.gitignore b/.gitignore index 8146ac0..30bf6c3 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ test-*.js # LaTeX resume (unrelated to the site) resume.tex + +# CI/CD — webhook secret + deployment env +.env diff --git a/AGENTS.md b/AGENTS.md index 5f1b6ca..e10bce7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -325,4 +325,82 @@ renderer.updateText(layout) ``` Font files go in `public/` and are fetched at runtime. Uses `opentype.js` (runtime -dep) + `@types/opentype.js` (dev dep). \ No newline at end of file +dep) + `@types/opentype.js` (dev dep). + +## Deployment (Docker + nginx + webhook CI/CD) + +The stack runs in Docker Compose with two containers on a shared internal network: + +| Container | Role | Port exposed | +|---------------------|-----------------------------------------|--------------| +| `webgpu-portfolio-web` | App: nginx serving the built dist/ | internal:80 | +| `webgpu-portfolio-nginx` | Reverse proxy in front of web | host:80 | + +- `nginx/reverse.conf` — reverse proxy config, routes `djosen.one` → `web:80` +- `nginx.default.conf` — internal nginx config in the app container (SPA, gzip, caching) +- `docker-compose.yml` — full stack, health checks, auto-restart +- `.dockerignore` — excludes node_modules, dist, git, and CI files from the build context + +### CI/CD webhook + +A Python stdlib webhook server (`webhook/server.py`) listens on port 9000, verifies +Gitea's `X-Gitea-Signature` HMAC-SHA256, and on `push` events runs: + +``` +git pull origin master +docker compose up -d --build --remove-orphans +``` + +Minimal downtime (~1–2 s) because only the `web` container is rebuilt; nginx-proxy +stays running and buffers connections during the restart. + +### Setup on the server (first time) + +```bash +# 1. Clone the repo +git clone https://git.djosen.one/djoser/webgpu-portfolio /home/djoser/webgpu-portfolio +cd /home/djoser/webgpu-portfolio + +# 2. Create .env with a webhook secret +openssl rand -hex 32 > /tmp/secret +echo "WEBHOOK_SECRET=$(cat /tmp/secret)" > .env +echo "REPO_DIR=/home/djoser/webgpu-portfolio" >> .env + +# 3. Start the stack +docker compose up -d + +# 4. Install & start the webhook service +sudo cp webhook/webhook.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now webhook-webhook.service + +# 5. Configure Gitea webhook +# - Go to repo Settings → Webhooks → Add Webhook (Gitea) +# - Target URL: http://:9000 +# - Secret: +# - Events: Push events +# - Active: ✓ +``` + +### Manual deploy (no webhook) + +```bash +bash scripts/deploy.sh +``` + +### Verify everything is working + +```bash +# Stack health +docker compose ps +curl -s http://localhost:80/health + +# Webhook health +curl -s http://localhost:9000/health + +# Test webhook (simulate a push) +curl -X POST http://localhost:9000/ \ + -H "X-Gitea-Event: push" \ + -H "X-Gitea-Signature: ..." \ + -d '{"ref":"refs/heads/master"}' +``` \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 7ab97d1..272a0bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,14 +9,26 @@ RUN npm ci # Copy source and build COPY tsconfig.json vite.config.ts index.html ./ COPY src/ src/ + +# Run tests first — gates the build +RUN npm test + RUN npm run build # ---- stage 2: serve ---- FROM nginx:alpine-slim AS serve + +# curl needed for the healthcheck in docker-compose +RUN apk add --no-cache curl + COPY --from=build /app/dist /usr/share/nginx/html -# Optional: custom nginx config for caching / security -COPY nginx.default.conf /etc/nginx/conf.d/default.conf +# Wipe any default server blocks that could conflict with our config. +RUN rm -f /etc/nginx/conf.d/default.conf -EXPOSE 80 +# Deploy our custom nginx config and validate it at build time +COPY nginx.default.conf /etc/nginx/conf.d/default.conf +RUN nginx -t + +EXPOSE 8080 CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index 89fdefd..1c4b070 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # WebGPU Portfolio +[![Build & Deploy](https://git.djosen.one/djoser/webgpu-portfolio/actions/workflows/deploy.yml/badge.svg?branch=master)](https://git.djosen.one/djoser/webgpu-portfolio/actions) + Generative-art portfolio site. WebGPU canvas background, DOM overlay UI. ## Dev diff --git a/docker-compose.yml b/docker-compose.yml index 40c13ea..65fd378 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,17 @@ services: web: - build: . - ports: - - "8080:80" + build: + context: . + dockerfile: Dockerfile + image: webgpu-portfolio:latest + container_name: webgpu-portfolio-web restart: unless-stopped + ports: + # Loopback only — system nginx proxies from :443 → :8080 + - "127.0.0.1:8080:8080" + healthcheck: + test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8080/ || exit 1"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 5s diff --git a/nginx.default.conf b/nginx.default.conf index 2d33b2f..e5c59fb 100644 --- a/nginx.default.conf +++ b/nginx.default.conf @@ -1,5 +1,5 @@ server { - listen 80; + listen 8080 default_server; server_name _; root /usr/share/nginx/html; diff --git a/nginx/reverse.conf b/nginx/reverse.conf new file mode 100644 index 0000000..959cae6 --- /dev/null +++ b/nginx/reverse.conf @@ -0,0 +1,44 @@ +# nginx reverse proxy — sits in front of the app container +# This config is mounted into the nginx-proxy container in docker-compose. +upstream app { + # Points to the web service by container name (docker DNS) + server web:8080; +} + +server { + listen 80; + server_name djosen.one www.djosen.one; + + # Proxy all requests to the app container + location / { + proxy_pass http://app; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket support (not currently used, but forward-looking) + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # Buffering: off for WebGPU/streaming, but on for static is fine. + # We serve static from the app container's nginx, so keep this simple. + } + + # Health-check endpoint — used by the webhook deploy script to verify liveness + location /health { + access_log off; + default_type text/plain; + return 200 "ok"; + } + + # Security + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Logs + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; +} diff --git a/nginx/system-default.conf b/nginx/system-default.conf new file mode 100644 index 0000000..3ff12d5 --- /dev/null +++ b/nginx/system-default.conf @@ -0,0 +1,75 @@ +server { + listen 80 default_server; + listen [::]:80 default_server; + + server_name _; + + root /var/www/html; + index index.html index.htm index.nginx-debian.html; + + location / { + try_files $uri $uri/ =404; + } + + location /valentine { + proxy_pass http://localhost:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } +} +server { + server_name djosen.one; # managed by Certbot + + # SSL termination — Certbot managed + listen [::]:443 ssl ipv6only=on; # managed by Certbot + listen 443 ssl; # managed by Certbot + ssl_certificate /etc/letsencrypt/live/djosen.one/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/djosen.one/privkey.pem; # managed by Certbot + include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot + + # valentine app (unchanged) + location /valentine { + proxy_pass http://localhost:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + + # Health-check endpoint for deploy scripts + location /health { + access_log off; + default_type text/plain; + return 200 "ok"; + } + + # Everything else → Docker portfolio container + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} +server { + if ($host = djosen.one) { + return 301 https://$host$request_uri; + } # managed by Certbot + + + listen 80 ; + listen [::]:80 ; + server_name djosen.one; + return 404; # managed by Certbot + + +} diff --git a/package.json b/package.json index 01fb0a7..1c77f70 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc --noEmit && vite build", "preview": "vite preview", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "tsc --noEmit" }, "devDependencies": { "@types/opentype.js": "^1.3.10", diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..94ccff9 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# deploy.sh — manual deploy (pull + rebuild + health check) +# Run this on the server when you don't want to wait for a webhook trigger. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(dirname "$SCRIPT_DIR")" +cd "$REPO_DIR" + +echo "=== Pulling latest code ===" +git pull origin master + +echo "=== Running tests ===" +npm ci --silent +npm test + +echo "=== Rebuilding & restarting ===" +docker compose up -d --build --remove-orphans + +echo "=== Waiting for health checks ===" +sleep 3 +docker compose ps + +echo "=== Smoke test ===" +curl -sf http://localhost:80/health > /dev/null && echo "OK — /health passed" || echo "WARN — /health failed" + +echo "" +echo "Done. App is live." diff --git a/src/app.ts b/src/app.ts index b385415..ba8461c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,7 +1,8 @@ import { Renderer, UiState } from './engine/renderer' import { Input } from './ui/input' import { DomOverlay } from './ui/overlay' -import { SCENES } from './data/scenes' +import { Router, Route } from './router' +import { PAGES, PROJECTS_SCENE, RESUME_SCENE } from './data/pages' const DPR_CAP = 2 @@ -10,6 +11,7 @@ export class App { private renderer = new Renderer() private input!: Input private overlay!: DomOverlay + private router = new Router() private dpr = 1 private res = new Float32Array(2) @@ -47,6 +49,31 @@ export class App { cancelAnimationFrame(this.rafId) } + // ---- routing ---- + + private applyRoute(route: Route): void { + this.scene = route.scene + this.overlay.setScene(this.scene) + + if (route.scene === PROJECTS_SCENE && 'view' in route) { + if (route.view === 'category' && 'catId' in route && route.catId) { + this.overlay.showCategory(route.catId) + } else if ( + route.view === 'detail' && + 'catId' in route && route.catId && + 'projectId' in route && route.projectId + ) { + this.overlay.showProjectDetail(route.projectId, route.catId) + } else { + this.overlay.showCategoryGrid() + } + } else if (route.scene === RESUME_SCENE) { + this.overlay.showResume() + } + } + + // ---- rendering ---- + private resize(): void { this.dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP) const w = Math.floor(window.innerWidth * this.dpr) @@ -62,23 +89,16 @@ export class App { private computeStemMultipliers(time: number): void { const sm = this.stemMultipliers - // scene-based color identity and speed - const colorBase = [0.0, 0.6, 1.2, 2.4] - const speedBase = [0.0, 0.2, 0.1, 0.15] + const colorBase = [0.0, 0.6, 1.2, 1.8, 2.4] + const speedBase = [0.0, 0.2, 0.1, 0.0, 0.15] const scene = this.scene - // [0] = (camera speed, 0, color offset, twist/flash) sm[0] = speedBase[scene] sm[2] = colorBase[scene] * 0.2 sm[3] = 0.3 + 0.3 * Math.sin(time * 0.5) - // [1] = (color offset, 0, 0, 0) sm[4] = colorBase[scene] * 0.4 - - // [2] = (size scale, 0, 0, 0) - sm[8] = 0.1// + 0.4 * Math.sin(time * 0.3 + scene * 3.0) - - // rest of the array stays zero + sm[8] = 0.1 } private loop = (): void => { @@ -97,7 +117,6 @@ export class App { if (this.scene !== this.prevScene) { this.prevScene = this.scene - this.overlay.setScene(this.scene) } this.computeStemMultipliers(time) @@ -107,8 +126,8 @@ export class App { time, scene: this.scene, mouse: this.mouse, - stemMultipliers: this.stemMultipliers + stemMultipliers: this.stemMultipliers, } this.renderer.render(state) } -} \ No newline at end of file +} diff --git a/src/data/pages.ts b/src/data/pages.ts new file mode 100644 index 0000000..17db920 --- /dev/null +++ b/src/data/pages.ts @@ -0,0 +1,67 @@ +// Single source of truth for every page on the site. +// Add a new entry here and routing + navigation wire up automatically. +// +// Slug conventions: +// '' (empty string) → home page at / +// 'about' → /about +// 'projects' → /projects (sub-routes handled by the router) +// 'resume' → /resume +// +// Scene index = position in the array. Keep scenes 0-1 as simple pages +// and reserve scene 2 for projects (which has sub-navigation via categories). + +export interface PageDef { + slug: string + title: string + subtitle: string + body: string +} + +export const PAGES: PageDef[] = [ + { + slug: '', + title: 'Ahmed A. Mohamed', + subtitle: 'Creative Developer & Graphics Engineer', + body: "Welcome to my personal website, there's a lot going on in my life that I will be sharing here randomly over time so take a look.", + }, + { + slug: 'about', + title: 'ABOUT', + subtitle: 'Who I am', + body: 'I build creative web experiences\nusing cutting-edge graphics technology.', + }, + { + slug: 'projects', + title: 'PROJECTS', + subtitle: 'Things I have built', + body: 'A collection of work exploring\nWebGPU, shaders, and generative art.', + }, + { + slug: 'contact', + title: 'CONTACT', + subtitle: 'Get in touch', + body: 'Reach me at hello@example.com\nor find me on GitHub.', + }, + { + slug: 'resume', + title: 'RESUME', + subtitle: 'Ahmed Mohamed', + body: 'Graphics & Systems Engineer', + }, +] + +// ---- derived lookup maps (built once, used by the router) ---- + +/** path (e.g. '/about') → scene index */ +export const PATH_TO_SCENE: ReadonlyMap = new Map( + PAGES.map((p, i) => [p.slug === '' ? '/' : `/${p.slug}`, i]), +) + +/** scene index → base path (e.g. 1 → '/about') */ +export const SCENE_TO_PATH: ReadonlyMap = new Map( + PAGES.map((_, i) => [i, PAGES[i].slug === '' ? '/' : `/${PAGES[i].slug}`]), +) + +/** Scene indices resolved by slug — update PAGES and these auto-track. */ +export const PROJECTS_SCENE = PAGES.findIndex((p) => p.slug === 'projects') +export const RESUME_SCENE = PAGES.findIndex((p) => p.slug === 'resume') diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 0000000..dbc8baf --- /dev/null +++ b/src/router.ts @@ -0,0 +1,146 @@ +// Minimal client-side router using the History API. +// Maps are derived dynamically from src/data/pages.ts — add a page there +// and routing wires up automatically. +// +// The nginx config already has try_files → /index.html so server-side +// everything is ready for clean paths like /resume, /projects/graphics, etc. + +import { PATH_TO_SCENE, SCENE_TO_PATH } from './data/pages' + +// ---------- route types ---------- + +export interface SimpleRoute { + scene: number + path: string +} + +export interface ProjectsGridRoute { + scene: number + view: 'grid' + path: string +} + +export interface ProjectsCategoryRoute { + scene: number + view: 'category' + catId: string + path: string +} + +export interface ProjectsDetailRoute { + scene: number + view: 'detail' + catId: string + projectId: string + path: string +} + +export type Route = + | SimpleRoute + | ProjectsGridRoute + | ProjectsCategoryRoute + | ProjectsDetailRoute + +export type RouteChangeHandler = (route: Route) => void + +// ---- resolve the projects scene index dynamically ---- + +const PROJECTS_SCENE = PATH_TO_SCENE.get('/projects') +const PROJECTS_BASE = '/projects' + +// ---------- Router ---------- + +export class Router { + private handler: RouteChangeHandler | null = null + + constructor() { + window.addEventListener('popstate', () => { + const route = Router.parse(window.location.pathname) + this.handler?.(route) + }) + } + + /** Register the single route-change callback. */ + onChange(handler: RouteChangeHandler): void { + this.handler = handler + } + + /** Parse the current URL and return the initial route. */ + start(): Route { + return Router.parse(window.location.pathname) + } + + /** Push a new history entry and fire the route change. No-op if same path. */ + navigate(path: string): void { + if (window.location.pathname === path) return + history.pushState(null, '', path) + const route = Router.parse(path) + this.handler?.(route) + } + + /** Replace the current history entry (used for redirects). */ + replace(path: string): void { + history.replaceState(null, '', path) + } + + // ---- static helpers ---- + + /** Parse a pathname into a Route. Unknown paths → home redirect. */ + static parse(pathname: string): Route { + const path = pathname === '/' ? '/' : pathname.replace(/\/+$/, '') + const parts = path.split('/').filter(Boolean) + + if (parts.length === 0) { + return { scene: 0, path: '/' } + } + + const base = '/' + parts[0] + const scene = PATH_TO_SCENE.get(base) + + if (scene === undefined) { + return { scene: 0, path: '/' } + } + + // Projects sub-routes: /projects[/:catId[/:projectId]] + if (scene === PROJECTS_SCENE && base === PROJECTS_BASE) { + if (parts.length === 1) { + return { scene, view: 'grid', path: PROJECTS_BASE } + } + if (parts.length === 2) { + return { scene, view: 'category', catId: parts[1], path: `/${parts.join('/')}` } + } + return { + scene, + view: 'detail', + catId: parts[1], + projectId: parts[2], + path: `/${parts.join('/')}`, + } + } + + return { scene, path: base } + } + + /** Build a path string from a route. */ + static toPath(route: Route): string { + if ( + route.scene === PROJECTS_SCENE && + 'view' in route + ) { + if (route.view === 'category' && 'catId' in route && route.catId) { + return `/projects/${route.catId}` + } + if (route.view === 'detail' && 'catId' in route && route.catId && + 'projectId' in route && route.projectId) { + return `/projects/${route.catId}/${route.projectId}` + } + return '/projects' + } + return SCENE_TO_PATH.get(route.scene) ?? '/' + } + + /** Scene index → base path (resets any project sub-navigation). */ + static sceneToBasePath(scene: number): string { + return SCENE_TO_PATH.get(scene) ?? '/' + } +} diff --git a/src/ui/overlay.ts b/src/ui/overlay.ts index 73cac36..ff54579 100644 --- a/src/ui/overlay.ts +++ b/src/ui/overlay.ts @@ -1,10 +1,17 @@ import { RESUME, ResumeData, ResumeEntry, SkillGroup } from '../data/resume' import { PROJECTS, CATEGORIES, ProjectPage, ProjectCategory } from '../data/projects/index' -import type { SceneContent } from '../data/scenes' +import { PROJECTS_SCENE, RESUME_SCENE } from '../data/pages' + +interface SceneContent { + title: string + subtitle: string + body: string +} export interface OverlayCallbacks { onNavClick: (scene: number) => void onButtonClick: () => void + onNavigate: (path: string) => void } // ---------- HTML builders ---------- @@ -134,8 +141,6 @@ export class DomOverlay { private readonly resumeSectionEl: HTMLElement private readonly cycleBtnEl: HTMLElement private readonly scenes: SceneContent[] - private projectsBuilt = false - private resumeBuilt = false constructor(scenes: SceneContent[], callbacks: OverlayCallbacks) { this.scenes = scenes @@ -154,59 +159,62 @@ export class DomOverlay { this.cycleBtnEl.addEventListener('click', () => callbacks.onButtonClick()) - // delegate clicks inside projects section + // delegate clicks inside projects section → onNavigate callback this.projectsSectionEl.addEventListener('click', (e) => { const target = e.target as HTMLElement - // category card click → open category + // category card click const catCard = target.closest('.cat-card') - if (catCard && catCard.dataset.catId) { - this.showCategory(catCard.dataset.catId) + if (catCard?.dataset.catId) { + callbacks.onNavigate(`/projects/${catCard.dataset.catId}`) return } - // project card click → open detail + // project card click const projCard = target.closest('.project-card') - if (projCard && projCard.dataset.projectId) { - // find which category this project belongs to + if (projCard?.dataset.projectId) { const cat = CATEGORIES.find((c) => c.projects.some((p) => p.id === projCard.dataset.projectId) ) if (cat) { - this.showProjectDetail(projCard.dataset.projectId, cat.id) + callbacks.onNavigate(`/projects/${cat.id}/${projCard.dataset.projectId}`) } return } - // back button + // back buttons const back = target.closest('.project-back') - if (back && back.dataset.back === 'grid') { - this.showCategoryGrid() + if (back?.dataset.back === 'grid') { + callbacks.onNavigate('/projects') return } - if (back && back.dataset.back === 'cat' && back.dataset.catId) { - this.showCategory(back.dataset.catId) + if (back?.dataset.back === 'cat' && back.dataset.catId) { + callbacks.onNavigate(`/projects/${back.dataset.catId}`) return } }) } - private showCategoryGrid(): void { + showCategoryGrid(): void { this.projectsSectionEl.innerHTML = buildCategoryGridHTML(CATEGORIES) } - private showCategory(id: string): void { + showCategory(id: string): void { const cat = CATEGORIES.find((c) => c.id === id) if (!cat) return this.projectsSectionEl.innerHTML = buildCategoryListHTML(cat) } - private showProjectDetail(projectId: string, catId: string): void { + showProjectDetail(projectId: string, catId: string): void { const p = PROJECTS.find((p) => p.id === projectId) if (!p) return this.projectsSectionEl.innerHTML = buildProjectDetailHTML(p, catId) } + showResume(): void { + this.resumeSectionEl.innerHTML = buildResumeHTML(RESUME) + } + setScene(scene: number): void { const content = this.scenes[scene] if (content) { @@ -221,25 +229,14 @@ export class DomOverlay { this.resumeSectionEl.classList.remove('visible') this.cycleBtnEl.style.display = '' - if (scene === 2) { + if (scene === PROJECTS_SCENE) { this.sceneContentEl.classList.add('hidden') this.projectsSectionEl.classList.add('visible') this.cycleBtnEl.style.display = 'none' - if (!this.projectsBuilt) { - this.showCategoryGrid() - this.projectsBuilt = true - } else { - // always reset to category grid when landing on projects - this.showCategoryGrid() - } - } else if (scene === 3) { + } else if (scene === RESUME_SCENE) { this.sceneContentEl.classList.add('hidden') this.resumeSectionEl.classList.add('visible') this.cycleBtnEl.style.display = 'none' - if (!this.resumeBuilt) { - this.resumeSectionEl.innerHTML = buildResumeHTML(RESUME) - this.resumeBuilt = true - } } this.navItems.forEach((item, i) => { diff --git a/webhook/gitea-runner.service b/webhook/gitea-runner.service new file mode 100644 index 0000000..8a79248 --- /dev/null +++ b/webhook/gitea-runner.service @@ -0,0 +1,22 @@ +[Unit] +Description=Gitea Actions Runner (webgpu-portfolio) +After=network.target docker.service +Requires=docker.service + +[Service] +Type=simple +User=djoser +ExecStart=/usr/local/bin/act_runner daemon --config /home/djoser/.gitea-act-runner/config.yaml +WorkingDirectory=/home/djoser/.gitea-act-runner +Restart=always +RestartSec=5 + +# Security +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=/home/djoser/.gitea-act-runner /home/djoser/.cache/actcache /var/run/docker.sock + +[Install] +WantedBy=multi-user.target diff --git a/webhook/server.py b/webhook/server.py new file mode 100755 index 0000000..b5976be --- /dev/null +++ b/webhook/server.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +""" +Gitea webhook receiver — listens for push events and triggers a docker-compose +redeploy with zero-downtime (rebuild + restart only the changed service). + +Usage: + WEBHOOK_SECRET=... REPO_DIR=/home/djoser/webgpu-portfolio python3 server.py + +Environment: + WEBHOOK_SECRET Gitea webhook secret (HMAC-SHA256) [required] + REPO_DIR Path to the repo with docker-compose.yml [default: .] + LISTEN_HOST Bind address [default: 0.0.0.0] + LISTEN_PORT Port [default: 9000] +""" + +import hashlib +import hmac +import http.server +import json +import os +import subprocess +import sys +from pathlib import Path + + +# ---- config ---- + +SECRET = os.environ.get("WEBHOOK_SECRET", "").encode() +REPO_DIR = Path(os.environ.get("REPO_DIR", Path.cwd())) +HOST = os.environ.get("LISTEN_HOST", "0.0.0.0") +PORT = int(os.environ.get("LISTEN_PORT", "9000")) + + +def verify_signature(body: bytes, header: str) -> bool: + """Verify Gitea's X-Gitea-Signature header against the shared secret.""" + if not SECRET: + print("[webhook] WARNING: WEBHOOK_SECRET is empty — accepting all requests", flush=True) + return True + if not header: + return False + expected = hmac.new(SECRET, body, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, header) + + +def deploy() -> tuple[int, str]: + """Run 'git pull' + 'docker compose up -d --build' in REPO_DIR.""" + steps = [ + # Pull latest code + (["git", "pull", "origin", "master"], "git pull"), + # Rebuild + restart only the web container (nginx-proxy doesn't change) + (["docker", "compose", "up", "-d", "--build", "--remove-orphans"], "docker compose up"), + ] + + for cmd, label in steps: + result = subprocess.run( + cmd, + cwd=REPO_DIR, + capture_output=True, + text=True, + timeout=120, + ) + if result.returncode != 0: + return 1, f"{label} failed (exit {result.returncode}):\n{result.stderr.strip()}" + print(f"[webhook] {label}: ok", flush=True) + + return 0, "deploy ok" + + +class WebhookHandler(http.server.BaseHTTPRequestHandler): + def log_message(self, fmt: str, *args) -> None: + print(f"[webhook] {self.client_address[0]} — {fmt % args}", flush=True) + + def do_POST(self) -> None: + content_length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(content_length) + + # Verify signature + signature = self.headers.get("X-Gitea-Signature", "") + if not verify_signature(body, signature): + self.send_error(403, "Bad signature") + return + + # Parse event + event_type = self.headers.get("X-Gitea-Event", "") + if event_type != "push": + self.send_response(200) + self.end_headers() + self.wfile.write(f'{{"status":"ignored","event":"{event_type}"}}\n'.encode()) + return + + try: + payload = json.loads(body) + except json.JSONDecodeError: + self.send_error(400, "Invalid JSON") + return + + ref = payload.get("ref", "") + print(f"[webhook] push on {ref} — deploying...", flush=True) + + exit_code, msg = deploy() + if exit_code == 0: + self.send_response(200) + self.end_headers() + self.wfile.write(b'{"status":"ok"}\n') + else: + self.send_response(500) + self.end_headers() + self.wfile.write(f'{{"status":"error","detail":{json.dumps(msg)}}}\n'.encode()) + + def do_GET(self) -> None: + """Health-check endpoint.""" + if self.path == "/health": + self.send_response(200) + self.end_headers() + self.wfile.write(b"ok\n") + else: + self.send_error(405, "Method not allowed") + + +def main() -> None: + if not SECRET: + print( + "[webhook] WARNING: WEBHOOK_SECRET is empty. Set it for security.\n" + " Generate one: openssl rand -hex 32", + flush=True, + ) + + server = http.server.HTTPServer((HOST, PORT), WebhookHandler) + print(f"[webhook] Listening on {HOST}:{PORT} (repo dir: {REPO_DIR})", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + print("\n[webhook] Shutting down.", flush=True) + server.shutdown() + + +if __name__ == "__main__": + main() diff --git a/webhook/webhook.service b/webhook/webhook.service new file mode 100644 index 0000000..f9d32f3 --- /dev/null +++ b/webhook/webhook.service @@ -0,0 +1,23 @@ +[Unit] +Description=WebGPU Portfolio webhook receiver +After=network.target docker.service +Requires=docker.service + +[Service] +Type=simple +User=djoser +WorkingDirectory=/home/djoser/webgpu-portfolio +EnvironmentFile=/home/djoser/webgpu-portfolio/.env +ExecStart=/usr/bin/python3 /home/djoser/webgpu-portfolio/webhook/server.py +Restart=always +RestartSec=5 + +# Security hardening +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=/home/djoser/webgpu-portfolio /var/run/docker.sock /home/djoser/.docker + +[Install] +WantedBy=multi-user.target