included serverside mime detection
All checks were successful
Sonarqube Scanner / Build and analyze (push) Successful in 1m6s
All checks were successful
Sonarqube Scanner / Build and analyze (push) Successful in 1m6s
This commit is contained in:
parent
3b3069ca8c
commit
0def9b23db
15
README.md
15
README.md
@ -4,7 +4,7 @@ Neuaufbau des früheren schiffsbezogenen Ansagesystems als mobile Lernweg-Anwend
|
|||||||
|
|
||||||
## Architektur
|
## Architektur
|
||||||
|
|
||||||
- **Server:** Node.js 22.13+, Express 5, `node:sqlite`, Multer und `fast-xml-parser`
|
- **Server:** Node.js 22.13+, Express 5, `node:sqlite`, Multer, `fast-xml-parser`, `file-type` und `@file-type/av`
|
||||||
- **Client:** klassisches JavaScript, jQuery 4.0.0 und jQuery UI 1.14.2 mit lokalem Base-Theme
|
- **Client:** klassisches JavaScript, jQuery 4.0.0 und jQuery UI 1.14.2 mit lokalem Base-Theme
|
||||||
- **Medien:** getrennte Verzeichnisse für aktive und zum Löschen markierte Strecken
|
- **Medien:** getrennte Verzeichnisse für aktive und zum Löschen markierte Strecken
|
||||||
- **Löschmodell:** Soft Delete in SQLite plus atomisches Verschieben des gesamten Streckenordners in `storage/trash/routes`
|
- **Löschmodell:** Soft Delete in SQLite plus atomisches Verschieben des gesamten Streckenordners in `storage/trash/routes`
|
||||||
@ -66,12 +66,17 @@ 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 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.
|
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.
|
||||||
|
|
||||||
## API-Dokumentation
|
## API-Dokumentation
|
||||||
|
|
||||||
Die vollständige REST-API ist einschließlich aller Pfad-, Query-, Formular- und Datei-Parameter sowie ausführlicher `curl`-Beispiele in [`docs/REST-API.md`](docs/REST-API.md) dokumentiert. Die README beschreibt bewusst nur Installation, Architektur und Bedienverhalten.
|
Die vollständige REST-API ist einschließlich aller Pfad-, Query-, Formular- und Datei-Parameter sowie ausführlicher `curl`-Beispiele in [`docs/REST-API.md`](docs/REST-API.md) dokumentiert. Die README beschreibt bewusst nur Installation, Architektur und Bedienverhalten.
|
||||||
|
|
||||||
|
|
||||||
|
## Python-Werkzeuge
|
||||||
|
|
||||||
|
Unter [`tools/python/`](tools/python/README.md) liegen eigenständige Python-3-Skripte zum Anlegen, Ändern, Erweitern, Wiederherstellen und Löschen von Routen, POIs, Bildern und Audiodateien. Sie verwenden ausschließlich die Python-Standardbibliothek. Fehlende Parameter werden interaktiv abgefragt. Antwortet ein vorgeschalteter Passwortschutz mit HTTP 401, fragt die gemeinsame Request-Schicht Benutzername und Passwort ab und wiederholt den Request.
|
||||||
|
|
||||||
## Dateistruktur
|
## Dateistruktur
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@ -88,7 +93,8 @@ data/ lokale SQLite-Datei
|
|||||||
storage/active/routes/ aktive GPX-, Bild- und Audiodateien
|
storage/active/routes/ aktive GPX-, Bild- und Audiodateien
|
||||||
storage/trash/routes/ zum Löschen markierte Strecken
|
storage/trash/routes/ zum Löschen markierte Strecken
|
||||||
examples/ Beispiel-GPX
|
examples/ Beispiel-GPX
|
||||||
test/ Basistests
|
test/ automatisierte Tests
|
||||||
|
tools/python/ interaktive API-Werkzeuge ohne Node.js-Abhängigkeit
|
||||||
```
|
```
|
||||||
|
|
||||||
## Technische Hinweise
|
## Technische Hinweise
|
||||||
@ -97,7 +103,8 @@ test/ Basistests
|
|||||||
- Ö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.
|
- Ö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.
|
- Die Node.js-Anwendung authentifiziert Schreibzugriffe nicht selbst. Ein vorgeschalteter Nginx kann Basic Auth erzwingen; Rollen, CSRF-Schutz, Rate-Limits und ein Moderationsworkflow bleiben sinnvolle Ausbaustufen.
|
||||||
|
- Uploadtypen werden serverseitig aus dem Inhalt erkannt. Binärformate verwenden `file-type` und `@file-type/av`; GPX wird als UTF-8-XML mit `<gpx>`-Wurzelelement geprüft. Dateiname und vom Client behaupteter MIME-Typ sind dafür unerheblich.
|
||||||
- Der Server bindet standardmäßig nur an `127.0.0.1`; Zugriffe von anderen Geräten sind damit bewusst ausgeschlossen.
|
- Der Server bindet standardmäßig nur an `127.0.0.1`; Zugriffe von anderen Geräten sind damit bewusst ausgeschlossen.
|
||||||
- Anwendungsaktionen verwenden Pointer Events für Touch, Stift und Maus. Enter und Leertaste bleiben als Tastaturbedienung erhalten.
|
- Anwendungsaktionen verwenden Pointer Events für Touch, Stift und Maus. Enter und Leertaste bleiben als Tastaturbedienung erhalten.
|
||||||
- Bei gestarteter Route zeigt ein fixierter Footer vorrangig Entfernung und Richtung zum nächstgelegenen, noch nicht erreichten POI. Der GPX-Fortschritt wird parallel entlang der Trackpunkte nachgeführt; sind alle POIs erreicht, übernimmt wieder der nächste GPX-Trackpunkt beziehungsweise das Routenziel. Ohne Kompassdaten wird die Peilung mit Norden oben dargestellt.
|
- Bei gestarteter Route zeigt ein fixierter Footer vorrangig Entfernung und Richtung zum nächstgelegenen, noch nicht erreichten POI. Der GPX-Fortschritt wird parallel entlang der Trackpunkte nachgeführt; sind alle POIs erreicht, übernimmt wieder der nächste GPX-Trackpunkt beziehungsweise das Routenziel. Ohne Kompassdaten wird die Peilung mit Norden oben dargestellt.
|
||||||
|
|||||||
@ -29,14 +29,15 @@ 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 `application/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`.
|
||||||
- GPX-Dateien werden weiterhin ausschließlich als `multipart/form-data` übertragen.
|
- GPX-Dateien werden ausschließlich als `multipart/form-data` übertragen.
|
||||||
- Bild- und Audioendpunkte akzeptieren wahlweise `multipart/form-data` oder ein JSON-Objekt mit Base64-kodierten Dateidaten.
|
- Bild- und Audioendpunkte akzeptieren wahlweise `multipart/form-data` oder ein JSON-Objekt mit Base64-kodierten Dateidaten.
|
||||||
|
- Der Server ermittelt den tatsächlichen MIME-Typ aus dem Dateiinhalt. Dateiname, Dateiendung, Multipart-`Content-Type`, JSON-Felder und Data-URL-Präfixe werden nicht als Typnachweis verwendet.
|
||||||
- Für JSON wird `Content-Type: application/json` empfohlen. `Content-Type: text/json` wird aus Kompatibilitätsgründen ebenfalls akzeptiert.
|
- 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.
|
- 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.
|
- 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 Node.js-Anwendung besitzt keine eigene Authentifizierung. Ein vorgeschalteter Webserver kann Basic Auth erzwingen; die mitgelieferten Python-Werkzeuge erkennen `401 Unauthorized`, fragen Zugangsdaten ab und wiederholen den Request.
|
||||||
|
|
||||||
### Uploadgrenzen und Dateitypen
|
### Uploadgrenzen und Dateitypen
|
||||||
|
|
||||||
@ -46,7 +47,7 @@ Unterstützte Dateitypen:
|
|||||||
|
|
||||||
| Ressource | Dateiendungen beziehungsweise MIME-Typen |
|
| Ressource | Dateiendungen beziehungsweise MIME-Typen |
|
||||||
|---|---|
|
|---|---|
|
||||||
| GPX | `.gpx`, `application/gpx+xml`, `application/xml`, `text/xml` |
|
| GPX | GPX-XML mit `<gpx>`-Wurzelelement |
|
||||||
| Bilder | JPEG, PNG, WebP |
|
| Bilder | JPEG, PNG, WebP |
|
||||||
| Audio | MP3, MP4/M4A, AAC, Ogg, WAV, WebM |
|
| Audio | MP3, MP4/M4A, AAC, Ogg, WAV, WebM |
|
||||||
|
|
||||||
@ -62,12 +63,20 @@ Bei einem JSON-Medienupload ist `picture` beziehungsweise `audio` ein Objekt:
|
|||||||
|
|
||||||
| Eigenschaft | Typ | Pflicht | Beschreibung |
|
| Eigenschaft | Typ | Pflicht | Beschreibung |
|
||||||
|---|---|---:|---|
|
|---|---|---:|---|
|
||||||
| `filename` | Text | ja | ursprünglicher Dateiname einschließlich passender Endung |
|
| `filename` | Text | nein | ursprünglicher Dateiname für Protokollierung; die Endung wird nicht zur Typbestimmung verwendet |
|
||||||
| `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 |
|
| `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,...` |
|
| `dataUrl` | Text | alternativ zu `base64` | vollständige Base64-Data-URL; der dort genannte Medientyp wird ignoriert |
|
||||||
|
|
||||||
`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.
|
`base64` und `dataUrl` sind Alternativen. Der Server erkennt Format und Speicherendung ausschließlich anhand der dekodierten Bytes. Ein vom Client mitgesendetes `contentType`- oder `mimeType`-Feld wird ignoriert und ist nicht erforderlich.
|
||||||
|
|
||||||
|
### Inhaltsbasierte Dateityperkennung
|
||||||
|
|
||||||
|
Binäre Bild- und Audiodateien werden mit `file-type` anhand ihrer Magic Bytes analysiert. Für Audio-/Video-Container wird zusätzlich `@file-type/av` verwendet, damit beispielsweise M4A und WebM möglichst zuverlässig als Audio oder Video unterschieden werden. GPX ist ein textbasiertes XML-Format und wird deshalb separat als UTF-8 gelesen und anhand des `<gpx>`-Wurzelelements validiert; anschließend übernimmt der vorhandene GPX-Parser die fachliche Prüfung.
|
||||||
|
|
||||||
|
Daraus folgen zwei wichtige Regeln:
|
||||||
|
|
||||||
|
- Eine als `image/png` deklarierte Textdatei wird mit `415 Unsupported Media Type` abgewiesen.
|
||||||
|
- Eine echte PNG-Datei wird auch dann akzeptiert, wenn sie `datei.bin` heißt oder im Multipart-Request kein Datei-`Content-Type` angegeben ist.
|
||||||
|
|
||||||
### Fehlerformat
|
### Fehlerformat
|
||||||
|
|
||||||
@ -85,6 +94,7 @@ Typische Statuscodes:
|
|||||||
| `200` | Anfrage erfolgreich |
|
| `200` | Anfrage erfolgreich |
|
||||||
| `201` | Ressource wurde angelegt |
|
| `201` | Ressource wurde angelegt |
|
||||||
| `400` | Parameter oder Upload fehlt beziehungsweise ist ungültig |
|
| `400` | Parameter oder Upload fehlt beziehungsweise ist ungültig |
|
||||||
|
| `401` | vorgeschalteter Webserver verlangt Authentifizierung |
|
||||||
| `404` | Route, POI oder Medienressource wurde nicht gefunden |
|
| `404` | Route, POI oder Medienressource wurde nicht gefunden |
|
||||||
| `409` | Ressource existiert bereits oder Dateisystemzustand verhindert die Operation |
|
| `409` | Ressource existiert bereits oder Dateisystemzustand verhindert die Operation |
|
||||||
| `413` | Datei überschreitet `MAX_UPLOAD_MB` |
|
| `413` | Datei überschreitet `MAX_UPLOAD_MB` |
|
||||||
@ -202,6 +212,7 @@ Pro POI kann höchstens eine Audiodatei existieren. Deshalb wird die Audioressou
|
|||||||
| `GET` | `/api/routes/:routeId/pois/:poiId` | einzelnen POI der Route 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/routes/:routeId/pois/:poiId` | POI-Metadaten aktualisieren |
|
| `PUT` | `/api/routes/:routeId/pois/:poiId` | POI-Metadaten aktualisieren |
|
||||||
|
| `DELETE` | `/api/routes/:routeId/pois/:poiId` | POI einschließlich Bildern und Audio löschen |
|
||||||
| `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 |
|
||||||
@ -308,7 +319,7 @@ Content-Type: `multipart/form-data`
|
|||||||
|
|
||||||
| Feld | Typ | Pflicht | Standard | Beschreibung |
|
| Feld | Typ | Pflicht | Standard | Beschreibung |
|
||||||
|---|---|---:|---|---|
|
|---|---|---:|---|---|
|
||||||
| `gpx` | Datei | ja | – | GPX-Datei mit mindestens einem Trackpunkt |
|
| `gpx` | Datei | ja | – | GPX-Datei mit mindestens einem Trackpunkt; der Dateiname ist unerheblich |
|
||||||
| `name` | Text | bedingt | Name aus GPX | erforderlich, wenn GPX keinen Namen enthält |
|
| `name` | Text | bedingt | Name aus GPX | erforderlich, wenn GPX keinen Namen enthält |
|
||||||
| `slug` | Text | nein | aus `name` | interne URL-freundliche Kennung |
|
| `slug` | Text | nein | aus `name` | interne URL-freundliche Kennung |
|
||||||
| `description` | Text | nein | leer | Routenbeschreibung |
|
| `description` | Text | nein | leer | Routenbeschreibung |
|
||||||
@ -320,7 +331,7 @@ curl -X POST http://127.0.0.1:47145/api/routes \
|
|||||||
-F 'slug=schulwald-runde-klasse-7a' \
|
-F 'slug=schulwald-runde-klasse-7a' \
|
||||||
-F 'description=Naturkundlicher Rundweg der Klasse 7a' \
|
-F 'description=Naturkundlicher Rundweg der Klasse 7a' \
|
||||||
-F 'schoolName=Beispielschule' \
|
-F 'schoolName=Beispielschule' \
|
||||||
-F 'gpx=@examples/sample-route.gpx;type=application/gpx+xml'
|
-F 'gpx=@examples/sample-route.gpx'
|
||||||
```
|
```
|
||||||
|
|
||||||
Erfolg: `201 Created` und `Location: /api/routes/<id>`.
|
Erfolg: `201 Created` und `Location: /api/routes/<id>`.
|
||||||
@ -341,7 +352,7 @@ 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 Strecke' \
|
-F 'description=Überarbeitete Strecke' \
|
||||||
-F 'schoolName=Beispielschule' \
|
-F 'schoolName=Beispielschule' \
|
||||||
-F 'gpx=@route-neu.gpx;type=application/gpx+xml'
|
-F 'gpx=@route-neu.gpx'
|
||||||
```
|
```
|
||||||
|
|
||||||
### `POST /api/routes/:id/append`
|
### `POST /api/routes/:id/append`
|
||||||
@ -354,7 +365,7 @@ Hängt alle Trackpunkte einer GPX-Datei an die Route an und berechnet Streckenwe
|
|||||||
|
|
||||||
```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=@verlaengerung.gpx;type=application/gpx+xml'
|
-F 'gpx=@verlaengerung.gpx'
|
||||||
```
|
```
|
||||||
|
|
||||||
## 7. POIs lesen und bearbeiten
|
## 7. POIs lesen und bearbeiten
|
||||||
@ -443,6 +454,22 @@ curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7 \
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `DELETE /api/routes/:routeId/pois/:poiId`
|
||||||
|
|
||||||
|
Löscht den POI-Datensatz sowie alle zugehörigen Bilddatensätze, Bilddateien und die optionale Audiodatei. Dieser Vorgang ist im Gegensatz zum Soft Delete einer vollständigen Route nicht wiederherstellbar.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X DELETE http://127.0.0.1:47145/api/routes/1/pois/7
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"routeId": 1,
|
||||||
|
"deleted": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## 8. Bilder eines POIs einzeln verwalten
|
## 8. Bilder eines POIs einzeln verwalten
|
||||||
|
|
||||||
Die Route und der POI sind Bestandteil jedes Bildpfades. Dadurch ist die Zuordnung eindeutig und beim Upload muss keine zusätzliche `poiId` übergeben werden.
|
Die Route und der POI sind Bestandteil jedes Bildpfades. Dadurch ist die Zuordnung eindeutig und beim Upload muss keine zusätzliche `poiId` übergeben werden.
|
||||||
@ -512,7 +539,7 @@ Lädt genau ein Bild für den im Pfad angegebenen POI hoch. Zulässig sind zwei
|
|||||||
|
|
||||||
| Feld | Typ | Pflicht | Standard | Beschreibung |
|
| Feld | Typ | Pflicht | Standard | Beschreibung |
|
||||||
|---|---|---:|---:|---|
|
|---|---|---:|---:|---|
|
||||||
| `picture` | Datei | ja | – | JPEG-, PNG- oder WebP-Datei |
|
| `picture` | Datei | ja | – | JPEG-, PNG- oder WebP-Datei; der Typ wird aus dem Inhalt erkannt |
|
||||||
| `caption` | Text | nein | leer | sichtbare Bildbeschreibung und Grundlage für den Alternativtext |
|
| `caption` | Text | nein | leer | sichtbare Bildbeschreibung und Grundlage für den Alternativtext |
|
||||||
| `sequence` | nichtnegative Ganzzahl | nein | nächster freier Wert des POIs | Reihenfolge in der Diashow |
|
| `sequence` | nichtnegative Ganzzahl | nein | nächster freier Wert des POIs | Reihenfolge in der Diashow |
|
||||||
|
|
||||||
@ -520,7 +547,7 @@ Lädt genau ein Bild für den im Pfad angegebenen POI hoch. Zulässig sind zwei
|
|||||||
curl -X POST http://127.0.0.1:47145/api/routes/1/pois/7/pictures \
|
curl -X POST http://127.0.0.1:47145/api/routes/1/pois/7/pictures \
|
||||||
-F 'caption=Blick auf die Baumkrone' \
|
-F 'caption=Blick auf die Baumkrone' \
|
||||||
-F 'sequence=0' \
|
-F 'sequence=0' \
|
||||||
-F 'picture=@eiche.jpg;type=image/jpeg'
|
-F 'picture=@eiche.jpg'
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Variante B: JSON mit Base64
|
#### Variante B: JSON mit Base64
|
||||||
@ -533,7 +560,6 @@ Empfohlener Content-Type: `application/json`. Der Server akzeptiert zusätzlich
|
|||||||
"sequence": 0,
|
"sequence": 0,
|
||||||
"picture": {
|
"picture": {
|
||||||
"filename": "eiche.jpg",
|
"filename": "eiche.jpg",
|
||||||
"contentType": "image/jpeg",
|
|
||||||
"base64": "/9j/4AAQSkZJRgABAQ..."
|
"base64": "/9j/4AAQSkZJRgABAQ..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -553,7 +579,6 @@ jq -n \
|
|||||||
sequence: $sequence,
|
sequence: $sequence,
|
||||||
picture: {
|
picture: {
|
||||||
filename: "eiche.jpg",
|
filename: "eiche.jpg",
|
||||||
contentType: "image/jpeg",
|
|
||||||
base64: $data
|
base64: $data
|
||||||
}
|
}
|
||||||
}' > picture.json
|
}' > picture.json
|
||||||
@ -606,7 +631,7 @@ Datei und Metadaten per Multipart ersetzen:
|
|||||||
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 \
|
||||||
-F 'caption=Neue Aufnahme der Eiche' \
|
-F 'caption=Neue Aufnahme der Eiche' \
|
||||||
-F 'sequence=2' \
|
-F 'sequence=2' \
|
||||||
-F 'picture=@eiche-neu.webp;type=image/webp'
|
-F 'picture=@eiche-neu.webp'
|
||||||
```
|
```
|
||||||
|
|
||||||
Datei und Metadaten per JSON ersetzen:
|
Datei und Metadaten per JSON ersetzen:
|
||||||
@ -617,7 +642,6 @@ Datei und Metadaten per JSON ersetzen:
|
|||||||
"sequence": 2,
|
"sequence": 2,
|
||||||
"picture": {
|
"picture": {
|
||||||
"filename": "eiche-neu.webp",
|
"filename": "eiche-neu.webp",
|
||||||
"contentType": "image/webp",
|
|
||||||
"base64": "UklGRiQAAABXRUJQVlA4..."
|
"base64": "UklGRiQAAABXRUJQVlA4..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -691,11 +715,11 @@ Legt die Audiodatei des im Pfad angegebenen POIs an. Zulässig sind `multipart/f
|
|||||||
|
|
||||||
| Feld | Typ | Pflicht | Beschreibung |
|
| Feld | Typ | Pflicht | Beschreibung |
|
||||||
|---|---|---:|---|
|
|---|---|---:|---|
|
||||||
| `audio` | Datei | ja | MP3-, MP4/M4A-, AAC-, Ogg-, WAV- oder WebM-Datei |
|
| `audio` | Datei | ja | MP3-, MP4/M4A-, AAC-, Ogg-, WAV- oder WebM-Datei; der Typ wird aus dem Inhalt erkannt |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://127.0.0.1:47145/api/routes/1/pois/7/audio \
|
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'
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Variante B: JSON mit Base64
|
#### Variante B: JSON mit Base64
|
||||||
@ -704,7 +728,6 @@ curl -X POST http://127.0.0.1:47145/api/routes/1/pois/7/audio \
|
|||||||
{
|
{
|
||||||
"audio": {
|
"audio": {
|
||||||
"filename": "ansage.mp3",
|
"filename": "ansage.mp3",
|
||||||
"contentType": "audio/mpeg",
|
|
||||||
"base64": "SUQzBAAAAAAAI1RTU0UAAA..."
|
"base64": "SUQzBAAAAAAAI1RTU0UAAA..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -717,7 +740,6 @@ jq -n --rawfile data ansage.mp3.b64 \
|
|||||||
'{
|
'{
|
||||||
audio: {
|
audio: {
|
||||||
filename: "ansage.mp3",
|
filename: "ansage.mp3",
|
||||||
contentType: "audio/mpeg",
|
|
||||||
base64: $data
|
base64: $data
|
||||||
}
|
}
|
||||||
}' > audio.json
|
}' > audio.json
|
||||||
@ -745,7 +767,7 @@ 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'
|
||||||
```
|
```
|
||||||
|
|
||||||
JSON-Beispiel:
|
JSON-Beispiel:
|
||||||
@ -754,7 +776,6 @@ JSON-Beispiel:
|
|||||||
{
|
{
|
||||||
"audio": {
|
"audio": {
|
||||||
"filename": "ansage-neu.ogg",
|
"filename": "ansage-neu.ogg",
|
||||||
"contentType": "audio/ogg",
|
|
||||||
"base64": "T2dnUwACAAAAAAAAAAB..."
|
"base64": "T2dnUwACAAAAAAAAAAB..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -836,3 +857,10 @@ Die Zuordnung lautet:
|
|||||||
| `audio.url` | `/api/routes/:routeId/pois/:poiId/audio` |
|
| `audio.url` | `/api/routes/:routeId/pois/:poiId/audio` |
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
## 12. Python-Werkzeuge
|
||||||
|
|
||||||
|
Unter `tools/python/` liegen interaktive Skripte für Anlegen, Ändern, Erweitern, Wiederherstellen und Löschen von Routen, POIs, Bildern und Audio. Sie verwenden ausschließlich die Python-Standardbibliothek. Fehlende Parameter werden abgefragt. Antwortet ein vorgeschalteter Webserver mit HTTP 401, fragt die gemeinsame Request-Schicht Benutzername und Passwort ab und wiederholt den ursprünglichen Request.
|
||||||
|
|
||||||
|
Details und Aufrufbeispiele stehen in [`tools/python/README.md`](../tools/python/README.md).
|
||||||
|
|||||||
211
package-lock.json
generated
211
package-lock.json
generated
@ -1,16 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "wegwichtel-next",
|
"name": "wegwichtel-next",
|
||||||
"version": "0.11.1",
|
"version": "0.12.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "wegwichtel-next",
|
"name": "wegwichtel-next",
|
||||||
"version": "0.11.1",
|
"version": "0.12.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@file-type/av": "0.2.0",
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"fast-xml-parser": "5.9.0",
|
"fast-xml-parser": "5.9.0",
|
||||||
|
"file-type": "22.0.1",
|
||||||
"jquery": "4.0.0",
|
"jquery": "4.0.0",
|
||||||
"jquery-ui": "1.14.2",
|
"jquery-ui": "1.14.2",
|
||||||
"multer": "2.2.0"
|
"multer": "2.2.0"
|
||||||
@ -19,6 +21,25 @@
|
|||||||
"node": ">=22.13.0"
|
"node": ">=22.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@borewit/text-codec": {
|
||||||
|
"version": "0.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
|
||||||
|
"integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Borewit"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@file-type/av": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@file-type/av/-/av-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-Su7VjPaPJjYIRKMMMVBct42sy5r62n4+OflyZBxZhWLuX/6iD6WPhQG31p9sAr+Uj/2wzHfP26Lw5R/1OcUdNQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"music-metadata": "^11.6.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nodable/entities": {
|
"node_modules/@nodable/entities": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz",
|
||||||
@ -31,6 +52,29 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@tokenizer/inflate": {
|
||||||
|
"version": "0.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
|
||||||
|
"integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"token-types": "^6.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Borewit"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tokenizer/token": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
@ -389,6 +433,23 @@
|
|||||||
"fxparser": "src/cli/cli.js"
|
"fxparser": "src/cli/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/file-type": {
|
||||||
|
"version": "22.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/file-type/-/file-type-22.0.1.tgz",
|
||||||
|
"integrity": "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@tokenizer/inflate": "^0.4.1",
|
||||||
|
"strtok3": "^10.3.5",
|
||||||
|
"token-types": "^6.1.2",
|
||||||
|
"uint8array-extras": "^1.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/finalhandler": {
|
"node_modules/finalhandler": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
||||||
@ -546,6 +607,26 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ieee754": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
@ -716,6 +797,80 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/music-metadata": {
|
||||||
|
"version": "11.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.13.0.tgz",
|
||||||
|
"integrity": "sha512-uXRaov9dfjSpQufXIU7sMxVZnh+FilCQv2mXn+K5EJ/decP3dTWrgvPYa5r6MtRbieNSCE708Da4J0u1UGfQIw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Borewit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "buymeacoffee",
|
||||||
|
"url": "https://buymeacoffee.com/borewit"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@borewit/text-codec": "^0.2.2",
|
||||||
|
"@tokenizer/token": "^0.3.0",
|
||||||
|
"content-type": "^2.0.0",
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"file-type": "^21.3.4",
|
||||||
|
"media-typer": "^2.0.0",
|
||||||
|
"strtok3": "^10.3.5",
|
||||||
|
"token-types": "^6.1.2",
|
||||||
|
"uint8array-extras": "^1.5.0",
|
||||||
|
"win-guid": "^0.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/music-metadata/node_modules/content-type": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/music-metadata/node_modules/file-type": {
|
||||||
|
"version": "21.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz",
|
||||||
|
"integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==",
|
||||||
|
"dependencies": {
|
||||||
|
"@tokenizer/inflate": "^0.4.1",
|
||||||
|
"strtok3": "^10.3.4",
|
||||||
|
"token-types": "^6.1.1",
|
||||||
|
"uint8array-extras": "^1.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/music-metadata/node_modules/media-typer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/negotiator": {
|
"node_modules/negotiator": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||||
@ -1060,6 +1215,22 @@
|
|||||||
"anynum": "^1.0.0"
|
"anynum": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strtok3": {
|
||||||
|
"version": "10.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
|
||||||
|
"integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tokenizer/token": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Borewit"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/toidentifier": {
|
"node_modules/toidentifier": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
@ -1069,6 +1240,24 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/token-types": {
|
||||||
|
"version": "6.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
|
||||||
|
"integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@borewit/text-codec": "^0.2.1",
|
||||||
|
"@tokenizer/token": "^0.3.0",
|
||||||
|
"ieee754": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Borewit"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||||
@ -1106,6 +1295,18 @@
|
|||||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/uint8array-extras": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
@ -1130,6 +1331,12 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/win-guid": {
|
||||||
|
"version": "0.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz",
|
||||||
|
"integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/wrappy": {
|
"node_modules/wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wegwichtel-next",
|
"name": "wegwichtel-next",
|
||||||
"version": "0.11.1",
|
"version": "0.12.0",
|
||||||
"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",
|
||||||
@ -17,8 +17,10 @@
|
|||||||
"test": "node --test"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@file-type/av": "0.2.0",
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"fast-xml-parser": "5.9.0",
|
"fast-xml-parser": "5.9.0",
|
||||||
|
"file-type": "22.0.1",
|
||||||
"jquery": "4.0.0",
|
"jquery": "4.0.0",
|
||||||
"jquery-ui": "1.14.2",
|
"jquery-ui": "1.14.2",
|
||||||
"multer": "2.2.0"
|
"multer": "2.2.0"
|
||||||
|
|||||||
@ -1,35 +1,40 @@
|
|||||||
import fs from 'node:fs';
|
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 { TextDecoder } from 'node:util';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
|
import { fileTypeFromFile } from 'file-type';
|
||||||
|
import { detectAv } from '@file-type/av';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { HttpError } from './errors.js';
|
import { HttpError } from './errors.js';
|
||||||
|
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: path.join(config.storageDir, 'uploads'),
|
destination: path.join(config.storageDir, 'uploads'),
|
||||||
filename(req, file, callback) {
|
filename(req, file, callback) {
|
||||||
const safe = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_');
|
const safe = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_') || 'upload';
|
||||||
callback(null, `${Date.now()}-${crypto.randomUUID()}-${safe}`);
|
callback(null, `${Date.now()}-${crypto.randomUUID()}-${safe}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function fileFilter(req, file, callback) {
|
|
||||||
const allowed = new Set([
|
|
||||||
'application/gpx+xml', 'application/xml', 'text/xml',
|
|
||||||
'image/jpeg', 'image/png', 'image/webp',
|
|
||||||
'audio/mpeg', 'audio/mp4', 'audio/x-m4a', 'audio/aac', 'audio/ogg', 'audio/wav', 'audio/webm'
|
|
||||||
]);
|
|
||||||
const extension = path.extname(file.originalname).toLowerCase();
|
|
||||||
if (allowed.has(file.mimetype) || extension === '.gpx') return callback(null, true);
|
|
||||||
callback(new HttpError(415, `Dateityp nicht unterstützt: ${file.originalname}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const upload = multer({
|
export const upload = multer({
|
||||||
storage,
|
storage,
|
||||||
limits: { fileSize: config.maxUploadBytes, files: 25 },
|
limits: { fileSize: config.maxUploadBytes, files: 25 }
|
||||||
fileFilter
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ALLOWED_TYPES = Object.freeze({
|
||||||
|
picture: Object.freeze({
|
||||||
|
mime: new Set(['image/jpeg', 'image/png', 'image/webp']),
|
||||||
|
ext: new Set(['jpg', 'png', 'webp'])
|
||||||
|
}),
|
||||||
|
audio: Object.freeze({
|
||||||
|
mime: new Set(['audio/mpeg', 'audio/mp4', 'audio/aac', 'audio/ogg', 'audio/wav', 'audio/webm']),
|
||||||
|
ext: new Set(['mp3', 'm4a', 'aac', 'ogg', 'opus', 'spx', 'wav', 'webm'])
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
function removeTemporaryFile(file) {
|
||||||
|
if (file?.path && fs.existsSync(file.path)) fs.rmSync(file.path, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
function decodeBase64(value, fieldName) {
|
function decodeBase64(value, fieldName) {
|
||||||
if (typeof value !== 'string' || !value.trim()) {
|
if (typeof value !== 'string' || !value.trim()) {
|
||||||
@ -59,39 +64,32 @@ function jsonFileDescriptor(value, fieldName) {
|
|||||||
throw new HttpError(400, `${fieldName} muss ein JSON-Objekt sein.`);
|
throw new HttpError(400, `${fieldName} muss ein JSON-Objekt sein.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalname = String(value.filename ?? '').trim();
|
const originalname = String(value.filename ?? `${fieldName}.upload`).trim() || `${fieldName}.upload`;
|
||||||
if (!originalname) throw new HttpError(400, `${fieldName}.filename ist erforderlich.`);
|
|
||||||
|
|
||||||
let mimetype = String(value.contentType ?? value.mimeType ?? '').trim().toLowerCase();
|
|
||||||
let base64 = value.base64;
|
let base64 = value.base64;
|
||||||
|
|
||||||
if (value.dataUrl != null) {
|
if (value.dataUrl != null) {
|
||||||
if (typeof value.dataUrl !== 'string') {
|
if (typeof value.dataUrl !== 'string') {
|
||||||
throw new HttpError(400, `${fieldName}.dataUrl muss eine Zeichenkette sein.`);
|
throw new HttpError(400, `${fieldName}.dataUrl muss eine Zeichenkette sein.`);
|
||||||
}
|
}
|
||||||
const match = /^data:([^;,]+);base64,(.*)$/s.exec(value.dataUrl.trim());
|
const match = /^data:[^,]*;base64,(.*)$/s.exec(value.dataUrl.trim());
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new HttpError(400, `${fieldName}.dataUrl muss eine Base64-Data-URL sein.`);
|
throw new HttpError(400, `${fieldName}.dataUrl muss eine Base64-Data-URL sein.`);
|
||||||
}
|
}
|
||||||
if (!mimetype) mimetype = match[1].trim().toLowerCase();
|
base64 = match[1];
|
||||||
base64 = match[2];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mimetype) throw new HttpError(400, `${fieldName}.contentType ist erforderlich.`);
|
return { originalname, buffer: decodeBase64(base64, fieldName) };
|
||||||
return { originalname, mimetype, buffer: decodeBase64(base64, fieldName) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function jsonUpload(fieldName) {
|
function materializeJsonUpload(req, fieldName) {
|
||||||
return function parseJsonUpload(req, res, next) {
|
if (req.file || req.body?.[fieldName] == null) return;
|
||||||
try {
|
|
||||||
if (req.file || req.body?.[fieldName] == null) return next();
|
|
||||||
|
|
||||||
const descriptor = jsonFileDescriptor(req.body[fieldName], fieldName);
|
const descriptor = jsonFileDescriptor(req.body[fieldName], fieldName);
|
||||||
if (!descriptor) return next();
|
if (!descriptor) return;
|
||||||
|
|
||||||
const destination = path.join(config.storageDir, 'uploads');
|
const destination = path.join(config.storageDir, 'uploads');
|
||||||
fs.mkdirSync(destination, { recursive: true });
|
fs.mkdirSync(destination, { recursive: true });
|
||||||
const safe = descriptor.originalname.replace(/[^a-zA-Z0-9._-]/g, '_');
|
const safe = descriptor.originalname.replace(/[^a-zA-Z0-9._-]/g, '_') || 'upload';
|
||||||
const filename = `${Date.now()}-${crypto.randomUUID()}-${safe}`;
|
const filename = `${Date.now()}-${crypto.randomUUID()}-${safe}`;
|
||||||
const filePath = path.join(destination, filename);
|
const filePath = path.join(destination, filename);
|
||||||
fs.writeFileSync(filePath, descriptor.buffer, { flag: 'wx' });
|
fs.writeFileSync(filePath, descriptor.buffer, { flag: 'wx' });
|
||||||
@ -100,15 +98,77 @@ export function jsonUpload(fieldName) {
|
|||||||
fieldname: fieldName,
|
fieldname: fieldName,
|
||||||
originalname: descriptor.originalname,
|
originalname: descriptor.originalname,
|
||||||
encoding: '7bit',
|
encoding: '7bit',
|
||||||
mimetype: descriptor.mimetype,
|
mimetype: 'application/octet-stream',
|
||||||
destination,
|
destination,
|
||||||
filename,
|
filename,
|
||||||
path: filePath,
|
path: filePath,
|
||||||
size: descriptor.buffer.length
|
size: descriptor.buffer.length
|
||||||
};
|
};
|
||||||
delete req.body[fieldName];
|
delete req.body[fieldName];
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectGpx(file) {
|
||||||
|
let xml;
|
||||||
|
try {
|
||||||
|
const data = fs.readFileSync(file.path);
|
||||||
|
xml = new TextDecoder('utf-8', { fatal: true }).decode(data);
|
||||||
|
} catch {
|
||||||
|
throw new HttpError(415, 'Der Upload ist keine gültig kodierte GPX-Datei.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = xml.slice(0, 131072).replace(/^\uFEFF/, '');
|
||||||
|
if (!/<(?:[A-Za-z_][\w.-]*:)?gpx(?:\s|>)/i.test(start)) {
|
||||||
|
throw new HttpError(415, 'Der Upload enthält kein GPX-Dokument.');
|
||||||
|
}
|
||||||
|
return { ext: 'gpx', mime: 'application/gpx+xml' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectBinary(file) {
|
||||||
|
try {
|
||||||
|
return await fileTypeFromFile(file.path, {
|
||||||
|
customDetectors: [detectAv],
|
||||||
|
mpegOffsetTolerance: 10
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectAndValidate(file, kind) {
|
||||||
|
const detected = kind === 'gpx' ? detectGpx(file) : await detectBinary(file);
|
||||||
|
if (!detected) {
|
||||||
|
throw new HttpError(415, kind === 'picture'
|
||||||
|
? 'Der Dateiinhalt ist kein unterstütztes Bildformat.'
|
||||||
|
: 'Der Dateiinhalt ist kein unterstütztes Audioformat.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind !== 'gpx') {
|
||||||
|
const rules = ALLOWED_TYPES[kind];
|
||||||
|
const baseMime = String(detected.mime).split(';', 1)[0].toLowerCase();
|
||||||
|
const extension = String(detected.ext).toLowerCase();
|
||||||
|
if (!rules || !rules.mime.has(baseMime) || !rules.ext.has(extension)) {
|
||||||
|
throw new HttpError(415, kind === 'picture'
|
||||||
|
? `Nicht unterstütztes Bildformat: ${detected.mime}`
|
||||||
|
: `Nicht unterstütztes Audioformat: ${detected.mime}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file.mimetype = detected.mime;
|
||||||
|
file.detectedMime = detected.mime;
|
||||||
|
file.detectedExtension = detected.ext;
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inspectUpload(fieldName, kind, { allowJson = false } = {}) {
|
||||||
|
return async function inspectUploadedFile(req, res, next) {
|
||||||
|
try {
|
||||||
|
if (allowJson) materializeJsonUpload(req, fieldName);
|
||||||
|
if (!req.file) return next();
|
||||||
|
await detectAndValidate(req.file, kind);
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
removeTemporaryFile(req.file);
|
||||||
|
req.file = undefined;
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { jsonUpload, upload } from '../middleware/upload.js';
|
import { inspectUpload, 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,
|
||||||
listPois, getPoi, createPoi, updatePoi, softDeleteRoute, restoreRoute
|
listPois, getPoi, createPoi, updatePoi, deletePoi, softDeleteRoute, restoreRoute
|
||||||
} from '../services/routes-service.js';
|
} from '../services/routes-service.js';
|
||||||
import {
|
import {
|
||||||
listPictures, getPicture, getPictureFile, createPicture, updatePicture, deletePicture,
|
listPictures, getPicture, getPictureFile, createPicture, updatePicture, deletePicture,
|
||||||
@ -68,16 +68,16 @@ export function createApiRouter(db) {
|
|||||||
res.json(getPoi(db, Number(req.params.routeId), Number(req.params.poiId)));
|
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'), inspectUpload('gpx', 'gpx'), (req, res) => {
|
||||||
const route = createRoute(db, req.body, req.file);
|
const route = createRoute(db, req.body, req.file);
|
||||||
res.status(201).location(`/api/routes/${route.id}`).json(route);
|
res.status(201).location(`/api/routes/${route.id}`).json(route);
|
||||||
});
|
});
|
||||||
|
|
||||||
api.put('/routes/:id', upload.single('gpx'), (req, res) => {
|
api.put('/routes/:id', upload.single('gpx'), inspectUpload('gpx', 'gpx'), (req, res) => {
|
||||||
res.json(updateRoute(db, Number(req.params.id), req.body, req.file));
|
res.json(updateRoute(db, Number(req.params.id), req.body, req.file));
|
||||||
});
|
});
|
||||||
|
|
||||||
api.post('/routes/:id/append', upload.single('gpx'), (req, res) => {
|
api.post('/routes/:id/append', upload.single('gpx'), inspectUpload('gpx', 'gpx'), (req, res) => {
|
||||||
res.json(appendRoute(db, Number(req.params.id), req.file));
|
res.json(appendRoute(db, Number(req.params.id), req.file));
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -97,6 +97,10 @@ export function createApiRouter(db) {
|
|||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
api.delete('/routes/:routeId/pois/:poiId', (req, res) => {
|
||||||
|
res.json(deletePoi(db, Number(req.params.routeId), Number(req.params.poiId)));
|
||||||
|
});
|
||||||
|
|
||||||
api.get('/routes/:routeId/pois/:poiId/pictures', (req, res) => {
|
api.get('/routes/:routeId/pois/:poiId/pictures', (req, res) => {
|
||||||
res.json({ pictures: listPictures(db, Number(req.params.routeId), Number(req.params.poiId)) });
|
res.json({ pictures: listPictures(db, Number(req.params.routeId), Number(req.params.poiId)) });
|
||||||
});
|
});
|
||||||
@ -112,7 +116,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'), jsonUpload('picture'), (req, res) => {
|
api.post('/routes/:routeId/pois/:poiId/pictures', upload.single('picture'), inspectUpload('picture', 'picture', { allowJson: true }), (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);
|
||||||
@ -121,7 +125,7 @@ export function createApiRouter(db) {
|
|||||||
.json(picture);
|
.json(picture);
|
||||||
});
|
});
|
||||||
|
|
||||||
api.put('/routes/:routeId/pois/:poiId/pictures/:pictureId', upload.single('picture'), jsonUpload('picture'), (req, res) => {
|
api.put('/routes/:routeId/pois/:poiId/pictures/:pictureId', upload.single('picture'), inspectUpload('picture', 'picture', { allowJson: true }), (req, res) => {
|
||||||
res.json(updatePicture(
|
res.json(updatePicture(
|
||||||
db,
|
db,
|
||||||
Number(req.params.routeId),
|
Number(req.params.routeId),
|
||||||
@ -151,7 +155,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'), jsonUpload('audio'), (req, res) => {
|
api.post('/routes/:routeId/pois/:poiId/audio', upload.single('audio'), inspectUpload('audio', 'audio', { allowJson: true }), (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);
|
||||||
@ -160,7 +164,7 @@ export function createApiRouter(db) {
|
|||||||
.json(audio);
|
.json(audio);
|
||||||
});
|
});
|
||||||
|
|
||||||
api.put('/routes/:routeId/pois/:poiId/audio', upload.single('audio'), jsonUpload('audio'), (req, res) => {
|
api.put('/routes/:routeId/pois/:poiId/audio', upload.single('audio'), inspectUpload('audio', 'audio', { allowJson: true }), (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));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -20,10 +20,7 @@ const AUDIO_MIME_TYPES = new Set([
|
|||||||
'audio/webm'
|
'audio/webm'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
|
const uniqueFilename = file => `${crypto.randomUUID()}.${file.detectedExtension}`;
|
||||||
const AUDIO_EXTENSIONS = new Set(['.mp3', '.mp4', '.m4a', '.aac', '.ogg', '.wav', '.webm']);
|
|
||||||
|
|
||||||
const uniqueFilename = original => `${crypto.randomUUID()}${path.extname(original).toLowerCase()}`;
|
|
||||||
const pictureUrl = (routeId, poiId, pictureId) => `/api/routes/${routeId}/pois/${poiId}/pictures/${pictureId}`;
|
const pictureUrl = (routeId, poiId, pictureId) => `/api/routes/${routeId}/pois/${poiId}/pictures/${pictureId}`;
|
||||||
const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/pois/${poiId}/audio`;
|
const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/pois/${poiId}/audio`;
|
||||||
|
|
||||||
@ -50,16 +47,16 @@ function poiOnRoute(db, routeId, poiId) {
|
|||||||
function validateUpload(file, kind) {
|
function validateUpload(file, kind) {
|
||||||
if (!file) throw new HttpError(400, `Eine ${kind === 'picture' ? 'Bilddatei' : 'Audiodatei'} ist erforderlich.`);
|
if (!file) throw new HttpError(400, `Eine ${kind === 'picture' ? 'Bilddatei' : 'Audiodatei'} ist erforderlich.`);
|
||||||
|
|
||||||
const extension = path.extname(file.originalname).toLowerCase();
|
const baseMime = String(file.detectedMime || file.mimetype || '').split(';', 1)[0].toLowerCase();
|
||||||
const valid = kind === 'picture'
|
const valid = kind === 'picture'
|
||||||
? IMAGE_MIME_TYPES.has(file.mimetype) || IMAGE_EXTENSIONS.has(extension)
|
? IMAGE_MIME_TYPES.has(baseMime)
|
||||||
: AUDIO_MIME_TYPES.has(file.mimetype) || AUDIO_EXTENSIONS.has(extension);
|
: AUDIO_MIME_TYPES.has(baseMime);
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid || !file.detectedExtension) {
|
||||||
removeUpload(file);
|
removeUpload(file);
|
||||||
throw new HttpError(415, kind === 'picture'
|
throw new HttpError(415, kind === 'picture'
|
||||||
? 'Der Upload ist keine unterstützte Bilddatei.'
|
? 'Der Dateiinhalt ist keine unterstützte Bilddatei.'
|
||||||
: 'Der Upload ist keine unterstützte Audiodatei.');
|
: 'Der Dateiinhalt ist keine unterstützte Audiodatei.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -125,7 +122,7 @@ export function createPicture(db, routeId, poiId, fields, file) {
|
|||||||
const caption = String(fields.caption ?? '');
|
const caption = String(fields.caption ?? '');
|
||||||
const root = ensureRouteDirectories(routeId);
|
const root = ensureRouteDirectories(routeId);
|
||||||
|
|
||||||
relativePath = moveUploadedFile(file, path.join(root, 'images', uniqueFilename(file.originalname)));
|
relativePath = moveUploadedFile(file, path.join(root, 'images', uniqueFilename(file)));
|
||||||
const result = db.prepare('INSERT INTO poi_images (poi_id, path, caption, sequence) VALUES (?, ?, ?, ?)')
|
const result = db.prepare('INSERT INTO poi_images (poi_id, path, caption, sequence) VALUES (?, ?, ?, ?)')
|
||||||
.run(poiId, relativePath, caption, sequence);
|
.run(poiId, relativePath, caption, sequence);
|
||||||
return getPicture(db, routeId, poiId, Number(result.lastInsertRowid));
|
return getPicture(db, routeId, poiId, Number(result.lastInsertRowid));
|
||||||
@ -150,7 +147,7 @@ export function updatePicture(db, routeId, poiId, pictureId, fields, file) {
|
|||||||
if (file) {
|
if (file) {
|
||||||
validateUpload(file, 'picture');
|
validateUpload(file, 'picture');
|
||||||
const root = ensureRouteDirectories(routeId);
|
const root = ensureRouteDirectories(routeId);
|
||||||
newPath = moveUploadedFile(file, path.join(root, 'images', uniqueFilename(file.originalname)));
|
newPath = moveUploadedFile(file, path.join(root, 'images', uniqueFilename(file)));
|
||||||
}
|
}
|
||||||
|
|
||||||
db.prepare('UPDATE poi_images SET path = ?, caption = ?, sequence = ? WHERE id = ? AND poi_id = ?')
|
db.prepare('UPDATE poi_images SET path = ?, caption = ?, sequence = ? WHERE id = ? AND poi_id = ?')
|
||||||
@ -216,7 +213,7 @@ function storeAudio(db, routeId, poiId, file, { requireAbsent, requireExisting }
|
|||||||
}
|
}
|
||||||
|
|
||||||
const root = ensureRouteDirectories(routeId);
|
const root = ensureRouteDirectories(routeId);
|
||||||
relativePath = moveUploadedFile(file, path.join(root, 'audio', uniqueFilename(file.originalname)));
|
relativePath = moveUploadedFile(file, path.join(root, 'audio', uniqueFilename(file)));
|
||||||
db.prepare('UPDATE pois SET audio_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND route_id = ?')
|
db.prepare('UPDATE pois SET audio_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND route_id = ?')
|
||||||
.run(relativePath, poiId, routeId);
|
.run(relativePath, poiId, routeId);
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ 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, resolveStoredFile, removeUpload
|
softDeleteRouteDirectory, restoreRouteDirectory, resolveStoredFile, removeUpload, removeStoredFile
|
||||||
} 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()}`;
|
||||||
@ -188,6 +188,22 @@ export function updatePoi(db, routeId, poiId, fields) {
|
|||||||
return listPois(db, existing.route_id).find(poi => poi.id === Number(poiId));
|
return listPois(db, existing.route_id).find(poi => poi.id === Number(poiId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deletePoi(db, routeId, poiId) {
|
||||||
|
const existing = db.prepare(`SELECT p.id, p.audio_path 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 (!existing) throw new HttpError(404, 'POI nicht gefunden.');
|
||||||
|
|
||||||
|
const images = db.prepare('SELECT path FROM poi_images WHERE poi_id = ?').all(poiId);
|
||||||
|
transaction(db, () => {
|
||||||
|
db.prepare('DELETE FROM poi_images WHERE poi_id = ?').run(poiId);
|
||||||
|
db.prepare('DELETE FROM pois WHERE id = ? AND route_id = ?').run(poiId, routeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const image of images) removeStoredFile(image.path);
|
||||||
|
removeStoredFile(existing.audio_path);
|
||||||
|
return { id: poiId, routeId, deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
export function softDeleteRoute(db, id) {
|
export function softDeleteRoute(db, id) {
|
||||||
const route = db.prepare("SELECT * FROM routes WHERE id = ? AND status = 'active'").get(id);
|
const route = db.prepare("SELECT * FROM routes WHERE id = ? AND status = 'active'").get(id);
|
||||||
if (!route) throw new HttpError(404, 'Aktive Strecke nicht gefunden.');
|
if (!route) throw new HttpError(404, 'Aktive Strecke nicht gefunden.');
|
||||||
|
|||||||
@ -26,6 +26,7 @@ test('complete REST API documentation is kept outside the README', async () => {
|
|||||||
'POST /api/routes/:id/append',
|
'POST /api/routes/:id/append',
|
||||||
'POST /api/routes/:id/pois',
|
'POST /api/routes/:id/pois',
|
||||||
'PUT /api/routes/:routeId/pois/:poiId',
|
'PUT /api/routes/:routeId/pois/:poiId',
|
||||||
|
'DELETE /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',
|
||||||
@ -45,7 +46,7 @@ test('complete REST API documentation is kept outside the README', async () => {
|
|||||||
'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'
|
'filename', '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}`);
|
||||||
}
|
}
|
||||||
@ -57,17 +58,18 @@ test('POI media uploads use POI-scoped single-resource endpoints', async () => {
|
|||||||
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');
|
const api = await read('docs/REST-API.md');
|
||||||
|
|
||||||
assert.match(router, /post\('\/routes\/:routeId\/pois\/:poiId\/pictures', upload\.single\('picture'\), jsonUpload\('picture'\)/);
|
assert.match(router, /post\('\/routes\/:routeId\/pois\/:poiId\/pictures', upload\.single\('picture'\), inspectUpload\('picture', 'picture'/);
|
||||||
assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId', upload\.single\('picture'\), jsonUpload\('picture'\)/);
|
assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId', upload\.single\('picture'\), inspectUpload\('picture', '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'\), jsonUpload\('audio'\)/);
|
assert.match(router, /post\('\/routes\/:routeId\/pois\/:poiId\/audio', upload\.single\('audio'\), inspectUpload\('audio', 'audio'/);
|
||||||
assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/audio', upload\.single\('audio'\), jsonUpload\('audio'\)/);
|
assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/audio', upload\.single\('audio'\), inspectUpload\('audio', '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, /get\('\/routes\/:routeId\/pois\/:poiId'/);
|
||||||
assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId', upload\.none\(\)/);
|
assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId', upload\.none\(\)/);
|
||||||
|
assert.match(router, /delete\('\/routes\/:routeId\/pois\/:poiId'/);
|
||||||
assert.doesNotMatch(router, /['"]\/pois\/:id['"]/);
|
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/);
|
||||||
@ -78,7 +80,7 @@ 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(router, /inspectUpload/);
|
||||||
assert.match(api, /Content-Type: `application\/json`/);
|
assert.match(api, /Content-Type: `application\/json`/);
|
||||||
assert.match(api, /`text\/json`/);
|
assert.match(api, /`text\/json`/);
|
||||||
assert.match(api, /Base64/);
|
assert.match(api, /Base64/);
|
||||||
|
|||||||
@ -7,6 +7,19 @@ import { spawn } from 'node:child_process';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
const root = path.resolve(import.meta.dirname, '..');
|
const root = path.resolve(import.meta.dirname, '..');
|
||||||
|
const PNG_FILE = Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=',
|
||||||
|
'base64'
|
||||||
|
);
|
||||||
|
const MP3_FILE = Buffer.concat([
|
||||||
|
Buffer.from('49443304000000000000', 'hex'),
|
||||||
|
Buffer.from('fffb9064', 'hex'),
|
||||||
|
Buffer.alloc(512)
|
||||||
|
]);
|
||||||
|
const WAV_FILE = Buffer.from(
|
||||||
|
'524946462400000057415645666d74201000000001000100401f0000803e0000020010006461746100000000',
|
||||||
|
'hex'
|
||||||
|
);
|
||||||
|
|
||||||
async function freePort() {
|
async function freePort() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@ -79,7 +92,7 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
|
|||||||
const gpx = await fs.readFile(path.join(root, 'examples', 'sample-route.gpx'));
|
const gpx = await fs.readFile(path.join(root, 'examples', 'sample-route.gpx'));
|
||||||
const routeForm = new FormData();
|
const routeForm = new FormData();
|
||||||
routeForm.append('name', 'Medien-API-Test');
|
routeForm.append('name', 'Medien-API-Test');
|
||||||
routeForm.append('gpx', new Blob([gpx], { type: 'application/gpx+xml' }), 'route.gpx');
|
routeForm.append('gpx', new Blob([gpx], { type: 'application/octet-stream' }), 'route.txt');
|
||||||
const { body: route } = await requestJson(`${baseUrl}/api/routes`, {
|
const { body: route } = await requestJson(`${baseUrl}/api/routes`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: routeForm
|
body: routeForm
|
||||||
@ -139,7 +152,7 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
|
|||||||
pictureForm.append('poiId', String(otherPoi.id));
|
pictureForm.append('poiId', String(otherPoi.id));
|
||||||
pictureForm.append('caption', 'Erste Bildbeschreibung');
|
pictureForm.append('caption', 'Erste Bildbeschreibung');
|
||||||
pictureForm.append('sequence', '0');
|
pictureForm.append('sequence', '0');
|
||||||
pictureForm.append('picture', new Blob(['picture-one'], { type: 'image/png' }), 'bild.png');
|
pictureForm.append('picture', new Blob([PNG_FILE], { type: 'application/octet-stream' }), 'bild.txt');
|
||||||
const { response: pictureResponse, body: picture } = await requestJson(
|
const { response: pictureResponse, body: picture } = await requestJson(
|
||||||
`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures`,
|
`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures`,
|
||||||
{ method: 'POST', body: pictureForm },
|
{ method: 'POST', body: pictureForm },
|
||||||
@ -151,7 +164,7 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
|
|||||||
assert.equal(picture.url, `/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`);
|
assert.equal(picture.url, `/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`);
|
||||||
const { response: pictureFileResponse, body: pictureFile } = await requestBuffer(`${baseUrl}${picture.url}`);
|
const { response: pictureFileResponse, body: pictureFile } = await requestBuffer(`${baseUrl}${picture.url}`);
|
||||||
assert.match(pictureFileResponse.headers.get('content-type') || '', /image\/png/);
|
assert.match(pictureFileResponse.headers.get('content-type') || '', /image\/png/);
|
||||||
assert.equal(pictureFile.toString(), 'picture-one');
|
assert.deepEqual(pictureFile, PNG_FILE);
|
||||||
const { body: pictureMetadata } = await requestJson(`${baseUrl}${picture.url}?metadata=true`);
|
const { body: pictureMetadata } = await requestJson(`${baseUrl}${picture.url}?metadata=true`);
|
||||||
assert.equal(pictureMetadata.caption, 'Erste Bildbeschreibung');
|
assert.equal(pictureMetadata.caption, 'Erste Bildbeschreibung');
|
||||||
assert.equal(pictureMetadata.poiId, poi.id);
|
assert.equal(pictureMetadata.poiId, poi.id);
|
||||||
@ -169,9 +182,8 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
|
|||||||
caption: 'Bild aus JSON',
|
caption: 'Bild aus JSON',
|
||||||
sequence: 1,
|
sequence: 1,
|
||||||
picture: {
|
picture: {
|
||||||
filename: 'bild-json.webp',
|
filename: 'bild-json.bin',
|
||||||
contentType: 'image/webp',
|
base64: PNG_FILE.toString('base64')
|
||||||
base64: Buffer.from('picture-json').toString('base64')
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const { body: jsonPicture } = await requestJson(
|
const { body: jsonPicture } = await requestJson(
|
||||||
@ -186,8 +198,8 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
|
|||||||
assert.equal(jsonPicture.poiId, otherPoi.id);
|
assert.equal(jsonPicture.poiId, otherPoi.id);
|
||||||
assert.equal(jsonPicture.caption, 'Bild aus JSON');
|
assert.equal(jsonPicture.caption, 'Bild aus JSON');
|
||||||
const { response: jsonPictureResponse, body: jsonPictureFile } = await requestBuffer(`${baseUrl}${jsonPicture.url}`);
|
const { response: jsonPictureResponse, body: jsonPictureFile } = await requestBuffer(`${baseUrl}${jsonPicture.url}`);
|
||||||
assert.match(jsonPictureResponse.headers.get('content-type') || '', /image\/webp/);
|
assert.match(jsonPictureResponse.headers.get('content-type') || '', /image\/png/);
|
||||||
assert.equal(jsonPictureFile.toString(), 'picture-json');
|
assert.deepEqual(jsonPictureFile, PNG_FILE);
|
||||||
|
|
||||||
await requestJson(
|
await requestJson(
|
||||||
`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures`,
|
`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures`,
|
||||||
@ -199,6 +211,22 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
|
|||||||
400
|
400
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await requestJson(
|
||||||
|
`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
picture: {
|
||||||
|
filename: 'behauptetes-bild.png',
|
||||||
|
contentType: 'image/png',
|
||||||
|
base64: Buffer.from('kein Bild').toString('base64')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
415
|
||||||
|
);
|
||||||
|
|
||||||
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}`,
|
||||||
{
|
{
|
||||||
@ -211,7 +239,7 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
|
|||||||
assert.equal(updatedPicture.sequence, 2);
|
assert.equal(updatedPicture.sequence, 2);
|
||||||
|
|
||||||
const audioForm = new FormData();
|
const audioForm = new FormData();
|
||||||
audioForm.append('audio', new Blob(['audio-one'], { type: 'audio/mpeg' }), 'ansage.mp3');
|
audioForm.append('audio', new Blob([MP3_FILE], { type: 'application/octet-stream' }), 'ansage.txt');
|
||||||
const { response: audioResponse, body: audio } = await requestJson(
|
const { response: audioResponse, body: audio } = await requestJson(
|
||||||
`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`,
|
`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`,
|
||||||
{ method: 'POST', body: audioForm },
|
{ method: 'POST', body: audioForm },
|
||||||
@ -222,12 +250,12 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
|
|||||||
assert.equal(audio.url, `/api/routes/${route.id}/pois/${poi.id}/audio`);
|
assert.equal(audio.url, `/api/routes/${route.id}/pois/${poi.id}/audio`);
|
||||||
const { response: audioFileResponse, body: audioFile } = await requestBuffer(`${baseUrl}${audio.url}`);
|
const { response: audioFileResponse, body: audioFile } = await requestBuffer(`${baseUrl}${audio.url}`);
|
||||||
assert.match(audioFileResponse.headers.get('content-type') || '', /audio\/mpeg/);
|
assert.match(audioFileResponse.headers.get('content-type') || '', /audio\/mpeg/);
|
||||||
assert.equal(audioFile.toString(), 'audio-one');
|
assert.deepEqual(audioFile, MP3_FILE);
|
||||||
const { body: audioMetadata } = await requestJson(`${baseUrl}${audio.url}?metadata=true`);
|
const { body: audioMetadata } = await requestJson(`${baseUrl}${audio.url}?metadata=true`);
|
||||||
assert.equal(audioMetadata.poiId, poi.id);
|
assert.equal(audioMetadata.poiId, poi.id);
|
||||||
|
|
||||||
const duplicateAudio = new FormData();
|
const duplicateAudio = new FormData();
|
||||||
duplicateAudio.append('audio', new Blob(['duplicate'], { type: 'audio/mpeg' }), 'doppelt.mp3');
|
duplicateAudio.append('audio', new Blob([MP3_FILE], { type: 'image/png' }), 'doppelt.png');
|
||||||
await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`, {
|
await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: duplicateAudio
|
body: duplicateAudio
|
||||||
@ -235,9 +263,8 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
|
|||||||
|
|
||||||
const replacementAudio = {
|
const replacementAudio = {
|
||||||
audio: {
|
audio: {
|
||||||
filename: 'ansage-neu.ogg',
|
filename: 'ansage-neu.bin',
|
||||||
contentType: 'audio/ogg',
|
base64: WAV_FILE.toString('base64')
|
||||||
base64: Buffer.from('audio-two').toString('base64')
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const { body: replacedAudio } = await requestJson(
|
const { body: replacedAudio } = await requestJson(
|
||||||
@ -250,8 +277,8 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
|
|||||||
);
|
);
|
||||||
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}`);
|
||||||
assert.match(replacedAudioResponse.headers.get('content-type') || '', /audio\/ogg/);
|
assert.match(replacedAudioResponse.headers.get('content-type') || '', /audio\/wav/);
|
||||||
assert.equal(replacedAudioFile.toString(), 'audio-two');
|
assert.deepEqual(replacedAudioFile, WAV_FILE);
|
||||||
|
|
||||||
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');
|
||||||
@ -278,11 +305,22 @@ 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(
|
const otherAudioForm = new FormData();
|
||||||
`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures/${jsonPicture.id}`,
|
otherAudioForm.append('audio', new Blob([MP3_FILE], { type: 'text/plain' }), 'falsch.txt');
|
||||||
|
await requestJson(
|
||||||
|
`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/audio`,
|
||||||
|
{ method: 'POST', body: otherAudioForm },
|
||||||
|
201
|
||||||
|
);
|
||||||
|
|
||||||
|
const { body: deletedPoi } = await requestJson(
|
||||||
|
`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}`,
|
||||||
{ method: 'DELETE' }
|
{ method: 'DELETE' }
|
||||||
);
|
);
|
||||||
assert.equal(deletedJsonPicture.deleted, true);
|
assert.equal(deletedPoi.deleted, true);
|
||||||
|
await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}`, {}, 404);
|
||||||
|
await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures/${jsonPicture.id}`, {}, 404);
|
||||||
|
await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/audio`, {}, 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}/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);
|
||||||
|
|||||||
87
test/upload-detection-tools.test.js
Normal file
87
test/upload-detection-tools.test.js
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
|
import http from 'node:http';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dirname, '..');
|
||||||
|
const read = relative => fs.readFile(path.join(root, relative), 'utf8');
|
||||||
|
|
||||||
|
test('upload MIME types are detected from content and not trusted request metadata', async () => {
|
||||||
|
const upload = await read('src/middleware/upload.js');
|
||||||
|
const media = await read('src/services/media-service.js');
|
||||||
|
const pkg = JSON.parse(await read('package.json'));
|
||||||
|
|
||||||
|
assert.equal(pkg.dependencies['file-type'], '22.0.1');
|
||||||
|
assert.equal(pkg.dependencies['@file-type/av'], '0.2.0');
|
||||||
|
assert.match(upload, /fileTypeFromFile/);
|
||||||
|
assert.match(upload, /customDetectors:\s*\[detectAv\]/);
|
||||||
|
assert.match(upload, /detectGpx/);
|
||||||
|
assert.doesNotMatch(upload, /allowed\.has\(file\.mimetype\)/);
|
||||||
|
assert.doesNotMatch(upload, /contentType ist erforderlich/);
|
||||||
|
assert.match(media, /file\.detectedExtension/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Python API tools are syntax-valid and contain reusable 401 authentication handling', async () => {
|
||||||
|
const toolsDir = path.join(root, 'tools/python');
|
||||||
|
const names = (await fs.readdir(toolsDir)).filter(name => name.endsWith('.py'));
|
||||||
|
const expected = [
|
||||||
|
'append_route.py', 'common.py', 'create_audio.py', 'create_picture.py', 'create_poi.py',
|
||||||
|
'create_route.py', 'delete_audio.py', 'delete_picture.py', 'delete_poi.py', 'delete_route.py',
|
||||||
|
'restore_route.py', 'update_audio.py', 'update_picture.py', 'update_poi.py', 'update_route.py'
|
||||||
|
];
|
||||||
|
assert.deepEqual(names.sort(), expected.sort());
|
||||||
|
|
||||||
|
const compile = spawnSync('python3', ['-m', 'py_compile', ...names.map(name => path.join(toolsDir, name))], {
|
||||||
|
cwd: root,
|
||||||
|
encoding: 'utf8'
|
||||||
|
});
|
||||||
|
assert.equal(compile.status, 0, compile.stderr);
|
||||||
|
await fs.rm(path.join(toolsDir, '__pycache__'), { recursive: true, force: true });
|
||||||
|
|
||||||
|
const common = await read('tools/python/common.py');
|
||||||
|
assert.match(common, /class ApiClient/);
|
||||||
|
assert.match(common, /error\.code == 401/);
|
||||||
|
assert.match(common, /self\.prompt_credentials\(\)/);
|
||||||
|
assert.match(common, /getpass\.getpass/);
|
||||||
|
assert.match(common, /def encode_multipart/);
|
||||||
|
assert.doesNotMatch(common, /Content-Type:.*application\/octet-stream/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('Python client retries the same request with credentials after HTTP 401', async t => {
|
||||||
|
const expected = `Basic ${Buffer.from('tester:secret').toString('base64')}`;
|
||||||
|
let requests = 0;
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
requests += 1;
|
||||||
|
if (req.headers.authorization !== expected) {
|
||||||
|
res.writeHead(401, { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Basic realm="test"' });
|
||||||
|
res.end(JSON.stringify({ message: 'Zugangsdaten erforderlich.' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ ok: true }));
|
||||||
|
});
|
||||||
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
t.after(() => server.close());
|
||||||
|
const { port } = server.address();
|
||||||
|
|
||||||
|
const script = `
|
||||||
|
from common import ApiClient, Credentials
|
||||||
|
class TestClient(ApiClient):
|
||||||
|
def prompt_credentials(self):
|
||||||
|
self.credentials = Credentials('tester', 'secret')
|
||||||
|
result = TestClient('http://127.0.0.1:${port}').request('GET', '/protected')
|
||||||
|
assert result == {'ok': True}
|
||||||
|
`;
|
||||||
|
const child = spawn('python3', ['-c', script], {
|
||||||
|
env: { ...process.env, PYTHONPATH: path.join(root, 'tools/python') },
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
const stderr = [];
|
||||||
|
child.stderr.on('data', chunk => stderr.push(chunk.toString()));
|
||||||
|
const status = await new Promise(resolve => child.once('exit', resolve));
|
||||||
|
assert.equal(status, 0, stderr.join(''));
|
||||||
|
assert.equal(requests, 2);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user