diff --git a/server.js b/server.js index c37b1e4..9b892d2 100644 --- a/server.js +++ b/server.js @@ -4,10 +4,45 @@ 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', + '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(); + let base = path.basename(filename, path.extname(filename)); + base = base.replace(/[^a-zA-Z0-9]+/g, ' ').trim(); + if (!base) return generateRandomTitle(); + 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()); @@ -16,27 +51,24 @@ app.use('/uploads', express.static(path.join(__dirname, 'public', 'uploads'))); // In-memory data store let posts = []; -let votes = {}; let comments = []; -// Multer config for file uploads -const storage = multer.diskStorage({ +// Multer: save raw upload to temp, we process with sharp after +const rawStorage = multer.diskStorage({ destination: (req, file, cb) => { - const uploadDir = path.join(__dirname, 'public', 'uploads'); - if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); - cb(null, uploadDir); + const tmpDir = path.join(__dirname, 'public', 'uploads', 'tmp'); + if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true }); + cb(null, tmpDir); }, filename: (req, file, cb) => { - const id = uuidv4().slice(0, 8); - const ext = path.extname(file.originalname); - cb(null, `${id}${ext}`); + 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, + storage: rawStorage, limits: { fileSize: 20 * 1024 * 1024 }, fileFilter: (req, file, cb) => { if (ALLOWED_TYPES.includes(file.mimetype)) { @@ -47,6 +79,32 @@ const upload = multer({ } }); +// 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'); @@ -59,7 +117,7 @@ function loadSeedData() { // --- API Routes --- -// GET /api/posts - List all posts with optional sort +// GET /api/posts app.get('/api/posts', (req, res) => { const { sort = 'new' } = req.query; let sorted = [...posts]; @@ -89,7 +147,7 @@ app.get('/api/posts', (req, res) => { res.json(sorted); }); -// GET /api/posts/:id - Single post +// 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' }); @@ -97,35 +155,42 @@ app.get('/api/posts/:id', (req, res) => { res.json({ ...post, commentCount }); }); -// POST /api/posts - Create new post -app.post('/api/posts', upload.single('image'), (req, res) => { +// 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' }); } - const post = { - id: uuidv4().slice(0, 8), - title: req.body.title || 'Untitled', - url: `/uploads/${req.file.filename}`, - 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' - }; + try { + const processedFilename = await processImage(req.file.path); + const title = req.body.title?.trim() || deriveTitle(req.file.originalname); - posts.unshift(post); - res.status(201).json(post); + 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 - Vote on a post +// 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; - const userId = req.body.userId || 'anonymous'; - if (direction === 'up') { post.upvotes = (post.upvotes || 0) + 1; } else if (direction === 'down') { @@ -133,17 +198,16 @@ app.post('/api/posts/:id/vote', (req, res) => { } post.views = (post.views || 0) + 1; - res.json({ upvotes: post.upvotes, views: post.views }); }); -// GET /api/comments/:postId - Get comments for a post +// 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 - Create a comment +// POST /api/comments app.post('/api/comments', (req, res) => { const { postId, text, author, parentId } = req.body; @@ -164,25 +228,34 @@ app.post('/api/comments', (req, res) => { res.status(201).json(comment); }); -// POST /api/upload - File upload endpoint -app.post('/api/upload', upload.single('image'), (req, res) => { +// 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' }); } - const post = { - id: uuidv4().slice(0, 8), - title: req.body.title || 'Untitled', - url: `/uploads/${req.file.filename}`, - 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' - }; + try { + const processedFilename = await processImage(req.file.path); + const title = req.body.title?.trim() || deriveTitle(req.file.originalname); - posts.unshift(post); - res.status(201).json(post); + 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 @@ -195,7 +268,6 @@ loadSeedData(); console.log(`🚀 Imgur Clone running on port ${PORT}`); console.log(`📦 ${posts.length} seed posts loaded`); -// Listen on both IPv4 and IPv6 for health check compatibility const server = app.listen(PORT, () => { console.log(`✅ Server listening on http://0.0.0.0:${PORT}`); console.log(`✅ Server listening on http://[::]:${PORT}`);