← back to Jill Website

src/server.ts

285 lines

import express, { Request, Response, NextFunction, Application } from 'express';
import session from 'express-session';
import path from 'path';
import dotenv from 'dotenv';
import { PropertyModel } from './models/Property';
import propertiesRouter from './routes/properties';
import inquiriesRouter from './routes/inquiries';
import activitiesRouter from './routes/activities';
import adminRouter from './routes/admin';
import linkValidationRouter from './routes/linkValidation';
import { requireAuth } from './middleware/auth';
import { formatPhoneNumber, formatPhoneForTel } from './utils/phoneFormatter';

// Site-wide basic auth middleware
const siteAuth = (req: Request, res: Response, next: NextFunction): void => {
  // Skip auth for health check
  if (req.path === '/health') {
    next();
    return;
  }

  const authHeader = req.headers.authorization;

  if (!authHeader) {
    res.setHeader('WWW-Authenticate', 'Basic realm="Jill Website"');
    res.status(401).send('Authentication required');
    return;
  }

  const credentials = Buffer.from(authHeader.split(' ')[1], 'base64').toString().split(':');
  if (credentials[0] === SITE_USERNAME && credentials[1] === SITE_PASSWORD) {
    next();
  } else {
    res.setHeader('WWW-Authenticate', 'Basic realm="Jill Website"');
    res.status(401).send('Invalid credentials');
  }
};

// Extend session data type
declare module 'express-session' {
  interface SessionData {
    adminUser?: {
      username: string;
      loginTime: number;
    };
  }
}

// Load environment variables
dotenv.config();

function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(`Missing required environment variable: ${name}`);
  }
  return value;
}

const app: Application = express();
const PORT = process.env.PORT || 3000;
const SITE_USERNAME = requireEnv('SITE_USERNAME');
const SITE_PASSWORD = requireEnv('SITE_PASSWORD');
const SESSION_SECRET = requireEnv('SESSION_SECRET');
requireEnv('ADMIN_PASSWORD');

// Session middleware (must be before routes that use it)
app.use(session({
  secret: SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: process.env.NODE_ENV === 'production',
    httpOnly: true,
    maxAge: 24 * 60 * 60 * 1000 // 24 hours
  }
}));

// Site-wide authentication (before other routes)
app.use(siteAuth);

// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// SECURITY: 404-guard for snapshot/backup paths. Even if a .bak / .pre-* /
// .orig / .rej / .swp / ~ file ever slips into public/ via a sloppy edit,
// it must NEVER be servable over HTTP. Block before static handler runs.
app.use((req: Request, res: Response, next: NextFunction) => {
  if (/(\.bak(\.[^/]+)?|\.pre-[^/]+|\.orig|\.rej|\.swp|~)$/i.test(req.path)) {
    res.status(404).send('Not Found');
    return;
  }
  next();
});

// Static files
app.use(express.static(path.join(__dirname, '../public')));

// View engine setup
app.set('views', path.join(__dirname, '../views'));
app.set('view engine', 'ejs');

// Make phone formatter functions available in all views
app.use((req: Request, res: Response, next: NextFunction) => {
  res.locals.formatPhoneNumber = formatPhoneNumber;
  res.locals.formatPhoneForTel = formatPhoneForTel;
  next();
});

// API Routes
app.use('/api', propertiesRouter);
app.use('/api', inquiriesRouter);
app.use('/api', activitiesRouter);
app.use('/api/link-validation', linkValidationRouter);

// Admin API Routes
app.use('/api/admin', adminRouter);

// Basic health check route
app.get('/health', (req: Request, res: Response) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// Home route - Render homepage with properties
app.get('/', async (req: Request, res: Response) => {
  try {
    const propertyModel = new PropertyModel();
    const properties = await propertyModel.findAll();

    res.render('index', {
      properties,
      currentYear: new Date().getFullYear()
    });
  } catch (error) {
    console.error('Error loading homepage:', error);
    // Render with empty properties array on error
    res.render('index', {
      properties: [],
      currentYear: new Date().getFullYear()
    });
  }
});

// Beaches route
app.get('/beaches', (req: Request, res: Response) => {
  res.render('beaches', {
    currentYear: new Date().getFullYear(),
    currentPage: 'beaches'
  });
});

// Surfing route
app.get('/surfing', (req: Request, res: Response) => {
  res.render('surfing', {
    currentYear: new Date().getFullYear(),
    currentPage: 'surfing'
  });
});

// Yoga & Wellness route
app.get('/yoga-wellness', (req: Request, res: Response) => {
  res.render('yoga-wellness', {
    currentYear: new Date().getFullYear(),
    currentPage: 'yoga-wellness'
  });
});

// Nature Adventures route
app.get('/nature-adventures', (req: Request, res: Response) => {
  res.render('nature-adventures', {
    currentYear: new Date().getFullYear(),
    currentPage: 'nature-adventures'
  });
});

// Culinary Delights route
app.get('/culinary-delights', (req: Request, res: Response) => {
  res.render('culinary-delights', {
    currentYear: new Date().getFullYear(),
    currentPage: 'culinary-delights'
  });
});

// Adventure Activities route
app.get('/adventure-activities', (req: Request, res: Response) => {
  res.render('adventure-activities', {
    currentYear: new Date().getFullYear(),
    currentPage: 'adventure-activities'
  });
});

// Gallery Demo route
app.get('/gallery-demo', (req: Request, res: Response) => {
  res.render('gallery-demo', {
    currentYear: new Date().getFullYear()
  });
});

// Video demo route
app.get('/video-demo', (req: Request, res: Response) => {
  res.render('video-demo', {
    currentYear: new Date().getFullYear()
  });
});

// Luna property page route
app.get('/properties/luna', (req: Request, res: Response) => {
  res.render('properties-luna', {
    currentYear: new Date().getFullYear()
  });
});

// Sol property page route
app.get('/properties/sol', (req: Request, res: Response) => {
  res.render('properties-sol', {
    currentYear: new Date().getFullYear()
  });
});

// Casita property page route
app.get('/properties/casita', (req: Request, res: Response) => {
  res.render('properties-casita', {
    currentYear: new Date().getFullYear()
  });
});

// Main House property page route
app.get('/properties/main-house', (req: Request, res: Response) => {
  res.render('properties-main-house', {
    currentYear: new Date().getFullYear()
  });
});

// Property detail route
app.get('/property/:id', async (req: Request, res: Response) => {
  try {
    const idParam = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
    const propertyId = parseInt(idParam, 10);

    if (isNaN(propertyId)) {
      return res.status(400).render('error', {
        message: 'Invalid property ID',
        currentYear: new Date().getFullYear()
      });
    }

    const propertyModel = new PropertyModel();
    const property = await propertyModel.findById(propertyId);

    if (!property) {
      return res.status(404).render('error', {
        message: 'Property not found',
        currentYear: new Date().getFullYear()
      });
    }

    res.render('property-detail', {
      property,
      currentYear: new Date().getFullYear()
    });
  } catch (error) {
    console.error('Error loading property detail:', error);
    res.status(500).render('error', {
      message: 'Failed to load property details',
      currentYear: new Date().getFullYear()
    });
  }
});

// 404 handler
app.use((req: Request, res: Response) => {
  res.status(404).json({ error: 'Not Found' });
});

// Start server
if (require.main === module) {
  app.listen(PORT, () => {
    console.log(`🚀 Server running on port ${PORT}`);
    console.log(`📍 Environment: ${process.env.NODE_ENV || 'development'}`);
  });
}

export default app;