Files
bitvid-seeder/server.js
T
2025-02-16 11:10:14 -05:00

268 lines
8.5 KiB
JavaScript

import { EventEmitter } from 'events';
EventEmitter.defaultMaxListeners = 50;
import express from 'express';
import WebTorrent from 'webtorrent';
import fs from 'fs';
import { promises as fsPromises } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import rateLimit from 'express-rate-limit';
import winston from 'winston';
// Set up Winston logger
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.printf(
({ timestamp, level, message }) => `${timestamp} ${level}: ${message}`
)
),
transports: [new winston.transports.Console()]
});
const app = express();
const port = process.env.PORT || 3000;
const client = new WebTorrent();
// Increase max listeners for the client to avoid warnings (if needed)
client.setMaxListeners(50);
// ES module __dirname workaround
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Rate limiting middleware (100 requests per minute per IP)
const limiter = rateLimit({
windowMs: 60 * 1000,
max: 100
});
app.use(limiter);
// Serve static files and parse JSON bodies
app.use(express.static('public'));
app.use(express.json());
// Load configuration from config.json
const CONFIG_FILE = path.join(__dirname, 'config.json');
let config = { storageDir: path.join(__dirname, 'downloads') };
try {
if (fs.existsSync(CONFIG_FILE)) {
const configData = fs.readFileSync(CONFIG_FILE, 'utf-8');
config = JSON.parse(configData);
// Resolve storageDir relative to __dirname
config.storageDir = path.resolve(__dirname, config.storageDir);
} else {
fs.writeFileSync(CONFIG_FILE, JSON.stringify({ storageDir: './downloads' }, null, 2));
}
} catch (err) {
logger.error('Error reading config file, using default config: ' + err);
}
let STORAGE_DIR = config.storageDir;
if (!fs.existsSync(STORAGE_DIR)) {
fs.mkdirSync(STORAGE_DIR, { recursive: true });
}
// JSON file to store torrent metadata
const TORRENTS_FILE = path.join(__dirname, 'torrents.json');
let savedTorrents = [];
try {
if (fs.existsSync(TORRENTS_FILE)) {
const data = fs.readFileSync(TORRENTS_FILE, 'utf-8');
savedTorrents = JSON.parse(data);
logger.info(`Loaded saved torrents: ${JSON.stringify(savedTorrents)}`);
} else {
logger.info('No torrents.json file found. Starting with an empty list.');
}
} catch (err) {
logger.error('Error loading torrents file: ' + err);
}
function saveTorrents() {
try {
fs.writeFileSync(TORRENTS_FILE, JSON.stringify(savedTorrents, null, 2));
} catch (err) {
logger.error('Error saving torrents file: ' + err);
}
}
// Helper: Check if a path exists asynchronously
async function pathExists(p) {
try {
await fsPromises.access(p);
return true;
} catch {
return false;
}
}
// Load saved torrents concurrently on startup.
// For each saved torrent, if the expected file/folder exists, we seed from that location;
// otherwise, we add the torrent to download.
async function loadSavedTorrents() {
await Promise.all(
savedTorrents.map(async torrentData => {
const torrentPath = path.join(STORAGE_DIR, torrentData.name);
const exists = await pathExists(torrentPath);
try {
if (exists) {
// If the file exists, seed it to keep it active.
client.seed(torrentPath, { name: torrentData.name, keepSeeding: true }, torrent => {
logger.info(`Seeding torrent from existing files: ${torrent.infoHash}`);
});
} else {
// Otherwise, add the torrent to download its files.
client.add(torrentData.magnet, { path: STORAGE_DIR, keepSeeding: true }, torrent => {
logger.info(`Downloading torrent: ${torrent.infoHash}`);
});
}
} catch (err) {
logger.error('Error loading saved torrent on startup: ' + err);
}
})
);
}
loadSavedTorrents();
// GET /config - Return current storage configuration
app.get('/config', (req, res) => {
res.json({ storageDir: STORAGE_DIR });
});
// POST /config - Update storage directory (with input validation)
app.post('/config', (req, res) => {
const { storageDir } = req.body;
if (!storageDir || typeof storageDir !== 'string') {
return res.status(400).json({ error: 'Invalid storageDir provided' });
}
try {
STORAGE_DIR = path.resolve(__dirname, storageDir);
config.storageDir = STORAGE_DIR;
if (!fs.existsSync(STORAGE_DIR)) {
fs.mkdirSync(STORAGE_DIR, { recursive: true });
}
fs.writeFileSync(CONFIG_FILE, JSON.stringify({ storageDir }, null, 2));
res.json({ message: 'Config updated', storageDir: STORAGE_DIR });
} catch (err) {
logger.error('Error updating config: ' + err);
res.status(500).json({ error: 'Error updating config' });
}
});
// POST /add - Add a new torrent (with basic validation and duplicate check)
// The keepSeeding option ensures that the torrent remains active.
app.post('/add', (req, res) => {
const { magnet } = req.body;
if (!magnet || typeof magnet !== 'string') {
return res.status(400).json({ error: 'No valid magnet link provided' });
}
if (!magnet.startsWith('magnet:?')) {
return res.status(400).json({ error: 'Invalid magnet link format' });
}
// Check if the torrent already exists in saved metadata.
if (savedTorrents.some(t => t.magnet === magnet)) {
logger.info('Attempted to add duplicate torrent.');
return res.status(409).json({ error: 'Torrent already added.' });
}
try {
client.add(magnet, { path: STORAGE_DIR, keepSeeding: true }, torrent => {
logger.info(`Torrent added: ${torrent.infoHash}`);
const torrentData = {
magnet,
infoHash: torrent.infoHash,
name: torrent.name
};
savedTorrents.push(torrentData);
saveTorrents();
res.json(torrentData);
});
} catch (err) {
logger.error('Error adding torrent: ' + err);
res.status(500).json({ error: 'Error adding torrent' });
}
});
// GET /torrents - List active torrents with extra info
app.get('/torrents', (req, res) => {
try {
const torrents = client.torrents.map(t => ({
infoHash: t.infoHash,
name: t.name,
progress: t.progress,
numPeers: t.numPeers,
downloaded: t.downloaded, // in bytes
uploaded: t.uploaded // in bytes
}));
logger.info('GET /torrents returning: ' + JSON.stringify(torrents));
res.json(torrents);
} catch (err) {
logger.error('Error listing torrents: ' + err);
res.status(500).json({ error: 'Error listing torrents' });
}
});
// DELETE /remove/:infoHash - Remove a torrent
app.delete('/remove/:infoHash', (req, res) => {
const infoHash = req.params.infoHash;
if (!infoHash || typeof infoHash !== 'string') {
return res.status(400).json({ error: 'Invalid infoHash' });
}
const torrent = client.get(infoHash);
const savedData = savedTorrents.find(t => t.infoHash === infoHash);
// Use the torrent's name if available; otherwise, use saved metadata.
const torrentName = (torrent && torrent.name) || (savedData && savedData.name);
if (!torrentName) {
return res.status(400).json({ error: 'Torrent name not found' });
}
try {
client.remove(infoHash, { destroyStore: true }, async err => {
if (err) {
logger.error('Error removing torrent: ' + err);
return res.status(500).json({ error: 'Error removing torrent' });
}
savedTorrents = savedTorrents.filter(t => t.infoHash !== infoHash);
saveTorrents();
const torrentPath = path.join(STORAGE_DIR, torrentName);
if (await pathExists(torrentPath)) {
try {
await fsPromises.rm(torrentPath, { recursive: true, force: true });
} catch (rmErr) {
logger.error('Error removing torrent files: ' + rmErr);
}
}
logger.info(`Torrent removed: ${infoHash}`);
res.json({ message: 'Torrent removed' });
});
} catch (err) {
logger.error('Error processing removal: ' + err);
res.status(500).json({ error: 'Error processing removal' });
}
});
// Start the server
const server = app.listen(port, () => {
logger.info(`Server running on http://localhost:${port}`);
});
// Graceful shutdown
function shutdown() {
logger.info('Shutting down server...');
server.close(() => {
logger.info('HTTP server closed.');
client.destroy(err => {
if (err) {
logger.error('Error destroying WebTorrent client: ' + err);
} else {
logger.info('WebTorrent client destroyed.');
}
process.exit(0);
});
});
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);