feat: generate image metadata and expose public search API
This commit is contained in:
7 files changed
+450
-93
No files matched your search
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user