const express = require('express'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const { v4: uuidv4 } = require('uuid'); const cors = require('cors'); const sharp = require('sharp'); const app = express(); const PORT = process.env.PORT || 3000; // Max dimension for processed images (4K) const MAX_DIM = 3840; // Word list for random title generation const WORDS = [ 'Ball', 'Help', 'Tree', 'Moon', 'River', 'Storm', 'Cloud', 'Fire', 'Ocean', 'Stone', 'Flame', 'Frost', 'Shadow', 'Light', 'Spark', 'Blaze', 'Drift', 'Hawk', 'Wolf', 'Bear', 'Lion', 'Eagle', 'Shark', 'Coral', 'Moss', 'Fern', 'Pine', 'Oak', 'Ash', 'Elm', 'Yew', 'Bloom', 'Thorn', 'Root', 'Leaf', 'Seed', 'Wave', 'Tide', 'Rain', 'Snow', 'Hail', 'Dew', 'Mist', 'Fog', 'Gale', 'Wind', 'Dust', 'Sand', 'Clay', 'Rock', 'Crag', 'Peak', 'Ridge', 'Dale', 'Glen', 'Cove', 'Bay', 'Strait', 'Reef', 'Shore', 'Cliff', 'Dune', 'Cave', 'Grot', 'Arch', 'Pill', 'Beam', 'Rune', 'Glyph', 'Mark', 'Sign', 'Glyph', 'Echo', 'Bane', 'Ward', 'Veil', 'Haze', 'Glow', 'Shine' ]; function generateRandomTitle() { const pick = () => WORDS[Math.floor(Math.random() * WORDS.length)]; let a, b, c; do { a = pick(); } while (false); do { b = pick(); } while (b === a); do { c = pick(); } while (c === a || c === b); return `${a}${b}${c}`; } function deriveTitle(filename) { if (!filename) return generateRandomTitle(); // Strip extension, clean up let base = path.basename(filename, path.extname(filename)); // Replace non-alpha with spaces, then title-case base = base.replace(/[^a-zA-Z0-9]+/g, ' ').trim(); if (!base) return generateRandomTitle(); // Title-case each word return base.split(/\s+/).map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(' ').slice(0, 80); } // Middleware app.use(cors()); app.use(express.json()); app.use(express.static('public')); app.use('/uploads', express.static(path.join(__dirname, 'public', 'uploads'))); // In-memory data store let posts = []; let comments = []; // Multer: save raw upload to temp, we process with sharp after const rawStorage = multer.diskStorage({ destination: (req, file, cb) => { const tmpDir = path.join(__dirname, 'public', 'uploads', 'tmp'); if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true }); cb(null, tmpDir); }, filename: (req, file, cb) => { cb(null, `${uuidv4().slice(0, 8)}${path.extname(file.originalname)}`); } }); const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; const upload = multer({ storage: rawStorage, limits: { fileSize: 20 * 1024 * 1024 }, fileFilter: (req, file, cb) => { if (ALLOWED_TYPES.includes(file.mimetype)) { cb(null, true); } else { cb(new Error('Unsupported file type. Only JPEG, PNG, GIF, and WebP are allowed.'), false); } } }); // Process image with sharp: resize to max 4K, output JPEG quality 92 async function processImage(inputPath) { const uploadDir = path.join(__dirname, 'public', 'uploads'); const outFilename = `${uuidv4().slice(0, 8)}.jpg`; const outputPath = path.join(uploadDir, outFilename); await sharp(inputPath) .resize(MAX_DIM, MAX_DIM, { fit: 'inside', withoutEnlargement: true, withoutAspectRatio: false }) .jpeg({ quality: 92, mozjpeg: true, progressive: true, chromaSubsampling: '4:4:4' }) .toFile(outputPath); // Remove temp file await fs.promises.unlink(inputPath).catch(() => {}); return outFilename; } // Load seed data function loadSeedData() { const seedPath = path.join(__dirname, 'seed', 'data.js'); if (fs.existsSync(seedPath)) { const seed = require(seedPath); posts = seed.posts.map(p => ({ ...p, id: p.id, createdAt: new Date().toISOString() })); comments = seed.comments || []; } } // --- API Routes --- // GET /api/posts app.get('/api/posts', (req, res) => { const { sort = 'new' } = req.query; let sorted = [...posts]; switch (sort) { case 'viral': sorted.sort((a, b) => (b.upvotes || 0) - (a.upvotes || 0)); break; case 'top': const today = new Date(); today.setHours(0, 0, 0, 0); const todayPosts = posts.filter(p => new Date(p.createdAt) >= today); sorted = todayPosts.sort((a, b) => (b.upvotes || 0) - (a.upvotes || 0)); break; case 'user': sorted.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); break; default: sorted.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); } sorted = sorted.map(p => ({ ...p, commentCount: comments.filter(c => c.postId === p.id).length })); res.json(sorted); }); // GET /api/posts/:id app.get('/api/posts/:id', (req, res) => { const post = posts.find(p => p.id === req.params.id); if (!post) return res.status(404).json({ error: 'Post not found' }); const commentCount = comments.filter(c => c.postId === post.id).length; res.json({ ...post, commentCount }); }); // POST /api/posts - Create post with image app.post('/api/posts', upload.single('image'), async (req, res) => { if (!req.file) { return res.status(400).json({ error: 'No image file provided' }); } try { const processedFilename = await processImage(req.file.path); const title = req.body.title?.trim() || deriveTitle(req.file.originalname); const post = { id: uuidv4().slice(0, 8), title, url: `/uploads/${processedFilename}`, upvotes: 1, views: 0, tags: req.body.tags ? req.body.tags.split(',').map(t => t.trim()).filter(Boolean) : [], createdAt: new Date().toISOString(), author: req.body.author || 'Anonymous' }; posts.unshift(post); res.status(201).json(post); } catch (err) { await fs.promises.unlink(req.file.path).catch(() => {}); console.error('Image processing error:', err); res.status(500).json({ error: 'Failed to process image' }); } }); // POST /api/posts/:id/vote app.post('/api/posts/:id/vote', (req, res) => { const post = posts.find(p => p.id === req.params.id); if (!post) return res.status(404).json({ error: 'Post not found' }); const { direction } = req.body; if (direction === 'up') { post.upvotes = (post.upvotes || 0) + 1; } else if (direction === 'down') { post.upvotes = Math.max(0, (post.upvotes || 0) - 1); } post.views = (post.views || 0) + 1; res.json({ upvotes: post.upvotes, views: post.views }); }); // GET /api/comments/:postId app.get('/api/comments/:postId', (req, res) => { const postComments = comments.filter(c => c.postId === req.params.postId); res.json(postComments); }); // POST /api/comments app.post('/api/comments', (req, res) => { const { postId, text, author, parentId } = req.body; if (!postId || !text) { return res.status(400).json({ error: 'postId and text are required' }); } const comment = { id: uuidv4().slice(0, 8), postId, text, author: author || 'Anonymous', parentId: parentId || null, timestamp: new Date().toISOString() }; comments.push(comment); res.status(201).json(comment); }); // POST /api/upload - Main upload endpoint app.post('/api/upload', upload.single('image'), async (req, res) => { if (!req.file) { return res.status(400).json({ error: 'No file uploaded' }); } try { const processedFilename = await processImage(req.file.path); const title = req.body.title?.trim() || deriveTitle(req.file.originalname); const post = { id: uuidv4().slice(0, 8), title, url: `/uploads/${processedFilename}`, upvotes: 1, views: 0, tags: req.body.tags ? req.body.tags.split(',').map(t => t.trim()).filter(Boolean) : [], createdAt: new Date().toISOString(), author: req.body.author || 'You' }; posts.unshift(post); res.status(201).json(post); } catch (err) { await fs.promises.unlink(req.file.path).catch(() => {}); console.error('Image processing error:', err); res.status(500).json({ error: 'Failed to process image' }); } }); // Health check app.get('/api/health', (req, res) => { res.json({ status: 'ok', posts: posts.length, comments: comments.length, uptime: process.uptime() }); }); // Initialize and start loadSeedData(); console.log(`🚀 Imgur Clone running on port ${PORT}`); console.log(`📦 ${posts.length} seed posts loaded`); const server = app.listen(PORT, () => { console.log(`✅ Server listening on http://0.0.0.0:${PORT}`); console.log(`✅ Server listening on http://[::]:${PORT}`); });