diff --git a/README.md b/README.md index 9a6dc72..2507129 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Wegwichtel Next -Neuaufbau des früheren schiffsbezogenen Ansagesystems als mobile Lernweg-Anwendung für Schulen. GPX-Strecken werden serverseitig verwaltet; POIs können Bilder und eine Audioansage enthalten. Der Client schlägt anhand der aktuellen Position nahe Routen vor und aktiviert POIs während einer Wanderung über einen GPS-Watcher. Beim Erreichen eines POIs wird die Audioansage nur vorgeladen und erst nach einer bewussten Bedienung abgespielt. +Neuaufbau des früheren schiffsbezogenen Ansagesystems als mobile Lernweg-Anwendung für Schulen. GPX-Strecken werden serverseitig verwaltet; POIs können einzeln verwaltete Bilder mit Beschreibungen und eine separat verwaltete Audioansage enthalten. Der Client schlägt anhand der aktuellen Position nahe Routen vor und aktiviert POIs während einer Wanderung über einen GPS-Watcher. Beim Erreichen eines POIs wird die Audioansage nur vorgeladen und erst nach einer bewussten Bedienung abgespielt. ## Architektur @@ -66,7 +66,7 @@ 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 @@ -93,7 +93,7 @@ test/ Basistests ## Technische Hinweise -- Medienpfade werden relativ zu `storage/` gespeichert. So bleibt das Projekt verschiebbar. +- Medienpfade werden relativ zu `storage/` gespeichert. So bleibt das Projekt verschiebbar. Bild- und Audiodateien werden der Clientanwendung ausschließlich über `/api/routes/:id/pictures/:pictureId` beziehungsweise `/api/routes/:id/audio?poiId=:poiId` bereitgestellt. - 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. @@ -109,7 +109,7 @@ test/ Basistests - Benutzer-, Schul- und Projektzuordnung mit Rollenmodell - Offline-Cache/PWA für Wanderungen ohne Mobilfunkempfang - Kartenansicht, GPX-Visualisierung und Abweichungswarnung -- Bildunterschriften, Sortierung und gezieltes Entfernen einzelner Medien +- Administrationsoberfläche für die bereits vorhandenen Einzelendpunkte zur Medienverwaltung - Hintergrundbereinigung des Papierkorbs nach einer konfigurierbaren Aufbewahrungsfrist - Integritätsjournal für Dateiverschiebungen und Wiederherstellungen diff --git a/docs/REST-API.md b/docs/REST-API.md index 7e0a56f..cbc03f1 100644 --- a/docs/REST-API.md +++ b/docs/REST-API.md @@ -1,10 +1,10 @@ # Wegwichtel REST-API -Diese Datei dokumentiert die vollständige HTTP-Schnittstelle des aktuellen Wegwichtel-Servers. Die API wird unter `/api` bereitgestellt. Aktive GPX-, Bild- und Audiodateien werden zusätzlich unter `/media` ausgeliefert. +Diese Datei dokumentiert die vollständige HTTP-Schnittstelle des Wegwichtel-Servers. Die API-Basis lautet `/api`. Bild- und Audiodateien werden ebenfalls ausschließlich über API-Endpunkte ausgeliefert; die Clientanwendung verwendet dafür keine direkten Speicherpfade. ## 1. Grundlagen -### Basisadresse +### Basisadressen Lokaler Standard: @@ -18,7 +18,7 @@ API-Basis: http://127.0.0.1:47145/api ``` -Bei einer Nginx-Installation bleibt der Pfad gleich, beispielsweise: +Bei vorgeschaltetem Nginx bleibt der Pfad erhalten, zum Beispiel: ```text https://wegwichtel.example.org/api @@ -26,13 +26,25 @@ https://wegwichtel.example.org/api ### Formate -- Lesezugriffe liefern JSON. -- Routen- und POI-Schreibzugriffe mit Dateien verwenden `multipart/form-data`. -- Fehler werden als JSON ausgegeben. -- Zeitstempel stammen aus SQLite beziehungsweise JavaScript und werden als Text oder ISO-8601-Zeitstempel ausgegeben. -- Die aktuelle API besitzt noch keine Authentifizierung. Schreibzugriffe dürfen deshalb nicht ungeschützt öffentlich erreichbar sein. +- JSON-Lesezugriffe liefern `application/json`. +- Bild- und Audio-GETs liefern den jeweiligen binären Medientyp. +- Schreibzugriffe ohne Datei akzeptieren JSON, URL-encoded Formulare oder `multipart/form-data`. +- Datei-Uploads verwenden `multipart/form-data`. +- Pro Medienrequest wird genau eine Datei verarbeitet. +- Zeitstempel werden als SQLite- beziehungsweise ISO-8601-Text ausgegeben. +- Die derzeitige API besitzt noch keine Authentifizierung. Schreibzugriffe dürfen nicht ungeschützt öffentlich erreichbar sein. -### Allgemeines Fehlerformat +### Uploadgrenzen und unterstützte Dateitypen + +Das Dateilimit pro Upload wird mit `MAX_UPLOAD_MB` festgelegt und beträgt standardmäßig `50 MB`. + +| Ressource | Uploadfeld | Dateitypen | +|---|---|---| +| GPX | `gpx` | `.gpx`, `application/gpx+xml`, XML | +| Bild | `picture` | JPEG, PNG, WebP | +| Audio | `audio` | MP3, MP4/M4A, AAC, Ogg, WAV, WebM | + +### Fehlerformat ```json { @@ -41,51 +53,33 @@ https://wegwichtel.example.org/api } ``` -Bei Detailinformationen kann zusätzlich `details` enthalten sein: - -```json -{ - "error": "HttpError", - "message": "Die GPX-Datei ist kein gültiges XML.", - "details": "Parsermeldung" -} -``` - Typische Statuscodes: | Status | Bedeutung | |---:|---| | `200` | Anfrage erfolgreich | | `201` | Ressource wurde angelegt | -| `400` | Parameter oder Upload unvollständig beziehungsweise ungültig | -| `404` | Route, POI oder Datei wurde nicht gefunden | -| `409` | Dateisystemzustand verhindert Löschen oder Wiederherstellen | -| `413` | Eine hochgeladene Datei überschreitet das konfigurierte Limit | +| `400` | Parameter oder Datei fehlt beziehungsweise ist ungültig | +| `404` | Route, POI oder Medienressource wurde nicht gefunden | +| `409` | Ressource existiert bereits oder widerspricht dem aktuellen Zustand | +| `413` | Datei überschreitet `MAX_UPLOAD_MB` | | `415` | Dateityp wird nicht unterstützt | | `500` | Interner Serverfehler | -### IDs +### IDs und Medienadressierung -Die Pfadparameter `:id` sind positive, von SQLite erzeugte Ganzzahlen. Beispiele verwenden überwiegend Route `1` und POI `7`. +- `:id` bezeichnet bei `/routes/:id/...` die Route. +- `:pictureId` bezeichnet ein einzelnes Bild aus `poi_images`. +- `poiId` bezeichnet einen POI innerhalb einer Route. +- Ein POI kann mehrere Bilder, aber höchstens eine Audiodatei besitzen. +- Bilder erhalten deshalb eine eigene Pfad-ID. +- Audio verwendet keinen zusätzlichen Pfadabschnitt; der POI wird über `poiId` angegeben. -### Uploadgrenzen und Dateitypen - -Das Dateilimit pro Datei wird durch `MAX_UPLOAD_MB` festgelegt und beträgt standardmäßig `50 MB`. Multer akzeptiert höchstens 25 Dateien pro Request. - -Erlaubte Uploadtypen: - -- GPX/XML: `.gpx`, `application/gpx+xml`, `application/xml`, `text/xml` -- Bilder: JPEG, PNG, WebP -- Audio: MP3, MP4/M4A, AAC, Ogg, WAV, WebM - -Beim Anlegen oder Aktualisieren eines POIs gelten zusätzlich: - -- höchstens eine Datei im Feld `audio` -- höchstens 20 Dateien im Feld `images` +Beispiele verwenden Route `1`, POI `7` und Bild `15`. ## 2. Datenmodelle -### Kurzfassung einer Route +### Route ```json { @@ -95,13 +89,13 @@ Beim Anlegen oder Aktualisieren eines POIs gelten zusätzlich: "description": "Naturkundlicher Rundweg", "schoolName": "Beispielschule", "status": "active", - "start": { "lat": 52.5208, "lon": 13.4070 }, - "center": { "lat": 52.5210, "lon": 13.4080 }, + "start": { "lat": 52.5208, "lon": 13.407 }, + "center": { "lat": 52.521, "lon": 13.408 }, "bounds": { "minLat": 52.5208, - "minLon": 13.4070, + "minLon": 13.407, "maxLat": 52.5212, - "maxLon": 13.4090 + "maxLon": 13.409 }, "distanceM": 842.6, "elevationGainM": 14.2, @@ -109,31 +103,7 @@ Beim Anlegen oder Aktualisieren eines POIs gelten zusätzlich: "gpxUrl": "/media/routes/1/route.gpx", "createdAt": "2026-06-16 12:00:00", "updatedAt": "2026-06-16 12:00:00", - "deletedAt": null, - "proximityM": 324.8 -} -``` - -`proximityM` ist `null`, wenn beim Listenaufruf keine gültige Position übergeben wurde. Bei gelöschten Routen ist `gpxUrl` `null`, weil Dateien im Papierkorb nicht öffentlich ausgeliefert werden. - -### Vollständige Route - -`GET /api/routes/:id` ergänzt die Kurzfassung um `points` und `pois`: - -```json -{ - "id": 1, - "name": "Schulwald-Runde", - "points": [ - { - "sequence": 0, - "lat": 52.5208, - "lon": 13.4070, - "elevation": 71.4, - "recordedAt": "2026-06-16T09:00:00Z" - } - ], - "pois": [] + "deletedAt": null } ``` @@ -146,26 +116,77 @@ Beim Anlegen oder Aktualisieren eines POIs gelten zusätzlich: "title": "Die alte Eiche", "description": "Hier wird das Alter der Eiche erklärt.", "lat": 52.5208, - "lon": 13.4070, + "lon": 13.407, "triggerRadiusM": 60, "sequence": 2, - "audioUrl": "/media/routes/1/audio/uuid.mp3", + "audioUrl": "/api/routes/1/audio?poiId=7", "images": [ { "id": 15, - "caption": "", + "caption": "Blick auf die Baumkrone", "sequence": 0, - "url": "/media/routes/1/images/uuid.jpg" + "url": "/api/routes/1/pictures/15" } ] } ``` -## 3. Systemzustand +### Bildmetadaten -### `GET /api/health` +```json +{ + "id": 15, + "routeId": 1, + "poiId": 7, + "poiTitle": "Die alte Eiche", + "caption": "Blick auf die Baumkrone", + "sequence": 0, + "url": "/api/routes/1/pictures/15", + "createdAt": "2026-06-16 12:30:00" +} +``` -Prüft den Node.js-Prozess und eine SQLite-Abfrage. +### Audiometadaten + +```json +{ + "routeId": 1, + "poiId": 7, + "poiTitle": "Die alte Eiche", + "url": "/api/routes/1/audio?poiId=7", + "updatedAt": "2026-06-16 12:35:00" +} +``` + +## 3. Endpunktübersicht + +| Methode | Pfad | Zweck | +|---|---|---| +| `GET` | `/api/health` | Server- und SQLite-Zustand | +| `GET` | `/api/routes` | Routen auflisten | +| `GET` | `/api/routes/:id` | Route mit Punkten und POIs lesen | +| `POST` | `/api/routes` | Route anlegen | +| `PUT` | `/api/routes/:id` | Route aktualisieren | +| `POST` | `/api/routes/:id/append` | GPX-Punkte anhängen | +| `DELETE` | `/api/routes/:id` | Route weich löschen | +| `POST` | `/api/routes/:id/restore` | Route wiederherstellen | +| `GET` | `/api/routes/:id/pois` | POIs einer Route auflisten | +| `GET` | `/api/pois/:id` | einzelnen POI lesen | +| `POST` | `/api/routes/:id/pois` | POI anlegen | +| `PUT` | `/api/pois/:id` | POI aktualisieren | +| `GET` | `/api/routes/:id/pictures` | Bildmetadaten auflisten | +| `GET` | `/api/routes/:id/pictures/:pictureId` | Bilddatei ausliefern | +| `POST` | `/api/routes/:id/pictures` | Bild anlegen | +| `PUT` | `/api/routes/:id/pictures/:pictureId` | Bilddatei oder Metadaten aktualisieren | +| `DELETE` | `/api/routes/:id/pictures/:pictureId` | Bild löschen | +| `GET` | `/api/routes/:id/audio` | Audio auflisten oder für einen POI ausliefern | +| `POST` | `/api/routes/:id/audio` | Audio für einen POI anlegen | +| `PUT` | `/api/routes/:id/audio` | Audio eines POIs ersetzen | +| `DELETE` | `/api/routes/:id/audio` | Audio eines POIs löschen | + +## 4. Systemzustand + +### GET /api/health Parameter: keine. @@ -185,20 +206,20 @@ Beispielantwort: } ``` -## 4. Routen lesen +## 5. Routen -### `GET /api/routes` +### GET /api/routes -Liefert standardmäßig alle aktiven Routen alphabetisch. Werden `lat` und `lon` gemeinsam übergeben, berechnet der Server die Entfernung zum Startpunkt, filtert anhand `radiusKm` und sortiert nach Entfernung. +Listet aktive Routen auf. Werden `lat` und `lon` gemeinsam angegeben, berechnet der Server die Entfernung zum Routenstart, filtert mit `radiusKm` und sortiert nach Entfernung. -#### Query-Parameter +Query-Parameter: | Parameter | Typ | Pflicht | Standard | Beschreibung | -|---|---|---:|---:|---| -| `lat` | Dezimalzahl | nein | – | Breitengrad der aktuellen Position; nur zusammen mit `lon` wirksam | -| `lon` | Dezimalzahl | nein | – | Längengrad der aktuellen Position; nur zusammen mit `lat` wirksam | -| `radiusKm` | Dezimalzahl | nein | `DEFAULT_ROUTE_RADIUS_KM`, standardmäßig `25` | maximaler Abstand zum Routenstart; nur bei gültigem `lat` und `lon` wirksam | -| `includeDeleted` | Boolean-Text | nein | `false` | nur der exakte Wert `true` schließt gelöschte Routen ein | +|---|---|---:|---|---| +| `lat` | Dezimalzahl | nein | – | Breitengrad des Ausgangspunkts | +| `lon` | Dezimalzahl | nein | – | Längengrad des Ausgangspunkts | +| `radiusKm` | Dezimalzahl | nein | `DEFAULT_ROUTE_RADIUS_KM` | maximaler Abstand zum Routenstart | +| `includeDeleted` | `true`/`false` | nein | `false` | gelöschte Routen einschließen | Alle aktiven Routen: @@ -206,7 +227,7 @@ Alle aktiven Routen: curl http://127.0.0.1:47145/api/routes ``` -Alle Parameter in einem Beispiel: +Mit allen Query-Parametern: ```bash curl --get http://127.0.0.1:47145/api/routes \ @@ -216,421 +237,404 @@ curl --get http://127.0.0.1:47145/api/routes \ --data-urlencode 'includeDeleted=true' ``` -Antwort: +### GET /api/routes/:id -```json -{ - "routes": [ - { - "id": 1, - "name": "Schulwald-Runde", - "status": "active", - "proximityM": 324.8 - } - ] -} -``` - -Hinweise: - -- Eine ungültige Zahl wird intern wie ein nicht gesetzter Wert behandelt. -- Der Positionsfilter wird nur aktiv, wenn sowohl `lat` als auch `lon` gültige Zahlen sind. -- `radiusKm` wird derzeit nicht auf einen Mindest- oder Höchstwert begrenzt. - -### `GET /api/routes/:id` - -Liefert eine Route mit sämtlichen GPX-Punkten und POIs. - -#### Pfadparameter +Pfadparameter: | Parameter | Typ | Pflicht | Beschreibung | |---|---|---:|---| -| `id` | Ganzzahl | ja | ID der Route | +| `id` | positive Ganzzahl | ja | Route | -#### Query-Parameter +Query-Parameter: | Parameter | Typ | Pflicht | Standard | Beschreibung | -|---|---|---:|---:|---| -| `includeDeleted` | Boolean-Text | nein | `false` | mit `true` kann auch eine als gelöscht markierte Route gelesen werden | - -Aktive Route: +|---|---|---:|---|---| +| `includeDeleted` | `true`/`false` | nein | `false` | auch eine weich gelöschte Route lesen | ```bash curl http://127.0.0.1:47145/api/routes/1 ``` -Gelöschte Route ausdrücklich einschließen: +Die Antwort enthält `points`, `pois`, `images[].url` und `audioUrl`. Die Medien-URLs zeigen auf die API-Endpunkte. -```bash -curl 'http://127.0.0.1:47145/api/routes/1?includeDeleted=true' -``` +### POST /api/routes -Bei einer gelöschten Route sind Medien-URLs `null`, weil der Papierkorb nicht unter `/media` veröffentlicht wird. +Content-Type: `multipart/form-data`. -### `GET /api/routes/:id/pois` - -Liefert alle POIs einer aktiven Route in der Reihenfolge `sequence`, anschließend `id`. - -#### Pfadparameter - -| Parameter | Typ | Pflicht | Beschreibung | +| Feld | Typ | Pflicht | Beschreibung | |---|---|---:|---| -| `id` | Ganzzahl | ja | ID der aktiven Route | - -Weitere Parameter: keine. - -```bash -curl http://127.0.0.1:47145/api/routes/1/pois -``` - -Antwort: - -```json -{ - "pois": [ - { - "id": 7, - "routeId": 1, - "title": "Die alte Eiche", - "sequence": 2, - "audioUrl": "/media/routes/1/audio/uuid.mp3", - "images": [] - } - ] -} -``` - -## 5. POIs lesen - -### `GET /api/pois/:id` - -Liefert einen einzelnen POI einschließlich Bild- und Audio-URLs. Der POI muss zu einer aktiven Route gehören. - -#### Pfadparameter - -| Parameter | Typ | Pflicht | Beschreibung | -|---|---|---:|---| -| `id` | Ganzzahl | ja | ID des POIs | - -Weitere Parameter: keine. - -```bash -curl http://127.0.0.1:47145/api/pois/7 -``` - -## 6. Route anlegen - -### `POST /api/routes` - -Legt eine neue Route aus einer GPX-Datei an. Trackpunkte, Streckenlänge, Höhengewinn, Startpunkt, Mittelpunkt und Grenzen werden aus der Datei berechnet. - -Content-Type: `multipart/form-data` - -#### Formular- und Dateiparameter - -| Feld | Typ | Pflicht | Standard | Beschreibung | -|---|---|---:|---|---| -| `gpx` | Datei | ja | – | GPX-Datei mit mindestens einem verwertbaren `trkpt` | -| `name` | Text | bedingt | GPX-Track- oder Metadatenname | Routenname; erforderlich, falls die GPX-Datei keinen Namen enthält | -| `slug` | Text | nein | aus `name` abgeleitet | URL-freundliche interne Kennung; Kollisionen erhalten automatisch `-2`, `-3` usw. | -| `description` | Text | nein | leer | Beschreibung der Route | -| `schoolName` | Text | nein | leer | Name der Schule oder Einrichtung | - -Beispiel mit allen Parametern: +| `gpx` | Datei | ja | GPX-Datei | +| `name` | Text | nein, wenn GPX einen Namen enthält | Anzeigename | +| `slug` | Text | nein | gewünschter URL-tauglicher Bezeichner | +| `description` | Text | nein | Beschreibung | +| `schoolName` | Text | nein | Schule oder Projekt | ```bash curl -X POST http://127.0.0.1:47145/api/routes \ -F 'name=Schulwald-Runde' \ - -F 'slug=schulwald-runde-klasse-7a' \ - -F 'description=Naturkundlicher Rundweg der Klasse 7a' \ + -F 'slug=schulwald-runde' \ + -F 'description=Naturkundlicher Rundweg' \ -F 'schoolName=Beispielschule' \ - -F 'gpx=@examples/sample-route.gpx;type=application/gpx+xml' + -F 'gpx=@route.gpx;type=application/gpx+xml' ``` -Erfolg: +### PUT /api/routes/:id -- Status `201 Created` -- Header `Location: /api/routes/` -- Body: vollständige neu angelegte Route - -## 7. Route vollständig aktualisieren - -### `PUT /api/routes/:id` - -Ändert Metadaten einer aktiven Route. Eine optionale GPX-Datei ersetzt alle bisherigen Trackpunkte und die Datei `route.gpx`. - -Content-Type: `multipart/form-data` - -#### Pfadparameter - -| Parameter | Typ | Pflicht | Beschreibung | -|---|---|---:|---| -| `id` | Ganzzahl | ja | ID der aktiven Route | - -#### Formular- und Dateiparameter - -| Feld | Typ | Pflicht | Verhalten bei Auslassung | Beschreibung | -|---|---|---:|---|---| -| `name` | Text | nein | alter Wert bleibt | neuer Routenname | -| `description` | Text | nein | alter Wert bleibt | neue Beschreibung; leere Zeichenfolge löscht den Inhalt | -| `schoolName` | Text | nein | alter Wert bleibt | neuer Schulname; leere Zeichenfolge löscht den Inhalt | -| `gpx` | Datei | nein | Track bleibt unverändert | ersetzt GPX-Datei, Trackpunkte und berechnete Kennzahlen vollständig | - -`slug` kann über diesen Endpunkt derzeit nicht geändert werden. - -Beispiel mit allen Parametern: - -```bash -curl -X PUT http://127.0.0.1:47145/api/routes/1 \ - -F 'name=Schulwald-Runde – überarbeitet' \ - -F 'description=Neue Wegführung ab dem Schulhof' \ - -F 'schoolName=Beispielschule Nord' \ - -F 'gpx=@examples/sample-route.gpx;type=application/gpx+xml' -``` - -Nur die Beschreibung ändern: - -```bash -curl -X PUT http://127.0.0.1:47145/api/routes/1 \ - -F 'description=Nur dieser Wert wird geändert.' -``` - -Antwort: vollständige aktualisierte Route. - -## 8. GPX-Punkte an eine Route anhängen - -### `POST /api/routes/:id/append` - -Hängt sämtliche verwertbaren Trackpunkte einer GPX-Datei an eine aktive Route an und berechnet die Streckenkennzahlen neu. - -Content-Type: `multipart/form-data` - -#### Pfadparameter - -| Parameter | Typ | Pflicht | Beschreibung | -|---|---|---:|---| -| `id` | Ganzzahl | ja | ID der aktiven Route | - -#### Dateiparameter +Aktualisiert Routendaten. Eine neue GPX-Datei ersetzt die gespeicherten Trackpunkte. | Feld | Typ | Pflicht | Beschreibung | |---|---|---:|---| -| `gpx` | Datei | ja | GPX-Datei mit den anzuhängenden Trackpunkten | +| `name` | Text | nein | neuer Name | +| `description` | Text | nein | neue Beschreibung | +| `schoolName` | Text | nein | neue Schule | +| `gpx` | Datei | nein | vollständige Ersatzstrecke | + +```bash +curl -X PUT http://127.0.0.1:47145/api/routes/1 \ + -F 'name=Schulwald-Runde 2026' \ + -F 'description=Überarbeitete Route' \ + -F 'schoolName=Beispielschule' \ + -F 'gpx=@route-neu.gpx;type=application/gpx+xml' +``` + +### POST /api/routes/:id/append + +Hängt GPX-Punkte an eine Route an. + +| Feld | Typ | Pflicht | Beschreibung | +|---|---|---:|---| +| `gpx` | Datei | ja | GPX-Datei mit zusätzlichen Punkten | ```bash curl -X POST http://127.0.0.1:47145/api/routes/1/append \ - -F 'gpx=@weiterer-abschnitt.gpx;type=application/gpx+xml' + -F 'gpx=@erweiterung.gpx;type=application/gpx+xml' ``` -Hinweis: Die Punkte werden in SQLite ergänzt. Die gespeicherte Originaldatei `route.gpx` wird durch diesen Endpunkt derzeit nicht zu einer konsolidierten GPX-Datei erweitert. +### DELETE /api/routes/:id -## 9. POI anlegen - -### `POST /api/routes/:id/pois` - -Legt einen POI für eine aktive Route an und speichert optional eine Audioansage und mehrere Bilder. - -Content-Type: `multipart/form-data` - -#### Pfadparameter - -| Parameter | Typ | Pflicht | Beschreibung | -|---|---|---:|---| -| `id` | Ganzzahl | ja | ID der aktiven Route | - -#### Formular- und Dateiparameter - -| Feld | Typ | Pflicht | Standard | Beschreibung | -|---|---|---:|---|---| -| `title` | Text | nein | `Unbenannter POI` | Titel der Station | -| `description` | Text | nein | leer | Beschreibung der Station | -| `lat` | Dezimalzahl | ja | – | Breitengrad des POIs | -| `lon` | Dezimalzahl | ja | – | Längengrad des POIs | -| `triggerRadiusM` | Dezimalzahl | nein | `DEFAULT_POI_TRIGGER_METERS`, standardmäßig `80` | Entfernung in Metern, ab der die Station automatisch aktiviert und ihre Audiodatei vorgeladen wird; die Wiedergabe startet nicht automatisch | -| `sequence` | Ganzzahl | nein | `0` | Sortierreihenfolge innerhalb der Route | -| `audio` | Datei | nein | keine | eine Audioansage | -| `images` | Datei, wiederholbar | nein | keine | bis zu 20 Bilder; jedes Bild wird als eigenes Feld `images` gesendet | - -Beispiel mit allen Parametern: - -```bash -curl -X POST http://127.0.0.1:47145/api/routes/1/pois \ - -F 'title=Die alte Eiche' \ - -F 'description=Hier wird das Alter der Eiche erklärt.' \ - -F 'lat=52.5208' \ - -F 'lon=13.4070' \ - -F 'triggerRadiusM=60' \ - -F 'sequence=2' \ - -F 'audio=@ansage.mp3;type=audio/mpeg' \ - -F 'images=@eiche-1.jpg;type=image/jpeg' \ - -F 'images=@eiche-2.webp;type=image/webp' -``` - -Erfolg: - -- Status `201 Created` -- Header `Location: /api/pois/` -- Body: neu angelegter POI - -Bildunterschriften können in der aktuellen API noch nicht per Parameter gesetzt werden und bleiben leer. - -## 10. POI aktualisieren und Medien ergänzen - -### `PUT /api/pois/:id` - -Ändert einen POI einer aktiven Route. Eine neue Audiodatei ersetzt den in der Datenbank referenzierten Audiopfad. Neue Bilder werden an die vorhandene Bilderliste angehängt. - -Content-Type: `multipart/form-data` - -#### Pfadparameter - -| Parameter | Typ | Pflicht | Beschreibung | -|---|---|---:|---| -| `id` | Ganzzahl | ja | ID des POIs | - -#### Formular- und Dateiparameter - -| Feld | Typ | Pflicht | Verhalten bei Auslassung | Beschreibung | -|---|---|---:|---|---| -| `title` | Text | nein | alter Wert bleibt | neuer Titel | -| `description` | Text | nein | alter Wert bleibt | neue Beschreibung | -| `lat` | Dezimalzahl | nein | alter Wert bleibt | neuer Breitengrad | -| `lon` | Dezimalzahl | nein | alter Wert bleibt | neuer Längengrad | -| `triggerRadiusM` | Dezimalzahl | nein | alter Wert bleibt | neuer automatischer Auslöseradius in Metern | -| `sequence` | Ganzzahl | nein | alter Wert bleibt | neue Sortierreihenfolge | -| `audio` | Datei | nein | alte Referenz bleibt | neue Audioansage; maximal eine Datei | -| `images` | Datei, wiederholbar | nein | Bilder bleiben unverändert | bis zu 20 zusätzliche Bilder | - -Beispiel mit allen Parametern: - -```bash -curl -X PUT http://127.0.0.1:47145/api/pois/7 \ - -F 'title=Die sehr alte Eiche' \ - -F 'description=Überarbeitete Ansage und neue Fotos.' \ - -F 'lat=52.5209' \ - -F 'lon=13.4072' \ - -F 'triggerRadiusM=45' \ - -F 'sequence=3' \ - -F 'audio=@ansage-neu.ogg;type=audio/ogg' \ - -F 'images=@eiche-3.png;type=image/png' \ - -F 'images=@eiche-4.jpg;type=image/jpeg' -``` - -Wichtige aktuelle Einschränkungen: - -- Einzelne Bilder können noch nicht per API gelöscht, umsortiert oder beschriftet werden. -- Neu hochgeladene Bilder werden hinter vorhandenen Bildern einsortiert. -- Beim Ersetzen der Audio-Referenz wird die vorherige Audiodatei derzeit nicht automatisch aus dem aktiven Verzeichnis entfernt. - -## 11. Route zum Löschen markieren - -### `DELETE /api/routes/:id` - -Markiert eine aktive Route als gelöscht und verschiebt ihr gesamtes Verzeichnis einschließlich GPX, Bildern und Audio nach `storage/trash/routes`. Die Datenbankzeilen bleiben erhalten; alle gespeicherten Pfade werden auf den Papierkorb umgeschrieben. - -#### Pfadparameter - -| Parameter | Typ | Pflicht | Beschreibung | -|---|---|---:|---| -| `id` | Ganzzahl | ja | ID der aktiven Route | - -Weitere Parameter: keine. +Markiert die Route als gelöscht und verschiebt GPX, Bilder und Audio in den Papierkorb. ```bash curl -X DELETE http://127.0.0.1:47145/api/routes/1 ``` -Antwort: +### POST /api/routes/:id/restore -```json -{ - "route": { - "id": 1, - "status": "deleted", - "gpxUrl": null, - "deletedAt": "2026-06-16 12:30:00" - }, - "softDeleted": true -} -``` - -Es werden keine Dateien endgültig entfernt. - -## 12. Route wiederherstellen - -### `POST /api/routes/:id/restore` - -Stellt eine als gelöscht markierte Route wieder her, verschiebt ihr Verzeichnis nach `storage/active/routes/` zurück und korrigiert alle GPX-, Bild- und Audiopfade. - -#### Pfadparameter - -| Parameter | Typ | Pflicht | Beschreibung | -|---|---|---:|---| -| `id` | Ganzzahl | ja | ID der gelöschten Route | - -Weitere Parameter: keine. Der Request besitzt keinen Body. +Stellt eine weich gelöschte Route einschließlich ihrer Dateien wieder her. ```bash curl -X POST http://127.0.0.1:47145/api/routes/1/restore ``` -Antwort: +## 6. POIs + +### GET /api/routes/:id/pois + +```bash +curl http://127.0.0.1:47145/api/routes/1/pois +``` + +### GET /api/pois/:id + +```bash +curl http://127.0.0.1:47145/api/pois/7 +``` + +### POST /api/routes/:id/pois + +Akzeptiert JSON, URL-encoded Formulare oder Multipart ohne Mediendateien. + +| Feld | Typ | Pflicht | Standard | Beschreibung | +|---|---|---:|---|---| +| `title` | Text | nein | `Unbenannter POI` | Titel | +| `description` | Text | nein | leer | Beschreibung | +| `lat` | Dezimalzahl | ja | – | Breitengrad | +| `lon` | Dezimalzahl | ja | – | Längengrad | +| `triggerRadiusM` | Dezimalzahl | nein | `DEFAULT_POI_TRIGGER_METERS` | Aktivierungsradius in Metern | +| `sequence` | Ganzzahl ≥ 0 | nein | `0` | Reihenfolge | + +```bash +curl -X POST http://127.0.0.1:47145/api/routes/1/pois \ + -H 'Content-Type: application/json' \ + -d '{ + "title": "Die alte Eiche", + "description": "Informationen zur Baumart", + "lat": 52.5208, + "lon": 13.4070, + "triggerRadiusM": 60, + "sequence": 2 + }' +``` + +### PUT /api/pois/:id + +Alle Felder sind optional; nicht angegebene Werte bleiben erhalten. + +```bash +curl -X PUT http://127.0.0.1:47145/api/pois/7 \ + -H 'Content-Type: application/json' \ + -d '{ + "title": "Die sehr alte Eiche", + "description": "Aktualisierte Beschreibung", + "lat": 52.5209, + "lon": 13.4071, + "triggerRadiusM": 45, + "sequence": 1 + }' +``` + +## 7. Bilder + +Ein POI kann mehrere Bilder besitzen. Die Collection wird über die Route adressiert; jedes Bild hat zusätzlich eine `pictureId`. + +### GET /api/routes/:id/pictures + +Liefert Bildmetadaten als JSON. + +Query-Parameter: + +| Parameter | Typ | Pflicht | Beschreibung | +|---|---|---:|---| +| `poiId` | positive Ganzzahl | nein | auf Bilder eines POIs einschränken | + +Alle Bilder der Route: + +```bash +curl http://127.0.0.1:47145/api/routes/1/pictures +``` + +Nur Bilder von POI 7: + +```bash +curl --get http://127.0.0.1:47145/api/routes/1/pictures \ + --data-urlencode 'poiId=7' +``` + +Beispielantwort: ```json { - "route": { - "id": 1, - "status": "active", - "gpxUrl": "/media/routes/1/route.gpx", - "deletedAt": null - }, - "restored": true + "pictures": [ + { + "id": 15, + "routeId": 1, + "poiId": 7, + "poiTitle": "Die alte Eiche", + "caption": "Blick auf die Baumkrone", + "sequence": 0, + "url": "/api/routes/1/pictures/15", + "createdAt": "2026-06-16 12:30:00" + } + ] } ``` -## 13. Medien abrufen +### GET /api/routes/:id/pictures/:pictureId -Aktive Dateien werden nicht unter `/api`, sondern statisch unter `/media` ausgeliefert. Die benötigten URLs stehen in `gpxUrl`, `audioUrl` und `images[].url`. - -Parameter: keine zusätzlichen Query- oder Formularparameter. Der komplette Pfad stammt aus der jeweiligen API-Antwort. - -GPX-Datei: +Liefert die Bilddatei binär mit dem passenden `Content-Type`, beispielsweise `image/jpeg`, `image/png` oder `image/webp`. ```bash -curl --output route.gpx \ - http://127.0.0.1:47145/media/routes/1/route.gpx +curl http://127.0.0.1:47145/api/routes/1/pictures/15 \ + --output bild-15.jpg ``` -Audio: +Bildmetadaten werden über `GET /api/routes/:id/pictures` gelesen. + +### POST /api/routes/:id/pictures + +Content-Type: `multipart/form-data`. + +| Feld | Typ | Pflicht | Standard | Beschreibung | +|---|---|---:|---|---| +| `picture` | Datei | ja | – | Bilddatei | +| `poiId` | positive Ganzzahl | ja | – | zugehöriger POI | +| `caption` | Text | nein | leer | sichtbare Bildbeschreibung und Alternativtext | +| `sequence` | Ganzzahl ≥ 0 | nein | nächste freie Position | Reihenfolge in der Diashow | ```bash -curl --output ansage.mp3 \ - http://127.0.0.1:47145/media/routes/1/audio/DATEINAME.mp3 +curl -X POST http://127.0.0.1:47145/api/routes/1/pictures \ + -F 'poiId=7' \ + -F 'caption=Blick auf die Baumkrone' \ + -F 'sequence=0' \ + -F 'picture=@eiche.jpg;type=image/jpeg' ``` -Bild: +Die Antwort enthält Metadaten. Der `Location`-Header zeigt auf `/api/routes/1/pictures/:pictureId`. + +### PUT /api/routes/:id/pictures/:pictureId + +Aktualisiert Metadaten und optional die Datei. Der Request kann JSON ohne Datei oder Multipart mit Datei verwenden. + +| Feld | Typ | Pflicht | Beschreibung | +|---|---|---:|---| +| `picture` | Datei | nein | Ersatzdatei | +| `poiId` | positive Ganzzahl | nein | Bild einem anderen POI derselben Route zuordnen | +| `caption` | Text | nein | Bildbeschreibung | +| `sequence` | Ganzzahl ≥ 0 | nein | Reihenfolge | + +Nur Metadaten ändern: ```bash -curl --output station.jpg \ - http://127.0.0.1:47145/media/routes/1/images/DATEINAME.jpg +curl -X PUT http://127.0.0.1:47145/api/routes/1/pictures/15 \ + -H 'Content-Type: application/json' \ + -d '{ + "poiId": 7, + "caption": "Nahaufnahme der Eichenblätter", + "sequence": 1 + }' ``` -Nur `storage/active` wird veröffentlicht. Dateien gelöschter Routen unter `storage/trash` sind nicht über HTTP erreichbar. +Datei und Metadaten ersetzen: -## 14. Endpunktübersicht +```bash +curl -X PUT http://127.0.0.1:47145/api/routes/1/pictures/15 \ + -F 'poiId=7' \ + -F 'caption=Neue Bildbeschreibung' \ + -F 'sequence=1' \ + -F 'picture=@eiche-neu.webp;type=image/webp' +``` -| Methode | Pfad | Zweck | -|---|---|---| -| `GET` | `/api/health` | Server und SQLite prüfen | -| `GET` | `/api/routes` | Routen auflisten und optional räumlich filtern | -| `GET` | `/api/routes/:id` | vollständige Route lesen | -| `GET` | `/api/routes/:id/pois` | POIs einer Route lesen | -| `GET` | `/api/pois/:id` | einzelnen POI lesen | -| `POST` | `/api/routes` | Route aus GPX anlegen | -| `PUT` | `/api/routes/:id` | Route aktualisieren oder GPX ersetzen | -| `POST` | `/api/routes/:id/append` | GPX-Punkte anhängen | -| `POST` | `/api/routes/:id/pois` | POI und Medien anlegen | -| `PUT` | `/api/pois/:id` | POI aktualisieren und Medien ergänzen | -| `DELETE` | `/api/routes/:id` | Route in den Papierkorb verschieben | -| `POST` | `/api/routes/:id/restore` | Route wiederherstellen | -| `GET` | `/media/routes/...` | aktive GPX-, Bild- und Audiodateien abrufen | +### DELETE /api/routes/:id/pictures/:pictureId + +Löscht den Datenbankeintrag und die einzelne Bilddatei. + +```bash +curl -X DELETE http://127.0.0.1:47145/api/routes/1/pictures/15 +``` + +## 8. Audio + +Pro POI existiert höchstens eine Audiodatei. Deshalb gibt es keine zusätzliche Audio-ID und keinen Pfad wie `/audio/:poiId`. Alle Operationen verwenden `/api/routes/:id/audio`; der konkrete POI wird mit `poiId` angegeben. + +### GET /api/routes/:id/audio + +Ohne `poiId` liefert der Endpunkt alle Audiometadaten der Route als JSON: + +```bash +curl http://127.0.0.1:47145/api/routes/1/audio +``` + +Beispielantwort: + +```json +{ + "audio": [ + { + "routeId": 1, + "poiId": 7, + "poiTitle": "Die alte Eiche", + "url": "/api/routes/1/audio?poiId=7", + "updatedAt": "2026-06-16 12:35:00" + } + ] +} +``` + +Mit `poiId` liefert derselbe Endpunkt die Audiodatei binär: + +| Query-Parameter | Typ | Pflicht für Dateiausgabe | Beschreibung | +|---|---|---:|---| +| `poiId` | positive Ganzzahl | ja | POI, dessen Audio geliefert wird | + +```bash +curl --get http://127.0.0.1:47145/api/routes/1/audio \ + --data-urlencode 'poiId=7' \ + --output ansage.mp3 +``` + +Der Response-`Content-Type` entspricht dem gespeicherten Format, beispielsweise `audio/mpeg` oder `audio/ogg`. + +### POST /api/routes/:id/audio + +Legt die Audiodatei für einen POI an. Besteht bereits eine Datei, antwortet der Server mit `409`; zum Ersetzen wird `PUT` verwendet. + +| Feld | Typ | Pflicht | Beschreibung | +|---|---|---:|---| +| `audio` | Datei | ja | Audiodatei | +| `poiId` | positive Ganzzahl | ja | zugehöriger POI | + +```bash +curl -X POST http://127.0.0.1:47145/api/routes/1/audio \ + -F 'poiId=7' \ + -F 'audio=@ansage.mp3;type=audio/mpeg' +``` + +Der `Location`-Header lautet beispielsweise: + +```text +/api/routes/1/audio?poiId=7 +``` + +### PUT /api/routes/:id/audio + +Ersetzt eine vorhandene Audiodatei. `poiId` darf als Query-Parameter oder als Multipart-Feld angegeben werden. + +Query-Variante: + +```bash +curl -X PUT 'http://127.0.0.1:47145/api/routes/1/audio?poiId=7' \ + -F 'audio=@ansage-neu.ogg;type=audio/ogg' +``` + +Multipart-Variante: + +```bash +curl -X PUT http://127.0.0.1:47145/api/routes/1/audio \ + -F 'poiId=7' \ + -F 'audio=@ansage-neu.ogg;type=audio/ogg' +``` + +| Parameter | Ort | Typ | Pflicht | Beschreibung | +|---|---|---|---:|---| +| `poiId` | Query oder Formular | positive Ganzzahl | ja | POI | +| `audio` | Formular | Datei | ja | Ersatzdatei | + +### DELETE /api/routes/:id/audio + +Löscht die Audiodatei des angegebenen POIs und setzt `audioUrl` anschließend auf `null`. + +Empfohlen als Query-Parameter: + +```bash +curl -X DELETE 'http://127.0.0.1:47145/api/routes/1/audio?poiId=7' +``` + +Alternativ als JSON-Body: + +```bash +curl -X DELETE http://127.0.0.1:47145/api/routes/1/audio \ + -H 'Content-Type: application/json' \ + -d '{ "poiId": 7 }' +``` + +## 9. Verwendung durch die Clientanwendung + +Die Clientanwendung konstruiert Medienadressen ausschließlich über das API-Modul: + +```javascript +Wegwichtel.Api.pictureUrl(routeId, pictureId); +// /api/routes/1/pictures/15 + +Wegwichtel.Api.audioUrl(routeId, poiId); +// /api/routes/1/audio?poiId=7 +``` + +Die Diashow setzt die API-Bildadresse als `src` des Bildes. Der HTML5-Audioplayer setzt die API-Audioadresse als `src` und lädt sie mit `preload="auto"`. Das Erreichen eines POIs startet keine automatische Wiedergabe. + +## 10. Nginx-Hinweis + +Nginx sollte `/api/` vollständig an den Node.js-Prozess weiterleiten. Da Bilder und Audio über `/api` ausgeliefert werden, muss der Proxy binäre Responses unverändert durchreichen: + +```nginx +location ^~ /api/ { + proxy_pass http://127.0.0.1:47145; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; +} +``` + +Für Uploads muss außerdem `client_max_body_size` mindestens so groß wie `MAX_UPLOAD_MB` gewählt werden. diff --git a/package-lock.json b/package-lock.json index 9bc8487..a253ca9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wegwichtel-next", - "version": "0.7.1", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wegwichtel-next", - "version": "0.7.1", + "version": "0.9.0", "hasInstallScript": true, "dependencies": { "express": "5.2.1", diff --git a/package.json b/package.json index 0efd379..3930f4c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wegwichtel-next", - "version": "0.7.1", + "version": "0.9.0", "private": true, "description": "GPS-gestützte Lern- und Wanderwege mit GPX, POIs, Bildern und Audioansagen.", "type": "module", diff --git a/public/js/api-client.js b/public/js/api-client.js index c9b9203..1cf0282 100644 --- a/public/js/api-client.js +++ b/public/js/api-client.js @@ -6,6 +6,8 @@ ns.Api = { health: () => request('/health'), routes: position => request('/routes' + (position ? `?lat=${encodeURIComponent(position.lat)}&lon=${encodeURIComponent(position.lon)}&radiusKm=${ns.Config.routeRadiusKm}` : '')), - route: id => request(`/routes/${encodeURIComponent(id)}`) + route: id => request(`/routes/${encodeURIComponent(id)}`), + pictureUrl: (routeId, pictureId) => `${ns.Config.apiBase}/routes/${encodeURIComponent(routeId)}/pictures/${encodeURIComponent(pictureId)}`, + audioUrl: (routeId, poiId) => `${ns.Config.apiBase}/routes/${encodeURIComponent(routeId)}/audio?poiId=${encodeURIComponent(poiId)}` }; }(window.Wegwichtel, window.jQuery)); diff --git a/public/js/app.js b/public/js/app.js index e4df5bc..95667a6 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -261,8 +261,11 @@ setActivePoi(poi.id); $('#poi-title').text(poi.title); $('#poi-description').text(poi.description || ''); - ns.Slideshow.show(poi.images); - ns.AudioPlayer.load(poi.audioUrl); + ns.Slideshow.show((poi.images || []).map(image => ({ + ...image, + url: ns.Api.pictureUrl(state.route.id, image.id) + }))); + ns.AudioPlayer.load(poi.audioUrl ? ns.Api.audioUrl(state.route.id, poi.id) : null); navigate('#poi-page'); } diff --git a/src/routes/api.js b/src/routes/api.js index f5af0c3..1c529d4 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -5,6 +5,10 @@ import { listRoutes, getRoute, createRoute, updateRoute, appendRoute, listPois, getPoi, createPoi, updatePoi, softDeleteRoute, restoreRoute } from '../services/routes-service.js'; +import { + listPictures, getPictureContent, createPicture, updatePicture, deletePicture, + listAudio, getAudioContent, createAudio, updateAudio, deleteAudio +} from '../services/media-service.js'; const numberOrUndefined = value => { if (value == null || value === '') return undefined; @@ -12,6 +16,26 @@ const numberOrUndefined = value => { return Number.isFinite(parsed) ? parsed : undefined; }; +const positiveInteger = (value, name) => { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + const error = new Error(`${name} muss eine positive Ganzzahl sein.`); + error.status = 400; + throw error; + } + return parsed; +}; + +function sendMedia(res, resource) { + res.set({ + 'Cache-Control': 'private, max-age=60', + 'Content-Disposition': `inline; filename="${resource.filename.replace(/["\\]/g, '_')}"`, + 'X-Content-Type-Options': 'nosniff' + }); + res.type(resource.contentType); + res.sendFile(resource.absolutePath); +} + export function createApiRouter(db) { const api = Router(); @@ -61,13 +85,63 @@ export function createApiRouter(db) { res.json(appendRoute(db, Number(req.params.id), req.file)); }); - api.post('/routes/:id/pois', upload.fields([{ name: 'audio', maxCount: 1 }, { name: 'images', maxCount: 20 }]), (req, res) => { - const poi = createPoi(db, Number(req.params.id), req.body, req.files); + api.post('/routes/:id/pois', upload.none(), (req, res) => { + const poi = createPoi(db, Number(req.params.id), req.body); res.status(201).location(`/api/pois/${poi.id}`).json(poi); }); - api.put('/pois/:id', upload.fields([{ name: 'audio', maxCount: 1 }, { name: 'images', maxCount: 20 }]), (req, res) => { - res.json(updatePoi(db, Number(req.params.id), req.body, req.files)); + api.put('/pois/:id', upload.none(), (req, res) => { + res.json(updatePoi(db, Number(req.params.id), req.body)); + }); + + api.get('/routes/:id/pictures', (req, res) => { + res.json({ pictures: listPictures(db, Number(req.params.id), { poiId: req.query.poiId }) }); + }); + + api.get('/routes/:id/pictures/:pictureId', (req, res) => { + sendMedia(res, getPictureContent(db, Number(req.params.id), Number(req.params.pictureId))); + }); + + api.post('/routes/:id/pictures', upload.single('picture'), (req, res) => { + const picture = createPicture(db, Number(req.params.id), req.body, req.file); + res.status(201) + .location(`/api/routes/${req.params.id}/pictures/${picture.id}`) + .json(picture); + }); + + api.put('/routes/:id/pictures/:pictureId', upload.single('picture'), (req, res) => { + res.json(updatePicture(db, Number(req.params.id), Number(req.params.pictureId), req.body, req.file)); + }); + + api.delete('/routes/:id/pictures/:pictureId', (req, res) => { + res.json(deletePicture(db, Number(req.params.id), Number(req.params.pictureId))); + }); + + api.get('/routes/:id/audio', (req, res) => { + if (req.query.poiId == null || req.query.poiId === '') { + res.json({ audio: listAudio(db, Number(req.params.id)) }); + return; + } + + const poiId = positiveInteger(req.query.poiId, 'poiId'); + sendMedia(res, getAudioContent(db, Number(req.params.id), poiId)); + }); + + api.post('/routes/:id/audio', upload.single('audio'), (req, res) => { + const audio = createAudio(db, Number(req.params.id), req.body, req.file); + res.status(201) + .location(`/api/routes/${req.params.id}/audio?poiId=${encodeURIComponent(audio.poiId)}`) + .json(audio); + }); + + api.put('/routes/:id/audio', upload.single('audio'), (req, res) => { + const poiId = positiveInteger(req.query.poiId ?? req.body.poiId, 'poiId'); + res.json(updateAudio(db, Number(req.params.id), poiId, req.file)); + }); + + api.delete('/routes/:id/audio', (req, res) => { + const poiId = positiveInteger(req.query.poiId ?? req.body?.poiId, 'poiId'); + res.json(deleteAudio(db, Number(req.params.id), poiId)); }); api.delete('/routes/:id', (req, res) => { diff --git a/src/services/media-service.js b/src/services/media-service.js new file mode 100644 index 0000000..700f331 --- /dev/null +++ b/src/services/media-service.js @@ -0,0 +1,301 @@ +import path from 'node:path'; +import crypto from 'node:crypto'; +import { HttpError } from '../middleware/errors.js'; +import { + ensureRouteDirectories, + moveUploadedFile, + removeStoredFile, + removeUpload, + resolveStoredFile +} from './storage.js'; + +const IMAGE_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); +const AUDIO_MIME_TYPES = new Set([ + 'audio/mpeg', + 'audio/mp4', + 'audio/x-m4a', + 'audio/aac', + 'audio/ogg', + 'audio/wav', + '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 pictureApiUrl = (routeId, pictureId) => `/api/routes/${routeId}/pictures/${pictureId}`; +const audioApiUrl = (routeId, poiId) => `/api/routes/${routeId}/audio?poiId=${encodeURIComponent(poiId)}`; + +const CONTENT_TYPES = Object.freeze({ + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.webp': 'image/webp', + '.mp3': 'audio/mpeg', + '.mp4': 'audio/mp4', + '.m4a': 'audio/mp4', + '.aac': 'audio/aac', + '.ogg': 'audio/ogg', + '.wav': 'audio/wav', + '.webm': 'audio/webm' +}); + +function contentResource(relativePath) { + const absolutePath = resolveStoredFile(relativePath); + return { + absolutePath, + contentType: CONTENT_TYPES[path.extname(absolutePath).toLowerCase()] || 'application/octet-stream', + filename: path.basename(absolutePath) + }; +} + +function requirePositiveInteger(value, name) { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new HttpError(400, `${name} muss eine positive Ganzzahl sein.`); + } + return parsed; +} + +function optionalSequence(value, fallback) { + if (value == null || value === '') return fallback; + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new HttpError(400, 'sequence muss eine nichtnegative Ganzzahl sein.'); + } + return parsed; +} + +function activeRoute(db, routeId) { + const route = db.prepare("SELECT id FROM routes WHERE id = ? AND status = 'active'").get(routeId); + if (!route) throw new HttpError(404, 'Strecke nicht gefunden.'); + return route; +} + +function poiOnRoute(db, routeId, poiId) { + const poi = db.prepare(` + SELECT p.id, p.route_id, p.title, 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 (!poi) throw new HttpError(404, 'POI auf dieser Strecke nicht gefunden.'); + return poi; +} + +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 valid = kind === 'picture' + ? IMAGE_MIME_TYPES.has(file.mimetype) || IMAGE_EXTENSIONS.has(extension) + : AUDIO_MIME_TYPES.has(file.mimetype) || AUDIO_EXTENSIONS.has(extension); + + if (!valid) { + removeUpload(file); + throw new HttpError(415, kind === 'picture' + ? 'Der Upload ist keine unterstützte Bilddatei.' + : 'Der Upload ist keine unterstützte Audiodatei.'); + } +} + +function pictureFromRow(row) { + return { + id: row.id, + routeId: row.route_id, + poiId: row.poi_id, + poiTitle: row.poi_title, + caption: row.caption, + sequence: row.sequence, + url: pictureApiUrl(row.route_id, row.id), + createdAt: row.created_at + }; +} + +const pictureSelect = ` + SELECT i.id, i.poi_id, p.route_id, p.title AS poi_title, + i.path, i.caption, i.sequence, i.created_at + FROM poi_images i + JOIN pois p ON p.id = i.poi_id + JOIN routes r ON r.id = p.route_id +`; + +export function listPictures(db, routeId, { poiId } = {}) { + activeRoute(db, routeId); + const filterPoiId = poiId == null || poiId === '' ? null : requirePositiveInteger(poiId, 'poiId'); + const rows = filterPoiId == null + ? db.prepare(`${pictureSelect} WHERE p.route_id = ? AND r.status = 'active' ORDER BY p.sequence, p.id, i.sequence, i.id`).all(routeId) + : db.prepare(`${pictureSelect} WHERE p.route_id = ? AND p.id = ? AND r.status = 'active' ORDER BY i.sequence, i.id`).all(routeId, filterPoiId); + return rows.map(pictureFromRow); +} + +export function getPicture(db, routeId, pictureId) { + activeRoute(db, routeId); + const row = db.prepare(`${pictureSelect} WHERE p.route_id = ? AND i.id = ? AND r.status = 'active'`).get(routeId, pictureId); + if (!row) throw new HttpError(404, 'Bild auf dieser Strecke nicht gefunden.'); + return pictureFromRow(row); +} + +export function getPictureContent(db, routeId, pictureId) { + activeRoute(db, routeId); + const row = db.prepare(`${pictureSelect} WHERE p.route_id = ? AND i.id = ? AND r.status = 'active'`).get(routeId, pictureId); + if (!row) throw new HttpError(404, 'Bild auf dieser Strecke nicht gefunden.'); + return contentResource(row.path); +} + +export function createPicture(db, routeId, fields, file) { + let relativePath; + try { + activeRoute(db, routeId); + validateUpload(file, 'picture'); + + const poiId = requirePositiveInteger(fields.poiId, 'poiId'); + poiOnRoute(db, routeId, poiId); + const sequence = optionalSequence(fields.sequence, + db.prepare('SELECT COALESCE(MAX(sequence), -1) + 1 AS value FROM poi_images WHERE poi_id = ?').get(poiId).value); + const caption = String(fields.caption ?? ''); + const root = ensureRouteDirectories(routeId); + + relativePath = moveUploadedFile(file, path.join(root, 'images', uniqueFilename(file.originalname))); + const result = db.prepare('INSERT INTO poi_images (poi_id, path, caption, sequence) VALUES (?, ?, ?, ?)') + .run(poiId, relativePath, caption, sequence); + return getPicture(db, routeId, Number(result.lastInsertRowid)); + } catch (error) { + if (relativePath) removeStoredFile(relativePath); + else removeUpload(file); + throw error; + } +} + +export function updatePicture(db, routeId, pictureId, fields, file) { + let newPath; + let oldPath; + try { + const existing = getPicture(db, routeId, pictureId); + const row = db.prepare(`${pictureSelect} WHERE p.route_id = ? AND i.id = ? AND r.status = 'active'`).get(routeId, pictureId); + oldPath = row.path; + const poiId = fields.poiId == null || fields.poiId === '' + ? existing.poiId + : requirePositiveInteger(fields.poiId, 'poiId'); + poiOnRoute(db, routeId, poiId); + const caption = fields.caption == null ? existing.caption : String(fields.caption); + const sequence = optionalSequence(fields.sequence, existing.sequence); + + newPath = row.path; + if (file) { + validateUpload(file, 'picture'); + const root = ensureRouteDirectories(routeId); + newPath = moveUploadedFile(file, path.join(root, 'images', uniqueFilename(file.originalname))); + } + + db.prepare('UPDATE poi_images SET poi_id = ?, path = ?, caption = ?, sequence = ? WHERE id = ?') + .run(poiId, newPath, caption, sequence, pictureId); + + if (file && newPath !== oldPath) removeStoredFile(oldPath); + return getPicture(db, routeId, pictureId); + } catch (error) { + if (file) { + if (newPath && newPath !== oldPath) removeStoredFile(newPath); + else removeUpload(file); + } + throw error; + } +} + +export function deletePicture(db, routeId, pictureId) { + const picture = getPicture(db, routeId, pictureId); + const row = db.prepare(`${pictureSelect} WHERE p.route_id = ? AND i.id = ? AND r.status = 'active'`).get(routeId, pictureId); + db.prepare('DELETE FROM poi_images WHERE id = ?').run(pictureId); + removeStoredFile(row.path); + return { id: picture.id, routeId: picture.routeId, poiId: picture.poiId, deleted: true }; +} + +function audioFromRow(row) { + return { + routeId: row.route_id, + poiId: row.poi_id, + poiTitle: row.poi_title, + url: audioApiUrl(row.route_id, row.poi_id), + updatedAt: row.updated_at + }; +} + +const audioSelect = ` + SELECT p.id AS poi_id, p.route_id, p.title AS poi_title, + p.audio_path, p.updated_at + FROM pois p + JOIN routes r ON r.id = p.route_id +`; + +export function listAudio(db, routeId, { poiId } = {}) { + activeRoute(db, routeId); + const filterPoiId = poiId == null || poiId === '' ? null : requirePositiveInteger(poiId, 'poiId'); + const rows = filterPoiId == null + ? db.prepare(`${audioSelect} WHERE p.route_id = ? AND p.audio_path IS NOT NULL AND r.status = 'active' ORDER BY p.sequence, p.id`).all(routeId) + : db.prepare(`${audioSelect} WHERE p.route_id = ? AND p.id = ? AND p.audio_path IS NOT NULL AND r.status = 'active'`).all(routeId, filterPoiId); + return rows.map(audioFromRow); +} + +export function getAudio(db, routeId, poiId) { + activeRoute(db, routeId); + const row = db.prepare(`${audioSelect} WHERE p.route_id = ? AND p.id = ? AND p.audio_path IS NOT NULL AND r.status = 'active'`) + .get(routeId, poiId); + if (!row) throw new HttpError(404, 'Audiodatei für diesen POI nicht gefunden.'); + return audioFromRow(row); +} + +export function getAudioContent(db, routeId, poiId) { + activeRoute(db, routeId); + const row = db.prepare(`${audioSelect} WHERE p.route_id = ? AND p.id = ? AND p.audio_path IS NOT NULL AND r.status = 'active'`) + .get(routeId, poiId); + if (!row) throw new HttpError(404, 'Audiodatei für diesen POI nicht gefunden.'); + return contentResource(row.audio_path); +} + +function storeAudio(db, routeId, poiId, file, { requireAbsent, requireExisting }) { + let relativePath; + let oldPath; + try { + activeRoute(db, routeId); + validateUpload(file, 'audio'); + const poi = poiOnRoute(db, routeId, poiId); + oldPath = poi.audio_path; + + if (requireAbsent && poi.audio_path) { + throw new HttpError(409, 'Für diesen POI ist bereits eine Audiodatei hinterlegt. Verwende PUT zum Ersetzen.'); + } + if (requireExisting && !poi.audio_path) { + throw new HttpError(404, 'Für diesen POI ist noch keine Audiodatei hinterlegt. Verwende POST zum Anlegen.'); + } + + const root = ensureRouteDirectories(routeId); + relativePath = moveUploadedFile(file, path.join(root, 'audio', uniqueFilename(file.originalname))); + db.prepare('UPDATE pois SET audio_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?') + .run(relativePath, poiId); + + if (oldPath && oldPath !== relativePath) removeStoredFile(oldPath); + return getAudio(db, routeId, poiId); + } catch (error) { + if (relativePath && relativePath !== oldPath) removeStoredFile(relativePath); + else removeUpload(file); + throw error; + } +} + +export function createAudio(db, routeId, fields, file) { + const poiId = requirePositiveInteger(fields.poiId, 'poiId'); + return storeAudio(db, routeId, poiId, file, { requireAbsent: true, requireExisting: false }); +} + +export function updateAudio(db, routeId, poiId, file) { + return storeAudio(db, routeId, poiId, file, { requireAbsent: false, requireExisting: true }); +} + +export function deleteAudio(db, routeId, poiId) { + const audio = getAudio(db, routeId, poiId); + const poi = poiOnRoute(db, routeId, poiId); + db.prepare('UPDATE pois SET audio_path = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(poiId); + removeStoredFile(poi.audio_path); + return { routeId: audio.routeId, poiId: audio.poiId, deleted: true }; +} diff --git a/src/services/routes-service.js b/src/services/routes-service.js index 83cb098..c68b4ff 100644 --- a/src/services/routes-service.js +++ b/src/services/routes-service.js @@ -1,19 +1,16 @@ import fs from 'node:fs'; import path from 'node:path'; -import crypto from 'node:crypto'; import { transaction } from '../database.js'; import { config } from '../config.js'; import { HttpError } from '../middleware/errors.js'; import { parseGpx } from './gpx.js'; import { distanceMeters, routeMetrics } from './geo.js'; import { - ensureRouteDirectories, moveUploadedFile, relativeStoragePath, routeDirectory, + ensureRouteDirectories, moveUploadedFile, routeDirectory, softDeleteRouteDirectory, restoreRouteDirectory, safeMediaUrl, removeUpload } 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 uniqueFilename = original => `${crypto.randomUUID()}${path.extname(original).toLowerCase()}`; - function rowToRoute(row, includeDeleted = false) { if (!row || (!includeDeleted && row.status !== 'active')) return null; return { @@ -132,8 +129,13 @@ export function listPois(db, routeId) { return pois.map(poi => ({ id: poi.id, routeId: poi.route_id, title: poi.title, description: poi.description, lat: poi.lat, lon: poi.lon, triggerRadiusM: poi.trigger_radius_m, sequence: poi.sequence, - audioUrl: safeMediaUrl(poi.audio_path), - images: imageStatement.all(poi.id).map(image => ({ id: image.id, caption: image.caption, sequence: image.sequence, url: safeMediaUrl(image.path) })) + audioUrl: poi.audio_path ? `/api/routes/${routeId}/audio?poiId=${encodeURIComponent(poi.id)}` : null, + images: imageStatement.all(poi.id).map(image => ({ + id: image.id, + caption: image.caption, + sequence: image.sequence, + url: `/api/routes/${routeId}/pictures/${image.id}` + })) })); } @@ -143,49 +145,32 @@ export function getPoi(db, poiId) { return listPois(db, row.routeId).find(poi => poi.id === Number(poiId)); } -export function createPoi(db, routeId, fields, files = {}) { +export function createPoi(db, routeId, fields) { const route = db.prepare("SELECT id FROM routes WHERE id = ? AND status = 'active'").get(routeId); if (!route) throw new HttpError(404, 'Strecke nicht gefunden.'); - const lat = Number.parseFloat(fields.lat); const lon = Number.parseFloat(fields.lon); - if (!Number.isFinite(lat) || !Number.isFinite(lon)) throw new HttpError(400, 'Gültige POI-Koordinaten sind erforderlich.'); + const lat = Number.parseFloat(fields.lat); + const lon = Number.parseFloat(fields.lon); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) { + throw new HttpError(400, 'Gültige POI-Koordinaten sind erforderlich.'); + } const result = db.prepare(`INSERT INTO pois (route_id, title, description, lat, lon, trigger_radius_m, sequence) VALUES (?, ?, ?, ?, ?, ?, ?)`) .run(routeId, fields.title || 'Unbenannter POI', fields.description || '', lat, lon, Number.parseFloat(fields.triggerRadiusM || config.defaultPoiTriggerMeters), Number.parseInt(fields.sequence || '0', 10)); - const poiId = Number(result.lastInsertRowid); - const routeRoot = ensureRouteDirectories(routeId); - const audio = files.audio?.[0]; - if (audio) { - const relative = moveUploadedFile(audio, path.join(routeRoot, 'audio', uniqueFilename(audio.originalname))); - db.prepare('UPDATE pois SET audio_path = ? WHERE id = ?').run(relative, poiId); - } - const insertImage = db.prepare('INSERT INTO poi_images (poi_id, path, caption, sequence) VALUES (?, ?, ?, ?)'); - for (const [index, image] of (files.images || []).entries()) { - const relative = moveUploadedFile(image, path.join(routeRoot, 'images', uniqueFilename(image.originalname))); - insertImage.run(poiId, relative, '', index); - } - return listPois(db, routeId).find(poi => poi.id === poiId); + return listPois(db, routeId).find(poi => poi.id === Number(result.lastInsertRowid)); } -export function updatePoi(db, poiId, fields, files = {}) { +export function updatePoi(db, poiId, fields) { const existing = db.prepare(`SELECT p.*, r.status FROM pois p JOIN routes r ON r.id = p.route_id WHERE p.id = ?`).get(poiId); if (!existing || existing.status !== 'active') throw new HttpError(404, 'POI nicht gefunden.'); + const lat = fields.lat == null ? existing.lat : Number.parseFloat(fields.lat); + const lon = fields.lon == null ? existing.lon : Number.parseFloat(fields.lon); + const radius = fields.triggerRadiusM == null ? existing.trigger_radius_m : Number.parseFloat(fields.triggerRadiusM); + const sequence = fields.sequence == null ? existing.sequence : Number.parseInt(fields.sequence, 10); + if (![lat, lon, radius].every(Number.isFinite) || !Number.isInteger(sequence) || sequence < 0) { + throw new HttpError(400, 'POI-Koordinaten, Auslöseradius oder Reihenfolge sind ungültig.'); + } db.prepare(`UPDATE pois SET title=?, description=?, lat=?, lon=?, trigger_radius_m=?, sequence=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`) - .run(fields.title ?? existing.title, fields.description ?? existing.description, - fields.lat == null ? existing.lat : Number.parseFloat(fields.lat), fields.lon == null ? existing.lon : Number.parseFloat(fields.lon), - fields.triggerRadiusM == null ? existing.trigger_radius_m : Number.parseFloat(fields.triggerRadiusM), - fields.sequence == null ? existing.sequence : Number.parseInt(fields.sequence, 10), poiId); - const root = ensureRouteDirectories(existing.route_id); - const audio = files.audio?.[0]; - if (audio) { - const relative = moveUploadedFile(audio, path.join(root, 'audio', uniqueFilename(audio.originalname))); - db.prepare('UPDATE pois SET audio_path = ? WHERE id = ?').run(relative, poiId); - } - const maxSeq = db.prepare('SELECT COALESCE(MAX(sequence), -1) AS value FROM poi_images WHERE poi_id = ?').get(poiId).value; - const insert = db.prepare('INSERT INTO poi_images (poi_id, path, caption, sequence) VALUES (?, ?, ?, ?)'); - for (const [index, image] of (files.images || []).entries()) { - const relative = moveUploadedFile(image, path.join(root, 'images', uniqueFilename(image.originalname))); - insert.run(poiId, relative, '', maxSeq + index + 1); - } + .run(fields.title ?? existing.title, fields.description ?? existing.description, lat, lon, radius, sequence, poiId); return listPois(db, existing.route_id).find(poi => poi.id === Number(poiId)); } diff --git a/src/services/storage.js b/src/services/storage.js index 27cc89e..6f0285e 100644 --- a/src/services/storage.js +++ b/src/services/storage.js @@ -25,6 +25,34 @@ export function removeUpload(uploaded) { if (uploaded?.path && fs.existsSync(uploaded.path)) fs.rmSync(uploaded.path, { force: true }); } +export function resolveStoredFile(relativePath) { + if (!relativePath || !relativePath.startsWith('active/')) { + throw new HttpError(404, 'Aktive Mediendatei nicht gefunden.'); + } + + const storageRoot = path.resolve(config.storageDir); + const target = path.resolve(storageRoot, relativePath); + if (target !== storageRoot && !target.startsWith(`${storageRoot}${path.sep}`)) { + throw new HttpError(500, 'Ungültiger interner Medienpfad.'); + } + if (!fs.existsSync(target) || !fs.statSync(target).isFile()) { + throw new HttpError(404, 'Mediendatei nicht gefunden.'); + } + return target; +} + +export function removeStoredFile(relativePath) { + if (!relativePath) return false; + const storageRoot = path.resolve(config.storageDir); + const target = path.resolve(storageRoot, relativePath); + if (target !== storageRoot && !target.startsWith(`${storageRoot}${path.sep}`)) { + throw new HttpError(500, 'Ungültiger interner Medienpfad.'); + } + if (!fs.existsSync(target)) return false; + fs.rmSync(target, { force: true }); + return true; +} + export function softDeleteRouteDirectory(id) { const source = routeDirectory(id); if (!fs.existsSync(source)) throw new HttpError(409, 'Streckenverzeichnis fehlt; Löschmarkierung wurde nicht ausgeführt.'); diff --git a/test/api-docs.test.js b/test/api-docs.test.js index 0a6ef84..8515c01 100644 --- a/test/api-docs.test.js +++ b/test/api-docs.test.js @@ -25,6 +25,15 @@ test('complete REST API documentation is kept outside the README', async () => { 'POST /api/routes/:id/append', 'POST /api/routes/:id/pois', 'PUT /api/pois/:id', + 'GET /api/routes/:id/pictures', + 'GET /api/routes/:id/pictures/:pictureId', + 'POST /api/routes/:id/pictures', + 'PUT /api/routes/:id/pictures/:pictureId', + 'DELETE /api/routes/:id/pictures/:pictureId', + 'GET /api/routes/:id/audio', + 'POST /api/routes/:id/audio', + 'PUT /api/routes/:id/audio', + 'DELETE /api/routes/:id/audio', 'DELETE /api/routes/:id', 'POST /api/routes/:id/restore' ]) { @@ -34,8 +43,28 @@ test('complete REST API documentation is kept outside the README', async () => { for (const parameter of [ 'lat', 'lon', 'radiusKm', 'includeDeleted', 'gpx', 'name', 'slug', 'description', 'schoolName', 'title', 'triggerRadiusM', 'sequence', - 'audio', 'images' + 'picture', 'pictureId', 'caption', 'audio', 'poiId' ]) { assert.match(api, new RegExp(`\\b${parameter}\\b`), `missing parameter documentation: ${parameter}`); } }); + +test('POI media uploads use dedicated single-resource endpoints', async () => { + const router = await read('src/routes/api.js'); + const routesService = await read('src/services/routes-service.js'); + const mediaService = await read('src/services/media-service.js'); + + assert.match(router, /post\('\/routes\/:id\/pictures', upload\.single\('picture'\)/); + assert.match(router, /put\('\/routes\/:id\/pictures\/:pictureId', upload\.single\('picture'\)/); + assert.match(router, /delete\('\/routes\/:id\/pictures\/:pictureId'/); + assert.match(router, /post\('\/routes\/:id\/audio', upload\.single\('audio'\)/); + assert.match(router, /put\('\/routes\/:id\/audio', upload\.single\('audio'\)/); + assert.match(router, /delete\('\/routes\/:id\/audio'/); + assert.match(router, /post\('\/routes\/:id\/pois', upload\.none\(\)/); + assert.doesNotMatch(routesService, /files\.images|files\.audio/); + assert.match(mediaService, /caption/); + assert.match(mediaService, /removeStoredFile/); + assert.match(mediaService, /pictureApiUrl/); + assert.match(mediaService, /audioApiUrl/); + assert.doesNotMatch(router, /audio\/:poiId/); +}); diff --git a/test/media-api.test.js b/test/media-api.test.js new file mode 100644 index 0000000..b6be248 --- /dev/null +++ b/test/media-api.test.js @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import test from 'node:test'; + +const root = path.resolve(import.meta.dirname, '..'); + +async function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + server.close(error => error ? reject(error) : resolve(port)); + }); + }); +} + +async function requestJson(url, options = {}, expectedStatus = 200) { + const response = await fetch(url, options); + const body = await response.json(); + assert.equal(response.status, expectedStatus, JSON.stringify(body)); + return { response, body }; +} + +async function requestBytes(url, expectedStatus = 200) { + const response = await fetch(url); + const bytes = Buffer.from(await response.arrayBuffer()); + assert.equal(response.status, expectedStatus, bytes.toString()); + return { response, bytes }; +} + +async function waitForServer(baseUrl, child, output) { + for (let attempt = 0; attempt < 80; attempt += 1) { + if (child.exitCode != null) throw new Error(`Server wurde vorzeitig beendet.\n${output.join('')}`); + try { + const response = await fetch(`${baseUrl}/api/health`); + if (response.ok) return; + } catch { + // Server startet noch. + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error(`Serverstart hat das Zeitlimit überschritten.\n${output.join('')}`); +} + +test('pictures and per-POI audio are delivered and managed through REST API resources', { timeout: 30000 }, async t => { + const runtime = await fs.mkdtemp(path.join(os.tmpdir(), 'wegwichtel-media-api-')); + const port = await freePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const output = []; + const child = spawn(process.execPath, ['server.js'], { + cwd: root, + env: { + ...process.env, + HOST: '127.0.0.1', + PORT: String(port), + DATA_DIR: path.join(runtime, 'data'), + STORAGE_DIR: path.join(runtime, 'storage') + }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + child.stdout.on('data', chunk => output.push(chunk.toString())); + child.stderr.on('data', chunk => output.push(chunk.toString())); + + t.after(async () => { + if (child.exitCode == null) { + child.kill('SIGTERM'); + await new Promise(resolve => child.once('exit', resolve)); + } + await fs.rm(runtime, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl, child, output); + + 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'); + const { body: route } = await requestJson(`${baseUrl}/api/routes`, { + method: 'POST', + body: routeForm + }, 201); + + const { body: poi } = await requestJson(`${baseUrl}/api/routes/${route.id}/pois`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: 'Teststation', + description: 'Station für Medientests', + lat: route.start.lat, + lon: route.start.lon, + triggerRadiusM: 40, + sequence: 0 + }) + }, 201); + + const pictureForm = new FormData(); + pictureForm.append('poiId', String(poi.id)); + pictureForm.append('caption', 'Erste Bildbeschreibung'); + pictureForm.append('sequence', '0'); + pictureForm.append('picture', new Blob(['picture-one'], { type: 'image/png' }), 'bild.png'); + const { response: pictureResponse, body: picture } = await requestJson( + `${baseUrl}/api/routes/${route.id}/pictures`, + { method: 'POST', body: pictureForm }, + 201 + ); + assert.equal(pictureResponse.headers.get('location'), `/api/routes/${route.id}/pictures/${picture.id}`); + assert.equal(picture.poiId, poi.id); + assert.equal(picture.caption, 'Erste Bildbeschreibung'); + + const { body: pictureList } = await requestJson(`${baseUrl}/api/routes/${route.id}/pictures?poiId=${poi.id}`); + assert.equal(pictureList.pictures.length, 1); + assert.equal(pictureList.pictures[0].id, picture.id); + assert.equal(pictureList.pictures[0].url, `/api/routes/${route.id}/pictures/${picture.id}`); + + const { response: pictureContentResponse, bytes: pictureBytes } = await requestBytes( + `${baseUrl}/api/routes/${route.id}/pictures/${picture.id}` + ); + assert.equal(pictureContentResponse.headers.get('content-type'), 'image/png'); + assert.equal(pictureBytes.toString(), 'picture-one'); + + const { body: updatedPicture } = await requestJson( + `${baseUrl}/api/routes/${route.id}/pictures/${picture.id}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ caption: 'Aktualisierte Bildbeschreibung', sequence: 2 }) + } + ); + assert.equal(updatedPicture.caption, 'Aktualisierte Bildbeschreibung'); + assert.equal(updatedPicture.sequence, 2); + + const audioForm = new FormData(); + audioForm.append('poiId', String(poi.id)); + audioForm.append('audio', new Blob(['audio-one'], { type: 'audio/mpeg' }), 'ansage.mp3'); + const { response: audioResponse, body: audio } = await requestJson( + `${baseUrl}/api/routes/${route.id}/audio`, + { method: 'POST', body: audioForm }, + 201 + ); + assert.equal(audioResponse.headers.get('location'), `/api/routes/${route.id}/audio?poiId=${poi.id}`); + assert.equal(audio.poiId, poi.id); + + const duplicateAudio = new FormData(); + duplicateAudio.append('poiId', String(poi.id)); + duplicateAudio.append('audio', new Blob(['duplicate'], { type: 'audio/mpeg' }), 'doppelt.mp3'); + await requestJson(`${baseUrl}/api/routes/${route.id}/audio`, { + method: 'POST', + body: duplicateAudio + }, 409); + + const replacementAudio = new FormData(); + replacementAudio.append('audio', new Blob(['audio-two'], { type: 'audio/ogg' }), 'ansage-neu.ogg'); + const { body: replacedAudio } = await requestJson( + `${baseUrl}/api/routes/${route.id}/audio?poiId=${poi.id}`, + { method: 'PUT', body: replacementAudio } + ); + assert.equal(replacedAudio.url, `/api/routes/${route.id}/audio?poiId=${poi.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].url, `/api/routes/${route.id}/pictures/${picture.id}`); + assert.equal(routeWithMedia.pois[0].audioUrl, `/api/routes/${route.id}/audio?poiId=${poi.id}`); + + const { response: audioContentResponse, bytes: audioBytes } = await requestBytes( + `${baseUrl}/api/routes/${route.id}/audio?poiId=${poi.id}` + ); + assert.equal(audioContentResponse.headers.get('content-type'), 'audio/ogg'); + assert.equal(audioBytes.toString(), 'audio-two'); + + const { body: deletedAudio } = await requestJson( + `${baseUrl}/api/routes/${route.id}/audio?poiId=${poi.id}`, + { method: 'DELETE' } + ); + assert.equal(deletedAudio.deleted, true); + + const { body: deletedPicture } = await requestJson( + `${baseUrl}/api/routes/${route.id}/pictures/${picture.id}`, + { method: 'DELETE' } + ); + assert.equal(deletedPicture.deleted, true); + + await requestJson(`${baseUrl}/api/routes/${route.id}/pictures/${picture.id}`, {}, 404); + await requestJson(`${baseUrl}/api/routes/${route.id}/audio?poiId=${poi.id}`, {}, 404); +}); diff --git a/test/mobile-navigation-ui.test.js b/test/mobile-navigation-ui.test.js index c49da6f..b932697 100644 --- a/test/mobile-navigation-ui.test.js +++ b/test/mobile-navigation-ui.test.js @@ -152,7 +152,8 @@ test('reaching a POI preloads audio without autoplay and uses the vibration wrap const html = await read('public/index.html'); const loader = await read('public/js/bootstrap-loader.js'); - assert.match(app, /ns\.AudioPlayer\.load\(poi\.audioUrl\)/); + assert.match(app, /ns\.AudioPlayer\.load\(poi\.audioUrl \? ns\.Api\.audioUrl/); + assert.match(app, /ns\.Api\.pictureUrl/); assert.doesNotMatch(app, /AudioPlayer\.play\(/); assert.match(app, /ns\.Vibration\.start\(ns\.Vibration\.Patterns\.ACTIVE_POI\)/); assert.doesNotMatch(app, /navigator\.vibrate/); @@ -161,3 +162,19 @@ test('reaching a POI preloads audio without autoplay and uses the vibration wrap assert.match(html, /