new pipeline and build improvements
Some checks failed
Homepage Build and Deploy (Test) / Build Homepage Image (push) Successful in 32s
Homepage Build and Deploy (Test) / Deploy Homepage (push) Failing after 3s

This commit is contained in:
2026-07-10 17:06:16 -03:00
parent 9410d3a7c6
commit 72c7d6c9e0
14 changed files with 1969 additions and 190 deletions

View File

@@ -1,3 +1,5 @@
.git .git
.gitea .gitea
dist
node_modules
utils/xor-enc.py utils/xor-enc.py

View File

@@ -0,0 +1,22 @@
name: Homepage Deploy (Production)
on:
workflow_dispatch: {}
jobs:
deploy:
name: Deploy Homepage
runs-on: ubuntu-amd64
needs: build
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Recreate Container
uses: https://git.ivanch.me/ivanch/pipeline-actions/ssh-deploy@main
with:
ssh_host: ${{ secrets.HOST }}
ssh_username: ${{ secrets.USERNAME }}
ssh_key: ${{ secrets.KEY }}
ssh_port: ${{ secrets.PORT }}
remote_dir: ${{ secrets.DIR }}

View File

@@ -1,4 +1,4 @@
name: Homepage Build and Deploy name: Homepage Build and Deploy (Test)
on: on:
push: push:
@@ -41,4 +41,4 @@ jobs:
ssh_username: ${{ secrets.USERNAME }} ssh_username: ${{ secrets.USERNAME }}
ssh_key: ${{ secrets.KEY }} ssh_key: ${{ secrets.KEY }}
ssh_port: ${{ secrets.PORT }} ssh_port: ${{ secrets.PORT }}
remote_dir: ${{ secrets.DIR }} remote_dir: ${{ secrets.TEST_DIR }}

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
dist/
node_modules/

View File

@@ -15,6 +15,11 @@
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon"> <link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
<title>404 | ivanch</title> <title>404 | ivanch</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Lexend:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/variables.css">
<link rel="stylesheet" href="css/main.css"> <link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/matrix.css"> <link rel="stylesheet" href="css/matrix.css">
<link rel="stylesheet" href="css/button.css"> <link rel="stylesheet" href="css/button.css">

View File

@@ -1,18 +1,17 @@
FROM node:alpine AS build FROM node:22-alpine AS build
WORKDIR /src WORKDIR /src
COPY . /src COPY package.json package-lock.json ./
RUN npm ci
# Install terser to minify JS files COPY . .
RUN npm install -g terser RUN OUTPUT_DIR=/dist npm run build:production
RUN if [ -d "js" ]; then find js -type f -name "*.js" -exec sh -c 'for f; do terser "$f" -c -m -o "$f"; done' sh {} +; fi
FROM nginx:alpine-slim AS final FROM nginx:alpine-slim AS final
COPY nginx.conf /etc/nginx/nginx.conf COPY nginx.conf /etc/nginx/nginx.conf
COPY . /usr/share/nginx/html COPY --from=build /dist /usr/share/nginx/html
COPY --from=build /src/js /usr/share/nginx/html/js
EXPOSE 80 EXPOSE 80

View File

@@ -1,7 +1,3 @@
@import url('variables.css');
@import url('https://fonts.googleapis.com/css2?family=Lexend:wght@100..900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400..700&display=swap');
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -60,7 +56,6 @@ img {
border-top: 1px solid var(--glass-border); border-top: 1px solid var(--glass-border);
border-bottom: 1px solid var(--glass-border); border-bottom: 1px solid var(--glass-border);
box-shadow: var(--shadow-lg), var(--inset-shadow); box-shadow: var(--shadow-lg), var(--inset-shadow);
will-change: box-shadow;
animation: glass-shadow 10s ease-in-out infinite; animation: glass-shadow 10s ease-in-out infinite;
} }

View File

@@ -14,6 +14,10 @@
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon"> <link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
<title>ivanch</title> <title>ivanch</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Lexend:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/variables.css">
<link rel="stylesheet" href="css/main.css"> <link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/sections/profile.css"> <link rel="stylesheet" href="css/sections/profile.css">
@@ -34,7 +38,7 @@
<div class="glass hero-card"> <div class="glass hero-card">
<div class="profile-container"> <div class="profile-container">
<div class="avatar"> <div class="avatar">
<img src="/assets/lain.png" alt="Avatar" /> <img src="/assets/lain.png" alt="Avatar" width="160" height="160" decoding="async" />
</div> </div>
<div class="profile-info"> <div class="profile-info">
<div class="profile-info-item"> <div class="profile-info-item">

