fix(api): Bildmaße prüfen, Dateien ohne delete(owner), Limits, Melder-Netz

- Beim Veröffentlichen liest die API die ersten 64 Bytes beider Dateien (PNG-IHDR, WebP VP8/VP8L/VP8X) und verlangt Bild 1536x969,
  Vorschau 480x302-304, sonst 400 "Das Bild hat nicht das Kartenformat."
- Veröffentlichte und ausgeblendete Dateien tragen kein delete(owner) mehr (Abweichung von Spec §7): kein Löschen und Neuanlegen
  derselben Datei-ID nach dem Veröffentlichen; scheitert das Festlegen, bekommen die Dateien delete(owner) für den Client-Aufräumer zurück
- Melder-Identität aus dem IPv4-/24 bzw. IPv6-/48; das Rate-Limit zählt weiter je Anschluss
- Höchstens 20 Veröffentlichungen je Konto (429); sourceCardId muss eine eigene Zeile in `cards` sein (403)
- Steuer-, Nullbreiten- und Bidi-Zeichen in Titel, Herausgeber, Anzeigename und Tags abgelehnt
- Tags als ",a,b," gespeichert (exakter Tag-Filter)
- fehlgeschlagenes Zurücknehmen wird mit `rollback failed for card <id>` protokolliert
- README: Endpunkt-Tabelle, Regeln, Scopes (documents.*), akzeptiertes Risiko X-Real-Ip im Docker-Netz

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 16:30:28 +00:00
1 parent 87b71ec71f
commit 4c90ee49e8
6 files changed
+743 -163

No files matched your search

