Initial commit: WORLDPLAY event website

- Express server with health check
- Single-page HTML site
- Docker + docker-compose setup
- Traefik integration for worldplay.jeffemmett.com

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-01-21 11:50:14 +01:00
commit 52000c0f5a
6 changed files with 2202 additions and 0 deletions

9
.dockerignore Normal file
View File

@ -0,0 +1,9 @@
node_modules
npm-debug.log
.git
.gitignore
*.md
.env
.env.*
data/
*.log

48
Dockerfile Normal file
View File

@ -0,0 +1,48 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install --omit=dev
# Production stage
FROM node:20-alpine
WORKDIR /app
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Copy built node_modules from builder
COPY --from=builder /app/node_modules ./node_modules
# Copy application files
COPY package*.json ./
COPY server.js ./
COPY index.html ./
# Create data directory with proper permissions
RUN mkdir -p /app/data && chown -R nodejs:nodejs /app
# Switch to non-root user
USER nodejs
# Expose port
EXPOSE 3000
# Environment variables
ENV NODE_ENV=production
ENV PORT=3000
ENV DATA_DIR=/app/data
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
# Start the server
CMD ["node", "server.js"]

44
docker-compose.yml Normal file
View File

@ -0,0 +1,44 @@
version: '3.8'
services:
worldplay:
build: .
container_name: worldplay-website
restart: unless-stopped
environment:
- NODE_ENV=production
- PORT=3000
- DATA_DIR=/app/data
- ADMIN_TOKEN=${ADMIN_TOKEN:-worldplay-admin-2026}
volumes:
- worldplay-data:/app/data
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
# Main domain
- "traefik.http.routers.worldplay.rule=Host(`worldplay.jeffemmett.com`)"
- "traefik.http.routers.worldplay.entrypoints=web"
- "traefik.http.routers.worldplay.service=worldplay"
- "traefik.http.services.worldplay.loadbalancer.server.port=3000"
# Optional: Add www subdomain redirect
- "traefik.http.routers.worldplay-www.rule=Host(`www.worldplay.jeffemmett.com`)"
- "traefik.http.routers.worldplay-www.entrypoints=web"
- "traefik.http.routers.worldplay-www.middlewares=worldplay-www-redirect"
- "traefik.http.middlewares.worldplay-www-redirect.redirectregex.regex=^https://www\\.worldplay\\.jeffemmett\\.com/(.*)"
- "traefik.http.middlewares.worldplay-www-redirect.redirectregex.replacement=https://worldplay.jeffemmett.com/$${1}"
networks:
- traefik-public
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
volumes:
worldplay-data:
driver: local
networks:
traefik-public:
external: true

1868
index.html Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "worldplay-website",
"version": "1.0.0",
"description": "WORLDPLAY event website - prefiguring postcapitalist futures through fiction, performance and play",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node server.js"
},
"keywords": [
"worldplay",
"event",
"commons",
"postcapitalism",
"futures"
],
"author": "WORLDPLAY Collective",
"license": "MIT",
"dependencies": {
"express": "^4.18.2"
},
"engines": {
"node": ">=18.0.0"
}
}

208
server.js Normal file
View File

