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:
1 parent
87b71ec71f
commit
4c90ee49e8
6 files changed
+743
-163
No files matched your search
+181
-56
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user