This commit is contained in:
2026-07-27 17:00:24 +02:00
21 changed files with 827 additions and 59 deletions

View File

@@ -3,7 +3,10 @@ dist
.git
.gitignore
*.md
!README.md
.DS_Store
.vscode
*.log
.env
.env.example
nginx/
webhook/
scripts/
docker-compose.yml
.hermes/

14
.env.example Normal file
View File

@@ -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

32
.gitea/workflows/ci.yml Normal file
View File

@@ -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

View File

@@ -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"

3
.gitignore vendored
View File

@@ -17,3 +17,6 @@ test-*.js
# LaTeX resume (unrelated to the site)
resume.tex
# CI/CD — webhook secret + deployment env
.env

View File

@@ -326,3 +326,81 @@ renderer.updateText(layout)
Font files go in `public/` and are fetched at runtime. Uses `opentype.js` (runtime
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 (~12 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://<server-ip>:9000
# - Secret: <content of /tmp/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"}'
```

View File

@@ -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;"]

View File

@@ -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

View File

@@ -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

View File

@@ -1,5 +1,5 @@
server {
listen 80;
listen 8080 default_server;
server_name _;
root /usr/share/nginx/html;

44
nginx/reverse.conf Normal file
View File

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

75
nginx/system-default.conf Normal file
View File

@@ -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
}

View File

@@ -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",

28
scripts/deploy.sh Executable file
View File

@@ -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."

View File

@@ -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,7 +126,7 @@ export class App {
time,
scene: this.scene,
mouse: this.mouse,
stemMultipliers: this.stemMultipliers
stemMultipliers: this.stemMultipliers,
}
this.renderer.render(state)
}

67
src/data/pages.ts Normal file
View File

@@ -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<string, number> = 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<number, string> = 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')

146
src/router.ts Normal file
View File

@@ -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) ?? '/'
}
}

View File

@@ -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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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) => {

View File

@@ -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

138
webhook/server.py Executable file
View File

@@ -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()

23
webhook/webhook.service Normal file
View File

@@ -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