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