This commit is contained in:
2026-05-08 18:18:36 -03:00
parent c4bb073ca1
commit 5e10af882b
26 changed files with 3150 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
export class HttpError extends Error {
constructor(statusCode, message) {
super(message);
this.statusCode = statusCode;
}
}
+67
View File
@@ -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);
});
}
+93
View File
@@ -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)
);
}
+112
View File
@@ -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');
}
+85
View File
@@ -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);
}
+50
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
};
}
+66
View File
@@ -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: {} };
}
}