Add sharp image processing (4K resize, JPEG q92) and auto-titles
This commit is contained in:
@@ -4,10 +4,45 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
|
const sharp = require('sharp');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
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
|
// Middleware
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -16,27 +51,24 @@ app.use('/uploads', express.static(path.join(__dirname, 'public', 'uploads')));
|
|||||||
|
|
||||||
// In-memory data store
|
// In-memory data store
|
||||||
let posts = [];
|
let posts = [];
|
||||||
let votes = {};
|
|
||||||
let comments = [];
|
let comments = [];
|
||||||
|
|
||||||
// Multer config for file uploads
|
// Multer: save raw upload to temp, we process with sharp after
|
||||||
const storage = multer.diskStorage({
|
const rawStorage = multer.diskStorage({
|
||||||
destination: (req, file, cb) => {
|
destination: (req, file, cb) => {
|
||||||
const uploadDir = path.join(__dirname, 'public', 'uploads');
|
const tmpDir = path.join(__dirname, 'public', 'uploads', 'tmp');
|
||||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
|
||||||
cb(null, uploadDir);
|
cb(null, tmpDir);
|
||||||
},
|
},
|
||||||
filename: (req, file, cb) => {
|
filename: (req, file, cb) => {
|
||||||
const id = uuidv4().slice(0, 8);
|
cb(null, `${uuidv4().slice(0, 8)}${path.extname(file.originalname)}`);
|
||||||
const ext = path.extname(file.originalname);
|
|
||||||
cb(null, `${id}${ext}`);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
|
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage,
|
storage: rawStorage,
|
||||||
limits: { fileSize: 20 * 1024 * 1024 },
|
limits: { fileSize: 20 * 1024 * 1024 },
|
||||||
fileFilter: (req, file, cb) => {
|
fileFilter: (req, file, cb) => {
|
||||||
if (ALLOWED_TYPES.includes(file.mimetype)) {
|
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
|
// Load seed data
|
||||||
function loadSeedData() {
|
function loadSeedData() {
|
||||||
const seedPath = path.join(__dirname, 'seed', 'data.js');
|
const seedPath = path.join(__dirname, 'seed', 'data.js');
|
||||||
@@ -59,7 +117,7 @@ function loadSeedData() {
|
|||||||
|
|
||||||
// --- API Routes ---
|
// --- API Routes ---
|
||||||
|
|
||||||
// GET /api/posts - List all posts with optional sort
|
// GET /api/posts
|
||||||
app.get('/api/posts', (req, res) => {
|
app.get('/api/posts', (req, res) => {
|
||||||
const { sort = 'new' } = req.query;
|
const { sort = 'new' } = req.query;
|
||||||
let sorted = [...posts];
|
let sorted = [...posts];
|
||||||
@@ -89,7 +147,7 @@ app.get('/api/posts', (req, res) => {
|
|||||||
res.json(sorted);
|
res.json(sorted);
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/posts/:id - Single post
|
// GET /api/posts/:id
|
||||||
app.get('/api/posts/:id', (req, res) => {
|
app.get('/api/posts/:id', (req, res) => {
|
||||||
const post = posts.find(p => p.id === req.params.id);
|
const post = posts.find(p => p.id === req.params.id);
|
||||||
if (!post) return res.status(404).json({ error: 'Post not found' });
|
if (!post) return res.status(404).json({ error: 'Post not found' });
|
||||||
@@ -97,16 +155,20 @@ app.get('/api/posts/:id', (req, res) => {
|
|||||||
res.json({ ...post, commentCount });
|
res.json({ ...post, commentCount });
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/posts - Create new post
|
// POST /api/posts - Create post with image
|
||||||
app.post('/api/posts', upload.single('image'), (req, res) => {
|
app.post('/api/posts', upload.single('image'), async (req, res) => {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({ error: 'No image file provided' });
|
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 = {
|
const post = {
|
||||||
id: uuidv4().slice(0, 8),
|
id: uuidv4().slice(0, 8),
|
||||||
title: req.body.title || 'Untitled',
|
title,
|
||||||
url: `/uploads/${req.file.filename}`,
|
url: `/uploads/${processedFilename}`,
|
||||||
upvotes: 1,
|
upvotes: 1,
|
||||||
views: 0,
|
views: 0,
|
||||||
tags: req.body.tags ? req.body.tags.split(',').map(t => t.trim()).filter(Boolean) : [],
|
tags: req.body.tags ? req.body.tags.split(',').map(t => t.trim()).filter(Boolean) : [],
|
||||||
@@ -116,16 +178,19 @@ app.post('/api/posts', upload.single('image'), (req, res) => {
|
|||||||
|
|
||||||
posts.unshift(post);
|
posts.unshift(post);
|
||||||
res.status(201).json(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) => {
|
app.post('/api/posts/:id/vote', (req, res) => {
|
||||||
const post = posts.find(p => p.id === req.params.id);
|
const post = posts.find(p => p.id === req.params.id);
|
||||||
if (!post) return res.status(404).json({ error: 'Post not found' });
|
if (!post) return res.status(404).json({ error: 'Post not found' });
|
||||||
|
|
||||||
const { direction } = req.body;
|
const { direction } = req.body;
|
||||||
const userId = req.body.userId || 'anonymous';
|
|
||||||
|
|
||||||
if (direction === 'up') {
|
if (direction === 'up') {
|
||||||
post.upvotes = (post.upvotes || 0) + 1;
|
post.upvotes = (post.upvotes || 0) + 1;
|
||||||
} else if (direction === 'down') {
|
} else if (direction === 'down') {
|
||||||
@@ -133,17 +198,16 @@ app.post('/api/posts/:id/vote', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
post.views = (post.views || 0) + 1;
|
post.views = (post.views || 0) + 1;
|
||||||
|
|
||||||
res.json({ upvotes: post.upvotes, views: post.views });
|
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) => {
|
app.get('/api/comments/:postId', (req, res) => {
|
||||||
const postComments = comments.filter(c => c.postId === req.params.postId);
|
const postComments = comments.filter(c => c.postId === req.params.postId);
|
||||||
res.json(postComments);
|
res.json(postComments);
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/comments - Create a comment
|
// POST /api/comments
|
||||||
app.post('/api/comments', (req, res) => {
|
app.post('/api/comments', (req, res) => {
|
||||||
const { postId, text, author, parentId } = req.body;
|
const { postId, text, author, parentId } = req.body;
|
||||||
|
|
||||||
@@ -164,16 +228,20 @@ app.post('/api/comments', (req, res) => {
|
|||||||
res.status(201).json(comment);
|
res.status(201).json(comment);
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/upload - File upload endpoint
|
// POST /api/upload - Main upload endpoint
|
||||||
app.post('/api/upload', upload.single('image'), (req, res) => {
|
app.post('/api/upload', upload.single('image'), async (req, res) => {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({ error: 'No file uploaded' });
|
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 = {
|
const post = {
|
||||||
id: uuidv4().slice(0, 8),
|
id: uuidv4().slice(0, 8),
|
||||||
title: req.body.title || 'Untitled',
|
title,
|
||||||
url: `/uploads/${req.file.filename}`,
|
url: `/uploads/${processedFilename}`,
|
||||||
upvotes: 1,
|
upvotes: 1,
|
||||||
views: 0,
|
views: 0,
|
||||||
tags: req.body.tags ? req.body.tags.split(',').map(t => t.trim()).filter(Boolean) : [],
|
tags: req.body.tags ? req.body.tags.split(',').map(t => t.trim()).filter(Boolean) : [],
|
||||||
@@ -183,6 +251,11 @@ app.post('/api/upload', upload.single('image'), (req, res) => {
|
|||||||
|
|
||||||
posts.unshift(post);
|
posts.unshift(post);
|
||||||
res.status(201).json(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
|
// Health check
|
||||||
@@ -195,7 +268,6 @@ loadSeedData();
|
|||||||
console.log(`🚀 Imgur Clone running on port ${PORT}`);
|
console.log(`🚀 Imgur Clone running on port ${PORT}`);
|
||||||
console.log(`📦 ${posts.length} seed posts loaded`);
|
console.log(`📦 ${posts.length} seed posts loaded`);
|
||||||
|
|
||||||
// Listen on both IPv4 and IPv6 for health check compatibility
|
|
||||||
const server = app.listen(PORT, () => {
|
const server = app.listen(PORT, () => {
|
||||||
console.log(`✅ Server listening on http://0.0.0.0:${PORT}`);
|
console.log(`✅ Server listening on http://0.0.0.0:${PORT}`);
|
||||||
console.log(`✅ Server listening on http://[::]:${PORT}`);
|
console.log(`✅ Server listening on http://[::]:${PORT}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user