621 lines
20 KiB
JavaScript
621 lines
20 KiB
JavaScript
// 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(); |