View File

@@ -1,13 +1,21 @@
class MatrixBackground { class MatrixBackground {
constructor() { constructor() {
this.canvas = document.getElementById('matrixCanvas'); this.canvas = document.getElementById('matrixCanvas');
if (!this.canvas) return;
this.ctx = this.canvas.getContext('2d'); this.ctx = this.canvas.getContext('2d');
if (!this.ctx) return;
this.dots = []; this.dots = [];
this.connections = []; this.connections = [];
this.mouse = { x: -9999, y: -9999, active: false }; this.mouse = { x: -9999, y: -9999, active: false };
this.rafId = null; this.rafId = null;
this.isRunning = true; this.isRunning = false;
this.prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; this.lastTimestamp = 0;
this.motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
this.prefersReducedMotion = this.motionQuery.matches;
this.dotSpriteCache = new Map();
this.onAnimationFrame = (timestamp) => this.animate(timestamp);
this.config = { this.config = {
dotCount: this.getMobileDotCount(), dotCount: this.getMobileDotCount(),
@@ -18,7 +26,6 @@ class MatrixBackground {
dotSize: 3, dotSize: 3,
mouseRadius: 110, mouseRadius: 110,
mouseForce: 0.6, mouseForce: 0.6,
// Palette synced to the UI accent tokens
colors: { colors: {
dotDefault: 'rgba(230, 240, 255, 0.78)', dotDefault: 'rgba(230, 240, 255, 0.78)',
dotDefaultGlow: 'rgba(180, 220, 255, 0.40)', dotDefaultGlow: 'rgba(180, 220, 255, 0.40)',
@@ -51,15 +58,18 @@ class MatrixBackground {
init() { init() {
this.handleResize(); this.handleResize();
this.bindEvents(); this.bindEvents();
this.animate();
if (this.prefersReducedMotion) {
this.renderFrame(0);
} else {
this.start();
}
} }
bindEvents() { bindEvents() {
// Pointer reactivity — push dots away from cursor for a "matrix repel" feel. window.addEventListener('pointermove', (event) => {
// Touch devices are included; the dot pushes happen only while touched. this.mouse.x = event.clientX;
window.addEventListener('pointermove', (e) => { this.mouse.y = event.clientY;
this.mouse.x = e.clientX;
this.mouse.y = e.clientY;
this.mouse.active = true; this.mouse.active = true;
}, { passive: true }); }, { passive: true });
@@ -69,35 +79,59 @@ class MatrixBackground {
this.mouse.y = -9999; this.mouse.y = -9999;
}); });
// Pause the rAF loop when the tab is hidden to save CPU / battery.
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.hidden) { if (document.hidden) {
this.stop(); this.stop();
} else if (this.prefersReducedMotion) {
this.renderFrame(this.lastTimestamp);
} else { } else {
this.start(); this.start();
} }
}); });
// Resize is debounced via rAF; disabled on mobile for perf parity with original. const updateMotionPreference = (event) => {
this.prefersReducedMotion = event.matches;
if (this.prefersReducedMotion) {
this.stop();
this.renderFrame(this.lastTimestamp);
} else if (!document.hidden) {
this.start();
}
};
if (this.motionQuery.addEventListener) {
this.motionQuery.addEventListener('change', updateMotionPreference);
} else {
this.motionQuery.addListener(updateMotionPreference);
}
if (window.innerWidth > 768) { if (window.innerWidth > 768) {
let resizeRaf = null; let resizeRaf = null;
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
if (resizeRaf) cancelAnimationFrame(resizeRaf); if (resizeRaf !== null) cancelAnimationFrame(resizeRaf);
resizeRaf = requestAnimationFrame(() => this.handleResize()); resizeRaf = requestAnimationFrame(() => {
resizeRaf = null;
this.handleResize();
});
}); });
} }
} }
start() { start() {
if (this.rafId || !this.isRunning) return; if (this.rafId !== null || this.prefersReducedMotion || document.hidden) return;
this.animate();
this.isRunning = true;
this.rafId = requestAnimationFrame(this.onAnimationFrame);
} }
stop() { stop() {
if (this.rafId) { if (this.rafId !== null) {
cancelAnimationFrame(this.rafId); cancelAnimationFrame(this.rafId);
this.rafId = null;
} }
this.rafId = null;
this.isRunning = false;
} }
handleResize() { handleResize() {
@@ -105,12 +139,17 @@ class MatrixBackground {
this.canvas.height = window.innerHeight; this.canvas.height = window.innerHeight;
this.config.dotCount = this.getMobileDotCount(); this.config.dotCount = this.getMobileDotCount();
this.config.maxConnections = this.getMobileMaxConnections(); this.config.maxConnections = this.getMobileMaxConnections();
this.connections = [];
this.createDots(); this.createDots();
if (this.prefersReducedMotion) {
this.renderFrame(this.lastTimestamp);
}
} }
createDots() { createDots() {
this.dots = []; this.dots = [];
for (let i = 0; i < this.config.dotCount; i++) { for (let index = 0; index < this.config.dotCount; index++) {
this.createDot(); this.createDot();
} }
} }
@@ -118,7 +157,6 @@ class MatrixBackground {
createDot() { createDot() {
const startX = Math.random() * (this.canvas.width - 20) + 10; const startX = Math.random() * (this.canvas.width - 20) + 10;
const startY = Math.random() * (this.canvas.height - 20) + 10; const startY = Math.random() * (this.canvas.height - 20) + 10;
const angle = Math.random() * Math.PI * 2; const angle = Math.random() * Math.PI * 2;
const speed = this.config.dotSpeed * (0.8 + Math.random() * 0.4); const speed = this.config.dotSpeed * (0.8 + Math.random() * 0.4);
const baseVelocityX = Math.cos(angle) * speed; const baseVelocityX = Math.cos(angle) * speed;
@@ -141,36 +179,32 @@ class MatrixBackground {
updateDots() { updateDots() {
const radius = this.config.mouseRadius; const radius = this.config.mouseRadius;
const radiusSq = radius * radius; const radiusSquared = radius * radius;
const force = this.config.mouseForce; const force = this.config.mouseForce;
this.dots.forEach(dot => { for (const dot of this.dots) {
// Mouse repulsion
if (this.mouse.active) { if (this.mouse.active) {
const dx = dot.x - this.mouse.x; const dx = dot.x - this.mouse.x;
const dy = dot.y - this.mouse.y; const dy = dot.y - this.mouse.y;
const distSq = dx * dx + dy * dy; const distanceSquared = dx * dx + dy * dy;
if (distSq < radiusSq && distSq > 0.5) {
const dist = Math.sqrt(distSq); if (distanceSquared < radiusSquared && distanceSquared > 0.5) {
const strength = (1 - dist / radius) * force; const distance = Math.sqrt(distanceSquared);
dot.vx += (dx / dist) * strength; const strength = (1 - distance / radius) * force;
dot.vy += (dy / dist) * strength; dot.vx += (dx / distance) * strength;
dot.vy += (dy / distance) * strength;
} }
} }
dot.x += dot.vx; dot.x += dot.vx;
dot.y += dot.vy; dot.y += dot.vy;
// Ease back to a gentle autonomous drift after pointer interaction.
dot.wanderPhase += dot.wanderSpeed; dot.wanderPhase += dot.wanderSpeed;
const wanderX = Math.cos(dot.wanderPhase) * 0.03; const wanderX = Math.cos(dot.wanderPhase) * 0.03;
const wanderY = Math.sin(dot.wanderPhase * 0.8) * 0.03; const wanderY = Math.sin(dot.wanderPhase * 0.8) * 0.03;
dot.vx += (dot.baseVx + wanderX - dot.vx) * 0.02; dot.vx += (dot.baseVx + wanderX - dot.vx) * 0.02;
dot.vy += (dot.baseVy + wanderY - dot.vy) * 0.02; dot.vy += (dot.baseVy + wanderY - dot.vy) * 0.02;
// Reflect at the canvas edge. Keep the base velocity in sync with
// the bounce so the drift easing does not push the dot back into
// the same wall on the next frames.
const dotRadius = dot.size / 2; const dotRadius = dot.size / 2;
const minX = dotRadius; const minX = dotRadius;
const minY = dotRadius; const minY = dotRadius;
@@ -197,69 +231,160 @@ class MatrixBackground {
dot.baseVy = -Math.abs(dot.baseVy); dot.baseVy = -Math.abs(dot.baseVy);
} }
// A large pointer impulse can overshoot both edges by more than
// one frame, so clamp after reflecting the position.
dot.x = Math.max(minX, Math.min(maxX, dot.x)); dot.x = Math.max(minX, Math.min(maxX, dot.x));
dot.y = Math.max(minY, Math.min(maxY, dot.y)); dot.y = Math.max(minY, Math.min(maxY, dot.y));
}); }
}
getDotSprite(size, isConnected) {
const roundedSize = Math.round(size * 2) / 2;
const state = isConnected ? 'connected' : 'default';
const cacheKey = state + ':' + roundedSize;
const cachedSprite = this.dotSpriteCache.get(cacheKey);
if (cachedSprite) {
return cachedSprite;
}
const padding = 10;
const dimension = Math.ceil(roundedSize + padding * 2);
const sprite = document.createElement('canvas');
sprite.width = dimension;
sprite.height = dimension;
const spriteContext = sprite.getContext('2d');
spriteContext.shadowBlur = 6;
spriteContext.shadowColor = isConnected
? this.config.colors.dotConnectedGlow
: this.config.colors.dotDefaultGlow;
spriteContext.fillStyle = isConnected
? this.config.colors.dotConnected
: this.config.colors.dotDefault;
spriteContext.beginPath();
spriteContext.arc(dimension / 2, dimension / 2, roundedSize / 2, 0, Math.PI * 2);
spriteContext.fill();
this.dotSpriteCache.set(cacheKey, sprite);
return sprite;
} }
drawDots() { drawDots() {
this.dots.forEach(dot => { for (const dot of this.dots) {
const isConnected = dot.connectionCount > 0; const sprite = this.getDotSprite(dot.size, dot.connectionCount > 0);
this.ctx.beginPath(); const halfDimension = sprite.width / 2;
this.ctx.arc(dot.x, dot.y, dot.size / 2, 0, Math.PI * 2);
this.ctx.fillStyle = isConnected ? this.config.colors.dotConnected : this.config.colors.dotDefault;
this.ctx.globalAlpha = dot.opacity; this.ctx.globalAlpha = dot.opacity;
this.ctx.shadowBlur = 6; this.ctx.drawImage(sprite, dot.x - halfDimension, dot.y - halfDimension);
this.ctx.shadowColor = isConnected ? this.config.colors.dotConnectedGlow : this.config.colors.dotDefaultGlow;
this.ctx.fill();
});
this.ctx.globalAlpha = 1;
this.ctx.shadowBlur = 0;
} }
createConnection(dot1, dot2) { this.ctx.globalAlpha = 1;
}
createConnection(dot1, dot2, timestamp) {
if (this.connections.length >= this.config.maxConnections) return; if (this.connections.length >= this.config.maxConnections) return;
dot1.connectionCount++; dot1.connectionCount++;
dot2.connectionCount++; dot2.connectionCount++;
this.connections.push({ this.connections.push({
dot1: dot1, dot1,
dot2: dot2, dot2,
startTime: Date.now(), startTime: timestamp,
duration: Math.random() * (this.config.connectionDuration.max - this.config.connectionDuration.min) + this.config.connectionDuration.min duration: Math.random() * (this.config.connectionDuration.max - this.config.connectionDuration.min)
+ this.config.connectionDuration.min
}); });
} }
updateConnections() { updateConnections(timestamp) {
this.connections = this.connections.filter(conn => { let writeIndex = 0;
const elapsed = Date.now() - conn.startTime;
if (elapsed > conn.duration) { for (let readIndex = 0; readIndex < this.connections.length; readIndex++) {
conn.dot1.connectionCount--; const connection = this.connections[readIndex];
conn.dot2.connectionCount--;
return false; if (timestamp - connection.startTime > connection.duration) {
} connection.dot1.connectionCount--;
return true; connection.dot2.connectionCount--;
}); continue;
} }
drawConnections() { this.connections[writeIndex] = connection;
this.connections.forEach(conn => { writeIndex++;
const { dot1, dot2, startTime, duration } = conn; }
const elapsed = Date.now() - startTime;
this.connections.length = writeIndex;
}
samplePoisson(lambda) {
if (lambda <= 0) return 0;
const threshold = Math.exp(-lambda);
let product = 1;
let count = 0;
do {
count++;
product *= Math.random();
} while (product > threshold);
return count - 1;
}
tryCreateConnections(timestamp) {
const dotCount = this.dots.length;
if (dotCount < 2 || this.connections.length >= this.config.maxConnections) return;
const pairCount = dotCount * (dotCount - 1) / 2;
const attempts = this.samplePoisson(pairCount * this.config.connectionChance);
for (let attempt = 0;
attempt < attempts && this.connections.length < this.config.maxConnections;
attempt++) {
const firstIndex = Math.floor(Math.random() * dotCount);
let secondIndex = Math.floor(Math.random() * (dotCount - 1));
if (secondIndex >= firstIndex) {
secondIndex++;
}
const dot1 = this.dots[firstIndex];
const dot2 = this.dots[secondIndex];
const dx = dot2.x - dot1.x;
const dy = dot2.y - dot1.y;
const distanceSquared = dx * dx + dy * dy;
if (distanceSquared >= 22500 || distanceSquared <= 900) {
continue;
}
const alreadyConnected = this.connections.some((connection) =>
(connection.dot1 === dot1 && connection.dot2 === dot2)
|| (connection.dot1 === dot2 && connection.dot2 === dot1)
);
if (!alreadyConnected) {
this.createConnection(dot1, dot2, timestamp);
}
}
}
drawConnections(timestamp) {
for (const connection of this.connections) {
const elapsed = timestamp - connection.startTime;
const opacity = Math.min(1, elapsed / 500); const opacity = Math.min(1, elapsed / 500);
const gradient = this.ctx.createLinearGradient(
connection.dot1.x,
connection.dot1.y,
connection.dot2.x,
connection.dot2.y
);
const gradient = this.ctx.createLinearGradient(dot1.x, dot1.y, dot2.x, dot2.y);
gradient.addColorStop(0, this.config.colors.connection.start); gradient.addColorStop(0, this.config.colors.connection.start);
gradient.addColorStop(0.5, this.config.colors.connection.middle); gradient.addColorStop(0.5, this.config.colors.connection.middle);
gradient.addColorStop(1, this.config.colors.connection.end); gradient.addColorStop(1, this.config.colors.connection.end);
this.ctx.beginPath(); this.ctx.beginPath();
this.ctx.moveTo(dot1.x, dot1.y); this.ctx.moveTo(connection.dot1.x, connection.dot1.y);
this.ctx.lineTo(dot2.x, dot2.y); this.ctx.lineTo(connection.dot2.x, connection.dot2.y);
this.ctx.strokeStyle = gradient; this.ctx.strokeStyle = gradient;
this.ctx.lineWidth = 2; this.ctx.lineWidth = 2;
this.ctx.globalAlpha = opacity; this.ctx.globalAlpha = opacity;
@@ -267,51 +392,34 @@ class MatrixBackground {
const pulse = (Math.sin((elapsed / 2000) * Math.PI * 2) + 1) / 2; const pulse = (Math.sin((elapsed / 2000) * Math.PI * 2) + 1) / 2;
this.ctx.shadowBlur = 4 + pulse * 6; this.ctx.shadowBlur = 4 + pulse * 6;
this.ctx.shadowColor = 'rgba(118, 75, 162, 0.5)'; this.ctx.shadowColor = 'rgba(118, 75, 162, 0.5)';
this.ctx.stroke(); this.ctx.stroke();
}); }
this.ctx.globalAlpha = 1; this.ctx.globalAlpha = 1;
this.ctx.shadowBlur = 0; this.ctx.shadowBlur = 0;
} }
tryCreateConnections() { renderFrame(timestamp) {
for (let i = 0; i < this.dots.length; i++) { this.lastTimestamp = timestamp;
for (let j = i + 1; j < this.dots.length; j++) {
if (Math.random() < this.config.connectionChance) {
const dot1 = this.dots[i];
const dot2 = this.dots[j];
const dx = dot2.x - dot1.x;
const dy = dot2.y - dot1.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 150 && distance > 30) {
const alreadyConnected = this.connections.some(conn =>
(conn.dot1 === dot1 && conn.dot2 === dot2) ||
(conn.dot1 === dot2 && conn.dot2 === dot1)
);
if (!alreadyConnected) {
this.createConnection(dot1, dot2);
}
}
}
}
}
}
animate() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
if (!this.prefersReducedMotion) { if (!this.prefersReducedMotion) {
this.updateDots(); this.updateDots();
this.updateConnections(); this.updateConnections(timestamp);
this.tryCreateConnections(); this.tryCreateConnections(timestamp);
} }
this.drawConnections(); this.drawConnections(timestamp);
this.drawDots(); this.drawDots();
}
this.rafId = requestAnimationFrame(() => this.animate()); animate(timestamp) {
this.rafId = null;
this.renderFrame(timestamp);
if (this.isRunning && !this.prefersReducedMotion && !document.hidden) {
this.rafId = requestAnimationFrame(this.onAnimationFrame);
}
} }
} }

View File

@@ -9,6 +9,9 @@ document.addEventListener('DOMContentLoaded', () => {
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (avatar && avatarImg && !prefersReducedMotion) { if (avatar && avatarImg && !prefersReducedMotion) {
const createGlitchLayers = () => {
if (avatar.querySelector('.glitch-layer')) return;
['r', 'b'].forEach((channel) => { ['r', 'b'].forEach((channel) => {
const clone = avatarImg.cloneNode(false); const clone = avatarImg.cloneNode(false);
clone.classList.add('glitch-layer', channel); clone.classList.add('glitch-layer', channel);
@@ -16,6 +19,15 @@ document.addEventListener('DOMContentLoaded', () => {
clone.setAttribute('aria-hidden', 'true'); clone.setAttribute('aria-hidden', 'true');
avatar.appendChild(clone); avatar.appendChild(clone);
}); });
};
const removeGlitchLayers = () => {
avatar.querySelectorAll('.glitch-layer').forEach((layer) => layer.remove());
};
avatar.addEventListener('pointerenter', createGlitchLayers);
avatar.addEventListener('pointerleave', removeGlitchLayers);
avatar.addEventListener('pointercancel', removeGlitchLayers);
} }
// ---------------------------------------------------------------- // ----------------------------------------------------------------

View File

@@ -1,10 +1,10 @@
user nginx; user nginx;
worker_processes auto; worker_processes 1;
error_log /var/log/nginx/error.log warn; error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid; pid /var/run/nginx.pid;
events { events {
worker_connections 1024; worker_connections 256;
use epoll; use epoll;
} }
@@ -12,22 +12,16 @@ http {
include /etc/nginx/mime.types; include /etc/nginx/mime.types;
default_type application/octet-stream; default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" ' access_log off;
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
# Performance optimizations
sendfile on; sendfile on;
tcp_nopush on; tcp_nopush on;
tcp_nodelay on; tcp_nodelay on;
keepalive_timeout 65; keepalive_timeout 15;
keepalive_requests 100; keepalive_requests 100;
types_hash_max_size 2048; types_hash_max_size 2048;
client_max_body_size 20M; client_max_body_size 1m;
# Buffers & Timeouts tuning
client_body_buffer_size 16k; client_body_buffer_size 16k;
client_header_buffer_size 1k; client_header_buffer_size 1k;
large_client_header_buffers 4 8k; large_client_header_buffers 4 8k;
@@ -35,17 +29,18 @@ http {
client_header_timeout 12s; client_header_timeout 12s;
send_timeout 10s; send_timeout 10s;
# Open File Cache open_file_cache max=100 inactive=60s;
open_file_cache max=1000 inactive=20s; open_file_cache_valid 60s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2; open_file_cache_min_uses 2;
open_file_cache_errors on; open_file_cache_errors off;
# Gzip compression
gzip on; gzip on;
gzip_static on;
gzip_vary on; gzip_vary on;
gzip_proxied any; gzip_proxied any;
gzip_comp_level 6; gzip_comp_level 4;
gzip_min_length 256;
gzip_disable "msie6";
gzip_types gzip_types
text/plain text/plain
text/css text/css
@@ -62,10 +57,7 @@ http {
font/woff2 font/woff2
application/vnd.ms-fontobject application/vnd.ms-fontobject
application/x-font-ttf; application/x-font-ttf;
gzip_min_length 256;
gzip_disable "msie6";
# Security headers (Global default)
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
@@ -73,67 +65,33 @@ http {
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always; add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
# HTTP (port 80) Server Block
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
server_name localhost ivanch.me www.ivanch.me; server_name localhost ivanch.me www.ivanch.me;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Charset
charset utf-8; charset utf-8;
# Custom error pages
error_page 404 /404.html; error_page 404 /404.html;
error_page 500 502 503 504 /50x.html;
# Main location
location / { location / {
try_files $uri $uri/ =404; try_files $uri $uri/ =404;
} }
# Cache static assets & redeclare security headers to bypass Nginx header override gotcha location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|webp|woff|woff2|ttf|otf|eot)$ {
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp)$ { add_header Cache-Control "public, max-age=31536000, immutable" always;
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always; add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
access_log off;
} }
location ~* \.(css|js)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
access_log off;
}
location ~* \.(woff|woff2|ttf|otf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
access_log off;
}
# HTML files - shorter cache
location ~* \.html$ { location ~* \.html$ {
expires 1h; expires off;
add_header Cache-Control "public, must-revalidate"; add_header Cache-Control "no-cache, must-revalidate" always;
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
@@ -142,19 +100,14 @@ http {
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
} }
# Deny access to hidden files
location ~ /\. { location ~ /\. {
deny all; deny all;
access_log off;
log_not_found off; log_not_found off;
} }
# Deny access to backup files
location ~ ~$ { location ~ ~$ {
deny all; deny all;
access_log off;
log_not_found off; log_not_found off;
} }
} }
} }

1415
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

12
package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "ivanch-me",
"private": true,
"scripts": {
"build:production": "node utils/build-production.mjs"
},
"devDependencies": {
"esbuild": "0.28.1",
"html-minifier-terser": "7.2.0",
"sharp": "0.35.3"
}
}

250
utils/build-production.mjs Normal file
View File

@@ -0,0 +1,250 @@
import { createHash } from 'node:crypto';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { gzipSync } from 'node:zlib';
import { transform } from 'esbuild';
import { minify } from 'html-minifier-terser';
import sharp from 'sharp';
const rootDirectory = path.resolve(process.cwd());
const outputDirectory = path.resolve(process.env.OUTPUT_DIR || path.join(rootDirectory, 'dist'));
const cssFiles = [
'css/variables.css',
'css/main.css',
'css/button.css',
'css/matrix.css',
'css/sections/error.css',
'css/sections/hero.css',
'css/sections/homelab.css',
'css/sections/profile.css',
'css/sections/projects.css'
];
const jsFiles = [
'js/matrix.js',
'js/social.js'
];
const htmlFiles = [
'index.html',
'404.html'
];
const versionedFiles = new Map();
function absoluteSourcePath(relativePath) {
return path.join(rootDirectory, relativePath);
}
function absoluteOutputPath(relativePath) {
return path.join(outputDirectory, relativePath);
}
function versionFor(buffer) {
return createHash('sha256').update(buffer).digest('hex').slice(0, 12);
}
async function requireSourceFile(relativePath) {
try {
await fs.access(absoluteSourcePath(relativePath));
} catch {
throw new Error('Required source file is missing: ' + relativePath);
}
}
async function writeOutput(relativePath, content) {
const targetPath = absoluteOutputPath(relativePath);
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.writeFile(targetPath, content);
}
async function minifyCss(relativePath) {
let source = await fs.readFile(absoluteSourcePath(relativePath), 'utf8');
const transformed = await transform(source, {
loader: 'css',
legalComments: 'none',
minify: true
});
await writeOutput(relativePath, transformed.code);
versionedFiles.set(relativePath, versionFor(Buffer.from(transformed.code)));
}
async function minifyJavaScript(relativePath) {
const source = await fs.readFile(absoluteSourcePath(relativePath), 'utf8');
const transformed = await transform(source, {
loader: 'js',
legalComments: 'none',
minify: true,
target: 'es2020'
});
await writeOutput(relativePath, transformed.code);
versionedFiles.set(relativePath, versionFor(Buffer.from(transformed.code)));
}
async function copyStaticFile(relativePath) {
const content = await fs.readFile(absoluteSourcePath(relativePath));
await writeOutput(relativePath, content);
versionedFiles.set(relativePath, versionFor(content));
}
function isExternalReference(reference) {
return /^(?:[a-z][a-z0-9+.-]*:|\/\/|#|data:)/i.test(reference);
}
function versionedReference(reference) {
if (isExternalReference(reference)) {
return reference;
}
const isAbsolute = reference.startsWith('/');
const relativePath = (isAbsolute ? reference.slice(1) : reference).split(/[?#]/)[0];
const version = versionedFiles.get(relativePath);
if (!version) {
return reference;
}
return (isAbsolute ? '/' : '') + relativePath + '?v=' + version;
}
function replaceProductionReferences(html, avatarVersion) {
return html.replace(/\b(?:href|src)=(["'])([^"']+)\1/g, (match, quote, reference) => {
if (reference === '/assets/lain.png') {
return match.replace(reference, '/assets/lain.webp?v=' + avatarVersion);
}
const replacement = versionedReference(reference);
return replacement === reference ? match : match.replace(reference, replacement);
});
}
async function minifyHtml(relativePath, avatarVersion) {
const source = await fs.readFile(absoluteSourcePath(relativePath), 'utf8');
const withProductionAssets = replaceProductionReferences(source, avatarVersion);
const compactHtml = await minify(withProductionAssets, {
collapseWhitespace: true,
minifyCSS: false,
minifyJS: false,
removeComments: true,
removeRedundantAttributes: true,
sortAttributes: false,
sortClassName: false,
useShortDoctype: true
});
await writeOutput(relativePath, compactHtml);
}
async function assertHtmlReferencesResolve(relativePath) {
const html = await fs.readFile(absoluteOutputPath(relativePath), 'utf8');
const references = html.matchAll(/\b(?:href|src)=(["'])([^"']+)\1/g);
for (const referenceMatch of references) {
const reference = referenceMatch[2];
if (isExternalReference(reference)) {
continue;
}
const relativePath = (reference.startsWith('/') ? reference.slice(1) : reference).split(/[?#]/)[0];
const targetPath = path.resolve(outputDirectory, relativePath);
const relativeTarget = path.relative(outputDirectory, targetPath);
if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) {
throw new Error('Production reference escapes the output directory: ' + reference);
}
try {
await fs.access(targetPath);
} catch {
throw new Error('Production reference does not resolve: ' + reference);
}
}
}
async function gzipFile(relativePath) {
const source = await fs.readFile(absoluteOutputPath(relativePath));
await fs.writeFile(absoluteOutputPath(relativePath) + '.gz', gzipSync(source, {
level: 9,
mtime: 0
}));
}
async function byteSize(relativePath) {
return (await fs.stat(absoluteOutputPath(relativePath))).size;
}
async function build() {
const requiredFiles = [
...cssFiles,
...jsFiles,
...htmlFiles,
'assets/favicon.ico',
'assets/lain.png'
];
await Promise.all(requiredFiles.map(requireSourceFile));
await fs.rm(outputDirectory, { force: true, recursive: true });
await fs.mkdir(outputDirectory, { recursive: true });
for (const cssFile of cssFiles) {
await minifyCss(cssFile);
}
for (const jsFile of jsFiles) {
await minifyJavaScript(jsFile);
}
await copyStaticFile('assets/favicon.ico');
const optimizedAvatar = 'assets/lain.webp';
await fs.mkdir(path.dirname(absoluteOutputPath(optimizedAvatar)), { recursive: true });
await sharp(absoluteSourcePath('assets/lain.png'))
.resize(320, 320, { fit: 'cover', position: 'centre' })
.webp({ effort: 4, quality: 82 })
.toFile(absoluteOutputPath(optimizedAvatar));
const avatarVersion = versionFor(await fs.readFile(absoluteOutputPath(optimizedAvatar)));
for (const htmlFile of htmlFiles) {
await minifyHtml(htmlFile, avatarVersion);
}
await Promise.all(htmlFiles.map(assertHtmlReferencesResolve));
const compressedFiles = [
...cssFiles,
...jsFiles,
...htmlFiles
];
await Promise.all(compressedFiles.map(gzipFile));
const avatarBytes = await byteSize(optimizedAvatar);
if (avatarBytes > 100 * 1024) {
throw new Error('Optimized avatar exceeds the 100 KiB budget: ' + avatarBytes + ' bytes');
}
const firstPartyFiles = [
'index.html.gz',
...cssFiles.map((file) => file + '.gz'),
...jsFiles.map((file) => file + '.gz'),
optimizedAvatar,
'assets/favicon.ico'
];
const firstPartyBytes = (await Promise.all(firstPartyFiles.map(byteSize)))
.reduce((total, size) => total + size, 0);
if (firstPartyBytes > 200 * 1024) {
throw new Error('First-party transfer exceeds the 200 KiB budget: ' + firstPartyBytes + ' bytes');
}
console.log(JSON.stringify({
avatarBytes,
firstPartyBytes,
outputDirectory
}));
}
await build();