← 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}`);
});