feat(api): Logo-Endpunkte, Fehlercodes und gemeinsame Helfer
- common.js (aus app.js herausgelöst, Verhalten gleich): MSG, HttpError, Prüfungen, Rechte-Strings, Melder-Hash,
Bildmaße und die Dateiprüfungen checkFileExists/checkFileOwnership. Neu: die Logo-Texte in MSG, codeFor,
likeRowPermissions. app.js exportiert weiter alles wie bisher.
- Fehlerantworten sind jetzt {error, code}: code = MSG-Schlüssel des Textes, dynamische Prüftexte badRequest,
Appwrite-Fehler unavailable (502) bzw. internal (500).
- logos.js: POST /api/logos/hochladen, /herz, /melden, /loeschen und /api/admin/logo-sichtbarkeit nach dem Vorbild der
Karten-Endpunkte (Datei prüfen, Zeile anlegen, Datei auf read("any") festlegen, sonst Rücknahme; likeCount und
reportCount immer aus countRows; ein gemeinsames Rate-Limit für Karten- und Logo-Meldungen).
- appwrite.js: countRows. fake-appwrite.js: Tabellen logos, logo_likes, logo_reports mit Spaltenlängen, Bucket
logo-images und total in den Listen-Antworten.
- Tests: logos.test.js (59), code-Fälle und MSG-Eindeutigkeit in app.test.js (+7), countRows in appwrite.test.js (+2);
alle 111 bisherigen Tests unverändert grün (179 gesamt).
- Doku: api/README.md (Endpunkte, Fehlercodes, Logo-Regeln), deploy/README.md (Betrieb kopiert fünf Dateien).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
88abc2061d
commit
dc5de99611
10 files changed
+2181
-286
No files matched your search
@@ -0,0 +1,366 @@
|
||||
'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)
|
||||
* 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. */
|
||||
async function checkLogoSize(fileId) {
|
||||
const head = await appwrite.readFileHead(BUCKET, fileId, IMAGE_HEAD_BYTES);
|
||||
if (head === null) throw new HttpError(400, MSG.fileMissing);
|
||||
if (!isLogoSize(head)) throw new HttpError(400, MSG.logoFormat);
|
||||
}
|
||||
|
||||
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.
|
||||
try {
|
||||
const pinned = await appwrite.updateFilePermissions(BUCKET, data.fileId, visibleFilePermissions());
|
||||
if (pinned === null) throw new HttpError(400, MSG.fileMissing);
|
||||
} 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;
|
||||
}
|
||||
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 };
|
||||
Reference in new issue
Block a user