// server.js - Main server file for MangaShelf const express = require('express'); const cors = require('cors'); const path = require('path'); const fs = require('fs'); const { promisify } = require('util'); const readdir = promisify(fs.readdir); const stat = promisify(fs.stat); const unlink = promisify(fs.unlink); const mkdir = promisify(fs.mkdir); const sqlite3 = require('sqlite3').verbose(); const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const multer = require('multer'); const unzipper = require('unzipper'); const unrar = require('node-unrar-js'); const sharp = require('sharp'); const rateLimit = require('express-rate-limit'); require('dotenv').config(); const app = express(); const PORT = process.env.PORT || 3000; const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this'; // Rate limiting for auth endpoints const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, // Limit each IP to 5 requests per windowMs message: 'Too many auth attempts, please try again later', standardHeaders: true, legacyHeaders: false, }); // Middleware app.use(cors()); app.use(express.json()); app.use('/covers', express.static('covers')); app.use('/pages', express.static('library')); // Create necessary directories const initDirectories = async () => { const dirs = ['library', 'covers', 'uploads', 'data']; for (const dir of dirs) { await mkdir(dir, { recursive: true }); } }; // Initialize SQLite database const db = new sqlite3.Database('./data/mangashelf.db'); const initDatabase = () => { db.serialize(() => { // Users table db.run(`CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); // Manga/Comics table db.run(`CREATE TABLE IF NOT EXISTS manga ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, author TEXT, description TEXT, cover_image TEXT, total_chapters INTEGER DEFAULT 0, file_path TEXT, added_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); // Chapters table db.run(`CREATE TABLE IF NOT EXISTS chapters ( id INTEGER PRIMARY KEY AUTOINCREMENT, manga_id INTEGER NOT NULL, chapter_number REAL NOT NULL, title TEXT, pages INTEGER DEFAULT 0, file_path TEXT, added_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (manga_id) REFERENCES manga (id) ON DELETE CASCADE )`); // Reading progress table db.run(`CREATE TABLE IF NOT EXISTS reading_progress ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, manga_id INTEGER NOT NULL, chapter_id INTEGER, page_number INTEGER DEFAULT 1, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (manga_id) REFERENCES manga (id) ON DELETE CASCADE, FOREIGN KEY (chapter_id) REFERENCES chapters (id) ON DELETE CASCADE, UNIQUE(user_id, manga_id) )`); }); }; // Auth middleware const authenticateToken = (req, res, next) => { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; if (!token) { return res.status(401).json({ error: 'Access token required' }); } jwt.verify(token, JWT_SECRET, (err, user) => { if (err) return res.status(403).json({ error: 'Invalid token' }); req.user = user; next(); }); }; // ==================== AUTH ROUTES ==================== // Register new user app.post('/api/auth/register', authLimiter, async (req, res) => { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Username and password required' }); } try { const hashedPassword = await bcrypt.hash(password, 10); db.run( 'INSERT INTO users (username, password) VALUES (?, ?)', [username, hashedPassword], function(err) { if (err) { if (err.message.includes('UNIQUE')) { return res.status(400).json({ error: 'Username already exists' }); } return res.status(500).json({ error: 'Database error' }); } const token = jwt.sign( { id: this.lastID, username }, JWT_SECRET, { expiresIn: '7d' } ); res.json({ message: 'User created successfully', token, user: { id: this.lastID, username } }); } ); } catch (error) { res.status(500).json({ error: 'Server error' }); } }); // Login app.post('/api/auth/login', authLimiter, async (req, res) => { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Username and password required' }); } db.get( 'SELECT * FROM users WHERE username = ?', [username], async (err, user) => { if (err) { return res.status(500).json({ error: 'Database error' }); } if (!user) { return res.status(401).json({ error: 'Invalid credentials' }); } const validPassword = await bcrypt.compare(password, user.password); if (!validPassword) { return res.status(401).json({ error: 'Invalid credentials' }); } const token = jwt.sign( { id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '7d' } ); res.json({ message: 'Login successful', token, user: { id: user.id, username: user.username } }); } ); }); // ==================== LIBRARY ROUTES ==================== // Get all manga in library app.get('/api/library', authenticateToken, (req, res) => { db.all('SELECT * FROM manga ORDER BY updated_at DESC', [], (err, rows) => { if (err) { return res.status(500).json({ error: 'Database error' }); } res.json(rows); }); }); // Get single manga details with chapters app.get('/api/manga/:id', authenticateToken, (req, res) => { const mangaId = req.params.id; db.get('SELECT * FROM manga WHERE id = ?', [mangaId], (err, manga) => { if (err) { return res.status(500).json({ error: 'Database error' }); } if (!manga) { return res.status(404).json({ error: 'Manga not found' }); } db.all( 'SELECT * FROM chapters WHERE manga_id = ? ORDER BY chapter_number', [mangaId], (err, chapters) => { if (err) { return res.status(500).json({ error: 'Database error' }); } // Get reading progress db.get( 'SELECT * FROM reading_progress WHERE user_id = ? AND manga_id = ?', [req.user.id, mangaId], (err, progress) => { if (err) { return res.status(500).json({ error: 'Database error' }); } res.json({ ...manga, chapters, progress }); } ); } ); }); }); // Get chapter pages app.get('/api/chapters/:id/pages', authenticateToken, async (req, res) => { const chapterId = req.params.id; db.get('SELECT * FROM chapters WHERE id = ?', [chapterId], async (err, chapter) => { if (err) { return res.status(500).json({ error: 'Database error' }); } if (!chapter) { return res.status(404).json({ error: 'Chapter not found' }); } try { const chapterPath = chapter.file_path; const files = await readdir(chapterPath); // Filter and sort image files const imageFiles = files .filter(file => /\.(jpg|jpeg|png|webp|gif)$/i.test(file)) .sort((a, b) => { // Natural sort for page numbers const aNum = parseInt(a.match(/\d+/) || 0); const bNum = parseInt(b.match(/\d+/) || 0); return aNum - bNum; }); const pages = imageFiles.map((file, index) => ({ page_number: index + 1, url: `/pages/${path.relative('library', path.join(chapterPath, file))}` })); res.json({ chapter_id: chapterId, total_pages: pages.length, pages }); } catch (error) { res.status(500).json({ error: 'Error reading chapter files' }); } }); }); // Update reading progress app.post('/api/progress', authenticateToken, (req, res) => { const { manga_id, chapter_id, page_number } = req.body; const user_id = req.user.id; db.run( `INSERT INTO reading_progress (user_id, manga_id, chapter_id, page_number, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(user_id, manga_id) DO UPDATE SET chapter_id = ?, page_number = ?, updated_at = CURRENT_TIMESTAMP`, [user_id, manga_id, chapter_id, page_number, chapter_id, page_number], function(err) { if (err) { return res.status(500).json({ error: 'Database error' }); } res.json({ message: 'Progress updated successfully' }); } ); }); // ==================== UPLOAD ROUTES ==================== // Configure multer for file uploads const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, 'uploads/'); }, filename: (req, file, cb) => { cb(null, Date.now() + '-' + file.originalname); } }); const upload = multer({ storage, fileFilter: (req, file, cb) => { const allowedTypes = /cbz|zip|cbr|rar/; const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase()); if (extname) { cb(null, true); } else { cb(new Error('Only CBZ, CBR, ZIP files are allowed'), false); } }, limits: { fileSize: (process.env.MAX_FILE_SIZE || 500) * 1024 * 1024 // Convert MB to bytes } }); // Upload new manga/comic app.post('/api/upload', authenticateToken, upload.single('comic'), async (req, res) => { if (!req.file) { return res.status(400).json({ error: 'No file uploaded' }); } const { title, author, description } = req.body; const filePath = req.file.path; try { // Create directory for this manga const mangaDir = path.join('library', `manga_${Date.now()}`); await mkdir(mangaDir, { recursive: true }); // Extract comic file await extractComic(filePath, mangaDir); // Get first image as cover const coverPath = await generateCover(mangaDir); // Save to database db.run( `INSERT INTO manga (title, author, description, cover_image, file_path) VALUES (?, ?, ?, ?, ?)`, [title || req.file.originalname, author, description, coverPath, mangaDir], async function(err) { if (err) { return res.status(500).json({ error: 'Database error' }); } const mangaId = this.lastID; // Scan for chapters await scanChapters(mangaId, mangaDir); // Clean up uploaded file await unlink(filePath); res.json({ message: 'Manga uploaded successfully', manga_id: mangaId }); } ); } catch (error) { console.error('Upload error:', error); res.status(500).json({ error: 'Error processing upload', details: error.message }); } }); // ==================== HELPER FUNCTIONS ==================== async function extractComic(filePath, destDir) { const fileExt = path.extname(filePath).toLowerCase(); console.log('Extracting file:', filePath, 'Extension:', fileExt); if (fileExt === '.cbz' || fileExt === '.zip') { // Use unzipper for .cbz and .zip files return new Promise((resolve, reject) => { fs.createReadStream(filePath) .pipe(unzipper.Extract({ path: destDir })) .on('close', () => { console.log('ZIP extraction complete'); resolve(); }) .on('error', reject); }); } else if (fileExt === '.cbr' || fileExt === '.rar') { // Use node-unrar-js for .cbr and .rar files try { console.log('Reading RAR file...'); const buf = fs.readFileSync(filePath); // Create extractor const extractor = await unrar.createExtractorFromData({ data: buf }); console.log('Extracting RAR contents...'); // Extract all files const extracted = extractor.extractAll(); // Get the list of extracted files const extractedFiles = [...extracted.files]; for (const file of extractedFiles) { const { extraction, fileHeader } = file; // Skip directories if (fileHeader.flags.directory) continue; const destPath = path.join(destDir, fileHeader.name); // Create directory if needed const dirPath = path.dirname(destPath); if (!fs.existsSync(dirPath)) { await mkdir(dirPath, { recursive: true }); } // Write file if (extraction) { fs.writeFileSync(destPath, extraction); } } console.log('RAR extraction complete'); } catch (err) { console.error('RAR extraction error:', err); // Fallback: try using the command line unrar if available try { console.log('Trying fallback RAR extraction...'); await extractRarFallback(filePath, destDir); } catch (fallbackError) { console.error('Fallback RAR extraction also failed:', fallbackError); throw new Error('Failed to extract RAR file: ' + err.message); } } } else { throw new Error('Unsupported file type: ' + fileExt); } } // Fallback RAR extraction using command line async function extractRarFallback(filePath, destDir) { return new Promise((resolve, reject) => { const { spawn } = require('child_process'); // Try different unrar command names const commands = ['unrar', 'rar', 'unrar-free']; const tryExtract = (index) => { if (index >= commands.length) { reject(new Error('No RAR extraction command found. Please install unrar.')); return; } const command = commands[index]; const args = ['x', '-o+', filePath, destDir + '/']; console.log(`Trying command: ${command} ${args.join(' ')}`); const process = spawn(command, args); process.on('close', (code) => { if (code === 0) { console.log('Fallback RAR extraction successful'); resolve(); } else { console.log(`Command ${command} failed with code ${code}, trying next...`); tryExtract(index + 1); } }); process.on('error', (err) => { console.log(`Command ${command} not found: ${err.message}`); tryExtract(index + 1); }); }; tryExtract(0); }); } async function generateCover(mangaDir) { try { // Recursively find image files const findImageFiles = async (dir) => { const items = await readdir(dir); const imageFiles = []; for (const item of items) { const itemPath = path.join(dir, item); const stats = await stat(itemPath); if (stats.isDirectory()) { const subImages = await findImageFiles(itemPath); imageFiles.push(...subImages); } else if (/\.(jpg|jpeg|png|webp)$/i.test(item)) { imageFiles.push(itemPath); } } return imageFiles.sort(); }; const imageFiles = await findImageFiles(mangaDir); if (imageFiles.length > 0) { const firstImage = imageFiles[0]; const coverName = `cover_${Date.now()}.jpg`; const coverPath = path.join('covers', coverName); // Resize and optimize cover image await sharp(firstImage) .resize(300, 450, { fit: 'cover' }) .jpeg({ quality: 80 }) .toFile(coverPath); return `/covers/${coverName}`; } } catch (error) { console.error('Error generating cover:', error); } return null; } async function scanChapters(mangaId, mangaDir) { try { const items = await readdir(mangaDir); // Check if it's a single chapter (just images) or multiple chapters (folders) const directories = []; const imageFiles = []; // First, separate directories from image files for (const item of items) { const itemPath = path.join(mangaDir, item); const stats = await stat(itemPath); if (stats.isDirectory()) { directories.push(item); } else if (/\.(jpg|jpeg|png|webp|gif)$/i.test(item)) { imageFiles.push(item); } } // If there are subdirectories, treat each as a chapter if (directories.length > 0) { for (const dir of directories) { const dirPath = path.join(mangaDir, dir); const chapterNumber = parseFloat(dir.match(/\d+(\.\d+)?/) || [1])[0] || 1; await addChapter(mangaId, chapterNumber, `Chapter ${chapterNumber}`, dirPath); } } else { // Single chapter - all images in root await addChapter(mangaId, 1, 'Chapter 1', mangaDir); } } catch (error) { console.error('Error scanning chapters:', error); // Even if scanning fails, we still want to add at least one chapter try { await addChapter(mangaId, 1, 'Chapter 1', mangaDir); } catch (e) { console.error('Error adding default chapter:', e); } } } function addChapter(mangaId, chapterNumber, title, filePath) { return new Promise((resolve, reject) => { db.run( `INSERT INTO chapters (manga_id, chapter_number, title, file_path) VALUES (?, ?, ?, ?)`, [mangaId, chapterNumber, title, filePath], function(err) { if (err) reject(err); else resolve(this.lastID); } ); }); } // ==================== SERVER STARTUP ==================== const startServer = async () => { await initDirectories(); initDatabase(); app.listen(PORT, () => { console.log(`MangaShelf server running on http://localhost:${PORT}`); console.log('Default directories created: library/, covers/, uploads/, data/'); console.log('Database initialized at: data/mangashelf.db'); }); }; startServer();