349 lines
13 KiB
JavaScript
349 lines
13 KiB
JavaScript
import http from 'node:http';
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs/promises';
|
|
import { URL } from 'node:url';
|
|
import { parseMultipartUpload } from './src/multipart.js';
|
|
import { sendFile, sendHtml, sendJson, sendText, withSecurityHeaders } from './src/http.js';
|
|
import { createStore } from './src/store.js';
|
|
import { validateImage } from './src/image.js';
|
|
import { normalizeToWebp } from './src/normalize.js';
|
|
import { seedDemoMemes } from './src/seed.js';
|
|
import { moderateImage } from './src/moderation.js';
|
|
import { createUploadLimiter } from './src/uploadLimits.js';
|
|
import { feedJson, llmsTxt, manifest, noIndex, publicBaseUrl, renderIndex, robotsTxt, sitemapXml } from './src/seo.js';
|
|
|
|
const PORT = Number.parseInt(process.env.PORT || '8080', 10);
|
|
const HOST = process.env.HOST || '0.0.0.0';
|
|
const DATA_DIR = process.env.DATA_DIR || './data';
|
|
const PAGE_SIZE_MAX = 48;
|
|
const UPLOAD_MAX_BYTES = 5 * 1024 * 1024;
|
|
const REQUEST_MAX_BYTES = 6 * 1024 * 1024;
|
|
const SSE_HEARTBEAT_MS = 25_000;
|
|
const DEBUG_CLIENT_IP = process.env.DEBUG_CLIENT_IP === 'true';
|
|
const events = new Set();
|
|
const DISCOVERY_ROUTES = new Set([
|
|
'/robots.txt',
|
|
'/llms.txt',
|
|
'/site.webmanifest',
|
|
'/feed.json',
|
|
'/sitemap.xml'
|
|
]);
|
|
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || crypto.randomBytes(24).toString('hex');
|
|
const indexTemplate = await fs.readFile('./public/index.html', 'utf8');
|
|
|
|
const store = await createStore({ dataDir: DATA_DIR });
|
|
const uploadLimiter = await createUploadLimiter({ dataDir: DATA_DIR });
|
|
if (process.env.SEED_DEMO_MEMES !== 'false') {
|
|
await seedDemoMemes(store);
|
|
}
|
|
if (!process.env.ADMIN_TOKEN) {
|
|
console.log(`Admin review URL: /admin/${ADMIN_TOKEN}`);
|
|
}
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
try {
|
|
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
withSecurityHeaders(res, { contentSecurityPolicy: !isDiscoveryRoute(req, url) });
|
|
const baseUrl = publicBaseUrl(req);
|
|
|
|
if (req.method === 'GET' && url.pathname === '/') {
|
|
const nonce = crypto.randomBytes(16).toString('base64');
|
|
withSecurityHeaders(res, { scriptNonce: nonce });
|
|
return sendHtml(res, 200, renderIndex({
|
|
template: indexTemplate,
|
|
baseUrl,
|
|
nonce,
|
|
approvedCount: store.count('approved')
|
|
}));
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname === '/robots.txt') {
|
|
return sendText(res, 200, robotsTxt(baseUrl));
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname === '/llms.txt') {
|
|
return sendText(res, 200, llmsTxt(baseUrl));
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname === '/site.webmanifest') {
|
|
return sendJson(res, 200, manifest(baseUrl));
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname === '/feed.json') {
|
|
return sendJson(res, 200, feedJson(baseUrl, store.listForReview({ status: 'approved' }).memes));
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname === '/sitemap.xml') {
|
|
const body = sitemapXml(baseUrl, store.listForReview({ status: 'approved' }).memes);
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/xml; charset=utf-8',
|
|
'Content-Length': String(Buffer.byteLength(body))
|
|
});
|
|
return res.end(body);
|
|
}
|
|
|
|
const adminPageMatch = url.pathname.match(/^\/admin\/([A-Za-z0-9_-]{24,128})$/);
|
|
if (req.method === 'GET' && adminPageMatch) {
|
|
if (!isAdminToken(adminPageMatch[1])) return sendText(res, 404, 'Not found');
|
|
noIndex(res);
|
|
return sendFile(res, './public/admin.html');
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname.startsWith('/assets/')) {
|
|
return sendFile(res, `./public${url.pathname}`);
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname === '/api/memes') {
|
|
noIndex(res);
|
|
const page = positiveInt(url.searchParams.get('page'), 1);
|
|
const pageSize = Math.min(positiveInt(url.searchParams.get('pageSize'), 12), PAGE_SIZE_MAX);
|
|
return sendJson(res, 200, store.list({ page, pageSize }));
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname === '/api/status') {
|
|
noIndex(res);
|
|
return sendJson(res, 200, {
|
|
ok: true,
|
|
memeCount: store.count('approved'),
|
|
liveClients: events.size,
|
|
uptimeSeconds: Math.floor(process.uptime())
|
|
});
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname === '/api/debug/ip') {
|
|
noIndex(res);
|
|
if (!DEBUG_CLIENT_IP) return sendJson(res, 404, { error: 'Not found' });
|
|
return sendJson(res, 200, clientIpDebug(req));
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname === '/api/admin/pending') {
|
|
noIndex(res);
|
|
if (!isAdminRequest(req)) return sendJson(res, 404, { error: 'Not found' });
|
|
return sendJson(res, 200, store.listForReview({ status: 'pending' }));
|
|
}
|
|
|
|
if (req.method === 'POST' && url.pathname === '/api/admin/approve') {
|
|
noIndex(res);
|
|
if (!isAdminRequest(req)) return sendJson(res, 404, { error: 'Not found' });
|
|
const body = await readJsonBody(req, 64 * 1024);
|
|
const approved = await store.approve(safeIds(body.ids));
|
|
return sendJson(res, 200, { approved });
|
|
}
|
|
|
|
if (req.method === 'POST' && url.pathname === '/api/admin/delete') {
|
|
noIndex(res);
|
|
if (!isAdminRequest(req)) return sendJson(res, 404, { error: 'Not found' });
|
|
const body = await readJsonBody(req, 64 * 1024);
|
|
const deleted = await store.delete(safeIds(body.ids));
|
|
return sendJson(res, 200, { deleted });
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname === '/api/events') {
|
|
noIndex(res);
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
'Cache-Control': 'no-cache, no-transform',
|
|
Connection: 'keep-alive',
|
|
'X-Accel-Buffering': 'no'
|
|
});
|
|
res.write('retry: 5000\n');
|
|
res.write('event: ready\ndata: {}\n\n');
|
|
events.add(res);
|
|
const heartbeat = setInterval(() => {
|
|
res.write(': keep-alive\n\n');
|
|
}, SSE_HEARTBEAT_MS);
|
|
req.on('close', () => {
|
|
clearInterval(heartbeat);
|
|
events.delete(res);
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'POST' && url.pathname === '/api/memes') {
|
|
noIndex(res);
|
|
const contentLength = Number.parseInt(req.headers['content-length'] || '0', 10);
|
|
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
|
return sendJson(res, 411, { error: 'Missing upload size.' });
|
|
}
|
|
if (contentLength > REQUEST_MAX_BYTES) {
|
|
return sendJson(res, 413, { error: 'Upload request is too large.' });
|
|
}
|
|
const quota = await uploadLimiter.checkAndConsume(clientIp(req));
|
|
|
|
const upload = await parseMultipartUpload(req, {
|
|
maxRequestBytes: REQUEST_MAX_BYTES,
|
|
maxFileBytes: UPLOAD_MAX_BYTES,
|
|
fieldName: 'meme'
|
|
});
|
|
const image = validateImage(upload.buffer, {
|
|
maxBytes: UPLOAD_MAX_BYTES,
|
|
maxWidth: 6000,
|
|
maxHeight: 6000,
|
|
maxPixels: 20_000_000
|
|
});
|
|
const normalized = await normalizeToWebp(upload.buffer);
|
|
const moderation = await moderateImage({ buffer: normalized.buffer, mime: normalized.image.mime });
|
|
if (moderation.status === 'rejected') {
|
|
return sendJson(res, 422, {
|
|
error: `Upload rejected: ${moderation.reason}`,
|
|
moderationScore: moderation.score
|
|
});
|
|
}
|
|
const meme = await store.save({
|
|
buffer: normalized.buffer,
|
|
image: normalized.image,
|
|
originalName: upload.filename,
|
|
originalMime: image.mime,
|
|
status: moderation.status,
|
|
moderationScore: moderation.score,
|
|
moderationReason: moderation.reason
|
|
});
|
|
return sendJson(res, meme.status === 'approved' ? 201 : 202, {
|
|
meme,
|
|
quota,
|
|
message: meme.status === 'approved' ? 'Upload approved.' : 'Upload queued for admin review.'
|
|
});
|
|
}
|
|
|
|
const viewMatch = url.pathname.match(/^\/api\/memes\/([a-f0-9]{64})\/view$/);
|
|
if (req.method === 'POST' && viewMatch) {
|
|
if (store.get(viewMatch[1])?.status !== 'approved') return sendText(res, 404, 'Not found');
|
|
const meme = await store.incrementMetric(viewMatch[1], 'viewCount');
|
|
if (!meme) return sendText(res, 404, 'Not found');
|
|
broadcastMetric(meme);
|
|
return sendJson(res, 200, { meme });
|
|
}
|
|
|
|
const mediaMatch = url.pathname.match(/^\/media\/([a-f0-9]{64})$/);
|
|
if (req.method === 'GET' && mediaMatch) {
|
|
const meme = store.get(mediaMatch[1]);
|
|
if (!meme || meme.status !== 'approved') return sendText(res, 404, 'Not found');
|
|
res.setHeader('Content-Type', meme.mime);
|
|
res.setHeader('Content-Length', String(meme.byteSize));
|
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
return sendFile(res, store.absolutePath(meme.storageKey), { absolute: true });
|
|
}
|
|
|
|
const adminMediaMatch = url.pathname.match(/^\/admin-media\/([A-Za-z0-9_-]{24,128})\/([a-f0-9]{64})$/);
|
|
if (req.method === 'GET' && adminMediaMatch) {
|
|
noIndex(res);
|
|
if (!isAdminToken(adminMediaMatch[1])) return sendText(res, 404, 'Not found');
|
|
const meme = store.get(adminMediaMatch[2]);
|
|
if (!meme) return sendText(res, 404, 'Not found');
|
|
res.setHeader('Content-Type', meme.mime);
|
|
res.setHeader('Content-Length', String(meme.byteSize));
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
return sendFile(res, store.absolutePath(meme.storageKey), { absolute: true });
|
|
}
|
|
|
|
const downloadMatch = url.pathname.match(/^\/download\/([a-f0-9]{64})$/);
|
|
if (req.method === 'GET' && downloadMatch) {
|
|
noIndex(res);
|
|
if (store.get(downloadMatch[1])?.status !== 'approved') return sendText(res, 404, 'Not found');
|
|
const updated = await store.incrementMetric(downloadMatch[1], 'downloadCount');
|
|
const meme = store.get(downloadMatch[1]);
|
|
if (!meme) return sendText(res, 404, 'Not found');
|
|
broadcastMetric(updated);
|
|
res.setHeader('Content-Type', meme.mime);
|
|
res.setHeader('Content-Disposition', `attachment; filename="${downloadName(meme)}"`);
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
return sendFile(res, store.absolutePath(meme.storageKey), { absolute: true });
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname === '/healthz') {
|
|
noIndex(res);
|
|
return sendJson(res, 200, { ok: true });
|
|
}
|
|
|
|
return sendText(res, 404, 'Not found');
|
|
} catch (error) {
|
|
const status = error.statusCode || 500;
|
|
const message = status === 500 ? 'Internal server error.' : error.message;
|
|
return sendJson(res, status, { error: message });
|
|
}
|
|
});
|
|
|
|
server.listen(PORT, HOST, () => {
|
|
console.log(`The Meme Protocol listening on http://${HOST}:${PORT}`);
|
|
});
|
|
|
|
function positiveInt(value, fallback) {
|
|
const parsed = Number.parseInt(value || '', 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
function isDiscoveryRoute(req, url) {
|
|
if (req.method !== 'GET') return false;
|
|
return DISCOVERY_ROUTES.has(url.pathname);
|
|
}
|
|
|
|
function downloadName(meme) {
|
|
return `meme-protocol-${meme.id.slice(0, 12)}.${meme.ext}`;
|
|
}
|
|
|
|
function broadcastMetric(meme) {
|
|
const payload = JSON.stringify({
|
|
id: meme.id,
|
|
viewCount: meme.viewCount,
|
|
downloadCount: meme.downloadCount
|
|
});
|
|
for (const res of events) {
|
|
res.write(`event: metric\ndata: ${payload}\n\n`);
|
|
}
|
|
}
|
|
|
|
function isAdminRequest(req) {
|
|
return isAdminToken(req.headers['x-admin-token'] || '');
|
|
}
|
|
|
|
function isAdminToken(value) {
|
|
const received = Buffer.from(String(value));
|
|
const expected = Buffer.from(ADMIN_TOKEN);
|
|
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
|
|
}
|
|
|
|
async function readJsonBody(req, maxBytes) {
|
|
const chunks = [];
|
|
let total = 0;
|
|
for await (const chunk of req) {
|
|
total += chunk.length;
|
|
if (total > maxBytes) {
|
|
const error = new Error('Request body is too large.');
|
|
error.statusCode = 413;
|
|
throw error;
|
|
}
|
|
chunks.push(chunk);
|
|
}
|
|
if (total === 0) return {};
|
|
return JSON.parse(Buffer.concat(chunks, total).toString('utf8'));
|
|
}
|
|
|
|
function safeIds(ids) {
|
|
if (!Array.isArray(ids)) return [];
|
|
return ids.filter((id) => typeof id === 'string' && /^[a-f0-9]{64}$/.test(id));
|
|
}
|
|
|
|
function clientIp(req) {
|
|
if (process.env.TRUST_PROXY === 'true') {
|
|
const forwarded = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
|
if (forwarded) return forwarded;
|
|
}
|
|
return req.socket.remoteAddress || 'unknown';
|
|
}
|
|
|
|
function clientIpDebug(req) {
|
|
return {
|
|
trustProxy: process.env.TRUST_PROXY === 'true',
|
|
resolvedClientIp: clientIp(req),
|
|
remoteAddress: req.socket.remoteAddress || '',
|
|
headers: {
|
|
xForwardedFor: req.headers['x-forwarded-for'] || '',
|
|
xRealIp: req.headers['x-real-ip'] || '',
|
|
forwarded: req.headers.forwarded || '',
|
|
cfConnectingIp: req.headers['cf-connecting-ip'] || ''
|
|
}
|
|
};
|
|
}
|