included serverside mime detection
All checks were successful
Sonarqube Scanner / Build and analyze (push) Successful in 1m6s

This commit is contained in:
Florian Zumpe 2026-06-17 11:42:43 +02:00
parent 3b3069ca8c
commit 0def9b23db
11 changed files with 574 additions and 126 deletions

View File

@ -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.

View File

@ -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
View File

@ -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",

View File

@ -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"

View File

@ -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,56 +64,111 @@ 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;
const descriptor = jsonFileDescriptor(req.body[fieldName], fieldName);
if (!descriptor) return;
const destination = path.join(config.storageDir, 'uploads');
fs.mkdirSync(destination, { recursive: true });
const safe = descriptor.originalname.replace(/[^a-zA-Z0-9._-]/g, '_') || 'upload';
const filename = `${Date.now()}-${crypto.randomUUID()}-${safe}`;
const filePath = path.join(destination, filename);
fs.writeFileSync(filePath, descriptor.buffer, { flag: 'wx' });
req.file = {
fieldname: fieldName,
originalname: descriptor.originalname,
encoding: '7bit',
mimetype: 'application/octet-stream',
destination,
filename,
path: filePath,
size: descriptor.buffer.length
};
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 { try {
if (req.file || req.body?.[fieldName] == null) return next(); if (allowJson) materializeJsonUpload(req, fieldName);
if (!req.file) return next();
const descriptor = jsonFileDescriptor(req.body[fieldName], fieldName); await detectAndValidate(req.file, kind);
if (!descriptor) return next();
const destination = path.join(config.storageDir, 'uploads');
fs.mkdirSync(destination, { recursive: true });
const safe = descriptor.originalname.replace(/[^a-zA-Z0-9._-]/g, '_');
const filename = `${Date.now()}-${crypto.randomUUID()}-${safe}`;
const filePath = path.join(destination, filename);
fs.writeFileSync(filePath, descriptor.buffer, { flag: 'wx' });
req.file = {
fieldname: fieldName,
originalname: descriptor.originalname,
encoding: '7bit',
mimetype: descriptor.mimetype,
destination,
filename,
path: filePath,
size: descriptor.buffer.length
};
delete req.body[fieldName];
next(); next();
} catch (error) { } catch (error) {
removeTemporaryFile(req.file);
req.file = undefined;
next(error); next(error);
} }
}; };

View File

@ -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));
}); });

View 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);

View File

@ -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.');

View File

@ -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/);

View File

@ -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);

View 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);
});