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
@@ -7,6 +7,9 @@
|
||||
* GET|POST /v1/tablesdb/{db}/tables/{t}/rows POST {rowId, data, permissions}; doppelt -> 409
|
||||
* GET|PATCH|DELETE .../rows/{id} PATCH {data?, permissions?}
|
||||
* GET|PUT|DELETE /v1/storage/buckets/{b}/files/{f} PUT {permissions}
|
||||
* GET /v1/storage/buckets/{b}/files/{f}/view Dateiinhalt (Bytes aus `content`)
|
||||
*
|
||||
* Die Tabelle `cards` ist nur zum Lesen da (Zeilen legt der Test mit `addCard` an).
|
||||
*
|
||||
* Abfragen: `queries[]=<json>` mit den Methoden equal, cursorAfter und limit. Zeilen werden gegen die Spaltenlängen der
|
||||
* echten Tabellen geprüft (document_invalid_structure), damit zu lange Werte im Test auffallen.
|
||||
@@ -18,6 +21,46 @@ const http = require('node:http');
|
||||
|
||||
const BUCKETS = ['gallery-images', 'card-images'];
|
||||
|
||||
/** PNG-Anfang (Signatur + IHDR) mit den Maßen; reicht für die Größenprüfung der API. */
|
||||
function pngHeader(width, height) {
|
||||
const head = Buffer.alloc(33);
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(head, 0);
|
||||
head.writeUInt32BE(13, 8);
|
||||
head.write('IHDR', 12, 'latin1');
|
||||
head.writeUInt32BE(width, 16);
|
||||
head.writeUInt32BE(height, 20);
|
||||
head[24] = 8; // Bit-Tiefe
|
||||
head[25] = 6; // RGBA
|
||||
return head;
|
||||
}
|
||||
|
||||
/** WebP-Anfang mit den Maßen; kind: 'VP8 ' (verlustbehaftet), 'VP8L' (verlustfrei) oder 'VP8X' (erweitert). */
|
||||
function webpHeader(kind, width, height) {
|
||||
const head = Buffer.alloc(40);
|
||||
head.write('RIFF', 0, 'latin1');
|
||||
head.writeUInt32LE(32, 4);
|
||||
head.write('WEBP', 8, 'latin1');
|
||||
head.write(kind, 12, 'latin1');
|
||||
if (kind === 'VP8 ') {
|
||||
head.writeUInt32LE(10, 16);
|
||||
Buffer.from([0x30, 0x01, 0x00, 0x9d, 0x01, 0x2a]).copy(head, 20);
|
||||
head.writeUInt16LE(width & 0x3fff, 26);
|
||||
head.writeUInt16LE(height & 0x3fff, 28);
|
||||
} else if (kind === 'VP8L') {
|
||||
head.writeUInt32LE(5, 16);
|
||||
head[20] = 0x2f;
|
||||
head.writeUInt32LE(((width - 1) & 0x3fff) | (((height - 1) & 0x3fff) << 14), 21);
|
||||
} else if (kind === 'VP8X') {
|
||||
head.writeUInt32LE(10, 16);
|
||||
head[20] = 0x10; // Alpha-Flag
|
||||
head.writeUIntLE(width - 1, 24, 3);
|
||||
head.writeUIntLE(height - 1, 27, 3);
|
||||
} else {
|
||||
throw new Error(`Unbekannte WebP-Art: ${kind}`);
|
||||
}
|
||||
return head;
|
||||
}
|
||||
|
||||
const COLUMNS = {
|
||||
gallery: {
|
||||
title: { size: 60, required: true },
|
||||
@@ -40,8 +83,9 @@ const COLUMNS = {
|
||||
};
|
||||
|
||||
async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret-value', database = 'aircard' } = {}) {
|
||||
const tables = { gallery: new Map(), reports: new Map() };
|
||||
const tables = { gallery: new Map(), reports: new Map(), cards: new Map() };
|
||||
const files = new Map();
|
||||
const contents = new Map();
|
||||
const users = new Map();
|
||||
const calls = [];
|
||||
const injections = [];
|
||||
@@ -56,7 +100,13 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
|
||||
users.set(jwt, account);
|
||||
}
|
||||
|
||||
function addFile({ id, bucket = 'gallery-images', owner, permissions, mimeType = 'image/png', size = 1000 }) {
|
||||
/**
|
||||
* Datei mit Metadaten und Inhalt. `role` bestimmt den Standardinhalt: 'image' = Kartenbild 1536 x 969, 'preview' =
|
||||
* Vorschau 480 x 303 (Standard: WebP ist die Vorschau, alles andere das Bild); `content` überschreibt ihn.
|
||||
*/
|
||||
function addFile({ id, bucket = 'gallery-images', owner, permissions, mimeType = 'image/png', size = 1000, role, content }) {
|
||||
const kind = role || (mimeType === 'image/webp' ? 'preview' : 'image');
|
||||
const [width, height] = kind === 'preview' ? [480, 303] : [1536, 969];
|
||||
files.set(id, {
|
||||
$id: id,
|
||||
bucketId: bucket,
|
||||
@@ -65,6 +115,12 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
|
||||
mimeType,
|
||||
sizeOriginal: size,
|
||||
});
|
||||
contents.set(id, content || (mimeType === 'image/webp' ? webpHeader('VP8 ', width, height) : pngHeader(width, height)));
|
||||
}
|
||||
|
||||
/** Private Karte (Tabelle `cards`) mit den Rechten, die Appwrite dem Besitzer beim Anlegen gibt. */
|
||||
function addCard(id, owner, permissions) {
|
||||
return addRow('cards', id, { name: 'Karte' }, permissions || [`read("user:${owner}")`, `update("user:${owner}")`, `delete("user:${owner}")`]);
|
||||
}
|
||||
|
||||
function addRow(table, id, data, permissions) {
|
||||
@@ -206,6 +262,17 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
|
||||
}
|
||||
}
|
||||
|
||||
// Dateiinhalt: GET .../files/{id}/view
|
||||
if (segments[1] === 'storage' && segments[2] === 'buckets' && segments[4] === 'files' && segments.length === 7 && segments[6] === 'view' && req.method === 'GET') {
|
||||
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');
|
||||
const content = contents.get(segments[5]);
|
||||
res.writeHead(200, { 'Content-Type': file.mimeType, 'Content-Length': content.length });
|
||||
res.end(content);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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');
|
||||
@@ -214,6 +281,7 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
|
||||
if (req.method === 'GET') return reply(200, file);
|
||||
if (req.method === 'DELETE') {
|
||||
files.delete(segments[5]);
|
||||
contents.delete(segments[5]);
|
||||
return reply(204);
|
||||
}
|
||||
if (req.method === 'PUT') {
|
||||
@@ -240,6 +308,7 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
|
||||
addUser,
|
||||
addRawUser,
|
||||
addFile,
|
||||
addCard,
|
||||
addRow,
|
||||
failNext,
|
||||
hangNext,
|
||||
@@ -303,4 +372,4 @@ function runQueries(rows, search) {
|
||||
return result.slice(0, limit);
|
||||
}
|
||||
|
||||
module.exports = { startFakeAppwrite };
|
||||
module.exports = { startFakeAppwrite, pngHeader, webpHeader };
|
||||
Reference in new issue
Block a user