From 0def9b23db712de74e5946d8efd790327a60461c Mon Sep 17 00:00:00 2001 From: Florian Zumpe Date: Wed, 17 Jun 2026 11:42:43 +0200 Subject: [PATCH] included serverside mime detection --- README.md | 15 +- docs/REST-API.md | 74 +++++++--- package-lock.json | 211 +++++++++++++++++++++++++++- package.json | 4 +- src/middleware/upload.js | 156 +++++++++++++------- src/routes/api.js | 22 +-- src/services/media-service.js | 23 ++- src/services/routes-service.js | 18 ++- test/api-docs.test.js | 14 +- test/media-api.test.js | 76 +++++++--- test/upload-detection-tools.test.js | 87 ++++++++++++ 11 files changed, 574 insertions(+), 126 deletions(-) create mode 100644 test/upload-detection-tools.test.js diff --git a/README.md b/README.md index eed5503..6673a1a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Neuaufbau des früheren schiffsbezogenen Ansagesystems als mobile Lernweg-Anwend ## 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 - **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` @@ -66,12 +66,17 @@ public/vendor/jquery-ui/jquery-ui-1.14.2.min.css 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 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 ```text @@ -88,7 +93,8 @@ data/ lokale SQLite-Datei storage/active/routes/ aktive GPX-, Bild- und Audiodateien storage/trash/routes/ zum Löschen markierte Strecken examples/ Beispiel-GPX -test/ Basistests +test/ automatisierte Tests +tools/python/ interaktive API-Werkzeuge ohne Node.js-Abhängigkeit ``` ## 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. - 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. -- 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 ``-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. - 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. diff --git a/docs/REST-API.md b/docs/REST-API.md index 2933013..97c1cb6 100644 --- a/docs/REST-API.md +++ b/docs/REST-API.md @@ -29,14 +29,15 @@ https://wegwichtel.example.org/api - 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. - 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. +- 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. - 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. - 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. -- 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 @@ -46,7 +47,7 @@ Unterstützte Dateitypen: | Ressource | Dateiendungen beziehungsweise MIME-Typen | |---|---| -| GPX | `.gpx`, `application/gpx+xml`, `application/xml`, `text/xml` | +| GPX | GPX-XML mit ``-Wurzelelement | | Bilder | JPEG, PNG, WebP | | 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 | |---|---|---:|---| -| `filename` | Text | ja | ursprünglicher Dateiname einschließlich passender Endung | -| `contentType` | Text | ja, sofern keine `dataUrl` den Typ enthält | MIME-Typ, beispielsweise `image/jpeg` oder `audio/mpeg` | +| `filename` | Text | nein | ursprünglicher Dateiname für Protokollierung; die Endung wird nicht zur Typbestimmung verwendet | | `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 ``-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 @@ -85,6 +94,7 @@ Typische Statuscodes: | `200` | Anfrage erfolgreich | | `201` | Ressource wurde angelegt | | `400` | Parameter oder Upload fehlt beziehungsweise ist ungültig | +| `401` | vorgeschalteter Webserver verlangt Authentifizierung | | `404` | Route, POI oder Medienressource wurde nicht gefunden | | `409` | Ressource existiert bereits oder Dateisystemzustand verhindert die Operation | | `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 | | `POST` | `/api/routes/:id/pois` | POI-Metadaten anlegen | | `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/:pictureId` | Bilddatei ausliefern; optional Metadaten mit `?metadata=true` | | `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 | |---|---|---:|---|---| -| `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 | | `slug` | Text | nein | aus `name` | interne URL-freundliche Kennung | | `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 'description=Naturkundlicher Rundweg der Klasse 7a' \ -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/`. @@ -341,7 +352,7 @@ curl -X PUT http://127.0.0.1:47145/api/routes/1 \ -F 'name=Schulwald-Runde 2026' \ -F 'description=Überarbeitete Strecke' \ -F 'schoolName=Beispielschule' \ - -F 'gpx=@route-neu.gpx;type=application/gpx+xml' + -F 'gpx=@route-neu.gpx' ``` ### `POST /api/routes/:id/append` @@ -354,7 +365,7 @@ Hängt alle Trackpunkte einer GPX-Datei an die Route an und berechnet Streckenwe ```bash 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 @@ -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 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 | |---|---|---:|---:|---| -| `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 | | `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 \ -F 'caption=Blick auf die Baumkrone' \ -F 'sequence=0' \ - -F 'picture=@eiche.jpg;type=image/jpeg' + -F 'picture=@eiche.jpg' ``` #### Variante B: JSON mit Base64 @@ -533,7 +560,6 @@ Empfohlener Content-Type: `application/json`. Der Server akzeptiert zusätzlich "sequence": 0, "picture": { "filename": "eiche.jpg", - "contentType": "image/jpeg", "base64": "/9j/4AAQSkZJRgABAQ..." } } @@ -553,7 +579,6 @@ jq -n \ sequence: $sequence, picture: { filename: "eiche.jpg", - contentType: "image/jpeg", base64: $data } }' > 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 \ -F 'caption=Neue Aufnahme der Eiche' \ -F 'sequence=2' \ - -F 'picture=@eiche-neu.webp;type=image/webp' + -F 'picture=@eiche-neu.webp' ``` Datei und Metadaten per JSON ersetzen: @@ -617,7 +642,6 @@ Datei und Metadaten per JSON ersetzen: "sequence": 2, "picture": { "filename": "eiche-neu.webp", - "contentType": "image/webp", "base64": "UklGRiQAAABXRUJQVlA4..." } } @@ -691,11 +715,11 @@ Legt die Audiodatei des im Pfad angegebenen POIs an. Zulässig sind `multipart/f | 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 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 @@ -704,7 +728,6 @@ curl -X POST http://127.0.0.1:47145/api/routes/1/pois/7/audio \ { "audio": { "filename": "ansage.mp3", - "contentType": "audio/mpeg", "base64": "SUQzBAAAAAAAI1RTU0UAAA..." } } @@ -717,7 +740,6 @@ jq -n --rawfile data ansage.mp3.b64 \ '{ audio: { filename: "ansage.mp3", - contentType: "audio/mpeg", base64: $data } }' > audio.json @@ -745,7 +767,7 @@ Multipart-Beispiel: ```bash 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: @@ -754,7 +776,6 @@ JSON-Beispiel: { "audio": { "filename": "ansage-neu.ogg", - "contentType": "audio/ogg", "base64": "T2dnUwACAAAAAAAAAAB..." } } @@ -836,3 +857,10 @@ Die Zuordnung lautet: | `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. + + +## 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). diff --git a/package-lock.json b/package-lock.json index 6f2c8b6..380042e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { "name": "wegwichtel-next", - "version": "0.11.1", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wegwichtel-next", - "version": "0.11.1", + "version": "0.12.0", "hasInstallScript": true, "dependencies": { + "@file-type/av": "0.2.0", "express": "5.2.1", "fast-xml-parser": "5.9.0", + "file-type": "22.0.1", "jquery": "4.0.0", "jquery-ui": "1.14.2", "multer": "2.2.0" @@ -19,6 +21,25 @@ "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": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", @@ -31,6 +52,29 @@ ], "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -389,6 +433,23 @@ "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": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -546,6 +607,26 @@ "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": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -716,6 +797,80 @@ "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -1060,6 +1215,22 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1069,6 +1240,24 @@ "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": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -1106,6 +1295,18 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1130,6 +1331,12 @@ "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": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 280f360..3416cd0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wegwichtel-next", - "version": "0.11.1", + "version": "0.12.0", "private": true, "description": "GPS-gestützte Lern- und Wanderwege mit GPX, POIs, Bildern und Audioansagen.", "type": "module", @@ -17,8 +17,10 @@ "test": "node --test" }, "dependencies": { + "@file-type/av": "0.2.0", "express": "5.2.1", "fast-xml-parser": "5.9.0", + "file-type": "22.0.1", "jquery": "4.0.0", "jquery-ui": "1.14.2", "multer": "2.2.0" diff --git a/src/middleware/upload.js b/src/middleware/upload.js index ec72d2a..795cbf3 100644 --- a/src/middleware/upload.js +++ b/src/middleware/upload.js @@ -1,35 +1,40 @@ import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; +import { TextDecoder } from 'node:util'; import multer from 'multer'; +import { fileTypeFromFile } from 'file-type'; +import { detectAv } from '@file-type/av'; import { config } from '../config.js'; import { HttpError } from './errors.js'; const storage = multer.diskStorage({ destination: path.join(config.storageDir, 'uploads'), 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}`); } }); -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({ storage, - limits: { fileSize: config.maxUploadBytes, files: 25 }, - fileFilter + limits: { fileSize: config.maxUploadBytes, files: 25 } }); +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) { if (typeof value !== 'string' || !value.trim()) { @@ -59,56 +64,111 @@ function jsonFileDescriptor(value, fieldName) { throw new HttpError(400, `${fieldName} muss ein JSON-Objekt sein.`); } - const originalname = String(value.filename ?? '').trim(); - if (!originalname) throw new HttpError(400, `${fieldName}.filename ist erforderlich.`); - - let mimetype = String(value.contentType ?? value.mimeType ?? '').trim().toLowerCase(); + const originalname = String(value.filename ?? `${fieldName}.upload`).trim() || `${fieldName}.upload`; let base64 = value.base64; if (value.dataUrl != null) { if (typeof value.dataUrl !== 'string') { throw new HttpError(400, `${fieldName}.dataUrl muss eine Zeichenkette sein.`); } - const match = /^data:([^;,]+);base64,(.*)$/s.exec(value.dataUrl.trim()); + const match = /^data:[^,]*;base64,(.*)$/s.exec(value.dataUrl.trim()); if (!match) { throw new HttpError(400, `${fieldName}.dataUrl muss eine Base64-Data-URL sein.`); } - if (!mimetype) mimetype = match[1].trim().toLowerCase(); - base64 = match[2]; + base64 = match[1]; } - if (!mimetype) throw new HttpError(400, `${fieldName}.contentType ist erforderlich.`); - return { originalname, mimetype, buffer: decodeBase64(base64, fieldName) }; + return { originalname, buffer: decodeBase64(base64, fieldName) }; } -export function jsonUpload(fieldName) { - return function parseJsonUpload(req, res, next) { +function materializeJsonUpload(req, fieldName) { + 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 { - if (req.file || req.body?.[fieldName] == null) return next(); - - const descriptor = jsonFileDescriptor(req.body[fieldName], fieldName); - if (!descriptor) return next(); - - const destination = path.join(config.storageDir, 'uploads'); - fs.mkdirSync(destination, { recursive: true }); - const safe = descriptor.originalname.replace(/[^a-zA-Z0-9._-]/g, '_'); - const filename = `${Date.now()}-${crypto.randomUUID()}-${safe}`; - const filePath = path.join(destination, filename); - fs.writeFileSync(filePath, descriptor.buffer, { flag: 'wx' }); - - req.file = { - fieldname: fieldName, - originalname: descriptor.originalname, - encoding: '7bit', - mimetype: descriptor.mimetype, - destination, - filename, - path: filePath, - size: descriptor.buffer.length - }; - delete req.body[fieldName]; + if (allowJson) materializeJsonUpload(req, fieldName); + if (!req.file) return next(); + await detectAndValidate(req.file, kind); next(); } catch (error) { + removeTemporaryFile(req.file); + req.file = undefined; next(error); } }; diff --git a/src/routes/api.js b/src/routes/api.js index 4818709..ee1bbcf 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -1,9 +1,9 @@ import { Router } from 'express'; -import { jsonUpload, upload } from '../middleware/upload.js'; +import { inspectUpload, upload } from '../middleware/upload.js'; import { config } from '../config.js'; import { listRoutes, getRoute, getRouteGpxFile, createRoute, updateRoute, appendRoute, - listPois, getPoi, createPoi, updatePoi, softDeleteRoute, restoreRoute + listPois, getPoi, createPoi, updatePoi, deletePoi, softDeleteRoute, restoreRoute } from '../services/routes-service.js'; import { 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))); }); - 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); 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)); }); - 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)); }); @@ -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) => { 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)); }); - 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 poiId = Number(req.params.poiId); const picture = createPicture(db, routeId, poiId, req.body, req.file); @@ -121,7 +125,7 @@ export function createApiRouter(db) { .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( db, Number(req.params.routeId), @@ -151,7 +155,7 @@ export function createApiRouter(db) { 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 poiId = Number(req.params.poiId); const audio = createAudio(db, routeId, poiId, req.file); @@ -160,7 +164,7 @@ export function createApiRouter(db) { .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)); }); diff --git a/src/services/media-service.js b/src/services/media-service.js index ab7a58f..5ac14d2 100644 --- a/src/services/media-service.js +++ b/src/services/media-service.js @@ -20,10 +20,7 @@ const AUDIO_MIME_TYPES = new Set([ 'audio/webm' ]); -const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']); -const AUDIO_EXTENSIONS = new Set(['.mp3', '.mp4', '.m4a', '.aac', '.ogg', '.wav', '.webm']); - -const uniqueFilename = original => `${crypto.randomUUID()}${path.extname(original).toLowerCase()}`; +const uniqueFilename = file => `${crypto.randomUUID()}.${file.detectedExtension}`; const pictureUrl = (routeId, poiId, pictureId) => `/api/routes/${routeId}/pois/${poiId}/pictures/${pictureId}`; const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/pois/${poiId}/audio`; @@ -50,16 +47,16 @@ function poiOnRoute(db, routeId, poiId) { function validateUpload(file, kind) { 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' - ? IMAGE_MIME_TYPES.has(file.mimetype) || IMAGE_EXTENSIONS.has(extension) - : AUDIO_MIME_TYPES.has(file.mimetype) || AUDIO_EXTENSIONS.has(extension); + ? IMAGE_MIME_TYPES.has(baseMime) + : AUDIO_MIME_TYPES.has(baseMime); - if (!valid) { + if (!valid || !file.detectedExtension) { removeUpload(file); throw new HttpError(415, kind === 'picture' - ? 'Der Upload ist keine unterstützte Bilddatei.' - : 'Der Upload ist keine unterstützte Audiodatei.'); + ? 'Der Dateiinhalt ist keine unterstützte Bilddatei.' + : '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 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 (?, ?, ?, ?)') .run(poiId, relativePath, caption, sequence); return getPicture(db, routeId, poiId, Number(result.lastInsertRowid)); @@ -150,7 +147,7 @@ export function updatePicture(db, routeId, poiId, pictureId, fields, file) { if (file) { validateUpload(file, 'picture'); 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 = ?') @@ -216,7 +213,7 @@ function storeAudio(db, routeId, poiId, file, { requireAbsent, requireExisting } } 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 = ?') .run(relativePath, poiId, routeId); diff --git a/src/services/routes-service.js b/src/services/routes-service.js index ceaf6ce..424c725 100644 --- a/src/services/routes-service.js +++ b/src/services/routes-service.js @@ -7,7 +7,7 @@ import { parseGpx } from './gpx.js'; import { distanceMeters, routeMetrics } from './geo.js'; import { ensureRouteDirectories, moveUploadedFile, routeDirectory, - softDeleteRouteDirectory, restoreRouteDirectory, resolveStoredFile, removeUpload + softDeleteRouteDirectory, restoreRouteDirectory, resolveStoredFile, removeUpload, removeStoredFile } 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()}`; @@ -188,6 +188,22 @@ export function updatePoi(db, routeId, poiId, fields) { 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) { const route = db.prepare("SELECT * FROM routes WHERE id = ? AND status = 'active'").get(id); if (!route) throw new HttpError(404, 'Aktive Strecke nicht gefunden.'); diff --git a/test/api-docs.test.js b/test/api-docs.test.js index c92a45a..86c4e14 100644 --- a/test/api-docs.test.js +++ b/test/api-docs.test.js @@ -26,6 +26,7 @@ test('complete REST API documentation is kept outside the README', async () => { 'POST /api/routes/:id/append', 'POST /api/routes/:id/pois', '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/:pictureId', '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', 'description', 'schoolName', 'title', 'triggerRadiusM', 'sequence', '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}`); } @@ -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 api = await read('docs/REST-API.md'); - assert.match(router, /post\('\/routes\/:routeId\/pois\/:poiId\/pictures', upload\.single\('picture'\), jsonUpload\('picture'\)/); - assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId', upload\.single\('picture'\), 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'\), inspectUpload\('picture', 'picture'/); assert.match(router, /delete\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId'/); 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, /put\('\/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'\), inspectUpload\('audio', 'audio'/); assert.match(router, /delete\('\/routes\/:routeId\/pois\/:poiId\/audio'/); assert.doesNotMatch(router, /\/routes\/:id\/(?:pictures|audio)/); assert.match(router, /post\('\/routes\/:id\/pois', upload\.none\(\),/); assert.match(router, /get\('\/routes\/:routeId\/pois\/:poiId'/); assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId', upload\.none\(\)/); + assert.match(router, /delete\('\/routes\/:routeId\/pois\/:poiId'/); assert.doesNotMatch(router, /['"]\/pois\/:id['"]/); assert.doesNotMatch(routesService, /files\.images|files\.audio/); 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(routesService, /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, /`text\/json`/); assert.match(api, /Base64/); diff --git a/test/media-api.test.js b/test/media-api.test.js index 39bc8bb..389c1db 100644 --- a/test/media-api.test.js +++ b/test/media-api.test.js @@ -7,6 +7,19 @@ import { spawn } from 'node:child_process'; import test from 'node:test'; 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() { 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 routeForm = new FormData(); 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`, { method: 'POST', 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('caption', 'Erste Bildbeschreibung'); 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( `${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures`, { 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}`); const { response: pictureFileResponse, body: pictureFile } = await requestBuffer(`${baseUrl}${picture.url}`); assert.match(pictureFileResponse.headers.get('content-type') || '', /image\/png/); - assert.equal(pictureFile.toString(), 'picture-one'); + assert.deepEqual(pictureFile, PNG_FILE); const { body: pictureMetadata } = await requestJson(`${baseUrl}${picture.url}?metadata=true`); assert.equal(pictureMetadata.caption, 'Erste Bildbeschreibung'); 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', sequence: 1, picture: { - filename: 'bild-json.webp', - contentType: 'image/webp', - base64: Buffer.from('picture-json').toString('base64') + filename: 'bild-json.bin', + base64: PNG_FILE.toString('base64') } }; 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.caption, 'Bild aus JSON'); const { response: jsonPictureResponse, body: jsonPictureFile } = await requestBuffer(`${baseUrl}${jsonPicture.url}`); - assert.match(jsonPictureResponse.headers.get('content-type') || '', /image\/webp/); - assert.equal(jsonPictureFile.toString(), 'picture-json'); + assert.match(jsonPictureResponse.headers.get('content-type') || '', /image\/png/); + assert.deepEqual(jsonPictureFile, PNG_FILE); await requestJson( `${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 ); + 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( `${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); 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( `${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`, { 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`); const { response: audioFileResponse, body: audioFile } = await requestBuffer(`${baseUrl}${audio.url}`); assert.match(audioFileResponse.headers.get('content-type') || '', /audio\/mpeg/); - assert.equal(audioFile.toString(), 'audio-one'); + assert.deepEqual(audioFile, MP3_FILE); const { body: audioMetadata } = await requestJson(`${baseUrl}${audio.url}?metadata=true`); assert.equal(audioMetadata.poiId, poi.id); 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`, { method: 'POST', body: duplicateAudio @@ -235,9 +263,8 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3 const replacementAudio = { audio: { - filename: 'ansage-neu.ogg', - contentType: 'audio/ogg', - base64: Buffer.from('audio-two').toString('base64') + filename: 'ansage-neu.bin', + base64: WAV_FILE.toString('base64') } }; 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`); const { response: replacedAudioResponse, body: replacedAudioFile } = await requestBuffer(`${baseUrl}${replacedAudio.url}`); - assert.match(replacedAudioResponse.headers.get('content-type') || '', /audio\/ogg/); - assert.equal(replacedAudioFile.toString(), 'audio-two'); + assert.match(replacedAudioResponse.headers.get('content-type') || '', /audio\/wav/); + assert.deepEqual(replacedAudioFile, WAV_FILE); const { body: routeWithMedia } = await requestJson(`${baseUrl}/api/routes/${route.id}`); 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); - const { body: deletedJsonPicture } = await requestJson( - `${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures/${jsonPicture.id}`, + const otherAudioForm = new FormData(); + 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' } ); - 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}/audio`, {}, 404); diff --git a/test/upload-detection-tools.test.js b/test/upload-detection-tools.test.js new file mode 100644 index 0000000..349c258 --- /dev/null +++ b/test/upload-detection-tools.test.js @@ -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); +});