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:
commit
52000c0f5a
|
|
@ -0,0 +1,9 @@
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
data/
|
||||||
|
*.log
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue