Files

278 lines
8.4 KiB
JavaScript

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(`🚀 Picta 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}`);
});