Files
Manmics/mangashelf-server/server.js
T
2025-10-10 18:00:07 -04:00

533 lines
17 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('unrar-promise');
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); // Must call cb with null and a boolean
} 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 fs.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 fs.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();
if (fileExt === '.cbz' || fileExt === '.zip') {
// Use unzipper for .cbz and .zip files
return new Promise((resolve, reject) => {
const readStream = fs.createReadStream(filePath);
const extractStream = unzipper.Extract({ path: destDir });
extractStream.on('entry', (entry) => {
if (entry.path.includes('../') || entry.path.includes('..\\')) {
entry.autodrain();
} else {
entry.pipe(extractStream);
}
});
readStream
.pipe(extractStream)
.on('close', resolve)
.on('error', reject);
});
} else if (fileExt === '.cbr' || fileExt === '.rar') {
// Use unrar-promise for .cbr and .rar files
// This library might need to be used differently
const unrar = new Unrar(filePath, {
strip: 0 // Don't strip any path components
});
return new Promise((resolve, reject) => {
unrar.extract({
path: destDir,
}, (err) => {
if (err) {
reject(new Error('Failed to extract RAR archive: ' + err.message));
} else {
resolve();
}
});
});
} else {
throw new Error('Unsupported file type: ' + fileExt);
}
}
async function generateCover(mangaDir) {
try {
const files = await readdir(mangaDir);
const imageFile = files.find(f => /\.(jpg|jpeg|png|webp)$/i.test(f));
if (imageFile) {
const coverName = `cover_${Date.now()}.jpg`;
const coverPath = path.join('covers', coverName);
// Resize and optimize cover image
await sharp(path.join(mangaDir, imageFile))
.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();