@ -0,0 +1,208 @@
const express = require('express');
const fs = require('fs').promises;
const path = require('path');
const app = express();
const PORT = process.env.PORT || 3000;
const DATA_DIR = process.env.DATA_DIR || './data';
const REGISTRATIONS_FILE = path.join(DATA_DIR, 'registrations.json');
// Middleware
app.use(express.json());
app.use(express.static('.'));
// Ensure data directory exists
async function ensureDataDir() {
try {
await fs.mkdir(DATA_DIR, { recursive: true });
try {
await fs.access(REGISTRATIONS_FILE);
} catch {
await fs.writeFile(REGISTRATIONS_FILE, '[]');
}
} catch (error) {
console.error('Error creating data directory:', error);
}
}
// Load registrations
async function loadRegistrations() {
try {
const data = await fs.readFile(REGISTRATIONS_FILE, 'utf8');
return JSON.parse(data);
} catch {
return [];
}
}
// Save registrations
async function saveRegistrations(registrations) {
await fs.writeFile(REGISTRATIONS_FILE, JSON.stringify(registrations, null, 2));
}
// Registration endpoint
app.post('/api/register', async (req, res) => {
try {
const { firstName, lastName, email, location, role, interests, contribute, message } = req.body;
// Validation
if (!firstName || !lastName || !email) {
return res.status(400).json({ error: 'First name, last name, and email are required' });
}
if (!email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
return res.status(400).json({ error: 'Please provide a valid email address' });
}
// Load existing registrations
const registrations = await loadRegistrations();
// Check for duplicate email
if (registrations.some(r => r.email.toLowerCase() === email.toLowerCase())) {
return res.status(400).json({ error: 'This email is already registered' });
}
// Create new registration
const registration = {
id: Date.now().toString(36) + Math.random().toString(36).substr(2),
firstName: firstName.trim(),
lastName: lastName.trim(),
email: email.toLowerCase().trim(),
location: location?.trim() || '',
role: role || '',
interests: interests || [],
contribute: contribute || '',
message: message?.trim() || '',
registeredAt: new Date().toISOString(),
ipAddress: req.ip || req.connection.remoteAddress
};
// Save registration
registrations.push(registration);
await saveRegistrations(registrations);
console.log(`New registration: ${registration.firstName} ${registration.lastName} <${registration.email}>`);
res.json({
success: true,
message: 'Registration successful',
id: registration.id
});
} catch (error) {
console.error('Registration error:', error);
res.status(500).json({ error: 'An error occurred. Please try again.' });
}
});
// Admin endpoint to view registrations (protected by simple token)
app.get('/api/registrations', async (req, res) => {
const token = req.headers['x-admin-token'] || req.query.token;
const adminToken = process.env.ADMIN_TOKEN || 'worldplay-admin-2026';
if (token !== adminToken) {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
const registrations = await loadRegistrations();
res.json({
count: registrations.length,
registrations: registrations.map(r => ({
...r,
ipAddress: undefined // Don't expose IP in admin view
}))
});
} catch (error) {
res.status(500).json({ error: 'Failed to load registrations' });
}
});
// Export registrations as CSV
app.get('/api/registrations/export', async (req, res) => {
const token = req.headers['x-admin-token'] || req.query.token;
const adminToken = process.env.ADMIN_TOKEN || 'worldplay-admin-2026';
if (token !== adminToken) {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
const registrations = await loadRegistrations();
const headers = ['ID', 'First Name', 'Last Name', 'Email', 'Location', 'Role', 'Interests', 'Contribute', 'Message', 'Registered At'];
const rows = registrations.map(r => [
r.id,
r.firstName,
r.lastName,
r.email,
r.location,
r.role,
Array.isArray(r.interests) ? r.interests.join('; ') : r.interests,
r.contribute,
r.message.replace(/"/g, '""'),
r.registeredAt
]);
const csv = [
headers.join(','),
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
].join('\n');
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename=worldplay-registrations.csv');
res.send(csv);
} catch (error) {
res.status(500).json({ error: 'Failed to export registrations' });
}
});
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Stats endpoint
app.get('/api/stats', async (req, res) => {
try {
const registrations = await loadRegistrations();
const stats = {
totalRegistrations: registrations.length,
byRole: {},
byInterest: {},
byContribute: {}
};
registrations.forEach(r => {
// Count by role
if (r.role) {
stats.byRole[r.role] = (stats.byRole[r.role] || 0) + 1;
}
// Count by interest
if (Array.isArray(r.interests)) {
r.interests.forEach(interest => {
stats.byInterest[interest] = (stats.byInterest[interest] || 0) + 1;
});
}
// Count by contribute
if (r.contribute) {
stats.byContribute[r.contribute] = (stats.byContribute[r.contribute] || 0) + 1;
}
});
res.json(stats);
} catch (error) {
res.status(500).json({ error: 'Failed to calculate stats' });
}
});
// Start server
ensureDataDir().then(() => {
app.listen(PORT, '0.0.0.0', () => {
console.log(`WORLDPLAY server running on port ${PORT}`);
console.log(`Admin token: ${process.env.ADMIN_TOKEN || 'worldplay-admin-2026'}`);
});
});