220 lines
6.4 KiB
JavaScript

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;
// Trust proxy for rate limiting (required for Coolify/reverse proxy)
app.set('trust proxy', 1);
// 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 with proper permissions
async function ensureDataDir() {
try {
await fs.mkdir(DATA_DIR, { recursive: true });
// Fix permissions for volume-mounted directory
try {
await fs.chmod(DATA_DIR, 0o755);
console.log(`📁 Data directory ready: ${DATA_DIR}`);
} catch (permErr) {
console.warn('Could not set directory permissions:', permErr.message);
}
} 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);