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