Add Public API
This commit is contained in:
@@ -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'] || '');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user