fix(api): Log-Flut begrenzen und Appwrite-Fehler strenger behandeln

- Kein Zugriffsprotokoll je Anfrage mehr (abgewiesene Anfragen an das
  unauthentifizierte /api/melden loggten sonst unbegrenzt); nur noch
  Ereignis- und 5xx-Zeilen, höchstens 120 pro Minute mit Sammelzeile
- Compose: logging json-file, max-size 5m, max-file 2
- appwrite.js: 404 nur mit Typ row_not_found/storage_file_not_found als
  "fehlt", listRows ohne rows-Array wirft, redirect: error
- Appwrite-Fehler: 502 bei 0/429/5xx, sonst 500 "Interner Fehler."
- Veröffentlichen legt beide Dateien auf die sichtbaren Rechte fest
  (erst nach dem Anlegen der Zeile, Rollback bei Fehler)
- setFilePermissions mit Promise.allSettled, gemeinsamer safeToken-Helfer
- Tests: 85 (Log-Flut, Log-Budget, 404-Typen, Auth-Header, Konto-Antworten)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 14:24:05 +00:00
1 parent dd1597862a
commit cd55d4c891
8 files changed
+423 -31

No files matched your search

+3 -3
View File
@@ -39,19 +39,19 @@ Alle Antworten sind JSON; Fehler haben die Form `{"error": "<deutscher Text>"}`.
| `POST /api/admin/sichtbarkeit` `{id, sichtbar}` | Admin-JWT | `200 {ok:true}` · `401` · `403` kein Admin · `400` · `404` |
| `GET /api/gesundheit` | keine | `200 {ok:true}` |
Ist Appwrite nicht erreichbar oder antwortet mit einem Fehler, antwortet der Dienst `502` „Der Dienst ist gerade nicht erreichbar.“ (Details nur im Log, ohne Secrets), bei unerwarteten Fehlern `500`.
Ist Appwrite nicht erreichbar, überlastet (429) oder antwortet mit einem Serverfehler (5xx), antwortet der Dienst `502` „Der Dienst ist gerade nicht erreichbar.“. Jede andere unerwartete Appwrite-Antwort (falscher Key, fehlende Tabelle oder Datenbank, fehlender Bucket, unbekannte Route, ungültige Antwort) und jeder unerwartete Fehler ist `500` „Interner Fehler.“ Details stehen nur im Log, ohne Secrets. Ein 404 von Appwrite gilt nur mit dem Typ `row_not_found` bzw. `storage_file_not_found` als „fehlt“; ein fehlendes Konfigurationsobjekt wird so nie zu „Karte nicht verfügbar“.
### Regeln im Einzelnen
- **IDs**: Jede ID im Body (`id`, `imageFileId`, `previewFileId`, `sourceCardId`) muss `^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$` erfüllen (nur Zeichenketten), sonst `400`. `appwrite.js` kodiert zusätzlich jedes Pfadsegment und lehnt `.` und `..` ab.
- **Felder**: Titel 1–60, Herausgeber ≤ 40, Anzeigename ≤ 30 Zeichen (getrimmt, keine Steuerzeichen). `tags` ist ein `string[]`: getrimmt, klein, Leere und Doppelte entfernt, höchstens 5 mit je höchstens 20 Zeichen, kein Komma. Bild und Vorschau müssen verschiedene Dateien sein.
- **Dateien** (beide): liegen im Bucket `gallery-images`, tragen `delete("user:<ich>")`, sind `image/png` oder `image/webp` und höchstens 5 MiB. Eine Datei, die es nicht gibt, ergibt `400`; eine fremde `403`. Bei Appwrite 1.8 liefert eine Datei aus einem anderen Bucket schon beim Abruf 404, das erscheint dann ebenfalls als `400` „Bild nicht gefunden.“
- **Dateien** (beide): liegen im Bucket `gallery-images`, tragen `delete("user:<ich>")`, sind `image/png` oder `image/webp` und höchstens 5 MiB. Eine Datei, die es nicht gibt, ergibt `400`; eine fremde `403`. Nach dem Anlegen der Zeile setzt die API beide Dateien auf genau `read("any")`, `delete("user:<ich>")` (kanonischer Zustand, auch wenn der Client mehr Rechte vergeben hat); scheitert das, wird die Zeile zurückgenommen. Ein 409 fasst die Dateirechte nie an. Bei Appwrite 1.8 liefert eine Datei aus einem anderen Bucket schon beim Abruf 404, das erscheint dann ebenfalls als `400` „Bild nicht gefunden.“
- **Doppelt veröffentlichen**: Die Zeilen-ID der Galerie ist `sha256(ownerId|sourceCardId)` (erste 32 Hex). Eine zweite Veröffentlichung derselben Karte scheitert deshalb atomar an Appwrite (409), auch bei gleichzeitigen Anfragen. Nach dem Zurückziehen ist die Karte wieder frei.
- **Melden**: Rate-Limit 10 pro Stunde und Melder (Speicher, geht bei Neustart verloren; abgelaufene Einträge werden entfernt, höchstens 50 000 Einträge). Der Melder-Hash ist `HMAC-SHA256(salt, ip)`; IPv6-Adressen werden vorher auf ihr /64-Netz gekürzt, IPv4-gemappte IPv6 auf IPv4. Die Zeilen-ID der Meldung ist `sha256(galleryId|reporterHash)`, eine zweite Meldung desselben Melders ergibt `{schonGemeldet:true}`. `reportCount` = Anzahl der Meldungszeilen der Karte; ab 3 wird ausgeblendet. Auch eine doppelte Meldung zählt neu und heilt so einen früher steckengebliebenen Zählerstand. Pro Karte läuft immer nur eine Änderung gleichzeitig.
- **Client-IP**: `X-Real-Ip` (setzt Traefik), sonst erster Eintrag von `X-Forwarded-For`, sonst die Socket-Adresse. Der Dienst darf deshalb nur über Traefik erreichbar sein (kein `ports:` in Compose).
- **Rechte** (genau diese Strings): sichtbar Zeile `read("any")`; ausgeblendet Zeile `read("user:<owner>")`, `read("label:admin")`; Datei sichtbar `read("any")`, `delete("user:<owner>")`; Datei ausgeblendet `read("user:<owner>")`, `read("label:admin")`, `delete("user:<owner>")`. Meldungszeilen haben keine eigenen Rechte (Tabelle: nur `read("label:admin")`).
- **Reihenfolge bei Mehrschritt-Aktionen** (jede Aktion ist wiederholbar): Ausblenden/Einblenden ändert zuerst die Dateirechte, beim Einblenden danach die Meldungen löschen, die Zeile zuletzt. Zurückziehen löscht Dateien, Meldungen, dann die Zeile. Bleibt etwas hängen (502), heilt der nächste Versuch.
- **Logs**: Zeilen beginnen mit `[aircard-api]`. Sie nennen Methode, Route, Status, Karten-IDs und Fehlertypen, nie Key, Salz, JWT, IPs oder Fehlertexte von Appwrite.
- **Logs**: Zeilen beginnen mit `[aircard-api]`. Es gibt kein Zugriffsprotokoll je Anfrage (`/api/melden` ist ohne Anmeldung erreichbar, ein Angreifer würde sonst das Log-Volumen bestimmen): geloggt werden nur Ereignisse (veröffentlicht, gemeldet, ausgeblendet, zurückgezogen, Admin-Aktion) und Serverfehler (5xx), zusammen höchstens 120 Zeilen pro Minute (der Rest wird gezählt und als Sammelzeile „N log lines suppressed“ nachgetragen). Die Zeilen nennen Route, Status, Karten-ID und Fehlertypen, nie Key, Salz, JWT, IPs oder Fehlertexte von Appwrite. Zusätzlich deckelt Compose die Logdatei (`logging: json-file`, 5 MB, 2 Dateien).
## Tests
+60 -11
View File
@@ -19,6 +19,9 @@
* Das Nutzer-JWT kommt im Header X-Aircard-Jwt und wird bei Appwrite (GET /account) geprüft; Admin = Label `admin`.
* 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).
* Logs: kein Zugriffsprotokoll je Anfrage (sonst steuert ein Angreifer das Log-Volumen), nur Ereigniszeilen
* (veröffentlicht, gemeldet, ausgeblendet, …) und Serverfehler, zusammen höchstens 120 Zeilen pro Minute.
*/
const crypto = require('node:crypto');
@@ -39,6 +42,8 @@ const RATE_LIMIT = 10;
const RATE_WINDOW_MS = 60 * 60 * 1000;
const RATE_SWEEP_MS = 60 * 1000;
const RATE_MAX_KEYS = 50_000;
const LOG_BUDGET_PER_MINUTE = 120;
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}$/;
@@ -173,6 +178,12 @@ function isPlainObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
/** Fehlername oder -typ fürs Log: nur Wortzeichen, Punkt und Bindestrich, höchstens 60 Zeichen. */
function safeToken(value, fallback = 'unknown') {
const text = String(value === undefined || value === null ? '' : value).replace(/[^\w.-]/g, '').slice(0, 60);
return text === '' ? fallback : text;
}
const TEXT_FIELDS = [
{ key: 'title', article: 'Der', name: 'Titel', max: 60, required: true },
{ key: 'issuer', article: 'Der', name: 'Herausgeber', max: 40, required: false },
@@ -362,8 +373,27 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
if (!appwrite || typeof appwrite !== 'object') throw new TypeError('appwrite fehlt');
if (typeof salt !== 'string' || salt === '') throw new TypeError('salt fehlt');
// Logs enthalten nie Key, Salz, JWT oder IPs: nur Karten-IDs, Zahlen und Fehlertypen.
const say = (message) => log(`[aircard-api] ${new Date().toISOString()} ${message}`);
// Logs enthalten nie Key, Salz, JWT oder IPs: nur Karten-IDs, Zahlen und Fehlertypen. Zusätzlich gilt ein
// Zeilenbudget je Minute; was darüber liegt, wird gezählt und als eine Sammelzeile nachgetragen.
const emit = (message) => log(`[aircard-api] ${new Date().toISOString()} ${message}`);
let logWindowStart = 0;
let logCount = 0;
let logSuppressed = 0;
function say(message) {
const t = now();
if (t - logWindowStart >= LOG_WINDOW_MS) {
if (logSuppressed > 0) emit(`${logSuppressed} log lines suppressed (limit ${LOG_BUDGET_PER_MINUTE} per minute)`);
logWindowStart = t;
logCount = 0;
logSuppressed = 0;
}
if (logCount >= LOG_BUDGET_PER_MINUTE) {
logSuppressed += 1;
return;
}
logCount += 1;
emit(message);
}
const withLock = createKeyedLock();
// ---- Rate-Limit je Melder-Hash (im Speicher, geht bei Neustart verloren; Dedup liegt in `reports`)
@@ -422,8 +452,16 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
const fileIdsOf = (row) => [row.imageFileId, row.previewFileId].filter(isId);
/**
* Setzt die Rechte beider Dateien. Wartet auf beide Aufrufe, auch wenn einer scheitert (die Sperre der Karte darf
* nicht freigegeben werden, solange noch ein PUT unterwegs ist), und wirft danach den ersten Fehler.
* @returns {Promise<Array>} je Datei das Ergebnis; null heißt: Datei gibt es nicht (mehr)
*/
async function setFilePermissions(row, permissions) {
await Promise.all(fileIdsOf(row).map((fileId) => appwrite.updateFilePermissions(BUCKET, fileId, permissions)));
const results = await Promise.allSettled(fileIdsOf(row).map((fileId) => appwrite.updateFilePermissions(BUCKET, fileId, permissions)));
const failed = results.find((result) => result.status === 'rejected');
if (failed) throw failed.reason;
return results.map((result) => result.value);
}
/** Dateien zuerst, Zeile zuletzt: bleibt etwas hängen, heilt der nächste Versuch (alles ist wiederholbar). */
@@ -490,6 +528,17 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
if (isDuplicate(err)) throw new HttpError(409, MSG.alreadyPublished);
throw err;
}
// Kanonischer Zustand: Beide Dateien tragen genau die sichtbaren Rechte (kein update, keine fremden Rollen).
// Erst nach dem Anlegen der Zeile: ein 409 darf die Rechte einer schon veröffentlichten (oder ausgeblendeten)
// Karte nie anfassen. Scheitert das Festlegen, wird die Zeile zurückgenommen.
try {
const pinned = await setFilePermissions(data, visibleFilePermissions(user.id));
if (pinned.some((file) => file === null)) throw new HttpError(400, MSG.fileMissing);
} catch (err) {
await appwrite.deleteRow(TABLE_GALLERY, id).catch(() => {});
throw err;
}
say(`card ${id} published`);
return { status: 201, body: { id } };
}
@@ -577,7 +626,6 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
]);
async function handle(req, res) {
const started = Date.now();
const pathname = (req.url || '').split('?')[0];
const route = ROUTES.get(`${req.method} ${pathname}`);
let status = 500;
@@ -605,23 +653,24 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
message = err.message;
headers = err.headers;
} else if (err instanceof AppwriteError) {
status = 502;
message = MSG.unavailable;
say(`appwrite error: status=${err.status} type=${String(err.type).replace(/[^\w.-]/g, '').slice(0, 60)}`);
// Keine Antwort, Appwrite überlastet (429) oder Serverfehler: nicht erreichbar (502). Alles andere
// (falscher Key, fehlende Tabelle, ungültige Struktur) ist ein Fehler hier (500).
const unavailable = err.status === 0 || err.status === 429 || err.status >= 500;
status = unavailable ? 502 : 500;
message = unavailable ? MSG.unavailable : MSG.internal;
say(`${req.method} /api/${route.name} -> ${status}: appwrite error status=${err.status} type=${safeToken(err.type)}`);
} else {
status = 500;
say(`unexpected error: ${err && err.name ? String(err.name).replace(/[^\w.-]/g, '').slice(0, 60) : 'unknown'}`);
say(`${req.method} /api/${route ? route.name : '-'} -> 500: unexpected error ${safeToken(err && err.name)}`);
}
if (!res.headersSent) sendJson(res, status, { error: message }, headers);
} finally {
if (route && route.name !== 'gesundheit') say(`${req.method} /api/${route.name} -> ${status} (${Date.now() - started} ms)`);
}
}
function handler(req, res) {
return handle(req, res).catch((err) => {
// handle() fängt alles selbst ab; das hier sichert nur gegen Fehler beim Senden.
say(`unexpected error: ${err && err.name ? String(err.name).replace(/[^\w.-]/g, '').slice(0, 60) : 'unknown'}`);
say(`unexpected error ${safeToken(err && err.name)}`);
try {
res.destroy();
} catch {
+196 -1
View File
@@ -8,7 +8,7 @@ const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const http = require('node:http');
const { createAppwrite } = require('./appwrite.js');
const { createAppwrite, AppwriteError } = require('./appwrite.js');
const { startFakeAppwrite } = require('./fake-appwrite.js');
const {
createApp,
@@ -917,6 +917,201 @@ test('Logs enthalten nie Key, Salz, JWT oder IPs im Klartext, aber das Präfix [
for (const line of logs) assert.ok(line.startsWith('[aircard-api] '), line);
});
test('Log-Flut: abgewiesene Anfragen (400/401/404/413/415/429) erzeugen keine Logzeilen', async () => {
const { call, post, report, logs } = await setup();
const big = `{"id":"x","reason":"spam"}${' '.repeat(9000)}`;
for (let i = 0; i < 12; i += 1) {
await report('gibts-nicht', '203.0.113.5'); // 404, ab der 11. Meldung 429
await call('POST', '/api/melden', { raw: '{"id":"x","reason":"spam"}', contentType: 'text/plain' }); // 415
await call('POST', '/api/melden', { raw: big }); // 413
await call('POST', '/api/melden', { raw: '{kaputt' }); // 400
await post('/api/melden', { id: '../x', reason: 'spam' }); // 400
await post('/api/veroeffentlichen', publishBody()); // 401
await post('/api/zurueckziehen', { id: 'x' }, 'zwei teile'); // 401
await call('GET', '/api/gibts-nicht'); // 404
}
const lastLimited = await report('gibts-nicht', '203.0.113.5');
assert.equal(lastLimited.status, 429, 'das Rate-Limit hat wirklich gegriffen');
assert.deepEqual(logs, []);
});
test('Log-Budget: höchstens 120 Zeilen pro Minute, der Rest wird gezählt und als Sammelzeile nachgetragen', async () => {
const logs = [];
const clock = { value: 1_700_000_000_000 };
const broken = {
accountFromJwt: async () => {
throw new AppwriteError(500, 'general_server_error', 'kaputt');
},
};
const app = createApp({ appwrite: broken, salt: SALT, now: () => clock.value, log: (line) => logs.push(String(line)) });
const server = http.createServer(app);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
cleanups.push(() => {
server.closeAllConnections();
return new Promise((resolve) => server.close(resolve));
});
const { port } = server.address();
const hit = () => request(port, 'POST', '/api/zurueckziehen', { body: { id: 'x' }, headers: { 'X-Aircard-Jwt': JWT_ME } });
for (let i = 0; i < 300; i += 1) assert.equal((await hit()).status, 502);
assert.equal(logs.length, 120, 'nach 120 Zeilen ist Schluss');
clock.value += 60_000;
await hit();
assert.equal(logs.length, 122, 'Sammelzeile + die neue Zeile');
assert.match(logs[120], /^\[aircard-api\] \S+ 180 log lines suppressed/);
assert.match(logs[121], /appwrite error status=500 type=general_server_error/);
});
test('Appwrite-Fehler: 502 bei 5xx und 429, 500 bei allem anderen (falscher Key, fehlende Tabelle)', async () => {
const { report, fake } = await setup();
seedCard(fake);
const isRowRead = (c) => c.method === 'GET' && c.path.includes('/tables/gallery/rows/');
const expectations = [
[{ status: 500, type: 'general_server_error' }, 502, 'Der Dienst ist gerade nicht erreichbar.'],
[{ status: 429, type: 'general_rate_limit_exceeded' }, 502, 'Der Dienst ist gerade nicht erreichbar.'],
[{ status: 404, type: 'table_not_found' }, 500, 'Interner Fehler.'],
[{ status: 404, type: 'database_not_found' }, 500, 'Interner Fehler.'],
[{ status: 404, type: 'general_route_not_found' }, 500, 'Interner Fehler.'],
[{ status: 401, type: 'general_unauthorized_scope' }, 500, 'Interner Fehler.'],
];
for (const [failure, status, message] of expectations) {
fake.failNext(isRowRead, failure);
const res = await report('card-1', '203.0.113.1');
assert.equal(res.status, status, `${failure.status} ${failure.type}`);
assert.equal(res.json.error, message);
}
assert.equal(fake.rows('reports').length, 0);
});
test('Fehlende Tabelle oder Bucket ist kein „Karte nicht verfügbar“, sondern ein 500', async () => {
const fake = await startFakeAppwrite();
fake.addUser(JWT_ME, { id: ME });
const appwrite = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key, database: 'falsche-db' });
const app = createApp({ appwrite, salt: SALT, log: () => {} });
const server = http.createServer(app);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
cleanups.push(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
await fake.close();
});
const { port } = server.address();
const res = await request(port, 'POST', '/api/melden', { body: { id: 'card-1', reason: 'spam' } });
assert.equal(res.status, 500);
assert.equal(res.json.error, 'Interner Fehler.');
});
test('Veröffentlichen legt beide Dateien auf genau die sichtbaren Rechte fest (kanonischer Zustand)', async () => {
const { post, fake } = await setup();
fake.addFile({ id: 'img1', owner: ME, permissions: ['read("any")', 'delete("user:user-me")', 'update("user:user-me")', 'read("users")'] });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', permissions: ['delete("user:user-me")', 'write("user:user-me")'] });
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 201);
assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")']);
assert.deepEqual(fake.file('prev1').$permissions, ['read("any")', 'delete("user:user-me")']);
});
test('Ein 409 beim Veröffentlichen fasst die Dateirechte nicht an (auch nicht die einer ausgeblendeten Karte)', async () => {
const { post, fake } = await setup();
// Karte 1 ist schon veröffentlicht und wurde ausgeblendet; ihr Autor schickt dieselben Dateien erneut.
const hiddenFile = ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")'];
fake.addFile({ id: 'img1', owner: ME, permissions: hiddenFile });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', permissions: hiddenFile });
const rowId = galleryRowId(ME, 'card1');
fake.addRow(
'gallery',
rowId,
{ title: 'Karte', imageFileId: 'img1', previewFileId: 'prev1', sourceCardId: 'card1', ownerId: ME, hidden: true, reportCount: 3 },
hiddenRowPermissions(ME),
);
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 409);
assert.deepEqual(fake.file('img1').$permissions, hiddenFile);
assert.deepEqual(fake.file('prev1').$permissions, hiddenFile);
assert.equal(fake.row('gallery', rowId).hidden, true);
assert.equal(fake.calls.filter((c) => c.method === 'PUT').length, 0, 'kein PUT auf Dateien');
});
test('Veröffentlichen: scheitert das Festlegen der Dateirechte, wird die Zeile zurückgenommen', async () => {
const { post, fake } = await setup();
seedUploads(fake);
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/prev1'), { status: 500 });
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 502);
assert.equal(fake.rows('gallery').length, 0, 'keine halbfertige Zeile, die eine Wiederholung mit 409 blockiert');
// Datei verschwindet zwischen Prüfung und Festlegen: 400 und ebenfalls zurückgenommen
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/img1'), { status: 404, type: 'storage_file_not_found' });
res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 400);
assert.equal(res.json.error, 'Bild nicht gefunden.');
assert.equal(fake.rows('gallery').length, 0);
// danach geht ein neuer Versuch
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201);
});
test('Dateirechte setzen wartet auf beide Aufrufe, auch wenn einer scheitert (Sperre bleibt bis dahin)', async () => {
const { post, fake } = await setup();
seedCard(fake);
fake.delayNext((c) => c.method === 'PUT' && c.path.endsWith('/files/card-1-img'), 150);
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/card-1-prev'), { status: 500 });
const res = await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, JWT_ADMIN);
assert.equal(res.status, 502);
// Die Antwort kam erst, nachdem auch der langsame PUT durch war.
assert.deepEqual(fake.file('card-1-img').$permissions, ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")']);
assert.deepEqual(fake.file('card-1-prev').$permissions, ['read("any")', 'delete("user:user-me")']);
assert.equal(fake.row('gallery', 'card-1').hidden, false, 'die Zeile wird erst nach den Dateien geändert');
});
test('Ein gültiges JWT nur in Authorization oder X-Appwrite-JWT wird ignoriert (401)', async () => {
const { post, fake } = await setup();
seedUploads(fake);
seedCard(fake);
const ignored = [
{ Authorization: `Bearer ${JWT_ME}` },
{ 'X-Appwrite-JWT': JWT_ME },
{ 'X-Appwrite-JWT': JWT_ADMIN, Authorization: `Bearer ${JWT_ADMIN}` },
];
for (const headers of ignored) {
assert.equal((await post('/api/veroeffentlichen', publishBody(), null, headers)).status, 401);
assert.equal((await post('/api/zurueckziehen', { id: 'card-1' }, null, headers)).status, 401);
assert.equal((await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, null, headers)).status, 401);
}
assert.equal(fake.calls.filter((c) => c.path === '/v1/account').length, 0, 'Appwrite wurde nie nach dem Konto gefragt');
assert.equal(fake.rows('gallery').length, 1);
assert.ok(fake.row('gallery', 'card-1'));
});
test('Konto-Antwort mit ungültiger $id ergibt 401, ohne labels oder mit falschem Typ ist die Person kein Admin', async () => {
const { post, fake } = await setup();
seedCard(fake);
for (const [jwt, account] of [
['bad.id.one', { $id: 'x","read("any' }],
['bad.id.two', { $id: 42 }],
['bad.id.three', { $id: '../x' }],
['bad.id.four', { $id: '' }],
['bad.id.five', {}],
['bad.id.six', { $id: 'a'.repeat(37) }],
]) {
fake.addRawUser(jwt, account);
assert.equal((await post('/api/zurueckziehen', { id: 'card-1' }, jwt)).status, 401, JSON.stringify(account));
}
assert.ok(fake.row('gallery', 'card-1'));
fake.addRawUser('no.labels.jwt', { $id: 'user-nolabels' });
fake.addRawUser('string.labels.jwt', { $id: 'user-stringlabels', labels: 'admin' });
fake.addRawUser('object.labels.jwt', { $id: 'user-objectlabels', labels: { admin: true } });
for (const jwt of ['no.labels.jwt', 'string.labels.jwt', 'object.labels.jwt']) {
assert.equal((await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, jwt)).status, 403, jwt);
assert.equal((await post('/api/zurueckziehen', { id: 'card-1' }, jwt)).status, 403, jwt);
}
assert.equal(fake.row('gallery', 'card-1').hidden, false);
});
test('Unerwartete Fehler im Appwrite-Objekt ergeben 500, ohne die Anfrage hängen zu lassen', async () => {
const logs = [];
const broken = {
+25 -15
View File
@@ -4,16 +4,21 @@
* Schmaler REST-Client für Appwrite 1.8 (Node 22, nur Bordmittel) mit dem Server-Key des API-Dienstes.
*
* createAppwrite({ endpoint, project, key, fetchImpl?, database?, timeoutMs? })
* getRow(table, id) Zeile oder null (404)
* listRows(table, queries) Array von Zeilen; queries = JSON-Strings (queryEqual, queryLimit)
* getRow(table, id) Zeile oder null (404 row_not_found)
* listRows(table, queries) Array von Zeilen; queries = JSON-Strings (queryEqual, queryLimit);
* eine Antwort ohne `rows`-Array ist ein Fehler (kein stilles [])
* createRow(table, rowId, data, permissions) doppelte ID -> AppwriteError mit status 409
* updateRow(table, id, data, permissions?) Rechte nur ändern, wenn übergeben
* deleteRow(table, id) true, false bei 404 (wiederholbar)
* deleteRow(table, id) true, false bei 404 row_not_found (wiederholbar)
* getFile(bucket, id) Datei-Metadaten ($permissions, bucketId, mimeType, sizeOriginal) oder null
* updateFilePermissions(bucket, id, permissions) Datei oder null (404)
* deleteFile(bucket, id) true, false bei 404
* updateFilePermissions(bucket, id, permissions) Datei oder null (404 storage_file_not_found)
* deleteFile(bucket, id) true, false bei 404 storage_file_not_found
* accountFromJwt(jwt) Nutzer ($id, labels) oder null (401); OHNE Server-Key
*
* Als „fehlt“ gilt nur ein 404 mit dem erwarteten Typ (row_not_found, storage_file_not_found). Jedes andere 404
* (table_not_found, database_not_found, storage_bucket_not_found, unbekannte Route, 404 ohne JSON) ist ein Fehler:
* so verschwindet ein Konfigurationsfehler nicht als „Karte nicht gefunden“.
* Redirects werden nie verfolgt (`redirect: 'error'`), damit der Server-Key nicht an eine andere Adresse geht.
* Jeder Aufruf bricht nach `timeoutMs` (10 s) ab. Jedes Pfadsegment wird mit encodeURIComponent kodiert;
* "." und ".." lehnt der Client ab, weil encodeURIComponent sie nicht verändert und die URL-Normalisierung
* sie sonst als Verzeichniswechsel auslegen würde.
@@ -23,6 +28,8 @@
*/
const DEFAULT_TIMEOUT_MS = 10_000;
const ROW_NOT_FOUND = 'row_not_found';
const FILE_NOT_FOUND = 'storage_file_not_found';
class AppwriteError extends Error {
constructor(status, type, message) {
@@ -60,11 +67,11 @@ function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch,
const rowsPath = (table) => `/tablesdb/${segment(database)}/tables/${segment(table)}/rows`;
const filePath = (bucket, id) => `/storage/buckets/${segment(bucket)}/files/${segment(id)}`;
/** Ein HTTP-Aufruf; liefert { status, body } für 2xx und 404, sonst wirft er. */
async function send(method, path, { body, headers = {}, withKey = true, tolerate = [] } = {}) {
/** Ein HTTP-Aufruf; liefert { status, body } für 2xx und für ein 404 mit einem Typ aus `missingTypes`, sonst wirft er. */
async function send(method, path, { body, headers = {}, withKey = true, missingTypes = [] } = {}) {
const requestHeaders = { 'X-Appwrite-Project': project, ...headers };
if (withKey) requestHeaders['X-Appwrite-Key'] = key;
const init = { method, headers: requestHeaders, signal: AbortSignal.timeout(timeoutMs) };
const init = { method, headers: requestHeaders, redirect: 'error', signal: AbortSignal.timeout(timeoutMs) };
if (body !== undefined) {
requestHeaders['Content-Type'] = 'application/json';
init.body = JSON.stringify(body);
@@ -92,22 +99,25 @@ function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch,
}
}
if (response.status >= 200 && response.status < 300) return { status: response.status, body: parsed };
if (tolerate.includes(response.status)) return { status: response.status, body: null };
const type = parsed && typeof parsed.type === 'string' ? parsed.type : 'unknown';
if (response.status === 404 && missingTypes.includes(type)) return { status: 404, body: null };
const message = parsed && typeof parsed.message === 'string' ? parsed.message : `HTTP ${response.status}`;
throw new AppwriteError(response.status, type, message);
}
return {
async getRow(table, id) {
const result = await send('GET', `${rowsPath(table)}/${segment(id)}`, { tolerate: [404] });
const result = await send('GET', `${rowsPath(table)}/${segment(id)}`, { missingTypes: [ROW_NOT_FOUND] });
return result.status === 404 ? null : result.body;
},
async listRows(table, queries = []) {
const search = queries.map((query) => `queries[]=${encodeURIComponent(query)}`).join('&');
const result = await send('GET', `${rowsPath(table)}${search === '' ? '' : `?${search}`}`);
return Array.isArray(result.body && result.body.rows) ? result.body.rows : [];
if (!result.body || !Array.isArray(result.body.rows)) {
throw new AppwriteError(502, 'invalid_response', 'Unerwartete Antwort von Appwrite');
}
return result.body.rows;
},
async createRow(table, rowId, data, permissions) {
@@ -123,22 +133,22 @@ function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch,
},
async deleteRow(table, id) {
const result = await send('DELETE', `${rowsPath(table)}/${segment(id)}`, { tolerate: [404] });
const result = await send('DELETE', `${rowsPath(table)}/${segment(id)}`, { missingTypes: [ROW_NOT_FOUND] });
return result.status !== 404;
},
async getFile(bucket, id) {
const result = await send('GET', filePath(bucket, id), { tolerate: [404] });
const result = await send('GET', filePath(bucket, id), { missingTypes: [FILE_NOT_FOUND] });
return result.status === 404 ? null : result.body;
},
async updateFilePermissions(bucket, id, permissions) {
const result = await send('PUT', filePath(bucket, id), { body: { permissions }, tolerate: [404] });
const result = await send('PUT', filePath(bucket, id), { body: { permissions }, missingTypes: [FILE_NOT_FOUND] });
return result.status === 404 ? null : result.body;
},
async deleteFile(bucket, id) {
const result = await send('DELETE', filePath(bucket, id), { tolerate: [404] });
const result = await send('DELETE', filePath(bucket, id), { missingTypes: [FILE_NOT_FOUND] });
return result.status !== 404;
},
+121
View File
@@ -5,6 +5,7 @@
const { test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const { createAppwrite, AppwriteError, queryEqual, queryLimit } = require('./appwrite.js');
const { startFakeAppwrite } = require('./fake-appwrite.js');
@@ -209,3 +210,123 @@ test('Netzfehler: AppwriteError (unreachable) ohne Key im Text', async () => {
(err) => err instanceof AppwriteError && err.type === 'unreachable' && !String(err.message).includes('GEHEIMER-KEY-123'),
);
});
test('404 gilt nur mit dem erwarteten Typ als „fehlt“, jedes andere 404 ist ein Fehler', async () => {
const { fake, appwrite } = await setup();
const rejectsWith404 = (promise, type) =>
assert.rejects(promise, (err) => err instanceof AppwriteError && err.status === 404 && err.type === type);
// erwartete Typen: fehlt
assert.equal(await appwrite.getRow('gallery', 'x'), null);
assert.equal(await appwrite.deleteRow('gallery', 'x'), false);
assert.equal(await appwrite.getFile('gallery-images', 'x'), null);
assert.equal(await appwrite.deleteFile('gallery-images', 'x'), false);
assert.equal(await appwrite.updateFilePermissions('gallery-images', 'x', []), null);
// unbekannte Tabelle / Datenbank / Bucket: Fehler
await rejectsWith404(appwrite.getRow('keine-tabelle', 'x'), 'table_not_found');
await rejectsWith404(appwrite.deleteRow('keine-tabelle', 'x'), 'table_not_found');
await rejectsWith404(appwrite.listRows('keine-tabelle', []), 'table_not_found');
await rejectsWith404(appwrite.getFile('kein-bucket', 'x'), 'storage_bucket_not_found');
await rejectsWith404(appwrite.updateFilePermissions('kein-bucket', 'x', []), 'storage_bucket_not_found');
await rejectsWith404(appwrite.deleteFile('kein-bucket', 'x'), 'storage_bucket_not_found');
const otherDatabase = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key, database: 'andere-db' });
await rejectsWith404(otherDatabase.getRow('gallery', 'x'), 'database_not_found');
// Typ von der falschen Art (Zeile statt Datei und umgekehrt) und unbekannte Route
fake.failNext((c) => c.method === 'GET', { status: 404, type: 'storage_file_not_found' });
await rejectsWith404(appwrite.getRow('gallery', 'x'), 'storage_file_not_found');
fake.failNext((c) => c.method === 'GET', { status: 404, type: 'row_not_found' });
await rejectsWith404(appwrite.getFile('gallery-images', 'x'), 'row_not_found');
fake.failNext((c) => c.method === 'GET', { status: 404, type: 'general_route_not_found' });
await rejectsWith404(appwrite.getRow('gallery', 'x'), 'general_route_not_found');
});
test('404 ohne JSON-Antwort (z. B. von einem Proxy) ist ein Fehler, kein „fehlt“', async () => {
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async () => new Response('404 page not found', { status: 404 }),
});
for (const call of [
() => appwrite.getRow('gallery', 'x'),
() => appwrite.getFile('gallery-images', 'x'),
() => appwrite.deleteRow('gallery', 'x'),
() => appwrite.deleteFile('gallery-images', 'x'),
() => appwrite.updateFilePermissions('gallery-images', 'x', []),
]) {
await assert.rejects(call, (err) => err instanceof AppwriteError && err.status === 404 && err.type === 'unknown');
}
});
test('listRows: eine Antwort ohne rows-Array wirft einen AppwriteError statt still [] zu liefern', async () => {
for (const payload of [{ total: 0 }, { rows: 'x' }, { rows: null }, [], 'text', null]) {
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async () => new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
await assert.rejects(
() => appwrite.listRows('reports', []),
(err) => err instanceof AppwriteError && err.type === 'invalid_response' && err.status === 502,
JSON.stringify(payload),
);
}
const empty = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async () => new Response(JSON.stringify({ total: 0, rows: [] }), { status: 200 }),
});
assert.deepEqual(await empty.listRows('reports', []), [], 'eine echte leere Liste bleibt eine leere Liste');
});
test('Alle Appwrite-Aufrufe verwenden redirect: "error" (der Key folgt keiner Weiterleitung)', async () => {
const seen = [];
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async (url, init) => {
seen.push(init.redirect);
return new Response(JSON.stringify({ rows: [], $id: 'x' }), { status: 200 });
},
});
await appwrite.getRow('gallery', 'x');
await appwrite.listRows('gallery', []);
await appwrite.createRow('gallery', 'x', {}, []);
await appwrite.updateRow('gallery', 'x', {});
await appwrite.deleteRow('gallery', 'x');
await appwrite.getFile('gallery-images', 'x');
await appwrite.updateFilePermissions('gallery-images', 'x', []);
await appwrite.deleteFile('gallery-images', 'x');
await appwrite.accountFromJwt('a.b.c');
assert.equal(seen.length, 9);
assert.ok(seen.every((value) => value === 'error'));
});
test('Eine Weiterleitung von Appwrite wird nicht verfolgt (echter Server antwortet 302)', async () => {
const target = http.createServer((req, res) => {
target.hits = (target.hits || 0) + 1;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('{}');
});
await new Promise((resolve) => target.listen(0, '127.0.0.1', resolve));
const redirector = http.createServer((req, res) => {
res.writeHead(302, { Location: `http://127.0.0.1:${target.address().port}/gesammelt` });
res.end();
});
await new Promise((resolve) => redirector.listen(0, '127.0.0.1', resolve));
try {
const appwrite = createAppwrite({ endpoint: `http://127.0.0.1:${redirector.address().port}/v1`, project: 'p', key: 'GEHEIMER-KEY' });
await assert.rejects(() => appwrite.getRow('gallery', 'x'), (err) => err instanceof AppwriteError && err.type === 'unreachable');
assert.equal(target.hits || 0, 0, 'das Ziel der Weiterleitung wurde nie angefragt');
} finally {
redirector.closeAllConnections();
target.closeAllConnections();
await new Promise((resolve) => redirector.close(resolve));
await new Promise((resolve) => target.close(resolve));
}
});
+11 -1
View File
@@ -16,6 +16,8 @@
const http = require('node:http');
const BUCKETS = ['gallery-images', 'card-images'];
const COLUMNS = {
gallery: {
title: { size: 60, required: true },
@@ -49,6 +51,11 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
users.set(jwt, { $id: id, email: `${id}@example.test`, labels });
}
/** Beliebige Konto-Antwort für `GET /account` (z. B. ohne labels oder mit ungültiger ID). */
function addRawUser(jwt, account) {
users.set(jwt, account);
}
function addFile({ id, bucket = 'gallery-images', owner, permissions, mimeType = 'image/png', size = 1000 }) {
files.set(id, {
$id: id,
@@ -157,7 +164,8 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
// Zeilen
if (segments[1] === 'tablesdb' && segments[3] === 'tables' && segments[5] === 'rows') {
const table = segments[4];
if (segments[2] !== database || !tables[table]) return error(404, 'table_not_found');
if (segments[2] !== database) return error(404, 'database_not_found');
if (!tables[table]) return error(404, 'table_not_found');
const store = tables[table];
if (segments.length === 6 && req.method === 'POST') {
@@ -200,6 +208,7 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
// Dateien
if (segments[1] === 'storage' && segments[2] === 'buckets' && segments[4] === 'files' && segments.length === 6) {
if (!BUCKETS.includes(segments[3])) return error(404, 'storage_bucket_not_found');
const file = files.get(segments[5]);
if (!file) return error(404, 'storage_file_not_found');
if (req.method === 'GET') return reply(200, file);
@@ -229,6 +238,7 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
key,
calls,
addUser,
addRawUser,
addFile,
addRow,
failNext,