213 lines
6.2 KiB
JavaScript
213 lines
6.2 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
|
|
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); |