Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ NODE_ENV=development
# Security
CORS_ORIGIN=http://localhost:5173

# Express `trust proxy` value: hop count (1), true/false, or address list ("loopback, 10.0.0.0/8").
# Defaults to 1 in production (one TLS-terminating proxy in front) and false otherwise.
# TRUST_PROXY=1

# API Rate Limiting (Optional - defaults are secure)
# General API rate limiting (15 minutes window)
GENERAL_RATE_LIMIT_WINDOW_MS=900000
Expand Down
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions server/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ dist
# Gatsby files
.cache/
public
# Vite static assets (copied verbatim into client/dist)
!client/public

# vuepress build output
.vuepress/dist
Expand Down
15 changes: 15 additions & 0 deletions server/client/public/_headers
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Cloudflare Pages response headers (applied to the static site and /api/* functions).
# CSP: the SPA injects a <style> element for print rules, so styles allow 'unsafe-inline';
# scripts are bundled by Vite and need only 'self'.
/*
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin

/assets/*
Cache-Control: public, max-age=31536000, immutable
1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"express": "^5.2.1",
"express-rate-limit": "^8.7.0",
"express-slow-down": "^3.1.1",
"helmet": "^8.3.0",
"openai": "^7.17.0",
"zod": "^4.6.5"
},
Expand Down
17 changes: 16 additions & 1 deletion server/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,24 @@ if (missingVars.length > 0) {
}

export const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3001;
export const NODE_ENV = process.env.NODE_ENV || 'development';
export const NODE_ENV = process.env.NODE_ENV || 'production';
export const IS_PRODUCTION = NODE_ENV === 'production';
export const CORS_ORIGIN = process.env.CORS_ORIGIN || 'http://localhost:5173';

// Express `trust proxy` setting. Accepts the same values Express does:
// a hop count ("1"), "true"/"false", or a comma-separated list of
// addresses/CIDRs/presets ("loopback, 10.0.0.0/8"). Defaults to one hop in
// production (TLS-terminating proxy in front) and none in development.
const parseTrustProxy = (value: string | undefined): boolean | number | string => {
if (value === undefined || value === '') return IS_PRODUCTION ? 1 : false;
const normalized = value.trim().toLowerCase();
if (normalized === 'true') return true;
if (normalized === 'false') return false;
if (/^\d+$/.test(normalized)) return parseInt(normalized, 10);
return value.trim();
};
export const TRUST_PROXY = parseTrustProxy(process.env.TRUST_PROXY);

// Initialize OpenAI with explicit configuration
export const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
Expand Down
57 changes: 34 additions & 23 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@
import cors from 'cors';
import dotenv from 'dotenv';
import express, { Request, Response } from 'express';
import helmet from 'helmet';

// Import configuration and routes
import { PORT, CORS_ORIGIN, NODE_ENV } from './config/index.js';
import { PORT, CORS_ORIGIN, NODE_ENV, IS_PRODUCTION, TRUST_PROXY } from './config/index.js';
import { errorHandler } from './middleware/errorHandler.js';
import { generalRateLimit, requestSizeLimit, usageMonitor } from './middleware/rateLimiting.js';
import worksheetRoutes from './routes/worksheet.js';

console.log(`🚀 Starting LearningLab server in ${NODE_ENV} mode...`);

Check warning on line 15 in server/src/index.ts

View workflow job for this annotation

GitHub Actions / verify

Unexpected console statement. Only these console methods are allowed: warn, error

// Load environment variables
dotenv.config({ quiet: true });
Expand Down Expand Up @@ -46,7 +47,8 @@

// Define app first
const app = express();
const isProduction = NODE_ENV === 'production';
const isProduction = IS_PRODUCTION;
app.set('trust proxy', TRUST_PROXY);

// Declare server variable with proper type
type ServerType = ReturnType<typeof app.listen>;
Expand All @@ -54,10 +56,10 @@

// Handle SIGTERM for graceful shutdown
process.on('SIGTERM', () => {
console.log('\n👋 SIGTERM RECEIVED. Shutting down gracefully');

Check warning on line 59 in server/src/index.ts

View workflow job for this annotation

GitHub Actions / verify

Unexpected console statement. Only these console methods are allowed: warn, error
if (server) {
server.close(() => {
console.log('💥 Process terminated!');

Check warning on line 62 in server/src/index.ts

View workflow job for this annotation

GitHub Actions / verify

Unexpected console statement. Only these console methods are allowed: warn, error
});
} else {
process.exit(0);
Expand All @@ -66,29 +68,48 @@

// App and server configuration moved above server declaration

// Security middleware (applied first)
// Security headers. The SPA injects a <style> element for print rules and
// Tailwind emits no inline scripts, so only styles need 'unsafe-inline'.
app.use(
helmet({
contentSecurityPolicy: {
useDefaults: true,
directives: {
'script-src': ["'self'"],
'style-src': ["'self'", "'unsafe-inline'"],
'img-src': ["'self'", 'data:'],
'connect-src': ["'self'"],
'frame-ancestors': ["'none'"],
'form-action': ["'self'"],
'upgrade-insecure-requests': isProduction ? [] : null,
},
},
crossOriginEmbedderPolicy: false,
})
);

// CORS configuration (before rate limiting so preflights are not counted)
const corsOptions = {
origin: CORS_ORIGIN,
credentials: true,
optionsSuccessStatus: 200, // Some legacy browsers choke on 204
};

app.use(cors(corsOptions));

// Security middleware
app.use(requestSizeLimit);
app.use(usageMonitor);
app.use(generalRateLimit);

// Basic request logging middleware
app.use((req: Request, _res: Response, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);

Check warning on line 107 in server/src/index.ts

View workflow job for this annotation

GitHub Actions / verify

Unexpected console statement. Only these console methods are allowed: warn, error
next();
});

// Middleware
app.use(express.json({ limit: '1kb' })); // Enforce JSON size limit
app.use(express.urlencoded({ extended: true, limit: '1kb' })); // Enforce URL encoded size limit

// CORS configuration
const corsOptions = {
origin: CORS_ORIGIN,
credentials: true,
optionsSuccessStatus: 200, // Some legacy browsers choke on 204
};

app.use(cors(corsOptions));

// Simple health check endpoint
app.get('/api/health', (_req: Request, res: Response) => {
Expand All @@ -106,7 +127,7 @@
if (isProduction) {
// Serve the built client (server/client/dist) relative to dist/index.js
const publicPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '../client/dist');
console.log(`Serving static files from: ${publicPath}`);

Check warning on line 130 in server/src/index.ts

View workflow job for this annotation

GitHub Actions / verify

Unexpected console statement. Only these console methods are allowed: warn, error

// Serve static files
app.use(express.static(publicPath));
Expand Down Expand Up @@ -189,25 +210,15 @@

// Start server
server = app.listen(PORT, () => {
console.log(

Check warning on line 213 in server/src/index.ts

View workflow job for this annotation

GitHub Actions / verify

Unexpected console statement. Only these console methods are allowed: warn, error
`\n🚀 Server running on port ${PORT} in ${isProduction ? 'production' : 'development'} mode`
);
console.log(`📅 ${new Date().toISOString()}`);

Check warning on line 216 in server/src/index.ts

View workflow job for this annotation

GitHub Actions / verify

Unexpected console statement. Only these console methods are allowed: warn, error
console.log('----------------------------------------');

Check warning on line 217 in server/src/index.ts

View workflow job for this annotation

GitHub Actions / verify

Unexpected console statement. Only these console methods are allowed: warn, error

if (!isProduction) {
console.log(`🔗 Frontend dev server: http://localhost:5173`);

Check warning on line 220 in server/src/index.ts

View workflow job for this annotation

GitHub Actions / verify

Unexpected console statement. Only these console methods are allowed: warn, error
console.log(`🔗 API Base URL: http://localhost:${PORT}/api`);
console.log(`🔗 Health Check: http://localhost:${PORT}/api/health`);
}
});

// Handle unhandled promise rejections
process.on('unhandledRejection', (err: Error) => {
console.error('Unhandled Rejection:', err);
if (server) {
server.close(() => process.exit(1));
} else {
process.exit(1);
}
});
27 changes: 25 additions & 2 deletions server/src/middleware/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
import { Request, Response, NextFunction, ErrorRequestHandler } from 'express';

import { IS_PRODUCTION } from '../config/index.js';
import { AppError, AppErrorInterface } from '../utils/AppError.js';

// Client-side body errors raised by express.json (body-parser `type` codes).
const BODY_PARSER_ERRORS: Record<string, { statusCode: number; message: string }> = {
'entity.parse.failed': { statusCode: 400, message: 'Request body must be valid JSON' },
'entity.too.large': { statusCode: 413, message: 'Request payload must be less than 1KB' },
'encoding.unsupported': { statusCode: 415, message: 'Unsupported content encoding' },
'charset.unsupported': { statusCode: 415, message: 'Unsupported charset' },
'entity.verify.failed': { statusCode: 403, message: 'Request body failed verification' },
'request.aborted': { statusCode: 400, message: 'Request aborted' },
'request.size.invalid': { statusCode: 400, message: 'Request size did not match Content-Length' },
'parameters.too.many': { statusCode: 413, message: 'Too many parameters' },
};

// Error handling middleware with proper type annotations
export const errorHandler: ErrorRequestHandler = (
err: any,
Expand All @@ -22,7 +35,7 @@ export const errorHandler: ErrorRequestHandler = (
};

// Log error in development
if (process.env.NODE_ENV === 'development') {
if (!IS_PRODUCTION) {
console.error('❌ Error:', {
message: error.message,
stack: error.stack,
Expand All @@ -35,6 +48,16 @@ export const errorHandler: ErrorRequestHandler = (
}

// Handle different types of errors
const bodyError = typeof err.type === 'string' ? BODY_PARSER_ERRORS[err.type] : undefined;
if (bodyError) {
res.status(bodyError.statusCode).json({
status: 'error',
error: 'Invalid request',
message: bodyError.message,
});
return next();
}

if (error.name === 'ValidationError') {
res.status(400).json({
status: 'error',
Expand Down Expand Up @@ -90,7 +113,7 @@ export const errorHandler: ErrorRequestHandler = (
res.status(statusCode).json({
status: 'error',
message: 'Something went wrong!',
...(process.env.NODE_ENV === 'development' && {
...(!IS_PRODUCTION && {
error: error.message,
stack: error.stack,
}),
Expand Down
4 changes: 0 additions & 4 deletions server/src/middleware/rateLimiting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,6 @@ export const worksheetRateLimit = rateLimit({
},
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
// Skip requests that don't consume resources
skip: req => {
return req.path === '/api/health';
},
});

// Progressive delay for worksheet requests to discourage rapid requests
Expand Down
Loading