feat(api): aircard-api für Veröffentlichen, Melden und Admin
Neuer abhängigkeitsfreier Node-22-Dienst in deploy/api/ (appwrite.js, app.js,
server.js), der alle Galerie-Schreibzugriffe mit dem Appwrite-Server-Key
ausführt: POST /api/veroeffentlichen, /zurueckziehen, /melden (Rate-Limit
10/h je Melder, IPv6 auf /64, Auto-Ausblenden ab 3), /admin/sichtbarkeit und
GET /api/gesundheit.
- JWT-Prüfung über GET /account (Header X-Aircard-Jwt), Admin = Label admin
- IDs per Muster geprüft, Pfadsegmente kodiert, "." und ".." abgelehnt
- deterministische Zeilen-IDs (Galerie, Meldung): Appwrite-409 ersetzt jede
Doppelt-Prüfung; Sperre je Karte gegen verlorene Zählerstände
- Dateiprüfung: Bucket, delete("user:<ich>"), PNG/WebP, höchstens 5 MiB
- Appwrite-Aufrufe mit 10-s-Zeitlimit, Logs ohne Key, Salz, JWT und IPs
- Tests gegen einen Fake-Appwrite im Speicher (70 Tests), Compose-Service
aircard-api (Router aircard-gallery-api) und README ergänzt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9e4f4ffacc
commit
dd1597862a
11 files changed
+2659
-13
No files matched your search
@@ -0,0 +1,288 @@
|
||||
'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 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 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 });
|
||||
}
|
||||
|
||||
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 || !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) {
|
||||
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,
|
||||
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/limit an; null bei unbekannter Abfrage. */
|
||||
function runQueries(rows, search) {
|
||||
let result = rows;
|
||||
let limit = 25;
|
||||
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 {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return result.slice(0, limit);
|
||||
}
|
||||
|
||||
module.exports = { startFakeAppwrite };
|
||||
Reference in new issue
Block a user