Der Hochlader behält delete auf seiner Datei, bis die API sie auf read("any") festlegt. Zwischen der ersten Prüfung
(Typ, Größe, PNG-Kopf, Maße) und dem Festlegen ließ sich die Datei unter derselben ID löschen und mit anderem Inhalt
neu anlegen (z. B. 4000 × 4000). Jetzt liest die API den Dateianfang nach dem Festlegen noch einmal, wenn der
Hochlader die Datei nicht mehr ändern kann:
- PNG-Kopf und Maße 8 × 8 bis 720 × 250 werden erneut geprüft. Fällt die Datei durch oder fehlt sie, löscht die API die
Datei (Server-Key) und die Zeile (beides wird versucht) und antwortet 400 logoFormat bzw. fileMissing. Log:
"logo <id> removed after upload check (<code>)", bleibt etwas stehen: "rollback failed for logo <id>".
- Ein Appwrite-Fehler beim zweiten Lesen wird wie ein Fehler beim Festlegen behandelt (502, Zeile zurückgenommen, die
Datei bekommt delete(owner) zurück).
- fake-appwrite.js: Testhilfe swapContentAfterPermissions (der Inhalt ändert sich nach dem nächsten Rechte-Update).
- Tests: 7 neue in logos.test.js (Austausch auf 4000 × 4000, weitere ungültige Inhalte, Datei weg, Appwrite-Fehler,
Aufräumen scheitert, Ablauffolge, weiterhin gültiger Inhalt), der Logtest prüft das neue Ereignis mit. Der
Karten-Ablauf ist unverändert (app.js und common.js nicht angefasst).
- Doku: api/README.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
402 lines
18 KiB
JavaScript
402 lines
18 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* Endpunkte der Community-Logos im API-Dienst `aircard-api` (Node 22, nur Bordmittel). Sie folgen den Karten-Endpunkten
|
||
* in app.js: gleiche Reihenfolge der Prüfungen, gleiche Rücknahme beim Hochladen, gleiche Melde- und Ausblend-Logik.
|
||
* app.js hängt die Routen an und gibt Anmeldung, Sperre je Schlüssel, das gemeinsame Rate-Limit und das Log mit.
|
||
*
|
||
* POST /api/logos/hochladen Nutzer-JWT {name, displayName?, mono?, fileId}
|
||
* 401 · 400 Validierung, Datei fehlt, kein PNG, größer als 1 MiB, Maße nicht 8 × 8 bis 720 × 250 · 403 Datei nicht
|
||
* meine / falscher Bucket / Datei gesperrt (kein read("any")) · 409 Datei schon für ein Logo verwendet · 429 schon
|
||
* 30 Logos · 201 {id} (id = Datei-ID = Zeilen-ID)
|
||
* Nach dem Festlegen der Datei auf read("any") wird ihr Anfang noch einmal geprüft (der Hochlader kann sie dann nicht
|
||
* mehr ändern): fällt sie durch oder fehlt sie, werden Datei und Zeile gelöscht und die Antwort ist 400.
|
||
* POST /api/logos/herz Nutzer-JWT {id, herz}
|
||
* 401 · 400 · 404 fehlt oder ausgeblendet · 200 {herz, likeCount}
|
||
* POST /api/logos/melden ohne Anmeldung {id, reason} (wie /api/melden, Rate-Limit gemeinsam)
|
||
* 400 · 404 fehlt oder ausgeblendet · 429 Rate-Limit · 200 {ok:true} oder {schonGemeldet:true}
|
||
* POST /api/logos/loeschen Nutzer-JWT (Hochlader oder Admin) {id}
|
||
* 401 · 400 · 403 fremd · 404 fehlt · 200 {ok:true}
|
||
* POST /api/admin/logo-sichtbarkeit Admin-JWT {id, sichtbar}
|
||
* 401 · 403 kein Admin · 400 · 404 · 200 {ok:true}
|
||
*
|
||
* Tabellen `logos` (Zeilen-ID = Datei-ID, damit eine Datei nie zwei Logos tragen kann), `logo_likes`, `logo_reports`,
|
||
* Bucket `logo-images`. `likeCount` und `reportCount` werden immer aus der Zahl der Zeilen gesetzt (countRows), nie hoch-
|
||
* oder runtergezählt. Sperren: `logo-owner:<Nutzer>` beim Hochladen, `logo:<id>` für alles je Logo.
|
||
* Logs nennen nur Logo-IDs und Zahlen, nie Namen, IPs, Hashes oder JWTs; Herzen werden nicht geloggt.
|
||
*/
|
||
|
||
const { queryEqual, queryLimit } = require('./appwrite.js');
|
||
const {
|
||
MAX_LOGOS,
|
||
HIDE_AT_REPORTS,
|
||
IMAGE_HEAD_BYTES,
|
||
REASONS,
|
||
CONTROL_CHARACTERS,
|
||
MSG,
|
||
HttpError,
|
||
isId,
|
||
isPlainObject,
|
||
isDuplicate,
|
||
parseJsonObject,
|
||
readImageSize,
|
||
hash32,
|
||
hmacHex,
|
||
reporterHash,
|
||
normalizeIp,
|
||
clientIp,
|
||
visibleRowPermissions,
|
||
hiddenRowPermissions,
|
||
likeRowPermissions,
|
||
uploadFilePermissions,
|
||
visibleFilePermissions,
|
||
hiddenFilePermissions,
|
||
checkFileExists,
|
||
checkFileOwnership,
|
||
} = require('./common.js');
|
||
|
||
const TABLE_LOGOS = 'logos';
|
||
const TABLE_LIKES = 'logo_likes';
|
||
const TABLE_REPORTS = 'logo_reports';
|
||
const BUCKET = 'logo-images';
|
||
|
||
const MAX_LOGO_BYTES = 1024 * 1024;
|
||
/** Maße des fertigen Logos (wie die Verarbeitung im Editor): 8 × 8 bis 720 × 250 Pixel. */
|
||
const LOGO_MIN_SIDE = 8;
|
||
const LOGO_MAX_WIDTH = 720;
|
||
const LOGO_MAX_HEIGHT = 250;
|
||
/** Herzen und Meldungen eines Logos werden in Runden zu 100 gelöscht, höchstens 50 Runden. */
|
||
const ROW_BATCH = 100;
|
||
const ROW_ROUNDS = 50;
|
||
|
||
const LOGO_TEXT_FIELDS = [
|
||
{ key: 'name', label: 'Der Name', max: 40, required: true },
|
||
{ key: 'displayName', label: 'Der Anzeigename', max: 30, required: false },
|
||
];
|
||
|
||
// ---- Reine Helfer ----------------------------------------------------------------------------------
|
||
|
||
/** Feste Zeilen-ID je Logo und Nutzer: ein Herz pro Konto, Appwrite-409 ersetzt jede Doppelt-Prüfung. */
|
||
function logoLikeId(logoId, userId) {
|
||
return hash32(`${logoId}|${userId}`);
|
||
}
|
||
|
||
/** Feste Zeilen-ID je Logo und Melder (`logo|` trennt sie von den Karten-Meldungen): eine Meldung pro Melder und Logo. */
|
||
function logoReportId(logoId, reporter) {
|
||
return hash32(`logo|${logoId}|${reporter}`);
|
||
}
|
||
|
||
function readLogoText(raw, field) {
|
||
if (raw === undefined || raw === null) return field.required ? { message: MSG.logoNameMissing } : { value: '' };
|
||
if (typeof raw !== 'string') return { message: `${field.label} ist ungültig.` };
|
||
const value = raw.trim();
|
||
if (value === '' && field.required) return { message: MSG.logoNameMissing };
|
||
if (CONTROL_CHARACTERS.test(value)) return { message: `${field.label} enthält ungültige Zeichen.` };
|
||
if (value.length > field.max) return { message: `${field.label} ist zu lang (höchstens ${field.max} Zeichen).` };
|
||
return { value };
|
||
}
|
||
|
||
/**
|
||
* Prüft den Hochladen-Body: Name 1-40, Anzeigename ≤ 30 (getrimmt, ohne Steuer- und Bidi-Zeichen), `mono` fehlt = false
|
||
* (sonst muss es ein Boolean sein), `fileId` nach ID_PATTERN.
|
||
* @returns {{ok: true, data: {name: string, displayName: string, mono: boolean, fileId: string}} | {ok: false, message: string}}
|
||
*/
|
||
function validateLogoUpload(body) {
|
||
if (!isPlainObject(body)) return { ok: false, message: MSG.badRequest };
|
||
|
||
const data = {};
|
||
for (const field of LOGO_TEXT_FIELDS) {
|
||
const result = readLogoText(body[field.key], field);
|
||
if (result.message) return { ok: false, message: result.message };
|
||
data[field.key] = result.value;
|
||
}
|
||
if (body.mono !== undefined && typeof body.mono !== 'boolean') return { ok: false, message: MSG.badMono };
|
||
data.mono = body.mono === true;
|
||
if (!isId(body.fileId)) return { ok: false, message: 'Ungültige Datei-ID.' };
|
||
data.fileId = body.fileId;
|
||
return { ok: true, data };
|
||
}
|
||
|
||
/** Typ und Größe laut Appwrite (erst nach der Besitzprüfung, damit fremde Dateien nichts darüber verraten). */
|
||
function checkLogoContent(file) {
|
||
if (file.mimeType !== 'image/png') throw new HttpError(400, MSG.logoFileType);
|
||
if (!Number.isInteger(file.sizeOriginal) || file.sizeOriginal > MAX_LOGO_BYTES) throw new HttpError(400, MSG.logoFileSize);
|
||
}
|
||
|
||
function isLogoSize(head) {
|
||
// readImageSize kennt auch WebP: ein Logo muss einen PNG-Kopf haben, nicht nur den Dateityp laut Appwrite
|
||
if (head.toString('latin1', 1, 4) !== 'PNG') return false;
|
||
const size = readImageSize(head);
|
||
return (
|
||
size !== null &&
|
||
size.width >= LOGO_MIN_SIDE &&
|
||
size.height >= LOGO_MIN_SIDE &&
|
||
size.width <= LOGO_MAX_WIDTH &&
|
||
size.height <= LOGO_MAX_HEIGHT
|
||
);
|
||
}
|
||
|
||
// ---- Die Routen ------------------------------------------------------------------------------------
|
||
|
||
/**
|
||
* @param {object} options
|
||
* @param {object} options.appwrite Objekt aus createAppwrite
|
||
* @param {string} options.salt Salz für die Melder-Hashes
|
||
* @param {(message: string) => void} options.say Ereigniszeile ins Log (mit Zeilenbudget)
|
||
* @param {(key: string, fn: () => Promise<any>) => Promise<any>} options.withLock Je Schlüssel nacheinander
|
||
* @param {(key: string) => {allowed: boolean, retryAfter?: number}} options.rateLimit das Rate-Limit der Meldungen (gemeinsam mit den Karten)
|
||
* @param {(req) => Promise<{id: string, isAdmin: boolean}>} options.requireUser wirft 401 ohne gültiges JWT
|
||
* @returns {Array<[string, {name: string, json: true, run: (ctx: {req, raw: string}) => Promise<{status: number, body: object}>}]>}
|
||
*/
|
||
function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUser }) {
|
||
const withLogoLock = (id, fn) => withLock(`logo:${id}`, fn);
|
||
|
||
/** Setzt die Rechte der Logo-Datei; null heißt: die Datei gibt es nicht (mehr). */
|
||
function setFilePermissions(row, permissions) {
|
||
return isId(row.fileId) ? appwrite.updateFilePermissions(BUCKET, row.fileId, permissions) : Promise.resolve(null);
|
||
}
|
||
|
||
/** Datei zuerst, Zeile zuletzt: bleibt etwas hängen, heilt der nächste Versuch (alles ist wiederholbar). */
|
||
async function hideLogo(id, row, extra = {}) {
|
||
await setFilePermissions(row, hiddenFilePermissions(row.ownerId));
|
||
await appwrite.updateRow(TABLE_LOGOS, id, { hidden: true, ...extra }, hiddenRowPermissions(row.ownerId));
|
||
}
|
||
|
||
/** Löscht alle Zeilen der Tabelle zu diesem Logo, in Runden (die Zeile des Logos selbst bleibt der Anker für eine Wiederholung). */
|
||
async function deleteRowsOf(table, logoId) {
|
||
for (let round = 0; round < ROW_ROUNDS; round += 1) {
|
||
const rows = await appwrite.listRows(table, [queryEqual('logoId', [logoId]), queryLimit(ROW_BATCH)]);
|
||
if (rows.length === 0) return;
|
||
for (const row of rows) await appwrite.deleteRow(table, row.$id);
|
||
}
|
||
}
|
||
|
||
/** reportCount = Anzahl Meldungszeilen des Logos (neu gezählt); ab 3 wird ausgeblendet. */
|
||
async function syncReports(id, row) {
|
||
const count = await appwrite.countRows(TABLE_REPORTS, [queryEqual('logoId', [id])]);
|
||
if (count >= HIDE_AT_REPORTS) {
|
||
await hideLogo(id, row, { reportCount: count });
|
||
say(`logo ${id} hidden automatically (${count} reports)`);
|
||
} else if (row.reportCount !== count) {
|
||
await appwrite.updateRow(TABLE_LOGOS, id, { reportCount: count });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Liest den Anfang der Datei und verlangt einen PNG-Kopf mit Maßen von 8 × 8 bis 720 × 250.
|
||
* @returns {Promise<HttpError | null>} das Urteil (400 fileMissing bzw. logoFormat), null = in Ordnung; Appwrite-Fehler werfen
|
||
*/
|
||
async function logoSizeProblem(fileId) {
|
||
const head = await appwrite.readFileHead(BUCKET, fileId, IMAGE_HEAD_BYTES);
|
||
if (head === null) return new HttpError(400, MSG.fileMissing);
|
||
return isLogoSize(head) ? null : new HttpError(400, MSG.logoFormat);
|
||
}
|
||
|
||
async function checkLogoSize(fileId) {
|
||
const problem = await logoSizeProblem(fileId);
|
||
if (problem !== null) throw problem;
|
||
}
|
||
|
||
/**
|
||
* Das Logo ist nach dem Festlegen der Rechte durchgefallen (Inhalt ausgetauscht oder Datei weg): die Datei mit dem
|
||
* Server-Key löschen, dann die Zeile. Beides wird versucht, auch wenn das erste scheitert: die Zeile macht das Logo für
|
||
* andere sichtbar. Bleibt etwas stehen, steht `rollback failed for logo <id>` im Log.
|
||
*/
|
||
async function removeRejectedLogo(id, problem) {
|
||
let clean = true;
|
||
await appwrite.deleteFile(BUCKET, id).catch(() => {
|
||
clean = false;
|
||
});
|
||
await appwrite.deleteRow(TABLE_LOGOS, id).catch(() => {
|
||
clean = false;
|
||
});
|
||
say(clean ? `logo ${id} removed after upload check (${problem.code})` : `rollback failed for logo ${id}`);
|
||
}
|
||
|
||
async function uploadLogo({ req, raw }) {
|
||
const user = await requireUser(req);
|
||
const validation = validateLogoUpload(parseJsonObject(raw));
|
||
if (!validation.ok) throw new HttpError(400, validation.message);
|
||
const data = validation.data;
|
||
|
||
const file = await appwrite.getFile(BUCKET, data.fileId);
|
||
checkFileExists(file, BUCKET);
|
||
|
||
// Je Konto nacheinander: Limit, Prüfung „Datei schon verwendet“ und Anlegen der Zeile dürfen sich nicht überholen.
|
||
return withLock(`logo-owner:${user.id}`, async () => {
|
||
// Die Zeilen dieses Kontos (auch ausgeblendete), höchstens MAX_LOGOS: ab dort ist das Limit erreicht.
|
||
const rows = await appwrite.listRows(TABLE_LOGOS, [queryEqual('ownerId', [user.id]), queryLimit(MAX_LOGOS)]);
|
||
if (rows.length >= MAX_LOGOS) throw new HttpError(429, MSG.tooManyLogos);
|
||
// Zeilen-ID = Datei-ID: dieses Logo gibt es schon (seine Datei trägt dann kein delete(owner) mehr, darum vor der Besitzprüfung).
|
||
if (rows.some((row) => row.$id === data.fileId)) throw new HttpError(409, MSG.logoTaken);
|
||
checkFileOwnership(file, user.id);
|
||
checkLogoContent(file);
|
||
await checkLogoSize(data.fileId);
|
||
|
||
try {
|
||
await appwrite.createRow(
|
||
TABLE_LOGOS,
|
||
data.fileId,
|
||
{
|
||
name: data.name,
|
||
displayName: data.displayName,
|
||
fileId: data.fileId,
|
||
ownerId: user.id,
|
||
mono: data.mono,
|
||
likeCount: 0,
|
||
hidden: false,
|
||
reportCount: 0,
|
||
},
|
||
visibleRowPermissions(),
|
||
);
|
||
} catch (err) {
|
||
// Eine Zeile mit dieser ID gehört schon jemandem (z. B. der liegengebliebene Anker eines halb gelöschten Logos).
|
||
if (isDuplicate(err)) throw new HttpError(409, MSG.logoTaken);
|
||
throw err;
|
||
}
|
||
|
||
// Kanonischer Zustand: die Datei trägt genau read("any") (kein delete(owner): sonst ließe sich das Bild tauschen).
|
||
// Erst nach dem Anlegen der Zeile: ein 409 fasst die Datei nie an. Scheitert das Festlegen, wird die Zeile zurückgenommen.
|
||
let problem = null;
|
||
try {
|
||
const pinned = await appwrite.updateFilePermissions(BUCKET, data.fileId, visibleFilePermissions());
|
||
if (pinned === null) throw new HttpError(400, MSG.fileMissing);
|
||
// Jetzt hat der Hochlader kein delete mehr und kann die Datei nicht mehr ändern: den Anfang noch einmal prüfen. So
|
||
// lässt sich die Datei nicht zwischen der ersten Prüfung und dem Festlegen unter derselben ID austauschen. Ein
|
||
// Appwrite-Fehler hier wird wie ein Fehler beim Festlegen behandelt (Rücknahme unten, nichts bleibt veröffentlicht).
|
||
problem = await logoSizeProblem(data.fileId);
|
||
} catch (err) {
|
||
let rolledBack = true;
|
||
await appwrite.deleteRow(TABLE_LOGOS, data.fileId).catch(() => {
|
||
rolledBack = false;
|
||
say(`rollback failed for logo ${data.fileId}`);
|
||
});
|
||
// Nur wenn keine Zeile mehr auf die Datei zeigt, bekommt sie delete(owner) zurück (der Client räumt damit auf).
|
||
if (rolledBack) await appwrite.updateFilePermissions(BUCKET, data.fileId, uploadFilePermissions(user.id)).catch(() => {});
|
||
throw err;
|
||
}
|
||
if (problem !== null) {
|
||
await removeRejectedLogo(data.fileId, problem);
|
||
throw problem;
|
||
}
|
||
say(`logo ${data.fileId} uploaded`);
|
||
return { status: 201, body: { id: data.fileId } };
|
||
});
|
||
}
|
||
|
||
async function setHeart({ req, raw }) {
|
||
const user = await requireUser(req);
|
||
const body = parseJsonObject(raw);
|
||
if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId);
|
||
if (typeof body.herz !== 'boolean') throw new HttpError(400, MSG.badHeart);
|
||
const id = body.id;
|
||
|
||
return withLogoLock(id, async () => {
|
||
const row = await appwrite.getRow(TABLE_LOGOS, id);
|
||
if (!row || row.hidden === true) throw new HttpError(404, MSG.logoGone);
|
||
|
||
const likeId = logoLikeId(id, user.id);
|
||
if (body.herz) {
|
||
try {
|
||
await appwrite.createRow(TABLE_LIKES, likeId, { logoId: id, userId: user.id }, likeRowPermissions(user.id));
|
||
} catch (err) {
|
||
if (!isDuplicate(err)) throw err; // schon da: kein Fehler
|
||
}
|
||
} else {
|
||
await appwrite.deleteRow(TABLE_LIKES, likeId); // schon weg (false): kein Fehler
|
||
}
|
||
// Immer neu zählen statt hoch- oder runterzählen: das heilt einen früher steckengebliebenen Zählerstand.
|
||
const likeCount = await appwrite.countRows(TABLE_LIKES, [queryEqual('logoId', [id])]);
|
||
if (row.likeCount !== likeCount) await appwrite.updateRow(TABLE_LOGOS, id, { likeCount });
|
||
return { status: 200, body: { herz: body.herz, likeCount } };
|
||
});
|
||
}
|
||
|
||
async function reportLogo({ req, raw }) {
|
||
const body = parseJsonObject(raw);
|
||
if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId);
|
||
if (typeof body.reason !== 'string' || !REASONS.includes(body.reason)) throw new HttpError(400, MSG.badReason);
|
||
const id = body.id;
|
||
|
||
// Melder-Identität = Netz (IPv4 /24, IPv6 /48); das Rate-Limit zählt je Anschluss und gilt für Karten und Logos zusammen.
|
||
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 withLogoLock(id, async () => {
|
||
const row = await appwrite.getRow(TABLE_LOGOS, id);
|
||
if (!row || row.hidden === true) throw new HttpError(404, MSG.logoGone);
|
||
|
||
let isNew = true;
|
||
try {
|
||
await appwrite.createRow(TABLE_REPORTS, logoReportId(id, reporter), { logoId: id, reason: body.reason, reporterHash: reporter }, []);
|
||
} catch (err) {
|
||
if (!isDuplicate(err)) throw err;
|
||
isNew = false;
|
||
}
|
||
// Auch bei einer doppelten Meldung neu zählen: heilt einen früher steckengebliebenen Zählerstand.
|
||
await syncReports(id, row);
|
||
if (isNew) say(`logo ${id} reported (${body.reason})`);
|
||
return { status: 200, body: isNew ? { ok: true } : { schonGemeldet: true } };
|
||
});
|
||
}
|
||
|
||
async function deleteLogo({ req, raw }) {
|
||
const user = await requireUser(req);
|
||
const body = parseJsonObject(raw);
|
||
if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId);
|
||
const id = body.id;
|
||
|
||
return withLogoLock(id, async () => {
|
||
const row = await appwrite.getRow(TABLE_LOGOS, id);
|
||
if (!row) throw new HttpError(404, MSG.logoGone);
|
||
if (row.ownerId !== user.id && !user.isAdmin) throw new HttpError(403, MSG.forbidden);
|
||
|
||
// Zeile zuletzt: scheitert etwas, bleibt sie als Anker für die Wiederholung stehen.
|
||
if (isId(row.fileId)) await appwrite.deleteFile(BUCKET, row.fileId);
|
||
await deleteRowsOf(TABLE_LIKES, id);
|
||
await deleteRowsOf(TABLE_REPORTS, id);
|
||
await appwrite.deleteRow(TABLE_LOGOS, id);
|
||
say(`logo ${id} deleted${row.ownerId === user.id ? '' : ' by admin'}`);
|
||
return { status: 200, body: { ok: true } };
|
||
});
|
||
}
|
||
|
||
async function setVisibility({ req, raw }) {
|
||
const user = await requireUser(req);
|
||
if (!user.isAdmin) throw new HttpError(403, MSG.adminOnly);
|
||
const body = parseJsonObject(raw);
|
||
if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId);
|
||
if (typeof body.sichtbar !== 'boolean') throw new HttpError(400, MSG.badVisibility);
|
||
const id = body.id;
|
||
|
||
return withLogoLock(id, async () => {
|
||
const row = await appwrite.getRow(TABLE_LOGOS, id);
|
||
if (!row) throw new HttpError(404, MSG.logoGone);
|
||
|
||
if (body.sichtbar) {
|
||
// Datei, dann Meldungen, Zeile zuletzt: sichtbar wird das Logo erst, wenn alles andere steht.
|
||
await setFilePermissions(row, visibleFilePermissions());
|
||
await deleteRowsOf(TABLE_REPORTS, id);
|
||
await appwrite.updateRow(TABLE_LOGOS, id, { hidden: false, reportCount: 0 }, visibleRowPermissions());
|
||
say(`logo ${id} made visible by admin`);
|
||
} else {
|
||
await hideLogo(id, row);
|
||
say(`logo ${id} hidden by admin`);
|
||
}
|
||
return { status: 200, body: { ok: true } };
|
||
});
|
||
}
|
||
|
||
return [
|
||
['POST /api/logos/hochladen', { name: 'logos/hochladen', json: true, run: uploadLogo }],
|
||
['POST /api/logos/herz', { name: 'logos/herz', json: true, run: setHeart }],
|
||
['POST /api/logos/melden', { name: 'logos/melden', json: true, run: reportLogo }],
|
||
['POST /api/logos/loeschen', { name: 'logos/loeschen', json: true, run: deleteLogo }],
|
||
['POST /api/admin/logo-sichtbarkeit', { name: 'admin/logo-sichtbarkeit', json: true, run: setVisibility }],
|
||
];
|
||
}
|
||
|
||
module.exports = { createLogoRoutes, logoLikeId, logoReportId, validateLogoUpload };
|