feat: generate image metadata and expose public search API

This commit is contained in:
smileBeda committed 2026-09-30 16:24:53 -03:00
1 parent 082f715c39
commit 137cad1d84
7 files changed
+450 -93

No files matched your search

+81 -27
View File
@@ -4,11 +4,13 @@ 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 { createStore, toPublicMeme as publicMeme } from './src/store.js';
import { searchOptions } from './src/search.js';
import { HttpError } from './src/errors.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 { moderateImage, captionImage } from './src/moderation.js';
import { createUploadLimiter } from './src/uploadLimits.js';
import { createIpRateLimiter } from './src/rateLimits.js';
import { feedJson, llmsTxt, manifest, noIndex, publicBaseUrl, renderIndex, renderNotFound, robotsTxt, sitemapXml } from './src/seo.js';
@@ -152,6 +154,7 @@ const server = http.createServer(async (req, res) => {
if (req.method === 'GET' && adminPageMatch) {
if (!isAdminToken(adminPageMatch[1])) return sendText(res, 404, 'Not found');
noIndex(res);
res.setHeader('Cache-Control', 'no-store');
return sendFile(res, './public/admin.html');
}
@@ -167,11 +170,75 @@ const server = http.createServer(async (req, 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 }),
...store.list({ page, pageSize, ...searchOptions(url.searchParams) }),
rateLimit: readQuota
});
}
if (req.method === 'GET' && url.pathname === '/api/tags') {
noIndex(res);
const readQuota = checkReadLimit(req, res, apiReadLimiter);
return sendJson(res, 200, { tags: store.listTags(), rateLimit: readQuota });
}
// Authenticate every admin API route before reading bodies or performing work.
if (url.pathname.startsWith('/api/admin/')) {
noIndex(res);
res.setHeader('Cache-Control', 'no-store');
if (!isAdminRequest(req)) return sendJson(res, 404, { error: 'Not found' });
}
if (req.method === 'GET' && url.pathname === '/api/admin/memes') {
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.listForAdmin({
page, pageSize, status: url.searchParams.get('status') || 'all',
missingCaption: url.searchParams.get('missingCaption') === 'true',
...searchOptions(url.searchParams)
}));
}
if (req.method === 'GET' && url.pathname === '/api/admin/tags') {
return sendJson(res, 200, { tags: store.listTags() });
}
if (req.method === 'POST' && url.pathname === '/api/admin/tags') {
const body = await readJsonBody(req, 16 * 1024);
const tag = await store.addTag(body.name);
return sendJson(res, 201, { tag, tags: store.listTags() });
}
const adminTagMatch = url.pathname.match(/^\/api\/admin\/tags\/([^/]+)$/);
if (req.method === 'DELETE' && adminTagMatch) {
let name;
try { name = decodeURIComponent(adminTagMatch[1]); } catch { throw new HttpError(400, 'Invalid tag name.'); }
const deleted = await store.deleteTag(name);
return sendJson(res, 200, { deleted, tags: store.listTags() });
}
const adminMemeMatch = url.pathname.match(/^\/api\/admin\/memes\/([a-f0-9]{64})$/);
if (req.method === 'PATCH' && adminMemeMatch) {
const body = await readJsonBody(req, 64 * 1024);
if (Object.keys(body).some((key) => !['caption', 'tags'].includes(key))) throw new HttpError(400, 'Only caption and tags can be edited here.');
return sendJson(res, 200, { meme: await store.updateMetadata(adminMemeMatch[1], body) });
}
const generateMatch = url.pathname.match(/^\/api\/admin\/memes\/([a-f0-9]{64})\/generate$/);
if (req.method === 'POST' && generateMatch) {
const record = store.get(generateMatch[1]);
if (!record) throw new HttpError(404, 'Meme not found.');
const body = await readJsonBody(req, 16 * 1024);
if ('onlyMissing' in body && typeof body.onlyMissing !== 'boolean') throw new HttpError(400, 'onlyMissing must be a boolean.');
if (body.onlyMissing && record.caption) return sendJson(res, 200, { meme: publicMeme(record), skipped: true });
const expectedVersion = record.metadataVersion;
const buffer = await fs.readFile(store.absolutePath(record.storageKey));
const metadata = await captionImage({ buffer, mime: record.mime, availableTags: store.listTags() });
// Recheck the catalog after generation in case a tag was removed meanwhile.
metadata.tags = metadata.tags.filter((tag) => store.listTags().includes(tag));
const meme = await store.updateMetadata(record.id, metadata, { expectedVersion });
return sendJson(res, 200, { meme });
}
if (req.method === 'GET' && url.pathname === '/api/status') {
noIndex(res);
checkReadLimit(req, res, apiReadLimiter);
@@ -440,7 +507,7 @@ async function addMeme(req) {
maxPixels: 20_000_000
});
const normalized = await normalizeToWebp(upload.buffer);
const moderation = await moderateImage({ buffer: normalized.buffer, mime: normalized.image.mime });
const moderation = await moderateImage({ buffer: normalized.buffer, mime: normalized.image.mime, availableTags: store.listTags() });
if (moderation.status === 'rejected') {
const error = new Error(`Upload rejected: ${moderation.reason}`);
error.statusCode = 422;
@@ -458,7 +525,9 @@ async function addMeme(req) {
originalMime: image.mime,
status: moderation.status,
moderationScore: moderation.score,
moderationReason: moderation.reason
moderationReason: moderation.reason,
caption: moderation.caption,
tags: moderation.tags
});
return { meme, quota };
}
@@ -475,27 +544,6 @@ function setRateLimitHeaders(res, quota) {
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'] || '');
}
@@ -519,7 +567,13 @@ async function readJsonBody(req, maxBytes) {
chunks.push(chunk);
}
if (total === 0) return {};
return JSON.parse(Buffer.concat(chunks, total).toString('utf8'));
try {
const body = JSON.parse(Buffer.concat(chunks, total).toString('utf8'));
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Invalid object');
return body;
} catch {
throw new HttpError(400, 'Request body must be a valid JSON object.');
}
}
function safeIds(ids) {