533 lines
17 KiB
JavaScript
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();
|