const express = require('express'); const path = require('path'); const fs = require('fs'); const https = require('https'); const { formatStoryName, naturalSort } = require('./utils'); const { fetchHFData, forceRefreshHFData, startAutoRefresh, getHFStories, getHFCacheStatus, getHFAssetBase } = require('./hfService'); const app = express(); app.disable('x-powered-by'); // Hide "Express" header const PORT = process.env.PORT || 3000; const DATA_DIR = path.join(__dirname, 'data'); // Serve static files app.use(express.static(path.join(__dirname, 'public'))); app.use('/data', express.static(DATA_DIR)); // ============================================ // Image Proxy Endpoint // Proxy images from Hugging Face with proper URL encoding // ============================================ app.get('/api/proxy/*', (req, res) => { const assetPath = req.params[0]; if (!assetPath) { return res.status(400).json({ error: 'Missing asset path' }); } const MAX_REDIRECTS = 5; const TIMEOUT_MS = 30000; // 30 seconds timeout // Build properly encoded URL const targetUrl = encodeURI(`${getHFAssetBase()}/${assetPath}`); const fetchWithRedirects = (url, redirectCount = 0) => { if (redirectCount > MAX_REDIRECTS) { console.error('[Proxy] Too many redirects'); if (!res.headersSent) { return res.status(502).json({ error: 'Too many redirects' }); } return; } const request = https.get(url, { timeout: TIMEOUT_MS }, (proxyRes) => { const { statusCode, headers } = proxyRes; // Handle redirects (301, 302, 303, 307, 308) if (statusCode >= 300 && statusCode < 400 && headers.location) { return fetchWithRedirects(headers.location, redirectCount + 1); } // Handle non-success status codes if (statusCode !== 200) { console.error(`[Proxy] Upstream error: ${statusCode} for ${url.substring(0, 80)}`); if (!res.headersSent) { return res.status(statusCode).json({ error: 'Failed to fetch resource', upstream_status: statusCode }); } return; } // Forward response headers if (headers['content-type']) { res.setHeader('Content-Type', headers['content-type']); } if (headers['content-length']) { res.setHeader('Content-Length', headers['content-length']); } // Set caching headers (1 hour for images) res.setHeader('Cache-Control', 'public, max-age=3600'); res.setHeader('X-Proxy-Source', 'huggingface'); // Stream response to client proxyRes.pipe(res); // Handle stream errors proxyRes.on('error', (err) => { console.error('[Proxy] Stream error:', err.message); if (!res.headersSent) { res.status(500).json({ error: 'Stream error' }); } }); }); // Handle request errors request.on('error', (err) => { console.error('[Proxy] Request error:', err.message); if (!res.headersSent) { res.status(502).json({ error: 'Failed to connect to upstream' }); } }); // Handle timeout request.on('timeout', () => { console.error('[Proxy] Request timeout'); request.destroy(); if (!res.headersSent) { res.status(504).json({ error: 'Upstream timeout' }); } }); }; fetchWithRedirects(targetUrl); }); // Helper: Get local stories function getLocalStories() { try { if (!fs.existsSync(DATA_DIR)) return []; return fs.readdirSync(DATA_DIR, { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) .map(dirent => { const storyPath = path.join(DATA_DIR, dirent.name); return { id: dirent.name, path: storyPath }; }); } catch (e) { return []; } } // API: Get all stories (Merged) app.get('/api/stories', (req, res) => { try { // 1. Get Local Stories const localStories = getLocalStories().map(s => { const storyPath = s.path; const chapters = fs.readdirSync(storyPath, { withFileTypes: true }) .filter(d => d.isDirectory()); // Cover logic let cover = null; const thumbFiles = fs.readdirSync(storyPath) .filter(f => /^thum\.(jpg|jpeg|png|webp|gif)$/i.test(f)); if (thumbFiles.length > 0) { cover = `/data/${s.id}/${thumbFiles[0]}`; } else if (chapters.length > 0) { try { const firstChapterPath = path.join(storyPath, chapters[0].name); const images = fs.readdirSync(firstChapterPath) .filter(f => /\.(png|jpg|jpeg|webp|gif)$/i.test(f)).sort(); if (images.length > 0) cover = `/data/${s.id}/${chapters[0].name}/${images[0]}`; } catch (e) { } } return { id: s.id, name: formatStoryName(s.id), chapterCount: chapters.length, cover: cover, source: 'local' }; }); // 2. Merge with HF Stories const mergedMap = new Map(); const hfStories = getHFStories(); // Add local first localStories.forEach(s => mergedMap.set(s.id, s)); // Merge HF Object.values(hfStories).forEach(hfStory => { // Construct proxy URL for cover const proxyCover = hfStory.cover ? `/api/proxy/${hfStory.cover}` : null; if (mergedMap.has(hfStory.id)) { const local = mergedMap.get(hfStory.id); if (!local.cover && proxyCover) local.cover = proxyCover; } else { mergedMap.set(hfStory.id, { id: hfStory.id, name: formatStoryName(hfStory.id), chapterCount: Object.keys(hfStory.chapters).length, cover: proxyCover, source: 'remote' }); } }); res.json(Array.from(mergedMap.values())); } catch (error) { console.error(error); res.status(500).json({ error: 'Failed to load stories' }); } }); // API: Get chapters of a story app.get('/api/stories/:story/chapters', (req, res) => { try { const storyId = req.params.story; const localPath = path.join(DATA_DIR, storyId); let chapters = []; const seenChapters = new Set(); // 1. Local Chapters if (fs.existsSync(localPath)) { const localDirs = fs.readdirSync(localPath, { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) .map(d => d.name) .sort(naturalSort); // Use natural sort chapters = localDirs.map(chapterName => { seenChapters.add(chapterName); const chapterPath = path.join(localPath, chapterName); const images = fs.readdirSync(chapterPath).filter(f => /\.(png|jpg|jpeg|webp|gif)$/i.test(f)); let thumb = null; if (images.length > 0) thumb = `/data/${storyId}/${chapterName}/${images[0]}`; return { id: chapterName, name: `Chapter ${chapterName}`, imageCount: images.length, thumbnail: thumb, source: 'local' }; }); } // 2. HF Chapters const hfStories = getHFStories(); const hfStory = hfStories[storyId]; if (hfStory) { const hfChapterIds = Object.keys(hfStory.chapters).sort(naturalSort); // Use natural sort hfChapterIds.forEach(chId => { if (!seenChapters.has(chId)) { const imgs = hfStory.chapters[chId]; const thumb = imgs.length > 0 ? `/api/proxy/${imgs[0].path}` : null; chapters.push({ id: chId, name: `Chapter ${chId}`, imageCount: imgs.length, thumbnail: thumb, source: 'remote' }); } }); } // Re-sort all chapters chapters.sort((a, b) => naturalSort(a.id, b.id)); // Use natural sort res.json({ story: { id: storyId, name: formatStoryName(storyId) }, chapters: chapters }); } catch (error) { console.error(error); res.status(500).json({ error: 'Failed to load chapters' }); } }); // API: Get images of a chapter app.get('/api/stories/:story/chapters/:chapter/images', (req, res) => { try { const storyId = req.params.story; const chapterId = req.params.chapter; const hfStories = getHFStories(); let images = []; let source = 'unknown'; // 1. Try Local first const chapterPath = path.join(DATA_DIR, storyId, chapterId); if (fs.existsSync(chapterPath)) { images = fs.readdirSync(chapterPath) .filter(f => /\.(png|jpg|jpeg|webp|gif)$/i.test(f)) .sort(naturalSort) // Optional: Sort images naturally too if they are numbered .map(f => `/data/${storyId}/${chapterId}/${f}`); source = 'local'; } // 2. If not local, try HF if (images.length === 0 && hfStories[storyId]?.chapters[chapterId]) { images = hfStories[storyId].chapters[chapterId].map(img => `/api/proxy/${img.path}`); source = 'remote'; } if (images.length === 0) { return res.status(404).json({ error: 'Chapter not found' }); } // Navigation Logic let allChapterIds = new Set(); // Local IDs if (fs.existsSync(path.join(DATA_DIR, storyId))) { fs.readdirSync(path.join(DATA_DIR, storyId), { withFileTypes: true }) .filter(d => d.isDirectory()) .forEach(d => allChapterIds.add(d.name)); } // HF IDs if (hfStories[storyId]) { Object.keys(hfStories[storyId].chapters).forEach(id => allChapterIds.add(id)); } // Sort using natural sort const sortedChapters = Array.from(allChapterIds).sort(naturalSort); const currentIndex = sortedChapters.indexOf(chapterId); res.json({ story: { id: storyId, name: formatStoryName(storyId) }, chapter: { id: chapterId, name: `Chapter ${chapterId}` }, images: images, source: source, navigation: { prevChapter: currentIndex > 0 ? sortedChapters[currentIndex - 1] : null, nextChapter: currentIndex < sortedChapters.length - 1 ? sortedChapters[currentIndex + 1] : null, totalChapters: sortedChapters.length, currentIndex: currentIndex + 1 } }); } catch (error) { console.error(error); res.status(500).json({ error: 'Failed to load images' }); } }); // API: Get cache status and manual refresh app.get('/api/hf-status', (req, res) => { res.json(getHFCacheStatus()); }); // Refresh HF data - supports both GET and POST app.get('/api/refresh', async (req, res) => { try { const result = await forceRefreshHFData(); res.json({ success: true, ...result }); } catch (error) { res.status(500).json({ success: false, error: 'Failed to refresh' }); } }); app.post('/api/refresh', async (req, res) => { try { const result = await forceRefreshHFData(); res.json({ success: true, ...result }); } catch (error) { res.status(500).json({ success: false, error: 'Failed to refresh' }); } }); // Fallback to index.html for SPA routes - MUST BE LAST app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); // Start server and fetch data app.listen(PORT, async () => { console.log(`🚀 Web Truyện is running at http://localhost:${PORT}`); await fetchHFData(); startAutoRefresh(); // Enable auto-refresh every 15 minutes });