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
@@ -59,9 +59,11 @@ docker exec webklar-aircard-build sh /deploy.sh
|
|||||||
|
|
||||||
Starten nur mit:
|
Starten nur mit:
|
||||||
```sh
|
```sh
|
||||||
docker compose up -d aircard-build aircard
|
docker compose up -d aircard-build aircard aircard-api
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`aircard-api` ist der kleine API-Dienst der Community-Galerie (`deploy/api/`, Route `aircard-ios.org/api`, siehe `deploy/api/README.md`).
|
||||||
|
|
||||||
Die Appwrite-API wird über `/home/webklar/apps/traefik/dynamic/aircard.yml` geroutet (Vorlage: `deploy/traefik/aircard.yml`): Requests auf `aircard-ios.org/v1` mit Header `X-Appwrite-Project: 6ac2130a00289343d6d5` gehen an den Appwrite-Container. Ohne diesen Header antwortet der nginx der Seite mit 404.
|
Die Appwrite-API wird über `/home/webklar/apps/traefik/dynamic/aircard.yml` geroutet (Vorlage: `deploy/traefik/aircard.yml`): Requests auf `aircard-ios.org/v1` mit Header `X-Appwrite-Project: 6ac2130a00289343d6d5` gehen an den Appwrite-Container. Ohne diesen Header antwortet der nginx der Seite mit 404.
|
||||||
|
|
||||||
## Appwrite
|
## Appwrite
|
||||||
|
|||||||
+28
-9
@@ -10,7 +10,8 @@ Die Seite wird auf dem Webklar-Server aus dem Gitea-Repo `knso/AirCardIOS` gebau
|
|||||||
| `deploy/webhook-server.js` | `aircard-ios.org/webhook-server.js` | nimmt den Gitea-Webhook an, startet `deploy.sh` nacheinander, pollt alle 15 Minuten |
|
| `deploy/webhook-server.js` | `aircard-ios.org/webhook-server.js` | nimmt den Gitea-Webhook an, startet `deploy.sh` nacheinander, pollt alle 15 Minuten |
|
||||||
| `deploy/nginx/aircard-ios.org.conf` | `aircard-ios.org/nginx/aircard-ios.org.conf` | nginx-Konfiguration der Seite |
|
| `deploy/nginx/aircard-ios.org.conf` | `aircard-ios.org/nginx/aircard-ios.org.conf` | nginx-Konfiguration der Seite |
|
||||||
| `deploy/traefik/aircard.yml` | `traefik/dynamic/aircard.yml` | Route `/v1` mit Header `X-Appwrite-Project: 6ac2130a00289343d6d5` an Appwrite |
|
| `deploy/traefik/aircard.yml` | `traefik/dynamic/aircard.yml` | Route `/v1` mit Header `X-Appwrite-Project: 6ac2130a00289343d6d5` an Appwrite |
|
||||||
| `deploy/compose-snippet.yml` | `docker-compose.yml` (nach dem eship-Block) | Dienste `aircard-build` und `aircard` |
|
| `deploy/api/appwrite.js`, `app.js`, `server.js` | `aircard-ios.org/api/` (nur diese drei Dateien) | API-Dienst `aircard-api` der Community-Galerie, siehe [api/README.md](api/README.md) |
|
||||||
|
| `deploy/compose-snippet.yml` | `docker-compose.yml` (nach dem eship-Block) | Dienste `aircard-build`, `aircard` und `aircard-api` |
|
||||||
|
|
||||||
Weitere Ordner unter `aircard-ios.org/` (nicht im Repo): `build/` (Klon des Repos, Arbeitsordner des Build-Containers), `out/` (gebaute Seite, von nginx ausgeliefert), `dev/` (Arbeitskopie für die Entwicklung, nicht gemountet).
|
Weitere Ordner unter `aircard-ios.org/` (nicht im Repo): `build/` (Klon des Repos, Arbeitsordner des Build-Containers), `out/` (gebaute Seite, von nginx ausgeliefert), `dev/` (Arbeitskopie für die Entwicklung, nicht gemountet).
|
||||||
|
|
||||||
@@ -20,10 +21,11 @@ Weitere Ordner unter `aircard-ios.org/` (nicht im Repo): `build/` (Klon des Repo
|
|||||||
APP=/home/webklar/apps
|
APP=/home/webklar/apps
|
||||||
SRC=$APP/aircard-ios.org/dev/deploy
|
SRC=$APP/aircard-ios.org/dev/deploy
|
||||||
|
|
||||||
mkdir -p $APP/aircard-ios.org/build $APP/aircard-ios.org/out $APP/aircard-ios.org/nginx
|
mkdir -p $APP/aircard-ios.org/build $APP/aircard-ios.org/out $APP/aircard-ios.org/nginx $APP/aircard-ios.org/api
|
||||||
cp $SRC/deploy.sh $APP/aircard-ios.org/deploy.sh && chmod +x $APP/aircard-ios.org/deploy.sh
|
cp $SRC/deploy.sh $APP/aircard-ios.org/deploy.sh && chmod +x $APP/aircard-ios.org/deploy.sh
|
||||||
cp $SRC/webhook-server.js $APP/aircard-ios.org/webhook-server.js
|
cp $SRC/webhook-server.js $APP/aircard-ios.org/webhook-server.js
|
||||||
cp $SRC/nginx/aircard-ios.org.conf $APP/aircard-ios.org/nginx/aircard-ios.org.conf
|
cp $SRC/nginx/aircard-ios.org.conf $APP/aircard-ios.org/nginx/aircard-ios.org.conf
|
||||||
|
cp $SRC/api/appwrite.js $SRC/api/app.js $SRC/api/server.js $APP/aircard-ios.org/api/
|
||||||
|
|
||||||
# Compose: Backup, dann den Inhalt von compose-snippet.yml unter `services:` direkt nach dem eship-Block einfügen
|
# Compose: Backup, dann den Inhalt von compose-snippet.yml unter `services:` direkt nach dem eship-Block einfügen
|
||||||
cp $APP/docker-compose.yml $APP/docker-compose.yml.bak-2026-10-04-aircard
|
cp $APP/docker-compose.yml $APP/docker-compose.yml.bak-2026-10-04-aircard
|
||||||
@@ -33,17 +35,32 @@ cd $APP && docker compose config -q; echo $? # 0; einzige erlaubte Ausgabe: W
|
|||||||
cp $SRC/traefik/aircard.yml $APP/traefik/dynamic/aircard.yml.tmp && mv $APP/traefik/dynamic/aircard.yml.tmp $APP/traefik/dynamic/aircard.yml
|
cp $SRC/traefik/aircard.yml $APP/traefik/dynamic/aircard.yml.tmp && mv $APP/traefik/dynamic/aircard.yml.tmp $APP/traefik/dynamic/aircard.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
Die `.env` neben der `docker-compose.yml` muss `GITEA_DEPLOY_TOKEN` (Lesezugriff auf `knso/AirCardIOS`) und `GITEA_DEPLOY_WEBHOOK_SECRET` enthalten. Beide Werte gehören nie in Logs, Chats oder Commits.
|
Die `.env` neben der `docker-compose.yml` muss `GITEA_DEPLOY_TOKEN` (Lesezugriff auf `knso/AirCardIOS`) und `GITEA_DEPLOY_WEBHOOK_SECRET` enthalten, für den API-Dienst außerdem `AIRCARD_APPWRITE_PROJECT_ID`, `AIRCARD_API_KEY` und `AIRCARD_API_SALT`. Diese Werte gehören nie in Logs, Chats oder Commits. Das Salz entsteht, ohne dass der Wert auf dem Bildschirm erscheint:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
printf 'AIRCARD_API_SALT=%s\n' "$(openssl rand -hex 32)" >> /home/webklar/apps/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Den Key `AIRCARD_API_KEY` legt Kenso nach [docs/appwrite-console.md](../docs/appwrite-console.md) (Abschnitt 7.3) an und trägt ihn mit dem verdeckten `read -s`-Befehl ein.
|
||||||
|
|
||||||
## Starten
|
## Starten
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd /home/webklar/apps && docker compose up -d aircard-build aircard
|
cd /home/webklar/apps && docker compose up -d aircard-build aircard aircard-api
|
||||||
```
|
```
|
||||||
|
|
||||||
Nur diese beiden Dienste starten, nie ein nacktes `docker compose up -d` (das würde nicht laufende Dienste wie eship-build wieder anlegen). Der Build-Container installiert beim Start `git`, startet den Webhook-Server und baut sofort einmal (`initial`). Den Fortschritt zeigt `docker logs -f webklar-aircard-build`; am Ende steht `Deployment abgeschlossen` (oder eine Zeile mit `ERROR:`).
|
Nur diese drei Dienste starten, nie ein nacktes `docker compose up -d` (das würde nicht laufende Dienste wie eship-build wieder anlegen). Der Build-Container installiert beim Start `git`, startet den Webhook-Server und baut sofort einmal (`initial`). Den Fortschritt zeigt `docker logs -f webklar-aircard-build`; am Ende steht `Deployment abgeschlossen` (oder eine Zeile mit `ERROR:`).
|
||||||
|
|
||||||
Ohne `AIRCARD_WEBHOOK_TOKEN` (also ohne `GITEA_DEPLOY_WEBHOOK_SECRET` in der `.env`) beendet sich der Webhook-Server mit Exit-Code 1, statt Anfragen ungeprüft durchzulassen.
|
Ohne `AIRCARD_WEBHOOK_TOKEN` (also ohne `GITEA_DEPLOY_WEBHOOK_SECRET` in der `.env`) beendet sich der Webhook-Server mit Exit-Code 1, statt Anfragen ungeprüft durchzulassen. Ebenso beendet sich `aircard-api` mit Exit-Code 1, wenn `AIRCARD_API_KEY`, `AIRCARD_API_SALT` oder `AIRCARD_APPWRITE_PROJECT_ID` fehlen (die Meldung nennt nur den Namen der Variable).
|
||||||
|
|
||||||
|
## API-Dienst `aircard-api`
|
||||||
|
|
||||||
|
Kleiner Node-Dienst ohne Abhängigkeiten (`deploy/api/`), Container `webklar-aircard-api`. Er führt alle Schreibzugriffe der Community-Galerie mit dem Appwrite-Server-Key aus (Veröffentlichen, Zurückziehen, Melden mit Auto-Ausblenden ab 3 Meldern, Admin-Sichtbarkeit). Endpunkte, Statuscodes und Betrieb stehen in [api/README.md](api/README.md).
|
||||||
|
|
||||||
|
- **Route**: Traefik-Label am Dienst, Router `aircard-gallery-api` (``Host(`aircard-ios.org`) && PathPrefix(`/api`)``, Priorität 200, Port 8088). Der Name ist absichtlich nicht `aircard-api`: so heißt schon die Appwrite-Route (`/v1`) in `traefik/dynamic/aircard.yml`.
|
||||||
|
- **Prüfen** nach dem Start: `curl -s https://aircard-ios.org/api/gesundheit` liefert `{"ok":true}`; `docker logs webklar-aircard-api` zeigt `listening on :8088`.
|
||||||
|
- **Ändern**: Dateien mit `cp` nach `aircard-ios.org/api/` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-api`.
|
||||||
|
- **Kein Port nach außen**: der Dienst vertraut dem Header `X-Real-Ip` (setzt Traefik) für Rate-Limit und Melder-Hash, deshalb nie `ports:` ergänzen.
|
||||||
|
|
||||||
## Manuell deployen
|
## Manuell deployen
|
||||||
|
|
||||||
@@ -79,12 +96,13 @@ Repo `knso/AirCardIOS` → Einstellungen → Webhooks → Gitea:
|
|||||||
- **Gemerkter Commit**: `build/.git/aircard-last-deployed-commit` enthält den zuletzt **versuchten** Commit, geschrieben vor dem Build. Ein kaputter Build wird deshalb nicht alle 15 Minuten wiederholt; die alte Seite bleibt online, bis ein neuer Commit oder ein Webhook-/manueller Lauf kommt.
|
- **Gemerkter Commit**: `build/.git/aircard-last-deployed-commit` enthält den zuletzt **versuchten** Commit, geschrieben vor dem Build. Ein kaputter Build wird deshalb nicht alle 15 Minuten wiederholt; die alte Seite bleibt online, bis ein neuer Commit oder ein Webhook-/manueller Lauf kommt.
|
||||||
- **Fehler**: Ein fehlgeschlagener Lauf wird mit `[webhook] … deployment failed` protokolliert und beendet den Server nicht. Fehlerzeilen des Skripts beginnen mit `ERROR:`.
|
- **Fehler**: Ein fehlgeschlagener Lauf wird mit `[webhook] … deployment failed` protokolliert und beendet den Server nicht. Fehlerzeilen des Skripts beginnen mit `ERROR:`.
|
||||||
- **Build-Variablen**: `VITE_APPWRITE_ENDPOINT` und `VITE_APPWRITE_PROJECT_ID` kommen aus der Container-Umgebung (compose); `AIRCARD_APPWRITE_PROJECT_ID` wird aus `/home/webklar/apps/.env` gelesen. Vite liest sie direkt.
|
- **Build-Variablen**: `VITE_APPWRITE_ENDPOINT` und `VITE_APPWRITE_PROJECT_ID` kommen aus der Container-Umgebung (compose); `AIRCARD_APPWRITE_PROJECT_ID` wird aus `/home/webklar/apps/.env` gelesen. Vite liest sie direkt.
|
||||||
- **.env-Variablen**: `AIRCARD_APPWRITE_PROJECT_ID` (Projekt-ID `6ac2130a00289343d6d5`) und `AIRCARD_APPWRITE_API_KEY` (für das Setup-Skript) müssen in `/home/webklar/apps/.env` eingetragen sein.
|
- **.env-Variablen**: `AIRCARD_APPWRITE_PROJECT_ID` (Projekt-ID `6ac2130a00289343d6d5`) und `AIRCARD_APPWRITE_API_KEY` (für das Setup-Skript) müssen in `/home/webklar/apps/.env` eingetragen sein; für `aircard-api` zusätzlich `AIRCARD_API_KEY` (Key `aircard-api`) und `AIRCARD_API_SALT`.
|
||||||
|
|
||||||
## Änderungen später
|
## Änderungen später
|
||||||
|
|
||||||
- `deploy.sh` ändern: Datei auf dem Server mit `cp` überschreiben (nicht `mv`, sonst zeigt der Datei-Mount auf die alte Datei). Gilt beim nächsten Lauf.
|
- `deploy.sh` ändern: Datei auf dem Server mit `cp` überschreiben (nicht `mv`, sonst zeigt der Datei-Mount auf die alte Datei). Gilt beim nächsten Lauf.
|
||||||
- `webhook-server.js` ändern: ebenso mit `cp` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-build`.
|
- `webhook-server.js` ändern: ebenso mit `cp` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-build`.
|
||||||
|
- `deploy/api/*.js` ändern: ebenso mit `cp` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-api`.
|
||||||
- nginx-Konfiguration ändern: mit `cp` ersetzen, dann `docker exec webklar-aircard nginx -t && docker exec webklar-aircard nginx -s reload`.
|
- nginx-Konfiguration ändern: mit `cp` ersetzen, dann `docker exec webklar-aircard nginx -t && docker exec webklar-aircard nginx -s reload`.
|
||||||
- Traefik-Route ändern: Datei atomar ersetzen wie oben; Löschen der Datei entfernt die Route wieder.
|
- Traefik-Route ändern: Datei atomar ersetzen wie oben; Löschen der Datei entfernt die Route wieder.
|
||||||
|
|
||||||
@@ -95,7 +113,8 @@ Solange `dig +short www.aircard-ios.org @8.8.8.8` nichts liefert, gibt es nur di
|
|||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
node --test deploy/webhook-server.test.js
|
node --test deploy/webhook-server.test.js deploy/deploy.test.js
|
||||||
|
node --test "deploy/api/*.test.js"
|
||||||
```
|
```
|
||||||
|
|
||||||
Die Tests brauchen nur Node und starten die Server auf Port 0.
|
Die Tests brauchen nur Node und starten die Server auf Port 0. Die API-Tests laufen gegen einen Fake-Appwrite im Speicher (`deploy/api/fake-appwrite.js`); das echte Appwrite wird nie angesprochen. Das Glob muss in Anführungszeichen stehen.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# aircard-api
|
||||||
|
|
||||||
|
Kleiner HTTP-Dienst (Node 22, **keine Abhängigkeiten**) für die Community-Galerie von aircard-ios.org. Er führt alle Schreibzugriffe mit dem Appwrite-Server-Key aus: Veröffentlichen, Zurückziehen, Melden (Auto-Ausblenden ab 3 verschiedenen Meldern) und die Admin-Sichtbarkeit. Die Website liest die Galerie direkt aus Appwrite; nur das Schreiben läuft hierüber.
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-10-04-aircard-community-design.md` §7, §8, §11. Deployment: [../README.md](../README.md).
|
||||||
|
|
||||||
|
## Dateien
|
||||||
|
|
||||||
|
| Datei | Aufgabe |
|
||||||
|
|---|---|
|
||||||
|
| `server.js` | Start: liest die Umgebung, `http.createServer`, Zeitlimits, sauberes Beenden |
|
||||||
|
| `app.js` | Routen, Validierung, Rechte-Strings, Rate-Limit, Logik (`createApp`) |
|
||||||
|
| `appwrite.js` | REST-Client mit Server-Key (`createAppwrite`) |
|
||||||
|
| `*.test.js`, `fake-appwrite.js` | Tests und Fake-Appwrite im Speicher (gehören nicht auf den Server) |
|
||||||
|
|
||||||
|
Auf den Server (`/home/webklar/apps/aircard-ios.org/api/`) kommen nur `server.js`, `app.js` und `appwrite.js`.
|
||||||
|
|
||||||
|
## Umgebung
|
||||||
|
|
||||||
|
| Variable | Standard | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `AIRCARD_API_KEY` | – (Pflicht) | Appwrite-Server-Key `aircard-api` (Scopes `rows.read`, `rows.write`, `files.read`, `files.write`) |
|
||||||
|
| `AIRCARD_API_SALT` | – (Pflicht) | Salz für die Melder-Hashes, 32 Byte hex |
|
||||||
|
| `AIRCARD_APPWRITE_PROJECT_ID` | – (Pflicht) | Projekt-ID (`6ac2130a00289343d6d5`) |
|
||||||
|
| `AIRCARD_APPWRITE_ENDPOINT` | `http://webklar-appwrite/v1` | interne Appwrite-Adresse im Docker-Netz |
|
||||||
|
| `AIRCARD_API_PORT` | `8088` | Port des Dienstes |
|
||||||
|
|
||||||
|
Fehlt eine Pflichtvariable, nennt die Meldung nur ihren Namen und der Prozess endet mit Exit-Code 1. Wer das Salz ändert, macht alle bisherigen Melder-Hashes ungültig: dieselben Personen können dieselbe Karte dann nochmal melden (die Karten selbst bleiben unberührt).
|
||||||
|
|
||||||
|
## Endpunkte
|
||||||
|
|
||||||
|
Alle Antworten sind JSON; Fehler haben die Form `{"error": "<deutscher Text>"}`. Das Nutzer-JWT kommt im Header `X-Aircard-Jwt` und wird bei Appwrite (`GET /account`) geprüft; Admin = Label `admin`. Alles andere (auch falsche Methoden) ist `404`; `POST` nur mit `Content-Type: application/json` (sonst `415`), Body höchstens 8 KB (sonst `413`).
|
||||||
|
|
||||||
|
| Endpunkt | Auth | Antworten |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST /api/veroeffentlichen` `{title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId}` | Nutzer-JWT | `201 {id}` · `401` · `400` Validierung, Datei fehlt („Bild nicht gefunden.“), falsches Format oder > 5 MiB · `403` Datei nicht meine / falscher Bucket · `409` „Diese Karte ist schon veröffentlicht.“ |
|
||||||
|
| `POST /api/zurueckziehen` `{id}` | Nutzer-JWT (Autor oder Admin) | `200 {ok:true}` · `401` · `400` · `403` fremd · `404` fehlt |
|
||||||
|
| `POST /api/melden` `{id, reason}` | keine | `200 {ok:true}` oder `200 {schonGemeldet:true}` · `400` Grund oder ID · `404` fehlt oder ausgeblendet · `429` „Zu viele Meldungen – bitte später nochmal.“ (mit `Retry-After`) |
|
||||||
|
| `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`.
|
||||||
|
|
||||||
|
### 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.“
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node --test "deploy/api/*.test.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Tests starten den Dienst auf Port 0 gegen einen Fake-Appwrite im Speicher (`fake-appwrite.js`, mit den Spaltenlängen der echten Tabellen) und greifen nie auf das echte Appwrite zu.
|
||||||
@@ -0,0 +1,650 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routen und Logik des API-Dienstes `aircard-api` (Node 22, nur Bordmittel). Alle Schreibzugriffe auf die
|
||||||
|
* 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
|
||||||
|
* 409 schon veröffentlicht · 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}
|
||||||
|
* 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}
|
||||||
|
* GET /api/gesundheit {ok:true}
|
||||||
|
* 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`.
|
||||||
|
* Reihenfolge je Anfrage: Content-Type (415) -> Body lesen (413) -> JWT (401) -> JSON und Felder (400) -> Logik.
|
||||||
|
* Fehlerantworten sind immer {error: <deutscher Text>}.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
const net = require('node:net');
|
||||||
|
const { AppwriteError, queryEqual, queryLimit } = require('./appwrite.js');
|
||||||
|
|
||||||
|
const TABLE_GALLERY = 'gallery';
|
||||||
|
const TABLE_REPORTS = 'reports';
|
||||||
|
const BUCKET = 'gallery-images';
|
||||||
|
|
||||||
|
const MAX_BODY_BYTES = 8 * 1024;
|
||||||
|
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 REPORT_DELETE_ROUNDS = 50;
|
||||||
|
const RATE_LIMIT = 10;
|
||||||
|
const RATE_WINDOW_MS = 60 * 60 * 1000;
|
||||||
|
const RATE_SWEEP_MS = 60 * 1000;
|
||||||
|
const RATE_MAX_KEYS = 50_000;
|
||||||
|
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]/;
|
||||||
|
const JWT_CHARACTERS = /^[\x21-\x7e]+$/;
|
||||||
|
|
||||||
|
const MSG = {
|
||||||
|
unauthorized: 'Bitte melde dich an.',
|
||||||
|
notFound: 'Nicht gefunden.',
|
||||||
|
cardGone: 'Diese Karte ist nicht (mehr) verfügbar.',
|
||||||
|
forbidden: 'Dazu hast du keine Berechtigung.',
|
||||||
|
adminOnly: 'Nur für Admins.',
|
||||||
|
foreignFile: 'Diese Datei gehört dir nicht.',
|
||||||
|
fileMissing: 'Bild nicht gefunden.',
|
||||||
|
fileType: 'Das Bild muss eine PNG- oder WebP-Datei sein.',
|
||||||
|
fileSize: 'Das Bild ist größer als 5 MB.',
|
||||||
|
alreadyPublished: 'Diese Karte ist schon veröffentlicht.',
|
||||||
|
tooManyReports: 'Zu viele Meldungen – bitte später nochmal.',
|
||||||
|
badRequest: 'Ungültige Anfrage.',
|
||||||
|
badCardId: 'Ungültige Karten-ID.',
|
||||||
|
badReason: 'Bitte wähle einen gültigen Grund.',
|
||||||
|
badVisibility: 'Ungültige Angabe zur Sichtbarkeit.',
|
||||||
|
tooLarge: 'Die Anfrage ist zu groß.',
|
||||||
|
jsonOnly: 'Nur application/json wird unterstützt.',
|
||||||
|
unavailable: 'Der Dienst ist gerade nicht erreichbar.',
|
||||||
|
internal: 'Interner Fehler.',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Reine Helfer ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function isId(value) {
|
||||||
|
return typeof value === 'string' && ID_PATTERN.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertOwner(owner) {
|
||||||
|
if (!isId(owner)) throw new TypeError('Ungültige Nutzer-ID');
|
||||||
|
return owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sichtbare Galerie-Zeile: lesen darf jeder. */
|
||||||
|
function visibleRowPermissions() {
|
||||||
|
return ['read("any")'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ausgeblendete Zeile: nur Autor und Admins. */
|
||||||
|
function hiddenRowPermissions(owner) {
|
||||||
|
assertOwner(owner);
|
||||||
|
return [`read("user:${owner}")`, 'read("label:admin")'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleFilePermissions(owner) {
|
||||||
|
assertOwner(owner);
|
||||||
|
return ['read("any")', `delete("user:${owner}")`];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hiddenFilePermissions(owner) {
|
||||||
|
assertOwner(owner);
|
||||||
|
return [`read("user:${owner}")`, 'read("label:admin")', `delete("user:${owner}")`];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hash32(text) {
|
||||||
|
return crypto.createHash('sha256').update(text).digest('hex').slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feste Zeilen-ID je Autor und private Karte: Appwrite-409 ersetzt jede Doppelt-Prüfung (auch bei Gleichzeitigkeit). */
|
||||||
|
function galleryRowId(ownerId, sourceCardId) {
|
||||||
|
return hash32(`${ownerId}|${sourceCardId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feste Zeilen-ID je Karte und Melder: eine Meldung pro Melder und Karte. */
|
||||||
|
function reportRowId(galleryId, hash) {
|
||||||
|
return hash32(`${galleryId}|${hash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandIpv6(address) {
|
||||||
|
let text = address;
|
||||||
|
const lastColon = text.lastIndexOf(':');
|
||||||
|
const tail = text.slice(lastColon + 1);
|
||||||
|
if (tail.includes('.')) {
|
||||||
|
const o = tail.split('.').map(Number);
|
||||||
|
text = `${text.slice(0, lastColon + 1)}${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`;
|
||||||
|
}
|
||||||
|
const [head, rest] = text.split('::');
|
||||||
|
const headGroups = head === '' ? [] : head.split(':');
|
||||||
|
const restGroups = rest === undefined || rest === '' ? [] : rest.split(':');
|
||||||
|
const groups = rest === undefined ? headGroups : [...headGroups, ...Array(8 - headGroups.length - restGroups.length).fill('0'), ...restGroups];
|
||||||
|
return groups.map((group) => Number.parseInt(group, 16));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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).
|
||||||
|
*/
|
||||||
|
function normalizeIp(ip) {
|
||||||
|
if (typeof ip !== 'string') return '';
|
||||||
|
let text = ip.trim();
|
||||||
|
if (text.startsWith('[') && text.endsWith(']')) text = text.slice(1, -1);
|
||||||
|
const zone = text.indexOf('%');
|
||||||
|
if (zone !== -1) text = text.slice(0, zone);
|
||||||
|
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.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 `${groups.slice(0, 4).map((group) => group.toString(16)).join(':')}::/64`;
|
||||||
|
}
|
||||||
|
return text.toLowerCase().slice(0, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstHeaderValue(req, name) {
|
||||||
|
const value = req.headers && req.headers[name];
|
||||||
|
const text = Array.isArray(value) ? value[0] : value;
|
||||||
|
if (typeof text !== 'string') return '';
|
||||||
|
return text.split(',')[0].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Echte Client-IP: X-Real-Ip (setzt Traefik), sonst erster X-Forwarded-For-Eintrag, sonst Socket. */
|
||||||
|
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 reporterHash(salt, ip) {
|
||||||
|
return crypto.createHmac('sha256', salt).update(normalizeIp(ip)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value) {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEXT_FIELDS = [
|
||||||
|
{ key: 'title', article: 'Der', name: 'Titel', max: 60, required: true },
|
||||||
|
{ key: 'issuer', article: 'Der', name: 'Herausgeber', max: 40, required: false },
|
||||||
|
{ key: 'displayName', article: 'Der', name: 'Anzeigename', max: 30, required: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
function readText(raw, field) {
|
||||||
|
if (raw === undefined || raw === null) {
|
||||||
|
return field.required ? { message: 'Bitte gib einen Titel an.' } : { value: '' };
|
||||||
|
}
|
||||||
|
if (typeof raw !== 'string') return { message: `${field.article} ${field.name} ist ungültig.` };
|
||||||
|
const value = raw.trim();
|
||||||
|
if (value === '' && field.required) return { message: 'Bitte gib einen Titel an.' };
|
||||||
|
if (CONTROL_CHARACTERS.test(value)) return { message: `${field.article} ${field.name} enthält ungültige Zeichen.` };
|
||||||
|
if (value.length > field.max) return { message: `${field.article} ${field.name} ist zu lang (höchstens ${field.max} Zeichen).` };
|
||||||
|
return { value };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tags: string[], je getrimmt und klein, Leere raus, Doppelte raus, höchstens 5 mit je höchstens 20 Zeichen. */
|
||||||
|
function readTags(raw) {
|
||||||
|
if (raw === undefined || raw === null) return { tags: [] };
|
||||||
|
if (!Array.isArray(raw)) return { message: 'Tags müssen als Liste gesendet werden.' };
|
||||||
|
const tags = [];
|
||||||
|
for (const entry of raw) {
|
||||||
|
if (typeof entry !== 'string') return { message: 'Ungültige Tags.' };
|
||||||
|
const tag = entry.trim().toLowerCase();
|
||||||
|
if (tag === '') continue;
|
||||||
|
if (tag.includes(',')) return { message: 'Tags dürfen kein Komma enthalten.' };
|
||||||
|
if (CONTROL_CHARACTERS.test(tag)) return { message: 'Tags enthalten ungültige Zeichen.' };
|
||||||
|
if (tag.length > 20) return { message: 'Ein Tag darf höchstens 20 Zeichen lang sein.' };
|
||||||
|
if (!tags.includes(tag)) tags.push(tag);
|
||||||
|
}
|
||||||
|
if (tags.length > 5) return { message: 'Bitte gib höchstens 5 Tags an.' };
|
||||||
|
return { tags };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft den Veröffentlichen-Body (Längen aus Spec §6, IDs nach ID_PATTERN).
|
||||||
|
* @returns {{ok: true, data: object} | {ok: false, message: string}}
|
||||||
|
*/
|
||||||
|
function validatePublish(body) {
|
||||||
|
if (!isPlainObject(body)) return { ok: false, message: MSG.badRequest };
|
||||||
|
|
||||||
|
const data = {};
|
||||||
|
for (const field of TEXT_FIELDS) {
|
||||||
|
const result = readText(body[field.key], field);
|
||||||
|
if (result.message) return { ok: false, message: result.message };
|
||||||
|
data[field.key] = result.value;
|
||||||
|
}
|
||||||
|
const tags = readTags(body.tags);
|
||||||
|
if (tags.message) return { ok: false, message: tags.message };
|
||||||
|
data.tags = tags.tags;
|
||||||
|
|
||||||
|
for (const [key, message] of [
|
||||||
|
['imageFileId', 'Ungültige Bild-ID.'],
|
||||||
|
['previewFileId', 'Ungültige Vorschau-ID.'],
|
||||||
|
['sourceCardId', MSG.badCardId],
|
||||||
|
]) {
|
||||||
|
if (!isId(body[key])) return { ok: false, message };
|
||||||
|
data[key] = body[key];
|
||||||
|
}
|
||||||
|
if (data.imageFileId === data.previewFileId) {
|
||||||
|
return { ok: false, message: 'Bild und Vorschau müssen verschiedene Dateien sein.' };
|
||||||
|
}
|
||||||
|
return { ok: true, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `title issuer tags` klein mit Leerzeichen, höchstens 300 Zeichen (Volltext-Index der Galerie). */
|
||||||
|
function buildSearchText({ title = '', issuer = '', tags = [] }) {
|
||||||
|
const tagList = Array.isArray(tags) ? tags : String(tags).split(',');
|
||||||
|
return [title, issuer, ...tagList]
|
||||||
|
.map((part) => String(part).trim())
|
||||||
|
.filter((part) => part !== '')
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.slice(0, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- HTTP-Hilfen ------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class HttpError extends Error {
|
||||||
|
constructor(status, message, headers = {}) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
this.headers = headers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res, status, payload, headers = {}) {
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
res.writeHead(status, {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Content-Length': Buffer.byteLength(body),
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
...headers,
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const JSON_CONTENT_TYPE = /^application\/json\s*(;|$)/i;
|
||||||
|
|
||||||
|
/** Liest den Rohbody mit Obergrenze; bei Überschreitung wird der Rest verworfen und die Verbindung geschlossen. */
|
||||||
|
function readRawBody(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tooLarge = () => new HttpError(413, MSG.tooLarge, { Connection: 'close' });
|
||||||
|
const declared = Number(req.headers['content-length']);
|
||||||
|
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
||||||
|
req.resume();
|
||||||
|
reject(tooLarge());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
let size = 0;
|
||||||
|
let settled = false;
|
||||||
|
const settle = (fn, value) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
chunks.length = 0;
|
||||||
|
fn(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
if (settled) return; // Rest verwerfen, Verbindung bleibt sauber
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > MAX_BODY_BYTES) settle(reject, tooLarge());
|
||||||
|
else chunks.push(chunk);
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
if (settled) return;
|
||||||
|
const raw = Buffer.concat(chunks).toString('utf8');
|
||||||
|
settle(resolve, raw);
|
||||||
|
});
|
||||||
|
req.on('error', () => settle(reject, new HttpError(400, MSG.badRequest)));
|
||||||
|
req.on('close', () => settle(reject, new HttpError(400, MSG.badRequest)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonObject(raw) {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
throw new HttpError(400, MSG.badRequest);
|
||||||
|
}
|
||||||
|
if (!isPlainObject(parsed)) throw new HttpError(400, MSG.badRequest);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Je Schlüssel nacheinander abarbeiten (z. B. je Karten-ID), damit Zähler und Rechte nie durcheinanderlaufen. */
|
||||||
|
function createKeyedLock() {
|
||||||
|
const tails = new Map();
|
||||||
|
return async function withLock(key, fn) {
|
||||||
|
const previous = tails.get(key) || Promise.resolve();
|
||||||
|
let release;
|
||||||
|
const gate = new Promise((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
const tail = previous.then(() => gate);
|
||||||
|
tails.set(key, tail);
|
||||||
|
await previous;
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
if (tails.get(key) === tail) tails.delete(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDuplicate(err) {
|
||||||
|
return err instanceof AppwriteError && err.status === 409;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Die App ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} options
|
||||||
|
* @param {object} options.appwrite Objekt aus createAppwrite
|
||||||
|
* @param {string} options.salt Salz für die Melder-Hashes
|
||||||
|
* @param {() => number} [options.now] Uhr in ms (für das Rate-Limit)
|
||||||
|
* @param {(line: string) => void} [options.log] bekommt fertige Zeilen mit Präfix [aircard-api]
|
||||||
|
* @returns {(req, res) => Promise<void>} Handler für http.createServer, mit `rateLimitEntries()` für Tests
|
||||||
|
*/
|
||||||
|
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}`);
|
||||||
|
const withLock = createKeyedLock();
|
||||||
|
|
||||||
|
// ---- Rate-Limit je Melder-Hash (im Speicher, geht bei Neustart verloren; Dedup liegt in `reports`)
|
||||||
|
const hits = new Map();
|
||||||
|
let lastSweep = 0;
|
||||||
|
|
||||||
|
function sweepRateLimit(t) {
|
||||||
|
if (t - lastSweep < RATE_SWEEP_MS) return;
|
||||||
|
lastSweep = t;
|
||||||
|
for (const [key, list] of hits) {
|
||||||
|
if (t - list[list.length - 1] >= RATE_WINDOW_MS) hits.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {{allowed: boolean, retryAfter?: number}} zählt die Meldung mit, wenn erlaubt */
|
||||||
|
function rateLimit(key) {
|
||||||
|
const t = now();
|
||||||
|
sweepRateLimit(t);
|
||||||
|
const list = (hits.get(key) || []).filter((stamp) => t - stamp < RATE_WINDOW_MS);
|
||||||
|
if (list.length >= RATE_LIMIT) {
|
||||||
|
hits.set(key, list);
|
||||||
|
return { allowed: false, retryAfter: Math.max(1, Math.ceil((list[0] + RATE_WINDOW_MS - t) / 1000)) };
|
||||||
|
}
|
||||||
|
list.push(t);
|
||||||
|
hits.delete(key); // neu einfügen: Reihenfolge = zuletzt benutzt
|
||||||
|
hits.set(key, list);
|
||||||
|
while (hits.size > RATE_MAX_KEYS) hits.delete(hits.keys().next().value);
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Anmeldung
|
||||||
|
async function authenticate(req) {
|
||||||
|
const jwt = req.headers['x-aircard-jwt'];
|
||||||
|
if (typeof jwt !== 'string' || jwt.length > 4096 || !JWT_CHARACTERS.test(jwt)) return null;
|
||||||
|
const account = await appwrite.accountFromJwt(jwt);
|
||||||
|
if (!account || !isId(account.$id)) return null;
|
||||||
|
return { id: account.$id, isAdmin: Array.isArray(account.labels) && account.labels.includes('admin') };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireUser(req) {
|
||||||
|
const user = await authenticate(req);
|
||||||
|
if (!user) throw new HttpError(401, MSG.unauthorized);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Dateien und Zeilen
|
||||||
|
function checkFile(file, userId) {
|
||||||
|
if (!file) throw new HttpError(400, MSG.fileMissing);
|
||||||
|
const permissions = Array.isArray(file.$permissions) ? file.$permissions : [];
|
||||||
|
if (file.bucketId !== BUCKET || !permissions.includes(`delete("user:${userId}")`)) {
|
||||||
|
throw new HttpError(403, MSG.foreignFile);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileIdsOf = (row) => [row.imageFileId, row.previewFileId].filter(isId);
|
||||||
|
|
||||||
|
async function setFilePermissions(row, permissions) {
|
||||||
|
await Promise.all(fileIdsOf(row).map((fileId) => appwrite.updateFilePermissions(BUCKET, fileId, permissions)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dateien zuerst, Zeile zuletzt: bleibt etwas hängen, heilt der nächste Versuch (alles ist wiederholbar). */
|
||||||
|
async function hideCard(id, row, extra = {}) {
|
||||||
|
await setFilePermissions(row, hiddenFilePermissions(row.ownerId));
|
||||||
|
await appwrite.updateRow(TABLE_GALLERY, id, { hidden: true, ...extra }, hiddenRowPermissions(row.ownerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteReports(galleryId) {
|
||||||
|
for (let round = 0; round < REPORT_DELETE_ROUNDS; round += 1) {
|
||||||
|
const reports = await appwrite.listRows(TABLE_REPORTS, [queryEqual('galleryId', [galleryId]), queryLimit(REPORT_LIST_LIMIT)]);
|
||||||
|
if (reports.length === 0) return;
|
||||||
|
for (const report of reports) await appwrite.deleteRow(TABLE_REPORTS, report.$id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** reportCount = Anzahl Meldungszeilen der Karte; ab 3 wird ausgeblendet. */
|
||||||
|
async function syncReports(id, row) {
|
||||||
|
const reports = await appwrite.listRows(TABLE_REPORTS, [queryEqual('galleryId', [id]), queryLimit(REPORT_LIST_LIMIT)]);
|
||||||
|
const count = reports.length;
|
||||||
|
if (count >= HIDE_AT_REPORTS) {
|
||||||
|
await hideCard(id, row, { reportCount: count });
|
||||||
|
say(`card ${id} hidden automatically (${count} reports)`);
|
||||||
|
} else if (row.reportCount !== count) {
|
||||||
|
await appwrite.updateRow(TABLE_GALLERY, id, { reportCount: count });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Endpunkte
|
||||||
|
async function publish({ req, raw }) {
|
||||||
|
const user = await requireUser(req);
|
||||||
|
const validation = validatePublish(parseJsonObject(raw));
|
||||||
|
if (!validation.ok) throw new HttpError(400, validation.message);
|
||||||
|
const data = validation.data;
|
||||||
|
|
||||||
|
const [image, preview] = await Promise.all([
|
||||||
|
appwrite.getFile(BUCKET, data.imageFileId),
|
||||||
|
appwrite.getFile(BUCKET, data.previewFileId),
|
||||||
|
]);
|
||||||
|
checkFile(image, user.id);
|
||||||
|
checkFile(preview, user.id);
|
||||||
|
|
||||||
|
const id = galleryRowId(user.id, data.sourceCardId);
|
||||||
|
try {
|
||||||
|
await appwrite.createRow(
|
||||||
|
TABLE_GALLERY,
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
title: data.title,
|
||||||
|
issuer: data.issuer,
|
||||||
|
tags: data.tags.join(','),
|
||||||
|
displayName: data.displayName,
|
||||||
|
imageFileId: data.imageFileId,
|
||||||
|
previewFileId: data.previewFileId,
|
||||||
|
sourceCardId: data.sourceCardId,
|
||||||
|
ownerId: user.id,
|
||||||
|
hidden: false,
|
||||||
|
reportCount: 0,
|
||||||
|
searchText: buildSearchText(data),
|
||||||
|
},
|
||||||
|
visibleRowPermissions(),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (isDuplicate(err)) throw new HttpError(409, MSG.alreadyPublished);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
say(`card ${id} published`);
|
||||||
|
return { status: 201, body: { id } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withdraw({ req, raw }) {
|
||||||
|
const user = await requireUser(req);
|
||||||
|
const body = parseJsonObject(raw);
|
||||||
|
if (!isId(body.id)) throw new HttpError(400, MSG.badCardId);
|
||||||
|
const id = body.id;
|
||||||
|
|
||||||
|
return withLock(id, async () => {
|
||||||
|
const row = await appwrite.getRow(TABLE_GALLERY, id);
|
||||||
|
if (!row) throw new HttpError(404, MSG.cardGone);
|
||||||
|
if (row.ownerId !== user.id && !user.isAdmin) throw new HttpError(403, MSG.forbidden);
|
||||||
|
|
||||||
|
// Zeile zuletzt: scheitert etwas, bleibt sie als Anker für die Wiederholung stehen.
|
||||||
|
for (const fileId of fileIdsOf(row)) await appwrite.deleteFile(BUCKET, fileId);
|
||||||
|
await deleteReports(id);
|
||||||
|
await appwrite.deleteRow(TABLE_GALLERY, id);
|
||||||
|
say(`card ${id} withdrawn${row.ownerId === user.id ? '' : ' by admin'}`);
|
||||||
|
return { status: 200, body: { ok: true } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reportCard({ req, raw }) {
|
||||||
|
const body = parseJsonObject(raw);
|
||||||
|
if (!isId(body.id)) throw new HttpError(400, MSG.badCardId);
|
||||||
|
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);
|
||||||
|
if (!limit.allowed) throw new HttpError(429, MSG.tooManyReports, { 'Retry-After': String(limit.retryAfter) });
|
||||||
|
|
||||||
|
return withLock(id, async () => {
|
||||||
|
const row = await appwrite.getRow(TABLE_GALLERY, id);
|
||||||
|
if (!row || row.hidden === true) throw new HttpError(404, MSG.cardGone);
|
||||||
|
|
||||||
|
let isNew = true;
|
||||||
|
try {
|
||||||
|
await appwrite.createRow(TABLE_REPORTS, reportRowId(id, reporter), { galleryId: id, reason: body.reason, reporterHash: reporter }, []);
|
||||||
|
} catch (err) {
|
||||||
|
if (!isDuplicate(err)) throw err;
|
||||||
|
isNew = false;
|
||||||
|
}
|
||||||
|
// Auch bei einer doppelten Meldung neu zählen: heilt einen früher steckengebliebenen Zählerstand.
|
||||||
|
await syncReports(id, row);
|
||||||
|
if (isNew) say(`card ${id} reported (${body.reason})`);
|
||||||
|
return { status: 200, body: isNew ? { ok: true } : { schonGemeldet: true } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setVisibility({ req, raw }) {
|
||||||
|
const user = await requireUser(req);
|
||||||
|
if (!user.isAdmin) throw new HttpError(403, MSG.adminOnly);
|
||||||
|
const body = parseJsonObject(raw);
|
||||||
|
if (!isId(body.id)) throw new HttpError(400, MSG.badCardId);
|
||||||
|
if (typeof body.sichtbar !== 'boolean') throw new HttpError(400, MSG.badVisibility);
|
||||||
|
const id = body.id;
|
||||||
|
|
||||||
|
return withLock(id, async () => {
|
||||||
|
const row = await appwrite.getRow(TABLE_GALLERY, id);
|
||||||
|
if (!row) throw new HttpError(404, MSG.cardGone);
|
||||||
|
|
||||||
|
if (body.sichtbar) {
|
||||||
|
// Dateien, dann Meldungen, Zeile zuletzt: sichtbar wird die Karte erst, wenn alles andere steht.
|
||||||
|
await setFilePermissions(row, visibleFilePermissions(row.ownerId));
|
||||||
|
await deleteReports(id);
|
||||||
|
await appwrite.updateRow(TABLE_GALLERY, id, { hidden: false, reportCount: 0 }, visibleRowPermissions());
|
||||||
|
say(`card ${id} made visible by admin`);
|
||||||
|
} else {
|
||||||
|
await hideCard(id, row);
|
||||||
|
say(`card ${id} hidden by admin`);
|
||||||
|
}
|
||||||
|
return { status: 200, body: { ok: true } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROUTES = new Map([
|
||||||
|
['GET /api/gesundheit', { name: 'gesundheit', run: async () => ({ status: 200, body: { ok: true } }) }],
|
||||||
|
['POST /api/veroeffentlichen', { name: 'veroeffentlichen', json: true, run: publish }],
|
||||||
|
['POST /api/zurueckziehen', { name: 'zurueckziehen', json: true, run: withdraw }],
|
||||||
|
['POST /api/melden', { name: 'melden', json: true, run: reportCard }],
|
||||||
|
['POST /api/admin/sichtbarkeit', { name: 'admin/sichtbarkeit', json: true, run: setVisibility }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!route) throw new HttpError(404, MSG.notFound);
|
||||||
|
|
||||||
|
let raw = '';
|
||||||
|
if (route.json) {
|
||||||
|
if (!JSON_CONTENT_TYPE.test(req.headers['content-type'] || '')) {
|
||||||
|
req.resume();
|
||||||
|
throw new HttpError(415, MSG.jsonOnly);
|
||||||
|
}
|
||||||
|
raw = await readRawBody(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await route.run({ req, raw });
|
||||||
|
status = result.status;
|
||||||
|
sendJson(res, result.status, result.body);
|
||||||
|
} catch (err) {
|
||||||
|
let headers = {};
|
||||||
|
let message = MSG.internal;
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
status = err.status;
|
||||||
|
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)}`);
|
||||||
|
} else {
|
||||||
|
status = 500;
|
||||||
|
say(`unexpected error: ${err && err.name ? String(err.name).replace(/[^\w.-]/g, '').slice(0, 60) : 'unknown'}`);
|
||||||
|
}
|
||||||
|
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'}`);
|
||||||
|
try {
|
||||||
|
res.destroy();
|
||||||
|
} catch {
|
||||||
|
// schon geschlossen
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.rateLimitEntries = () => hits.size;
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createApp,
|
||||||
|
visibleRowPermissions,
|
||||||
|
hiddenRowPermissions,
|
||||||
|
visibleFilePermissions,
|
||||||
|
hiddenFilePermissions,
|
||||||
|
reporterHash,
|
||||||
|
clientIp,
|
||||||
|
normalizeIp,
|
||||||
|
validatePublish,
|
||||||
|
buildSearchText,
|
||||||
|
galleryRowId,
|
||||||
|
reportRowId,
|
||||||
|
};
|
||||||
@@ -0,0 +1,947 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Tests für deploy/api/app.js: echte HTTP-Anfragen auf Port 0 gegen createApp, dahinter ein Fake-Appwrite im
|
||||||
|
// Speicher (fake-appwrite.js) und das echte appwrite.js. Aufruf: node --test "deploy/api/*.test.js"
|
||||||
|
|
||||||
|
const { test, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
const http = require('node:http');
|
||||||
|
|
||||||
|
const { createAppwrite } = require('./appwrite.js');
|
||||||
|
const { startFakeAppwrite } = require('./fake-appwrite.js');
|
||||||
|
const {
|
||||||
|
createApp,
|
||||||
|
visibleRowPermissions,
|
||||||
|
hiddenRowPermissions,
|
||||||
|
visibleFilePermissions,
|
||||||
|
hiddenFilePermissions,
|
||||||
|
reporterHash,
|
||||||
|
clientIp,
|
||||||
|
normalizeIp,
|
||||||
|
validatePublish,
|
||||||
|
buildSearchText,
|
||||||
|
galleryRowId,
|
||||||
|
reportRowId,
|
||||||
|
} = require('./app.js');
|
||||||
|
|
||||||
|
const SALT = 'test-salt-0123456789abcdef0123456789abcdef';
|
||||||
|
const ME = 'user-me';
|
||||||
|
const OTHER = 'user-other';
|
||||||
|
const ADMIN = 'user-admin';
|
||||||
|
const JWT_ME = 'eyJhbGciOiJI.payload-me.signature-me';
|
||||||
|
const JWT_OTHER = 'eyJhbGciOiJI.payload-other.signature-other';
|
||||||
|
const JWT_ADMIN = 'eyJhbGciOiJI.payload-admin.signature-admin';
|
||||||
|
const MIB5 = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
// ---- Aufbau ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const cleanups = [];
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const fn of cleanups.splice(0).reverse()) await fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
const fake = await startFakeAppwrite();
|
||||||
|
fake.addUser(JWT_ME, { id: ME });
|
||||||
|
fake.addUser(JWT_OTHER, { id: OTHER });
|
||||||
|
fake.addUser(JWT_ADMIN, { id: ADMIN, labels: ['admin'] });
|
||||||
|
|
||||||
|
const logs = [];
|
||||||
|
const clock = { value: 1_700_000_000_000 };
|
||||||
|
const appwrite = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key });
|
||||||
|
const app = createApp({ appwrite, 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));
|
||||||
|
const { port } = server.address();
|
||||||
|
|
||||||
|
cleanups.push(async () => {
|
||||||
|
server.closeAllConnections();
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
await fake.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = (method, path, options) => request(port, method, path, options);
|
||||||
|
const post = (path, body, jwt, headers = {}) =>
|
||||||
|
call('POST', path, { body, headers: { ...(jwt ? { 'X-Aircard-Jwt': jwt } : {}), ...headers } });
|
||||||
|
const report = (id, ip, reason = 'spam') => post('/api/melden', { id, reason }, null, ip ? { 'X-Real-Ip': ip } : {});
|
||||||
|
|
||||||
|
return { fake, app, logs, clock, port, call, post, report };
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(port, method, path, { headers = {}, body, raw, contentType = 'application/json' } = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const payload = raw !== undefined ? raw : body === undefined ? undefined : JSON.stringify(body);
|
||||||
|
const requestHeaders = { ...headers };
|
||||||
|
if (payload !== undefined && contentType) requestHeaders['Content-Type'] = contentType;
|
||||||
|
if (payload !== undefined && !('Transfer-Encoding' in requestHeaders)) {
|
||||||
|
requestHeaders['Content-Length'] = Buffer.byteLength(payload);
|
||||||
|
}
|
||||||
|
const req = http.request({ host: '127.0.0.1', port, method, path, headers: requestHeaders }, (res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
const text = Buffer.concat(chunks).toString('utf8');
|
||||||
|
let json = null;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
// kein JSON
|
||||||
|
}
|
||||||
|
resolve({ status: res.statusCode, headers: res.headers, text, json });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
if (payload !== undefined) req.write(payload);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishBody(overrides = {}) {
|
||||||
|
return {
|
||||||
|
title: 'Mein Motiv',
|
||||||
|
issuer: 'Beispielbank',
|
||||||
|
tags: ['Blau', ' Neon '],
|
||||||
|
displayName: 'Kenso',
|
||||||
|
imageFileId: 'img1',
|
||||||
|
previewFileId: 'prev1',
|
||||||
|
sourceCardId: 'card1',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedUploads(fake, owner = ME, { image = 'img1', preview = 'prev1' } = {}) {
|
||||||
|
fake.addFile({ id: image, owner });
|
||||||
|
fake.addFile({ id: preview, owner, mimeType: 'image/webp' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legt eine veröffentlichte Karte direkt im Fake an (Zeile + zwei Dateien mit passenden Rechten). */
|
||||||
|
function seedCard(fake, { id = 'card-1', owner = ME, hidden = false, reportCount = 0 } = {}) {
|
||||||
|
const imageFileId = `${id}-img`;
|
||||||
|
const previewFileId = `${id}-prev`;
|
||||||
|
fake.addFile({ id: imageFileId, owner });
|
||||||
|
fake.addFile({ id: previewFileId, owner, mimeType: 'image/webp' });
|
||||||
|
if (hidden) {
|
||||||
|
fake.file(imageFileId).$permissions = hiddenFilePermissions(owner);
|
||||||
|
fake.file(previewFileId).$permissions = hiddenFilePermissions(owner);
|
||||||
|
}
|
||||||
|
fake.addRow(
|
||||||
|
'gallery',
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
title: 'Karte',
|
||||||
|
issuer: '',
|
||||||
|
tags: '',
|
||||||
|
displayName: '',
|
||||||
|
imageFileId,
|
||||||
|
previewFileId,
|
||||||
|
sourceCardId: `src-${id}`,
|
||||||
|
ownerId: owner,
|
||||||
|
hidden,
|
||||||
|
reportCount,
|
||||||
|
searchText: 'karte',
|
||||||
|
},
|
||||||
|
hidden ? hiddenRowPermissions(owner) : visibleRowPermissions(),
|
||||||
|
);
|
||||||
|
return { imageFileId, previewFileId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageCalls = (fake) => fake.calls.filter((c) => c.path.startsWith('/v1/storage/'));
|
||||||
|
const sha32 = (text) => crypto.createHash('sha256').update(text).digest('hex').slice(0, 32);
|
||||||
|
|
||||||
|
// ---- Reine Helfer ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('Rechte-Strings sind genau die aus der Spec', () => {
|
||||||
|
assert.deepEqual(visibleRowPermissions(), ['read("any")']);
|
||||||
|
assert.deepEqual(hiddenRowPermissions('abc'), ['read("user:abc")', 'read("label:admin")']);
|
||||||
|
assert.deepEqual(visibleFilePermissions('abc'), ['read("any")', 'delete("user:abc")']);
|
||||||
|
assert.deepEqual(hiddenFilePermissions('abc'), ['read("user:abc")', 'read("label:admin")', 'delete("user:abc")']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Rechte-Helfer lehnen unzulässige Nutzer-IDs ab (kein Einschleusen in Rechte-Strings)', () => {
|
||||||
|
for (const bad of ['a")', 'x","read("any', '', '../x', 5, null]) {
|
||||||
|
assert.throws(() => hiddenRowPermissions(bad));
|
||||||
|
assert.throws(() => visibleFilePermissions(bad));
|
||||||
|
assert.throws(() => hiddenFilePermissions(bad));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zeilen-IDs sind deterministisch: erste 32 Hex von sha256(a|b)', () => {
|
||||||
|
assert.equal(galleryRowId('u1', 'c1'), sha32('u1|c1'));
|
||||||
|
assert.equal(reportRowId('g1', 'hash1'), sha32('g1|hash1'));
|
||||||
|
assert.match(galleryRowId('u1', 'c1'), /^[0-9a-f]{32}$/);
|
||||||
|
assert.notEqual(galleryRowId('u1', 'c1'), galleryRowId('u2', 'c1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reporterHash ist HMAC-SHA256 (Hex) mit dem Salz; IPv6 wird auf /64 gekürzt', () => {
|
||||||
|
assert.equal(reporterHash(SALT, '203.0.113.7'), crypto.createHmac('sha256', SALT).update('203.0.113.7').digest('hex'));
|
||||||
|
assert.notEqual(reporterHash('anderes-salz', '203.0.113.7'), reporterHash(SALT, '203.0.113.7'));
|
||||||
|
assert.equal(reporterHash(SALT, '2001:db8:abcd:12::1'), reporterHash(SALT, '2001:db8:abcd:12:ffff:eeee:dddd:2'));
|
||||||
|
assert.notEqual(reporterHash(SALT, '2001:db8:abcd:12::1'), reporterHash(SALT, '2001:db8:abcd:13::1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeIp: IPv4 bleibt, IPv4-gemappte IPv6 wird IPv4, sonst /64', () => {
|
||||||
|
assert.equal(normalizeIp('203.0.113.7'), '203.0.113.7');
|
||||||
|
assert.equal(normalizeIp(' 203.0.113.7:5555 '), '203.0.113.7');
|
||||||
|
assert.equal(normalizeIp('::ffff:203.0.113.7'), '203.0.113.7');
|
||||||
|
assert.equal(normalizeIp('::ffff:cb00:7107'), '203.0.113.7');
|
||||||
|
assert.equal(normalizeIp('2001:0DB8:abcd:0012:0000:0000:0000:0001'), '2001:db8:abcd:12::/64');
|
||||||
|
assert.equal(normalizeIp('2001:db8::1'), '2001:db8:0:0::/64');
|
||||||
|
assert.equal(normalizeIp('::1'), '0:0:0:0::/64');
|
||||||
|
assert.equal(normalizeIp('[2001:db8:1:2::9]'), '2001:db8:1:2::/64');
|
||||||
|
assert.equal(normalizeIp('fe80::1%eth0'), 'fe80:0:0:0::/64');
|
||||||
|
assert.equal(normalizeIp('64:ff9b::192.0.2.33'), '64:ff9b:0:0::/64');
|
||||||
|
assert.equal(normalizeIp('kein-ip'), 'kein-ip');
|
||||||
|
assert.equal(normalizeIp(undefined), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clientIp: x-real-ip, sonst erstes x-forwarded-for, sonst Socket', () => {
|
||||||
|
const socket = { remoteAddress: '10.0.0.9' };
|
||||||
|
assert.equal(clientIp({ headers: { 'x-real-ip': '1.1.1.1', 'x-forwarded-for': '2.2.2.2' }, socket }), '1.1.1.1');
|
||||||
|
assert.equal(clientIp({ headers: { 'x-forwarded-for': ' 2.2.2.2 , 3.3.3.3' }, socket }), '2.2.2.2');
|
||||||
|
assert.equal(clientIp({ headers: {}, socket }), '10.0.0.9');
|
||||||
|
assert.equal(clientIp({ headers: { 'x-real-ip': '' }, socket }), '10.0.0.9');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildSearchText: klein, mit Leerzeichen verbunden, höchstens 300 Zeichen', () => {
|
||||||
|
assert.equal(buildSearchText({ title: 'Mein MOTIV', issuer: 'Beispielbank', tags: ['blau', 'neon'] }), 'mein motiv beispielbank blau neon');
|
||||||
|
assert.equal(buildSearchText({ title: 'A', issuer: '', tags: [] }), 'a');
|
||||||
|
assert.equal(buildSearchText({ title: 'A', issuer: 'B', tags: 'x,y' }), 'a b x y');
|
||||||
|
assert.equal(buildSearchText({ title: 'x'.repeat(400), issuer: '', tags: [] }).length, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validatePublish: normalisiert Tags und liefert saubere Daten', () => {
|
||||||
|
const result = validatePublish(publishBody({ title: ' Mein Motiv ', tags: [' Blau ', 'NEON', '', 'blau'], issuer: undefined, displayName: null }));
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.deepEqual(result.data, {
|
||||||
|
title: 'Mein Motiv',
|
||||||
|
issuer: '',
|
||||||
|
tags: ['blau', 'neon'],
|
||||||
|
displayName: '',
|
||||||
|
imageFileId: 'img1',
|
||||||
|
previewFileId: 'prev1',
|
||||||
|
sourceCardId: 'card1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validatePublish: Längen und Tags an den Grenzen', () => {
|
||||||
|
assert.equal(validatePublish(publishBody({ title: 'x'.repeat(60), issuer: 'y'.repeat(40), displayName: 'z'.repeat(30) })).ok, true);
|
||||||
|
assert.equal(validatePublish(publishBody({ tags: ['a', 'b', 'c', 'd', 'e'] })).ok, true);
|
||||||
|
assert.equal(validatePublish(publishBody({ tags: ['x'.repeat(20)] })).ok, true);
|
||||||
|
assert.equal(validatePublish(publishBody({ tags: ['a', 'b', 'c', 'd', 'e', 'f'] })).ok, false);
|
||||||
|
assert.equal(validatePublish(publishBody({ tags: ['x'.repeat(21)] })).ok, false);
|
||||||
|
const bad = validatePublish(publishBody({ title: '' }));
|
||||||
|
assert.equal(bad.ok, false);
|
||||||
|
assert.equal(typeof bad.message, 'string');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Gesundheit, Routing, Body ------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('GET /api/gesundheit antwortet {ok:true} mit sicheren Kopfzeilen', async () => {
|
||||||
|
const { call } = await setup();
|
||||||
|
const res = await call('GET', '/api/gesundheit?x=1');
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual(res.json, { ok: true });
|
||||||
|
assert.match(res.headers['content-type'], /^application\/json/);
|
||||||
|
assert.equal(res.headers['cache-control'], 'no-store');
|
||||||
|
assert.equal(res.headers['x-content-type-options'], 'nosniff');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Alles andere ist 404, auch falsche Methoden', async () => {
|
||||||
|
const { call, fake } = await setup();
|
||||||
|
for (const [method, path] of [
|
||||||
|
['GET', '/'],
|
||||||
|
['GET', '/api'],
|
||||||
|
['GET', '/api/melden'],
|
||||||
|
['POST', '/api/gesundheit'],
|
||||||
|
['POST', '/api/unbekannt'],
|
||||||
|
['POST', '/api/melden/'],
|
||||||
|
['POST', '/api/admin'],
|
||||||
|
['PUT', '/api/melden'],
|
||||||
|
['OPTIONS', '/api/melden'],
|
||||||
|
['DELETE', '/api/zurueckziehen'],
|
||||||
|
['GET', '/v1/account'],
|
||||||
|
]) {
|
||||||
|
const res = await call(method, path, { body: {} });
|
||||||
|
assert.equal(res.status, 404, `${method} ${path}`);
|
||||||
|
assert.equal(typeof res.json.error, 'string');
|
||||||
|
}
|
||||||
|
assert.equal(fake.calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Nur application/json: sonst 415 (auch mit Charset-Zusatz erlaubt)', async () => {
|
||||||
|
const { call, fake } = await setup();
|
||||||
|
seedCard(fake);
|
||||||
|
for (const contentType of ['text/plain', 'application/x-www-form-urlencoded', 'application/jsonx', null]) {
|
||||||
|
const res = await call('POST', '/api/melden', { raw: '{"id":"card-1","reason":"spam"}', contentType });
|
||||||
|
assert.equal(res.status, 415, String(contentType));
|
||||||
|
}
|
||||||
|
const ok = await call('POST', '/api/melden', {
|
||||||
|
raw: '{"id":"card-1","reason":"spam"}',
|
||||||
|
contentType: 'application/json; charset=utf-8',
|
||||||
|
headers: { 'X-Real-Ip': '203.0.113.1' },
|
||||||
|
});
|
||||||
|
assert.equal(ok.status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Body über 8 KB: 413 (mit Content-Length und chunked), genau 8 KB geht noch durch', async () => {
|
||||||
|
const { call, fake } = await setup();
|
||||||
|
const padded = (length) => {
|
||||||
|
const base = '{"id":"nope","reason":"spam"}';
|
||||||
|
return base + ' '.repeat(length - Buffer.byteLength(base));
|
||||||
|
};
|
||||||
|
|
||||||
|
const declared = await call('POST', '/api/melden', { raw: padded(8193) });
|
||||||
|
assert.equal(declared.status, 413);
|
||||||
|
assert.equal(typeof declared.json.error, 'string');
|
||||||
|
assert.equal(declared.headers.connection, 'close', 'nach einem zu großen Body wird die Verbindung geschlossen');
|
||||||
|
|
||||||
|
const chunked = await call('POST', '/api/veroeffentlichen', {
|
||||||
|
raw: padded(9000),
|
||||||
|
headers: { 'Transfer-Encoding': 'chunked', 'X-Aircard-Jwt': JWT_ME },
|
||||||
|
});
|
||||||
|
assert.equal(chunked.status, 413);
|
||||||
|
|
||||||
|
const exact = await call('POST', '/api/melden', { raw: padded(8192), headers: { 'X-Real-Ip': '203.0.113.2' } });
|
||||||
|
assert.equal(exact.status, 404, 'genau 8192 Byte werden verarbeitet (Karte existiert nicht)');
|
||||||
|
assert.equal(fake.calls.filter((c) => c.path.includes('/account')).length, 0, 'bei 413 wurde nichts bei Appwrite abgefragt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Kaputtes JSON, Array oder leerer Body: 400', async () => {
|
||||||
|
const { call } = await setup();
|
||||||
|
for (const raw of ['{kaputt', '[]', '"text"', 'null', '']) {
|
||||||
|
const res = await call('POST', '/api/melden', { raw });
|
||||||
|
assert.equal(res.status, 400, JSON.stringify(raw));
|
||||||
|
assert.equal(typeof res.json.error, 'string');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Veröffentlichen --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('Veröffentlichen: legt eine sichtbare Zeile an (Rechte, searchText, ownerId)', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
seedUploads(fake);
|
||||||
|
|
||||||
|
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||||||
|
assert.equal(res.status, 201);
|
||||||
|
assert.equal(res.json.id, galleryRowId(ME, 'card1'));
|
||||||
|
|
||||||
|
const row = fake.row('gallery', res.json.id);
|
||||||
|
assert.deepEqual(row.$permissions, ['read("any")']);
|
||||||
|
assert.equal(row.title, 'Mein Motiv');
|
||||||
|
assert.equal(row.issuer, 'Beispielbank');
|
||||||
|
assert.equal(row.tags, 'blau,neon');
|
||||||
|
assert.equal(row.displayName, 'Kenso');
|
||||||
|
assert.equal(row.imageFileId, 'img1');
|
||||||
|
assert.equal(row.previewFileId, 'prev1');
|
||||||
|
assert.equal(row.sourceCardId, 'card1');
|
||||||
|
assert.equal(row.ownerId, ME);
|
||||||
|
assert.equal(row.hidden, false);
|
||||||
|
assert.equal(row.reportCount, 0);
|
||||||
|
assert.equal(row.searchText, 'mein motiv beispielbank blau neon');
|
||||||
|
// der Server-Key hängt an jedem Datenaufruf, das JWT nur am Konto-Aufruf
|
||||||
|
const accountCall = fake.calls.find((c) => c.path === '/v1/account');
|
||||||
|
assert.equal(accountCall.headers['x-appwrite-jwt'], JWT_ME);
|
||||||
|
assert.equal(accountCall.headers['x-appwrite-key'], undefined);
|
||||||
|
for (const c of fake.calls.filter((x) => x.path !== '/v1/account')) {
|
||||||
|
assert.equal(c.headers['x-appwrite-key'], fake.key);
|
||||||
|
assert.equal(c.headers['x-appwrite-jwt'], undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Veröffentlichen: Bild und Vorschau dürfen PNG oder WebP sein, Anzeigename und Herausgeber sind optional', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
fake.addFile({ id: 'img1', owner: ME, mimeType: 'image/png', size: MIB5 });
|
||||||
|
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/png' });
|
||||||
|
const res = await post('/api/veroeffentlichen', { title: 'Nur Titel', imageFileId: 'img1', previewFileId: 'prev1', sourceCardId: 'card1' }, JWT_ME);
|
||||||
|
assert.equal(res.status, 201);
|
||||||
|
const row = fake.row('gallery', res.json.id);
|
||||||
|
assert.equal(row.issuer, '');
|
||||||
|
assert.equal(row.tags, '');
|
||||||
|
assert.equal(row.displayName, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Veröffentlichen ohne oder mit ungültigem JWT: 401 und nichts angelegt', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
seedUploads(fake);
|
||||||
|
|
||||||
|
assert.equal((await post('/api/veroeffentlichen', publishBody())).status, 401);
|
||||||
|
assert.equal((await post('/api/veroeffentlichen', publishBody(), 'xxx.yyy.zzz')).status, 401);
|
||||||
|
assert.equal((await post('/api/veroeffentlichen', publishBody(), 'zwei teile')).status, 401);
|
||||||
|
assert.equal((await post('/api/veroeffentlichen', {}, '')).status, 401);
|
||||||
|
assert.equal(fake.rows('gallery').length, 0);
|
||||||
|
assert.equal(storageCalls(fake).length, 0);
|
||||||
|
assert.equal(fake.calls.filter((c) => c.path === '/v1/account').length, 1, 'fehlendes oder kaputtes JWT erreicht Appwrite gar nicht erst');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Veröffentlichen mit fremder Datei: 403 und nichts angelegt', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
fake.addFile({ id: 'img1', owner: OTHER });
|
||||||
|
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' });
|
||||||
|
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||||||
|
assert.equal(res.status, 403);
|
||||||
|
assert.equal(typeof res.json.error, 'string');
|
||||||
|
|
||||||
|
fake.addFile({ id: 'img2', owner: ME });
|
||||||
|
fake.addFile({ id: 'prev2', owner: OTHER, mimeType: 'image/webp' });
|
||||||
|
res = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img2', previewFileId: 'prev2' }), JWT_ME);
|
||||||
|
assert.equal(res.status, 403);
|
||||||
|
|
||||||
|
// Rechte ohne delete("user:me"): nur lesen reicht nicht
|
||||||
|
fake.addFile({ id: 'img3', owner: ME, permissions: ['read("any")', 'read("user:user-me")'] });
|
||||||
|
fake.addFile({ id: 'prev3', owner: ME, mimeType: 'image/webp' });
|
||||||
|
res = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img3', previewFileId: 'prev3' }), JWT_ME);
|
||||||
|
assert.equal(res.status, 403);
|
||||||
|
|
||||||
|
assert.equal(fake.rows('gallery').length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Veröffentlichen mit Datei in falschem Bucket: 403', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
fake.addFile({ id: 'img1', owner: ME, bucket: 'card-images' });
|
||||||
|
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' });
|
||||||
|
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||||||
|
assert.equal(res.status, 403);
|
||||||
|
assert.equal(fake.rows('gallery').length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Veröffentlichen mit fehlender Datei: 400 "Bild nicht gefunden."', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' });
|
||||||
|
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
assert.equal(res.json.error, 'Bild nicht gefunden.');
|
||||||
|
|
||||||
|
fake.addFile({ id: 'img1', owner: ME });
|
||||||
|
res = await post('/api/veroeffentlichen', publishBody({ previewFileId: 'ghost' }), JWT_ME);
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
assert.equal(res.json.error, 'Bild nicht gefunden.');
|
||||||
|
assert.equal(fake.rows('gallery').length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Veröffentlichen: nur PNG/WebP bis 5 MiB, sonst 400', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
const attempt = (image, preview) => {
|
||||||
|
fake.addFile({ id: 'img1', owner: ME, ...image });
|
||||||
|
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', ...preview });
|
||||||
|
return post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal((await attempt({ mimeType: 'image/jpeg' }, {})).status, 400);
|
||||||
|
assert.equal((await attempt({ mimeType: 'application/pdf' }, {})).status, 400);
|
||||||
|
assert.equal((await attempt({}, { mimeType: 'image/svg+xml' })).status, 400);
|
||||||
|
assert.equal((await attempt({ size: MIB5 + 1 }, {})).status, 400);
|
||||||
|
assert.equal((await attempt({}, { size: MIB5 + 1 })).status, 400);
|
||||||
|
assert.equal(fake.rows('gallery').length, 0);
|
||||||
|
|
||||||
|
assert.equal((await attempt({ size: MIB5 }, { size: MIB5 })).status, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Veröffentlichen doppelt: 409 "Diese Karte ist schon veröffentlicht."', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
seedUploads(fake);
|
||||||
|
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201);
|
||||||
|
|
||||||
|
fake.addFile({ id: 'img2', owner: ME });
|
||||||
|
fake.addFile({ id: 'prev2', owner: ME, mimeType: 'image/webp' });
|
||||||
|
const again = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img2', previewFileId: 'prev2' }), JWT_ME);
|
||||||
|
assert.equal(again.status, 409);
|
||||||
|
assert.equal(again.json.error, 'Diese Karte ist schon veröffentlicht.');
|
||||||
|
assert.equal(fake.rows('gallery').length, 1);
|
||||||
|
|
||||||
|
// eine andere Person darf dieselbe sourceCardId-Zeichenfolge haben (Zeilen-ID enthält den Besitzer)
|
||||||
|
seedUploads(fake, OTHER, { image: 'img9', preview: 'prev9' });
|
||||||
|
const other = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img9', previewFileId: 'prev9' }), JWT_OTHER);
|
||||||
|
assert.equal(other.status, 201);
|
||||||
|
assert.notEqual(other.json.id, galleryRowId(ME, 'card1'));
|
||||||
|
assert.equal(fake.rows('gallery').length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Veröffentlichen nach Zurückziehen ist wieder möglich (gleiche Zeilen-ID)', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
seedUploads(fake);
|
||||||
|
const first = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||||||
|
assert.equal((await post('/api/zurueckziehen', { id: first.json.id }, JWT_ME)).status, 200);
|
||||||
|
seedUploads(fake);
|
||||||
|
const second = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||||||
|
assert.equal(second.status, 201);
|
||||||
|
assert.equal(second.json.id, first.json.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Veröffentlichen: Validierung liefert 400 und fasst Appwrite (Dateien, Zeilen) nicht an', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
seedUploads(fake);
|
||||||
|
const cases = [
|
||||||
|
['leerer Titel', { title: '' }, /Titel/],
|
||||||
|
['nur Leerzeichen als Titel', { title: ' ' }, /Titel/],
|
||||||
|
['Titel fehlt', { title: undefined }, /Titel/],
|
||||||
|
['Titel zu lang', { title: 'x'.repeat(61) }, /Titel/],
|
||||||
|
['Titel keine Zeichenkette', { title: 42 }, /Titel/],
|
||||||
|
['Titel mit Steuerzeichen', { title: 'a\nb' }, /Titel/],
|
||||||
|
['Herausgeber zu lang', { issuer: 'x'.repeat(41) }, /Herausgeber/],
|
||||||
|
['Anzeigename zu lang', { displayName: 'x'.repeat(31) }, /Anzeigename/],
|
||||||
|
['6 Tags', { tags: ['a', 'b', 'c', 'd', 'e', 'f'] }, /Tags/],
|
||||||
|
['Tag zu lang', { tags: ['x'.repeat(21)] }, /Tag/],
|
||||||
|
['Tags als Zeichenkette', { tags: 'a,b' }, /Tags/],
|
||||||
|
['Tag keine Zeichenkette', { tags: ['a', 5] }, /Tags/],
|
||||||
|
['Tag mit Komma', { tags: ['a,b'] }, /Komma/],
|
||||||
|
['Bild-ID mit ../', { imageFileId: '../x' }, /ID/],
|
||||||
|
['Bild-ID ..', { imageFileId: '..' }, /ID/],
|
||||||
|
['Bild-ID Zahl', { imageFileId: 123 }, /ID/],
|
||||||
|
['Bild-ID mit Schrägstrich', { imageFileId: 'a/b' }, /ID/],
|
||||||
|
['Bild-ID zu lang', { imageFileId: 'a'.repeat(37) }, /ID/],
|
||||||
|
['Bild-ID beginnt mit Punkt', { imageFileId: '.hidden' }, /ID/],
|
||||||
|
['Bild-ID leer', { imageFileId: '' }, /ID/],
|
||||||
|
['Vorschau-ID null', { previewFileId: null }, /ID/],
|
||||||
|
['Vorschau-ID mit Leerzeichen', { previewFileId: 'a b' }, /ID/],
|
||||||
|
['sourceCardId ../x', { sourceCardId: '../x' }, /ID/],
|
||||||
|
['sourceCardId Zahl', { sourceCardId: 7 }, /ID/],
|
||||||
|
['sourceCardId fehlt', { sourceCardId: undefined }, /ID/],
|
||||||
|
['Bild = Vorschau', { previewFileId: 'img1' }, /verschieden/],
|
||||||
|
];
|
||||||
|
for (const [label, overrides, pattern] of cases) {
|
||||||
|
const res = await post('/api/veroeffentlichen', publishBody(overrides), JWT_ME);
|
||||||
|
assert.equal(res.status, 400, label);
|
||||||
|
assert.match(res.json.error, pattern, label);
|
||||||
|
}
|
||||||
|
assert.equal(fake.rows('gallery').length, 0);
|
||||||
|
assert.equal(storageCalls(fake).length, 0, 'ungültige IDs erreichen Appwrite nie');
|
||||||
|
assert.equal((await post('/api/veroeffentlichen', publishBody({ tags: 'a,b' }), JWT_ME)).status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Veröffentlichen: Tags werden serverseitig erneut normalisiert', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
seedUploads(fake);
|
||||||
|
const res = await post('/api/veroeffentlichen', publishBody({ tags: [' BLAU ', 'Neon', '', ' ', 'blau'] }), JWT_ME);
|
||||||
|
assert.equal(res.status, 201);
|
||||||
|
assert.equal(fake.row('gallery', res.json.id).tags, 'blau,neon');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Veröffentlichen: Appwrite-Ausfall ergibt 502 mit deutschem Text, nichts angelegt', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
seedUploads(fake);
|
||||||
|
fake.failNext((c) => c.path === '/v1/account', { status: 500 });
|
||||||
|
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||||||
|
assert.equal(res.status, 502);
|
||||||
|
assert.equal(res.json.error, 'Der Dienst ist gerade nicht erreichbar.');
|
||||||
|
|
||||||
|
fake.failNext((c) => c.method === 'POST' && c.path.endsWith('/rows'), { status: 500 });
|
||||||
|
res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||||||
|
assert.equal(res.status, 502);
|
||||||
|
assert.equal(fake.rows('gallery').length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Melden ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('Melden: drei verschiedene IPs blenden die Karte aus (Zeilen- und Dateirechte)', async () => {
|
||||||
|
const { report, fake } = await setup();
|
||||||
|
const { imageFileId, previewFileId } = seedCard(fake);
|
||||||
|
|
||||||
|
let res = await report('card-1', '203.0.113.1');
|
||||||
|
assert.deepEqual(res.json, { ok: true });
|
||||||
|
assert.equal(fake.row('gallery', 'card-1').reportCount, 1);
|
||||||
|
assert.equal(fake.row('gallery', 'card-1').hidden, false);
|
||||||
|
res = await report('card-1', '203.0.113.2', 'urheberrecht');
|
||||||
|
assert.deepEqual(res.json, { ok: true });
|
||||||
|
assert.equal(fake.row('gallery', 'card-1').reportCount, 2);
|
||||||
|
assert.deepEqual(fake.row('gallery', 'card-1').$permissions, ['read("any")']);
|
||||||
|
|
||||||
|
res = await report('card-1', '203.0.113.3', 'kartendaten');
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual(res.json, { ok: true });
|
||||||
|
|
||||||
|
const row = fake.row('gallery', 'card-1');
|
||||||
|
assert.equal(row.hidden, true);
|
||||||
|
assert.equal(row.reportCount, 3);
|
||||||
|
assert.deepEqual(row.$permissions, ['read("user:user-me")', 'read("label:admin")']);
|
||||||
|
const hiddenFile = ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")'];
|
||||||
|
assert.deepEqual(fake.file(imageFileId).$permissions, hiddenFile);
|
||||||
|
assert.deepEqual(fake.file(previewFileId).$permissions, hiddenFile);
|
||||||
|
assert.equal(fake.rows('reports').length, 3);
|
||||||
|
|
||||||
|
// ausgeblendete Karte melden: 404
|
||||||
|
res = await report('card-1', '203.0.113.4');
|
||||||
|
assert.equal(res.status, 404);
|
||||||
|
assert.equal(res.json.error, 'Diese Karte ist nicht (mehr) verfügbar.');
|
||||||
|
assert.equal(fake.rows('reports').length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Melden: zwei Melder reichen nicht, die Meldungen speichern nur Hash, Grund und Karte', async () => {
|
||||||
|
const { report, fake } = await setup();
|
||||||
|
seedCard(fake);
|
||||||
|
await report('card-1', '203.0.113.1', 'spam');
|
||||||
|
await report('card-1', '203.0.113.2', 'sonstiges');
|
||||||
|
assert.equal(fake.row('gallery', 'card-1').hidden, false);
|
||||||
|
|
||||||
|
const hash = reporterHash(SALT, '203.0.113.1');
|
||||||
|
const stored = fake.row('reports', reportRowId('card-1', hash));
|
||||||
|
assert.ok(stored, 'Zeilen-ID = sha256(galleryId|reporterHash)');
|
||||||
|
assert.equal(stored.galleryId, 'card-1');
|
||||||
|
assert.equal(stored.reason, 'spam');
|
||||||
|
assert.equal(stored.reporterHash, hash);
|
||||||
|
assert.deepEqual(stored.$permissions, []);
|
||||||
|
assert.ok(!JSON.stringify(fake.rows('reports')).includes('203.0.113'), 'keine IP im Klartext gespeichert');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Melden: dieselbe IP fünfmal zählt einmal, Antworten sind schonGemeldet', async () => {
|
||||||
|
const { report, fake } = await setup();
|
||||||
|
seedCard(fake);
|
||||||
|
|
||||||
|
const first = await report('card-1', '203.0.113.9');
|
||||||
|
assert.deepEqual(first.json, { ok: true });
|
||||||
|
for (let i = 0; i < 4; i += 1) {
|
||||||
|
const again = await report('card-1', '203.0.113.9', i % 2 ? 'spam' : 'unangemessen');
|
||||||
|
assert.equal(again.status, 200);
|
||||||
|
assert.deepEqual(again.json, { schonGemeldet: true });
|
||||||
|
}
|
||||||
|
assert.equal(fake.row('gallery', 'card-1').reportCount, 1);
|
||||||
|
assert.equal(fake.row('gallery', 'card-1').hidden, false);
|
||||||
|
assert.equal(fake.rows('reports').length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Melden: Rate-Limit 10 pro Stunde und IP, danach 429, nach einer Stunde wieder erlaubt', async () => {
|
||||||
|
const { report, fake, clock } = await setup();
|
||||||
|
seedCard(fake);
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i += 1) {
|
||||||
|
const res = await report('card-1', '203.0.113.50');
|
||||||
|
assert.equal(res.status, 200, `Meldung ${i + 1}`);
|
||||||
|
}
|
||||||
|
let res = await report('card-1', '203.0.113.50');
|
||||||
|
assert.equal(res.status, 429);
|
||||||
|
assert.equal(res.json.error, 'Zu viele Meldungen – bitte später nochmal.');
|
||||||
|
assert.equal(res.headers['retry-after'], '3600');
|
||||||
|
|
||||||
|
// eine andere IP ist nicht betroffen
|
||||||
|
assert.equal((await report('card-1', '203.0.113.51')).status, 200);
|
||||||
|
|
||||||
|
// knapp vor Ablauf noch gesperrt, nach einer Stunde wieder frei
|
||||||
|
clock.value += 3600_000 - 1;
|
||||||
|
assert.equal((await report('card-1', '203.0.113.50')).status, 429);
|
||||||
|
clock.value += 1;
|
||||||
|
res = await report('card-1', '203.0.113.50');
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual(res.json, { schonGemeldet: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Melden: abgelaufene Rate-Limit-Einträge werden entfernt', async () => {
|
||||||
|
const { report, fake, clock, app } = await setup();
|
||||||
|
seedCard(fake);
|
||||||
|
for (const ip of ['203.0.113.1', '203.0.113.2', '203.0.113.3']) await report('card-1', ip);
|
||||||
|
assert.equal(app.rateLimitEntries(), 3);
|
||||||
|
|
||||||
|
clock.value += 3600_000 + 60_000;
|
||||||
|
await report('card-1', '203.0.113.4');
|
||||||
|
assert.equal(app.rateLimitEntries(), 1, 'die drei alten Einträge sind weg, nur der neue bleibt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Melden: Ungültige Anfragen (Grund, ID) zählen nicht zum Limit und erreichen Appwrite nicht', async () => {
|
||||||
|
const { report, post, fake, app } = await setup();
|
||||||
|
seedCard(fake);
|
||||||
|
for (const reason of ['gibt-es-nicht', '', null, 7, undefined, 'SPAM']) {
|
||||||
|
const res = await post('/api/melden', { id: 'card-1', reason }, null, { 'X-Real-Ip': '203.0.113.1' });
|
||||||
|
assert.equal(res.status, 400, String(reason));
|
||||||
|
assert.equal(typeof res.json.error, 'string');
|
||||||
|
}
|
||||||
|
for (const id of ['../x', '..', 42, null, undefined, 'a/b', 'a'.repeat(37), '']) {
|
||||||
|
const res = await post('/api/melden', { id, reason: 'spam' }, null, { 'X-Real-Ip': '203.0.113.1' });
|
||||||
|
assert.equal(res.status, 400, String(id));
|
||||||
|
}
|
||||||
|
assert.equal(fake.calls.length, 0);
|
||||||
|
assert.equal(app.rateLimitEntries(), 0);
|
||||||
|
assert.equal((await report('card-1', '203.0.113.1')).status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Melden: nicht vorhandene Karte ist 404', async () => {
|
||||||
|
const { report, fake } = await setup();
|
||||||
|
const res = await report('gibts-nicht', '203.0.113.1');
|
||||||
|
assert.equal(res.status, 404);
|
||||||
|
assert.equal(res.json.error, 'Diese Karte ist nicht (mehr) verfügbar.');
|
||||||
|
assert.equal(fake.rows('reports').length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Melden: gültige Gründe sind unangemessen, kartendaten, urheberrecht, spam, sonstiges', async () => {
|
||||||
|
const { report, fake } = await setup();
|
||||||
|
seedCard(fake);
|
||||||
|
const reasons = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'];
|
||||||
|
for (let i = 0; i < reasons.length; i += 1) {
|
||||||
|
const res = await report('card-1', `198.51.100.${i + 1}`, reasons[i]);
|
||||||
|
assert.equal(res.status, 200, reasons[i]);
|
||||||
|
if (i === 2) break; // ab 3 Meldern ist die Karte weg
|
||||||
|
}
|
||||||
|
const stored = fake.rows('reports').map((r) => r.reason).sort();
|
||||||
|
assert.deepEqual(stored, ['kartendaten', 'unangemessen', 'urheberrecht']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Melden: IPv6-Adressen aus demselben /64 zählen als ein Melder', async () => {
|
||||||
|
const { report, fake } = await setup();
|
||||||
|
seedCard(fake);
|
||||||
|
assert.deepEqual((await report('card-1', '2001:db8:abcd:12::1')).json, { ok: true });
|
||||||
|
assert.deepEqual((await report('card-1', '2001:db8:abcd:12:aaaa:bbbb:cccc:dddd')).json, { schonGemeldet: true });
|
||||||
|
assert.deepEqual((await report('card-1', '2001:db8:abcd:13::1')).json, { ok: true });
|
||||||
|
assert.equal(fake.row('gallery', 'card-1').reportCount, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Melden: ohne X-Real-Ip gilt das erste X-Forwarded-For, sonst die Socket-Adresse', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
seedCard(fake);
|
||||||
|
const viaForwarded = (ip) => post('/api/melden', { id: 'card-1', reason: 'spam' }, null, { 'X-Forwarded-For': `${ip}, 10.0.0.1` });
|
||||||
|
assert.deepEqual((await viaForwarded('203.0.113.77')).json, { ok: true });
|
||||||
|
assert.deepEqual((await viaForwarded('203.0.113.77')).json, { schonGemeldet: true });
|
||||||
|
assert.deepEqual((await viaForwarded('203.0.113.78')).json, { ok: true });
|
||||||
|
// ohne beide Header zählt die Socket-Adresse (127.0.0.1) als dritter Melder
|
||||||
|
assert.deepEqual((await post('/api/melden', { id: 'card-1', reason: 'spam' })).json, { ok: true });
|
||||||
|
assert.equal(fake.row('gallery', 'card-1').hidden, true, 'drei verschiedene Quellen');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Melden: gleichzeitige Meldungen von drei IPs ergeben genau drei und blenden aus', async () => {
|
||||||
|
const { report, fake } = await setup();
|
||||||
|
seedCard(fake);
|
||||||
|
const results = await Promise.all([report('card-1', '203.0.113.1'), report('card-1', '203.0.113.2'), report('card-1', '203.0.113.3')]);
|
||||||
|
assert.deepEqual(results.map((r) => r.status), [200, 200, 200]);
|
||||||
|
const row = fake.row('gallery', 'card-1');
|
||||||
|
assert.equal(row.reportCount, 3);
|
||||||
|
assert.equal(row.hidden, true);
|
||||||
|
assert.equal(fake.rows('reports').length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Melden: eine verspätete Zählerstand-Änderung überschreibt neuere Stände nicht (Sperre je Karte)', async () => {
|
||||||
|
const { report, fake } = await setup();
|
||||||
|
seedCard(fake);
|
||||||
|
// Die erste Meldung schreibt ihren Zähler (1) erst nach 150 ms; währenddessen kommen zwei weitere.
|
||||||
|
fake.delayNext((c) => c.method === 'PATCH' && c.body && c.body.data && c.body.data.reportCount === 1, 150);
|
||||||
|
const first = report('card-1', '203.0.113.1');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||||
|
const others = await Promise.all([report('card-1', '203.0.113.2'), report('card-1', '203.0.113.3')]);
|
||||||
|
const firstResult = await first;
|
||||||
|
assert.deepEqual([firstResult.status, ...others.map((r) => r.status)], [200, 200, 200]);
|
||||||
|
|
||||||
|
const row = fake.row('gallery', 'card-1');
|
||||||
|
assert.equal(row.reportCount, 3);
|
||||||
|
assert.equal(row.hidden, true);
|
||||||
|
assert.deepEqual(row.$permissions, ['read("user:user-me")', 'read("label:admin")']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Melden: eine doppelte Meldung heilt einen früher steckengebliebenen Zählerstand', async () => {
|
||||||
|
const { report, fake } = await setup();
|
||||||
|
seedCard(fake);
|
||||||
|
// drei Meldungen liegen schon vor, der Zähler an der Karte kam aber nie an (z. B. Ausfall mitten im Ablauf)
|
||||||
|
for (const ip of ['203.0.113.1', '203.0.113.2', '203.0.113.3']) {
|
||||||
|
const hash = reporterHash(SALT, ip);
|
||||||
|
fake.addRow('reports', reportRowId('card-1', hash), { galleryId: 'card-1', reason: 'spam', reporterHash: hash }, []);
|
||||||
|
}
|
||||||
|
assert.equal(fake.row('gallery', 'card-1').hidden, false);
|
||||||
|
const res = await report('card-1', '203.0.113.1');
|
||||||
|
assert.deepEqual(res.json, { schonGemeldet: true });
|
||||||
|
assert.equal(fake.row('gallery', 'card-1').hidden, true);
|
||||||
|
assert.equal(fake.row('gallery', 'card-1').reportCount, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Melden: Ausfall beim Anlegen der Meldung ergibt 502 und zählt nicht', async () => {
|
||||||
|
const { report, fake } = await setup();
|
||||||
|
seedCard(fake);
|
||||||
|
fake.failNext((c) => c.method === 'POST' && c.path.endsWith('/tables/reports/rows'), { status: 500 });
|
||||||
|
const res = await report('card-1', '203.0.113.1');
|
||||||
|
assert.equal(res.status, 502);
|
||||||
|
assert.equal(fake.row('gallery', 'card-1').reportCount, 0);
|
||||||
|
assert.equal(fake.rows('reports').length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Melden: Ausfall beim Ausblenden endet mit 502, ein erneuter Versuch blendet aus', async () => {
|
||||||
|
const { report, fake } = await setup();
|
||||||
|
seedCard(fake, { reportCount: 0 });
|
||||||
|
await report('card-1', '203.0.113.1');
|
||||||
|
await report('card-1', '203.0.113.2');
|
||||||
|
fake.failNext((c) => c.method === 'PATCH' && c.path.includes('/tables/gallery/rows/'), { status: 500 });
|
||||||
|
const res = await report('card-1', '203.0.113.3');
|
||||||
|
assert.equal(res.status, 502);
|
||||||
|
assert.equal(fake.row('gallery', 'card-1').hidden, false);
|
||||||
|
const retry = await report('card-1', '203.0.113.3');
|
||||||
|
assert.equal(retry.status, 200);
|
||||||
|
assert.equal(fake.row('gallery', 'card-1').hidden, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Zurückziehen ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('Zurückziehen durch den Autor löscht Zeile, beide Dateien und die Meldungen', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
const { imageFileId, previewFileId } = seedCard(fake);
|
||||||
|
seedCard(fake, { id: 'card-2' });
|
||||||
|
for (const [id, hash] of [['card-1', 'h1'], ['card-1', 'h2'], ['card-2', 'h3']]) {
|
||||||
|
fake.addRow('reports', reportRowId(id, hash), { galleryId: id, reason: 'spam', reporterHash: hash }, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await post('/api/zurueckziehen', { id: 'card-1' }, JWT_ME);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual(res.json, { ok: true });
|
||||||
|
assert.equal(fake.row('gallery', 'card-1'), null);
|
||||||
|
assert.equal(fake.file(imageFileId), null);
|
||||||
|
assert.equal(fake.file(previewFileId), null);
|
||||||
|
assert.deepEqual(fake.rows('reports').map((r) => r.galleryId), ['card-2']);
|
||||||
|
assert.ok(fake.row('gallery', 'card-2'), 'andere Karten bleiben');
|
||||||
|
assert.ok(fake.file('card-2-img'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zurückziehen durch Admin geht, auch bei ausgeblendeter Karte', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
seedCard(fake, { hidden: true, reportCount: 3 });
|
||||||
|
const res = await post('/api/zurueckziehen', { id: 'card-1' }, JWT_ADMIN);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(fake.row('gallery', 'card-1'), null);
|
||||||
|
assert.equal(fake.file('card-1-img'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zurückziehen durch Fremde: 403 und nichts gelöscht', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
seedCard(fake);
|
||||||
|
const res = await post('/api/zurueckziehen', { id: 'card-1' }, JWT_OTHER);
|
||||||
|
assert.equal(res.status, 403);
|
||||||
|
assert.equal(typeof res.json.error, 'string');
|
||||||
|
assert.ok(fake.row('gallery', 'card-1'));
|
||||||
|
assert.ok(fake.file('card-1-img'));
|
||||||
|
assert.ok(fake.file('card-1-prev'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zurückziehen: 401 ohne JWT, 404 bei fehlender Karte, 400 bei ungültiger ID', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
seedCard(fake);
|
||||||
|
assert.equal((await post('/api/zurueckziehen', { id: 'card-1' })).status, 401);
|
||||||
|
assert.equal((await post('/api/zurueckziehen', { id: 'card-1' }, 'xxx.yyy.zzz')).status, 401);
|
||||||
|
assert.equal((await post('/api/zurueckziehen', { id: 'nope' }, JWT_ME)).status, 404);
|
||||||
|
for (const id of ['../x', '..', 5, null, undefined]) {
|
||||||
|
assert.equal((await post('/api/zurueckziehen', { id }, JWT_ME)).status, 400, String(id));
|
||||||
|
}
|
||||||
|
assert.ok(fake.row('gallery', 'card-1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zurückziehen ist wiederholbar, wenn schon eine Datei fehlt (Wiederholung nach Teilausfall)', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
seedCard(fake);
|
||||||
|
fake.failNext((c) => c.method === 'DELETE' && c.path.includes('/tables/gallery/rows/'), { status: 500 });
|
||||||
|
assert.equal((await post('/api/zurueckziehen', { id: 'card-1' }, JWT_ME)).status, 502);
|
||||||
|
assert.ok(fake.row('gallery', 'card-1'), 'Zeile bleibt, bis alles andere weg ist');
|
||||||
|
assert.equal(fake.file('card-1-img'), null);
|
||||||
|
const retry = await post('/api/zurueckziehen', { id: 'card-1' }, JWT_ME);
|
||||||
|
assert.equal(retry.status, 200);
|
||||||
|
assert.equal(fake.row('gallery', 'card-1'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Admin ------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('Admin-Sichtbarkeit: Nicht-Admin 403, ohne JWT 401', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
seedCard(fake);
|
||||||
|
assert.equal((await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, JWT_ME)).status, 403);
|
||||||
|
assert.equal((await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, JWT_OTHER)).status, 403);
|
||||||
|
assert.equal((await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false })).status, 401);
|
||||||
|
assert.deepEqual(fake.row('gallery', 'card-1').$permissions, ['read("any")']);
|
||||||
|
assert.equal(fake.row('gallery', 'card-1').hidden, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Admin blendet aus: Zeilen- und Dateirechte, hidden=true, Meldungen bleiben', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
seedCard(fake, { reportCount: 1 });
|
||||||
|
fake.addRow('reports', reportRowId('card-1', 'h1'), { galleryId: 'card-1', reason: 'spam', reporterHash: 'h1' }, []);
|
||||||
|
|
||||||
|
const res = await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, JWT_ADMIN);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const row = fake.row('gallery', 'card-1');
|
||||||
|
assert.equal(row.hidden, true);
|
||||||
|
assert.equal(row.reportCount, 1);
|
||||||
|
assert.deepEqual(row.$permissions, ['read("user:user-me")', 'read("label:admin")']);
|
||||||
|
const hiddenFile = ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")'];
|
||||||
|
assert.deepEqual(fake.file('card-1-img').$permissions, hiddenFile);
|
||||||
|
assert.deepEqual(fake.file('card-1-prev').$permissions, hiddenFile);
|
||||||
|
assert.equal(fake.rows('reports').length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Admin blendet ein: sichtbare Rechte, hidden=false, reportCount=0, Meldungen gelöscht', async () => {
|
||||||
|
const { post, fake, report } = await setup();
|
||||||
|
seedCard(fake, { hidden: true, reportCount: 3 });
|
||||||
|
seedCard(fake, { id: 'card-2' });
|
||||||
|
for (const [id, hash] of [['card-1', 'h1'], ['card-1', 'h2'], ['card-1', 'h3'], ['card-2', 'h4']]) {
|
||||||
|
fake.addRow('reports', reportRowId(id, hash), { galleryId: id, reason: 'spam', reporterHash: hash }, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: true }, JWT_ADMIN);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const row = fake.row('gallery', 'card-1');
|
||||||
|
assert.equal(row.hidden, false);
|
||||||
|
assert.equal(row.reportCount, 0);
|
||||||
|
assert.deepEqual(row.$permissions, ['read("any")']);
|
||||||
|
assert.deepEqual(fake.file('card-1-img').$permissions, ['read("any")', 'delete("user:user-me")']);
|
||||||
|
assert.deepEqual(fake.file('card-1-prev').$permissions, ['read("any")', 'delete("user:user-me")']);
|
||||||
|
assert.deepEqual(fake.rows('reports').map((r) => r.galleryId), ['card-2']);
|
||||||
|
|
||||||
|
// die Karte ist wieder meldbar
|
||||||
|
assert.equal((await report('card-1', '203.0.113.1')).status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Admin-Sichtbarkeit: Body-Prüfung und fehlende Karte', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
seedCard(fake);
|
||||||
|
for (const body of [
|
||||||
|
{ id: 'card-1' },
|
||||||
|
{ id: 'card-1', sichtbar: 'ja' },
|
||||||
|
{ id: 'card-1', sichtbar: 1 },
|
||||||
|
{ id: '../x', sichtbar: true },
|
||||||
|
{ id: 7, sichtbar: true },
|
||||||
|
{},
|
||||||
|
]) {
|
||||||
|
assert.equal((await post('/api/admin/sichtbarkeit', body, JWT_ADMIN)).status, 400, JSON.stringify(body));
|
||||||
|
}
|
||||||
|
assert.equal((await post('/api/admin/sichtbarkeit', { id: 'nope', sichtbar: true }, JWT_ADMIN)).status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Logs und Fehler --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('Logs enthalten nie Key, Salz, JWT oder IPs im Klartext, aber das Präfix [aircard-api]', async () => {
|
||||||
|
const { post, report, fake, logs } = await setup();
|
||||||
|
seedUploads(fake);
|
||||||
|
seedCard(fake, { id: 'card-9' });
|
||||||
|
await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||||||
|
await post('/api/veroeffentlichen', publishBody(), 'xxx.yyy.zzz');
|
||||||
|
await report('card-9', '203.0.113.7');
|
||||||
|
await report('card-9', '203.0.113.8');
|
||||||
|
await report('card-9', '203.0.113.9');
|
||||||
|
await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: true }, JWT_ADMIN);
|
||||||
|
fake.failNext((c) => c.method === 'GET' && c.path.includes('/rows/'), { status: 500 });
|
||||||
|
await post('/api/zurueckziehen', { id: 'card-9' }, JWT_ME);
|
||||||
|
await post('/api/zurueckziehen', { id: 'card-9' }, JWT_ME);
|
||||||
|
|
||||||
|
assert.ok(logs.length > 0);
|
||||||
|
const all = logs.join('\n');
|
||||||
|
for (const secret of [fake.key, SALT, JWT_ME, JWT_OTHER, JWT_ADMIN, 'xxx.yyy.zzz', '203.0.113', '127.0.0.1', reporterHash(SALT, '203.0.113.7')]) {
|
||||||
|
assert.ok(!all.includes(secret), `Log enthält ${secret.slice(0, 12)}…`);
|
||||||
|
}
|
||||||
|
for (const line of logs) assert.ok(line.startsWith('[aircard-api] '), line);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Unerwartete Fehler im Appwrite-Objekt ergeben 500, ohne die Anfrage hängen zu lassen', async () => {
|
||||||
|
const logs = [];
|
||||||
|
const broken = {
|
||||||
|
accountFromJwt: async () => {
|
||||||
|
throw new Error('kaputt GEHEIM');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const app = createApp({ appwrite: broken, salt: SALT, log: (line) => logs.push(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 res = await request(server.address().port, 'POST', '/api/zurueckziehen', {
|
||||||
|
body: { id: 'x' },
|
||||||
|
headers: { 'X-Aircard-Jwt': JWT_ME },
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 500);
|
||||||
|
assert.equal(typeof res.json.error, 'string');
|
||||||
|
assert.ok(!logs.join('\n').includes('GEHEIM'), 'Fehlertexte werden nicht ins Log übernommen');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createApp verlangt Appwrite-Objekt und Salz', () => {
|
||||||
|
assert.throws(() => createApp({ salt: SALT }));
|
||||||
|
assert.throws(() => createApp({ appwrite: {}, salt: '' }));
|
||||||
|
assert.throws(() => createApp({ appwrite: {} }));
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
* 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)
|
||||||
|
* 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
|
||||||
|
* accountFromJwt(jwt) Nutzer ($id, labels) oder null (401); OHNE Server-Key
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* Fehler: AppwriteError mit `status` (0 = keine Antwort), `type` (Appwrite-Typ, "timeout" oder "unreachable").
|
||||||
|
* Der Text enthält nie Key oder JWT.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
class AppwriteError extends Error {
|
||||||
|
constructor(status, type, message) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AppwriteError';
|
||||||
|
this.status = status;
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Appwrite-Abfrage als JSON-String. */
|
||||||
|
function queryEqual(attribute, values) {
|
||||||
|
return JSON.stringify({ method: 'equal', attribute, values });
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryLimit(count) {
|
||||||
|
return JSON.stringify({ method: 'limit', values: [count] });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Pfadsegment: nur nichtleere Zeichenketten, nie "." oder "..", immer kodiert. */
|
||||||
|
function segment(value) {
|
||||||
|
if (typeof value !== 'string' || value === '' || value === '.' || value === '..') {
|
||||||
|
throw new TypeError('Ungültiges Pfadsegment');
|
||||||
|
}
|
||||||
|
return encodeURIComponent(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch, database = 'aircard', timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
||||||
|
if (typeof endpoint !== 'string' || endpoint === '') throw new TypeError('endpoint fehlt');
|
||||||
|
if (typeof project !== 'string' || project === '') throw new TypeError('project fehlt');
|
||||||
|
if (typeof key !== 'string' || key === '') throw new TypeError('key fehlt');
|
||||||
|
if (typeof fetchImpl !== 'function') throw new TypeError('fetch fehlt');
|
||||||
|
|
||||||
|
const base = endpoint.replace(/\/+$/, '');
|
||||||
|
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 = [] } = {}) {
|
||||||
|
const requestHeaders = { 'X-Appwrite-Project': project, ...headers };
|
||||||
|
if (withKey) requestHeaders['X-Appwrite-Key'] = key;
|
||||||
|
const init = { method, headers: requestHeaders, signal: AbortSignal.timeout(timeoutMs) };
|
||||||
|
if (body !== undefined) {
|
||||||
|
requestHeaders['Content-Type'] = 'application/json';
|
||||||
|
init.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response;
|
||||||
|
let text;
|
||||||
|
try {
|
||||||
|
response = await fetchImpl(`${base}${path}`, init);
|
||||||
|
text = await response.text();
|
||||||
|
} catch (err) {
|
||||||
|
// Fehlertexte von fetch bleiben draußen (sie könnten Adresse und Header enthalten).
|
||||||
|
const timedOut = err && (err.name === 'TimeoutError' || err.name === 'AbortError');
|
||||||
|
throw timedOut
|
||||||
|
? new AppwriteError(0, 'timeout', 'Appwrite antwortet nicht rechtzeitig')
|
||||||
|
: new AppwriteError(0, 'unreachable', 'Appwrite nicht erreichbar');
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = null;
|
||||||
|
if (text !== '') {
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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';
|
||||||
|
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] });
|
||||||
|
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 : [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async createRow(table, rowId, data, permissions) {
|
||||||
|
const result = await send('POST', rowsPath(table), { body: { rowId, data, permissions } });
|
||||||
|
return result.body;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateRow(table, id, data, permissions) {
|
||||||
|
const body = { data };
|
||||||
|
if (Array.isArray(permissions)) body.permissions = permissions;
|
||||||
|
const result = await send('PATCH', `${rowsPath(table)}/${segment(id)}`, { body });
|
||||||
|
return result.body;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteRow(table, id) {
|
||||||
|
const result = await send('DELETE', `${rowsPath(table)}/${segment(id)}`, { tolerate: [404] });
|
||||||
|
return result.status !== 404;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getFile(bucket, id) {
|
||||||
|
const result = await send('GET', filePath(bucket, id), { tolerate: [404] });
|
||||||
|
return result.status === 404 ? null : result.body;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateFilePermissions(bucket, id, permissions) {
|
||||||
|
const result = await send('PUT', filePath(bucket, id), { body: { permissions }, tolerate: [404] });
|
||||||
|
return result.status === 404 ? null : result.body;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteFile(bucket, id) {
|
||||||
|
const result = await send('DELETE', filePath(bucket, id), { tolerate: [404] });
|
||||||
|
return result.status !== 404;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Prüft ein Nutzer-JWT: Konto aus GET /account (ohne Server-Key), null bei 401. */
|
||||||
|
async accountFromJwt(jwt) {
|
||||||
|
try {
|
||||||
|
const result = await send('GET', '/account', { headers: { 'X-Appwrite-JWT': jwt }, withKey: false });
|
||||||
|
return result.body;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AppwriteError && err.status === 401) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createAppwrite, AppwriteError, queryEqual, queryLimit };
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Tests für deploy/api/appwrite.js gegen einen Fake-Appwrite im Speicher (fake-appwrite.js).
|
||||||
|
// Aufruf: node --test "deploy/api/*.test.js"
|
||||||
|
|
||||||
|
const { test, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { createAppwrite, AppwriteError, queryEqual, queryLimit } = require('./appwrite.js');
|
||||||
|
const { startFakeAppwrite } = require('./fake-appwrite.js');
|
||||||
|
|
||||||
|
const fakes = [];
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const fake of fakes.splice(0)) await fake.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setup(options = {}) {
|
||||||
|
const fake = await startFakeAppwrite();
|
||||||
|
fakes.push(fake);
|
||||||
|
const appwrite = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key, ...options });
|
||||||
|
return { fake, appwrite };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastCall = (fake) => fake.calls[fake.calls.length - 1];
|
||||||
|
|
||||||
|
test('Zeilen: anlegen, lesen, ändern, löschen mit Projekt- und Key-Header', async () => {
|
||||||
|
const { fake, appwrite } = await setup();
|
||||||
|
|
||||||
|
const created = await appwrite.createRow('gallery', 'row1', { title: 'T', imageFileId: 'i', previewFileId: 'p', ownerId: 'o' }, ['read("any")']);
|
||||||
|
assert.equal(created.$id, 'row1');
|
||||||
|
assert.equal(lastCall(fake).method, 'POST');
|
||||||
|
assert.equal(lastCall(fake).path, '/v1/tablesdb/aircard/tables/gallery/rows');
|
||||||
|
assert.deepEqual(lastCall(fake).body, {
|
||||||
|
rowId: 'row1',
|
||||||
|
data: { title: 'T', imageFileId: 'i', previewFileId: 'p', ownerId: 'o' },
|
||||||
|
permissions: ['read("any")'],
|
||||||
|
});
|
||||||
|
assert.equal(lastCall(fake).headers['x-appwrite-project'], fake.project);
|
||||||
|
assert.equal(lastCall(fake).headers['x-appwrite-key'], fake.key);
|
||||||
|
assert.match(lastCall(fake).headers['content-type'], /^application\/json/);
|
||||||
|
|
||||||
|
assert.equal((await appwrite.getRow('gallery', 'row1')).title, 'T');
|
||||||
|
assert.equal(lastCall(fake).path, '/v1/tablesdb/aircard/tables/gallery/rows/row1');
|
||||||
|
|
||||||
|
await appwrite.updateRow('gallery', 'row1', { hidden: true });
|
||||||
|
assert.equal(lastCall(fake).method, 'PATCH');
|
||||||
|
assert.deepEqual(lastCall(fake).body, { data: { hidden: true } }, 'ohne permissions-Feld, wenn keine übergeben werden');
|
||||||
|
assert.deepEqual(fake.row('gallery', 'row1').$permissions, ['read("any")']);
|
||||||
|
|
||||||
|
await appwrite.updateRow('gallery', 'row1', { reportCount: 3 }, ['read("user:o")']);
|
||||||
|
assert.deepEqual(lastCall(fake).body, { data: { reportCount: 3 }, permissions: ['read("user:o")'] });
|
||||||
|
assert.deepEqual(fake.row('gallery', 'row1').$permissions, ['read("user:o")']);
|
||||||
|
|
||||||
|
assert.equal(await appwrite.deleteRow('gallery', 'row1'), true);
|
||||||
|
assert.equal(fake.row('gallery', 'row1'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Fehlende Zeile/Datei: getRow und getFile liefern null, deleteRow/deleteFile/updateFilePermissions sind tolerant', async () => {
|
||||||
|
const { appwrite } = await setup();
|
||||||
|
assert.equal(await appwrite.getRow('gallery', 'nope'), null);
|
||||||
|
assert.equal(await appwrite.getFile('gallery-images', 'nope'), null);
|
||||||
|
assert.equal(await appwrite.deleteRow('gallery', 'nope'), false);
|
||||||
|
assert.equal(await appwrite.deleteFile('gallery-images', 'nope'), false);
|
||||||
|
assert.equal(await appwrite.updateFilePermissions('gallery-images', 'nope', ['read("any")']), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Doppelte Zeilen-ID wirft AppwriteError mit Status 409 und Typ row_already_exists', async () => {
|
||||||
|
const { appwrite } = await setup();
|
||||||
|
const data = { galleryId: 'g', reason: 'spam', reporterHash: 'h' };
|
||||||
|
await appwrite.createRow('reports', 'r1', data, []);
|
||||||
|
await assert.rejects(
|
||||||
|
() => appwrite.createRow('reports', 'r1', data, []),
|
||||||
|
(err) => err instanceof AppwriteError && err.status === 409 && err.type === 'row_already_exists',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Andere Fehler (400, 401, 500) werden als AppwriteError geworfen', async () => {
|
||||||
|
const { fake, appwrite } = await setup();
|
||||||
|
await assert.rejects(
|
||||||
|
() => appwrite.createRow('gallery', 'x', { title: 'x'.repeat(61), imageFileId: 'i', previewFileId: 'p', ownerId: 'o' }, []),
|
||||||
|
(err) => err instanceof AppwriteError && err.status === 400 && err.type === 'document_invalid_structure',
|
||||||
|
);
|
||||||
|
fake.failNext((call) => call.method === 'GET', { status: 500, type: 'general_server_error' });
|
||||||
|
await assert.rejects(
|
||||||
|
() => appwrite.getRow('gallery', 'x'),
|
||||||
|
(err) => err instanceof AppwriteError && err.status === 500,
|
||||||
|
);
|
||||||
|
const wrongKey = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: 'falscher-key' });
|
||||||
|
await assert.rejects(
|
||||||
|
() => wrongKey.getRow('gallery', 'x'),
|
||||||
|
(err) => err instanceof AppwriteError && err.status === 401,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listRows schickt die Queries als queries[]-Parameter und liefert die Zeilen', async () => {
|
||||||
|
const { fake, appwrite } = await setup();
|
||||||
|
for (const id of ['a', 'b', 'c']) {
|
||||||
|
await appwrite.createRow('reports', id, { galleryId: id === 'c' ? 'other' : 'g1', reason: 'spam', reporterHash: id }, []);
|
||||||
|
}
|
||||||
|
const rows = await appwrite.listRows('reports', [queryEqual('galleryId', ['g1']), queryLimit(100)]);
|
||||||
|
assert.deepEqual(rows.map((row) => row.$id).sort(), ['a', 'b']);
|
||||||
|
|
||||||
|
const { search } = lastCall(fake);
|
||||||
|
const params = search.split('&').map((part) => decodeURIComponent(part));
|
||||||
|
assert.deepEqual(params, [
|
||||||
|
'queries[]={"method":"equal","attribute":"galleryId","values":["g1"]}',
|
||||||
|
'queries[]={"method":"limit","values":[100]}',
|
||||||
|
]);
|
||||||
|
assert.equal(lastCall(fake).headers['x-appwrite-key'], fake.key);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('queryEqual und queryLimit liefern Appwrite-JSON-Strings', () => {
|
||||||
|
assert.equal(queryEqual('galleryId', ['x']), '{"method":"equal","attribute":"galleryId","values":["x"]}');
|
||||||
|
assert.equal(queryLimit(100), '{"method":"limit","values":[100]}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Dateien: lesen, Rechte setzen, löschen', async () => {
|
||||||
|
const { fake, appwrite } = await setup();
|
||||||
|
fake.addFile({ id: 'f1', owner: 'u1', mimeType: 'image/webp', size: 1234 });
|
||||||
|
|
||||||
|
const file = await appwrite.getFile('gallery-images', 'f1');
|
||||||
|
assert.equal(file.mimeType, 'image/webp');
|
||||||
|
assert.equal(file.sizeOriginal, 1234);
|
||||||
|
assert.equal(file.bucketId, 'gallery-images');
|
||||||
|
assert.deepEqual(file.$permissions, ['read("any")', 'delete("user:u1")']);
|
||||||
|
assert.equal(lastCall(fake).path, '/v1/storage/buckets/gallery-images/files/f1');
|
||||||
|
|
||||||
|
await appwrite.updateFilePermissions('gallery-images', 'f1', ['read("user:u1")']);
|
||||||
|
assert.equal(lastCall(fake).method, 'PUT');
|
||||||
|
assert.deepEqual(lastCall(fake).body, { permissions: ['read("user:u1")'] });
|
||||||
|
assert.deepEqual(fake.file('f1').$permissions, ['read("user:u1")']);
|
||||||
|
|
||||||
|
assert.equal(await appwrite.deleteFile('gallery-images', 'f1'), true);
|
||||||
|
assert.equal(fake.file('f1'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accountFromJwt: JWT-Header ohne Key; 401 gibt null, andere Fehler werfen', async () => {
|
||||||
|
const { fake, appwrite } = await setup();
|
||||||
|
fake.addUser('aaa.bbb.ccc', { id: 'u1', labels: ['admin'] });
|
||||||
|
|
||||||
|
const user = await appwrite.accountFromJwt('aaa.bbb.ccc');
|
||||||
|
assert.equal(user.$id, 'u1');
|
||||||
|
assert.deepEqual(user.labels, ['admin']);
|
||||||
|
const call = lastCall(fake);
|
||||||
|
assert.equal(call.path, '/v1/account');
|
||||||
|
assert.equal(call.headers['x-appwrite-jwt'], 'aaa.bbb.ccc');
|
||||||
|
assert.equal(call.headers['x-appwrite-project'], fake.project);
|
||||||
|
assert.equal(call.headers['x-appwrite-key'], undefined, 'das Konto wird nie mit dem Server-Key abgefragt');
|
||||||
|
|
||||||
|
assert.equal(await appwrite.accountFromJwt('xxx.yyy.zzz'), null);
|
||||||
|
|
||||||
|
fake.failNext((c) => c.path === '/v1/account', { status: 500 });
|
||||||
|
await assert.rejects(() => appwrite.accountFromJwt('aaa.bbb.ccc'), (err) => err instanceof AppwriteError && err.status === 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Pfadsegmente: jede ID wird kodiert, "." und ".." werden abgelehnt, bevor eine Anfrage rausgeht', async () => {
|
||||||
|
const { fake, appwrite } = await setup();
|
||||||
|
|
||||||
|
await appwrite.getRow('gallery', '../x');
|
||||||
|
assert.equal(lastCall(fake).path, '/v1/tablesdb/aircard/tables/gallery/rows/..%2Fx');
|
||||||
|
await appwrite.getFile('gallery-images', 'a b/c');
|
||||||
|
assert.equal(lastCall(fake).path, '/v1/storage/buckets/gallery-images/files/a%20b%2Fc');
|
||||||
|
|
||||||
|
const before = fake.calls.length;
|
||||||
|
for (const bad of ['..', '.', '', 42, null, undefined]) {
|
||||||
|
await assert.rejects(() => appwrite.getRow('gallery', bad), /Pfadsegment/);
|
||||||
|
await assert.rejects(() => appwrite.deleteFile('gallery-images', bad), /Pfadsegment/);
|
||||||
|
}
|
||||||
|
await assert.rejects(() => appwrite.getRow('..', 'x'), /Pfadsegment/);
|
||||||
|
assert.equal(fake.calls.length, before, 'es ging keine Anfrage raus');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zeitlimit: Appwrite-Aufrufe tragen standardmäßig AbortSignal.timeout(10000)', async () => {
|
||||||
|
let seenSignal = null;
|
||||||
|
const appwrite = createAppwrite({
|
||||||
|
endpoint: 'http://appwrite.invalid/v1',
|
||||||
|
project: 'p',
|
||||||
|
key: 'k',
|
||||||
|
fetchImpl: async (url, init) => {
|
||||||
|
seenSignal = init.signal;
|
||||||
|
return new Response(JSON.stringify({ $id: 'x' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await appwrite.getRow('gallery', 'x');
|
||||||
|
assert.ok(seenSignal instanceof AbortSignal);
|
||||||
|
assert.equal(seenSignal.aborted, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zeitlimit: hängender Appwrite-Aufruf endet mit AppwriteError (timeout)', async () => {
|
||||||
|
const { fake, appwrite } = await setup({ timeoutMs: 80 });
|
||||||
|
fake.hangNext((call) => call.path.endsWith('/rows/slow'));
|
||||||
|
await assert.rejects(
|
||||||
|
() => appwrite.getRow('gallery', 'slow'),
|
||||||
|
(err) => err instanceof AppwriteError && err.type === 'timeout' && err.status === 0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Netzfehler: AppwriteError (unreachable) ohne Key im Text', async () => {
|
||||||
|
const appwrite = createAppwrite({
|
||||||
|
endpoint: 'http://appwrite.invalid/v1',
|
||||||
|
project: 'p',
|
||||||
|
key: 'GEHEIMER-KEY-123',
|
||||||
|
fetchImpl: async () => {
|
||||||
|
throw new TypeError('fetch failed GEHEIMER-KEY-123');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
() => appwrite.getRow('gallery', 'x'),
|
||||||
|
(err) => err instanceof AppwriteError && err.type === 'unreachable' && !String(err.message).includes('GEHEIMER-KEY-123'),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start des API-Dienstes `aircard-api` (Node 22, nur Bordmittel): liest die Umgebung, baut createApp und
|
||||||
|
* lauscht auf einem Port. Beschreibung der Endpunkte: app.js und README.md.
|
||||||
|
*
|
||||||
|
* Umgebung:
|
||||||
|
* AIRCARD_API_KEY Pflicht Appwrite-Server-Key `aircard-api` (rows.read/write, files.read/write)
|
||||||
|
* AIRCARD_API_SALT Pflicht Salz für die Melder-Hashes (32 Byte hex)
|
||||||
|
* AIRCARD_APPWRITE_PROJECT_ID Pflicht Projekt-ID
|
||||||
|
* AIRCARD_APPWRITE_ENDPOINT http://webklar-appwrite/v1
|
||||||
|
* AIRCARD_API_PORT 8088
|
||||||
|
* Fehlt etwas Pflichtiges, nennt die Meldung nur den Namen der Variable (nie Werte) und der Prozess endet mit Exit-Code 1.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const http = require('node:http');
|
||||||
|
const { createAppwrite } = require('./appwrite.js');
|
||||||
|
const { createApp } = require('./app.js');
|
||||||
|
|
||||||
|
const DEFAULT_PORT = 8088;
|
||||||
|
const DEFAULT_ENDPOINT = 'http://webklar-appwrite/v1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft die Umgebung, ohne etwas zu starten.
|
||||||
|
* @returns {{ok: true, config: object, warnings: string[]} | {ok: false, error: string}}
|
||||||
|
*/
|
||||||
|
function loadConfig(env) {
|
||||||
|
const read = (name) => (typeof env[name] === 'string' ? env[name].trim() : '');
|
||||||
|
|
||||||
|
const missing = ['AIRCARD_API_KEY', 'AIRCARD_API_SALT', 'AIRCARD_APPWRITE_PROJECT_ID'].filter((name) => read(name) === '');
|
||||||
|
if (missing.length > 0) {
|
||||||
|
return { ok: false, error: `${missing.join(', ')} ${missing.length > 1 ? 'sind' : 'ist'} nicht gesetzt - Abbruch` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = read('AIRCARD_APPWRITE_ENDPOINT') || DEFAULT_ENDPOINT;
|
||||||
|
if (!/^https?:\/\/\S+$/.test(endpoint)) {
|
||||||
|
return { ok: false, error: 'AIRCARD_APPWRITE_ENDPOINT muss mit http:// oder https:// beginnen - Abbruch' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const portText = read('AIRCARD_API_PORT');
|
||||||
|
const port = portText === '' ? DEFAULT_PORT : Number(portText);
|
||||||
|
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
||||||
|
return { ok: false, error: 'AIRCARD_API_PORT ist keine gültige Portnummer - Abbruch' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const warnings = [];
|
||||||
|
if (read('AIRCARD_API_SALT').length < 16) warnings.push('AIRCARD_API_SALT ist sehr kurz (empfohlen: 32 Byte hex)');
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
warnings,
|
||||||
|
config: {
|
||||||
|
port,
|
||||||
|
endpoint,
|
||||||
|
project: read('AIRCARD_APPWRITE_PROJECT_ID'),
|
||||||
|
key: read('AIRCARD_API_KEY'),
|
||||||
|
salt: read('AIRCARD_API_SALT'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const say = (message) => console.log(`[aircard-api] ${new Date().toISOString()} ${message}`);
|
||||||
|
|
||||||
|
const loaded = loadConfig(process.env);
|
||||||
|
if (!loaded.ok) {
|
||||||
|
console.error(`[aircard-api] ${loaded.error}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
for (const warning of loaded.warnings) say(`Warnung: ${warning}`);
|
||||||
|
const { port, endpoint, project, key, salt } = loaded.config;
|
||||||
|
|
||||||
|
const appwrite = createAppwrite({ endpoint, project, key });
|
||||||
|
const app = createApp({ appwrite, salt, log: console.log });
|
||||||
|
const server = http.createServer(app);
|
||||||
|
|
||||||
|
// Langsame oder hängende Clients nicht ewig halten; Keep-Alive länger als Traefiks Leerlauf (90 s),
|
||||||
|
// damit Traefik keine Verbindung wiederverwendet, die Node gerade schließt.
|
||||||
|
server.headersTimeout = 10_000;
|
||||||
|
server.requestTimeout = 15_000;
|
||||||
|
server.keepAliveTimeout = 95_000;
|
||||||
|
|
||||||
|
server.listen(port, () => say(`listening on :${server.address().port} (Appwrite: ${endpoint})`));
|
||||||
|
|
||||||
|
const shutdown = (signal) => {
|
||||||
|
say(`${signal} received, shutting down`);
|
||||||
|
server.close(() => process.exit(0));
|
||||||
|
server.closeIdleConnections();
|
||||||
|
setTimeout(() => process.exit(0), 5000).unref();
|
||||||
|
};
|
||||||
|
process.once('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.once('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { loadConfig };
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Tests für deploy/api/server.js: Umgebungsprüfung und ein Start als echter Prozess gegen einen Fake-Appwrite.
|
||||||
|
// Aufruf: node --test "deploy/api/*.test.js"
|
||||||
|
|
||||||
|
const { test, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const http = require('node:http');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { spawn } = require('node:child_process');
|
||||||
|
|
||||||
|
const { loadConfig } = require('./server.js');
|
||||||
|
const { startFakeAppwrite } = require('./fake-appwrite.js');
|
||||||
|
|
||||||
|
const SERVER = path.join(__dirname, 'server.js');
|
||||||
|
const KEY = 'KEY-test-secret-value';
|
||||||
|
const SALT = 'salt-test-secret-0123456789abcdef0123456789';
|
||||||
|
|
||||||
|
const children = [];
|
||||||
|
const fakes = [];
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const child of children.splice(0)) {
|
||||||
|
if (child.exitCode === null && child.signalCode === null) {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
await new Promise((resolve) => child.once('exit', resolve));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const fake of fakes.splice(0)) await fake.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function validEnv(extra = {}) {
|
||||||
|
return {
|
||||||
|
AIRCARD_API_KEY: KEY,
|
||||||
|
AIRCARD_API_SALT: SALT,
|
||||||
|
AIRCARD_APPWRITE_PROJECT_ID: 'proj-test',
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function startProcess(env) {
|
||||||
|
const child = spawn(process.execPath, [SERVER], { env: { PATH: process.env.PATH, ...env }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
children.push(child);
|
||||||
|
const output = { text: '' };
|
||||||
|
child.stdout.on('data', (chunk) => (output.text += chunk));
|
||||||
|
child.stderr.on('data', (chunk) => (output.text += chunk));
|
||||||
|
const exited = new Promise((resolve) => child.once('exit', (code, signal) => resolve({ code, signal })));
|
||||||
|
return { child, output, exited };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(condition, what, timeoutMs = 5000) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (!condition()) {
|
||||||
|
if (Date.now() > deadline) throw new Error(`Zeitüberschreitung beim Warten auf: ${what}`);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function httpRequest(port, method, urlPath, { headers = {}, body } = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const payload = body === undefined ? undefined : JSON.stringify(body);
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port,
|
||||||
|
method,
|
||||||
|
path: urlPath,
|
||||||
|
headers: payload === undefined ? headers : { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), ...headers },
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null') }));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on('error', reject);
|
||||||
|
if (payload !== undefined) req.write(payload);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('loadConfig: Standardwerte und gelesene Werte', () => {
|
||||||
|
const result = loadConfig(validEnv());
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.deepEqual(result.config, {
|
||||||
|
port: 8088,
|
||||||
|
endpoint: 'http://webklar-appwrite/v1',
|
||||||
|
project: 'proj-test',
|
||||||
|
key: KEY,
|
||||||
|
salt: SALT,
|
||||||
|
});
|
||||||
|
assert.deepEqual(result.warnings, []);
|
||||||
|
|
||||||
|
const custom = loadConfig(validEnv({ AIRCARD_API_PORT: '9100', AIRCARD_APPWRITE_ENDPOINT: 'https://appwrite.example/v1' }));
|
||||||
|
assert.equal(custom.config.port, 9100);
|
||||||
|
assert.equal(custom.config.endpoint, 'https://appwrite.example/v1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadConfig: fehlender Key, Salz oder Projekt wird mit dem Namen der Variable gemeldet, nie mit Werten', () => {
|
||||||
|
for (const name of ['AIRCARD_API_KEY', 'AIRCARD_API_SALT', 'AIRCARD_APPWRITE_PROJECT_ID']) {
|
||||||
|
for (const value of [undefined, '', ' ']) {
|
||||||
|
const env = validEnv();
|
||||||
|
if (value === undefined) delete env[name];
|
||||||
|
else env[name] = value;
|
||||||
|
const result = loadConfig(env);
|
||||||
|
assert.equal(result.ok, false, `${name}=${JSON.stringify(value)}`);
|
||||||
|
assert.ok(result.error.includes(name));
|
||||||
|
assert.ok(!result.error.includes(KEY) && !result.error.includes(SALT));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const none = loadConfig({});
|
||||||
|
assert.equal(none.ok, false);
|
||||||
|
assert.ok(none.error.includes('AIRCARD_API_KEY') && none.error.includes('AIRCARD_API_SALT'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadConfig: ungültiger Port oder Endpunkt, kurzes Salz warnt', () => {
|
||||||
|
assert.equal(loadConfig(validEnv({ AIRCARD_API_PORT: 'abc' })).ok, false);
|
||||||
|
assert.equal(loadConfig(validEnv({ AIRCARD_API_PORT: '70000' })).ok, false);
|
||||||
|
assert.equal(loadConfig(validEnv({ AIRCARD_API_PORT: '-1' })).ok, false);
|
||||||
|
assert.equal(loadConfig(validEnv({ AIRCARD_APPWRITE_ENDPOINT: 'webklar-appwrite/v1' })).ok, false);
|
||||||
|
const short = loadConfig(validEnv({ AIRCARD_API_SALT: 'xyz-9' }));
|
||||||
|
assert.equal(short.ok, true);
|
||||||
|
assert.equal(short.warnings.length, 1);
|
||||||
|
assert.ok(short.warnings[0].includes('AIRCARD_API_SALT') && !short.warnings[0].includes('xyz-9'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Start ohne Key oder ohne Salz: Exit-Code 1, Meldung ohne Werte', async () => {
|
||||||
|
for (const missing of ['AIRCARD_API_KEY', 'AIRCARD_API_SALT']) {
|
||||||
|
const env = validEnv({ AIRCARD_API_PORT: '0' });
|
||||||
|
delete env[missing];
|
||||||
|
const { output, exited } = startProcess(env);
|
||||||
|
const { code } = await exited;
|
||||||
|
assert.equal(code, 1, missing);
|
||||||
|
assert.ok(output.text.includes(missing));
|
||||||
|
assert.ok(output.text.includes('[aircard-api]'));
|
||||||
|
assert.ok(!output.text.includes(KEY) && !output.text.includes(SALT));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Start als Prozess: Gesundheit, Melden über Fake-Appwrite mit Server-Key, sauberes Beenden bei SIGTERM', async () => {
|
||||||
|
const fake = await startFakeAppwrite({ project: 'proj-test', key: KEY });
|
||||||
|
fakes.push(fake);
|
||||||
|
fake.addRow(
|
||||||
|
'gallery',
|
||||||
|
'card-1',
|
||||||
|
{ title: 'Karte', imageFileId: 'i', previewFileId: 'p', ownerId: 'user-me', hidden: false, reportCount: 0 },
|
||||||
|
['read("any")'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { child, output, exited } = startProcess(validEnv({ AIRCARD_API_PORT: '0', AIRCARD_APPWRITE_ENDPOINT: fake.endpoint }));
|
||||||
|
await waitFor(() => /listening on :\d+/.test(output.text), 'Startmeldung');
|
||||||
|
const port = Number(/listening on :(\d+)/.exec(output.text)[1]);
|
||||||
|
|
||||||
|
const health = await httpRequest(port, 'GET', '/api/gesundheit');
|
||||||
|
assert.deepEqual(health.json, { ok: true });
|
||||||
|
|
||||||
|
const reported = await httpRequest(port, 'POST', '/api/melden', {
|
||||||
|
body: { id: 'card-1', reason: 'spam' },
|
||||||
|
headers: { 'X-Real-Ip': '203.0.113.5' },
|
||||||
|
});
|
||||||
|
assert.equal(reported.status, 200);
|
||||||
|
assert.deepEqual(reported.json, { ok: true });
|
||||||
|
assert.equal(fake.row('gallery', 'card-1').reportCount, 1);
|
||||||
|
assert.ok(fake.calls.length > 0);
|
||||||
|
for (const call of fake.calls) assert.equal(call.headers['x-appwrite-key'], KEY);
|
||||||
|
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
const { code } = await exited;
|
||||||
|
assert.equal(code, 0);
|
||||||
|
assert.ok(!output.text.includes(KEY) && !output.text.includes(SALT), 'Ausgabe enthält weder Key noch Salz');
|
||||||
|
assert.ok(!output.text.includes('203.0.113.5'), 'Ausgabe enthält keine IP');
|
||||||
|
for (const line of output.text.trim().split('\n')) assert.ok(line.startsWith('[aircard-api]'), line);
|
||||||
|
});
|
||||||
@@ -3,10 +3,12 @@
|
|||||||
# URL: aircard-ios.org
|
# URL: aircard-ios.org
|
||||||
# Repository: https://git.webklar.com/knso/AirCardIOS
|
# Repository: https://git.webklar.com/knso/AirCardIOS
|
||||||
# Einfügen unter `services:` direkt nach dem eship-Block (siehe deploy/README.md).
|
# Einfügen unter `services:` direkt nach dem eship-Block (siehe deploy/README.md).
|
||||||
# Starten NUR mit: docker compose up -d aircard-build aircard
|
# Starten NUR mit: docker compose up -d aircard-build aircard aircard-api
|
||||||
# (ein nacktes `up -d` würde andere Dienste wieder anlegen)
|
# (ein nacktes `up -d` würde andere Dienste wieder anlegen)
|
||||||
# Die Appwrite-Route liegt als Datei in traefik/dynamic/aircard.yml.
|
# Die Appwrite-Route liegt als Datei in traefik/dynamic/aircard.yml (Router `aircard-api@file`, Pfad /v1).
|
||||||
# AIRCARD_APPWRITE_PROJECT_ID kommt aus /home/webklar/apps/.env.
|
# Der API-Dienst `aircard-api` hängt per Label am Pfad /api (Router `aircard-gallery-api`, ein anderer Name,
|
||||||
|
# damit er nicht mit der Datei-Route kollidiert).
|
||||||
|
# AIRCARD_APPWRITE_PROJECT_ID, AIRCARD_API_KEY und AIRCARD_API_SALT kommen aus /home/webklar/apps/.env.
|
||||||
# ==============================================
|
# ==============================================
|
||||||
aircard-build:
|
aircard-build:
|
||||||
image: node:22-alpine
|
image: node:22-alpine
|
||||||
@@ -80,3 +82,39 @@
|
|||||||
# - "traefik.http.middlewares.aircard-www.redirectregex.regex=^https?://www\\.aircard-ios\\.org(.*)"
|
# - "traefik.http.middlewares.aircard-www.redirectregex.regex=^https?://www\\.aircard-ios\\.org(.*)"
|
||||||
# - "traefik.http.middlewares.aircard-www.redirectregex.replacement=https://aircard-ios.org$${1}"
|
# - "traefik.http.middlewares.aircard-www.redirectregex.replacement=https://aircard-ios.org$${1}"
|
||||||
# - "traefik.http.middlewares.aircard-www.redirectregex.permanent=true"
|
# - "traefik.http.middlewares.aircard-www.redirectregex.permanent=true"
|
||||||
|
|
||||||
|
# --- API-Dienst der Community-Galerie (deploy/api/, siehe deploy/api/README.md) ---
|
||||||
|
# Ohne AIRCARD_API_KEY oder AIRCARD_API_SALT in der .env beendet sich der Dienst mit Exit-Code 1.
|
||||||
|
# Kein `ports:`: erreichbar nur über Traefik (der Dienst vertraut dem Header X-Real-Ip).
|
||||||
|
aircard-api:
|
||||||
|
image: node:22-alpine
|
||||||
|
container_name: webklar-aircard-api
|
||||||
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
mem_limit: 128m
|
||||||
|
user: node
|
||||||
|
read_only: true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
working_dir: /srv
|
||||||
|
environment:
|
||||||
|
- AIRCARD_API_PORT=8088
|
||||||
|
- AIRCARD_APPWRITE_ENDPOINT=http://webklar-appwrite/v1
|
||||||
|
- AIRCARD_APPWRITE_PROJECT_ID=${AIRCARD_APPWRITE_PROJECT_ID}
|
||||||
|
- AIRCARD_API_KEY=${AIRCARD_API_KEY}
|
||||||
|
- AIRCARD_API_SALT=${AIRCARD_API_SALT}
|
||||||
|
- NODE_OPTIONS=--max-old-space-size=64
|
||||||
|
volumes:
|
||||||
|
- ./aircard-ios.org/api:/srv:ro
|
||||||
|
networks:
|
||||||
|
- webklar-network
|
||||||
|
command: node /srv/server.js
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.aircard-gallery-api.rule=Host(`aircard-ios.org`) && PathPrefix(`/api`)"
|
||||||
|
- "traefik.http.routers.aircard-gallery-api.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.aircard-gallery-api.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.routers.aircard-gallery-api.priority=200"
|
||||||
|
- "traefik.http.services.aircard-gallery-api.loadbalancer.server.port=8088"
|
||||||
Reference in new issue
Block a user