← back to Site Factory
admin/server.js
157 lines
// sf-admin — Wix-like editor for Site Factory
// Google OAuth-gated; UI talks to orchestrator on :9880.
const express = require('express');
const helmet = require('helmet');
const session = require('express-session');
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const path = require('path');
const axios = require('axios');
const PORT = parseInt(process.env.PORT || '9883', 10);
const ORCH = process.env.ORCHESTRATOR_URL || 'http://127.0.0.1:9880';
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || 'steve@designerwallcoverings.com')
.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
const DEV_BYPASS = process.env.SF_ADMIN_DEV === '1';
const SESSION_SECRET = process.env.SESSION_SECRET;
if (!SESSION_SECRET || SESSION_SECRET.length < 32) {
throw new Error('SESSION_SECRET must be set to at least 32 characters.');
}
// SECURITY (P0 fix 2026-05-04): refuse to start with DEV_BYPASS in production.
// SF_ADMIN_DEV=1 stubs in a fake admin user and skips the OAuth gate entirely;
// it must NEVER be set with NODE_ENV=production.
if (DEV_BYPASS && process.env.NODE_ENV === 'production') {
throw new Error('SF_ADMIN_DEV=1 cannot be combined with NODE_ENV=production — refusing to start.');
}
const app = express();
// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
app.use(helmet({ contentSecurityPolicy: false }));
app.use(express.json({ limit: '1mb' }));
app.use(session({
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production', // P1 fix 2026-05-04: cookie must be Secure when nginx terminates TLS
maxAge: 7 * 24 * 60 * 60 * 1000,
},
}));
app.use(passport.initialize());
app.use(passport.session());
passport.serializeUser((u, done) => done(null, u));
passport.deserializeUser((u, done) => done(null, u));
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: process.env.GOOGLE_CALLBACK_URL || `http://127.0.0.1:${PORT}/auth/google/callback`,
}, (_at, _rt, profile, done) => {
const email = (profile.emails && profile.emails[0] && profile.emails[0].value || '').toLowerCase();
if (!ADMIN_EMAILS.includes(email)) return done(null, false, { message: 'not authorized' });
return done(null, { email, name: profile.displayName, id: profile.id });
}));
}
function requireAuth(req, res, next) {
if (DEV_BYPASS) {
req.user = req.user || { email: 'steve@designerwallcoverings.com', name: 'Dev', id: 'dev' };
return next();
}
if (req.isAuthenticated && req.isAuthenticated()) return next();
if (req.path.startsWith('/api/') || req.path.startsWith('/proxy/')) {
return res.status(401).json({ error: 'auth required' });
}
return res.redirect('/');
}
// ─── auth routes ────────────────────────────────────────────────────────
app.get('/auth/google',
(req, res, next) => {
if (!process.env.GOOGLE_CLIENT_ID) {
return res.status(500).send('Google OAuth not configured. Set GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET, or SF_ADMIN_DEV=1.');
}
passport.authenticate('google', { scope: ['profile', 'email'] })(req, res, next);
}
);
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/?err=denied' }),
(_req, res) => res.redirect('/editor')
);
app.post('/auth/logout', (req, res) => {
req.logout(() => res.json({ ok: true }));
});
app.get('/api/me', (req, res) => {
if (DEV_BYPASS) return res.json({ user: { email: 'steve@designerwallcoverings.com', name: 'Dev (bypass)' }, dev: true });
if (req.isAuthenticated && req.isAuthenticated()) return res.json({ user: req.user });
res.status(401).json({ error: 'not authenticated' });
});
// ─── thin proxy to orchestrator (all writes auth-gated) ─────────────────
async function proxyOrch(method, urlPath, body) {
const url = ORCH + urlPath;
try {
const r = await axios({ method, url, data: body, timeout: 8000, validateStatus: () => true });
return { status: r.status, data: r.data };
} catch (e) {
return { status: 502, data: { error: 'orchestrator unreachable', detail: e.message } };
}
}
app.get('/api/sites', requireAuth, async (_req, res) => {
const r = await proxyOrch('GET', '/sites');
res.status(r.status).json(r.data);
});
app.get('/api/palettes', requireAuth, async (_req, res) => {
const r = await proxyOrch('GET', '/palettes');
res.status(r.status).json(r.data);
});
app.get('/api/sites/:domain/findings', requireAuth, async (req, res) => {
const r = await proxyOrch('GET', `/sites/${encodeURIComponent(req.params.domain)}/findings`);
res.status(r.status).json(r.data);
});
app.patch('/api/sites/:domain/palette', requireAuth, async (req, res) => {
const r = await proxyOrch('PATCH', `/sites/${encodeURIComponent(req.params.domain)}/palette`, req.body);
res.status(r.status).json(r.data);
});
app.patch('/api/sites/:domain/copy', requireAuth, async (req, res) => {
const r = await proxyOrch('PATCH', `/sites/${encodeURIComponent(req.params.domain)}/copy`, req.body);
res.status(r.status).json(r.data);
});
app.post('/api/actions', requireAuth, async (req, res) => {
const r = await proxyOrch('POST', '/actions', req.body);
res.status(r.status).json(r.data);
});
// ─── pages ──────────────────────────────────────────────────────────────
app.get('/', (req, res) => {
if ((req.isAuthenticated && req.isAuthenticated()) || DEV_BYPASS) return res.redirect('/editor');
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
app.get('/editor', requireAuth, (_req, res) => {
res.sendFile(path.join(__dirname, 'public', 'editor.html'));
});
app.get('/editor/:domain', requireAuth, (_req, res) => {
res.sendFile(path.join(__dirname, 'public', 'editor.html'));
});
app.get('/health', (_req, res) => res.json({ ok: true, ts: new Date().toISOString() }));
// static (after page routes so /editor isn't shadowed)
app.use(express.static(path.join(__dirname, 'public'), { index: false }));
app.listen(PORT, '127.0.0.1', () => {
console.log(`sf-admin on :${PORT} (dev=${DEV_BYPASS}) → orch=${ORCH}`);
});