+181 -56
View File
@@ -5,12 +5,12 @@
* Community-Galerie laufen hier über den Appwrite-Server-Key (siehe appwrite.js).
*
* POST /api/veroeffentlichen Nutzer-JWT {title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId}
* 401 kein/ungültiges JWT · 400 Validierung oder Datei fehlt/falsches Format · 403 Datei nicht meine / falscher Bucket /
* Datei gesperrt (kein read("any")) oder von einer Karte des Autors schon verwendet
* 409 schon veröffentlicht · 201 {id}
* 401 kein/ungültiges JWT · 400 Validierung oder Datei fehlt/falsches Format/falsche Bildmaße · 403 Datei nicht meine /
* falscher Bucket / Datei gesperrt (kein read("any")) oder von einer Karte des Autors schon verwendet / Karte nicht
* meine (`cards`) · 409 schon veröffentlicht · 429 schon 20 Veröffentlichungen · 201 {id}
* POST /api/zurueckziehen Nutzer-JWT (Autor oder Admin) {id}
* 401 · 400 · 403 fremd · 404 fehlt · 200 {ok:true}
* POST /api/melden ohne Anmeldung {id, reason}
* POST /api/melden ohne Anmeldung {id, reason} (Melder = IPv4-/24 bzw. IPv6-/48, gehasht)
* 400 Grund/ID · 404 fehlt oder ausgeblendet · 429 Rate-Limit · 200 {ok:true} oder {schonGemeldet:true}
* POST /api/admin/sichtbarkeit Admin-JWT {id, sichtbar}
* 401 · 403 kein Admin · 400 · 404 · 200 {ok:true}
@@ -18,6 +18,7 @@
* alles andere 404; Body höchstens 8 KB (413), nur application/json (415)
*
* Das Nutzer-JWT kommt im Header X-Aircard-Jwt und wird bei Appwrite (GET /account) geprüft; Admin = Label `admin`.
* Der Appwrite-Key braucht die Console-Scopes documents.read/documents.write (= rows.* der API) und files.read/files.write.
* Reihenfolge je Anfrage: Content-Type (415) -> Body lesen (413) -> JWT (401) -> JSON und Felder (400) -> Logik.
* Fehlerantworten sind immer {error: <deutscher Text>}.
* Appwrite-Fehler: 502 (keine Antwort, 429, 5xx) bzw. 500 (alle anderen, z. B. falscher Key oder fehlende Tabelle).
@@ -27,10 +28,11 @@
const crypto = require('node:crypto');
const net = require('node:net');
const { AppwriteError, queryEqual, queryLimit, queryCursorAfter } = require('./appwrite.js');
const { AppwriteError, queryEqual, queryLimit } = require('./appwrite.js');
const TABLE_GALLERY = 'gallery';
const TABLE_REPORTS = 'reports';
const TABLE_CARDS = 'cards';
const BUCKET = 'gallery-images';
const MAX_BODY_BYTES = 8 * 1024;
@@ -38,9 +40,17 @@ const MAX_FILE_BYTES = 5 * 1024 * 1024;
const FILE_MIME_TYPES = new Set(['image/png', 'image/webp']);
const HIDE_AT_REPORTS = 3;
const REPORT_LIST_LIMIT = 100;
const OWNER_ROWS_PAGE = 100;
const OWNER_ROWS_MAX_PAGES = 100;
/** Höchstens so viele Veröffentlichungen je Konto (ausgeblendete zählen mit); Zurückziehen gibt einen Platz frei. */
const MAX_PUBLICATIONS = 20;
const REPORT_DELETE_ROUNDS = 50;
/** So viele Bytes vom Dateianfang genügen für PNG-IHDR und die WebP-Kopfzeilen. */
const IMAGE_HEAD_BYTES = 64;
/** Kartenmaße wie web/src/card/geometry.ts (CARD_W, CARD_H, PREVIEW_WIDTH; Vorschauhöhe 303 plus/minus 1). */
const IMAGE_WIDTH = 1536;
const IMAGE_HEIGHT = 969;
const PREVIEW_WIDTH = 480;
const PREVIEW_HEIGHT_MIN = 302;
const PREVIEW_HEIGHT_MAX = 304;
const RATE_LIMIT = 10;
const RATE_WINDOW_MS = 60 * 60 * 1000;
const RATE_SWEEP_MS = 60 * 1000;
@@ -50,7 +60,9 @@ const LOG_WINDOW_MS = 60 * 1000;
const REASONS = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'];
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
// Steuerzeichen (C0, DEL, C1), Nullbreiten- und Bidi-Zeichen (U+200B-200F, U+202A-202E, U+2060-206F) und das BOM (U+FEFF):
// sie sind unsichtbar, ordnen Text um oder lassen Titel leer aussehen. Dasselbe Muster steht in publish-dialog.ts.
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/;
const JWT_CHARACTERS = /^[\x21-\x7e]+$/;
const MSG = {
@@ -65,6 +77,9 @@ const MSG = {
fileSize: 'Das Bild ist größer als 5 MB.',
alreadyPublished: 'Diese Karte ist schon veröffentlicht.',
filesTaken: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.',
imageFormat: 'Das Bild hat nicht das Kartenformat.',
notYourCard: 'Diese Karte gehört nicht zu deinem Konto.',
tooManyPublications: `Du hast schon ${MAX_PUBLICATIONS} Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.`,
tooManyReports: 'Zu viele Meldungen – bitte später nochmal.',
badRequest: 'Ungültige Anfrage.',
badCardId: 'Ungültige Karten-ID.',
@@ -98,14 +113,28 @@ function hiddenRowPermissions(owner) {
return [`read("user:${owner}")`, 'read("label:admin")'];
}
function visibleFilePermissions(owner) {
/**
* Frisch hochgeladene, noch nicht veröffentlichte Datei: lesen darf jeder, löschen der Autor (der Client räumt damit
* Fehlversuche auf). Nur so kommen Dateien in `publish`; die API setzt sie beim Veröffentlichen auf `visibleFilePermissions`.
*/
function uploadFilePermissions(owner) {
assertOwner(owner);
return ['read("any")', `delete("user:${owner}")`];
}
/**
* Veröffentlichte Datei: nur read("any"), kein delete(owner). Abweichung von Spec §7 (Ruling): sonst könnte der Autor die
* Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen
* löscht die Dateien serverseitig mit dem Key.
*/
function visibleFilePermissions() {
return ['read("any")'];
}
/** Ausgeblendete Datei: nur Autor und Admins lesen; ebenfalls kein delete(owner). */
function hiddenFilePermissions(owner) {
assertOwner(owner);
return [`read("user:${owner}")`, 'read("label:admin")', `delete("user:${owner}")`];
return [`read("user:${owner}")`, 'read("label:admin")'];
}
function hash32(text) {
@@ -138,11 +167,11 @@ function expandIpv6(address) {
}
/**
* Vereinheitlicht eine Client-Adresse für Hash und Rate-Limit: IPv4 bleibt, IPv4-gemappte IPv6 wird zu IPv4,
* jede andere IPv6-Adresse wird auf ihr /64-Netz gekürzt (ein Anschluss hat Milliarden Adressen).
* Zerlegt eine Client-Adresse: { kind: 'v4', text } (auch IPv4-gemappte IPv6), { kind: 'v6', groups } (acht Zahlen),
* { kind: 'other', text } (keine Adresse) oder { kind: 'none' } (kein Text).
*/
function normalizeIp(ip) {
if (typeof ip !== 'string') return '';
function parseIp(ip) {
if (typeof ip !== 'string') return { kind: 'none' };
let text = ip.trim();
if (text.startsWith('[') && text.endsWith(']')) text = text.slice(1, -1);
const zone = text.indexOf('%');
@@ -150,15 +179,40 @@ function normalizeIp(ip) {
const withPort = /^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/.exec(text);
if (withPort) text = withPort[1];
if (net.isIPv4(text)) return text;
if (net.isIPv4(text)) return { kind: 'v4', text };
if (net.isIPv6(text)) {
const groups = expandIpv6(text);
if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) {
return `${groups[6] >> 8}.${groups[6] & 255}.${groups[7] >> 8}.${groups[7] & 255}`;
return { kind: 'v4', text: `${groups[6] >> 8}.${groups[6] & 255}.${groups[7] >> 8}.${groups[7] & 255}` };
}
return `${groups.slice(0, 4).map((group) => group.toString(16)).join(':')}::/64`;
return { kind: 'v6', groups };
}
return text.toLowerCase().slice(0, 64);
return { kind: 'other', text: text.toLowerCase().slice(0, 64) };
}
const hexGroups = (groups, count) => groups.slice(0, count).map((group) => group.toString(16)).join(':');
/**
* Vereinheitlicht eine Client-Adresse für das Rate-Limit (je Anschluss): IPv4 bleibt, IPv4-gemappte IPv6 wird zu IPv4,
* jede andere IPv6-Adresse wird auf ihr /64-Netz gekürzt (ein Anschluss hat Milliarden Adressen).
*/
function normalizeIp(ip) {
const parsed = parseIp(ip);
if (parsed.kind === 'none') return '';
if (parsed.kind === 'v6') return `${hexGroups(parsed.groups, 4)}::/64`;
return parsed.text;
}
/**
* Das Netz einer Client-Adresse für die Melder-Identität: IPv4 -> /24, IPv6 -> /48 (IPv4-gemappte IPv6 wie IPv4).
* So zählen drei Adressen aus demselben Netz als ein Melder und blenden eine Karte nicht aus.
*/
function reporterNetwork(ip) {
const parsed = parseIp(ip);
if (parsed.kind === 'none') return '';
if (parsed.kind === 'v4') return `${parsed.text.split('.').slice(0, 3).join('.')}.0/24`;
if (parsed.kind === 'v6') return `${hexGroups(parsed.groups, 3)}::/48`;
return parsed.text;
}
function firstHeaderValue(req, name) {
@@ -173,9 +227,13 @@ function clientIp(req) {
return firstHeaderValue(req, 'x-real-ip') || firstHeaderValue(req, 'x-forwarded-for') || (req.socket && req.socket.remoteAddress) || '';
}
/** HMAC-SHA256 (Hex) der normalisierten IP mit dem Server-Salz. */
function hmacHex(salt, text) {
return crypto.createHmac('sha256', salt).update(text).digest('hex');
}
/** HMAC-SHA256 (Hex) des Netzes der IP (IPv4 /24, IPv6 /48) mit dem Server-Salz: die Melder-Identität. */
function reporterHash(salt, ip) {
return crypto.createHmac('sha256', salt).update(normalizeIp(ip)).digest('hex');
return hmacHex(salt, reporterNetwork(ip));
}
function isPlainObject(value) {
@@ -188,6 +246,37 @@ function safeToken(value, fallback = 'unknown') {
return text === '' ? fallback : text;
}
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
/**
* Bildmaße aus den ersten Bytes einer Datei: PNG (IHDR) oder WebP (VP8, VP8L, VP8X); sonst null (auch bei zu kurzem Anfang).
* @returns {{width: number, height: number} | null}
*/
function readImageSize(bytes) {
if (!Buffer.isBuffer(bytes)) return null;
if (bytes.length >= 24 && bytes.subarray(0, 8).equals(PNG_SIGNATURE) && bytes.toString('latin1', 12, 16) === 'IHDR') {
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
}
if (bytes.length >= 16 && bytes.toString('latin1', 0, 4) === 'RIFF' && bytes.toString('latin1', 8, 12) === 'WEBP') {
const kind = bytes.toString('latin1', 12, 16);
if (kind === 'VP8 ' && bytes.length >= 30 && bytes[23] === 0x9d && bytes[24] === 0x01 && bytes[25] === 0x2a) {
return { width: bytes.readUInt16LE(26) & 0x3fff, height: bytes.readUInt16LE(28) & 0x3fff };
}
if (kind === 'VP8L' && bytes.length >= 25 && bytes[20] === 0x2f) {
const bits = bytes.readUInt32LE(21);
return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
}
if (kind === 'VP8X' && bytes.length >= 30) {
return { width: bytes.readUIntLE(24, 3) + 1, height: bytes.readUIntLE(27, 3) + 1 };
}
}
return null;
}
const isCardImageSize = (size) => size !== null && size.width === IMAGE_WIDTH && size.height === IMAGE_HEIGHT;
const isCardPreviewSize = (size) =>
size !== null && size.width === PREVIEW_WIDTH && size.height >= PREVIEW_HEIGHT_MIN && size.height <= PREVIEW_HEIGHT_MAX;
const TEXT_FIELDS = [
{ key: 'title', article: 'Der', name: 'Titel', max: 60, required: true },
{ key: 'issuer', article: 'Der', name: 'Herausgeber', max: 40, required: false },
@@ -444,41 +533,59 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
}
// ---- Dateien und Zeilen
function checkFile(file, userId) {
/**
* Erste Prüfung (ohne Wissen über den Autor): die Datei gibt es und liegt im Galerie-Bucket. Metadaten wie Typ und
* Größe prüft erst `checkFileContent`, damit fremde Dateien nichts darüber verraten.
*/
function checkFileExists(file) {
if (!file) throw new HttpError(400, MSG.fileMissing);
if (file.bucketId !== BUCKET) throw new HttpError(403, MSG.foreignFile);
}
/**
* Besitz: frisch hochgeladene Dateien tragen delete("user:<ich>") und read("any"). Eine Datei ohne delete(ich) gehört
* jemand anderem oder ist schon veröffentlicht/gesperrt (dann trägt sie kein delete(owner) mehr; eigene Zeilen-Dateien
* hat der Aufrufer vorher mit `used` als „schon verwendet“ abgewiesen); eine ohne read("any") ist ausgeblendet.
*/
function checkFileOwnership(file, userId) {
const permissions = Array.isArray(file.$permissions) ? file.$permissions : [];
if (file.bucketId !== BUCKET || !permissions.includes(`delete("user:${userId}")`)) {
throw new HttpError(403, MSG.foreignFile);
}
if (!permissions.includes(`delete("user:${userId}")`)) throw new HttpError(403, MSG.foreignFile);
// Frisch hochgeladene Galerie-Dateien tragen read("any"); ausgeblendete (gesperrte) Dateien nie.
if (!permissions.includes('read("any")')) throw new HttpError(403, MSG.filesTaken);
}
function checkFileContent(file) {
if (!FILE_MIME_TYPES.has(file.mimeType)) throw new HttpError(400, MSG.fileType);
if (!Number.isInteger(file.sizeOriginal) || file.sizeOriginal > MAX_FILE_BYTES) throw new HttpError(400, MSG.fileSize);
}
/** Liest den Anfang beider Dateien und verlangt Kartenformat: Bild 1536 x 969, Vorschau 480 x 302-304. */
async function checkImageSizes(data) {
const [image, preview] = await Promise.all([
appwrite.readFileHead(BUCKET, data.imageFileId, IMAGE_HEAD_BYTES),
appwrite.readFileHead(BUCKET, data.previewFileId, IMAGE_HEAD_BYTES),
]);
if (image === null || preview === null) throw new HttpError(400, MSG.fileMissing);
if (!isCardImageSize(readImageSize(image)) || !isCardPreviewSize(readImageSize(preview))) {
throw new HttpError(400, MSG.imageFormat);
}
}
const fileIdsOf = (row) => [row.imageFileId, row.previewFileId].filter(isId);
/**
* Datei-IDs, die schon eine Galerie-Zeile dieses Autors verwendet (Bild oder Vorschau, auch ausgeblendete Karten).
* Fremde Zeilen müssen nicht geprüft werden: deren Dateien kann dieser Autor nicht verwenden (kein delete-Recht).
* Fragt über `ownerId` ab (dafür gibt es einen Index; auf `imageFileId`/`previewFileId` nicht) und blättert mit
* Cursor. Mehr als OWNER_ROWS_MAX_PAGES Seiten: null (der Aufrufer lehnt dann ab).
* Die Galerie-Zeilen dieses Autors (auch ausgeblendete), höchstens MAX_PUBLICATIONS (mehr braucht der Aufrufer nicht:
* ab dort ist das Limit erreicht). Fragt über `ownerId` ab (dafür gibt es einen Index; auf `imageFileId`/`previewFileId`
* nicht). Fremde Zeilen müssen nicht geprüft werden: deren Dateien kann dieser Autor nicht verwenden (kein delete-Recht).
*/
async function fileIdsInUse(ownerId) {
const used = new Set();
let cursor = null;
for (let page = 0; page < OWNER_ROWS_MAX_PAGES; page += 1) {
const queries = [queryEqual('ownerId', [ownerId]), queryLimit(OWNER_ROWS_PAGE)];
if (cursor !== null) queries.push(queryCursorAfter(cursor));
const rows = await appwrite.listRows(TABLE_GALLERY, queries);
for (const row of rows) {
used.add(row.imageFileId);
used.add(row.previewFileId);
}
if (rows.length < OWNER_ROWS_PAGE) return used;
cursor = rows[rows.length - 1].$id;
}
return null;
function ownerRows(ownerId) {
return appwrite.listRows(TABLE_GALLERY, [queryEqual('ownerId', [ownerId]), queryLimit(MAX_PUBLICATIONS)]);
}
/** Die private Karte muss es geben und dem Konto gehören (Appwrite gibt dem Besitzer read("user:<id>") auf seine Zeile). */
async function ownsSourceCard(userId, cardId) {
const card = await appwrite.getRow(TABLE_CARDS, cardId);
return card !== null && Array.isArray(card.$permissions) && card.$permissions.includes(`read("user:${userId}")`);
}
/**
@@ -530,15 +637,20 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
appwrite.getFile(BUCKET, data.imageFileId),
appwrite.getFile(BUCKET, data.previewFileId),
]);
checkFile(image, user.id);
checkFile(preview, user.id);
checkFileExists(image);
checkFileExists(preview);
// Je Autor nacheinander: Prüfung „Dateien schon verwendet“ und Anlegen der Zeile dürfen sich nicht überholen.
// Je Autor nacheinander: Limit, Prüfung „Dateien schon verwendet“ und Anlegen der Zeile dürfen sich nicht überholen.
return withLock(`owner:${user.id}`, async () => {
const used = await fileIdsInUse(user.id);
if (used === null || used.has(data.imageFileId) || used.has(data.previewFileId)) {
throw new HttpError(403, MSG.filesTaken);
}
const rows = await ownerRows(user.id);
if (rows.length >= MAX_PUBLICATIONS) throw new HttpError(429, MSG.tooManyPublications);
if (!(await ownsSourceCard(user.id, data.sourceCardId))) throw new HttpError(403, MSG.notYourCard);
const used = new Set(rows.flatMap(fileIdsOf));
if (used.has(data.imageFileId) || used.has(data.previewFileId)) throw new HttpError(403, MSG.filesTaken);
for (const file of [image, preview]) checkFileOwnership(file, user.id);
for (const file of [image, preview]) checkFileContent(file);
await checkImageSizes(data);
const id = galleryRowId(user.id, data.sourceCardId);
try {
@@ -548,7 +660,8 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
{
title: data.title,
issuer: data.issuer,
tags: data.tags.join(','),
// ",a,b," statt "a,b": der Tag-Filter der Galerie sucht ",<tag>," und trifft so nur ganze Tags.
tags: data.tags.length > 0 ? `,${data.tags.join(',')},` : '',
displayName: data.displayName,
imageFileId: data.imageFileId,
previewFileId: data.previewFileId,
@@ -565,14 +678,21 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
throw err;
}
// Kanonischer Zustand: Beide Dateien tragen genau die sichtbaren Rechte (kein update, keine fremden Rollen).
// Kanonischer Zustand: Beide Dateien tragen genau read("any") (kein update/delete, keine fremden Rollen).
// Erst nach dem Anlegen der Zeile: ein 409 (dieselbe private Karte nochmal) fasst die Dateien nie an.
// Scheitert das Festlegen, wird die Zeile zurückgenommen.
try {
const pinned = await setFilePermissions(data, visibleFilePermissions(user.id));
const pinned = await setFilePermissions(data, visibleFilePermissions());
if (pinned.some((file) => file === null)) throw new HttpError(400, MSG.fileMissing);
} catch (err) {
await appwrite.deleteRow(TABLE_GALLERY, id).catch(() => {});
let rolledBack = true;
await appwrite.deleteRow(TABLE_GALLERY, id).catch(() => {
rolledBack = false;
say(`rollback failed for card ${id}`);
});
// Nur wenn keine Zeile mehr auf die Dateien zeigt: schon festgelegte Dateien bekommen delete(owner) zurück,
// damit der Client den Fehlversuch aufräumen kann (sonst blieben sie öffentlich und unlöschbar liegen).
if (rolledBack) await setFilePermissions(data, uploadFilePermissions(user.id)).catch(() => {});
throw err;
}
say(`card ${id} published`);
@@ -606,8 +726,10 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
if (typeof body.reason !== 'string' || !REASONS.includes(body.reason)) throw new HttpError(400, MSG.badReason);
const id = body.id;
const reporter = reporterHash(salt, clientIp(req));
const limit = rateLimit(reporter);
// Melder-Identität = Netz (IPv4 /24, IPv6 /48); das Rate-Limit zählt weiter je Anschluss (IPv4 voll, IPv6 /64).
const ip = clientIp(req);
const reporter = reporterHash(salt, ip);
const limit = rateLimit(hmacHex(salt, normalizeIp(ip)));
if (!limit.allowed) throw new HttpError(429, MSG.tooManyReports, { 'Retry-After': String(limit.retryAfter) });
return withLock(id, async () => {
@@ -724,11 +846,14 @@ module.exports = {
createApp,
visibleRowPermissions,
hiddenRowPermissions,
uploadFilePermissions,
visibleFilePermissions,
hiddenFilePermissions,
reporterHash,
reporterNetwork,
clientIp,
normalizeIp,
readImageSize,
validatePublish,
buildSearchText,
galleryRowId,