Files
AirCardIOS/deploy/api/fake-appwrite.js
T
ClaudeandClaude Sonnet 5.5 7bbe0a9fe6 fix(api): Dateien bereits veröffentlichter oder gesperrter Karten nicht wiederverwenden
Veröffentlichen lehnt mit 403 "Diese Bilder sind bereits veröffentlicht oder
gesperrt." ab, wenn eine Datei kein read("any") trägt (ausgeblendet) oder von
einer Galerie-Zeile des Autors schon als Bild oder Vorschau verwendet wird
(auch überkreuz, auch ausgeblendete Karten). Die Nutzungsprüfung liest die
Zeilen des Autors über den vorhandenen ownerId-Index seitenweise mit Cursor
(auf die Datei-ID-Spalten gibt es keinen Index) und läuft zusammen mit dem
Anlegen je Autor nacheinander. 92 Tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:27:29 +00:00

307 lines
11 KiB
JavaScript

'use strict';
/**
* Nur für Tests: Appwrite im Speicher (Teilmenge der REST-API von 1.8.1) als echter HTTP-Server auf Port 0.
*
* GET /v1/account Header X-Appwrite-JWT (+ Projekt, ohne Key)
* 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}
*
* 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.
* Dateien werden nach ID gesucht und liefern ihren echten `bucketId` zurück (der Bucket in der URL zählt nicht),
* so lässt sich der Fall „Datei liegt in einem anderen Bucket“ nachstellen.
*/
const http = require('node:http');
const BUCKETS = ['gallery-images', 'card-images'];
const COLUMNS = {
gallery: {
title: { size: 60, required: true },
issuer: { size: 40 },
tags: { size: 120 },
displayName: { size: 30 },
imageFileId: { size: 36, required: true },
previewFileId: { size: 36, required: true },
sourceCardId: { size: 36 },
ownerId: { size: 36, required: true },
hidden: { type: 'boolean' },
reportCount: { type: 'integer' },
searchText: { size: 300 },
},
reports: {
galleryId: { size: 36, required: true },
reason: { enum: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
reporterHash: { size: 64, required: true },
},
};
async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret-value', database = 'aircard' } = {}) {
const tables = { gallery: new Map(), reports: new Map() };
const files = new Map();
const users = new Map();
const calls = [];
const injections = [];
let sequence = 0;
function addUser(jwt, { id, labels = [] }) {
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,
bucketId: bucket,
name: `${id}.png`,
$permissions: permissions || ['read("any")', `delete("user:${owner}")`],
mimeType,
sizeOriginal: size,
});
}
function addRow(table, id, data, permissions) {
const row = { $id: id, $createdAt: new Date(1_700_000_000_000 + (++sequence) * 1000).toISOString(), $permissions: permissions, ...data };
tables[table].set(id, row);
return row;
}
/** Nächster passender Aufruf (predicate(call) -> boolean) scheitert mit Status/Typ. */
function failNext(predicate, { status = 500, type = 'general_server_error' } = {}) {
injections.push({ predicate, status, type, hang: false });
}
/** Nächster passender Aufruf bekommt nie eine Antwort. */
function hangNext(predicate) {
injections.push({ predicate, hang: true });
}
/** Nächster passender Aufruf wird erst nach `delayMs` bearbeitet (Zustand ändert sich erst dann). */
function delayNext(predicate, delayMs) {
injections.push({ predicate, delayMs });
}
function validateData(table, data, { partial }) {
const columns = COLUMNS[table];
for (const [name, value] of Object.entries(data)) {
const column = columns[name];
if (!column) return `Unknown attribute: ${name}`;
if (column.type === 'boolean' && typeof value !== 'boolean') return `Invalid ${name}`;
if (column.type === 'integer' && !Number.isInteger(value)) return `Invalid ${name}`;
if (column.enum && !column.enum.includes(value)) return `Invalid ${name}`;
if (column.size !== undefined) {
if (typeof value !== 'string') return `Invalid ${name}`;
if (value.length > column.size) return `Invalid ${name}: too long`;
}
}
if (!partial) {
for (const [name, column] of Object.entries(columns)) {
if (column.required && data[name] === undefined) return `Missing required attribute: ${name}`;
}
}
return null;
}
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', async () => {
const raw = Buffer.concat(chunks).toString('utf8');
const [pathname, search = ''] = req.url.split('?');
const call = {
method: req.method,
path: pathname,
search,
headers: req.headers,
body: raw === '' ? null : safeParse(raw),
};
calls.push(call);
const reply = (status, payload) => {
if (status === 204) {
res.writeHead(204);
res.end();
return;
}
const text = JSON.stringify(payload);
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(text) });
res.end(text);
};
const error = (status, type, message = type) => reply(status, { message, code: status, type });
const injectionIndex = injections.findIndex((entry) => entry.predicate(call));
if (injectionIndex !== -1) {
const [entry] = injections.splice(injectionIndex, 1);
if (entry.hang) return;
if (entry.delayMs) {
await new Promise((resolve) => setTimeout(resolve, entry.delayMs));
} else {
return error(entry.status, entry.type);
}
}
const segments = pathname.split('/').filter(Boolean).map(decodeSegment);
if (segments[0] !== 'v1' || req.headers['x-appwrite-project'] !== project) {
return error(401, 'general_unauthorized_scope', 'Missing or wrong project');
}
// GET /v1/account (JWT, ohne Key)
if (segments.length === 2 && segments[1] === 'account' && req.method === 'GET') {
const user = users.get(req.headers['x-appwrite-jwt']);
if (!user) return error(401, 'user_jwt_invalid', 'Invalid JWT');
return reply(200, user);
}
if (req.headers['x-appwrite-key'] !== key) return error(401, 'general_unauthorized_scope', 'Missing or wrong key');
// Zeilen
if (segments[1] === 'tablesdb' && segments[3] === 'tables' && segments[5] === 'rows') {
const table = segments[4];
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') {
const body = call.body || {};
if (typeof body.rowId !== 'string' || body.rowId === '') return error(400, 'general_argument_invalid', 'rowId is required');
if (store.has(body.rowId)) return error(409, 'row_already_exists');
const data = body.data || {};
const problem = validateData(table, data, { partial: false });
if (problem) return error(400, 'document_invalid_structure', problem);
const defaults = table === 'gallery' ? { hidden: false, reportCount: 0 } : {};
const row = addRow(table, body.rowId, { ...defaults, ...data }, body.permissions || []);
return reply(201, row);
}
if (segments.length === 6 && req.method === 'GET') {
const rows = runQueries([...store.values()], search);
if (rows === null) return error(400, 'general_query_invalid');
return reply(200, { total: rows.length, rows });
}
if (segments.length === 7) {
const row = store.get(segments[6]);
if (!row) return error(404, 'row_not_found');
if (req.method === 'GET') return reply(200, row);
if (req.method === 'DELETE') {
store.delete(segments[6]);
return reply(204);
}
if (req.method === 'PATCH') {
const body = call.body || {};
const data = body.data || {};
const problem = validateData(table, data, { partial: true });
if (problem) return error(400, 'document_invalid_structure', problem);
Object.assign(row, data);
if (Array.isArray(body.permissions)) row.$permissions = body.permissions;
return reply(200, row);
}
}
}
// 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);
if (req.method === 'DELETE') {
files.delete(segments[5]);
return reply(204);
}
if (req.method === 'PUT') {
const body = call.body || {};
if (!Array.isArray(body.permissions)) return error(400, 'general_argument_invalid');
file.$permissions = body.permissions;
return reply(200, file);
}
}
return error(404, 'general_route_not_found');
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
return {
url: `http://127.0.0.1:${port}`,
endpoint: `http://127.0.0.1:${port}/v1`,
project,
key,
calls,
addUser,
addRawUser,
addFile,
addRow,
failNext,
hangNext,
delayNext,
row: (table, id) => tables[table].get(id) || null,
rows: (table) => [...tables[table].values()],
file: (id) => files.get(id) || null,
close: async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
},
};
}
function decodeSegment(segment) {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
function safeParse(text) {
try {
return JSON.parse(text);
} catch {
return { _unparsable: text };
}
}
/** Wendet equal, cursorAfter und limit an (Reihenfolge = Anlegereihenfolge); null bei unbekannter Abfrage. */
function runQueries(rows, search) {
let result = rows;
let limit = 25;
let cursor = null;
for (const part of search.split('&').filter(Boolean)) {
const eq = part.indexOf('=');
const name = decodeURIComponent(part.slice(0, eq));
if (name !== 'queries[]') return null;
let query;
try {
query = JSON.parse(decodeURIComponent(part.slice(eq + 1)));
} catch {
return null;
}
if (query.method === 'equal') {
result = result.filter((row) => query.values.includes(row[query.attribute]));
} else if (query.method === 'limit') {
limit = query.values[0];
} else if (query.method === 'cursorAfter') {
cursor = query.values[0];
} else {
return null;
}
}
if (cursor !== null) {
const index = result.findIndex((row) => row.$id === cursor);
if (index === -1) return null;
result = result.slice(index + 1);
}
return result.slice(0, limit);
}
module.exports = { startFakeAppwrite };