remove /media endpoint
This commit is contained in:
parent
c98f304ffa
commit
833b5ad3a4
@ -66,7 +66,7 @@ public/vendor/jquery-ui/jquery-ui-1.14.2.min.css
|
|||||||
public/vendor/jquery-ui/images/*.png
|
public/vendor/jquery-ui/images/*.png
|
||||||
```
|
```
|
||||||
|
|
||||||
Die Anwendung verwendet aus jQuery UI insbesondere das Widget **Button**. Das offizielle vollständige jQuery-UI-Bundle bleibt lokal verfügbar, damit weitere aktuelle Widgets ohne erneuten CDN-Bezug ergänzt werden können. Die frühere jQuery-Mobile-Seitensteuerung wurde durch eine eigene, History-API-basierte Navigation ersetzt.
|
Die Anwendung verwendet aus jQuery UI insbesondere die Widgets **Button** und **Controlgroup**. Das offizielle vollständige jQuery-UI-Bundle bleibt lokal verfügbar, damit weitere aktuelle Widgets ohne erneuten CDN-Bezug ergänzt werden können. Die frühere jQuery-Mobile-Seitensteuerung wurde durch eine eigene, History-API-basierte Navigation ersetzt.
|
||||||
|
|
||||||
## API-Dokumentation
|
## API-Dokumentation
|
||||||
|
|
||||||
@ -93,7 +93,8 @@ test/ Basistests
|
|||||||
|
|
||||||
## Technische Hinweise
|
## Technische Hinweise
|
||||||
|
|
||||||
- Medienpfade werden relativ zu `storage/` gespeichert. So bleibt das Projekt verschiebbar. Bild- und Audiodateien werden der Clientanwendung ausschließlich über `/api/routes/:id/pictures/:pictureId` beziehungsweise `/api/routes/:id/audio?poiId=:poiId` bereitgestellt.
|
- Medienpfade werden relativ zu `storage/` gespeichert. So bleibt das Projekt verschiebbar.
|
||||||
|
- Öffentliche GPX-, Bild- und Audiodateien werden ausschließlich über die zugehörigen `/api/routes/...`-Ressourcen ausgeliefert. Interne Speicherpfade und ein separates `/media`-URL-Schema werden nicht veröffentlicht.
|
||||||
- Dateiverschiebung und Datenbankänderung sind durch eine kompensierende Rückverschiebung gekoppelt: Schlägt die SQL-Transaktion fehl, wird das Verzeichnis an seinen vorherigen Ort zurückbewegt.
|
- Dateiverschiebung und Datenbankänderung sind durch eine kompensierende Rückverschiebung gekoppelt: Schlägt die SQL-Transaktion fehl, wird das Verzeichnis an seinen vorherigen Ort zurückbewegt.
|
||||||
- GPX-Erweiterungen ergänzen die Trackpunkte in SQLite. Das Original-GPX bleibt im Skelett unverändert; ein späterer Exportdienst sollte aus den Datenbankpunkten eine konsolidierte GPX-Datei generieren.
|
- GPX-Erweiterungen ergänzen die Trackpunkte in SQLite. Das Original-GPX bleibt im Skelett unverändert; ein späterer Exportdienst sollte aus den Datenbankpunkten eine konsolidierte GPX-Datei generieren.
|
||||||
- Schreibzugriffe sind noch nicht authentifiziert. Vor einem öffentlichen Einsatz sind Rollen, Login, CSRF-Schutz, Rate-Limits, Dateisignaturprüfung und ein Moderationsworkflow zwingend zu ergänzen.
|
- Schreibzugriffe sind noch nicht authentifiziert. Vor einem öffentlichen Einsatz sind Rollen, Login, CSRF-Schutz, Rate-Limits, Dateisignaturprüfung und ein Moderationsworkflow zwingend zu ergänzen.
|
||||||
|
|||||||
535
docs/REST-API.md
535
docs/REST-API.md
@ -1,6 +1,6 @@
|
|||||||
# Wegwichtel REST-API
|
# Wegwichtel REST-API
|
||||||
|
|
||||||
Diese Datei dokumentiert die vollständige HTTP-Schnittstelle des Wegwichtel-Servers. Die API-Basis lautet `/api`. Bild- und Audiodateien werden ebenfalls ausschließlich über API-Endpunkte ausgeliefert; die Clientanwendung verwendet dafür keine direkten Speicherpfade.
|
Diese Datei dokumentiert die vollständige HTTP-Schnittstelle des Wegwichtel-Servers. Sämtliche JSON-Ressourcen und aktiven GPX-, Bild- und Audiodateien werden unter `/api` bereitgestellt. Ein separates öffentliches `/media`-URL-Schema wird nicht verwendet.
|
||||||
|
|
||||||
## 1. Grundlagen
|
## 1. Grundlagen
|
||||||
|
|
||||||
@ -18,7 +18,7 @@ API-Basis:
|
|||||||
http://127.0.0.1:47145/api
|
http://127.0.0.1:47145/api
|
||||||
```
|
```
|
||||||
|
|
||||||
Bei vorgeschaltetem Nginx bleibt der Pfad erhalten, zum Beispiel:
|
Bei einer Nginx-Installation bleibt der Pfad gleich, beispielsweise:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://wegwichtel.example.org/api
|
https://wegwichtel.example.org/api
|
||||||
@ -26,23 +26,33 @@ https://wegwichtel.example.org/api
|
|||||||
|
|
||||||
### Formate
|
### Formate
|
||||||
|
|
||||||
- JSON-Lesezugriffe liefern `application/json`.
|
- Listen- und Metadatenzugriffe liefern JSON.
|
||||||
- Bild- und Audio-GETs liefern den jeweiligen binären Medientyp.
|
- 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 Datei akzeptieren JSON, URL-encoded Formulare oder `multipart/form-data`.
|
- Schreibzugriffe ohne Dateien akzeptieren JSON, `application/x-www-form-urlencoded` oder `multipart/form-data`.
|
||||||
- Datei-Uploads verwenden `multipart/form-data`.
|
- Schreibzugriffe mit Dateien verwenden `multipart/form-data`.
|
||||||
- Pro Medienrequest wird genau eine Datei verarbeitet.
|
- Pro Medien-Request wird genau eine Bild- beziehungsweise Audiodatei verarbeitet.
|
||||||
- Zeitstempel werden als SQLite- beziehungsweise ISO-8601-Text ausgegeben.
|
- Zeitstempel werden als SQLite- oder ISO-8601-Text ausgegeben.
|
||||||
- Die derzeitige API besitzt noch 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.
|
||||||
|
|
||||||
### Uploadgrenzen und unterstützte Dateitypen
|
### Uploadgrenzen und Dateitypen
|
||||||
|
|
||||||
Das Dateilimit pro Upload wird mit `MAX_UPLOAD_MB` festgelegt und beträgt standardmäßig `50 MB`.
|
Das Dateilimit pro Datei wird durch `MAX_UPLOAD_MB` festgelegt und beträgt standardmäßig `50 MB`.
|
||||||
|
|
||||||
| Ressource | Uploadfeld | Dateitypen |
|
Unterstützte Dateitypen:
|
||||||
|---|---|---|
|
|
||||||
| GPX | `gpx` | `.gpx`, `application/gpx+xml`, XML |
|
| Ressource | Dateiendungen beziehungsweise MIME-Typen |
|
||||||
| Bild | `picture` | JPEG, PNG, WebP |
|
|---|---|
|
||||||
| Audio | `audio` | MP3, MP4/M4A, AAC, Ogg, WAV, WebM |
|
| GPX | `.gpx`, `application/gpx+xml`, `application/xml`, `text/xml` |
|
||||||
|
| Bilder | JPEG, PNG, WebP |
|
||||||
|
| Audio | MP3, MP4/M4A, AAC, Ogg, WAV, WebM |
|
||||||
|
|
||||||
|
Die Uploadfelder heißen:
|
||||||
|
|
||||||
|
| Ressource | Feldname |
|
||||||
|
|---|---|
|
||||||
|
| GPX | `gpx` |
|
||||||
|
| einzelnes Bild | `picture` |
|
||||||
|
| einzelne Audiodatei | `audio` |
|
||||||
|
|
||||||
### Fehlerformat
|
### Fehlerformat
|
||||||
|
|
||||||
@ -59,23 +69,22 @@ Typische Statuscodes:
|
|||||||
|---:|---|
|
|---:|---|
|
||||||
| `200` | Anfrage erfolgreich |
|
| `200` | Anfrage erfolgreich |
|
||||||
| `201` | Ressource wurde angelegt |
|
| `201` | Ressource wurde angelegt |
|
||||||
| `400` | Parameter oder Datei fehlt beziehungsweise ist ungültig |
|
| `400` | Parameter oder Upload fehlt beziehungsweise ist ungültig |
|
||||||
| `404` | Route, POI oder Medienressource wurde nicht gefunden |
|
| `404` | Route, POI oder Medienressource wurde nicht gefunden |
|
||||||
| `409` | Ressource existiert bereits oder widerspricht dem aktuellen Zustand |
|
| `409` | Ressource existiert bereits oder Dateisystemzustand verhindert die Operation |
|
||||||
| `413` | Datei überschreitet `MAX_UPLOAD_MB` |
|
| `413` | Datei überschreitet `MAX_UPLOAD_MB` |
|
||||||
| `415` | Dateityp wird nicht unterstützt |
|
| `415` | Dateityp wird nicht unterstützt |
|
||||||
| `500` | Interner Serverfehler |
|
| `500` | Interner Serverfehler |
|
||||||
|
|
||||||
### IDs und Medienadressierung
|
### IDs
|
||||||
|
|
||||||
|
Alle IDs sind positive, von SQLite erzeugte Ganzzahlen.
|
||||||
|
|
||||||
- `:id` bezeichnet bei `/routes/:id/...` die Route.
|
- `:id` bezeichnet bei `/routes/:id/...` die Route.
|
||||||
- `:pictureId` bezeichnet ein einzelnes Bild aus `poi_images`.
|
- `:pictureId` bezeichnet einen Datensatz aus `poi_images`.
|
||||||
- `poiId` bezeichnet einen POI innerhalb einer Route.
|
- `:poiId` bezeichnet den POI und zugleich seine höchstens eine Audioressource.
|
||||||
- Ein POI kann mehrere Bilder, aber höchstens eine Audiodatei besitzen.
|
|
||||||
- Bilder erhalten deshalb eine eigene Pfad-ID.
|
|
||||||
- Audio verwendet keinen zusätzlichen Pfadabschnitt; der POI wird über `poiId` angegeben.
|
|
||||||
|
|
||||||
Beispiele verwenden Route `1`, POI `7` und Bild `15`.
|
Beispiele verwenden überwiegend Route `1`, POI `7` und Bild `15`.
|
||||||
|
|
||||||
## 2. Datenmodelle
|
## 2. Datenmodelle
|
||||||
|
|
||||||
@ -100,10 +109,11 @@ Beispiele verwenden Route `1`, POI `7` und Bild `15`.
|
|||||||
"distanceM": 842.6,
|
"distanceM": 842.6,
|
||||||
"elevationGainM": 14.2,
|
"elevationGainM": 14.2,
|
||||||
"pointCount": 87,
|
"pointCount": 87,
|
||||||
"gpxUrl": "/media/routes/1/route.gpx",
|
"gpxUrl": "/api/routes/1/gpx",
|
||||||
"createdAt": "2026-06-16 12:00:00",
|
"createdAt": "2026-06-16 12:00:00",
|
||||||
"updatedAt": "2026-06-16 12:00:00",
|
"updatedAt": "2026-06-16 12:00:00",
|
||||||
"deletedAt": null
|
"deletedAt": null,
|
||||||
|
"proximityM": 324.8
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -119,7 +129,7 @@ Beispiele verwenden Route `1`, POI `7` und Bild `15`.
|
|||||||
"lon": 13.407,
|
"lon": 13.407,
|
||||||
"triggerRadiusM": 60,
|
"triggerRadiusM": 60,
|
||||||
"sequence": 2,
|
"sequence": 2,
|
||||||
"audioUrl": "/api/routes/1/audio?poiId=7",
|
"audioUrl": "/api/routes/1/audio/7",
|
||||||
"images": [
|
"images": [
|
||||||
{
|
{
|
||||||
"id": 15,
|
"id": 15,
|
||||||
@ -131,7 +141,7 @@ Beispiele verwenden Route `1`, POI `7` und Bild `15`.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Bildmetadaten
|
### Bildressource
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@ -146,14 +156,16 @@ Beispiele verwenden Route `1`, POI `7` und Bild `15`.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Audiometadaten
|
### Audioressource
|
||||||
|
|
||||||
|
Pro POI kann höchstens eine Audiodatei existieren. Deshalb wird die Audioressource über die `poiId` adressiert.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"routeId": 1,
|
"routeId": 1,
|
||||||
"poiId": 7,
|
"poiId": 7,
|
||||||
"poiTitle": "Die alte Eiche",
|
"poiTitle": "Die alte Eiche",
|
||||||
"url": "/api/routes/1/audio?poiId=7",
|
"url": "/api/routes/1/audio/7",
|
||||||
"updatedAt": "2026-06-16 12:35:00"
|
"updatedAt": "2026-06-16 12:35:00"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -165,6 +177,7 @@ Beispiele verwenden Route `1`, POI `7` und Bild `15`.
|
|||||||
| `GET` | `/api/health` | Server- und SQLite-Zustand |
|
| `GET` | `/api/health` | Server- und SQLite-Zustand |
|
||||||
| `GET` | `/api/routes` | Routen auflisten |
|
| `GET` | `/api/routes` | Routen auflisten |
|
||||||
| `GET` | `/api/routes/:id` | Route mit Punkten und POIs lesen |
|
| `GET` | `/api/routes/:id` | Route mit Punkten und POIs lesen |
|
||||||
|
| `GET` | `/api/routes/:id/gpx` | GPX-Datei der Route ausliefern |
|
||||||
| `POST` | `/api/routes` | Route anlegen |
|
| `POST` | `/api/routes` | Route anlegen |
|
||||||
| `PUT` | `/api/routes/:id` | Route aktualisieren |
|
| `PUT` | `/api/routes/:id` | Route aktualisieren |
|
||||||
| `POST` | `/api/routes/:id/append` | GPX-Punkte anhängen |
|
| `POST` | `/api/routes/:id/append` | GPX-Punkte anhängen |
|
||||||
@ -172,21 +185,22 @@ Beispiele verwenden Route `1`, POI `7` und Bild `15`.
|
|||||||
| `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/pois/:id` | einzelnen POI lesen |
|
||||||
| `POST` | `/api/routes/:id/pois` | POI anlegen |
|
| `POST` | `/api/routes/:id/pois` | POI-Metadaten anlegen |
|
||||||
| `PUT` | `/api/pois/:id` | POI aktualisieren |
|
| `PUT` | `/api/pois/:id` | POI-Metadaten aktualisieren |
|
||||||
| `GET` | `/api/routes/:id/pictures` | Bildmetadaten auflisten |
|
| `GET` | `/api/routes/:id/pictures` | Bilder einer Route auflisten |
|
||||||
| `GET` | `/api/routes/:id/pictures/:pictureId` | Bilddatei ausliefern |
|
| `GET` | `/api/routes/:id/pictures/:pictureId` | Bilddatei ausliefern; optional Metadaten mit `?metadata=true` |
|
||||||
| `POST` | `/api/routes/:id/pictures` | Bild anlegen |
|
| `POST` | `/api/routes/:id/pictures` | einzelnes Bild hochladen |
|
||||||
| `PUT` | `/api/routes/:id/pictures/:pictureId` | Bilddatei oder Metadaten aktualisieren |
|
| `PUT` | `/api/routes/:id/pictures/:pictureId` | Bilddatei oder Metadaten aktualisieren |
|
||||||
| `DELETE` | `/api/routes/:id/pictures/:pictureId` | Bild löschen |
|
| `DELETE` | `/api/routes/:id/pictures/:pictureId` | einzelnes Bild löschen |
|
||||||
| `GET` | `/api/routes/:id/audio` | Audio auflisten oder für einen POI ausliefern |
|
| `GET` | `/api/routes/:id/audio` | Audiodateien einer Route auflisten |
|
||||||
| `POST` | `/api/routes/:id/audio` | Audio für einen POI anlegen |
|
| `GET` | `/api/routes/:id/audio/:poiId` | Audiodatei ausliefern; optional Metadaten mit `?metadata=true` |
|
||||||
| `PUT` | `/api/routes/:id/audio` | Audio eines POIs ersetzen |
|
| `POST` | `/api/routes/:id/audio` | Audiodatei für einen POI anlegen |
|
||||||
| `DELETE` | `/api/routes/:id/audio` | Audio eines POIs löschen |
|
| `PUT` | `/api/routes/:id/audio/:poiId` | Audiodatei eines POIs ersetzen |
|
||||||
|
| `DELETE` | `/api/routes/:id/audio/:poiId` | Audiodatei eines POIs löschen |
|
||||||
|
|
||||||
## 4. Systemzustand
|
## 4. Systemzustand
|
||||||
|
|
||||||
### GET /api/health
|
### `GET /api/health`
|
||||||
|
|
||||||
Parameter: keine.
|
Parameter: keine.
|
||||||
|
|
||||||
@ -194,8 +208,6 @@ Parameter: keine.
|
|||||||
curl http://127.0.0.1:47145/api/health
|
curl http://127.0.0.1:47145/api/health
|
||||||
```
|
```
|
||||||
|
|
||||||
Beispielantwort:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@ -206,20 +218,20 @@ Beispielantwort:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 5. Routen
|
## 5. Routen lesen
|
||||||
|
|
||||||
### GET /api/routes
|
### `GET /api/routes`
|
||||||
|
|
||||||
Listet aktive Routen auf. Werden `lat` und `lon` gemeinsam angegeben, berechnet der Server die Entfernung zum Routenstart, filtert mit `radiusKm` und sortiert nach Entfernung.
|
Ohne Positionsparameter werden alle aktiven Routen alphabetisch geliefert. Mit `lat` und `lon` wird die Entfernung zum Routenstart berechnet, anhand `radiusKm` gefiltert und nach Entfernung sortiert.
|
||||||
|
|
||||||
Query-Parameter:
|
#### Query-Parameter
|
||||||
|
|
||||||
| Parameter | Typ | Pflicht | Standard | Beschreibung |
|
| Parameter | Typ | Pflicht | Standard | Beschreibung |
|
||||||
|---|---|---:|---|---|
|
|---|---|---:|---:|---|
|
||||||
| `lat` | Dezimalzahl | nein | – | Breitengrad des Ausgangspunkts |
|
| `lat` | Dezimalzahl | nein | – | Breitengrad; nur zusammen mit `lon` wirksam |
|
||||||
| `lon` | Dezimalzahl | nein | – | Längengrad des Ausgangspunkts |
|
| `lon` | Dezimalzahl | nein | – | Längengrad; nur zusammen mit `lat` wirksam |
|
||||||
| `radiusKm` | Dezimalzahl | nein | `DEFAULT_ROUTE_RADIUS_KM` | maximaler Abstand zum Routenstart |
|
| `radiusKm` | Dezimalzahl | nein | `DEFAULT_ROUTE_RADIUS_KM`, standardmäßig `25` | maximaler Abstand zum Routenstart |
|
||||||
| `includeDeleted` | `true`/`false` | nein | `false` | gelöschte Routen einschließen |
|
| `includeDeleted` | Boolean-Text | nein | `false` | der exakte Wert `true` schließt gelöschte Routen ein |
|
||||||
|
|
||||||
Alle aktiven Routen:
|
Alle aktiven Routen:
|
||||||
|
|
||||||
@ -237,128 +249,139 @@ curl --get http://127.0.0.1:47145/api/routes \
|
|||||||
--data-urlencode 'includeDeleted=true'
|
--data-urlencode 'includeDeleted=true'
|
||||||
```
|
```
|
||||||
|
|
||||||
### GET /api/routes/:id
|
### `GET /api/routes/:id`
|
||||||
|
|
||||||
Pfadparameter:
|
Liefert Route, GPX-Punkte und POIs einschließlich Medien-URLs.
|
||||||
|
|
||||||
|
#### Pfadparameter
|
||||||
|
|
||||||
| Parameter | Typ | Pflicht | Beschreibung |
|
| Parameter | Typ | Pflicht | Beschreibung |
|
||||||
|---|---|---:|---|
|
|---|---|---:|---|
|
||||||
| `id` | positive Ganzzahl | ja | Route |
|
| `id` | Ganzzahl | ja | Route |
|
||||||
|
|
||||||
Query-Parameter:
|
#### Query-Parameter
|
||||||
|
|
||||||
| Parameter | Typ | Pflicht | Standard | Beschreibung |
|
| Parameter | Typ | Pflicht | Standard | Beschreibung |
|
||||||
|---|---|---:|---|---|
|
|---|---|---:|---:|---|
|
||||||
| `includeDeleted` | `true`/`false` | nein | `false` | auch eine weich gelöschte Route lesen |
|
| `includeDeleted` | Boolean-Text | nein | `false` | mit `true` kann eine gelöschte Route gelesen werden |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:47145/api/routes/1
|
curl http://127.0.0.1:47145/api/routes/1
|
||||||
```
|
```
|
||||||
|
|
||||||
Die Antwort enthält `points`, `pois`, `images[].url` und `audioUrl`. Die Medien-URLs zeigen auf die API-Endpunkte.
|
```bash
|
||||||
|
curl 'http://127.0.0.1:47145/api/routes/1?includeDeleted=true'
|
||||||
|
```
|
||||||
|
|
||||||
### POST /api/routes
|
### `GET /api/routes/:id/gpx`
|
||||||
|
|
||||||
Content-Type: `multipart/form-data`.
|
Liefert die aktive GPX-Datei der Route direkt mit `Content-Type: application/gpx+xml` aus. Der in einer Routenressource enthaltene Wert `gpxUrl` verweist auf diesen Endpunkt.
|
||||||
|
|
||||||
| Feld | Typ | Pflicht | Beschreibung |
|
```bash
|
||||||
|---|---|---:|---|
|
curl http://127.0.0.1:47145/api/routes/1/gpx \
|
||||||
| `gpx` | Datei | ja | GPX-Datei |
|
--output schulwald-runde.gpx
|
||||||
| `name` | Text | nein, wenn GPX einen Namen enthält | Anzeigename |
|
```
|
||||||
| `slug` | Text | nein | gewünschter URL-tauglicher Bezeichner |
|
|
||||||
| `description` | Text | nein | Beschreibung |
|
Parameter außer der Routen-ID sind nicht vorgesehen. Gelöschte oder unbekannte Routen antworten mit `404`.
|
||||||
| `schoolName` | Text | nein | Schule oder Projekt |
|
|
||||||
|
## 6. Route anlegen und bearbeiten
|
||||||
|
|
||||||
|
### `POST /api/routes`
|
||||||
|
|
||||||
|
Legt eine Route aus einer GPX-Datei an.
|
||||||
|
|
||||||
|
Content-Type: `multipart/form-data`
|
||||||
|
|
||||||
|
| Feld | Typ | Pflicht | Standard | Beschreibung |
|
||||||
|
|---|---|---:|---|---|
|
||||||
|
| `gpx` | Datei | ja | – | GPX-Datei mit mindestens einem Trackpunkt |
|
||||||
|
| `name` | Text | bedingt | Name aus GPX | erforderlich, wenn GPX keinen Namen enthält |
|
||||||
|
| `slug` | Text | nein | aus `name` | interne URL-freundliche Kennung |
|
||||||
|
| `description` | Text | nein | leer | Routenbeschreibung |
|
||||||
|
| `schoolName` | Text | nein | leer | Schule oder Einrichtung |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://127.0.0.1:47145/api/routes \
|
curl -X POST http://127.0.0.1:47145/api/routes \
|
||||||
-F 'name=Schulwald-Runde' \
|
-F 'name=Schulwald-Runde' \
|
||||||
-F 'slug=schulwald-runde' \
|
-F 'slug=schulwald-runde-klasse-7a' \
|
||||||
-F 'description=Naturkundlicher Rundweg' \
|
-F 'description=Naturkundlicher Rundweg der Klasse 7a' \
|
||||||
-F 'schoolName=Beispielschule' \
|
-F 'schoolName=Beispielschule' \
|
||||||
-F 'gpx=@route.gpx;type=application/gpx+xml'
|
-F 'gpx=@examples/sample-route.gpx;type=application/gpx+xml'
|
||||||
```
|
```
|
||||||
|
|
||||||
### PUT /api/routes/:id
|
Erfolg: `201 Created` und `Location: /api/routes/<id>`.
|
||||||
|
|
||||||
Aktualisiert Routendaten. Eine neue GPX-Datei ersetzt die gespeicherten Trackpunkte.
|
### `PUT /api/routes/:id`
|
||||||
|
|
||||||
| Feld | Typ | Pflicht | Beschreibung |
|
Aktualisiert Metadaten. Eine optionale GPX-Datei ersetzt alle bisherigen Trackpunkte.
|
||||||
|
|
||||||
|
| Feld | Typ | Pflicht | Verhalten ohne Feld |
|
||||||
|---|---|---:|---|
|
|---|---|---:|---|
|
||||||
| `name` | Text | nein | neuer Name |
|
| `gpx` | Datei | nein | bisherige GPX-Punkte bleiben erhalten |
|
||||||
| `description` | Text | nein | neue Beschreibung |
|
| `name` | Text | nein | bisheriger Wert bleibt |
|
||||||
| `schoolName` | Text | nein | neue Schule |
|
| `description` | Text | nein | bisheriger Wert bleibt |
|
||||||
| `gpx` | Datei | nein | vollständige Ersatzstrecke |
|
| `schoolName` | Text | nein | bisheriger Wert bleibt |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X PUT http://127.0.0.1:47145/api/routes/1 \
|
curl -X PUT http://127.0.0.1:47145/api/routes/1 \
|
||||||
-F 'name=Schulwald-Runde 2026' \
|
-F 'name=Schulwald-Runde 2026' \
|
||||||
-F 'description=Überarbeitete Route' \
|
-F 'description=Überarbeitete Strecke' \
|
||||||
-F 'schoolName=Beispielschule' \
|
-F 'schoolName=Beispielschule' \
|
||||||
-F 'gpx=@route-neu.gpx;type=application/gpx+xml'
|
-F 'gpx=@route-neu.gpx;type=application/gpx+xml'
|
||||||
```
|
```
|
||||||
|
|
||||||
### POST /api/routes/:id/append
|
### `POST /api/routes/:id/append`
|
||||||
|
|
||||||
Hängt GPX-Punkte an eine Route an.
|
Hängt alle Trackpunkte einer GPX-Datei an die Route an und berechnet Streckenwerte neu.
|
||||||
|
|
||||||
| Feld | Typ | Pflicht | Beschreibung |
|
| Feld | Typ | Pflicht | Beschreibung |
|
||||||
|---|---|---:|---|
|
|---|---|---:|---|
|
||||||
| `gpx` | Datei | ja | GPX-Datei mit zusätzlichen Punkten |
|
| `gpx` | Datei | ja | anzuhängende GPX-Datei |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://127.0.0.1:47145/api/routes/1/append \
|
curl -X POST http://127.0.0.1:47145/api/routes/1/append \
|
||||||
-F 'gpx=@erweiterung.gpx;type=application/gpx+xml'
|
-F 'gpx=@verlaengerung.gpx;type=application/gpx+xml'
|
||||||
```
|
```
|
||||||
|
|
||||||
### DELETE /api/routes/:id
|
## 7. POIs lesen und bearbeiten
|
||||||
|
|
||||||
Markiert die Route als gelöscht und verschiebt GPX, Bilder und Audio in den Papierkorb.
|
### `GET /api/routes/:id/pois`
|
||||||
|
|
||||||
```bash
|
Liefert alle POIs einer aktiven Route nach `sequence` und `id`.
|
||||||
curl -X DELETE http://127.0.0.1:47145/api/routes/1
|
|
||||||
```
|
|
||||||
|
|
||||||
### POST /api/routes/:id/restore
|
|
||||||
|
|
||||||
Stellt eine weich gelöschte Route einschließlich ihrer Dateien wieder her.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://127.0.0.1:47145/api/routes/1/restore
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. POIs
|
|
||||||
|
|
||||||
### GET /api/routes/:id/pois
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
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/pois/:id`
|
||||||
|
|
||||||
|
Liefert einen einzelnen POI einschließlich seiner aktuellen Bild- und Audio-URLs.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:47145/api/pois/7
|
curl http://127.0.0.1:47145/api/pois/7
|
||||||
```
|
```
|
||||||
|
|
||||||
### POST /api/routes/:id/pois
|
### `POST /api/routes/:id/pois`
|
||||||
|
|
||||||
Akzeptiert JSON, URL-encoded Formulare oder Multipart ohne Mediendateien.
|
Legt ausschließlich die POI-Metadaten an. Bilder und Audio werden anschließend über die gesonderten Medienendpunkte hochgeladen.
|
||||||
|
|
||||||
| Feld | Typ | Pflicht | Standard | Beschreibung |
|
| Feld | Typ | Pflicht | Standard | Beschreibung |
|
||||||
|---|---|---:|---|---|
|
|---|---|---:|---:|---|
|
||||||
| `title` | Text | nein | `Unbenannter POI` | Titel |
|
| `title` | Text | nein | `Unbenannter POI` | Stationsname |
|
||||||
| `description` | Text | nein | leer | Beschreibung |
|
| `description` | Text | nein | leer | Beschreibung |
|
||||||
| `lat` | Dezimalzahl | ja | – | Breitengrad |
|
| `lat` | Dezimalzahl | ja | – | Breitengrad |
|
||||||
| `lon` | Dezimalzahl | ja | – | Längengrad |
|
| `lon` | Dezimalzahl | ja | – | Längengrad |
|
||||||
| `triggerRadiusM` | Dezimalzahl | nein | `DEFAULT_POI_TRIGGER_METERS` | Aktivierungsradius in Metern |
|
| `triggerRadiusM` | Dezimalzahl | nein | `DEFAULT_POI_TRIGGER_METERS`, standardmäßig `80` | Aktivierungsradius in Metern |
|
||||||
| `sequence` | Ganzzahl ≥ 0 | nein | `0` | Reihenfolge |
|
| `sequence` | Ganzzahl | nein | `0` | Reihenfolge in der Stationsliste |
|
||||||
|
|
||||||
|
JSON-Beispiel mit allen Parametern:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://127.0.0.1:47145/api/routes/1/pois \
|
curl -X POST http://127.0.0.1:47145/api/routes/1/pois \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{
|
-d '{
|
||||||
"title": "Die alte Eiche",
|
"title": "Die alte Eiche",
|
||||||
"description": "Informationen zur Baumart",
|
"description": "Hier wird das Alter der Eiche erklärt.",
|
||||||
"lat": 52.5208,
|
"lat": 52.5208,
|
||||||
"lon": 13.4070,
|
"lon": 13.4070,
|
||||||
"triggerRadiusM": 60,
|
"triggerRadiusM": 60,
|
||||||
@ -366,9 +389,20 @@ curl -X POST http://127.0.0.1:47145/api/routes/1/pois \
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
### PUT /api/pois/:id
|
Erfolg: `201 Created` und `Location: /api/pois/<id>`.
|
||||||
|
|
||||||
Alle Felder sind optional; nicht angegebene Werte bleiben erhalten.
|
### `PUT /api/pois/:id`
|
||||||
|
|
||||||
|
Aktualisiert ausschließlich POI-Metadaten. Nicht übergebene Werte bleiben erhalten.
|
||||||
|
|
||||||
|
| Feld | Typ | Pflicht | Beschreibung |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `title` | Text | nein | neuer Stationsname |
|
||||||
|
| `description` | Text | nein | neue Beschreibung |
|
||||||
|
| `lat` | Dezimalzahl | nein | neuer Breitengrad |
|
||||||
|
| `lon` | Dezimalzahl | nein | neuer Längengrad |
|
||||||
|
| `triggerRadiusM` | Dezimalzahl | nein | neuer Aktivierungsradius |
|
||||||
|
| `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/pois/7 \
|
||||||
@ -379,23 +413,21 @@ curl -X PUT http://127.0.0.1:47145/api/pois/7 \
|
|||||||
"lat": 52.5209,
|
"lat": 52.5209,
|
||||||
"lon": 13.4071,
|
"lon": 13.4071,
|
||||||
"triggerRadiusM": 45,
|
"triggerRadiusM": 45,
|
||||||
"sequence": 1
|
"sequence": 3
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
## 7. Bilder
|
## 8. Bilder einzeln verwalten
|
||||||
|
|
||||||
Ein POI kann mehrere Bilder besitzen. Die Collection wird über die Route adressiert; jedes Bild hat zusätzlich eine `pictureId`.
|
### `GET /api/routes/:id/pictures`
|
||||||
|
|
||||||
### GET /api/routes/:id/pictures
|
Liefert alle Bilder der Route. Optional kann auf einen POI eingeschränkt werden.
|
||||||
|
|
||||||
Liefert Bildmetadaten als JSON.
|
#### Query-Parameter
|
||||||
|
|
||||||
Query-Parameter:
|
|
||||||
|
|
||||||
| Parameter | Typ | Pflicht | Beschreibung |
|
| Parameter | Typ | Pflicht | Beschreibung |
|
||||||
|---|---|---:|---|
|
|---|---|---:|---|
|
||||||
| `poiId` | positive Ganzzahl | nein | auf Bilder eines POIs einschränken |
|
| `poiId` | positive Ganzzahl | nein | liefert nur Bilder dieses POIs |
|
||||||
|
|
||||||
Alle Bilder der Route:
|
Alle Bilder der Route:
|
||||||
|
|
||||||
@ -403,15 +435,13 @@ Alle Bilder der Route:
|
|||||||
curl http://127.0.0.1:47145/api/routes/1/pictures
|
curl http://127.0.0.1:47145/api/routes/1/pictures
|
||||||
```
|
```
|
||||||
|
|
||||||
Nur Bilder von POI 7:
|
Nur Bilder des POIs `7`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl --get http://127.0.0.1:47145/api/routes/1/pictures \
|
curl --get http://127.0.0.1:47145/api/routes/1/pictures \
|
||||||
--data-urlencode 'poiId=7'
|
--data-urlencode 'poiId=7'
|
||||||
```
|
```
|
||||||
|
|
||||||
Beispielantwort:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"pictures": [
|
"pictures": [
|
||||||
@ -429,27 +459,40 @@ Beispielantwort:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### GET /api/routes/:id/pictures/:pictureId
|
### `GET /api/routes/:id/pictures/:pictureId`
|
||||||
|
|
||||||
Liefert die Bilddatei binär mit dem passenden `Content-Type`, beispielsweise `image/jpeg`, `image/png` oder `image/webp`.
|
Liefert standardmäßig die Bilddatei direkt aus. Genau dieser Pfad wird im Feld `url` der Bildressource und unter `pois[].images[].url` ausgegeben.
|
||||||
|
|
||||||
|
Bild speichern:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:47145/api/routes/1/pictures/15 \
|
curl http://127.0.0.1:47145/api/routes/1/pictures/15 \
|
||||||
--output bild-15.jpg
|
--output bild-15.jpg
|
||||||
```
|
```
|
||||||
|
|
||||||
Bildmetadaten werden über `GET /api/routes/:id/pictures` gelesen.
|
Metadaten statt Dateidaten abrufen:
|
||||||
|
|
||||||
### POST /api/routes/:id/pictures
|
```bash
|
||||||
|
curl --get http://127.0.0.1:47145/api/routes/1/pictures/15 \
|
||||||
|
--data-urlencode 'metadata=true'
|
||||||
|
```
|
||||||
|
|
||||||
Content-Type: `multipart/form-data`.
|
| Query-Parameter | Typ | Pflicht | Standard | Beschreibung |
|
||||||
|
|---|---|---:|---:|---|
|
||||||
|
| `metadata` | Boolean | nein | `false` | bei `true` JSON-Metadaten statt der Bilddatei liefern |
|
||||||
|
|
||||||
|
### `POST /api/routes/:id/pictures`
|
||||||
|
|
||||||
|
Lädt genau ein Bild hoch und ordnet es einem POI derselben Route zu.
|
||||||
|
|
||||||
|
Content-Type: `multipart/form-data`
|
||||||
|
|
||||||
| Feld | Typ | Pflicht | Standard | Beschreibung |
|
| Feld | Typ | Pflicht | Standard | Beschreibung |
|
||||||
|---|---|---:|---|---|
|
|---|---|---:|---:|---|
|
||||||
| `picture` | Datei | ja | – | Bilddatei |
|
| `picture` | Datei | ja | – | JPEG-, PNG- oder WebP-Datei |
|
||||||
| `poiId` | positive Ganzzahl | ja | – | zugehöriger POI |
|
| `poiId` | positive Ganzzahl | ja | – | Ziel-POI derselben Route |
|
||||||
| `caption` | Text | nein | leer | sichtbare Bildbeschreibung und Alternativtext |
|
| `caption` | Text | nein | leer | sichtbare Bildbeschreibung und Grundlage für den Alternativtext |
|
||||||
| `sequence` | Ganzzahl ≥ 0 | nein | nächste freie Position | Reihenfolge in der Diashow |
|
| `sequence` | nichtnegative Ganzzahl | nein | nächster freier Wert des POIs | Reihenfolge in der Diashow |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://127.0.0.1:47145/api/routes/1/pictures \
|
curl -X POST http://127.0.0.1:47145/api/routes/1/pictures \
|
||||||
@ -459,20 +502,20 @@ curl -X POST http://127.0.0.1:47145/api/routes/1/pictures \
|
|||||||
-F 'picture=@eiche.jpg;type=image/jpeg'
|
-F 'picture=@eiche.jpg;type=image/jpeg'
|
||||||
```
|
```
|
||||||
|
|
||||||
Die Antwort enthält Metadaten. Der `Location`-Header zeigt auf `/api/routes/1/pictures/:pictureId`.
|
Erfolg: `201 Created` und `Location: /api/routes/1/pictures/<pictureId>`.
|
||||||
|
|
||||||
### PUT /api/routes/:id/pictures/:pictureId
|
### `PUT /api/routes/:id/pictures/:pictureId`
|
||||||
|
|
||||||
Aktualisiert Metadaten und optional die Datei. Der Request kann JSON ohne Datei oder Multipart mit Datei verwenden.
|
Aktualisiert Metadaten und kann optional die Datei ersetzen. Nicht übergebene Metadaten bleiben erhalten.
|
||||||
|
|
||||||
| Feld | Typ | Pflicht | Beschreibung |
|
| Feld | Typ | Pflicht | Beschreibung |
|
||||||
|---|---|---:|---|
|
|---|---|---:|---|
|
||||||
| `picture` | Datei | nein | Ersatzdatei |
|
| `picture` | Datei | nein | ersetzt die bisherige Bilddatei |
|
||||||
| `poiId` | positive Ganzzahl | nein | Bild einem anderen POI derselben Route zuordnen |
|
| `poiId` | positive Ganzzahl | nein | ordnet das Bild einem anderen POI derselben Route zu |
|
||||||
| `caption` | Text | nein | Bildbeschreibung |
|
| `caption` | Text | nein | neue Bildbeschreibung; leerer Text entfernt die Beschreibung |
|
||||||
| `sequence` | Ganzzahl ≥ 0 | nein | Reihenfolge |
|
| `sequence` | nichtnegative Ganzzahl | nein | neue Position in der Diashow |
|
||||||
|
|
||||||
Nur Metadaten ändern:
|
Nur Metadaten per JSON ändern:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X PUT http://127.0.0.1:47145/api/routes/1/pictures/15 \
|
curl -X PUT http://127.0.0.1:47145/api/routes/1/pictures/15 \
|
||||||
@ -484,37 +527,57 @@ curl -X PUT http://127.0.0.1:47145/api/routes/1/pictures/15 \
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Datei und Metadaten ersetzen:
|
Datei und alle Metadaten ersetzen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X PUT http://127.0.0.1:47145/api/routes/1/pictures/15 \
|
curl -X PUT http://127.0.0.1:47145/api/routes/1/pictures/15 \
|
||||||
-F 'poiId=7' \
|
-F 'poiId=7' \
|
||||||
-F 'caption=Neue Bildbeschreibung' \
|
-F 'caption=Neue Aufnahme der Eiche' \
|
||||||
-F 'sequence=1' \
|
-F 'sequence=2' \
|
||||||
-F 'picture=@eiche-neu.webp;type=image/webp'
|
-F 'picture=@eiche-neu.webp;type=image/webp'
|
||||||
```
|
```
|
||||||
|
|
||||||
### DELETE /api/routes/:id/pictures/:pictureId
|
Wird eine Datei ersetzt, entfernt der Server die bisherige Datei nach erfolgreicher Datenbankaktualisierung.
|
||||||
|
|
||||||
Löscht den Datenbankeintrag und die einzelne Bilddatei.
|
### `DELETE /api/routes/:id/pictures/:pictureId`
|
||||||
|
|
||||||
|
Entfernt Bilddatensatz und Datei.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X DELETE http://127.0.0.1:47145/api/routes/1/pictures/15
|
curl -X DELETE http://127.0.0.1:47145/api/routes/1/pictures/15
|
||||||
```
|
```
|
||||||
|
|
||||||
## 8. Audio
|
```json
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"routeId": 1,
|
||||||
|
"poiId": 7,
|
||||||
|
"deleted": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Pro POI existiert höchstens eine Audiodatei. Deshalb gibt es keine zusätzliche Audio-ID und keinen Pfad wie `/audio/:poiId`. Alle Operationen verwenden `/api/routes/:id/audio`; der konkrete POI wird mit `poiId` angegeben.
|
## 9. Audiodateien einzeln verwalten
|
||||||
|
|
||||||
### GET /api/routes/:id/audio
|
Pro POI ist höchstens eine Audiodatei vorgesehen. Deshalb bildet die `poiId` den Schlüssel der Audioressource.
|
||||||
|
|
||||||
Ohne `poiId` liefert der Endpunkt alle Audiometadaten der Route als JSON:
|
### `GET /api/routes/:id/audio`
|
||||||
|
|
||||||
|
Liefert alle vorhandenen Audiodateien der Route. POIs ohne Audio werden nicht ausgegeben.
|
||||||
|
|
||||||
|
#### Query-Parameter
|
||||||
|
|
||||||
|
| Parameter | Typ | Pflicht | Beschreibung |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `poiId` | positive Ganzzahl | nein | schränkt die Liste auf einen POI ein |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:47145/api/routes/1/audio
|
curl http://127.0.0.1:47145/api/routes/1/audio
|
||||||
```
|
```
|
||||||
|
|
||||||
Beispielantwort:
|
```bash
|
||||||
|
curl --get http://127.0.0.1:47145/api/routes/1/audio \
|
||||||
|
--data-urlencode 'poiId=7'
|
||||||
|
```
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@ -523,35 +586,47 @@ Beispielantwort:
|
|||||||
"routeId": 1,
|
"routeId": 1,
|
||||||
"poiId": 7,
|
"poiId": 7,
|
||||||
"poiTitle": "Die alte Eiche",
|
"poiTitle": "Die alte Eiche",
|
||||||
"url": "/api/routes/1/audio?poiId=7",
|
"url": "/api/routes/1/audio/7",
|
||||||
"updatedAt": "2026-06-16 12:35:00"
|
"updatedAt": "2026-06-16 12:35:00"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Mit `poiId` liefert derselbe Endpunkt die Audiodatei binär:
|
### `GET /api/routes/:id/audio/:poiId`
|
||||||
|
|
||||||
| Query-Parameter | Typ | Pflicht für Dateiausgabe | Beschreibung |
|
Liefert standardmäßig die Audiodatei des POIs direkt aus. Genau dieser Pfad wird in `audioUrl` und im Feld `url` einer Audioressource ausgegeben. Der Endpunkt unterstützt HTTP-Range-Anfragen, damit Browser innerhalb einer Audiodatei springen können.
|
||||||
|---|---|---:|---|
|
|
||||||
| `poiId` | positive Ganzzahl | ja | POI, dessen Audio geliefert wird |
|
Audiodatei speichern:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl --get http://127.0.0.1:47145/api/routes/1/audio \
|
curl http://127.0.0.1:47145/api/routes/1/audio/7 \
|
||||||
--data-urlencode 'poiId=7' \
|
--output ansage-7.mp3
|
||||||
--output ansage.mp3
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Der Response-`Content-Type` entspricht dem gespeicherten Format, beispielsweise `audio/mpeg` oder `audio/ogg`.
|
Metadaten statt Dateidaten abrufen:
|
||||||
|
|
||||||
### POST /api/routes/:id/audio
|
```bash
|
||||||
|
curl --get http://127.0.0.1:47145/api/routes/1/audio/7 \
|
||||||
|
--data-urlencode 'metadata=true'
|
||||||
|
```
|
||||||
|
|
||||||
Legt die Audiodatei für einen POI an. Besteht bereits eine Datei, antwortet der Server mit `409`; zum Ersetzen wird `PUT` verwendet.
|
| Query-Parameter | Typ | Pflicht | Standard | Beschreibung |
|
||||||
|
|---|---|---:|---:|---|
|
||||||
|
| `metadata` | Boolean | nein | `false` | bei `true` JSON-Metadaten statt der Audiodatei liefern |
|
||||||
|
|
||||||
|
Antwortet mit `404`, wenn der POI keine Audiodatei besitzt.
|
||||||
|
|
||||||
|
### `POST /api/routes/:id/audio`
|
||||||
|
|
||||||
|
Legt die Audiodatei eines POIs an.
|
||||||
|
|
||||||
|
Content-Type: `multipart/form-data`
|
||||||
|
|
||||||
| Feld | Typ | Pflicht | Beschreibung |
|
| Feld | Typ | Pflicht | Beschreibung |
|
||||||
|---|---|---:|---|
|
|---|---|---:|---|
|
||||||
| `audio` | Datei | ja | Audiodatei |
|
| `audio` | Datei | ja | MP3-, MP4/M4A-, AAC-, Ogg-, WAV- oder WebM-Datei |
|
||||||
| `poiId` | positive Ganzzahl | ja | zugehöriger POI |
|
| `poiId` | positive Ganzzahl | ja | POI derselben Route |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://127.0.0.1:47145/api/routes/1/audio \
|
curl -X POST http://127.0.0.1:47145/api/routes/1/audio \
|
||||||
@ -559,82 +634,90 @@ curl -X POST http://127.0.0.1:47145/api/routes/1/audio \
|
|||||||
-F 'audio=@ansage.mp3;type=audio/mpeg'
|
-F 'audio=@ansage.mp3;type=audio/mpeg'
|
||||||
```
|
```
|
||||||
|
|
||||||
Der `Location`-Header lautet beispielsweise:
|
Erfolg: `201 Created` und `Location: /api/routes/1/audio/7`.
|
||||||
|
|
||||||
```text
|
Existiert bereits eine Audiodatei, antwortet der Server mit `409`. Zum Ersetzen ist `PUT` zu verwenden.
|
||||||
/api/routes/1/audio?poiId=7
|
|
||||||
```
|
|
||||||
|
|
||||||
### PUT /api/routes/:id/audio
|
### `PUT /api/routes/:id/audio/:poiId`
|
||||||
|
|
||||||
Ersetzt eine vorhandene Audiodatei. `poiId` darf als Query-Parameter oder als Multipart-Feld angegeben werden.
|
Ersetzt die vorhandene Audiodatei des POIs.
|
||||||
|
|
||||||
Query-Variante:
|
| Feld | Typ | Pflicht | Beschreibung |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `audio` | Datei | ja | neue Audiodatei |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X PUT 'http://127.0.0.1:47145/api/routes/1/audio?poiId=7' \
|
curl -X PUT http://127.0.0.1:47145/api/routes/1/audio/7 \
|
||||||
-F 'audio=@ansage-neu.ogg;type=audio/ogg'
|
-F 'audio=@ansage-neu.ogg;type=audio/ogg'
|
||||||
```
|
```
|
||||||
|
|
||||||
Multipart-Variante:
|
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/:id/audio/:poiId`
|
||||||
|
|
||||||
|
Entfernt die Audiodatei und setzt `audioUrl` des POIs auf `null`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X PUT http://127.0.0.1:47145/api/routes/1/audio \
|
curl -X DELETE http://127.0.0.1:47145/api/routes/1/audio/7
|
||||||
-F 'poiId=7' \
|
|
||||||
-F 'audio=@ansage-neu.ogg;type=audio/ogg'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Ort | Typ | Pflicht | Beschreibung |
|
```json
|
||||||
|---|---|---|---:|---|
|
{
|
||||||
| `poiId` | Query oder Formular | positive Ganzzahl | ja | POI |
|
"routeId": 1,
|
||||||
| `audio` | Formular | Datei | ja | Ersatzdatei |
|
"poiId": 7,
|
||||||
|
"deleted": true
|
||||||
### DELETE /api/routes/:id/audio
|
|
||||||
|
|
||||||
Löscht die Audiodatei des angegebenen POIs und setzt `audioUrl` anschließend auf `null`.
|
|
||||||
|
|
||||||
Empfohlen als Query-Parameter:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X DELETE 'http://127.0.0.1:47145/api/routes/1/audio?poiId=7'
|
|
||||||
```
|
|
||||||
|
|
||||||
Alternativ als JSON-Body:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X DELETE http://127.0.0.1:47145/api/routes/1/audio \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{ "poiId": 7 }'
|
|
||||||
```
|
|
||||||
|
|
||||||
## 9. Verwendung durch die Clientanwendung
|
|
||||||
|
|
||||||
Die Clientanwendung konstruiert Medienadressen ausschließlich über das API-Modul:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
Wegwichtel.Api.pictureUrl(routeId, pictureId);
|
|
||||||
// /api/routes/1/pictures/15
|
|
||||||
|
|
||||||
Wegwichtel.Api.audioUrl(routeId, poiId);
|
|
||||||
// /api/routes/1/audio?poiId=7
|
|
||||||
```
|
|
||||||
|
|
||||||
Die Diashow setzt die API-Bildadresse als `src` des Bildes. Der HTML5-Audioplayer setzt die API-Audioadresse als `src` und lädt sie mit `preload="auto"`. Das Erreichen eines POIs startet keine automatische Wiedergabe.
|
|
||||||
|
|
||||||
## 10. Nginx-Hinweis
|
|
||||||
|
|
||||||
Nginx sollte `/api/` vollständig an den Node.js-Prozess weiterleiten. Da Bilder und Audio über `/api` ausgeliefert werden, muss der Proxy binäre Responses unverändert durchreichen:
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
location ^~ /api/ {
|
|
||||||
proxy_pass http://127.0.0.1:47145;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_read_timeout 120s;
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Für Uploads muss außerdem `client_max_body_size` mindestens so groß wie `MAX_UPLOAD_MB` gewählt werden.
|
## 10. Route weich löschen und wiederherstellen
|
||||||
|
|
||||||
|
### `DELETE /api/routes/:id`
|
||||||
|
|
||||||
|
Markiert die Route als gelöscht und verschiebt das vollständige Streckenverzeichnis mit GPX, Bildern und Audio nach `storage/trash/routes/`. Die Datenbankeinträge bleiben erhalten und ihre Pfade werden auf den Papierkorb umgeschrieben.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X DELETE http://127.0.0.1:47145/api/routes/1
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"route": {
|
||||||
|
"id": 1,
|
||||||
|
"status": "deleted",
|
||||||
|
"gpxUrl": null
|
||||||
|
},
|
||||||
|
"softDeleted": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /api/routes/:id/restore`
|
||||||
|
|
||||||
|
Verschiebt eine gelöschte Route zurück in den aktiven Speicher und schreibt alle GPX-, Bild- und Audiopfade zurück.
|
||||||
|
|
||||||
|
Parameter: keine.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:47145/api/routes/1/restore
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. Dateien über REST ausliefern
|
||||||
|
|
||||||
|
Die API veröffentlicht keine internen Speicherpfade und keine `/media/...`-Adressen. Alle in JSON ausgegebenen Datei-URLs verweisen auf stabile REST-Endpunkte:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/routes/1/gpx
|
||||||
|
GET /api/routes/1/pictures/15
|
||||||
|
GET /api/routes/1/audio/7
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Zuordnung lautet:
|
||||||
|
|
||||||
|
| JSON-Feld | Datei-Endpunkt |
|
||||||
|
|---|---|
|
||||||
|
| `route.gpxUrl` | `/api/routes/:id/gpx` |
|
||||||
|
| `poi.audioUrl` | `/api/routes/:id/audio/:poiId` |
|
||||||
|
| `poi.images[].url` | `/api/routes/:id/pictures/:pictureId` |
|
||||||
|
| `picture.url` | `/api/routes/:id/pictures/:pictureId` |
|
||||||
|
| `audio.url` | `/api/routes/:id/audio/:poiId` |
|
||||||
|
|
||||||
|
Die Dateinamen und relativen Pfade unter `storage/` bleiben ausschließlich interne Implementierungsdetails. Dateien gelöschter Routen liegen im Papierkorb und sind über keinen Datei-Endpunkt erreichbar.
|
||||||
|
|||||||
@ -6,8 +6,6 @@
|
|||||||
ns.Api = {
|
ns.Api = {
|
||||||
health: () => request('/health'),
|
health: () => request('/health'),
|
||||||
routes: position => request('/routes' + (position ? `?lat=${encodeURIComponent(position.lat)}&lon=${encodeURIComponent(position.lon)}&radiusKm=${ns.Config.routeRadiusKm}` : '')),
|
routes: position => request('/routes' + (position ? `?lat=${encodeURIComponent(position.lat)}&lon=${encodeURIComponent(position.lon)}&radiusKm=${ns.Config.routeRadiusKm}` : '')),
|
||||||
route: id => request(`/routes/${encodeURIComponent(id)}`),
|
route: id => request(`/routes/${encodeURIComponent(id)}`)
|
||||||
pictureUrl: (routeId, pictureId) => `${ns.Config.apiBase}/routes/${encodeURIComponent(routeId)}/pictures/${encodeURIComponent(pictureId)}`,
|
|
||||||
audioUrl: (routeId, poiId) => `${ns.Config.apiBase}/routes/${encodeURIComponent(routeId)}/audio?poiId=${encodeURIComponent(poiId)}`
|
|
||||||
};
|
};
|
||||||
}(window.Wegwichtel, window.jQuery));
|
}(window.Wegwichtel, window.jQuery));
|
||||||
|
|||||||
@ -261,11 +261,8 @@
|
|||||||
setActivePoi(poi.id);
|
setActivePoi(poi.id);
|
||||||
$('#poi-title').text(poi.title);
|
$('#poi-title').text(poi.title);
|
||||||
$('#poi-description').text(poi.description || '');
|
$('#poi-description').text(poi.description || '');
|
||||||
ns.Slideshow.show((poi.images || []).map(image => ({
|
ns.Slideshow.show(poi.images);
|
||||||
...image,
|
ns.AudioPlayer.load(poi.audioUrl);
|
||||||
url: ns.Api.pictureUrl(state.route.id, image.id)
|
|
||||||
})));
|
|
||||||
ns.AudioPlayer.load(poi.audioUrl ? ns.Api.audioUrl(state.route.id, poi.id) : null);
|
|
||||||
navigate('#poi-page');
|
navigate('#poi-page');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,13 +14,6 @@ const app = express();
|
|||||||
app.disable('x-powered-by');
|
app.disable('x-powered-by');
|
||||||
app.use(express.json({ limit: '2mb' }));
|
app.use(express.json({ limit: '2mb' }));
|
||||||
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
|
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
|
||||||
app.use('/media', express.static(path.join(config.storageDir, 'active'), {
|
|
||||||
fallthrough: false,
|
|
||||||
immutable: false,
|
|
||||||
setHeaders(res) {
|
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
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'] }));
|
||||||
app.use(notFoundHandler);
|
app.use(notFoundHandler);
|
||||||
|
|||||||
@ -2,12 +2,12 @@ import { Router } from 'express';
|
|||||||
import { upload } from '../middleware/upload.js';
|
import { upload } from '../middleware/upload.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import {
|
import {
|
||||||
listRoutes, getRoute, createRoute, updateRoute, appendRoute,
|
listRoutes, getRoute, getRouteGpxFile, createRoute, updateRoute, appendRoute,
|
||||||
listPois, getPoi, createPoi, updatePoi, softDeleteRoute, restoreRoute
|
listPois, getPoi, createPoi, updatePoi, softDeleteRoute, restoreRoute
|
||||||
} from '../services/routes-service.js';
|
} from '../services/routes-service.js';
|
||||||
import {
|
import {
|
||||||
listPictures, getPictureContent, createPicture, updatePicture, deletePicture,
|
listPictures, getPicture, getPictureFile, createPicture, updatePicture, deletePicture,
|
||||||
listAudio, getAudioContent, createAudio, updateAudio, deleteAudio
|
listAudio, getAudio, getAudioFile, createAudio, updateAudio, deleteAudio
|
||||||
} from '../services/media-service.js';
|
} from '../services/media-service.js';
|
||||||
|
|
||||||
const numberOrUndefined = value => {
|
const numberOrUndefined = value => {
|
||||||
@ -16,24 +16,16 @@ const numberOrUndefined = value => {
|
|||||||
return Number.isFinite(parsed) ? parsed : undefined;
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const positiveInteger = (value, name) => {
|
function sendFileResource(res, file, contentType = null) {
|
||||||
const parsed = Number.parseInt(value, 10);
|
if (contentType) res.type(contentType);
|
||||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
res.setHeader('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(file.filename)}`);
|
||||||
const error = new Error(`${name} muss eine positive Ganzzahl sein.`);
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
error.status = 400;
|
return res.sendFile(file.absolutePath, {
|
||||||
throw error;
|
acceptRanges: true,
|
||||||
}
|
cacheControl: true,
|
||||||
return parsed;
|
immutable: false,
|
||||||
};
|
maxAge: '1h'
|
||||||
|
|
||||||
function sendMedia(res, resource) {
|
|
||||||
res.set({
|
|
||||||
'Cache-Control': 'private, max-age=60',
|
|
||||||
'Content-Disposition': `inline; filename="${resource.filename.replace(/["\\]/g, '_')}"`,
|
|
||||||
'X-Content-Type-Options': 'nosniff'
|
|
||||||
});
|
});
|
||||||
res.type(resource.contentType);
|
|
||||||
res.sendFile(resource.absolutePath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createApiRouter(db) {
|
export function createApiRouter(db) {
|
||||||
@ -63,6 +55,10 @@ export function createApiRouter(db) {
|
|||||||
res.json(getRoute(db, Number(req.params.id), req.query.includeDeleted === 'true'));
|
res.json(getRoute(db, Number(req.params.id), req.query.includeDeleted === 'true'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
api.get('/routes/:id/gpx', (req, res) => {
|
||||||
|
sendFileResource(res, getRouteGpxFile(db, Number(req.params.id)), 'application/gpx+xml');
|
||||||
|
});
|
||||||
|
|
||||||
api.get('/routes/:id/pois', (req, res) => {
|
api.get('/routes/:id/pois', (req, res) => {
|
||||||
getRoute(db, Number(req.params.id));
|
getRoute(db, Number(req.params.id));
|
||||||
res.json({ pois: listPois(db, Number(req.params.id)) });
|
res.json({ pois: listPois(db, Number(req.params.id)) });
|
||||||
@ -99,7 +95,13 @@ export function createApiRouter(db) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
api.get('/routes/:id/pictures/:pictureId', (req, res) => {
|
api.get('/routes/:id/pictures/:pictureId', (req, res) => {
|
||||||
sendMedia(res, getPictureContent(db, Number(req.params.id), Number(req.params.pictureId)));
|
const routeId = Number(req.params.id);
|
||||||
|
const pictureId = Number(req.params.pictureId);
|
||||||
|
if (req.query.metadata === 'true') {
|
||||||
|
res.json(getPicture(db, routeId, pictureId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendFileResource(res, getPictureFile(db, routeId, pictureId));
|
||||||
});
|
});
|
||||||
|
|
||||||
api.post('/routes/:id/pictures', upload.single('picture'), (req, res) => {
|
api.post('/routes/:id/pictures', upload.single('picture'), (req, res) => {
|
||||||
@ -118,30 +120,32 @@ export function createApiRouter(db) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
api.get('/routes/:id/audio', (req, res) => {
|
api.get('/routes/:id/audio', (req, res) => {
|
||||||
if (req.query.poiId == null || req.query.poiId === '') {
|
res.json({ audio: listAudio(db, Number(req.params.id), { poiId: req.query.poiId }) });
|
||||||
res.json({ audio: listAudio(db, Number(req.params.id)) });
|
});
|
||||||
|
|
||||||
|
api.get('/routes/:id/audio/:poiId', (req, res) => {
|
||||||
|
const routeId = Number(req.params.id);
|
||||||
|
const poiId = Number(req.params.poiId);
|
||||||
|
if (req.query.metadata === 'true') {
|
||||||
|
res.json(getAudio(db, routeId, poiId));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
sendFileResource(res, getAudioFile(db, routeId, poiId));
|
||||||
const poiId = positiveInteger(req.query.poiId, 'poiId');
|
|
||||||
sendMedia(res, getAudioContent(db, Number(req.params.id), poiId));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
api.post('/routes/:id/audio', upload.single('audio'), (req, res) => {
|
api.post('/routes/:id/audio', upload.single('audio'), (req, res) => {
|
||||||
const audio = createAudio(db, Number(req.params.id), req.body, req.file);
|
const audio = createAudio(db, Number(req.params.id), req.body, req.file);
|
||||||
res.status(201)
|
res.status(201)
|
||||||
.location(`/api/routes/${req.params.id}/audio?poiId=${encodeURIComponent(audio.poiId)}`)
|
.location(`/api/routes/${req.params.id}/audio/${audio.poiId}`)
|
||||||
.json(audio);
|
.json(audio);
|
||||||
});
|
});
|
||||||
|
|
||||||
api.put('/routes/:id/audio', upload.single('audio'), (req, res) => {
|
api.put('/routes/:id/audio/:poiId', upload.single('audio'), (req, res) => {
|
||||||
const poiId = positiveInteger(req.query.poiId ?? req.body.poiId, 'poiId');
|
res.json(updateAudio(db, Number(req.params.id), Number(req.params.poiId), req.file));
|
||||||
res.json(updateAudio(db, Number(req.params.id), poiId, req.file));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
api.delete('/routes/:id/audio', (req, res) => {
|
api.delete('/routes/:id/audio/:poiId', (req, res) => {
|
||||||
const poiId = positiveInteger(req.query.poiId ?? req.body?.poiId, 'poiId');
|
res.json(deleteAudio(db, Number(req.params.id), Number(req.params.poiId)));
|
||||||
res.json(deleteAudio(db, Number(req.params.id), poiId));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
api.delete('/routes/:id', (req, res) => {
|
api.delete('/routes/:id', (req, res) => {
|
||||||
|
|||||||
@ -24,31 +24,8 @@ const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
|
|||||||
const AUDIO_EXTENSIONS = new Set(['.mp3', '.mp4', '.m4a', '.aac', '.ogg', '.wav', '.webm']);
|
const AUDIO_EXTENSIONS = new Set(['.mp3', '.mp4', '.m4a', '.aac', '.ogg', '.wav', '.webm']);
|
||||||
|
|
||||||
const uniqueFilename = original => `${crypto.randomUUID()}${path.extname(original).toLowerCase()}`;
|
const uniqueFilename = original => `${crypto.randomUUID()}${path.extname(original).toLowerCase()}`;
|
||||||
const pictureApiUrl = (routeId, pictureId) => `/api/routes/${routeId}/pictures/${pictureId}`;
|
const pictureUrl = (routeId, pictureId) => `/api/routes/${routeId}/pictures/${pictureId}`;
|
||||||
const audioApiUrl = (routeId, poiId) => `/api/routes/${routeId}/audio?poiId=${encodeURIComponent(poiId)}`;
|
const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/audio/${poiId}`;
|
||||||
|
|
||||||
const CONTENT_TYPES = Object.freeze({
|
|
||||||
'.jpg': 'image/jpeg',
|
|
||||||
'.jpeg': 'image/jpeg',
|
|
||||||
'.png': 'image/png',
|
|
||||||
'.webp': 'image/webp',
|
|
||||||
'.mp3': 'audio/mpeg',
|
|
||||||
'.mp4': 'audio/mp4',
|
|
||||||
'.m4a': 'audio/mp4',
|
|
||||||
'.aac': 'audio/aac',
|
|
||||||
'.ogg': 'audio/ogg',
|
|
||||||
'.wav': 'audio/wav',
|
|
||||||
'.webm': 'audio/webm'
|
|
||||||
});
|
|
||||||
|
|
||||||
function contentResource(relativePath) {
|
|
||||||
const absolutePath = resolveStoredFile(relativePath);
|
|
||||||
return {
|
|
||||||
absolutePath,
|
|
||||||
contentType: CONTENT_TYPES[path.extname(absolutePath).toLowerCase()] || 'application/octet-stream',
|
|
||||||
filename: path.basename(absolutePath)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function requirePositiveInteger(value, name) {
|
function requirePositiveInteger(value, name) {
|
||||||
const parsed = Number.parseInt(value, 10);
|
const parsed = Number.parseInt(value, 10);
|
||||||
@ -108,7 +85,7 @@ function pictureFromRow(row) {
|
|||||||
poiTitle: row.poi_title,
|
poiTitle: row.poi_title,
|
||||||
caption: row.caption,
|
caption: row.caption,
|
||||||
sequence: row.sequence,
|
sequence: row.sequence,
|
||||||
url: pictureApiUrl(row.route_id, row.id),
|
url: pictureUrl(row.route_id, row.id),
|
||||||
createdAt: row.created_at
|
createdAt: row.created_at
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -137,11 +114,14 @@ export function getPicture(db, routeId, pictureId) {
|
|||||||
return pictureFromRow(row);
|
return pictureFromRow(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPictureContent(db, routeId, pictureId) {
|
export function getPictureFile(db, routeId, pictureId) {
|
||||||
activeRoute(db, routeId);
|
activeRoute(db, routeId);
|
||||||
const row = db.prepare(`${pictureSelect} WHERE p.route_id = ? AND i.id = ? AND r.status = 'active'`).get(routeId, pictureId);
|
const row = db.prepare(`${pictureSelect} WHERE p.route_id = ? AND i.id = ? AND r.status = 'active'`).get(routeId, pictureId);
|
||||||
if (!row) throw new HttpError(404, 'Bild auf dieser Strecke nicht gefunden.');
|
if (!row) throw new HttpError(404, 'Bild auf dieser Strecke nicht gefunden.');
|
||||||
return contentResource(row.path);
|
return {
|
||||||
|
absolutePath: resolveStoredFile(row.path),
|
||||||
|
filename: path.basename(row.path)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPicture(db, routeId, fields, file) {
|
export function createPicture(db, routeId, fields, file) {
|
||||||
@ -216,7 +196,7 @@ function audioFromRow(row) {
|
|||||||
routeId: row.route_id,
|
routeId: row.route_id,
|
||||||
poiId: row.poi_id,
|
poiId: row.poi_id,
|
||||||
poiTitle: row.poi_title,
|
poiTitle: row.poi_title,
|
||||||
url: audioApiUrl(row.route_id, row.poi_id),
|
url: audioUrl(row.route_id, row.poi_id),
|
||||||
updatedAt: row.updated_at
|
updatedAt: row.updated_at
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -245,12 +225,15 @@ export function getAudio(db, routeId, poiId) {
|
|||||||
return audioFromRow(row);
|
return audioFromRow(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAudioContent(db, routeId, poiId) {
|
export function getAudioFile(db, routeId, poiId) {
|
||||||
activeRoute(db, routeId);
|
activeRoute(db, routeId);
|
||||||
const row = db.prepare(`${audioSelect} WHERE p.route_id = ? AND p.id = ? AND p.audio_path IS NOT NULL AND r.status = 'active'`)
|
const row = db.prepare(`${audioSelect} WHERE p.route_id = ? AND p.id = ? AND p.audio_path IS NOT NULL AND r.status = 'active'`)
|
||||||
.get(routeId, poiId);
|
.get(routeId, poiId);
|
||||||
if (!row) throw new HttpError(404, 'Audiodatei für diesen POI nicht gefunden.');
|
if (!row) throw new HttpError(404, 'Audiodatei für diesen POI nicht gefunden.');
|
||||||
return contentResource(row.audio_path);
|
return {
|
||||||
|
absolutePath: resolveStoredFile(row.audio_path),
|
||||||
|
filename: path.basename(row.audio_path)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function storeAudio(db, routeId, poiId, file, { requireAbsent, requireExisting }) {
|
function storeAudio(db, routeId, poiId, file, { requireAbsent, requireExisting }) {
|
||||||
|
|||||||
@ -7,10 +7,13 @@ import { parseGpx } from './gpx.js';
|
|||||||
import { distanceMeters, routeMetrics } from './geo.js';
|
import { distanceMeters, routeMetrics } from './geo.js';
|
||||||
import {
|
import {
|
||||||
ensureRouteDirectories, moveUploadedFile, routeDirectory,
|
ensureRouteDirectories, moveUploadedFile, routeDirectory,
|
||||||
softDeleteRouteDirectory, restoreRouteDirectory, safeMediaUrl, removeUpload
|
softDeleteRouteDirectory, restoreRouteDirectory, resolveStoredFile, removeUpload
|
||||||
} from './storage.js';
|
} from './storage.js';
|
||||||
|
|
||||||
const slugify = value => value.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80) || `route-${Date.now()}`;
|
const slugify = value => value.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80) || `route-${Date.now()}`;
|
||||||
|
const routeGpxUrl = routeId => `/api/routes/${routeId}/gpx`;
|
||||||
|
const pictureUrl = (routeId, pictureId) => `/api/routes/${routeId}/pictures/${pictureId}`;
|
||||||
|
const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/audio/${poiId}`;
|
||||||
function rowToRoute(row, includeDeleted = false) {
|
function rowToRoute(row, includeDeleted = false) {
|
||||||
if (!row || (!includeDeleted && row.status !== 'active')) return null;
|
if (!row || (!includeDeleted && row.status !== 'active')) return null;
|
||||||
return {
|
return {
|
||||||
@ -20,7 +23,7 @@ function rowToRoute(row, includeDeleted = false) {
|
|||||||
center: row.center_lat == null ? null : { lat: row.center_lat, lon: row.center_lon },
|
center: row.center_lat == null ? null : { lat: row.center_lat, lon: row.center_lon },
|
||||||
bounds: row.min_lat == null ? null : { minLat: row.min_lat, minLon: row.min_lon, maxLat: row.max_lat, maxLon: row.max_lon },
|
bounds: row.min_lat == null ? null : { minLat: row.min_lat, minLon: row.min_lon, maxLat: row.max_lat, maxLon: row.max_lon },
|
||||||
distanceM: row.distance_m, elevationGainM: row.elevation_gain_m, pointCount: row.point_count,
|
distanceM: row.distance_m, elevationGainM: row.elevation_gain_m, pointCount: row.point_count,
|
||||||
gpxUrl: row.gpx_path ? safeMediaUrl(row.gpx_path) : null,
|
gpxUrl: row.gpx_path && row.status === 'active' ? routeGpxUrl(row.id) : null,
|
||||||
createdAt: row.created_at, updatedAt: row.updated_at, deletedAt: row.deleted_at
|
createdAt: row.created_at, updatedAt: row.updated_at, deletedAt: row.deleted_at
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -43,6 +46,15 @@ export function getRoute(db, id, includeDeleted = false) {
|
|||||||
return { ...route, points, pois };
|
return { ...route, points, pois };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRouteGpxFile(db, id) {
|
||||||
|
const route = db.prepare("SELECT id, slug, gpx_path FROM routes WHERE id = ? AND status = 'active'").get(id);
|
||||||
|
if (!route) throw new HttpError(404, 'Strecke nicht gefunden.');
|
||||||
|
return {
|
||||||
|
absolutePath: resolveStoredFile(route.gpx_path),
|
||||||
|
filename: `${route.slug}.gpx`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createRoute(db, fields, gpxFile) {
|
export function createRoute(db, fields, gpxFile) {
|
||||||
if (!gpxFile) throw new HttpError(400, 'Eine GPX-Datei ist erforderlich.');
|
if (!gpxFile) throw new HttpError(400, 'Eine GPX-Datei ist erforderlich.');
|
||||||
const parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8'));
|
const parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8'));
|
||||||
@ -129,12 +141,12 @@ export function listPois(db, routeId) {
|
|||||||
return pois.map(poi => ({
|
return pois.map(poi => ({
|
||||||
id: poi.id, routeId: poi.route_id, title: poi.title, description: poi.description,
|
id: poi.id, routeId: poi.route_id, title: poi.title, description: poi.description,
|
||||||
lat: poi.lat, lon: poi.lon, triggerRadiusM: poi.trigger_radius_m, sequence: poi.sequence,
|
lat: poi.lat, lon: poi.lon, triggerRadiusM: poi.trigger_radius_m, sequence: poi.sequence,
|
||||||
audioUrl: poi.audio_path ? `/api/routes/${routeId}/audio?poiId=${encodeURIComponent(poi.id)}` : null,
|
audioUrl: poi.audio_path ? audioUrl(poi.route_id, poi.id) : null,
|
||||||
images: imageStatement.all(poi.id).map(image => ({
|
images: imageStatement.all(poi.id).map(image => ({
|
||||||
id: image.id,
|
id: image.id,
|
||||||
caption: image.caption,
|
caption: image.caption,
|
||||||
sequence: image.sequence,
|
sequence: image.sequence,
|
||||||
url: `/api/routes/${routeId}/pictures/${image.id}`
|
url: pictureUrl(poi.route_id, image.id)
|
||||||
}))
|
}))
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,22 +25,6 @@ export function removeUpload(uploaded) {
|
|||||||
if (uploaded?.path && fs.existsSync(uploaded.path)) fs.rmSync(uploaded.path, { force: true });
|
if (uploaded?.path && fs.existsSync(uploaded.path)) fs.rmSync(uploaded.path, { force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveStoredFile(relativePath) {
|
|
||||||
if (!relativePath || !relativePath.startsWith('active/')) {
|
|
||||||
throw new HttpError(404, 'Aktive Mediendatei nicht gefunden.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const storageRoot = path.resolve(config.storageDir);
|
|
||||||
const target = path.resolve(storageRoot, relativePath);
|
|
||||||
if (target !== storageRoot && !target.startsWith(`${storageRoot}${path.sep}`)) {
|
|
||||||
throw new HttpError(500, 'Ungültiger interner Medienpfad.');
|
|
||||||
}
|
|
||||||
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
|
|
||||||
throw new HttpError(404, 'Mediendatei nicht gefunden.');
|
|
||||||
}
|
|
||||||
return target;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeStoredFile(relativePath) {
|
export function removeStoredFile(relativePath) {
|
||||||
if (!relativePath) return false;
|
if (!relativePath) return false;
|
||||||
const storageRoot = path.resolve(config.storageDir);
|
const storageRoot = path.resolve(config.storageDir);
|
||||||
@ -70,9 +54,19 @@ export function restoreRouteDirectory(id, trashRelativePath) {
|
|||||||
return { sourcePrefix: relativeStoragePath(source), targetPrefix: relativeStoragePath(destination), source, destination };
|
return { sourcePrefix: relativeStoragePath(source), targetPrefix: relativeStoragePath(destination), source, destination };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function safeMediaUrl(relativePath) {
|
export function resolveStoredFile(relativePath) {
|
||||||
if (!relativePath || !relativePath.startsWith('active/')) return null;
|
if (!relativePath || !relativePath.startsWith('active/')) {
|
||||||
const publicPath = relativePath.slice('active/'.length);
|
throw new HttpError(404, 'Aktive Mediendatei nicht gefunden.');
|
||||||
return `/media/${publicPath.split('/').map(encodeURIComponent).join('/')}`;
|
}
|
||||||
|
|
||||||
|
const storageRoot = path.resolve(config.storageDir);
|
||||||
|
const target = path.resolve(storageRoot, relativePath);
|
||||||
|
if (target === storageRoot || !target.startsWith(`${storageRoot}${path.sep}`)) {
|
||||||
|
throw new HttpError(500, 'Ungültiger interner Medienpfad.');
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
|
||||||
|
throw new HttpError(404, 'Mediendatei nicht gefunden.');
|
||||||
|
}
|
||||||
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ test('complete REST API documentation is kept outside the README', async () => {
|
|||||||
'GET /api/health',
|
'GET /api/health',
|
||||||
'GET /api/routes',
|
'GET /api/routes',
|
||||||
'GET /api/routes/:id',
|
'GET /api/routes/:id',
|
||||||
|
'GET /api/routes/:id/gpx',
|
||||||
'GET /api/routes/:id/pois',
|
'GET /api/routes/:id/pois',
|
||||||
'GET /api/pois/:id',
|
'GET /api/pois/:id',
|
||||||
'POST /api/routes',
|
'POST /api/routes',
|
||||||
@ -31,9 +32,10 @@ test('complete REST API documentation is kept outside the README', async () => {
|
|||||||
'PUT /api/routes/:id/pictures/:pictureId',
|
'PUT /api/routes/:id/pictures/:pictureId',
|
||||||
'DELETE /api/routes/:id/pictures/:pictureId',
|
'DELETE /api/routes/:id/pictures/:pictureId',
|
||||||
'GET /api/routes/:id/audio',
|
'GET /api/routes/:id/audio',
|
||||||
|
'GET /api/routes/:id/audio/:poiId',
|
||||||
'POST /api/routes/:id/audio',
|
'POST /api/routes/:id/audio',
|
||||||
'PUT /api/routes/:id/audio',
|
'PUT /api/routes/:id/audio/:poiId',
|
||||||
'DELETE /api/routes/:id/audio',
|
'DELETE /api/routes/:id/audio/:poiId',
|
||||||
'DELETE /api/routes/:id',
|
'DELETE /api/routes/:id',
|
||||||
'POST /api/routes/:id/restore'
|
'POST /api/routes/:id/restore'
|
||||||
]) {
|
]) {
|
||||||
@ -43,7 +45,7 @@ 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'
|
'picture', 'pictureId', 'caption', 'audio', 'poiId', 'metadata'
|
||||||
]) {
|
]) {
|
||||||
assert.match(api, new RegExp(`\\b${parameter}\\b`), `missing parameter documentation: ${parameter}`);
|
assert.match(api, new RegExp(`\\b${parameter}\\b`), `missing parameter documentation: ${parameter}`);
|
||||||
}
|
}
|
||||||
@ -58,13 +60,15 @@ test('POI media uploads use dedicated single-resource endpoints', async () => {
|
|||||||
assert.match(router, /put\('\/routes\/:id\/pictures\/:pictureId', upload\.single\('picture'\)/);
|
assert.match(router, /put\('\/routes\/:id\/pictures\/:pictureId', upload\.single\('picture'\)/);
|
||||||
assert.match(router, /delete\('\/routes\/:id\/pictures\/:pictureId'/);
|
assert.match(router, /delete\('\/routes\/:id\/pictures\/:pictureId'/);
|
||||||
assert.match(router, /post\('\/routes\/:id\/audio', upload\.single\('audio'\)/);
|
assert.match(router, /post\('\/routes\/:id\/audio', upload\.single\('audio'\)/);
|
||||||
assert.match(router, /put\('\/routes\/:id\/audio', upload\.single\('audio'\)/);
|
assert.match(router, /put\('\/routes\/:id\/audio\/:poiId', upload\.single\('audio'\)/);
|
||||||
assert.match(router, /delete\('\/routes\/:id\/audio'/);
|
assert.match(router, /delete\('\/routes\/:id\/audio\/:poiId'/);
|
||||||
assert.match(router, /post\('\/routes\/:id\/pois', upload\.none\(\)/);
|
assert.match(router, /post\('\/routes\/:id\/pois', upload\.none\(\)/);
|
||||||
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/);
|
||||||
assert.match(mediaService, /pictureApiUrl/);
|
assert.match(router, /get\('\/routes\/:id\/gpx'/);
|
||||||
assert.match(mediaService, /audioApiUrl/);
|
assert.match(router, /req\.query\.metadata === 'true'/);
|
||||||
assert.doesNotMatch(router, /audio\/:poiId/);
|
assert.doesNotMatch(router, /['"`]\/media\//);
|
||||||
|
assert.doesNotMatch(routesService, /safeMediaUrl|\/media\//);
|
||||||
|
assert.doesNotMatch(mediaService, /safeMediaUrl|\/media\//);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -26,11 +26,11 @@ async function requestJson(url, options = {}, expectedStatus = 200) {
|
|||||||
return { response, body };
|
return { response, body };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestBytes(url, expectedStatus = 200) {
|
async function requestBuffer(url, expectedStatus = 200) {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
const bytes = Buffer.from(await response.arrayBuffer());
|
const body = Buffer.from(await response.arrayBuffer());
|
||||||
assert.equal(response.status, expectedStatus, bytes.toString());
|
assert.equal(response.status, expectedStatus);
|
||||||
return { response, bytes };
|
return { response, body };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForServer(baseUrl, child, output) {
|
async function waitForServer(baseUrl, child, output) {
|
||||||
@ -47,7 +47,7 @@ async function waitForServer(baseUrl, child, output) {
|
|||||||
throw new Error(`Serverstart hat das Zeitlimit überschritten.\n${output.join('')}`);
|
throw new Error(`Serverstart hat das Zeitlimit überschritten.\n${output.join('')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
test('pictures and per-POI audio are delivered and managed through REST API resources', { timeout: 30000 }, async t => {
|
test('pictures and audio are managed as individual REST resources', { timeout: 30000 }, async t => {
|
||||||
const runtime = await fs.mkdtemp(path.join(os.tmpdir(), 'wegwichtel-media-api-'));
|
const runtime = await fs.mkdtemp(path.join(os.tmpdir(), 'wegwichtel-media-api-'));
|
||||||
const port = await freePort();
|
const port = await freePort();
|
||||||
const baseUrl = `http://127.0.0.1:${port}`;
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
@ -84,6 +84,10 @@ test('pictures and per-POI audio are delivered and managed through REST API reso
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: routeForm
|
body: routeForm
|
||||||
}, 201);
|
}, 201);
|
||||||
|
assert.equal(route.gpxUrl, `/api/routes/${route.id}/gpx`);
|
||||||
|
const { response: gpxResponse, body: downloadedGpx } = await requestBuffer(`${baseUrl}${route.gpxUrl}`);
|
||||||
|
assert.match(gpxResponse.headers.get('content-type') || '', /application\/gpx\+xml/);
|
||||||
|
assert.deepEqual(downloadedGpx, gpx);
|
||||||
|
|
||||||
const { body: poi } = await requestJson(`${baseUrl}/api/routes/${route.id}/pois`, {
|
const { body: poi } = await requestJson(`${baseUrl}/api/routes/${route.id}/pois`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -111,17 +115,16 @@ test('pictures and per-POI audio are delivered and managed through REST API reso
|
|||||||
assert.equal(pictureResponse.headers.get('location'), `/api/routes/${route.id}/pictures/${picture.id}`);
|
assert.equal(pictureResponse.headers.get('location'), `/api/routes/${route.id}/pictures/${picture.id}`);
|
||||||
assert.equal(picture.poiId, poi.id);
|
assert.equal(picture.poiId, poi.id);
|
||||||
assert.equal(picture.caption, 'Erste Bildbeschreibung');
|
assert.equal(picture.caption, 'Erste Bildbeschreibung');
|
||||||
|
assert.equal(picture.url, `/api/routes/${route.id}/pictures/${picture.id}`);
|
||||||
|
const { response: pictureFileResponse, body: pictureFile } = await requestBuffer(`${baseUrl}${picture.url}`);
|
||||||
|
assert.match(pictureFileResponse.headers.get('content-type') || '', /image\/png/);
|
||||||
|
assert.equal(pictureFile.toString(), 'picture-one');
|
||||||
|
const { body: pictureMetadata } = await requestJson(`${baseUrl}${picture.url}?metadata=true`);
|
||||||
|
assert.equal(pictureMetadata.caption, 'Erste Bildbeschreibung');
|
||||||
|
|
||||||
const { body: pictureList } = await requestJson(`${baseUrl}/api/routes/${route.id}/pictures?poiId=${poi.id}`);
|
const { body: pictureList } = await requestJson(`${baseUrl}/api/routes/${route.id}/pictures?poiId=${poi.id}`);
|
||||||
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);
|
||||||
assert.equal(pictureList.pictures[0].url, `/api/routes/${route.id}/pictures/${picture.id}`);
|
|
||||||
|
|
||||||
const { response: pictureContentResponse, bytes: pictureBytes } = await requestBytes(
|
|
||||||
`${baseUrl}/api/routes/${route.id}/pictures/${picture.id}`
|
|
||||||
);
|
|
||||||
assert.equal(pictureContentResponse.headers.get('content-type'), 'image/png');
|
|
||||||
assert.equal(pictureBytes.toString(), 'picture-one');
|
|
||||||
|
|
||||||
const { body: updatedPicture } = await requestJson(
|
const { body: updatedPicture } = await requestJson(
|
||||||
`${baseUrl}/api/routes/${route.id}/pictures/${picture.id}`,
|
`${baseUrl}/api/routes/${route.id}/pictures/${picture.id}`,
|
||||||
@ -142,8 +145,14 @@ test('pictures and per-POI audio are delivered and managed through REST API reso
|
|||||||
{ method: 'POST', body: audioForm },
|
{ method: 'POST', body: audioForm },
|
||||||
201
|
201
|
||||||
);
|
);
|
||||||
assert.equal(audioResponse.headers.get('location'), `/api/routes/${route.id}/audio?poiId=${poi.id}`);
|
assert.equal(audioResponse.headers.get('location'), `/api/routes/${route.id}/audio/${poi.id}`);
|
||||||
assert.equal(audio.poiId, poi.id);
|
assert.equal(audio.poiId, poi.id);
|
||||||
|
assert.equal(audio.url, `/api/routes/${route.id}/audio/${poi.id}`);
|
||||||
|
const { response: audioFileResponse, body: audioFile } = await requestBuffer(`${baseUrl}${audio.url}`);
|
||||||
|
assert.match(audioFileResponse.headers.get('content-type') || '', /audio\/mpeg/);
|
||||||
|
assert.equal(audioFile.toString(), 'audio-one');
|
||||||
|
const { body: audioMetadata } = await requestJson(`${baseUrl}${audio.url}?metadata=true`);
|
||||||
|
assert.equal(audioMetadata.poiId, poi.id);
|
||||||
|
|
||||||
const duplicateAudio = new FormData();
|
const duplicateAudio = new FormData();
|
||||||
duplicateAudio.append('poiId', String(poi.id));
|
duplicateAudio.append('poiId', String(poi.id));
|
||||||
@ -156,24 +165,25 @@ test('pictures and per-POI audio are delivered and managed through REST API reso
|
|||||||
const replacementAudio = new FormData();
|
const replacementAudio = new FormData();
|
||||||
replacementAudio.append('audio', new Blob(['audio-two'], { type: 'audio/ogg' }), 'ansage-neu.ogg');
|
replacementAudio.append('audio', new Blob(['audio-two'], { type: 'audio/ogg' }), 'ansage-neu.ogg');
|
||||||
const { body: replacedAudio } = await requestJson(
|
const { body: replacedAudio } = await requestJson(
|
||||||
`${baseUrl}/api/routes/${route.id}/audio?poiId=${poi.id}`,
|
`${baseUrl}/api/routes/${route.id}/audio/${poi.id}`,
|
||||||
{ method: 'PUT', body: replacementAudio }
|
{ method: 'PUT', body: replacementAudio }
|
||||||
);
|
);
|
||||||
assert.equal(replacedAudio.url, `/api/routes/${route.id}/audio?poiId=${poi.id}`);
|
assert.equal(replacedAudio.url, `/api/routes/${route.id}/audio/${poi.id}`);
|
||||||
|
const { response: replacedAudioResponse, body: replacedAudioFile } = await requestBuffer(`${baseUrl}${replacedAudio.url}`);
|
||||||
|
assert.match(replacedAudioResponse.headers.get('content-type') || '', /audio\/ogg/);
|
||||||
|
assert.equal(replacedAudioFile.toString(), 'audio-two');
|
||||||
|
|
||||||
const { body: routeWithMedia } = await requestJson(`${baseUrl}/api/routes/${route.id}`);
|
const { body: routeWithMedia } = await requestJson(`${baseUrl}/api/routes/${route.id}`);
|
||||||
assert.equal(routeWithMedia.pois[0].images[0].caption, 'Aktualisierte Bildbeschreibung');
|
assert.equal(routeWithMedia.pois[0].images[0].caption, 'Aktualisierte Bildbeschreibung');
|
||||||
|
assert.equal(routeWithMedia.gpxUrl, `/api/routes/${route.id}/gpx`);
|
||||||
|
assert.equal(routeWithMedia.pois[0].audioUrl, `/api/routes/${route.id}/audio/${poi.id}`);
|
||||||
assert.equal(routeWithMedia.pois[0].images[0].url, `/api/routes/${route.id}/pictures/${picture.id}`);
|
assert.equal(routeWithMedia.pois[0].images[0].url, `/api/routes/${route.id}/pictures/${picture.id}`);
|
||||||
assert.equal(routeWithMedia.pois[0].audioUrl, `/api/routes/${route.id}/audio?poiId=${poi.id}`);
|
assert.doesNotMatch(JSON.stringify(routeWithMedia), /\/media\//);
|
||||||
|
const legacyMediaResponse = await fetch(`${baseUrl}/media/routes/${route.id}/route.gpx`);
|
||||||
const { response: audioContentResponse, bytes: audioBytes } = await requestBytes(
|
assert.equal(legacyMediaResponse.status, 404);
|
||||||
`${baseUrl}/api/routes/${route.id}/audio?poiId=${poi.id}`
|
|
||||||
);
|
|
||||||
assert.equal(audioContentResponse.headers.get('content-type'), 'audio/ogg');
|
|
||||||
assert.equal(audioBytes.toString(), 'audio-two');
|
|
||||||
|
|
||||||
const { body: deletedAudio } = await requestJson(
|
const { body: deletedAudio } = await requestJson(
|
||||||
`${baseUrl}/api/routes/${route.id}/audio?poiId=${poi.id}`,
|
`${baseUrl}/api/routes/${route.id}/audio/${poi.id}`,
|
||||||
{ method: 'DELETE' }
|
{ method: 'DELETE' }
|
||||||
);
|
);
|
||||||
assert.equal(deletedAudio.deleted, true);
|
assert.equal(deletedAudio.deleted, true);
|
||||||
@ -185,5 +195,5 @@ test('pictures and per-POI audio are delivered and managed through REST API reso
|
|||||||
assert.equal(deletedPicture.deleted, true);
|
assert.equal(deletedPicture.deleted, true);
|
||||||
|
|
||||||
await requestJson(`${baseUrl}/api/routes/${route.id}/pictures/${picture.id}`, {}, 404);
|
await requestJson(`${baseUrl}/api/routes/${route.id}/pictures/${picture.id}`, {}, 404);
|
||||||
await requestJson(`${baseUrl}/api/routes/${route.id}/audio?poiId=${poi.id}`, {}, 404);
|
await requestJson(`${baseUrl}/api/routes/${route.id}/audio/${poi.id}`, {}, 404);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -152,8 +152,7 @@ test('reaching a POI preloads audio without autoplay and uses the vibration wrap
|
|||||||
const html = await read('public/index.html');
|
const html = await read('public/index.html');
|
||||||
const loader = await read('public/js/bootstrap-loader.js');
|
const loader = await read('public/js/bootstrap-loader.js');
|
||||||
|
|
||||||
assert.match(app, /ns\.AudioPlayer\.load\(poi\.audioUrl \? ns\.Api\.audioUrl/);
|
assert.match(app, /ns\.AudioPlayer\.load\(poi\.audioUrl\)/);
|
||||||
assert.match(app, /ns\.Api\.pictureUrl/);
|
|
||||||
assert.doesNotMatch(app, /AudioPlayer\.play\(/);
|
assert.doesNotMatch(app, /AudioPlayer\.play\(/);
|
||||||
assert.match(app, /ns\.Vibration\.start\(ns\.Vibration\.Patterns\.ACTIVE_POI\)/);
|
assert.match(app, /ns\.Vibration\.start\(ns\.Vibration\.Patterns\.ACTIVE_POI\)/);
|
||||||
assert.doesNotMatch(app, /navigator\.vibrate/);
|
assert.doesNotMatch(app, /navigator\.vibrate/);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user