first commit
This commit is contained in:
@@ -0,0 +1,532 @@
|
||||
// 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();
|
||||
Reference in New Issue
Block a user