Add Public API

This commit is contained in:
2026-06-26 15:23:53 -03:00
parent 36edd32fa3
commit d80d596664
12 changed files with 891 additions and 42 deletions
+176 -38
View File
@@ -10,17 +10,24 @@ 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 { createIpRateLimiter } from './src/rateLimits.js';
import { feedJson, llmsTxt, manifest, noIndex, publicBaseUrl, renderIndex, robotsTxt, sitemapXml } from './src/seo.js';
await loadDotEnv();
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 API_READ_LIMIT_PER_MINUTE = 120;
const MEDIA_READ_LIMIT_PER_MINUTE = 600;
const SSE_HEARTBEAT_MS = 25_000;
const events = new Set();
const DISCOVERY_ROUTES = new Set([
'/openapi.json',
'/meme-api.skill.md',
'/robots.txt',
'/llms.txt',
'/site.webmanifest',
@@ -29,9 +36,21 @@ const DISCOVERY_ROUTES = new Set([
]);
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || crypto.randomBytes(24).toString('hex');
const indexTemplate = await fs.readFile('./public/index.html', 'utf8');
const openApiSpec = JSON.parse(await fs.readFile('./openapi.json', 'utf8'));
const memeApiSkillTemplate = await fs.readFile('./meme-api.skill.md', 'utf8');
const store = await createStore({ dataDir: DATA_DIR });
const uploadLimiter = await createUploadLimiter({ dataDir: DATA_DIR });
const apiReadLimiter = createIpRateLimiter({
windowMs: 60_000,
max: API_READ_LIMIT_PER_MINUTE,
message: 'API read limit exceeded. Try again shortly.'
});
const mediaReadLimiter = createIpRateLimiter({
windowMs: 60_000,
max: MEDIA_READ_LIMIT_PER_MINUTE,
message: 'Media read limit exceeded. Try again shortly.'
});
if (process.env.SEED_DEMO_MEMES !== 'false') {
await seedDemoMemes(store);
}
@@ -56,6 +75,16 @@ const server = http.createServer(async (req, res) => {
}));
}
if (req.method === 'GET' && url.pathname === '/openapi.json') {
res.setHeader('Cache-Control', 'public, max-age=300');
return sendJson(res, 200, openApiSpecFor(baseUrl));
}
if (req.method === 'GET' && url.pathname === '/meme-api.skill.md') {
res.setHeader('Cache-Control', 'public, max-age=300');
return sendText(res, 200, renderMemeApiSkill(baseUrl), 'text/markdown; charset=utf-8');
}
if (req.method === 'GET' && url.pathname === '/robots.txt') {
return sendText(res, 200, robotsTxt(baseUrl));
}
@@ -94,13 +123,18 @@ const server = http.createServer(async (req, res) => {
if (req.method === 'GET' && url.pathname === '/api/memes') {
noIndex(res);
const readQuota = checkReadLimit(req, res, apiReadLimiter);
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 }));
return sendJson(res, 200, {
...store.list({ page, pageSize }),
rateLimit: readQuota
});
}
if (req.method === 'GET' && url.pathname === '/api/status') {
noIndex(res);
checkReadLimit(req, res, apiReadLimiter);
return sendJson(res, 200, {
ok: true,
memeCount: store.count('approved'),
@@ -154,43 +188,7 @@ const server = http.createServer(async (req, res) => {
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
});
const { meme, quota } = await submitMeme(req);
return sendJson(res, meme.status === 'approved' ? 201 : 202, {
meme,
quota,
@@ -198,6 +196,15 @@ const server = http.createServer(async (req, res) => {
});
}
const apiMemeMatch = url.pathname.match(/^\/api\/memes\/([a-f0-9]{64})$/);
if (req.method === 'GET' && apiMemeMatch) {
noIndex(res);
const readQuota = checkReadLimit(req, res, apiReadLimiter);
const meme = store.get(apiMemeMatch[1]);
if (!meme || meme.status !== 'approved') return sendJson(res, 404, { error: 'Not found' });
return sendJson(res, 200, { meme: publicMeme(meme), rateLimit: readQuota });
}
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');
@@ -209,6 +216,7 @@ const server = http.createServer(async (req, res) => {
const mediaMatch = url.pathname.match(/^\/media\/([a-f0-9]{64})$/);
if (req.method === 'GET' && mediaMatch) {
checkReadLimit(req, res, mediaReadLimiter);
const meme = store.get(mediaMatch[1]);
if (!meme || meme.status !== 'approved') return sendText(res, 404, 'Not found');
res.setHeader('Content-Type', meme.mime);
@@ -234,6 +242,7 @@ const server = http.createServer(async (req, res) => {
const downloadMatch = url.pathname.match(/^\/download\/([a-f0-9]{64})$/);
if (req.method === 'GET' && downloadMatch) {
noIndex(res);
checkReadLimit(req, res, mediaReadLimiter);
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]);
@@ -254,6 +263,15 @@ const server = http.createServer(async (req, res) => {
} catch (error) {
const status = error.statusCode || 500;
const message = status === 500 ? 'Internal server error.' : error.message;
if (status === 429 && error.retryAfterSeconds) {
res.setHeader('Retry-After', String(error.retryAfterSeconds));
}
if (status === 429 && error.rateLimit) {
setRateLimitHeaders(res, error.rateLimit);
}
if (error.payload && typeof error.payload === 'object') {
return sendJson(res, status, error.payload);
}
return sendJson(res, status, { error: message });
}
});
@@ -272,6 +290,45 @@ function isDiscoveryRoute(req, url) {
return DISCOVERY_ROUTES.has(url.pathname);
}
async function loadDotEnv() {
let content = '';
try {
content = await fs.readFile('.env', 'utf8');
} catch (error) {
if (error.code !== 'ENOENT') throw error;
return;
}
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match || process.env[match[1]] !== undefined) continue;
process.env[match[1]] = unquoteEnvValue(match[2].trim());
}
}
function unquoteEnvValue(value) {
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1);
}
return value;
}
function openApiSpecFor(baseUrl) {
return {
...openApiSpec,
servers: [{ url: baseUrl }]
};
}
function renderMemeApiSkill(baseUrl) {
return memeApiSkillTemplate.replaceAll('__API_BASE_URL__', baseUrl);
}
function downloadName(meme) {
return `meme-protocol-${meme.id.slice(0, 12)}.${meme.ext}`;
}
@@ -287,6 +344,87 @@ function broadcastMetric(meme) {
}
}
async function submitMeme(req) {
const contentLength = Number.parseInt(req.headers['content-length'] || '0', 10);
if (!Number.isFinite(contentLength) || contentLength <= 0) {
const error = new Error('Missing upload size.');
error.statusCode = 411;
throw error;
}
if (contentLength > REQUEST_MAX_BYTES) {
const error = new Error('Upload request is too large.');
error.statusCode = 413;
throw error;
}
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') {
const error = new Error(`Upload rejected: ${moderation.reason}`);
error.statusCode = 422;
error.payload = {
error: error.message,
moderationScore: moderation.score
};
throw error;
}
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 { meme, quota };
}
function checkReadLimit(req, res, limiter) {
const quota = limiter.check(clientIp(req));
setRateLimitHeaders(res, quota);
return quota;
}
function setRateLimitHeaders(res, quota) {
res.setHeader('RateLimit-Limit', String(quota.limit));
res.setHeader('RateLimit-Remaining', String(quota.remaining));
res.setHeader('RateLimit-Reset', quota.resetAt);
}
function publicMeme(record) {
return {
id: record.id,
createdAt: record.createdAt,
byteSize: record.byteSize,
width: record.width,
height: record.height,
mime: record.mime,
originalMime: record.originalMime,
status: record.status,
moderationScore: record.moderationScore,
moderationReason: record.moderationReason,
viewCount: record.viewCount,
downloadCount: record.downloadCount,
originalName: record.originalName,
url: `/media/${record.id}`,
downloadUrl: `/download/${record.id}`,
demo: Boolean(record.demo)
};
}
function isAdminRequest(req) {
return isAdminToken(req.headers['x-admin-token'] || '');
}