Files
quixotic/src/server.ts
Andrey Kondratev f6b696a5f8 cache versions
2025-11-10 14:27:58 +05:00

408 lines
15 KiB
TypeScript

import express, { Request, Response } from 'express';
import compression from 'compression';
import path from 'path';
import fs from 'fs';
import ffmpeg from 'fluent-ffmpeg';
// Configure ffmpeg paths
ffmpeg.setFfmpegPath('/usr/bin/ffmpeg');
ffmpeg.setFfprobePath('/usr/bin/ffprobe');
import { Database } from './database';
import { SoundCloudService } from './soundcloud';
import { QuixoticBot } from './bot';
import { logger } from './logger';
const app = express();
const port = process.env.PORT || 3000;
// Initialize services
const db = new Database();
const soundcloud = new SoundCloudService();
// Middleware
app.use(compression()); // Enable gzip compression
app.use(express.json());
app.use((req: Request, res: Response, next) => {
res.set('Content-Security-Policy',
'default-src \'self\'; ' +
'script-src \'self\' https://telegram.org \'unsafe-inline\'; ' +
'style-src \'self\' \'unsafe-inline\'; ' +
'img-src \'self\' data: https:; ' +
'font-src \'self\'; ' +
'connect-src \'self\' https://telegram.org; ' +
'frame-ancestors \'self\'; ' +
'base-uri \'self\'; ' +
'form-action \'self\''
);
res.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
res.set('Cross-Origin-Opener-Policy', 'same-origin');
res.set('X-Frame-Options', 'SAMEORIGIN');
res.set('X-Content-Type-Options', 'nosniff');
res.set('Referrer-Policy', 'strict-origin-when-cross-origin');
next();
});
// Optimized caching strategy
app.use(express.static('public', {
maxAge: '365d', // Cache static assets for 1 year by default
etag: true,
lastModified: true,
setHeaders: (res: Response, filePath: string) => {
// Cache images, fonts, etc. with immutable flag
if (filePath.match(/\.(jpg|jpeg|png|gif|ico|woff|woff2|ttf|eot|svg)$/)) {
res.set('Cache-Control', 'public, max-age=31536000, immutable');
}
// Cache CSS and JS with version string for 1 year
else if (filePath.match(/\.(css|js)$/)) {
res.set('Cache-Control', 'public, max-age=31536000, immutable'); // 1 year
}
// HTML files - short cache with revalidation
else if (filePath.match(/\.html$/)) {
res.set('Cache-Control', 'public, max-age=0, must-revalidate');
}
}
}));
// Ensure downloads directory exists
const downloadsDir = path.join(__dirname, '../downloads');
if (!fs.existsSync(downloadsDir)) {
fs.mkdirSync(downloadsDir, { recursive: true });
}
// Load version for cache busting
let appVersion = Date.now().toString();
try {
const versionPath = path.join(__dirname, '../public/version.json');
if (fs.existsSync(versionPath)) {
const versionData = JSON.parse(fs.readFileSync(versionPath, 'utf8'));
appVersion = versionData.version || appVersion;
logger.info(`App version loaded: ${appVersion}`);
}
} catch (error) {
logger.warn('Could not load version file, using timestamp');
}
// Routes
app.get('/', (req: Request, res: Response) => {
// Use minified HTML in production
const isProduction = process.env.NODE_ENV === 'production';
const htmlFile = isProduction ? 'index.min.html' : 'index.html';
const indexPath = path.join(__dirname, '../public', htmlFile);
// Set cache headers for HTML (no cache for HTML itself)
res.set({
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
});
// Read HTML and inject version
try {
let html = fs.readFileSync(indexPath, 'utf8');
// Replace version placeholders with actual version
html = html.replace(/\?v=\d+/g, `?v=${appVersion}`);
res.send(html);
} catch (error) {
logger.error('Error serving HTML:', error);
res.sendFile(indexPath);
}
});
// Search videos
app.post('/api/search', async (req: Request, res: Response) => {
try {
const { query, userId, page }: { query?: string; userId?: string; page?: number } = req.body;
if (!query || query.trim().length === 0) {
return res.status(400).json({ error: 'Query is required' });
}
// Calculate offset based on page number (10 results per page)
const currentPage = page || 1;
const resultsPerPage = 10;
const offset = (currentPage - 1) * resultsPerPage;
// Save search history
if (userId && userId !== 'demo') {
try {
const user = await db.getUserByTelegramId(userId);
if (user) {
await db.addSearchHistory(user.id, query);
}
} catch (dbError) {
logger.error('Database error:', dbError);
}
}
const videos = await soundcloud.searchTracks(query.trim(), resultsPerPage, offset);
// Return hasMore flag based on results
const hasMore = videos.length === resultsPerPage;
res.json({ videos, hasMore });
} catch (error) {
logger.error('Search error:', error);
res.status(500).json({ error: 'Failed to search videos' });
}
});
// Convert video to MP3
app.post('/api/convert', async (req: Request, res: Response) => {
try {
const { videoId, title, userId, url }: { videoId?: string; title?: string; userId?: string; url?: string } = req.body;
logger.info(`Convert request received: ${title} (ID: ${videoId})`);
if (!videoId) {
return res.status(400).json({ error: 'Video ID is required' });
}
// Generate safe filename
const safeTitle = (title || '').replace(/[^\w\s-]/g, '').replace(/\s+/g, '_').substring(0, 50);
const filename = `${videoId}_${safeTitle}.mp3`;
const outputPath = path.join(downloadsDir, filename);
// Check if file already exists
if (fs.existsSync(outputPath)) {
logger.info('File already exists, serving cached version');
const audioUrl = `${req.protocol}://${req.get('host')}/downloads/${filename}`;
return res.json({ audioUrl, title });
}
logger.info(`Starting MP3 conversion: ${title}`);
try {
// Get audio stream from YouTube
logger.debug(`Attempting to get audio stream for: ${videoId}`);
const audioStream = await soundcloud.getAudioStream(videoId, url);
logger.info('Audio stream obtained, starting FFmpeg conversion...');
// Download to temporary file first, then convert
const tempInputPath = path.join(downloadsDir, `temp_${videoId}.tmp`);
// Save stream to temporary file
await new Promise<void>((resolve, reject) => {
const writeStream = fs.createWriteStream(tempInputPath);
audioStream.pipe(writeStream);
audioStream.on('end', resolve);
audioStream.on('error', reject);
writeStream.on('error', reject);
});
logger.info('Temporary file saved, starting FFmpeg conversion...');
// Debug: check temp file
const stats = fs.statSync(tempInputPath);
logger.debug(`Temp file size: ${stats.size} bytes`);
// Test ffmpeg with simple command first
try {
const { execSync } = require('child_process');
execSync(`ffmpeg -i "${tempInputPath}" -t 1 -f null -`, { encoding: 'utf8', stdio: 'pipe' });
logger.debug('FFmpeg file test passed');
} catch (e: any) {
logger.error('FFmpeg file test failed:', e.stderr || e.message);
}
// Convert temporary file to MP3 using ffmpeg
await new Promise<void>((resolve, reject) => {
const conversion = ffmpeg(tempInputPath)
.audioCodec('libmp3lame')
.audioBitrate('192k')
.audioChannels(2)
.audioFrequency(44100)
.format('mp3')
.output(outputPath)
.on('start', (command: string) => {
logger.ffmpeg('Started', command);
})
.on('progress', (progress: any) => {
if (progress.percent) {
logger.ffmpeg('Progress', `${Math.round(progress.percent)}%`);
}
})
.on('end', () => {
logger.success('MP3 conversion completed successfully');
// Clean up temporary file
fs.unlink(tempInputPath, (err) => {
if (err) logger.error('Failed to delete temp file:', err);
});
resolve();
})
.on('error', (err: Error) => {
logger.error('FFmpeg error:', err.message);
// Clean up temporary file on error
fs.unlink(tempInputPath, () => {});
reject(err);
});
conversion.run();
});
// Save download record
if (userId && userId !== 'demo') {
try {
const user = await db.getUserByTelegramId(userId);
if (user) {
await db.addDownload(user.id, videoId, title || '', outputPath);
}
} catch (dbError) {
logger.error('Database error:', dbError);
}
}
const audioUrl = `${req.protocol}://${req.get('host')}/downloads/${filename}`;
logger.success(`Conversion successful: ${audioUrl}`);
res.json({ audioUrl, title });
} catch (conversionError: any) {
logger.error(`Conversion failed for video: ${videoId}`);
logger.error('Error details:', conversionError.message);
logger.error('Full error:', conversionError);
// Return error - no fallbacks for Telegram bot
return res.status(503).json({
error: 'MP3 conversion failed. This video may be restricted or unavailable for download.',
details: conversionError.message,
videoId: videoId
});
}
} catch (error) {
logger.error('Server error:', error);
res.status(500).json({ error: 'Failed to process request' });
}
});
// Direct Telegram API for sending audio
app.post('/api/telegram-send', async (req: Request, res: Response) => {
logger.telegram('Send request received');
try {
const { userId, audioUrl, title, performer, thumbnail }: { userId?: string; audioUrl?: string; title?: string; performer?: string; thumbnail?: string } = req.body;
logger.telegram('Sending to user', `${userId}: ${title}`);
if (!userId || !audioUrl || !title) {
return res.status(400).json({ error: 'Missing required fields' });
}
const botInstance = (global as any).quixoticBot;
if (!botInstance) {
logger.error('Bot not available');
return res.status(500).json({ error: 'Bot not available' });
}
const chatId = parseInt(userId);
await botInstance.sendAudioFile(chatId, audioUrl, title, performer, thumbnail);
logger.success('Audio sent successfully');
res.json({ success: true, message: 'Audio sent successfully' });
} catch (error: any) {
logger.error('Send failed:', error.message);
res.status(500).json({ error: error.message });
}
});
// Serve download files
app.use('/downloads', express.static(downloadsDir));
// Health check
app.get('/health', (req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Version endpoint for client-side cache busting
app.get('/api/version', (req: Request, res: Response) => {
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
try {
const versionPath = path.join(__dirname, '../public/version.json');
if (fs.existsSync(versionPath)) {
const versionData = fs.readFileSync(versionPath, 'utf8');
res.json(JSON.parse(versionData));
} else {
res.json({ version: appVersion, timestamp: Date.now() });
}
} catch (error) {
res.json({ version: appVersion, timestamp: Date.now() });
}
});
// Error handler
app.use((err: Error, _req: Request, res: Response, _next: any) => {
logger.error(err.stack || err.message);
res.status(500).json({ error: 'Something went wrong!' });
});
// Cleanup old files periodically (every hour)
setInterval(() => {
const maxAge = 24 * 60 * 60 * 1000; // 24 hours
const now = Date.now();
fs.readdir(downloadsDir, (err, files) => {
if (err) return;
files.forEach(file => {
const filePath = path.join(downloadsDir, file);
fs.stat(filePath, (err, stats) => {
if (err) return;
if (now - stats.mtime.getTime() > maxAge) {
fs.unlink(filePath, (err) => {
if (!err) {
logger.info('Deleted old file:', file);
}
});
}
});
});
});
}, 60 * 60 * 1000); // Run every hour
app.listen(port, () => {
logger.success(`Quixotic server running on port ${port}`);
logger.info(`Downloads directory: ${downloadsDir}`);
logger.info(`Open in browser: http://localhost:${port}`);
});
// Initialize Telegram bot
const botToken = process.env.TELEGRAM_BOT_TOKEN;
const webAppUrl = process.env.WEB_APP_URL || `http://localhost:${port}`;
if (botToken && botToken.length > 10 && botToken !== 'your_telegram_bot_token_here') {
try {
const botInstance = new QuixoticBot(botToken, webAppUrl);
// Store bot instance globally for API access
(global as any).quixoticBot = botInstance;
logger.telegram('Bot started and stored globally');
} catch (error: any) {
logger.error('Bot initialization failed:', error.message);
logger.warn('Bot disabled due to error');
logger.warn('Telegram integration will not be available');
// Don't crash the server, continue without bot
}
} else {
logger.warn('TELEGRAM_BOT_TOKEN not configured properly');
logger.warn('Bot will not start - only web interface will be available');
logger.info('To enable Telegram bot, set a valid TELEGRAM_BOT_TOKEN');
}
// Handle unhandled promise rejections
process.on('unhandledRejection', (reason: any, promise: Promise<any>) => {
logger.error('Unhandled Rejection at:', promise);
logger.error('Reason:', reason);
// Log but don't crash the server
if (reason?.code === 'ETELEGRAM') {
logger.warn('Telegram API error - continuing operation');
}
});
// Handle uncaught exceptions
process.on('uncaughtException', (error: Error) => {
logger.error('Uncaught Exception:', error);
// Log but try to continue
});
export default app;