import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; import rateLimit from 'express-rate-limit'; import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const PORT = process.env.PORT || 3001; // Data directory - will be mounted as Docker volume in production const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data'); const BOOKS_FILE = path.join(DATA_DIR, 'books.json'); // Security middleware app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], scriptSrc: ["'self'"], imgSrc: ["'self'", "data:", "https:"], connectSrc: ["'self'"], fontSrc: ["'self'"], objectSrc: ["'none'"], mediaSrc: ["'self'"], frameSrc: ["'none'"], }, }, crossOriginEmbedderPolicy: false // Allow for better browser compatibility })); // Rate limiting const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // Limit each IP to 100 requests per windowMs message: { error: 'Too many requests from this IP, please try again later.', retryAfter: 15 * 60 // seconds }, standardHeaders: true, legacyHeaders: false, }); const strictApiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 20, // Limit POST requests to 20 per windowMs message: { error: 'Too many save requests from this IP, please try again later.', retryAfter: 15 * 60 // seconds }, standardHeaders: true, legacyHeaders: false, }); // Apply rate limiting to API routes app.use('/api', apiLimiter); // CORS configuration const corsOptions = { origin: true, // Allow all origins since Coolify reverse proxy handles domain security credentials: false, optionsSuccessStatus: 200 }; app.use(cors(corsOptions)); app.use(express.json({ limit: '1mb', // Reduced from 10mb for security strict: true })); // Serve static files in production if (process.env.NODE_ENV === 'production') { const staticPath = path.join(__dirname, '..', 'dist'); app.use(express.static(staticPath)); } // Ensure data directory exists async function ensureDataDir() { try { await fs.mkdir(DATA_DIR, { recursive: true }); console.log(`📁 Data directory ready: ${DATA_DIR}`); } catch (err) { console.error('Failed to create data directory:', err.message); } } // Security helper - basic input validation function sanitizeBooks(data) { if (!Array.isArray(data)) return []; return data.map(book => { if (!book || typeof book !== 'object') return null; // Basic validation and sanitization const sanitized = { id: Number(book.id) || 0, title: String(book.title || '').slice(0, 200), author: String(book.author || '').slice(0, 200), totalPages: Math.max(1, Math.min(100000, Number(book.totalPages) || 1)), currentPage: Math.max(0, Number(book.currentPage) || 0), startDate: String(book.startDate || ''), targetDate: String(book.targetDate || ''), readingHistory: {}, createdAt: String(book.createdAt || new Date().toISOString()) }; // Validate and sanitize reading history if (book.readingHistory && typeof book.readingHistory === 'object') { for (const [dateKey, pages] of Object.entries(book.readingHistory)) { // Validate YYYY-MM-DD format if (/^\d{4}-\d{2}-\d{2}$/.test(dateKey)) { const pageNum = Number(pages); if (pageNum > 0 && pageNum <= sanitized.totalPages) { sanitized.readingHistory[dateKey] = pageNum; } } } } // Ensure currentPage doesn't exceed totalPages sanitized.currentPage = Math.min(sanitized.currentPage, sanitized.totalPages); return sanitized; }).filter(Boolean); } // API Routes // GET /api/books - Load books from JSON file app.get('/api/books', async (req, res) => { try { let books = []; try { const data = await fs.readFile(BOOKS_FILE, 'utf8'); const parsed = JSON.parse(data); books = sanitizeBooks(parsed.books || parsed); } catch (err) { // File doesn't exist or is invalid - return empty array console.log('📚 No existing books file, starting fresh'); } res.json(books); } catch (err) { console.error('Error loading books:', err.message); res.status(500).json({ error: 'Failed to load books' }); } }); // POST /api/books - Save books to JSON file app.post('/api/books', strictApiLimiter, async (req, res) => { try { const books = sanitizeBooks(req.body); const dataToSave = { books, lastModified: new Date().toISOString(), version: '1.0' }; await fs.writeFile(BOOKS_FILE, JSON.stringify(dataToSave, null, 2), 'utf8'); console.log(`💾 Saved ${books.length} books to ${BOOKS_FILE}`); res.json({ success: true, count: books.length }); } catch (err) { console.error('Error saving books:', err.message); res.status(500).json({ error: 'Failed to save books' }); } }); // Health check endpoint app.get('/api/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() // Removed dataDir from health check to prevent information disclosure }); }); // Serve React app for all other routes in production if (process.env.NODE_ENV === 'production') { app.get('*', (req, res) => { res.sendFile(path.join(__dirname, '..', 'dist', 'index.html')); }); } // Start server async function startServer() { await ensureDataDir(); app.listen(PORT, () => { console.log(`🚀 Reading Tracker server running on port ${PORT}`); console.log(`📊 API endpoints:`); console.log(` GET /api/books - Load books`); console.log(` POST /api/books - Save books`); console.log(` GET /api/health - Health check`); console.log(`📁 Data directory: ${DATA_DIR}`); if (process.env.NODE_ENV === 'production') { console.log(`🌐 Serving static files from dist/`); } }); } startServer().catch(console.error);