fixed missing endpoint GET /routes/:id/pois/:id and added json style fileupload
This commit is contained in:
parent
5c85a0194b
commit
09ce7c0605
192
docs/REST-API.md
192
docs/REST-API.md
@ -28,9 +28,13 @@ https://wegwichtel.example.org/api
|
|||||||
|
|
||||||
- Listen- und Metadatenzugriffe liefern JSON.
|
- Listen- und Metadatenzugriffe liefern JSON.
|
||||||
- Die Einzelendpunkte für GPX, Bilder und Audio liefern die jeweilige Datei direkt aus. Mit `?metadata=true` liefern die Bild- und Audioendpunkte stattdessen JSON-Metadaten.
|
- Die Einzelendpunkte für GPX, Bilder und Audio liefern die jeweilige Datei direkt aus. Mit `?metadata=true` liefern die Bild- und Audioendpunkte stattdessen JSON-Metadaten.
|
||||||
- Schreibzugriffe ohne Dateien akzeptieren JSON, `application/x-www-form-urlencoded` oder `multipart/form-data`.
|
- Schreibzugriffe ohne Dateien akzeptieren `application/json`, `application/x-www-form-urlencoded` oder `multipart/form-data`.
|
||||||
- Schreibzugriffe mit Dateien verwenden `multipart/form-data`.
|
- GPX-Dateien werden weiterhin ausschließlich als `multipart/form-data` übertragen.
|
||||||
|
- Bild- und Audioendpunkte akzeptieren wahlweise `multipart/form-data` oder ein JSON-Objekt mit Base64-kodierten Dateidaten.
|
||||||
|
- Für JSON wird `Content-Type: application/json` empfohlen. `Content-Type: text/json` wird aus Kompatibilitätsgründen ebenfalls akzeptiert.
|
||||||
|
- Ein leeres JSON-Objekt (`{}`) enthält keine Datei und kann deshalb keinen Bild- oder Audio-Upload ausführen.
|
||||||
- Pro Medien-Request wird genau eine Bild- beziehungsweise Audiodatei verarbeitet.
|
- Pro Medien-Request wird genau eine Bild- beziehungsweise Audiodatei verarbeitet.
|
||||||
|
- Base64 vergrößert die Requestgröße um ungefähr ein Drittel und benötigt beim Verarbeiten zusätzlichen Arbeitsspeicher. Für große Dateien ist `multipart/form-data` vorzuziehen.
|
||||||
- Zeitstempel werden als SQLite- oder ISO-8601-Text ausgegeben.
|
- Zeitstempel werden als SQLite- oder ISO-8601-Text ausgegeben.
|
||||||
- Die aktuelle API besitzt keine Authentifizierung. Schreibzugriffe dürfen nicht ungeschützt öffentlich erreichbar sein.
|
- Die aktuelle API besitzt keine Authentifizierung. Schreibzugriffe dürfen nicht ungeschützt öffentlich erreichbar sein.
|
||||||
|
|
||||||
@ -48,12 +52,23 @@ Unterstützte Dateitypen:
|
|||||||
|
|
||||||
Die Uploadfelder heißen:
|
Die Uploadfelder heißen:
|
||||||
|
|
||||||
| Ressource | Feldname |
|
| Ressource | Multipart-Feld beziehungsweise JSON-Eigenschaft |
|
||||||
|---|---|
|
|---|---|
|
||||||
| GPX | `gpx` |
|
| GPX | `gpx` |
|
||||||
| einzelnes Bild | `picture` |
|
| einzelnes Bild | `picture` |
|
||||||
| einzelne Audiodatei | `audio` |
|
| einzelne Audiodatei | `audio` |
|
||||||
|
|
||||||
|
Bei einem JSON-Medienupload ist `picture` beziehungsweise `audio` ein Objekt:
|
||||||
|
|
||||||
|
| Eigenschaft | Typ | Pflicht | Beschreibung |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `filename` | Text | ja | ursprünglicher Dateiname einschließlich passender Endung |
|
||||||
|
| `contentType` | Text | ja, sofern keine `dataUrl` den Typ enthält | MIME-Typ, beispielsweise `image/jpeg` oder `audio/mpeg` |
|
||||||
|
| `base64` | Text | ja, sofern `dataUrl` fehlt | reiner Base64-Inhalt ohne Präfix |
|
||||||
|
| `dataUrl` | Text | alternativ zu `base64` | vollständige Base64-Data-URL, beispielsweise `data:image/png;base64,...` |
|
||||||
|
|
||||||
|
`base64` und `dataUrl` sind Alternativen. Wird `dataUrl` verwendet, kann `contentType` daraus übernommen werden. `filename` bleibt erforderlich, weil die Dateiendung für Ablage und Auslieferung benötigt wird.
|
||||||
|
|
||||||
### Fehlerformat
|
### Fehlerformat
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@ -184,9 +199,9 @@ Pro POI kann höchstens eine Audiodatei existieren. Deshalb wird die Audioressou
|
|||||||
| `DELETE` | `/api/routes/:id` | Route weich löschen |
|
| `DELETE` | `/api/routes/:id` | Route weich löschen |
|
||||||
| `POST` | `/api/routes/:id/restore` | Route wiederherstellen |
|
| `POST` | `/api/routes/:id/restore` | Route wiederherstellen |
|
||||||
| `GET` | `/api/routes/:id/pois` | POIs einer Route auflisten |
|
| `GET` | `/api/routes/:id/pois` | POIs einer Route auflisten |
|
||||||
| `GET` | `/api/pois/:id` | einzelnen POI lesen |
|
| `GET` | `/api/routes/:routeId/pois/:poiId` | einzelnen POI der Route lesen |
|
||||||
| `POST` | `/api/routes/:id/pois` | POI-Metadaten anlegen |
|
| `POST` | `/api/routes/:id/pois` | POI-Metadaten anlegen |
|
||||||
| `PUT` | `/api/pois/:id` | POI-Metadaten aktualisieren |
|
| `PUT` | `/api/routes/:routeId/pois/:poiId` | POI-Metadaten aktualisieren |
|
||||||
| `GET` | `/api/routes/:routeId/pois/:poiId/pictures` | Bilder eines POIs auflisten |
|
| `GET` | `/api/routes/:routeId/pois/:poiId/pictures` | Bilder eines POIs auflisten |
|
||||||
| `GET` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` | Bilddatei ausliefern; optional Metadaten mit `?metadata=true` |
|
| `GET` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` | Bilddatei ausliefern; optional Metadaten mit `?metadata=true` |
|
||||||
| `POST` | `/api/routes/:routeId/pois/:poiId/pictures` | einzelnes Bild für den POI hochladen |
|
| `POST` | `/api/routes/:routeId/pois/:poiId/pictures` | einzelnes Bild für den POI hochladen |
|
||||||
@ -352,14 +367,21 @@ Liefert alle POIs einer aktiven Route nach `sequence` und `id`.
|
|||||||
curl http://127.0.0.1:47145/api/routes/1/pois
|
curl http://127.0.0.1:47145/api/routes/1/pois
|
||||||
```
|
```
|
||||||
|
|
||||||
### `GET /api/pois/:id`
|
### `GET /api/routes/:routeId/pois/:poiId`
|
||||||
|
|
||||||
Liefert einen einzelnen POI einschließlich seiner aktuellen Bild- und Audio-URLs.
|
Liefert einen einzelnen POI einschließlich seiner aktuellen Bild- und Audio-URLs. Route und POI werden gemeinsam geprüft; der POI muss zur angegebenen aktiven Route gehören.
|
||||||
|
|
||||||
|
| Pfadparameter | Typ | Pflicht | Beschreibung |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `routeId` | Ganzzahl | ja | ID der aktiven Route |
|
||||||
|
| `poiId` | Ganzzahl | ja | ID des POIs innerhalb dieser Route |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:47145/api/pois/7
|
curl http://127.0.0.1:47145/api/routes/1/pois/7
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Eine unbekannte Route, ein unbekannter POI oder eine falsche Route-POI-Kombination liefert `404 Not Found`.
|
||||||
|
|
||||||
### `POST /api/routes/:id/pois`
|
### `POST /api/routes/:id/pois`
|
||||||
|
|
||||||
Legt ausschließlich die POI-Metadaten an. Bilder und Audio werden anschließend über die gesonderten Medienendpunkte hochgeladen.
|
Legt ausschließlich die POI-Metadaten an. Bilder und Audio werden anschließend über die gesonderten Medienendpunkte hochgeladen.
|
||||||
@ -388,11 +410,16 @@ curl -X POST http://127.0.0.1:47145/api/routes/1/pois \
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Erfolg: `201 Created` und `Location: /api/pois/<id>`.
|
Erfolg: `201 Created` und `Location: /api/routes/<routeId>/pois/<poiId>`.
|
||||||
|
|
||||||
### `PUT /api/pois/:id`
|
### `PUT /api/routes/:routeId/pois/:poiId`
|
||||||
|
|
||||||
Aktualisiert ausschließlich POI-Metadaten. Nicht übergebene Werte bleiben erhalten.
|
Aktualisiert ausschließlich POI-Metadaten. Nicht übergebene Werte bleiben erhalten. Route und POI werden gemeinsam validiert; ein POI kann über diesen Endpunkt keiner anderen Route zugeordnet werden.
|
||||||
|
|
||||||
|
| Pfadparameter | Typ | Pflicht | Beschreibung |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `routeId` | Ganzzahl | ja | ID der aktiven Route |
|
||||||
|
| `poiId` | Ganzzahl | ja | ID des POIs innerhalb dieser Route |
|
||||||
|
|
||||||
| Feld | Typ | Pflicht | Beschreibung |
|
| Feld | Typ | Pflicht | Beschreibung |
|
||||||
|---|---|---:|---|
|
|---|---|---:|---|
|
||||||
@ -404,7 +431,7 @@ Aktualisiert ausschließlich POI-Metadaten. Nicht übergebene Werte bleiben erha
|
|||||||
| `sequence` | nichtnegative Ganzzahl | nein | neue Reihenfolge |
|
| `sequence` | nichtnegative Ganzzahl | nein | neue Reihenfolge |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X PUT http://127.0.0.1:47145/api/pois/7 \
|
curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7 \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{
|
-d '{
|
||||||
"title": "Die sehr alte Eiche",
|
"title": "Die sehr alte Eiche",
|
||||||
@ -479,9 +506,9 @@ Die API antwortet mit `404`, wenn Route, POI oder Bild nicht zusammengehören.
|
|||||||
|
|
||||||
### `POST /api/routes/:routeId/pois/:poiId/pictures`
|
### `POST /api/routes/:routeId/pois/:poiId/pictures`
|
||||||
|
|
||||||
Lädt genau ein Bild für den im Pfad angegebenen POI hoch.
|
Lädt genau ein Bild für den im Pfad angegebenen POI hoch. Zulässig sind zwei Übertragungsformen.
|
||||||
|
|
||||||
Content-Type: `multipart/form-data`
|
#### Variante A: `multipart/form-data`
|
||||||
|
|
||||||
| Feld | Typ | Pflicht | Standard | Beschreibung |
|
| Feld | Typ | Pflicht | Standard | Beschreibung |
|
||||||
|---|---|---:|---:|---|
|
|---|---|---:|---:|---|
|
||||||
@ -496,6 +523,60 @@ curl -X POST http://127.0.0.1:47145/api/routes/1/pois/7/pictures \
|
|||||||
-F 'picture=@eiche.jpg;type=image/jpeg'
|
-F 'picture=@eiche.jpg;type=image/jpeg'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Variante B: JSON mit Base64
|
||||||
|
|
||||||
|
Empfohlener Content-Type: `application/json`. Der Server akzeptiert zusätzlich `text/json`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"caption": "Blick auf die Baumkrone",
|
||||||
|
"sequence": 0,
|
||||||
|
"picture": {
|
||||||
|
"filename": "eiche.jpg",
|
||||||
|
"contentType": "image/jpeg",
|
||||||
|
"base64": "/9j/4AAQSkZJRgABAQ..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Beispiel mit `text/json` und einer separat erzeugten Payload-Datei:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
base64 < eiche.jpg | tr -d '\n' > eiche.jpg.b64
|
||||||
|
|
||||||
|
jq -n \
|
||||||
|
--arg caption 'Blick auf die Baumkrone' \
|
||||||
|
--argjson sequence 0 \
|
||||||
|
--rawfile data eiche.jpg.b64 \
|
||||||
|
'{
|
||||||
|
caption: $caption,
|
||||||
|
sequence: $sequence,
|
||||||
|
picture: {
|
||||||
|
filename: "eiche.jpg",
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
base64: $data
|
||||||
|
}
|
||||||
|
}' > picture.json
|
||||||
|
|
||||||
|
curl -X POST http://127.0.0.1:47145/api/routes/1/pois/7/pictures \
|
||||||
|
-H 'Content-Type: text/json' \
|
||||||
|
--data-binary @picture.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternativ kann eine Data-URL übertragen werden:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"caption": "Blick auf die Baumkrone",
|
||||||
|
"picture": {
|
||||||
|
"filename": "eiche.png",
|
||||||
|
"dataUrl": "data:image/png;base64,iVBORw0KGgoAAA..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein Request mit `-d '{}'` schlägt mit `400 Bad Request` fehl, weil weder eine Multipart-Datei noch ein JSON-Dateiobjekt enthalten ist.
|
||||||
|
|
||||||
Erfolg: `201 Created` und `Location: /api/routes/1/pois/7/pictures/<pictureId>`.
|
Erfolg: `201 Created` und `Location: /api/routes/1/pois/7/pictures/<pictureId>`.
|
||||||
|
|
||||||
### `PUT /api/routes/:routeId/pois/:poiId/pictures/:pictureId`
|
### `PUT /api/routes/:routeId/pois/:poiId/pictures/:pictureId`
|
||||||
@ -504,7 +585,7 @@ Aktualisiert Metadaten und kann optional die Datei ersetzen. Nicht übergebene M
|
|||||||
|
|
||||||
| Feld | Typ | Pflicht | Beschreibung |
|
| Feld | Typ | Pflicht | Beschreibung |
|
||||||
|---|---|---:|---|
|
|---|---|---:|---|
|
||||||
| `picture` | Datei | nein | ersetzt die bisherige Bilddatei |
|
| `picture` | Multipart-Datei oder JSON-Dateiobjekt | nein | ersetzt die bisherige Bilddatei |
|
||||||
| `caption` | Text | nein | neue Bildbeschreibung; leerer Text entfernt die Beschreibung |
|
| `caption` | Text | nein | neue Bildbeschreibung; leerer Text entfernt die Beschreibung |
|
||||||
| `sequence` | nichtnegative Ganzzahl | nein | neue Position in der Diashow |
|
| `sequence` | nichtnegative Ganzzahl | nein | neue Position in der Diashow |
|
||||||
|
|
||||||
@ -519,7 +600,7 @@ curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 \
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Datei und Metadaten ersetzen:
|
Datei und Metadaten per Multipart ersetzen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 \
|
curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 \
|
||||||
@ -528,6 +609,26 @@ curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 \
|
|||||||
-F 'picture=@eiche-neu.webp;type=image/webp'
|
-F 'picture=@eiche-neu.webp;type=image/webp'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Datei und Metadaten per JSON ersetzen:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"caption": "Neue Aufnahme der Eiche",
|
||||||
|
"sequence": 2,
|
||||||
|
"picture": {
|
||||||
|
"filename": "eiche-neu.webp",
|
||||||
|
"contentType": "image/webp",
|
||||||
|
"base64": "UklGRiQAAABXRUJQVlA4..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data-binary @picture-update.json
|
||||||
|
```
|
||||||
|
|
||||||
Wird eine Datei ersetzt, entfernt der Server die bisherige Datei nach erfolgreicher Datenbankaktualisierung.
|
Wird eine Datei ersetzt, entfernt der Server die bisherige Datei nach erfolgreicher Datenbankaktualisierung.
|
||||||
|
|
||||||
### `DELETE /api/routes/:routeId/pois/:poiId/pictures/:pictureId`
|
### `DELETE /api/routes/:routeId/pois/:poiId/pictures/:pictureId`
|
||||||
@ -584,9 +685,9 @@ Antwortet mit `404`, wenn Route und POI nicht zusammengehören oder der POI kein
|
|||||||
|
|
||||||
### `POST /api/routes/:routeId/pois/:poiId/audio`
|
### `POST /api/routes/:routeId/pois/:poiId/audio`
|
||||||
|
|
||||||
Legt die Audiodatei des im Pfad angegebenen POIs an.
|
Legt die Audiodatei des im Pfad angegebenen POIs an. Zulässig sind `multipart/form-data` und JSON mit Base64.
|
||||||
|
|
||||||
Content-Type: `multipart/form-data`
|
#### Variante A: `multipart/form-data`
|
||||||
|
|
||||||
| Feld | Typ | Pflicht | Beschreibung |
|
| Feld | Typ | Pflicht | Beschreibung |
|
||||||
|---|---|---:|---|
|
|---|---|---:|---|
|
||||||
@ -597,23 +698,74 @@ curl -X POST http://127.0.0.1:47145/api/routes/1/pois/7/audio \
|
|||||||
-F 'audio=@ansage.mp3;type=audio/mpeg'
|
-F 'audio=@ansage.mp3;type=audio/mpeg'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Variante B: JSON mit Base64
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"audio": {
|
||||||
|
"filename": "ansage.mp3",
|
||||||
|
"contentType": "audio/mpeg",
|
||||||
|
"base64": "SUQzBAAAAAAAI1RTU0UAAA..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
base64 < ansage.mp3 | tr -d '\n' > ansage.mp3.b64
|
||||||
|
|
||||||
|
jq -n --rawfile data ansage.mp3.b64 \
|
||||||
|
'{
|
||||||
|
audio: {
|
||||||
|
filename: "ansage.mp3",
|
||||||
|
contentType: "audio/mpeg",
|
||||||
|
base64: $data
|
||||||
|
}
|
||||||
|
}' > audio.json
|
||||||
|
|
||||||
|
curl -X POST http://127.0.0.1:47145/api/routes/1/pois/7/audio \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data-binary @audio.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Auch hier wird `Content-Type: text/json` akzeptiert. Ein leeres `{}` enthält keine Audiodatei und liefert `400 Bad Request`.
|
||||||
|
|
||||||
Erfolg: `201 Created` und `Location: /api/routes/1/pois/7/audio`.
|
Erfolg: `201 Created` und `Location: /api/routes/1/pois/7/audio`.
|
||||||
|
|
||||||
Existiert bereits eine Audiodatei, antwortet der Server mit `409`. Zum Ersetzen ist `PUT` zu verwenden.
|
Existiert bereits eine Audiodatei, antwortet der Server mit `409`. Zum Ersetzen ist `PUT` zu verwenden.
|
||||||
|
|
||||||
### `PUT /api/routes/:routeId/pois/:poiId/audio`
|
### `PUT /api/routes/:routeId/pois/:poiId/audio`
|
||||||
|
|
||||||
Ersetzt die vorhandene Audiodatei des POIs.
|
Ersetzt die vorhandene Audiodatei des POIs. Die Datei kann als Multipart-Upload oder als JSON-Dateiobjekt übertragen werden.
|
||||||
|
|
||||||
| Feld | Typ | Pflicht | Beschreibung |
|
| Feld | Typ | Pflicht | Beschreibung |
|
||||||
|---|---|---:|---|
|
|---|---|---:|---|
|
||||||
| `audio` | Datei | ja | neue Audiodatei |
|
| `audio` | Multipart-Datei oder JSON-Dateiobjekt | ja | neue Audiodatei |
|
||||||
|
|
||||||
|
Multipart-Beispiel:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/audio \
|
curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/audio \
|
||||||
-F 'audio=@ansage-neu.ogg;type=audio/ogg'
|
-F 'audio=@ansage-neu.ogg;type=audio/ogg'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
JSON-Beispiel:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"audio": {
|
||||||
|
"filename": "ansage-neu.ogg",
|
||||||
|
"contentType": "audio/ogg",
|
||||||
|
"base64": "T2dnUwACAAAAAAAAAAB..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/audio \
|
||||||
|
-H 'Content-Type: text/json' \
|
||||||
|
--data-binary @audio-update.json
|
||||||
|
```
|
||||||
|
|
||||||
Der Server entfernt die bisherige Datei nach erfolgreicher Aktualisierung. Besitzt der POI noch keine Audiodatei, antwortet der Server mit `404`; zum erstmaligen Anlegen ist `POST` zu verwenden.
|
Der Server entfernt die bisherige Datei nach erfolgreicher Aktualisierung. Besitzt der POI noch keine Audiodatei, antwortet der Server mit `404`; zum erstmaligen Anlegen ist `POST` zu verwenden.
|
||||||
|
|
||||||
### `DELETE /api/routes/:routeId/pois/:poiId/audio`
|
### `DELETE /api/routes/:routeId/pois/:poiId/audio`
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "wegwichtel-next",
|
"name": "wegwichtel-next",
|
||||||
"version": "0.10.0",
|
"version": "0.11.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "wegwichtel-next",
|
"name": "wegwichtel-next",
|
||||||
"version": "0.10.0",
|
"version": "0.11.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wegwichtel-next",
|
"name": "wegwichtel-next",
|
||||||
"version": "0.10.0",
|
"version": "0.11.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "GPS-gestützte Lern- und Wanderwege mit GPX, POIs, Bildern und Audioansagen.",
|
"description": "GPS-gestützte Lern- und Wanderwege mit GPX, POIs, Bildern und Audioansagen.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@ -12,7 +12,10 @@ const db = openDatabase();
|
|||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.disable('x-powered-by');
|
app.disable('x-powered-by');
|
||||||
app.use(express.json({ limit: '2mb' }));
|
app.use(express.json({
|
||||||
|
limit: config.maxJsonBodyBytes,
|
||||||
|
type: ['application/json', 'application/*+json', 'text/json']
|
||||||
|
}));
|
||||||
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
|
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
|
||||||
app.use('/api', createApiRouter(db));
|
app.use('/api', createApiRouter(db));
|
||||||
app.use(express.static(path.join(__dirname, 'public'), { extensions: ['html'] }));
|
app.use(express.static(path.join(__dirname, 'public'), { extensions: ['html'] }));
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
|
|
||||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const resolveFromRoot = (value, fallback) => path.resolve(projectRoot, value || fallback);
|
const resolveFromRoot = (value, fallback) => path.resolve(projectRoot, value || fallback);
|
||||||
|
const maxUploadBytes = Number.parseInt(process.env.MAX_UPLOAD_MB || '50', 10) * 1024 * 1024;
|
||||||
|
|
||||||
export const config = Object.freeze({
|
export const config = Object.freeze({
|
||||||
projectRoot,
|
projectRoot,
|
||||||
@ -11,7 +12,8 @@ export const config = Object.freeze({
|
|||||||
port: Number.parseInt(process.env.PORT || '47145', 10),
|
port: Number.parseInt(process.env.PORT || '47145', 10),
|
||||||
dataDir: resolveFromRoot(process.env.DATA_DIR, 'data'),
|
dataDir: resolveFromRoot(process.env.DATA_DIR, 'data'),
|
||||||
storageDir: resolveFromRoot(process.env.STORAGE_DIR, 'storage'),
|
storageDir: resolveFromRoot(process.env.STORAGE_DIR, 'storage'),
|
||||||
maxUploadBytes: Number.parseInt(process.env.MAX_UPLOAD_MB || '50', 10) * 1024 * 1024,
|
maxUploadBytes,
|
||||||
|
maxJsonBodyBytes: Math.ceil(maxUploadBytes * 4 / 3) + 1024 * 1024,
|
||||||
defaultRouteRadiusKm: Number.parseFloat(process.env.DEFAULT_ROUTE_RADIUS_KM || '25'),
|
defaultRouteRadiusKm: Number.parseFloat(process.env.DEFAULT_ROUTE_RADIUS_KM || '25'),
|
||||||
defaultPoiTriggerMeters: Number.parseFloat(process.env.DEFAULT_POI_TRIGGER_METERS || '80')
|
defaultPoiTriggerMeters: Number.parseFloat(process.env.DEFAULT_POI_TRIGGER_METERS || '80')
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
@ -28,3 +29,87 @@ export const upload = multer({
|
|||||||
limits: { fileSize: config.maxUploadBytes, files: 25 },
|
limits: { fileSize: config.maxUploadBytes, files: 25 },
|
||||||
fileFilter
|
fileFilter
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
function decodeBase64(value, fieldName) {
|
||||||
|
if (typeof value !== 'string' || !value.trim()) {
|
||||||
|
throw new HttpError(400, `${fieldName}.base64 muss eine nichtleere Base64-Zeichenkette sein.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const compact = value.replace(/\s+/g, '');
|
||||||
|
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(compact) || compact.length % 4 === 1) {
|
||||||
|
throw new HttpError(400, `${fieldName}.base64 enthält keine gültigen Base64-Daten.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(compact, 'base64');
|
||||||
|
const canonicalInput = compact.replace(/=+$/, '');
|
||||||
|
const canonicalOutput = buffer.toString('base64').replace(/=+$/, '');
|
||||||
|
if (canonicalInput !== canonicalOutput) {
|
||||||
|
throw new HttpError(400, `${fieldName}.base64 enthält keine gültigen Base64-Daten.`);
|
||||||
|
}
|
||||||
|
if (buffer.length > config.maxUploadBytes) {
|
||||||
|
throw new HttpError(413, `Die Datei überschreitet das Limit von ${Math.floor(config.maxUploadBytes / 1024 / 1024)} MB.`);
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonFileDescriptor(value, fieldName) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
throw new HttpError(400, `${fieldName} muss ein JSON-Objekt sein.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalname = String(value.filename ?? '').trim();
|
||||||
|
if (!originalname) throw new HttpError(400, `${fieldName}.filename ist erforderlich.`);
|
||||||
|
|
||||||
|
let mimetype = String(value.contentType ?? value.mimeType ?? '').trim().toLowerCase();
|
||||||
|
let base64 = value.base64;
|
||||||
|
|
||||||
|
if (value.dataUrl != null) {
|
||||||
|
if (typeof value.dataUrl !== 'string') {
|
||||||
|
throw new HttpError(400, `${fieldName}.dataUrl muss eine Zeichenkette sein.`);
|
||||||
|
}
|
||||||
|
const match = /^data:([^;,]+);base64,(.*)$/s.exec(value.dataUrl.trim());
|
||||||
|
if (!match) {
|
||||||
|
throw new HttpError(400, `${fieldName}.dataUrl muss eine Base64-Data-URL sein.`);
|
||||||
|
}
|
||||||
|
if (!mimetype) mimetype = match[1].trim().toLowerCase();
|
||||||
|
base64 = match[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mimetype) throw new HttpError(400, `${fieldName}.contentType ist erforderlich.`);
|
||||||
|
return { originalname, mimetype, buffer: decodeBase64(base64, fieldName) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jsonUpload(fieldName) {
|
||||||
|
return function parseJsonUpload(req, res, next) {
|
||||||
|
try {
|
||||||
|
if (req.file || req.body?.[fieldName] == null) return next();
|
||||||
|
|
||||||
|
const descriptor = jsonFileDescriptor(req.body[fieldName], fieldName);
|
||||||
|
if (!descriptor) return next();
|
||||||
|
|
||||||
|
const destination = path.join(config.storageDir, 'uploads');
|
||||||
|
fs.mkdirSync(destination, { recursive: true });
|
||||||
|
const safe = descriptor.originalname.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
const filename = `${Date.now()}-${crypto.randomUUID()}-${safe}`;
|
||||||
|
const filePath = path.join(destination, filename);
|
||||||
|
fs.writeFileSync(filePath, descriptor.buffer, { flag: 'wx' });
|
||||||
|
|
||||||
|
req.file = {
|
||||||
|
fieldname: fieldName,
|
||||||
|
originalname: descriptor.originalname,
|
||||||
|
encoding: '7bit',
|
||||||
|
mimetype: descriptor.mimetype,
|
||||||
|
destination,
|
||||||
|
filename,
|
||||||
|
path: filePath,
|
||||||
|
size: descriptor.buffer.length
|
||||||
|
};
|
||||||
|
delete req.body[fieldName];
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { upload } from '../middleware/upload.js';
|
import { jsonUpload, upload } from '../middleware/upload.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import {
|
import {
|
||||||
listRoutes, getRoute, getRouteGpxFile, createRoute, updateRoute, appendRoute,
|
listRoutes, getRoute, getRouteGpxFile, createRoute, updateRoute, appendRoute,
|
||||||
@ -64,8 +64,8 @@ export function createApiRouter(db) {
|
|||||||
res.json({ pois: listPois(db, Number(req.params.id)) });
|
res.json({ pois: listPois(db, Number(req.params.id)) });
|
||||||
});
|
});
|
||||||
|
|
||||||
api.get('/pois/:id', (req, res) => {
|
api.get('/routes/:routeId/pois/:poiId', (req, res) => {
|
||||||
res.json(getPoi(db, Number(req.params.id)));
|
res.json(getPoi(db, Number(req.params.routeId), Number(req.params.poiId)));
|
||||||
});
|
});
|
||||||
|
|
||||||
api.post('/routes', upload.single('gpx'), (req, res) => {
|
api.post('/routes', upload.single('gpx'), (req, res) => {
|
||||||
@ -83,11 +83,18 @@ export function createApiRouter(db) {
|
|||||||
|
|
||||||
api.post('/routes/:id/pois', upload.none(), (req, res) => {
|
api.post('/routes/:id/pois', upload.none(), (req, res) => {
|
||||||
const poi = createPoi(db, Number(req.params.id), req.body);
|
const poi = createPoi(db, Number(req.params.id), req.body);
|
||||||
res.status(201).location(`/api/pois/${poi.id}`).json(poi);
|
res.status(201)
|
||||||
|
.location(`/api/routes/${req.params.id}/pois/${poi.id}`)
|
||||||
|
.json(poi);
|
||||||
});
|
});
|
||||||
|
|
||||||
api.put('/pois/:id', upload.none(), (req, res) => {
|
api.put('/routes/:routeId/pois/:poiId', upload.none(), (req, res) => {
|
||||||
res.json(updatePoi(db, Number(req.params.id), req.body));
|
res.json(updatePoi(
|
||||||
|
db,
|
||||||
|
Number(req.params.routeId),
|
||||||
|
Number(req.params.poiId),
|
||||||
|
req.body
|
||||||
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
api.get('/routes/:routeId/pois/:poiId/pictures', (req, res) => {
|
api.get('/routes/:routeId/pois/:poiId/pictures', (req, res) => {
|
||||||
@ -105,7 +112,7 @@ export function createApiRouter(db) {
|
|||||||
sendFileResource(res, getPictureFile(db, routeId, poiId, pictureId));
|
sendFileResource(res, getPictureFile(db, routeId, poiId, pictureId));
|
||||||
});
|
});
|
||||||
|
|
||||||
api.post('/routes/:routeId/pois/:poiId/pictures', upload.single('picture'), (req, res) => {
|
api.post('/routes/:routeId/pois/:poiId/pictures', upload.single('picture'), jsonUpload('picture'), (req, res) => {
|
||||||
const routeId = Number(req.params.routeId);
|
const routeId = Number(req.params.routeId);
|
||||||
const poiId = Number(req.params.poiId);
|
const poiId = Number(req.params.poiId);
|
||||||
const picture = createPicture(db, routeId, poiId, req.body, req.file);
|
const picture = createPicture(db, routeId, poiId, req.body, req.file);
|
||||||
@ -114,7 +121,7 @@ export function createApiRouter(db) {
|
|||||||
.json(picture);
|
.json(picture);
|
||||||
});
|
});
|
||||||
|
|
||||||
api.put('/routes/:routeId/pois/:poiId/pictures/:pictureId', upload.single('picture'), (req, res) => {
|
api.put('/routes/:routeId/pois/:poiId/pictures/:pictureId', upload.single('picture'), jsonUpload('picture'), (req, res) => {
|
||||||
res.json(updatePicture(
|
res.json(updatePicture(
|
||||||
db,
|
db,
|
||||||
Number(req.params.routeId),
|
Number(req.params.routeId),
|
||||||
@ -144,7 +151,7 @@ export function createApiRouter(db) {
|
|||||||
sendFileResource(res, getAudioFile(db, routeId, poiId));
|
sendFileResource(res, getAudioFile(db, routeId, poiId));
|
||||||
});
|
});
|
||||||
|
|
||||||
api.post('/routes/:routeId/pois/:poiId/audio', upload.single('audio'), (req, res) => {
|
api.post('/routes/:routeId/pois/:poiId/audio', upload.single('audio'), jsonUpload('audio'), (req, res) => {
|
||||||
const routeId = Number(req.params.routeId);
|
const routeId = Number(req.params.routeId);
|
||||||
const poiId = Number(req.params.poiId);
|
const poiId = Number(req.params.poiId);
|
||||||
const audio = createAudio(db, routeId, poiId, req.file);
|
const audio = createAudio(db, routeId, poiId, req.file);
|
||||||
@ -153,7 +160,7 @@ export function createApiRouter(db) {
|
|||||||
.json(audio);
|
.json(audio);
|
||||||
});
|
});
|
||||||
|
|
||||||
api.put('/routes/:routeId/pois/:poiId/audio', upload.single('audio'), (req, res) => {
|
api.put('/routes/:routeId/pois/:poiId/audio', upload.single('audio'), jsonUpload('audio'), (req, res) => {
|
||||||
res.json(updateAudio(db, Number(req.params.routeId), Number(req.params.poiId), req.file));
|
res.json(updateAudio(db, Number(req.params.routeId), Number(req.params.poiId), req.file));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -151,10 +151,11 @@ export function listPois(db, routeId) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPoi(db, poiId) {
|
export function getPoi(db, routeId, poiId) {
|
||||||
const row = db.prepare(`SELECT p.route_id AS routeId FROM pois p JOIN routes r ON r.id = p.route_id WHERE p.id = ? AND r.status = 'active'`).get(poiId);
|
const row = db.prepare(`SELECT p.id FROM pois p JOIN routes r ON r.id = p.route_id
|
||||||
|
WHERE p.id = ? AND p.route_id = ? AND r.status = 'active'`).get(poiId, routeId);
|
||||||
if (!row) throw new HttpError(404, 'POI nicht gefunden.');
|
if (!row) throw new HttpError(404, 'POI nicht gefunden.');
|
||||||
return listPois(db, row.routeId).find(poi => poi.id === Number(poiId));
|
return listPois(db, routeId).find(poi => poi.id === Number(poiId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPoi(db, routeId, fields) {
|
export function createPoi(db, routeId, fields) {
|
||||||
@ -171,8 +172,9 @@ export function createPoi(db, routeId, fields) {
|
|||||||
return listPois(db, routeId).find(poi => poi.id === Number(result.lastInsertRowid));
|
return listPois(db, routeId).find(poi => poi.id === Number(result.lastInsertRowid));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updatePoi(db, poiId, fields) {
|
export function updatePoi(db, routeId, poiId, fields) {
|
||||||
const existing = db.prepare(`SELECT p.*, r.status FROM pois p JOIN routes r ON r.id = p.route_id WHERE p.id = ?`).get(poiId);
|
const existing = db.prepare(`SELECT p.*, r.status FROM pois p JOIN routes r ON r.id = p.route_id
|
||||||
|
WHERE p.id = ? AND p.route_id = ?`).get(poiId, routeId);
|
||||||
if (!existing || existing.status !== 'active') throw new HttpError(404, 'POI nicht gefunden.');
|
if (!existing || existing.status !== 'active') throw new HttpError(404, 'POI nicht gefunden.');
|
||||||
const lat = fields.lat == null ? existing.lat : Number.parseFloat(fields.lat);
|
const lat = fields.lat == null ? existing.lat : Number.parseFloat(fields.lat);
|
||||||
const lon = fields.lon == null ? existing.lon : Number.parseFloat(fields.lon);
|
const lon = fields.lon == null ? existing.lon : Number.parseFloat(fields.lon);
|
||||||
|
|||||||
@ -20,12 +20,12 @@ test('complete REST API documentation is kept outside the README', async () => {
|
|||||||
'GET /api/routes/:id',
|
'GET /api/routes/:id',
|
||||||
'GET /api/routes/:id/gpx',
|
'GET /api/routes/:id/gpx',
|
||||||
'GET /api/routes/:id/pois',
|
'GET /api/routes/:id/pois',
|
||||||
'GET /api/pois/:id',
|
'GET /api/routes/:routeId/pois/:poiId',
|
||||||
'POST /api/routes',
|
'POST /api/routes',
|
||||||
'PUT /api/routes/:id',
|
'PUT /api/routes/:id',
|
||||||
'POST /api/routes/:id/append',
|
'POST /api/routes/:id/append',
|
||||||
'POST /api/routes/:id/pois',
|
'POST /api/routes/:id/pois',
|
||||||
'PUT /api/pois/:id',
|
'PUT /api/routes/:routeId/pois/:poiId',
|
||||||
'GET /api/routes/:routeId/pois/:poiId/pictures',
|
'GET /api/routes/:routeId/pois/:poiId/pictures',
|
||||||
'GET /api/routes/:routeId/pois/:poiId/pictures/:pictureId',
|
'GET /api/routes/:routeId/pois/:poiId/pictures/:pictureId',
|
||||||
'POST /api/routes/:routeId/pois/:poiId/pictures',
|
'POST /api/routes/:routeId/pois/:poiId/pictures',
|
||||||
@ -44,7 +44,8 @@ test('complete REST API documentation is kept outside the README', async () => {
|
|||||||
for (const parameter of [
|
for (const parameter of [
|
||||||
'lat', 'lon', 'radiusKm', 'includeDeleted', 'gpx', 'name', 'slug',
|
'lat', 'lon', 'radiusKm', 'includeDeleted', 'gpx', 'name', 'slug',
|
||||||
'description', 'schoolName', 'title', 'triggerRadiusM', 'sequence',
|
'description', 'schoolName', 'title', 'triggerRadiusM', 'sequence',
|
||||||
'picture', 'pictureId', 'caption', 'audio', 'poiId', 'metadata'
|
'picture', 'pictureId', 'caption', 'audio', 'poiId', 'metadata',
|
||||||
|
'filename', 'contentType', 'base64', 'dataUrl'
|
||||||
]) {
|
]) {
|
||||||
assert.match(api, new RegExp(`\\b${parameter}\\b`), `missing parameter documentation: ${parameter}`);
|
assert.match(api, new RegExp(`\\b${parameter}\\b`), `missing parameter documentation: ${parameter}`);
|
||||||
}
|
}
|
||||||
@ -54,16 +55,20 @@ test('POI media uploads use POI-scoped single-resource endpoints', async () => {
|
|||||||
const router = await read('src/routes/api.js');
|
const router = await read('src/routes/api.js');
|
||||||
const routesService = await read('src/services/routes-service.js');
|
const routesService = await read('src/services/routes-service.js');
|
||||||
const mediaService = await read('src/services/media-service.js');
|
const mediaService = await read('src/services/media-service.js');
|
||||||
|
const api = await read('docs/REST-API.md');
|
||||||
|
|
||||||
assert.match(router, /post\('\/routes\/:routeId\/pois\/:poiId\/pictures', upload\.single\('picture'\)/);
|
assert.match(router, /post\('\/routes\/:routeId\/pois\/:poiId\/pictures', upload\.single\('picture'\), jsonUpload\('picture'\)/);
|
||||||
assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId', upload\.single\('picture'\)/);
|
assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId', upload\.single\('picture'\), jsonUpload\('picture'\)/);
|
||||||
assert.match(router, /delete\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId'/);
|
assert.match(router, /delete\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId'/);
|
||||||
assert.match(router, /get\('\/routes\/:routeId\/pois\/:poiId\/audio'/);
|
assert.match(router, /get\('\/routes\/:routeId\/pois\/:poiId\/audio'/);
|
||||||
assert.match(router, /post\('\/routes\/:routeId\/pois\/:poiId\/audio', upload\.single\('audio'\)/);
|
assert.match(router, /post\('\/routes\/:routeId\/pois\/:poiId\/audio', upload\.single\('audio'\), jsonUpload\('audio'\)/);
|
||||||
assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/audio', upload\.single\('audio'\)/);
|
assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/audio', upload\.single\('audio'\), jsonUpload\('audio'\)/);
|
||||||
assert.match(router, /delete\('\/routes\/:routeId\/pois\/:poiId\/audio'/);
|
assert.match(router, /delete\('\/routes\/:routeId\/pois\/:poiId\/audio'/);
|
||||||
assert.doesNotMatch(router, /\/routes\/:id\/(?:pictures|audio)/);
|
assert.doesNotMatch(router, /\/routes\/:id\/(?:pictures|audio)/);
|
||||||
assert.match(router, /post\('\/routes\/:id\/pois', upload\.none\(\),/);
|
assert.match(router, /post\('\/routes\/:id\/pois', upload\.none\(\),/);
|
||||||
|
assert.match(router, /get\('\/routes\/:routeId\/pois\/:poiId'/);
|
||||||
|
assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId', upload\.none\(\)/);
|
||||||
|
assert.doesNotMatch(router, /['"]\/pois\/:id['"]/);
|
||||||
assert.doesNotMatch(routesService, /files\.images|files\.audio/);
|
assert.doesNotMatch(routesService, /files\.images|files\.audio/);
|
||||||
assert.match(mediaService, /caption/);
|
assert.match(mediaService, /caption/);
|
||||||
assert.match(mediaService, /removeStoredFile/);
|
assert.match(mediaService, /removeStoredFile/);
|
||||||
@ -73,4 +78,8 @@ test('POI media uploads use POI-scoped single-resource endpoints', async () => {
|
|||||||
assert.doesNotMatch(router, /['"`]\/media\//);
|
assert.doesNotMatch(router, /['"`]\/media\//);
|
||||||
assert.doesNotMatch(routesService, /safeMediaUrl|\/media\//);
|
assert.doesNotMatch(routesService, /safeMediaUrl|\/media\//);
|
||||||
assert.doesNotMatch(mediaService, /safeMediaUrl|\/media\//);
|
assert.doesNotMatch(mediaService, /safeMediaUrl|\/media\//);
|
||||||
|
assert.match(router, /jsonUpload/);
|
||||||
|
assert.match(api, /Content-Type: `application\/json`/);
|
||||||
|
assert.match(api, /`text\/json`/);
|
||||||
|
assert.match(api, /Base64/);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -114,6 +114,27 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
|
|||||||
})
|
})
|
||||||
}, 201);
|
}, 201);
|
||||||
|
|
||||||
|
const { body: fetchedPoi } = await requestJson(
|
||||||
|
`${baseUrl}/api/routes/${route.id}/pois/${poi.id}`
|
||||||
|
);
|
||||||
|
assert.equal(fetchedPoi.id, poi.id);
|
||||||
|
assert.equal(fetchedPoi.routeId, route.id);
|
||||||
|
assert.equal(fetchedPoi.title, 'Teststation');
|
||||||
|
|
||||||
|
const { body: updatedPoi } = await requestJson(
|
||||||
|
`${baseUrl}/api/routes/${route.id}/pois/${poi.id}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title: 'Aktualisierte Teststation' })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert.equal(updatedPoi.title, 'Aktualisierte Teststation');
|
||||||
|
|
||||||
|
await requestJson(`${baseUrl}/api/routes/999999/pois/${poi.id}`, {}, 404);
|
||||||
|
await requestJson(`${baseUrl}/api/routes/${route.id}/pois/999999`, {}, 404);
|
||||||
|
await requestJson(`${baseUrl}/api/pois/${poi.id}`, {}, 404);
|
||||||
|
|
||||||
const pictureForm = new FormData();
|
const pictureForm = new FormData();
|
||||||
pictureForm.append('poiId', String(otherPoi.id));
|
pictureForm.append('poiId', String(otherPoi.id));
|
||||||
pictureForm.append('caption', 'Erste Bildbeschreibung');
|
pictureForm.append('caption', 'Erste Bildbeschreibung');
|
||||||
@ -144,6 +165,40 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
|
|||||||
assert.equal(pictureList.pictures.length, 1);
|
assert.equal(pictureList.pictures.length, 1);
|
||||||
assert.equal(pictureList.pictures[0].id, picture.id);
|
assert.equal(pictureList.pictures[0].id, picture.id);
|
||||||
|
|
||||||
|
const jsonPicturePayload = {
|
||||||
|
caption: 'Bild aus JSON',
|
||||||
|
sequence: 1,
|
||||||
|
picture: {
|
||||||
|
filename: 'bild-json.webp',
|
||||||
|
contentType: 'image/webp',
|
||||||
|
base64: Buffer.from('picture-json').toString('base64')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const { body: jsonPicture } = await requestJson(
|
||||||
|
`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'text/json' },
|
||||||
|
body: JSON.stringify(jsonPicturePayload)
|
||||||
|
},
|
||||||
|
201
|
||||||
|
);
|
||||||
|
assert.equal(jsonPicture.poiId, otherPoi.id);
|
||||||
|
assert.equal(jsonPicture.caption, 'Bild aus JSON');
|
||||||
|
const { response: jsonPictureResponse, body: jsonPictureFile } = await requestBuffer(`${baseUrl}${jsonPicture.url}`);
|
||||||
|
assert.match(jsonPictureResponse.headers.get('content-type') || '', /image\/webp/);
|
||||||
|
assert.equal(jsonPictureFile.toString(), 'picture-json');
|
||||||
|
|
||||||
|
await requestJson(
|
||||||
|
`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'text/json' },
|
||||||
|
body: '{}'
|
||||||
|
},
|
||||||
|
400
|
||||||
|
);
|
||||||
|
|
||||||
const { body: updatedPicture } = await requestJson(
|
const { body: updatedPicture } = await requestJson(
|
||||||
`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`,
|
`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`,
|
||||||
{
|
{
|
||||||
@ -178,11 +233,20 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
|
|||||||
body: duplicateAudio
|
body: duplicateAudio
|
||||||
}, 409);
|
}, 409);
|
||||||
|
|
||||||
const replacementAudio = new FormData();
|
const replacementAudio = {
|
||||||
replacementAudio.append('audio', new Blob(['audio-two'], { type: 'audio/ogg' }), 'ansage-neu.ogg');
|
audio: {
|
||||||
|
filename: 'ansage-neu.ogg',
|
||||||
|
contentType: 'audio/ogg',
|
||||||
|
base64: Buffer.from('audio-two').toString('base64')
|
||||||
|
}
|
||||||
|
};
|
||||||
const { body: replacedAudio } = await requestJson(
|
const { body: replacedAudio } = await requestJson(
|
||||||
`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`,
|
`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`,
|
||||||
{ method: 'PUT', body: replacementAudio }
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(replacementAudio)
|
||||||
|
}
|
||||||
);
|
);
|
||||||
assert.equal(replacedAudio.url, `/api/routes/${route.id}/pois/${poi.id}/audio`);
|
assert.equal(replacedAudio.url, `/api/routes/${route.id}/pois/${poi.id}/audio`);
|
||||||
const { response: replacedAudioResponse, body: replacedAudioFile } = await requestBuffer(`${baseUrl}${replacedAudio.url}`);
|
const { response: replacedAudioResponse, body: replacedAudioFile } = await requestBuffer(`${baseUrl}${replacedAudio.url}`);
|
||||||
@ -214,6 +278,12 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
|
|||||||
);
|
);
|
||||||
assert.equal(deletedPicture.deleted, true);
|
assert.equal(deletedPicture.deleted, true);
|
||||||
|
|
||||||
|
const { body: deletedJsonPicture } = await requestJson(
|
||||||
|
`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures/${jsonPicture.id}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
);
|
||||||
|
assert.equal(deletedJsonPicture.deleted, true);
|
||||||
|
|
||||||
await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`, {}, 404);
|
await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`, {}, 404);
|
||||||
await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`, {}, 404);
|
await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`, {}, 404);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user