init
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
export class HttpError extends Error {
|
||||
constructor(statusCode, message) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const PUBLIC_ROOT = path.resolve('./public');
|
||||
const TYPES = new Map([
|
||||
['.html', 'text/html; charset=utf-8'],
|
||||
['.css', 'text/css; charset=utf-8'],
|
||||
['.js', 'text/javascript; charset=utf-8'],
|
||||
['.png', 'image/png'],
|
||||
['.jpg', 'image/jpeg'],
|
||||
['.jpeg', 'image/jpeg'],
|
||||
['.webp', 'image/webp'],
|
||||
['.ico', 'image/x-icon']
|
||||
]);
|
||||
|
||||
export function withSecurityHeaders(res) {
|
||||
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
|
||||
res.setHeader('Referrer-Policy', 'same-origin');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
res.setHeader(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' blob:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"
|
||||
);
|
||||
}
|
||||
|
||||
export function sendJson(res, statusCode, payload) {
|
||||
const body = Buffer.from(JSON.stringify(payload));
|
||||
res.writeHead(statusCode, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': String(body.length)
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
export function sendText(res, statusCode, message) {
|
||||
const body = Buffer.from(message);
|
||||
res.writeHead(statusCode, {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Content-Length': String(body.length)
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
export function sendFile(res, filePath, options = {}) {
|
||||
const resolved = options.absolute ? path.resolve(filePath) : path.resolve(filePath);
|
||||
if (!options.absolute && !resolved.startsWith(PUBLIC_ROOT + path.sep) && resolved !== path.join(PUBLIC_ROOT, 'index.html')) {
|
||||
return sendText(res, 403, 'Forbidden');
|
||||
}
|
||||
|
||||
fs.stat(resolved, (statError, stats) => {
|
||||
if (statError || !stats.isFile()) {
|
||||
return sendText(res, 404, 'Not found');
|
||||
}
|
||||
|
||||
if (!res.getHeader('Content-Type')) {
|
||||
res.setHeader('Content-Type', TYPES.get(path.extname(resolved).toLowerCase()) || 'application/octet-stream');
|
||||
}
|
||||
if (!res.getHeader('Content-Length')) {
|
||||
res.setHeader('Content-Length', String(stats.size));
|
||||
}
|
||||
fs.createReadStream(resolved)
|
||||
.on('error', () => sendText(res, 500, 'File read failed'))
|
||||
.pipe(res);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { HttpError } from './errors.js';
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
export function validateImage(buffer, limits) {
|
||||
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
|
||||
throw new HttpError(400, 'Upload is empty.');
|
||||
}
|
||||
if (buffer.length > limits.maxBytes) {
|
||||
throw new HttpError(413, 'Image exceeds the 5 MB limit.');
|
||||
}
|
||||
|
||||
const image = detectImage(buffer);
|
||||
if (!image) {
|
||||
throw new HttpError(415, 'Only PNG and JPEG images are accepted.');
|
||||
}
|
||||
if (image.width < 1 || image.height < 1) {
|
||||
throw new HttpError(400, 'Image dimensions are invalid.');
|
||||
}
|
||||
if (image.width > limits.maxWidth || image.height > limits.maxHeight) {
|
||||
throw new HttpError(413, `Image dimensions must be at most ${limits.maxWidth}x${limits.maxHeight}.`);
|
||||
}
|
||||
if (image.width * image.height > limits.maxPixels) {
|
||||
throw new HttpError(413, 'Image has too many pixels.');
|
||||
}
|
||||
if (limits.requireSquare && image.width !== image.height) {
|
||||
throw new HttpError(422, 'Image must be square.');
|
||||
}
|
||||
|
||||
return { ...image, byteSize: buffer.length };
|
||||
}
|
||||
|
||||
export function detectImage(buffer) {
|
||||
if (buffer.subarray(0, 8).equals(PNG_SIGNATURE) && buffer.length >= 24) {
|
||||
return {
|
||||
format: 'png',
|
||||
ext: 'png',
|
||||
mime: 'image/png',
|
||||
width: buffer.readUInt32BE(16),
|
||||
height: buffer.readUInt32BE(20)
|
||||
};
|
||||
}
|
||||
|
||||
if (buffer.length > 4 && buffer[0] === 0xff && buffer[1] === 0xd8) {
|
||||
const jpeg = readJpegDimensions(buffer);
|
||||
if (jpeg) {
|
||||
return {
|
||||
format: 'jpeg',
|
||||
ext: 'jpg',
|
||||
mime: 'image/jpeg',
|
||||
width: jpeg.width,
|
||||
height: jpeg.height
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readJpegDimensions(buffer) {
|
||||
let offset = 2;
|
||||
while (offset < buffer.length) {
|
||||
while (buffer[offset] === 0xff) offset += 1;
|
||||
const marker = buffer[offset];
|
||||
offset += 1;
|
||||
|
||||
if (marker === 0xd9 || marker === 0xda) return null;
|
||||
if (offset + 2 > buffer.length) return null;
|
||||
|
||||
const length = buffer.readUInt16BE(offset);
|
||||
if (length < 2 || offset + length > buffer.length) return null;
|
||||
|
||||
if (isStartOfFrame(marker)) {
|
||||
if (length < 7) return null;
|
||||
return {
|
||||
height: buffer.readUInt16BE(offset + 3),
|
||||
width: buffer.readUInt16BE(offset + 5)
|
||||
};
|
||||
}
|
||||
|
||||
offset += length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isStartOfFrame(marker) {
|
||||
return (
|
||||
(marker >= 0xc0 && marker <= 0xc3) ||
|
||||
(marker >= 0xc5 && marker <= 0xc7) ||
|
||||
(marker >= 0xc9 && marker <= 0xcb) ||
|
||||
(marker >= 0xcd && marker <= 0xcf)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
const DEFAULT_MODEL = process.env.OPENAI_MODERATION_MODEL || 'gpt-4o-mini';
|
||||
|
||||
export async function moderateImage({ buffer, mime }) {
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
return {
|
||||
status: 'pending',
|
||||
score: 50,
|
||||
reason: 'AI moderation is not configured; queued for review.'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.openai.com/v1/responses', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: DEFAULT_MODEL,
|
||||
temperature: 0,
|
||||
max_output_tokens: 220,
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'input_text',
|
||||
text: moderationPrompt()
|
||||
},
|
||||
{
|
||||
type: 'input_image',
|
||||
image_url: `data:${mime};base64,${buffer.toString('base64')}`
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
status: 'pending',
|
||||
score: 50,
|
||||
reason: `AI moderation unavailable (${response.status}); queued for review.`
|
||||
};
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
return normalizeDecision(parseJsonOutput(payload.output_text || extractOutputText(payload)));
|
||||
} catch {
|
||||
return {
|
||||
status: 'pending',
|
||||
score: 50,
|
||||
reason: 'AI moderation failed; queued for review.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function moderationPrompt() {
|
||||
return [
|
||||
'You are moderating image uploads for THE_MEME_PROTOCOL, a meme gallery.',
|
||||
'This is a meme site. Images may be sarcastic, roasting, edgy, political, profane, absurd, or not PG-13.',
|
||||
'Do not reject merely because a meme is rude, critical, weird, darkly humorous, or adult in tone.',
|
||||
'Reject only if the image appears illegal or likely illegal to host or distribute, including child sexual content, sexual content involving minors, explicit non-consensual sexual content, bestiality, credible illegal activity instructions, terrorist or extremist recruitment, doxxing/private identity documents, or explicit threats that appear actionable.',
|
||||
'If legality or age is ambiguous, choose pending.',
|
||||
'Assign MEME_CONSENSUS_SCORE from 0-100: higher means it is clearly a meme/reaction/roast/remix and suitable for this site; lower means random photo, spam, ad, QR scam, screenshot dump, or unclear.',
|
||||
'Return only compact JSON with keys: decision, score, reason.',
|
||||
'decision must be one of: approved, pending, rejected.',
|
||||
'Use approved only when it appears legal and score is at least 65.',
|
||||
'Use pending for ambiguity, uncertainty, low meme relevance, or anything that needs human review.',
|
||||
'Use rejected only for likely illegal content.'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function parseJsonOutput(text) {
|
||||
const trimmed = String(text || '').trim();
|
||||
const match = trimmed.match(/\{[\s\S]*\}/);
|
||||
if (!match) return null;
|
||||
try {
|
||||
return JSON.parse(match[0]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDecision(raw) {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return { status: 'pending', score: 50, reason: 'AI response was ambiguous; queued for review.' };
|
||||
}
|
||||
|
||||
const decision = ['approved', 'pending', 'rejected'].includes(raw.decision) ? raw.decision : 'pending';
|
||||
const score = Math.max(0, Math.min(100, Number.parseInt(raw.score, 10) || 50));
|
||||
const reason = typeof raw.reason === 'string' && raw.reason.trim()
|
||||
? raw.reason.trim().slice(0, 300)
|
||||
: 'No moderation reason supplied.';
|
||||
|
||||
if (decision === 'approved' && score < 65) {
|
||||
return { status: 'pending', score, reason: `${reason} Low MEME_CONSENSUS_SCORE queued for review.` };
|
||||
}
|
||||
return { status: decision, score, reason };
|
||||
}
|
||||
|
||||
function extractOutputText(payload) {
|
||||
const parts = [];
|
||||
for (const item of payload.output || []) {
|
||||
for (const content of item.content || []) {
|
||||
if (content.type === 'output_text' && content.text) parts.push(content.text);
|
||||
}
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { HttpError } from './errors.js';
|
||||
|
||||
export async function parseMultipartUpload(req, options) {
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
const boundaryMatch = contentType.match(/multipart\/form-data;\s*boundary=(?:"([^"]+)"|([^;]+))/i);
|
||||
if (!boundaryMatch) {
|
||||
throw new HttpError(415, 'Expected multipart form data.');
|
||||
}
|
||||
|
||||
const body = await readRequestBody(req, options.maxRequestBytes);
|
||||
const boundary = Buffer.from(`--${boundaryMatch[1] || boundaryMatch[2]}`);
|
||||
const parts = splitMultipart(body, boundary);
|
||||
let upload = null;
|
||||
|
||||
for (const part of parts) {
|
||||
const separator = part.indexOf('\r\n\r\n');
|
||||
if (separator === -1) continue;
|
||||
const headerText = part.subarray(0, separator).toString('latin1');
|
||||
const content = trimTrailingCrlf(part.subarray(separator + 4));
|
||||
const disposition = headerText.match(/^content-disposition:\s*form-data;\s*(.+)$/im);
|
||||
if (!disposition) continue;
|
||||
|
||||
const attrs = parseDispositionAttrs(disposition[1]);
|
||||
if (attrs.name !== options.fieldName || !attrs.filename) continue;
|
||||
if (upload) throw new HttpError(400, 'Upload one image at a time.');
|
||||
if (content.length > options.maxFileBytes) throw new HttpError(413, 'Image exceeds the 5 MB limit.');
|
||||
upload = {
|
||||
filename: cleanFilename(attrs.filename),
|
||||
buffer: Buffer.from(content)
|
||||
};
|
||||
}
|
||||
|
||||
if (!upload) throw new HttpError(400, 'Missing image file.');
|
||||
return upload;
|
||||
}
|
||||
|
||||
async function readRequestBody(req, maxBytes) {
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
for await (const chunk of req) {
|
||||
total += chunk.length;
|
||||
if (total > maxBytes) {
|
||||
throw new HttpError(413, 'Upload request is too large.');
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks, total);
|
||||
}
|
||||
|
||||
function splitMultipart(body, boundary) {
|
||||
const parts = [];
|
||||
let cursor = body.indexOf(boundary);
|
||||
while (cursor !== -1) {
|
||||
cursor += boundary.length;
|
||||
if (body[cursor] === 0x2d && body[cursor + 1] === 0x2d) break;
|
||||
if (body[cursor] === 0x0d && body[cursor + 1] === 0x0a) cursor += 2;
|
||||
const next = body.indexOf(boundary, cursor);
|
||||
if (next === -1) break;
|
||||
parts.push(body.subarray(cursor, next));
|
||||
cursor = next;
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function trimTrailingCrlf(buffer) {
|
||||
if (buffer.length >= 2 && buffer[buffer.length - 2] === 0x0d && buffer[buffer.length - 1] === 0x0a) {
|
||||
return buffer.subarray(0, -2);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function parseDispositionAttrs(value) {
|
||||
const attrs = {};
|
||||
for (const part of value.split(';')) {
|
||||
const [rawKey, ...rawValue] = part.trim().split('=');
|
||||
if (!rawKey || rawValue.length === 0) continue;
|
||||
attrs[rawKey.toLowerCase()] = rawValue.join('=').trim().replace(/^"|"$/g, '');
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function cleanFilename(filename) {
|
||||
const base = filename.split(/[\\/]/).pop() || 'upload';
|
||||
return base.replace(/[^\w.-]+/g, '_').slice(0, 120);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import sharp from 'sharp';
|
||||
import { HttpError } from './errors.js';
|
||||
|
||||
const MAX_OUTPUT_DIMENSION = Number.parseInt(process.env.MAX_IMAGE_DIMENSION || '1600', 10);
|
||||
const WEBP_QUALITY = Number.parseInt(process.env.WEBP_QUALITY || '85', 10);
|
||||
|
||||
export async function normalizeToWebp(buffer) {
|
||||
let image;
|
||||
try {
|
||||
image = sharp(buffer, {
|
||||
animated: false,
|
||||
limitInputPixels: 20_000_000
|
||||
});
|
||||
} catch {
|
||||
throw new HttpError(400, 'Image could not be decoded.');
|
||||
}
|
||||
|
||||
const metadata = await image.metadata().catch(() => null);
|
||||
if (!metadata?.width || !metadata?.height) {
|
||||
throw new HttpError(400, 'Image could not be decoded.');
|
||||
}
|
||||
if (metadata.width !== metadata.height) {
|
||||
throw new HttpError(422, 'Image must be square.');
|
||||
}
|
||||
|
||||
const targetSize = Math.min(metadata.width, MAX_OUTPUT_DIMENSION);
|
||||
const output = await image
|
||||
.rotate()
|
||||
.resize(targetSize, targetSize, {
|
||||
fit: 'cover',
|
||||
withoutEnlargement: true
|
||||
})
|
||||
.webp({
|
||||
quality: WEBP_QUALITY,
|
||||
effort: 4
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
return {
|
||||
buffer: output,
|
||||
image: {
|
||||
format: 'webp',
|
||||
ext: 'webp',
|
||||
mime: 'image/webp',
|
||||
width: targetSize,
|
||||
height: targetSize,
|
||||
byteSize: output.length
|
||||
}
|
||||
};
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
export function encodePng(width, height, pixelAt) {
|
||||
const stride = width * 4 + 1;
|
||||
const raw = Buffer.alloc(stride * height);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const row = y * stride;
|
||||
raw[row] = 0;
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const [r, g, b, a = 255] = pixelAt(x, y, width, height);
|
||||
const offset = row + 1 + x * 4;
|
||||
raw[offset] = r;
|
||||
raw[offset + 1] = g;
|
||||
raw[offset + 2] = b;
|
||||
raw[offset + 3] = a;
|
||||
}
|
||||
}
|
||||
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
chunk('IHDR', ihdr(width, height)),
|
||||
chunk('IDAT', zlib.deflateSync(raw)),
|
||||
chunk('IEND', Buffer.alloc(0))
|
||||
]);
|
||||
}
|
||||
|
||||
function ihdr(width, height) {
|
||||
const buffer = Buffer.alloc(13);
|
||||
buffer.writeUInt32BE(width, 0);
|
||||
buffer.writeUInt32BE(height, 4);
|
||||
buffer[8] = 8;
|
||||
buffer[9] = 6;
|
||||
buffer[10] = 0;
|
||||
buffer[11] = 0;
|
||||
buffer[12] = 0;
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const typeBuffer = Buffer.from(type, 'ascii');
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(data.length, 0);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0);
|
||||
return Buffer.concat([length, typeBuffer, data, crc]);
|
||||
}
|
||||
|
||||
function crc32(buffer) {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of buffer) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
|
||||
}
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { validateImage } from './image.js';
|
||||
import { normalizeToWebp } from './normalize.js';
|
||||
import { encodePng } from './png.js';
|
||||
|
||||
const DEMO_COUNT = 12;
|
||||
|
||||
export async function seedDemoMemes(store) {
|
||||
if (store.count() > 0) return;
|
||||
|
||||
for (let index = 0; index < DEMO_COUNT; index += 1) {
|
||||
const width = 960;
|
||||
const height = 960;
|
||||
const buffer = encodePng(width, height, pixelFactory(index));
|
||||
const image = validateImage(buffer, {
|
||||
maxBytes: 5 * 1024 * 1024,
|
||||
maxWidth: 6000,
|
||||
maxHeight: 6000,
|
||||
maxPixels: 20_000_000
|
||||
});
|
||||
const normalized = await normalizeToWebp(buffer);
|
||||
const createdAt = new Date(Date.now() - index * 17 * 60 * 1000).toISOString();
|
||||
await store.save({
|
||||
buffer: normalized.buffer,
|
||||
image: normalized.image,
|
||||
originalName: `protocol-sample-${String(index + 1).padStart(2, '0')}.png`,
|
||||
originalMime: image.mime,
|
||||
createdAt,
|
||||
demo: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function pixelFactory(seed) {
|
||||
return (x, y, width, height) => {
|
||||
const nx = x / width;
|
||||
const ny = y / height;
|
||||
const grid = x % (32 + seed * 3) < 1 || y % (29 + seed * 2) < 1;
|
||||
const diagonal = Math.abs(((x + y + seed * 37) % 180) - 90) < 2;
|
||||
const ring = Math.abs(Math.hypot(nx - 0.5, ny - 0.5) - (0.18 + (seed % 4) * 0.05)) < 0.006;
|
||||
const scan = y % 7 === 0;
|
||||
const pulse = (Math.sin((x * (seed + 3) + y * 2) / 42) + 1) / 2;
|
||||
const green = grid || diagonal || ring ? 230 : Math.round(18 + pulse * 52);
|
||||
const blue = ring || (seed % 3 === 1 && diagonal) ? 210 : Math.round(18 + pulse * 30);
|
||||
const red = scan ? 22 : Math.round(4 + pulse * 14);
|
||||
return [red, green, blue, 255];
|
||||
};
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export async function createStore({ dataDir }) {
|
||||
const root = path.resolve(dataDir);
|
||||
const indexDir = path.join(root, 'index');
|
||||
const indexFile = path.join(indexDir, 'memes.jsonl');
|
||||
await fs.mkdir(indexDir, { recursive: true });
|
||||
const entries = await loadIndex(indexFile, root);
|
||||
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
||||
let writeChain = Promise.resolve();
|
||||
|
||||
return {
|
||||
count(status) {
|
||||
return status ? entries.filter((entry) => entry.status === status).length : entries.length;
|
||||
},
|
||||
get(id) {
|
||||
return byId.get(id) || null;
|
||||
},
|
||||
absolutePath(storageKey) {
|
||||
return path.join(root, storageKey);
|
||||
},
|
||||
list({ page, pageSize }) {
|
||||
const visible = entries.filter((entry) => entry.status === 'approved');
|
||||
const total = visible.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const start = (safePage - 1) * pageSize;
|
||||
return {
|
||||
page: safePage,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages,
|
||||
memes: visible.slice(start, start + pageSize).map(toPublicMeme)
|
||||
};
|
||||
},
|
||||
listForReview({ status = 'pending' } = {}) {
|
||||
return {
|
||||
total: entries.filter((entry) => entry.status === status).length,
|
||||
memes: entries.filter((entry) => entry.status === status).map(toPublicMeme)
|
||||
};
|
||||
},
|
||||
async approve(ids) {
|
||||
const approved = [];
|
||||
for (const id of ids) {
|
||||
const record = byId.get(id);
|
||||
if (!record || record.status !== 'pending') continue;
|
||||
record.status = 'approved';
|
||||
record.reviewedAt = new Date().toISOString();
|
||||
record.moderationReason = `${record.moderationReason || 'Queued for review.'} Admin approved.`;
|
||||
approved.push(toPublicMeme(record));
|
||||
}
|
||||
if (approved.length > 0) {
|
||||
writeChain = writeChain.then(() => persistAll(root, indexFile, entries));
|
||||
await writeChain;
|
||||
}
|
||||
return approved;
|
||||
},
|
||||
async delete(ids) {
|
||||
const deleted = [];
|
||||
for (const id of ids) {
|
||||
const index = entries.findIndex((entry) => entry.id === id);
|
||||
if (index === -1) continue;
|
||||
const [record] = entries.splice(index, 1);
|
||||
byId.delete(id);
|
||||
deleted.push(id);
|
||||
await fs.rm(path.join(root, record.storageKey), { force: true });
|
||||
await fs.rm(path.join(root, record.metaKey), { force: true });
|
||||
}
|
||||
if (deleted.length > 0) {
|
||||
writeChain = writeChain.then(() => persistAll(root, indexFile, entries));
|
||||
await writeChain;
|
||||
}
|
||||
return deleted;
|
||||
},
|
||||
async incrementMetric(id, metric) {
|
||||
const record = byId.get(id);
|
||||
if (!record) return null;
|
||||
if (metric !== 'viewCount' && metric !== 'downloadCount') return toPublicMeme(record);
|
||||
|
||||
record[metric] = Number.isFinite(record[metric]) ? record[metric] + 1 : 1;
|
||||
writeChain = writeChain.then(() => persistRecord(root, indexFile, entries, record));
|
||||
await writeChain;
|
||||
return toPublicMeme(record);
|
||||
},
|
||||
async save({
|
||||
buffer,
|
||||
image,
|
||||
originalName,
|
||||
createdAt = new Date().toISOString(),
|
||||
demo = false,
|
||||
status = 'approved',
|
||||
moderationScore = demo ? 90 : 0,
|
||||
moderationReason = '',
|
||||
originalMime = image.mime
|
||||
}) {
|
||||
const id = crypto.createHash('sha256').update(buffer).digest('hex');
|
||||
const existing = byId.get(id);
|
||||
if (existing) return toPublicMeme(existing);
|
||||
|
||||
const date = createdAt.slice(0, 10).replaceAll('-', '/');
|
||||
const shard = `${id.slice(0, 2)}/${id.slice(2, 4)}`;
|
||||
const storageKey = `memes/${date}/${shard}/${id}.${image.ext}`;
|
||||
const metaKey = `meta/${date}/${shard}/${id}.json`;
|
||||
const record = {
|
||||
id,
|
||||
createdAt,
|
||||
originalName,
|
||||
byteSize: image.byteSize,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
mime: image.mime,
|
||||
originalMime,
|
||||
ext: image.ext,
|
||||
status,
|
||||
moderationScore,
|
||||
moderationReason,
|
||||
viewCount: 0,
|
||||
downloadCount: 0,
|
||||
storageKey,
|
||||
metaKey,
|
||||
demo
|
||||
};
|
||||
|
||||
await fs.mkdir(path.dirname(path.join(root, storageKey)), { recursive: true });
|
||||
await fs.mkdir(path.dirname(path.join(root, metaKey)), { recursive: true });
|
||||
await fs.writeFile(path.join(root, storageKey), buffer, { flag: 'wx' }).catch((error) => {
|
||||
if (error.code !== 'EEXIST') throw error;
|
||||
});
|
||||
await persistRecord(root, indexFile, [...entries, record], record, { writeFileFlag: 'wx' });
|
||||
|
||||
entries.unshift(record);
|
||||
entries.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
byId.set(id, record);
|
||||
return toPublicMeme(record);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function loadIndex(indexFile, root) {
|
||||
let content = '';
|
||||
try {
|
||||
content = await fs.readFile(indexFile, 'utf8');
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
|
||||
const recordsById = new Map();
|
||||
for (const line of content.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const record = JSON.parse(line);
|
||||
record.status = record.status || 'approved';
|
||||
record.moderationScore = Number.isFinite(record.moderationScore) ? record.moderationScore : 80;
|
||||
record.moderationReason = record.moderationReason || '';
|
||||
record.originalMime = record.originalMime || record.mime;
|
||||
record.viewCount = Number.isFinite(record.viewCount) ? record.viewCount : 0;
|
||||
record.downloadCount = Number.isFinite(record.downloadCount) ? record.downloadCount : 0;
|
||||
const mediaPath = path.join(root, record.storageKey);
|
||||
const relative = path.relative(root, mediaPath);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) continue;
|
||||
await fs.access(mediaPath);
|
||||
recordsById.set(record.id, record);
|
||||
} catch {
|
||||
// Ignore corrupt index lines; individual metadata files remain on disk for recovery.
|
||||
}
|
||||
}
|
||||
const records = [...recordsById.values()];
|
||||
records.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
return records;
|
||||
}
|
||||
|
||||
async function persistRecord(root, indexFile, entries, record, options = {}) {
|
||||
await fs.writeFile(path.join(root, record.metaKey), `${JSON.stringify(record, null, 2)}\n`, {
|
||||
flag: options.writeFileFlag || 'w'
|
||||
}).catch((error) => {
|
||||
if (options.writeFileFlag === 'wx' && error.code === 'EEXIST') return;
|
||||
throw error;
|
||||
});
|
||||
await persistAll(root, indexFile, entries);
|
||||
}
|
||||
|
||||
async function persistAll(root, indexFile, entries) {
|
||||
await Promise.all(entries.map((entry) => fs.writeFile(
|
||||
path.join(root, entry.metaKey),
|
||||
`${JSON.stringify(entry, null, 2)}\n`
|
||||
).catch((error) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
})));
|
||||
await fs.writeFile(indexFile, `${entries.map((entry) => JSON.stringify(entry)).join('\n')}\n`);
|
||||
}
|
||||
|
||||
function toPublicMeme(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)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { HttpError } from './errors.js';
|
||||
|
||||
const HOUR_LIMIT = 5;
|
||||
const DAY_LIMIT = 10;
|
||||
const GLOBAL_DAY_LIMIT = 100;
|
||||
|
||||
export async function createUploadLimiter({ dataDir }) {
|
||||
const filePath = path.join(path.resolve(dataDir), 'index', 'upload-limits.json');
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
let state = await loadState(filePath);
|
||||
let writeChain = Promise.resolve();
|
||||
|
||||
return {
|
||||
async checkAndConsume(ip) {
|
||||
const now = new Date();
|
||||
const day = now.toISOString().slice(0, 10);
|
||||
const hour = now.toISOString().slice(0, 13);
|
||||
const key = crypto.createHash('sha256').update(ip || 'unknown').digest('hex');
|
||||
|
||||
if (state.day !== day) state = { day, global: 0, clients: {} };
|
||||
const client = state.clients[key] || { day, dayCount: 0, hour, hourCount: 0 };
|
||||
if (client.day !== day) {
|
||||
client.day = day;
|
||||
client.dayCount = 0;
|
||||
}
|
||||
if (client.hour !== hour) {
|
||||
client.hour = hour;
|
||||
client.hourCount = 0;
|
||||
}
|
||||
|
||||
if (state.global >= GLOBAL_DAY_LIMIT) {
|
||||
throw new HttpError(429, 'Daily site upload budget reached. Try again tomorrow.');
|
||||
}
|
||||
if (client.hourCount >= HOUR_LIMIT) {
|
||||
throw new HttpError(429, 'Upload limit reached: 5 images per hour.');
|
||||
}
|
||||
if (client.dayCount >= DAY_LIMIT) {
|
||||
throw new HttpError(429, 'Upload limit reached: 10 images per day.');
|
||||
}
|
||||
|
||||
state.global += 1;
|
||||
client.hourCount += 1;
|
||||
client.dayCount += 1;
|
||||
state.clients[key] = client;
|
||||
writeChain = writeChain.then(() => fs.writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`));
|
||||
await writeChain;
|
||||
|
||||
return {
|
||||
remainingHour: Math.max(0, HOUR_LIMIT - client.hourCount),
|
||||
remainingDay: Math.max(0, DAY_LIMIT - client.dayCount),
|
||||
remainingGlobalDay: Math.max(0, GLOBAL_DAY_LIMIT - state.global)
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function loadState(filePath) {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(filePath, 'utf8'));
|
||||
} catch {
|
||||
return { day: new Date().toISOString().slice(0, 10), global: 0, clients: {} };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user