feat: SPA routing, projects/resume sections, deployment infra
- Add client-side router with History API pushState (router.ts, pages.ts) - Extend overlay with projects grid/category/detail views and resume section - Add Gitea CI/CD workflow and webhook deployment server - Improve Docker Compose with nginx reverse proxy config - Add deployment script and .env.example
This commit is contained in:
@@ -3,7 +3,10 @@ dist
|
|||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
*.md
|
*.md
|
||||||
!README.md
|
.env
|
||||||
.DS_Store
|
.env.example
|
||||||
.vscode
|
nginx/
|
||||||
*.log
|
webhook/
|
||||||
|
scripts/
|
||||||
|
docker-compose.yml
|
||||||
|
.hermes/
|
||||||
|
|||||||
13
.env.example
Normal file
13
.env.example
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# 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
|
||||||
|
LISTEN_HOST=0.0.0.0
|
||||||
|
LISTEN_PORT=9000
|
||||||
53
.gitea/workflows/deploy.yml
Normal file
53
.gitea/workflows/deploy.yml
Normal 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
3
.gitignore
vendored
@@ -17,3 +17,6 @@ test-*.js
|
|||||||
|
|
||||||
# LaTeX resume (unrelated to the site)
|
# LaTeX resume (unrelated to the site)
|
||||||
resume.tex
|
resume.tex
|
||||||
|
|
||||||
|
# CI/CD — webhook secret + deployment env
|
||||||
|
.env
|
||||||
|
|||||||
80
AGENTS.md
80
AGENTS.md
@@ -325,4 +325,82 @@ renderer.updateText(layout)
|
|||||||
```
|
```
|
||||||
|
|
||||||
Font files go in `public/` and are fetched at runtime. Uses `opentype.js` (runtime
|
Font files go in `public/` and are fetched at runtime. Uses `opentype.js` (runtime
|
||||||
dep) + `@types/opentype.js` (dev dep).
|
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://<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"}'
|
||||||
|
```
|
||||||
18
Dockerfile
18
Dockerfile
@@ -9,14 +9,26 @@ RUN npm ci
|
|||||||
# Copy source and build
|
# Copy source and build
|
||||||
COPY tsconfig.json vite.config.ts index.html ./
|
COPY tsconfig.json vite.config.ts index.html ./
|
||||||
COPY src/ src/
|
COPY src/ src/
|
||||||
|
|
||||||
|
# Run tests first — gates the build
|
||||||
|
RUN npm test
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ---- stage 2: serve ----
|
# ---- stage 2: serve ----
|
||||||
FROM nginx:alpine-slim AS 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
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
# Optional: custom nginx config for caching / security
|
# Wipe any default server blocks that could conflict with our config.
|
||||||
COPY nginx.default.conf /etc/nginx/conf.d/default.conf
|
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;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# WebGPU Portfolio
|
# WebGPU Portfolio
|
||||||
|
|
||||||
|
[](https://git.djosen.one/djoser/webgpu-portfolio/actions)
|
||||||
|
|
||||||
Generative-art portfolio site. WebGPU canvas background, DOM overlay UI.
|
Generative-art portfolio site. WebGPU canvas background, DOM overlay UI.
|
||||||
|
|
||||||
## Dev
|
## Dev
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
build: .
|
build:
|
||||||
ports:
|
context: .
|
||||||
- "8080:80"
|
dockerfile: Dockerfile
|
||||||
|
image: webgpu-portfolio:latest
|
||||||
|
container_name: webgpu-portfolio-web
|
||||||
restart: unless-stopped
|
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
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 8080 default_server;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|||||||
44
nginx/reverse.conf
Normal file
44
nginx/reverse.conf
Normal 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
75
nginx/system-default.conf
Normal 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
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/opentype.js": "^1.3.10",
|
"@types/opentype.js": "^1.3.10",
|
||||||
|
|||||||
28
scripts/deploy.sh
Executable file
28
scripts/deploy.sh
Executable 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."
|
||||||
63
src/app.ts
63
src/app.ts
@@ -1,7 +1,8 @@
|
|||||||
import { Renderer, UiState } from './engine/renderer'
|
import { Renderer, UiState } from './engine/renderer'
|
||||||
import { Input } from './ui/input'
|
import { Input } from './ui/input'
|
||||||
import { DomOverlay } from './ui/overlay'
|
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
|
const DPR_CAP = 2
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ export class App {
|
|||||||
private renderer = new Renderer()
|
private renderer = new Renderer()
|
||||||
private input!: Input
|
private input!: Input
|
||||||
private overlay!: DomOverlay
|
private overlay!: DomOverlay
|
||||||
|
private router = new Router()
|
||||||
|
|
||||||
private dpr = 1
|
private dpr = 1
|
||||||
private res = new Float32Array(2)
|
private res = new Float32Array(2)
|
||||||
@@ -28,11 +30,23 @@ export class App {
|
|||||||
|
|
||||||
async start(): Promise<boolean> {
|
async start(): Promise<boolean> {
|
||||||
this.input = new Input()
|
this.input = new Input()
|
||||||
this.overlay = new DomOverlay(SCENES, {
|
|
||||||
onNavClick: (s) => { this.scene = s },
|
this.overlay = new DomOverlay(PAGES, {
|
||||||
onButtonClick: () => { this.scene = (this.scene + 1) % 5 }
|
onNavClick: (s) => this.router.navigate(Router.sceneToBasePath(s)),
|
||||||
|
onButtonClick: () => {
|
||||||
|
const next = (this.scene + 1) % PAGES.length
|
||||||
|
this.router.navigate(Router.sceneToBasePath(next))
|
||||||
|
},
|
||||||
|
onNavigate: (path) => this.router.navigate(path),
|
||||||
})
|
})
|
||||||
this.overlay.setScene(this.scene)
|
|
||||||
|
// Initial route from current URL
|
||||||
|
const route = this.router.start()
|
||||||
|
this.applyRoute(route)
|
||||||
|
|
||||||
|
// Subsequent navigation (popstate or pushState)
|
||||||
|
this.router.onChange((r) => this.applyRoute(r))
|
||||||
|
|
||||||
this.resize()
|
this.resize()
|
||||||
const ok = await this.renderer.init(this.canvas)
|
const ok = await this.renderer.init(this.canvas)
|
||||||
if (!ok) return false
|
if (!ok) return false
|
||||||
@@ -47,6 +61,31 @@ export class App {
|
|||||||
cancelAnimationFrame(this.rafId)
|
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 {
|
private resize(): void {
|
||||||
this.dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP)
|
this.dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP)
|
||||||
const w = Math.floor(window.innerWidth * this.dpr)
|
const w = Math.floor(window.innerWidth * this.dpr)
|
||||||
@@ -62,23 +101,16 @@ export class App {
|
|||||||
|
|
||||||
private computeStemMultipliers(time: number): void {
|
private computeStemMultipliers(time: number): void {
|
||||||
const sm = this.stemMultipliers
|
const sm = this.stemMultipliers
|
||||||
// scene-based color identity and speed
|
|
||||||
const colorBase = [0.0, 0.6, 1.2, 1.8, 2.4]
|
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 speedBase = [0.0, 0.2, 0.1, 0.0, 0.15]
|
||||||
const scene = this.scene
|
const scene = this.scene
|
||||||
|
|
||||||
// [0] = (camera speed, 0, color offset, twist/flash)
|
|
||||||
sm[0] = speedBase[scene]
|
sm[0] = speedBase[scene]
|
||||||
sm[2] = colorBase[scene] * 0.2
|
sm[2] = colorBase[scene] * 0.2
|
||||||
sm[3] = 0.3 + 0.3 * Math.sin(time * 0.5)
|
sm[3] = 0.3 + 0.3 * Math.sin(time * 0.5)
|
||||||
|
|
||||||
// [1] = (color offset, 0, 0, 0)
|
|
||||||
sm[4] = colorBase[scene] * 0.4
|
sm[4] = colorBase[scene] * 0.4
|
||||||
|
sm[8] = 0.1
|
||||||
// [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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private loop = (): void => {
|
private loop = (): void => {
|
||||||
@@ -97,7 +129,6 @@ export class App {
|
|||||||
|
|
||||||
if (this.scene !== this.prevScene) {
|
if (this.scene !== this.prevScene) {
|
||||||
this.prevScene = this.scene
|
this.prevScene = this.scene
|
||||||
this.overlay.setScene(this.scene)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.computeStemMultipliers(time)
|
this.computeStemMultipliers(time)
|
||||||
@@ -107,8 +138,8 @@ export class App {
|
|||||||
time,
|
time,
|
||||||
scene: this.scene,
|
scene: this.scene,
|
||||||
mouse: this.mouse,
|
mouse: this.mouse,
|
||||||
stemMultipliers: this.stemMultipliers
|
stemMultipliers: this.stemMultipliers,
|
||||||
}
|
}
|
||||||
this.renderer.render(state)
|
this.renderer.render(state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
67
src/data/pages.ts
Normal file
67
src/data/pages.ts
Normal 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
146
src/router.ts
Normal 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) ?? '/'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
import { RESUME, ResumeData, ResumeEntry, SkillGroup } from '../data/resume'
|
import { RESUME, ResumeData, ResumeEntry, SkillGroup } from '../data/resume'
|
||||||
import { PROJECTS, CATEGORIES, ProjectPage, ProjectCategory } from '../data/projects/index'
|
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 {
|
export interface OverlayCallbacks {
|
||||||
onNavClick: (scene: number) => void
|
onNavClick: (scene: number) => void
|
||||||
onButtonClick: () => void
|
onButtonClick: () => void
|
||||||
|
onNavigate: (path: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- HTML builders ----------
|
// ---------- HTML builders ----------
|
||||||
@@ -134,8 +141,6 @@ export class DomOverlay {
|
|||||||
private readonly resumeSectionEl: HTMLElement
|
private readonly resumeSectionEl: HTMLElement
|
||||||
private readonly cycleBtnEl: HTMLElement
|
private readonly cycleBtnEl: HTMLElement
|
||||||
private readonly scenes: SceneContent[]
|
private readonly scenes: SceneContent[]
|
||||||
private projectsBuilt = false
|
|
||||||
private resumeBuilt = false
|
|
||||||
|
|
||||||
constructor(scenes: SceneContent[], callbacks: OverlayCallbacks) {
|
constructor(scenes: SceneContent[], callbacks: OverlayCallbacks) {
|
||||||
this.scenes = scenes
|
this.scenes = scenes
|
||||||
@@ -154,59 +159,62 @@ export class DomOverlay {
|
|||||||
|
|
||||||
this.cycleBtnEl.addEventListener('click', () => callbacks.onButtonClick())
|
this.cycleBtnEl.addEventListener('click', () => callbacks.onButtonClick())
|
||||||
|
|
||||||
// delegate clicks inside projects section
|
// delegate clicks inside projects section → onNavigate callback
|
||||||
this.projectsSectionEl.addEventListener('click', (e) => {
|
this.projectsSectionEl.addEventListener('click', (e) => {
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
|
|
||||||
// category card click → open category
|
// category card click
|
||||||
const catCard = target.closest<HTMLElement>('.cat-card')
|
const catCard = target.closest<HTMLElement>('.cat-card')
|
||||||
if (catCard && catCard.dataset.catId) {
|
if (catCard?.dataset.catId) {
|
||||||
this.showCategory(catCard.dataset.catId)
|
callbacks.onNavigate(`/projects/${catCard.dataset.catId}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// project card click → open detail
|
// project card click
|
||||||
const projCard = target.closest<HTMLElement>('.project-card')
|
const projCard = target.closest<HTMLElement>('.project-card')
|
||||||
if (projCard && projCard.dataset.projectId) {
|
if (projCard?.dataset.projectId) {
|
||||||
// find which category this project belongs to
|
|
||||||
const cat = CATEGORIES.find((c) =>
|
const cat = CATEGORIES.find((c) =>
|
||||||
c.projects.some((p) => p.id === projCard.dataset.projectId)
|
c.projects.some((p) => p.id === projCard.dataset.projectId)
|
||||||
)
|
)
|
||||||
if (cat) {
|
if (cat) {
|
||||||
this.showProjectDetail(projCard.dataset.projectId, cat.id)
|
callbacks.onNavigate(`/projects/${cat.id}/${projCard.dataset.projectId}`)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// back button
|
// back buttons
|
||||||
const back = target.closest<HTMLElement>('.project-back')
|
const back = target.closest<HTMLElement>('.project-back')
|
||||||
if (back && back.dataset.back === 'grid') {
|
if (back?.dataset.back === 'grid') {
|
||||||
this.showCategoryGrid()
|
callbacks.onNavigate('/projects')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (back && back.dataset.back === 'cat' && back.dataset.catId) {
|
if (back?.dataset.back === 'cat' && back.dataset.catId) {
|
||||||
this.showCategory(back.dataset.catId)
|
callbacks.onNavigate(`/projects/${back.dataset.catId}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private showCategoryGrid(): void {
|
showCategoryGrid(): void {
|
||||||
this.projectsSectionEl.innerHTML = buildCategoryGridHTML(CATEGORIES)
|
this.projectsSectionEl.innerHTML = buildCategoryGridHTML(CATEGORIES)
|
||||||
}
|
}
|
||||||
|
|
||||||
private showCategory(id: string): void {
|
showCategory(id: string): void {
|
||||||
const cat = CATEGORIES.find((c) => c.id === id)
|
const cat = CATEGORIES.find((c) => c.id === id)
|
||||||
if (!cat) return
|
if (!cat) return
|
||||||
this.projectsSectionEl.innerHTML = buildCategoryListHTML(cat)
|
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)
|
const p = PROJECTS.find((p) => p.id === projectId)
|
||||||
if (!p) return
|
if (!p) return
|
||||||
this.projectsSectionEl.innerHTML = buildProjectDetailHTML(p, catId)
|
this.projectsSectionEl.innerHTML = buildProjectDetailHTML(p, catId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showResume(): void {
|
||||||
|
this.resumeSectionEl.innerHTML = buildResumeHTML(RESUME)
|
||||||
|
}
|
||||||
|
|
||||||
setScene(scene: number): void {
|
setScene(scene: number): void {
|
||||||
const content = this.scenes[scene]
|
const content = this.scenes[scene]
|
||||||
if (content) {
|
if (content) {
|
||||||
@@ -221,25 +229,14 @@ export class DomOverlay {
|
|||||||
this.resumeSectionEl.classList.remove('visible')
|
this.resumeSectionEl.classList.remove('visible')
|
||||||
this.cycleBtnEl.style.display = ''
|
this.cycleBtnEl.style.display = ''
|
||||||
|
|
||||||
if (scene === 2) {
|
if (scene === PROJECTS_SCENE) {
|
||||||
this.sceneContentEl.classList.add('hidden')
|
this.sceneContentEl.classList.add('hidden')
|
||||||
this.projectsSectionEl.classList.add('visible')
|
this.projectsSectionEl.classList.add('visible')
|
||||||
this.cycleBtnEl.style.display = 'none'
|
this.cycleBtnEl.style.display = 'none'
|
||||||
if (!this.projectsBuilt) {
|
} else if (scene === RESUME_SCENE) {
|
||||||
this.showCategoryGrid()
|
|
||||||
this.projectsBuilt = true
|
|
||||||
} else {
|
|
||||||
// always reset to category grid when landing on projects
|
|
||||||
this.showCategoryGrid()
|
|
||||||
}
|
|
||||||
} else if (scene === 4) {
|
|
||||||
this.sceneContentEl.classList.add('hidden')
|
this.sceneContentEl.classList.add('hidden')
|
||||||
this.resumeSectionEl.classList.add('visible')
|
this.resumeSectionEl.classList.add('visible')
|
||||||
this.cycleBtnEl.style.display = 'none'
|
this.cycleBtnEl.style.display = 'none'
|
||||||
if (!this.resumeBuilt) {
|
|
||||||
this.resumeSectionEl.innerHTML = buildResumeHTML(RESUME)
|
|
||||||
this.resumeBuilt = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.navItems.forEach((item, i) => {
|
this.navItems.forEach((item, i) => {
|
||||||
|
|||||||
138
webhook/server.py
Executable file
138
webhook/server.py
Executable 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()
|
||||||
24
webhook/webhook.service
Normal file
24
webhook/webhook.service
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
[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
|
||||||
|
ReadOnlyPaths=/var/run/docker.sock
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Reference in New Issue
Block a user