Compare commits

...

43 Commits
dsg ... main

Author SHA1 Message Date
Florian Zumpe
41e858c0a2 remove big image
Some checks failed
Sonarqube Scanner / Build and analyze (push) Failing after 1m20s
2026-06-23 16:12:03 +02:00
Florian Zumpe
d1b368d0c4 added privacy switches and documents, assets and local storage for already visited POIs 2026-06-23 16:10:19 +02:00
Florian Zumpe
fa9ab1aa14 Downgrade css to prevent older browsers displaying faulty views
Some checks failed
Sonarqube Scanner / Build and analyze (push) Failing after 1m36s
2026-06-17 18:03:44 +02:00
Florian Zumpe
7760ae4f49 Fix mobile view Bugs and add versions to static files for cache breaking 2026-06-17 17:29:39 +02:00
Florian Zumpe
13770f21e3 fix mobile browser view
All checks were successful
Sonarqube Scanner / Build and analyze (push) Successful in 1m36s
2026-06-17 16:27:56 +02:00
Florian Zumpe
32f161483d simplify application and remove window references
Some checks failed
Sonarqube Scanner / Build and analyze (push) Failing after 1m27s
2026-06-17 16:08:01 +02:00
Florian Zumpe
6a4d84ac6a replace faulty coverage module with unittest and included libraries
Some checks failed
Sonarqube Scanner / Build and analyze (push) Failing after 1m41s
2026-06-17 15:40:33 +02:00
Florian Zumpe
0b5c994957 included coverage.py
Some checks failed
Sonarqube Scanner / Build and analyze (push) Failing after 27s
2026-06-17 15:28:15 +02:00
Florian Zumpe
95026aac6e fixed sonarqube scan issues
Some checks failed
Sonarqube Scanner / Build and analyze (push) Failing after 22s
2026-06-17 15:12:46 +02:00
Florian Zumpe
2442f8d458 cleanup test structure
Some checks failed
Sonarqube Scanner / Build and analyze (push) Failing after 1m29s
2026-06-17 14:47:29 +02:00
Florian Zumpe
b9f3417aa8 excluded test and test-support folders from tests itself
Some checks failed
Sonarqube Scanner / Build and analyze (push) Failing after 1m21s
2026-06-17 14:25:44 +02:00
b5d38be46a fixed typo
Some checks failed
Sonarqube Scanner / Build and analyze (push) Failing after 2m5s
2026-06-17 14:19:51 +02:00
ae5bf0fe4c added qualitygate exceptions if <80% and diveded test and test-support scopes 2026-06-17 14:18:27 +02:00
8521d0ff9e test-support added to test includes
All checks were successful
Sonarqube Scanner / Build and analyze (push) Successful in 1m15s
2026-06-17 14:11:49 +02:00
a2845469e5 revert 58d8ba4f45daf1f3be8d77720f531f43537ff2f8
All checks were successful
Sonarqube Scanner / Build and analyze (push) Successful in 1m19s
revert fixed misconfigured test-path
2026-06-17 14:08:25 +02:00
Florian Zumpe
58d8ba4f45 fixed misconfigured test-path
Some checks failed
Sonarqube Scanner / Build and analyze (push) Failing after 18s
2026-06-17 14:05:05 +02:00
8fa62fe0c6 Remove outdated coverage reports if existing before tests
All checks were successful
Sonarqube Scanner / Build and analyze (push) Successful in 1m18s
2026-06-17 13:57:10 +02:00
Florian Zumpe
8df484e77e coverage tests spawn local server with authentication for read-write requests
All checks were successful
Sonarqube Scanner / Build and analyze (push) Successful in 1m57s
2026-06-17 13:38:05 +02:00
Florian Zumpe
d0046f5693 Added authentication for test-requests and test agains current nginx-server
Some checks failed
Sonarqube Scanner / Build and analyze (push) Failing after 20s
2026-06-17 13:08:48 +02:00
b33ed98c66 .gitea/workflows/sonar-scanner.yaml aktualisiert
Some checks failed
Sonarqube Scanner / Build and analyze (push) Has been cancelled
2026-06-17 12:59:06 +02:00
b36d14bd2b .gitea/workflows/sonar-scanner.yaml aktualisiert
Some checks failed
Sonarqube Scanner / Build and analyze (push) Has been cancelled
2026-06-17 12:52:24 +02:00
13598f8db1 run coverage tests before sonarscan
Some checks failed
Sonarqube Scanner / Build and analyze (push) Failing after 24s
2026-06-17 12:33:57 +02:00
Florian Zumpe
e5ae7eab3c add coverage test script to npm config
Some checks failed
Sonarqube Scanner / Build and analyze (push) Has been cancelled
2026-06-17 12:33:08 +02:00
28b8f3672e removed parameter doublette sonar.sources
All checks were successful
Sonarqube Scanner / Build and analyze (push) Successful in 56s
2026-06-17 12:23:35 +02:00
431f9b1d81 fixed typo
All checks were successful
Sonarqube Scanner / Build and analyze (push) Successful in 1m1s
2026-06-17 12:21:47 +02:00
8785a9ec47 sonarscanner unterscheidet nach tests und anwendung und lässt externe Module außen vor
Some checks failed
Sonarqube Scanner / Build and analyze (push) Failing after 1m1s
2026-06-17 12:18:20 +02:00
Florian Zumpe
0def9b23db included serverside mime detection
All checks were successful
Sonarqube Scanner / Build and analyze (push) Successful in 1m6s
2026-06-17 11:42:43 +02:00
Florian Zumpe
3b3069ca8c included scripts for api interaction 2026-06-17 11:41:36 +02:00
a697e293cb Sonarqube Scanner für SAST eingebunden
All checks were successful
Sonarqube Scanner / Build and analyze (push) Successful in 1m8s
2026-06-17 11:13:57 +02:00
faca276e2e gitea workflows excluded from git pull and clone 2026-06-17 11:12:56 +02:00
Florian Zumpe
09ce7c0605 fixed missing endpoint GET /routes/:id/pois/:id and added json style fileupload 2026-06-17 01:39:50 +02:00
Florian Zumpe
5c85a0194b /pictures und /audio endpoints moved under /poi 2026-06-17 01:14:19 +02:00
Florian Zumpe
833b5ad3a4 remove /media endpoint 2026-06-17 00:10:46 +02:00
Florian Zumpe
c98f304ffa added image and audio REST endpoints 2026-06-16 23:52:37 +02:00
Florian Zumpe
b910bce8df reworked poi details view 2026-06-16 17:46:53 +02:00
Florian Zumpe
44b1700c48 reworked app screens and added vibration 2026-06-16 17:30:55 +02:00
Florian Zumpe
40f230864b Reworked pause and stop buttons and documentation 2026-06-16 17:01:53 +02:00
Florian Zumpe
4529749baa Reworked watcher events and describing texts 2026-06-16 16:45:59 +02:00
289c6d5a43 Stop tracking server-specific nginx and systemd configuration 2026-06-16 16:24:38 +02:00
Florian Zumpe
4dfa7da08b Add Navigation Arrow and simplify static page 2026-06-16 16:20:09 +02:00
Florian Zumpe
278813fba6 Add all routes as selection 2026-06-16 15:14:46 +02:00
b6f1a0f825 change gitignore and system components 2026-06-16 12:47:19 +02:00
Florian Zumpe
eb559b16fb initial commit 2026-06-16 10:31:41 +02:00
326 changed files with 11489 additions and 41422 deletions

7
.env.example Normal file
View File

@ -0,0 +1,7 @@
HOST=127.0.0.1
PORT=47145
DATA_DIR=./data
STORAGE_DIR=./storage
MAX_UPLOAD_MB=50
DEFAULT_ROUTE_RADIUS_KM=25
DEFAULT_POI_TRIGGER_METERS=80

View File

@ -0,0 +1,71 @@
name: Sonarqube Scanner
on:
push:
branches:
- main
jobs:
build:
name: Build and analyze
runs-on: docker
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Download SonarQube Scanner
env:
SONAR_SCANNER_VERSION: ${{ vars.SONAR_SCANNER_VERSION }}
run: |-
apk --no-cache add curl unzip;
curl --create-dirs -sSLo /tmp/sonar-scanner.zip https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-$SONAR_SCANNER_VERSION.zip;
unzip -o /tmp/sonar-scanner.zip -d /tmp/;
- name: Check if SonarQube Scanner is available
env:
SONAR_SCANNER_HOME: ${{ vars.SONAR_SCANNER_HOME }}
run: |-
test -f $SONAR_SCANNER_HOME/bin/sonar-scanner;
- name: Verify SonarQube Scanner installation
env:
SONAR_SCANNER_HOME: ${{ vars.SONAR_SCANNER_HOME }}
run: |-
java -version;
$SONAR_SCANNER_HOME/bin/sonar-scanner --version
- name: Run Coverage tests
env:
API_USER: ${{ secrets.API_USER }}
API_PASSWORD: ${{ secrets.API_SECRET }}
run: |-
npm install -g c8;
rm -rf coverage/js;
npm ci;
npm run test:coverage;
- name: Run sonar-scanner
env:
SONAR_SCANNER_HOME: ${{ vars.SONAR_SCANNER_HOME }}
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
SONAR_PROJECT_KEY: ${{ vars.SONAR_PROJECT_KEY }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: |-
$SONAR_SCANNER_HOME/bin/sonar-scanner \
-Dsonar.projectKey=$SONAR_PROJECT_KEY \
-Dsonar.host.url=$SONAR_HOST_URL \
-Dsonar.javascript.lcov.reportPaths=coverage/js/lcov.info \
-Dsonar.sources=src,public,scripts,server.js,tools/python \
-Dsonar.tests=test,test-support \
-Dsonar.test.inclusions=test/**/*.test.js \
-Dsonar.test-support.inclusions=test-support/**/*.js \
-Dsonar.exclusions=public/vendor/**,node_modules/**,data/**,storage/**
- uses: SonarSource/sonarqube-quality-gate-action@v1
timeout-minutes: 5
env:
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

18
.gitignore vendored Normal file
View File

@ -0,0 +1,18 @@
node_modules/
data/*.sqlite
data/*.sqlite-*
storage/active/routes/*
storage/trash/routes/*
!storage/active/routes/.gitkeep
!storage/trash/routes/.gitkeep
.env
npm-debug.log*
coverage/
.gitea/workflows/
.coverage
__pycache__/
*.pyc

200
README.md Normal file
View File

@ -0,0 +1,200 @@
# Wegwichtel Next
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
- **Server:** Node.js 22.13+, Express 5, `node:sqlite`, Multer, `fast-xml-parser`, `file-type` und `@file-type/av`
- **Client:** klassisches JavaScript, jQuery 4.0.0 und jQuery UI 1.14.2 mit lokalem Base-Theme
- **Medien:** getrennte Verzeichnisse für aktive und zum Löschen markierte Strecken
- **Löschmodell:** Soft Delete in SQLite plus atomisches Verschieben des gesamten Streckenordners in `storage/trash/routes`
- **Wiederherstellung:** `POST /api/routes/:id/restore` verschiebt die Daten zurück und korrigiert alle gespeicherten Pfade
## Schnellstart
```bash
cp .env.example .env
npm install
npm run init-db
npm start
```
Danach lauscht Wegwichtel ausschließlich auf dem lokalen Socket `127.0.0.1:47145` und ist unter `http://127.0.0.1:47145` erreichbar. Die offiziellen, exakt versionierten Distributionsdateien sind bereits unter `public/vendor` enthalten. `npm install` installiert zusätzlich die Pakete `jquery@4.0.0` und `jquery-ui@1.14.2` und synchronisiert daraus JavaScript, Base-Theme, Themebilder und Lizenzdateien erneut in das Vendor-Verzeichnis. Der Browser lädt keine Bibliotheken von einem CDN.
## Server-Socket
Die Standardwerte stehen in `.env.example` und werden auch verwendet, wenn keine `.env` vorhanden ist:
```dotenv
HOST=127.0.0.1
PORT=47145
```
`HOST` ist die tatsächliche Bind-Adresse des Node.js-Servers. Der Health-Endpunkt meldet beispielsweise:
```json
{
"ok": true,
"service": "wegwichtel",
"socket": "127.0.0.1:47145"
}
```
## Initialisierungsablauf des Clients
1. statischer Initialisierungsbildschirm erscheint ohne Bibliotheksabhängigkeit,
2. lokale jQuery-Datei wird geladen und auf Version 4.0.0 geprüft,
3. lokales jQuery UI 1.14.2 samt Base-Theme wird geladen und über `jQuery.ui.version` geprüft,
4. die Clientmodule werden sequenziell geladen,
5. `/api/health` prüft Server und SQLite,
6. der Client lädt die Routenliste,
7. anschließend wird die Position ermittelt; daraus entstehen nahe Empfehlungen, während die Gesamtliste vollständig erhalten bleibt,
8. die responsive Routenansicht wird freigeschaltet.
Scheitert ein Schritt, bleibt der Initialisierungsbildschirm mit einer konkreten Fehlermeldung und einem Wiederholungsbutton sichtbar.
## Lokale UI-Abhängigkeiten
Im Vendor-Verzeichnis liegen die zur Laufzeit verwendeten Dateien vollständig lokal:
```text
public/vendor/jquery/jquery-4.0.0.min.js
public/vendor/jquery/LICENSE.txt
public/vendor/jquery-ui/jquery-ui-1.14.2.min.js
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.
## API-Dokumentation
Die vollständige REST-API ist einschließlich aller Pfad-, Query-, Formular- und Datei-Parameter sowie ausführlicher `curl`-Beispiele in [`docs/REST-API.md`](docs/REST-API.md) dokumentiert. Die README beschreibt bewusst nur Installation, Architektur und Bedienverhalten.
## Python-Werkzeuge
Unter [`tools/python/`](tools/python/README.md) liegen eigenständige Python-3-Skripte zum Anlegen, Ändern, Erweitern, Wiederherstellen und Löschen von Routen, POIs, Bildern und Audiodateien. Die Werkzeuge selbst verwenden ausschließlich die Python-Standardbibliothek. Fehlende Parameter werden interaktiv abgefragt. Antwortet ein vorgeschalteter Passwortschutz mit HTTP 401, fragt die gemeinsame Request-Schicht Benutzername und Passwort ab und wiederholt den Request. Fehler werden mit einer verständlichen Zusammenfassung und dem vollständigen Python-Stacktrace auf `stderr` ausgegeben.
## Dateistruktur
```text
public/ mobiler Client
index.html reduzierte semantische Seitenstruktur
js/bootstrap-loader.js lädt und prüft lokale Bibliotheken
js/orientation.js Kompassausrichtung für den Navigationspfeil
js/vibration.js gekapselte Vibration mit benannten Mustern
vendor/ lokale jQuery-/jQuery-UI-Dateien samt Themebildern
src/routes/ REST-Routing
src/services/ GPX-, Geodaten-, Speicher- und Fachlogik
database/schema.sql SQLite-Schema
data/ lokale SQLite-Datei
storage/active/routes/ aktive GPX-, Bild- und Audiodateien
storage/trash/routes/ zum Löschen markierte Strecken
examples/ Beispiel-GPX
test/ automatisierte Tests
tools/python/ interaktive API-Werkzeuge ohne Node.js-Abhängigkeit
```
## Automatisierte Tests
Die REST-Integrationstests starten die Anwendung über `npm start` mit isolierten temporären Daten- und Speicherverzeichnissen. Der eigentliche Node.js-Prozess lauscht innerhalb des Testaufbaus auf `127.0.0.1:47146`. Davor läuft ein ausschließlich für die Tests bestimmter Reverse-Proxy auf `http://127.0.0.1:47145`, sodass dieser Loopback-Endpunkt das einzige Ziel der API-Aufrufe bleibt. Der Produktivserver wird von der Testsuite nicht angesprochen.
Der lokale Proxy lässt `GET`, `HEAD` und `OPTIONS` ohne Anmeldung passieren. Für `POST`, `PUT`, `PATCH` und `DELETE` antwortet er zunächst mit `401 Unauthorized` und `WWW-Authenticate: Basic`, sofern kein gültiger Authorization-Header vorhanden ist. Nach erfolgreicher Basic-Auth-Prüfung entfernt der Proxy den Header wieder und leitet den Request an den lokal per NPM gestarteten Server weiter. Damit wird ein möglicher Nginx-Passwortschutz für schreibende Operationen realistisch simuliert, ohne Authentifizierung in die Anwendung selbst einzubauen.
Der Integrationstest prüft dabei ausdrücklich, dass lesende Requests nicht präventiv authentifiziert werden und dass für `POST`, `PUT` und `DELETE` jeweils zuerst ein `401` und anschließend ein erfolgreicher authentifizierter Request erfolgt. Zusätzlich bleibt ein kleiner, isolierter HTTP-Mock auf einem zufälligen Loopback-Port bestehen, der die 401-Wiederholungslogik der Python-Request-Schicht unabhängig vom vollständigen REST-Test prüft.
Die kombinierte Coverage wird mit folgenden Befehlen erzeugt:
```bash
npm ci
npm run test:coverage
```
Der Python-Coverage-Runner verwendet ausschließlich die Python-Standardbibliothek. Er führt die Unit-Tests mit `unittest` unter dem eingebauten `trace`-Modul aus und erzeugt daraus ohne `pip` oder zusätzliche Pakete einen Cobertura-kompatiblen Bericht. Ausführungsfehler werden mit vollständigem Python-Stacktrace auf `stderr` ausgegeben.
Dabei entstehen `coverage/js/lcov.info` für JavaScript und `coverage/python/coverage.xml` für die Python-Werkzeuge. Die zugehörigen SonarQube-Pfade stehen in `sonar-project.properties`. Die Coverage-Skripte erzwingen selbst keinen Mindestwert; die Bewertung und die Hinweise zu nicht abgedeckten Stellen bleiben Aufgabe des SonarQube Quality Gates.
## Technische Hinweise
- 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.
- Die Node.js-Anwendung authentifiziert Schreibzugriffe nicht selbst. Ein vorgeschalteter Nginx kann Basic Auth erzwingen; Rollen, CSRF-Schutz, Rate-Limits und ein Moderationsworkflow bleiben sinnvolle Ausbaustufen.
- Uploadtypen werden serverseitig aus dem Inhalt erkannt. Binärformate verwenden `file-type` und `@file-type/av`; GPX wird als UTF-8-XML mit `<gpx>`-Wurzelelement geprüft. Dateiname und vom Client behaupteter MIME-Typ sind dafür unerheblich.
- Der Server bindet standardmäßig nur an `127.0.0.1`; Zugriffe von anderen Geräten sind damit bewusst ausgeschlossen.
- Anwendungsaktionen verwenden Pointer Events für Touch, Stift und Maus. Enter und Leertaste bleiben als Tastaturbedienung erhalten.
- Bei gestarteter Route zeigt ein fixierter Footer vorrangig Entfernung und Richtung zum nächstgelegenen, noch nicht erreichten POI. Der GPX-Fortschritt wird parallel entlang der Trackpunkte nachgeführt; sind alle POIs erreicht, übernimmt wieder der nächste GPX-Trackpunkt beziehungsweise das Routenziel. Ohne Kompassdaten wird die Peilung mit Norden oben dargestellt.
- Auf iPhone und iPad wird die Freigabe der Geräteausrichtung beim Start der Route innerhalb der Benutzeraktion angefordert.
- Für die Geolokalisierung sollte der Client lokal über `http://127.0.0.1:47145` oder online ausschließlich über HTTPS geöffnet werden. Das betrifft ebenso den Gerätekompass.
## Nächste Ausbaustufen
- Administrationsoberfläche zum Zeichnen/Importieren von Routen und Platzieren der POIs
- Benutzer-, Schul- und Projektzuordnung mit Rollenmodell
- Offline-Cache/PWA für Wanderungen ohne Mobilfunkempfang
- Kartenansicht, GPX-Visualisierung und Abweichungswarnung
- Administrationsoberfläche für die bereits vorhandenen Einzelendpunkte zur Medienverwaltung
- Hintergrundbereinigung des Papierkorbs nach einer konfigurierbaren Aufbewahrungsfrist
- Integritätsjournal für Dateiverschiebungen und Wiederherstellungen
## Routenauswahl
Die Startseite zeigt standortbasierte Empfehlungen und darunter alle aktiven Routen. Die Gesamtliste bleibt auch bei verweigertem oder nicht verfügbarem GPS auswählbar und kann nach Name, Schule oder Beschreibung durchsucht werden.
## Mobile Bedienung und Routennavigation
Das HTML verwendet nur IDs, die von den Clientmodulen tatsächlich angesprochen werden. Die wenigen Klassen bilden wiederverwendete Layoutbausteine wie Seiten, Inhalte, Hinweise, Routenlisten und Routendaten ab. Automatisierte Tests gleichen diese Verwendungen ab.
Alle selbst implementierten Schaltaktionen reagieren primär auf `pointerup`; dadurch funktionieren dieselben Handler mit Touchscreen, Eingabestift und Maus. Die Diashow kann zusätzlich horizontal gewischt werden. Für die Tastatur werden Enter und Leertaste separat behandelt.
Beim Start der Route wird der aktuellen Position nächstgelegene GPX-Trackpunkt gesucht und der Fortschritt anschließend nur vorwärts entlang der Punktfolge nachgeführt. Für die sichtbare Navigation haben jedoch POIs Vorrang: Aus allen noch nicht automatisch aktivierten Stationen wird bei jeder Positionsänderung die räumlich nächstgelegene bestimmt. Der Footer zeigt:
- Name und auf ganze Meter gerundete Luftlinienentfernung zum nächsten POI,
- einen frei rotierenden SVG-Pfeil relativ zur Geräte- oder Bewegungsrichtung,
- andernfalls die absolute Peilung bei Norden oben,
- nach dem Aktivieren unmittelbar den nächsten noch offenen POI,
- und erst nach Erreichen aller POIs wieder den nächsten GPX-Trackpunkt beziehungsweise das Routenziel.
In der Stationsliste erhält jeder POI rechts einen kleinen Richtungspfeil und seine aktuelle Entfernung in Metern. Diese Werte werden mit jeder GPS- oder Kompassaktualisierung neu berechnet. Die Reihenfolge der Stationsliste bleibt dabei unverändert.
## Routensteuerung und GPS-Tracking
Die Routenansicht besitzt drei nebeneinanderliegende, semantische Schaltflächen mit lokalen SVG-Symbolen:
- **Route starten beziehungsweise fortsetzen:** aktiviert `navigator.geolocation.watchPosition()` und die Geräteausrichtung.
- **Route pausieren:** beendet den aktiven GPS-Watcher und den Kompass-Listener, pausiert eine laufende Audioansage und behält Routenfortschritt, Audioquelle, Abspielposition und bereits ausgelöste POIs bei.
- **Route beenden:** beendet alle Sensor-Listener, hält die Audioansage an, entlädt ihre Quelldatei, setzt den Fortschritt zurück und kehrt zur Routenauswahl zurück.
Die aktuell geöffnete beziehungsweise automatisch aktivierte Station wird in der Stationsliste sichtbar hervorgehoben und mit `aria-current="step"` semantisch gekennzeichnet. Beim Eintritt in den Auslöseradius wird ihre Audiodatei mit `preload="auto"` vorbereitet, aber niemals automatisch gestartet. Die Wiedergabe beginnt ausschließlich über die nativen Audio-Bedienelemente.
Die Vibration API ist in `public/js/vibration.js` gekapselt. Die aus dem historischen Projekt übernommenen Konstanten `MICRO`, `MINI`, `SHORT`, `LONG`, `ALARM` und `POI` stehen unter `Wegwichtel.Vibration.Patterns` zur Verfügung. Für eine neu aktivierte Station wird `ACTIVE_POI` mit dem Muster `[500, 300, 500]` verwendet. Nicht unterstützende Browser ignorieren den Aufruf ohne Fehler; Pause und Beenden brechen ein laufendes Muster ab.
Beispiel für einen Aufruf mit Konstante:
```javascript
Wegwichtel.Vibration.start(Wegwichtel.Vibration.Patterns.ACTIVE_POI);
```
Während die Route läuft, werden Positionsänderungen fortlaufend verarbeitet. Eine Browser-Webanwendung ist jedoch kein nativer Hintergrunddienst: Betriebssystem und Browser können die Aktualisierung bei gesperrtem Bildschirm, Energiesparmodus oder im Hintergrund drosseln beziehungsweise anhalten.
Die Symbole liegen unter `public/images/icons/`. Sie befinden sich innerhalb nativer `<button>`-Elemente. Die Bilder selbst sind dekorativ (`alt=""`); der zugängliche Name wird über `aria-label` und `title` am jeweiligen Button bereitgestellt.
## Datenschutz, Impressum und lokale Browserdaten
Die Oberfläche enthält rechts oben einen global verfügbaren Informationsbutton. Er öffnet ein touch- und tastaturbedienbares Overlay mit Datenschutzerklärung, Impressum sowie einer technischen Erläuterung zu Cookies, Local Storage und den lokal eingebundenen JavaScript-Bibliotheken.
Die enthaltenen Rechtstexte sind technische Vorlagen. Vor einer öffentlichen Bereitstellung müssen insbesondere Betreiber, ladungsfähige Anschrift, Kontakt, Hostinganbieter, Protokollierungsdauer, Datenschutzkontakt und zuständige Aufsicht ergänzt und rechtlich geprüft werden. Eine Checkliste steht in [`docs/PUBLICATION-CHECKLIST.md`](docs/PUBLICATION-CHECKLIST.md).
Wegwichtel setzt selbst keine Cookies und bindet keine Analyse-, Werbe- oder Trackingdienste ein. Der Startbildschirm weist darauf hin. Die freiwillige Speicherung des Streckenfortschritts wird getrennt gesteuert:
- `wegwichtel.privacy.v1` speichert die gewählte Einstellung,
- `wegwichtel.route-progress.v1` enthält bei aktivierter Funktion ein JSON-Objekt mit aktueller Route, Routenstatus, besuchten und angesehenen Stationen, abgespielten Audioeinträgen und Zeitstempeln,
- Streckendaten mit einer letzten Aktualisierung von mindestens 24 Stunden werden beim nächsten Start schrittweise entfernt,
- beim Ausschalten der Funktion wird der gesamte Streckenfortschritt sofort aus dem Local Storage gelöscht,
- ohne aktivierte Speicherung bleiben alle Routen- und Stationsdaten ausschließlich im Arbeitsspeicher des geöffneten Browserfensters.
Der Initialisierungsbildschirm bleibt nach erfolgreichem Laden sichtbar. Erst eine bewusste Pointer- oder Tastaturaktion öffnet die eigentliche Anwendung. Das Informationsfenster und die Datenschutzeinstellungen können bereits auf dem Startbildschirm verwendet werden, ohne die Anwendung weiterzuschalten.

17
SECURITY.md Normal file
View File

@ -0,0 +1,17 @@
# Sicherheitshinweise
## Legacy-Projekt
Das historische PHP-Projekt enthielt fest eingetragene Datenbank-Zugangsdaten. Diese Werte wurden nicht in das neue Projekt übernommen. Bestehende Zugangsdaten des Altprojekts sollten rotiert und anschließend aus der Repository-Historie entfernt beziehungsweise als kompromittiert behandelt werden.
## Stand dieses Skeletts
Die Schreibendpunkte besitzen absichtlich noch keine Authentifizierung und dürfen so nicht öffentlich erreichbar gemacht werden. Vor einem Produktivbetrieb sind mindestens erforderlich:
- Login und Rollen für Lehrkräfte, Redakteure und Administration,
- CSRF-Schutz beziehungsweise tokenbasierte API-Authentifizierung,
- serverseitige Prüfung tatsächlicher Datei-Inhalte statt nur MIME-Typ und Endung,
- Begrenzung von Request-Rate, Speicherverbrauch und Medienabmessungen,
- Protokollierung sämtlicher Änderungen und Wiederherstellungen,
- HTTPS, sichere Header und ein restriktiver Reverse-Proxy,
- Datenschutzkonzept für Standortdaten und von Schülerinnen und Schülern erstellte Medien.

File diff suppressed because one or more lines are too long

View File

@ -1,39 +0,0 @@
// From: https://developer.mozilla.org/de/docs/AJAX/Einf%C3%BChrung
var ajax = function(target, callback) {
var http_request = false;
if (window.XMLHttpRequest) { // Mozilla, Safari,...
http_request = new XMLHttpRequest();
if (http_request.overrideMimeType) {
http_request.overrideMimeType('text/xml');
}
} else if (window.ActiveXObject) { // IE
try {
http_request = new ActiveXObject("Msxml2.XMLHTTP");
} catch (e) {
try {
http_request = new ActiveXObject("Microsoft.XMLHTTP");
} catch (e) {}
}
}
if (!http_request) {
alert('Ende :( Kann keine XMLHTTP-Instanz erzeugen');
return false;
}
http_request.onreadystatechange = gotResponse;
http_request.open('GET', target, true);
http_request.send(null);
function gotResponse() {
if (http_request.readyState == 4) {
if (http_request.status == 200) {
if(callback != null) callback(http_request.responseText);
} else {
alert('Bei dem Request ist ein Problem aufgetreten.');
}
}
}
}

View File

@ -1,6 +0,0 @@
var scriptSettings = {
debug: true,
showDump: true,
showConsole: false,
}

View File

@ -1,55 +0,0 @@
/****************************************************************************************************************
* CLASS console: helper to make console-object available if not present *
* implemented as singleton-object *
* *
* methods: debug(args: variable number and type of arguments):void - dump parameters to console *
* *
* remarks: creates a css-styled div fixed on bottom of visible screen, if console-object is created *
****************************************************************************************************************/
if (!console)
{
console = {
create: function() {
$('body').append($('<div id="console"></div>'));
$('head').append($([
'<style type="text/css">',
' #console {',
' position: fixed;',
' bottom: 0;',
' left: 0;',
' right: 0;',
' z-index: 9998;',
' height: 12em;',
' line-height: 1.2em;',
' font-size: 0.8em;',
' background-color: rgb(220,220,220);',
' overflow-y: scroll;',
' }',
'</style>'
].join("\n")));
},
debug: function() {
var time = new Date();
$.each(arguments, function(key, data) {
$('#console').html(
$('#console').html()
+(
'('
+ ('0' + time.getHours()).substr(-2)
+ ':'
+ ('0' + time.getMinutes()).substr(-2)
+ ':'
+ ('0' + time.getSeconds()).substr(-2)
+ ') '
+ JSON.stringify(data)
+ ' ['
+ typeof(data)
+ ']'
).replace(/[\r\n]{1,1}/g, '<br />')
+'<br />'
).scrollTop($("#console").prop("scrollHeight"))
});
}
}
}

View File

@ -1,57 +0,0 @@
/****************************************************************************************************************
* CLASS DB: contains all data about routes and linked pois *
* *
* Events: body.databaseLoaded(database:DB) *
* *
* Returns: Array of routes in format *
* Object{ *
* id:Number, *
* name:String, *
* image:String, *
* languages:Array(String), *
* pois:Array(Object{ *
* lat:Number, *
* lon:Number, *
* desc:String, *
* range:Number, *
* audio:Number *
* } *
* } *
****************************************************************************************************************/
function DB() {
var that = this;
this._routes = []; // format: {id:0, name:'', image:'', languages:[], pois:[{lat:0, lon:0, desc:'', range:0, audio:0}]}
this._completeTracks = Array();
this._init = function() {
$('#database-loading').slideDown({duration: 500});
console.debug('loading database');
$.getJSON('database.php', {'get':'routes'}, function(data) {
console.debug('database: routes received');
data.forEach(function(track) {
//console.debug(id, track);
that._completeTracks[track.id] = false;
$.getJSON('database.php', {'get':'pois','id':track.id}, function(pois) {
console.debug(track.name, pois);
// console.debug('database: '+pois.length+' pois for '+track.name+' received');
track.pois = pois;
that._routes[track.id] = (track);
that._completeTracks[track.id] = true;
//console.debug('track done:', track);
if (that._completeTracks.reduce(function(prev, curr) { return (prev && curr); }, true)) { //tracks complete ?
$('#database-loading').slideUp({duration: 500});
console.debug('database loaded');
$('body').trigger('databaseLoaded', that);
}
});
});
});
}
this._init();
return this._routes;
}

View File

@ -1,27 +0,0 @@
// From: http://phpperformance.de/javascript-event-onload-und-die-bessere-alternative/
//create onDomReady Event
window.onDomReady = initReady;
// Initialize event depending on browser
function initReady(fn)
{
//W3C-compliant browser
if(document.addEventListener) {
document.addEventListener("DOMContentLoaded", fn, false);
}
//IE
else {
document.onreadystatechange = function(){readyState(fn)}
}
}
//IE execute function
function readyState(func)
{
// DOM is ready
if(document.readyState == "interactive" || document.readyState == "complete")
{
func();
}
}

View File

@ -1,176 +0,0 @@
/****************************************************************************************************************
* CLASS geoLocation: helper-class for fetching geolocation (among others latitude, longitude) *
* implemented as singleton-object *
* *
* methods: init(void):void --> initialize object and start fetching position *
* startWatcher(void):void --> starts intervalled position-watching (ran by init) *
* getPosFromServer(void):void --> fetch GPS-position from server (actually one fixed point) *
* distance(Object{lat:Number ,lon:Number} [,Object{lat:Number ,lon:Number}]):Number *
* -> distance between two positions (or given and current one, if second one missing) *
* *
* events: body.geoLocationError:Object{ *
* error:Object{ *
* number:Number, *
* message:String *
* }, *
* position:Object{ *
* latitude:Number, *
* longitude:Number, *
* accuracy:Number, *
* timestamp:Number *
* } *
* } *
* body.geoLocationGot:Object{ *
* position:Object{ *
* latitude:Number, *
* longitude:Number, *
* accuracy:Number, *
* timestamp:Number *
* } *
* } *
****************************************************************************************************************/
var geoLocation = { // implement as singleton
latitude: null,
longitude: null,
accuracy: null,
timestamp: null,
_pwatcher: null,
_ptimer: null,
_disableWatcherAt: 50,
_config: {
enableHighAccuracy: true,
maximumAge: 10000, // 10 sec max cache lifetime (if geoposition cached)
timeout: 60000 // 60 sec timeout
},
// event listeners
_geoReady: function(position) {
console.debug('geolocation api present - activate watcher');
// this.startWatcher();
// this._gotLocation(position);
},
_geoError: function(error) {
console.debug('gelocation api not present or error');
this._handle_error(error);
},
_gotLocation: function(position) {
this.latitude = position.coords.latitude;
this.longitude = position.coords.longitude;
this.accuracy = position.coords.accuracy;
this.timestamp = position.timestamp;
if (this.accuracy <= this._disableWatcherAt) // disable with timeout, if accurate location found
{
console.debug('disable watcher for 60 seconds', this._pwatcher);
if (this._pwatcher !== null)
{
console.debug('watcher cleared');
navigator.geolocation.clearWatch(this._pwatcher);
this._pwatcher = null;
}
that = this;
this._ptimer = setTimeout(function() {that.startWatcher.apply(that, arguments);}, 60000);
}
console.debug('got Position: ' + this.latitude + ', ' + this.longitude + '(' + this.accuracy + 'm < ' + this._disableWatcherAt + ': ' + (this.accuracy <= this._disableWatcherAt) + ')');
$('body').trigger('geoLocationGot', {'position':{latitude: this.latitude, longitude: this.longitude, accuracy: this.accuracy, timestamp: this.timestamp}});
},
_handle_error: function(error) {
console.debug(error);
switch(error.code)
{
case error.PERMISSION_DENIED:
if (_pwatcher != 'null') navigator.geolocation.clearWatch(_pwatcher);
_pwatcher = 'null';
break;
case error.POSITION_UNAVAILABLE:
break;
case error.TIMEOUT:
break;
default:
break;
}
$('body').trigger('geoLocationError',{'error':error,'position':{latitude: this.latitude, longitude: this.longitude, accuracy: this.accuracy, timestamp: this.timestamp}});
},
init: function() {
var that = this;
if (navigator.geolocation) {
console.debug('test geolocation api');
navigator.geolocation.getCurrentPosition(
function() {that._geoReady.apply(that, arguments);},
function() {that._geoError.apply(that, arguments);},
this._config
);
}
},
startWatcher: function() {
console.debug('waiting for location');
that = this;
if (this._pwatcher !== null) navigator.geolocation.clearWatch(this._pwatcher);
clearTimeout(this._ptimer);
this._pwatcher = navigator.geolocation.watchPosition(
function() {that._gotLocation.apply(that, arguments);},
function() {that._handle_error.apply(that, arguments);},
this._config
);
console.debug('watcher started',this._pwatcher);
},
getPosFromServer: function() {
console.debug('get position from server');
that = this;
$.getJSON('shippos.php', function(data) {
console.debug('got server response: ',data);
that._gotLocation.call(that, data);
});
},
//haversine formula for distance between two positions in m (see http://www.movable-type.co.uk/scripts/latlong.html)
distance:function(c1,c2)
{
PI180=Math.PI / 180;
if(!c1) return false;
if(!c2){ c2={'latitude':this.latitude, 'longitude':this.longitude}; }
var dLat = (c2.latitude-c1.latitude) * PI180; // delta in rad
var dLon = (c2.longitude-c1.longitude) * PI180;
var lat1 = c1.latitude * PI180;
var lat2 = c2.latitude * PI180;
var a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.sin(dLon/2) * Math.sin(dLon/2) * Math.cos(lat1) * Math.cos(lat2);
var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return (6371000 * c); // earth-radius in m * 1000
}
}; // geoLocation
$(document).ready(function() {
$('body').on('geoLocationGot', function(e) {
$('#no-gps').slideUp({duration: 500});
});
$('body').on('geoLocationError',function(e) {
$('#no-gps').slideDown({duration: 500});
});
geoLocation.init();
});

View File

@ -1,39 +0,0 @@
/****************************************************************************************************************
* CLASS vibrate: helper-class for using vibration on mobiles *
* *
* methods: start(duration:Number or Array(Number)):void --> starts vibrating for given amount of ms *
* -> may also be an array with format: [vibrate,pause,vibrate,pause...] in ms *
* *
* stop(void):void --> stops vibrating *
* *
* objects: patterns --> predefined vibration-patterns *
****************************************************************************************************************/
var vibrate = {
start: function(duration) {
if (duration==undefined || duration==null) duration = this.patterns.long;
duration = [duration].reduce(function(a, b) { return [].concat(a).concat(b); });
if (navigator.vibrate) {
navigator.vibrate(duration);
}
console.debug('vibrate:', duration);
},
stop: function() {
if (navigator.vibrate) {
console.debug('stop vibrating');
navigator.vibrate([]);
}
},
patterns: {
micro: 50,
mini: 100,
short: 200,
long: 2000,
alarm: [100,50,100],
poi: [2000,200,2000]
}
}

24
audiojs/audio.min.js vendored
View File

@ -1,24 +0,0 @@
(function(h,o,g){var p=function(){for(var b=/audio(.min)?.js.*/,a=document.getElementsByTagName("script"),c=0,d=a.length;c<d;c++){var e=a[c].getAttribute("src");if(b.test(e))return e.replace(b,"")}}();g[h]={instanceCount:0,instances:{},flashSource:' <object classid="clsid:D27CDB6E-AE6D-11cf-96B8-444553540000" id="$1" width="1" height="1" name="$1" style="position: absolute; left: -1px;"> <param name="movie" value="$2?playerInstance='+h+'.instances[\'$1\']&datetime=$3"> <param name="allowscriptaccess" value="always"> <embed name="$1" src="$2?playerInstance='+
h+'.instances[\'$1\']&datetime=$3" width="1" height="1" allowscriptaccess="always"> </object>',settings:{autoplay:false,loop:false,preload:true,imageLocation:p+"player-graphics.gif",swfLocation:p+"audiojs.swf",useFlash:function(){var b=document.createElement("audio");return!(b.canPlayType&&b.canPlayType("audio/mpeg;").replace(/no/,""))}(),hasFlash:function(){if(navigator.plugins&&navigator.plugins.length&&navigator.plugins["Shockwave Flash"])return true;else if(navigator.mimeTypes&&navigator.mimeTypes.length){var b=
navigator.mimeTypes["application/x-shockwave-flash"];return b&&b.enabledPlugin}else try{new ActiveXObject("ShockwaveFlash.ShockwaveFlash");return true}catch(a){}return false}(),createPlayer:{markup:' <div class="play-pause"> <p class="play"></p> <p class="pause"></p> <p class="loading"></p> <p class="error"></p> </div> <div class="scrubber"> <div class="progress"></div> <div class="loaded"></div> </div> <div class="time"> <em class="played">00:00</em>/<strong class="duration">00:00</strong> </div> <div class="error-message"></div>',
playPauseClass:"play-pause",scrubberClass:"scrubber",progressClass:"progress",loaderClass:"loaded",timeClass:"time",durationClass:"duration",playedClass:"played",errorMessageClass:"error-message",playingClass:"playing",loadingClass:"loading",errorClass:"error"},css:' .audiojs audio { position: absolute; left: -1px; } .audiojs { width: 460px; height: 36px; background: #404040; overflow: hidden; font-family: monospace; font-size: 12px; background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0, #444), color-stop(0.5, #555), color-stop(0.51, #444), color-stop(1, #444)); background-image: -moz-linear-gradient(center top, #444 0%, #555 50%, #444 51%, #444 100%); -webkit-box-shadow: 1px 1px 8px rgba(0, 0, 0, 0.3); -moz-box-shadow: 1px 1px 8px rgba(0, 0, 0, 0.3); -o-box-shadow: 1px 1px 8px rgba(0, 0, 0, 0.3); box-shadow: 1px 1px 8px rgba(0, 0, 0, 0.3); } .audiojs .play-pause { width: 25px; height: 40px; padding: 4px 6px; margin: 0px; float: left; overflow: hidden; border-right: 1px solid #000; } .audiojs p { display: none; width: 25px; height: 40px; margin: 0px; cursor: pointer; } .audiojs .play { display: block; } .audiojs .scrubber { position: relative; float: left; width: 280px; background: #5a5a5a; height: 14px; margin: 10px; border-top: 1px solid #3f3f3f; border-left: 0px; border-bottom: 0px; overflow: hidden; } .audiojs .progress { position: absolute; top: 0px; left: 0px; height: 14px; width: 0px; background: #ccc; z-index: 1; background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0, #ccc), color-stop(0.5, #ddd), color-stop(0.51, #ccc), color-stop(1, #ccc)); background-image: -moz-linear-gradient(center top, #ccc 0%, #ddd 50%, #ccc 51%, #ccc 100%); } .audiojs .loaded { position: absolute; top: 0px; left: 0px; height: 14px; width: 0px; background: #000; background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0, #222), color-stop(0.5, #333), color-stop(0.51, #222), color-stop(1, #222)); background-image: -moz-linear-gradient(center top, #222 0%, #333 50%, #222 51%, #222 100%); } .audiojs .time { float: left; height: 36px; line-height: 36px; margin: 0px 0px 0px 6px; padding: 0px 6px 0px 12px; border-left: 1px solid #000; color: #ddd; text-shadow: 1px 1px 0px rgba(0, 0, 0, 0.5); } .audiojs .time em { padding: 0px 2px 0px 0px; color: #f9f9f9; font-style: normal; } .audiojs .time strong { padding: 0px 0px 0px 2px; font-weight: normal; } .audiojs .error-message { float: left; display: none; margin: 0px 10px; height: 36px; width: 400px; overflow: hidden; line-height: 36px; white-space: nowrap; color: #fff; text-overflow: ellipsis; -o-text-overflow: ellipsis; -icab-text-overflow: ellipsis; -khtml-text-overflow: ellipsis; -moz-text-overflow: ellipsis; -webkit-text-overflow: ellipsis; } .audiojs .error-message a { color: #eee; text-decoration: none; padding-bottom: 1px; border-bottom: 1px solid #999; white-space: wrap; } .audiojs .play { background: url("$1") -2px -1px no-repeat; } .audiojs .loading { background: url("$1") -2px -31px no-repeat; } .audiojs .error { background: url("$1") -2px -61px no-repeat; } .audiojs .pause { background: url("$1") -2px -91px no-repeat; } .playing .play, .playing .loading, .playing .error { display: none; } .playing .pause { display: block; } .loading .play, .loading .pause, .loading .error { display: none; } .loading .loading { display: block; } .error .time, .error .play, .error .pause, .error .scrubber, .error .loading { display: none; } .error .error { display: block; } .error .play-pause p { cursor: auto; } .error .error-message { display: block; }',
trackEnded:function(){},flashError:function(){var b=this.settings.createPlayer,a=j(b.errorMessageClass,this.wrapper),c='Missing <a href="http://get.adobe.com/flashplayer/">flash player</a> plugin.';if(this.mp3)c+=' <a href="'+this.mp3+'">Download audio file</a>.';g[h].helpers.removeClass(this.wrapper,b.loadingClass);g[h].helpers.addClass(this.wrapper,b.errorClass);a.innerHTML=c},loadError:function(){var b=this.settings.createPlayer,a=j(b.errorMessageClass,this.wrapper);g[h].helpers.removeClass(this.wrapper,
b.loadingClass);g[h].helpers.addClass(this.wrapper,b.errorClass);a.innerHTML='Error loading: "'+this.mp3+'"'},init:function(){g[h].helpers.addClass(this.wrapper,this.settings.createPlayer.loadingClass)},loadStarted:function(){var b=this.settings.createPlayer,a=j(b.durationClass,this.wrapper),c=Math.floor(this.duration/60),d=Math.floor(this.duration%60);g[h].helpers.removeClass(this.wrapper,b.loadingClass);a.innerHTML=(c<10?"0":"")+c+":"+(d<10?"0":"")+d},loadProgress:function(b){var a=this.settings.createPlayer,
c=j(a.scrubberClass,this.wrapper);j(a.loaderClass,this.wrapper).style.width=c.offsetWidth*b+"px"},playPause:function(){this.playing?this.settings.play():this.settings.pause()},play:function(){g[h].helpers.addClass(this.wrapper,this.settings.createPlayer.playingClass)},pause:function(){g[h].helpers.removeClass(this.wrapper,this.settings.createPlayer.playingClass)},updatePlayhead:function(b){var a=this.settings.createPlayer,c=j(a.scrubberClass,this.wrapper);j(a.progressClass,this.wrapper).style.width=
c.offsetWidth*b+"px";a=j(a.playedClass,this.wrapper);c=this.duration*b;b=Math.floor(c/60);c=Math.floor(c%60);a.innerHTML=(b<10?"0":"")+b+":"+(c<10?"0":"")+c}},create:function(b,a){a=a||{};return b.length?this.createAll(a,b):this.newInstance(b,a)},createAll:function(b,a){var c=a||document.getElementsByTagName("audio"),d=[];b=b||{};for(var e=0,i=c.length;e<i;e++)d.push(this.newInstance(c[e],b));return d},newInstance:function(b,a){var c=this.helpers.clone(this.settings),d="audiojs"+this.instanceCount,
e="audiojs_wrapper"+this.instanceCount;this.instanceCount++;if(b.getAttribute("autoplay")!=null)c.autoplay=true;if(b.getAttribute("loop")!=null)c.loop=true;if(b.getAttribute("preload")=="none")c.preload=false;a&&this.helpers.merge(c,a);if(c.createPlayer.markup)b=this.createPlayer(b,c.createPlayer,e);else b.parentNode.setAttribute("id",e);e=new g[o](b,c);c.css&&this.helpers.injectCss(e,c.css);if(c.useFlash&&c.hasFlash){this.injectFlash(e,d);this.attachFlashEvents(e.wrapper,e)}else c.useFlash&&!c.hasFlash&&
this.settings.flashError.apply(e);if(!c.useFlash||c.useFlash&&c.hasFlash)this.attachEvents(e.wrapper,e);return this.instances[d]=e},createPlayer:function(b,a,c){var d=document.createElement("div"),e=b.cloneNode(true);d.setAttribute("class","audiojs");d.setAttribute("className","audiojs");d.setAttribute("id",c);if(e.outerHTML&&!document.createElement("audio").canPlayType){e=this.helpers.cloneHtml5Node(b);d.innerHTML=a.markup;d.appendChild(e);b.outerHTML=d.outerHTML;d=document.getElementById(c)}else{d.appendChild(e);
d.innerHTML+=a.markup;b.parentNode.replaceChild(d,b)}return d.getElementsByTagName("audio")[0]},attachEvents:function(b,a){if(a.settings.createPlayer){var c=a.settings.createPlayer,d=j(c.playPauseClass,b),e=j(c.scrubberClass,b);g[h].events.addListener(d,"click",function(){a.playPause.apply(a)});g[h].events.addListener(e,"click",function(i){i=i.clientX;var f=this,k=0;if(f.offsetParent){do k+=f.offsetLeft;while(f=f.offsetParent)}a.skipTo((i-k)/e.offsetWidth)});if(!a.settings.useFlash){g[h].events.trackLoadProgress(a);
g[h].events.addListener(a.element,"timeupdate",function(){a.updatePlayhead.apply(a)});g[h].events.addListener(a.element,"ended",function(){a.trackEnded.apply(a)});g[h].events.addListener(a.source,"error",function(){clearInterval(a.readyTimer);clearInterval(a.loadTimer);a.settings.loadError.apply(a)})}}},attachFlashEvents:function(b,a){a.swfReady=false;a.load=function(c){a.mp3=c;a.swfReady&&a.element.load(c)};a.loadProgress=function(c,d){a.loadedPercent=c;a.duration=d;a.settings.loadStarted.apply(a);
a.settings.loadProgress.apply(a,[c])};a.skipTo=function(c){if(!(c>a.loadedPercent)){a.updatePlayhead.call(a,[c]);a.element.skipTo(c)}};a.updatePlayhead=function(c){a.settings.updatePlayhead.apply(a,[c])};a.play=function(){if(!a.settings.preload){a.settings.preload=true;a.element.init(a.mp3)}a.playing=true;a.element.pplay();a.settings.play.apply(a)};a.pause=function(){a.playing=false;a.element.ppause();a.settings.pause.apply(a)};a.setVolume=function(c){a.element.setVolume(c)};a.loadStarted=function(){a.swfReady=
true;a.settings.preload&&a.element.init(a.mp3);a.settings.autoplay&&a.play.apply(a)}},injectFlash:function(b,a){var c=this.flashSource.replace(/\$1/g,a);c=c.replace(/\$2/g,b.settings.swfLocation);c=c.replace(/\$3/g,+new Date+Math.random());var d=b.wrapper.innerHTML,e=document.createElement("div");e.innerHTML=c+d;b.wrapper.innerHTML=e.innerHTML;b.element=this.helpers.getSwf(a)},helpers:{merge:function(b,a){for(attr in a)if(b.hasOwnProperty(attr)||a.hasOwnProperty(attr))b[attr]=a[attr]},clone:function(b){if(b==
null||typeof b!=="object")return b;var a=new b.constructor,c;for(c in b)a[c]=arguments.callee(b[c]);return a},addClass:function(b,a){RegExp("(\\s|^)"+a+"(\\s|$)").test(b.className)||(b.className+=" "+a)},removeClass:function(b,a){b.className=b.className.replace(RegExp("(\\s|^)"+a+"(\\s|$)")," ")},injectCss:function(b,a){for(var c="",d=document.getElementsByTagName("style"),e=a.replace(/\$1/g,b.settings.imageLocation),i=0,f=d.length;i<f;i++){var k=d[i].getAttribute("title");if(k&&~k.indexOf("audiojs")){f=
d[i];if(f.innerHTML===e)return;c=f.innerHTML;break}}d=document.getElementsByTagName("head")[0];i=d.firstChild;f=document.createElement("style");if(d){f.setAttribute("type","text/css");f.setAttribute("title","audiojs");if(f.styleSheet)f.styleSheet.cssText=c+e;else f.appendChild(document.createTextNode(c+e));i?d.insertBefore(f,i):d.appendChild(styleElement)}},cloneHtml5Node:function(b){var a=document.createDocumentFragment(),c=a.createElement?a:document;c.createElement("audio");c=c.createElement("div");
a.appendChild(c);c.innerHTML=b.outerHTML;return c.firstChild},getSwf:function(b){b=document[b]||window[b];return b.length>1?b[b.length-1]:b}},events:{memoryLeaking:false,listeners:[],addListener:function(b,a,c){if(b.addEventListener)b.addEventListener(a,c,false);else if(b.attachEvent){this.listeners.push(b);if(!this.memoryLeaking){window.attachEvent("onunload",function(){if(this.listeners)for(var d=0,e=this.listeners.length;d<e;d++)g[h].events.purge(this.listeners[d])});this.memoryLeaking=true}b.attachEvent("on"+
a,function(){c.call(b,window.event)})}},trackLoadProgress:function(b){if(b.settings.preload){var a,c;b=b;var d=/(ipod|iphone|ipad)/i.test(navigator.userAgent);a=setInterval(function(){if(b.element.readyState>-1)d||b.init.apply(b);if(b.element.readyState>1){b.settings.autoplay&&b.play.apply(b);clearInterval(a);c=setInterval(function(){b.loadProgress.apply(b);b.loadedPercent>=1&&clearInterval(c)})}},10);b.readyTimer=a;b.loadTimer=c}},purge:function(b){var a=b.attributes,c;if(a)for(c=0;c<a.length;c+=
1)if(typeof b[a[c].name]==="function")b[a[c].name]=null;if(a=b.childNodes)for(c=0;c<a.length;c+=1)purge(b.childNodes[c])},ready:function(){return function(b){var a=window,c=false,d=true,e=a.document,i=e.documentElement,f=e.addEventListener?"addEventListener":"attachEvent",k=e.addEventListener?"removeEventListener":"detachEvent",n=e.addEventListener?"":"on",m=function(l){if(!(l.type=="readystatechange"&&e.readyState!="complete")){(l.type=="load"?a:e)[k](n+l.type,m,false);if(!c&&(c=true))b.call(a,l.type||
l)}},q=function(){try{i.doScroll("left")}catch(l){setTimeout(q,50);return}m("poll")};if(e.readyState=="complete")b.call(a,"lazy");else{if(e.createEventObject&&i.doScroll){try{d=!a.frameElement}catch(r){}d&&q()}e[f](n+"DOMContentLoaded",m,false);e[f](n+"readystatechange",m,false);a[f](n+"load",m,false)}}}()}};g[o]=function(b,a){this.element=b;this.wrapper=b.parentNode;this.source=b.getElementsByTagName("source")[0]||b;this.mp3=function(c){var d=c.getElementsByTagName("source")[0];return c.getAttribute("src")||
(d?d.getAttribute("src"):null)}(b);this.settings=a;this.loadStartedCalled=false;this.loadedPercent=0;this.duration=1;this.playing=false};g[o].prototype={updatePlayhead:function(){this.settings.updatePlayhead.apply(this,[this.element.currentTime/this.duration])},skipTo:function(b){if(!(b>this.loadedPercent)){this.element.currentTime=this.duration*b;this.updatePlayhead()}},load:function(b){this.loadStartedCalled=false;this.source.setAttribute("src",b);this.element.load();this.mp3=b;g[h].events.trackLoadProgress(this)},
loadError:function(){this.settings.loadError.apply(this)},init:function(){this.settings.init.apply(this)},loadStarted:function(){if(!this.element.duration)return false;this.duration=this.element.duration;this.updatePlayhead();this.settings.loadStarted.apply(this)},loadProgress:function(){if(this.element.buffered!=null&&this.element.buffered.length){if(!this.loadStartedCalled)this.loadStartedCalled=this.loadStarted();this.loadedPercent=this.element.buffered.end(this.element.buffered.length-1)/this.duration;
this.settings.loadProgress.apply(this,[this.loadedPercent])}},playPause:function(){this.playing?this.pause():this.play()},play:function(){/(ipod|iphone|ipad)/i.test(navigator.userAgent)&&this.element.readyState==0&&this.init.apply(this);if(!this.settings.preload){this.settings.preload=true;this.element.setAttribute("preload","auto");g[h].events.trackLoadProgress(this)}this.playing=true;this.element.play();this.settings.play.apply(this)},pause:function(){this.playing=false;this.element.pause();this.settings.pause.apply(this)},
setVolume:function(b){this.element.volume=b},trackEnded:function(){this.skipTo.apply(this,[0]);this.settings.loop||this.pause.apply(this);this.settings.trackEnded.apply(this)}};var j=function(b,a){var c=[];a=a||document;if(a.getElementsByClassName)c=a.getElementsByClassName(b);else{var d,e,i=a.getElementsByTagName("*"),f=RegExp("(^|\\s)"+b+"(\\s|$)");d=0;for(e=i.length;d<e;d++)f.test(i[d].className)&&c.push(i[d])}return c.length>1?c:c[0]}})("audiojs","audiojsInstance",this);

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

0
data/.gitkeep Normal file
View File

View File

@ -1,43 +0,0 @@
<?php
require_once('db.inc.php');
$ret_data = Array();
$get = trim(strtolower($_GET['get']));
$id = intval($_GET['id']);
switch($get)
{
case 'routes':
$data = db_select('routes',Array('idx','name','image','languages'),['idx'=>'<=2'],Array('name'));
//echo utf8_decode(print_r($data, true));
//$ret_data = $data;
foreach($data as $row)
{
// format: {id:0, name:'', image:'', languages:[], pois:[{lat:0, lon:0, desc:'', range:0, audio:0}]}}}
$line = Array('id'=>($row['idx']), 'name'=>('Fahrtrichtung '.preg_replace('/.*\(([a-zäöüß]+)\)/i', '$1', $row['name'])), 'image'=>($row['image']), 'languages'=>preg_split('/[, |]+/',($row['languages'])), pois=>Array());
// print(print_r($line, true)."<br/>\n");
$ret_data[] = $line;
}
break;
case 'pois':
$data = db_select('trkpts',Array('poi_id','file_id','GREATEST(`trkpts.range`,`pois.range`) AS range', 'desc', 'lat','lon', 'display_text_de'), Array('route_id'=>$id), null, Array('pois'=>Array('poi_id','idx')));
foreach($data as $row)
{
// format: [{lat:0, lon:0, desc:'', range:0, audio:0}]
$ret_data[$row['poi_id']] = Array(
'lat'=>utf8_decode($row['lat']),
'lon'=>utf8_decode($row['lon']),
'desc'=>utf8_decode($row['desc']),
'range'=>utf8_decode($row['range']),
'text'=>$row['display_text_de'],
'audio'=>utf8_decode($row['file_id'])
);
}
break;
}
echo(json_encode($ret_data));
//exit("<br />\n".json_last_error());

63
database/schema.sql Normal file
View File

@ -0,0 +1,63 @@
CREATE TABLE IF NOT EXISTS routes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
school_name TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'deleted')),
gpx_path TEXT NOT NULL,
start_lat REAL,
start_lon REAL,
center_lat REAL,
center_lon REAL,
min_lat REAL,
min_lon REAL,
max_lat REAL,
max_lon REAL,
distance_m REAL NOT NULL DEFAULT 0,
elevation_gain_m REAL NOT NULL DEFAULT 0,
point_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TEXT,
trash_path TEXT
) STRICT;
CREATE TABLE IF NOT EXISTS route_points (
id INTEGER PRIMARY KEY AUTOINCREMENT,
route_id INTEGER NOT NULL REFERENCES routes(id),
sequence INTEGER NOT NULL,
lat REAL NOT NULL,
lon REAL NOT NULL,
elevation REAL,
recorded_at TEXT,
UNIQUE(route_id, sequence)
) STRICT;
CREATE TABLE IF NOT EXISTS pois (
id INTEGER PRIMARY KEY AUTOINCREMENT,
route_id INTEGER NOT NULL REFERENCES routes(id),
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
lat REAL NOT NULL,
lon REAL NOT NULL,
trigger_radius_m REAL NOT NULL DEFAULT 80,
sequence INTEGER NOT NULL DEFAULT 0,
audio_path TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
) STRICT;
CREATE TABLE IF NOT EXISTS poi_images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
poi_id INTEGER NOT NULL REFERENCES pois(id),
path TEXT NOT NULL,
caption TEXT NOT NULL DEFAULT '',
sequence INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
) STRICT;
CREATE INDEX IF NOT EXISTS idx_routes_status ON routes(status);
CREATE INDEX IF NOT EXISTS idx_route_points_route ON route_points(route_id, sequence);
CREATE INDEX IF NOT EXISTS idx_pois_route ON pois(route_id, sequence);
CREATE INDEX IF NOT EXISTS idx_poi_images_poi ON poi_images(poi_id, sequence);

View File

@ -1,114 +0,0 @@
<?php
define('DB_HOST','mysql1.mediaproject.de');
define('DB_USER','c30_sds_api');
define('DB_PASS','sBF77apxdcpfwJcAk28o');
define('DB_DATABASE','c30_sds_api');
$DB=new PDO('mysql:host='.DB_HOST.';dbname='.DB_DATABASE.';chatset=utf-8',DB_USER,DB_PASS);
$DB->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION );
$DB->query('SET NAMES \'utf8\'');
function db_select($table, $data, $where=[], $order=[], $join=[], $debug=false)
{
global $DB;
$STH = "";
if(gettype($data)=='string') $data = array($data);
$data = '`'.implode('`,`', $data).'`';
$data = str_replace('`*`','*', $data);
if(!empty($where) && is_array($where))
{
$cond = [];
foreach($where as $key=>$value) { $cond[] = "`$key`='$value'"; }
$where = implode(' AND ', $cond);
$where = str_replace('=\'!', '!=\'',$where);
$where = str_replace('AND `|', 'OR `', $where);
$where = preg_replace('#=\s*[\'\"]([<>=]+)(.*?)[\'\"]#', '$1 \'$2\'', $where);
}
if(!empty($order) && is_array($order))
{
// format: ['field1'=>'direction','field2'=>'direction'...] OR ['field1', 'field2'...]
$cond = [];
foreach($order as $field=>$direction) {
if(is_numeric($field)) {
$field = $direction;
$direction='ASC';
}
$cond[] = $field.' '.$direction;
}
$order = implode(',',$cond);
}
if(!empty($join) && is_array($join))
{
// format: ['table1'=>['field-of-source','field-of-table1'], 'table2'=>['field-of-source','field-of-table2']]
$cond = [];
foreach($join as $key=>$value)
{
if(is_array($value) && count($value)==2)
{
$cond[] = str_replace('~', $key, ' JOIN '.$key.' ON '.$table.'.'.implode(' = ~.',$value));
}
}
$join = implode('',$cond);
}
$data = preg_replace('#`([^`]+?)\.([^`]+?)`#', '$1.`$2`', $data);
$data = preg_replace('#`(.+?) as (.*?)`#i','`$1` AS `$2`', $data);
$data = preg_replace('#`([a-z0-9]+\(.*?\))`#i', '$1', $data);
$where = preg_replace('#`([^`]+?)\.([^`]+?)`#', '$1.`$2`', $where);
$order = preg_replace('#`([^`]+?)\.([^`]+?)`#', '$1.`$2`', $order);
$join = preg_replace('#`([^`]+?)\.([^`]+?)`#', '$1.`$2`', $join);
if(empty($where)) $where = '';
if(empty($order)) $order = '';
if(empty($join)) $join = '';
$sql = 'SELECT '.$data.' FROM '.$table.$join.(!empty($where) ? ' WHERE '.$where : '').(!empty($order) ? ' ORDER BY '.$order : '');
//exit($sql);
try{
$STH = $DB->prepare($sql);
$STH->execute();
$retVal = $STH->fetchAll(PDO::FETCH_ASSOC);
if($debug===true)
{
$dump = print_r($STH, true).": ".print_r($retVal, true);
print($dump.'<br>');
file_put_contents("query_debug.txt", $dump."\r\n", FILE_APPEND);
}
return $retVal;
}
catch(PDOException $err)
{
print_r($STH); print('<br/>');
print_r($err);
die();
}
}
function getOptions($data, $debug=false)
{
global $DB;
$STH = "";
$sql = 'SELECT * FROM options WHERE name IN (\''.implode('\',\'',$data).'\');';
try {
$STH = $DB->prepare($sql);
$STH->execute($data);
if($debug===true)
{
$dump = print_r($STH);
print($dump); print('<br>');
file_put_contents("query_debug.txt", $dump."\r\n", FILE_APPEND);
}
return $STH->fetchAll(PDO::FETCH_ASSOC);
} catch(Exception $err) {
print_r($STH); print('<br/>');
print_r($err);
}
}

View File

@ -0,0 +1,43 @@
# Checkliste vor der öffentlichen Bereitstellung
Die in der Anwendung enthaltenen Texte sind Vorlagen und ersetzen keine Prüfung des konkreten Betriebsmodells. Vor der Veröffentlichung sind mindestens die folgenden Punkte zu bearbeiten.
## Betreiber und Impressum
- vollständiger Name und Rechtsform der Betreiberin
- vertretungsberechtigte Person
- ladungsfähige Anschrift
- E-Mail-Adresse und weiterer unmittelbarer Kontaktweg
- inhaltlich verantwortliche Person
- gegebenenfalls Aufsichtsbehörde, Register und steuerliche Pflichtangaben
## Datenschutz
- Verantwortliche Stelle und Datenschutzkontakt
- Hostinganbieter und Ort der Verarbeitung
- Art und Dauer der Serverprotokollierung
- Rechtsgrundlagen für Serverbetrieb und freiwillige Browser-Speicherung
- zuständige Datenschutzaufsichtsbehörde
- Verfahren für Auskunft, Berichtigung, Löschung und Widerruf
- Auftragsverarbeitungsvertrag mit dem Hostinganbieter, soweit erforderlich
## Inhalte von Kindern und Dritten
- Einwilligungen der Sorgeberechtigten für erkennbare Kinder
- Freigaben für Sprachaufnahmen und Namensnennungen
- dokumentierte Nutzungsrechte an Bildern, Texten, Tonaufnahmen und Routendaten
- redaktionelle Prüfung vor Veröffentlichung
- festgelegtes Verfahren für Korrektur- und Löschanfragen
## Technischer Betrieb
- ausschließlich HTTPS für den öffentlichen Zugriff
- Prüfung der Reverse-Proxy-, Zugriffs- und Sicherheitsheader
- Schutz aller schreibenden API-Endpunkte
- regelmäßige Aktualisierung der lokalen JavaScript-Abhängigkeiten
- dokumentierte Datensicherung und Wiederherstellung
- Prüfung, dass keine zusätzlichen Cookies, externen Skripte, Schriftarten oder Trackingdienste durch Hosting oder Reverse Proxy ergänzt werden
## Konfiguration der Vorlagentexte
In `public/index.html` sind alle mit eckigen Klammern gekennzeichneten Platzhalter zu ersetzen. Nicht benötigte Abschnitte sind zu entfernen; zusätzliche gesetzliche oder organisatorische Pflichtangaben sind zu ergänzen.

866
docs/REST-API.md Normal file
View File

@ -0,0 +1,866 @@
# Wegwichtel REST-API
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
### Basisadressen
Lokaler Standard:
```text
http://127.0.0.1:47145
```
API-Basis:
```text
http://127.0.0.1:47145/api
```
Bei einer Nginx-Installation bleibt der Pfad gleich, beispielsweise:
```text
https://wegwichtel.example.org/api
```
### Formate
- Listen- und Metadatenzugriffe liefern JSON.
- Die Einzelendpunkte für GPX, Bilder und Audio liefern die jeweilige Datei direkt aus. Mit `?metadata=true` liefern die Bild- und Audioendpunkte stattdessen JSON-Metadaten.
- Schreibzugriffe ohne Dateien akzeptieren `application/json`, `application/x-www-form-urlencoded` oder `multipart/form-data`.
- GPX-Dateien werden ausschließlich als `multipart/form-data` übertragen.
- Bild- und Audioendpunkte akzeptieren wahlweise `multipart/form-data` oder ein JSON-Objekt mit Base64-kodierten Dateidaten.
- Der Server ermittelt den tatsächlichen MIME-Typ aus dem Dateiinhalt. Dateiname, Dateiendung, Multipart-`Content-Type`, JSON-Felder und Data-URL-Präfixe werden nicht als Typnachweis verwendet.
- Für JSON wird `Content-Type: application/json` empfohlen. `Content-Type: text/json` wird aus Kompatibilitätsgründen ebenfalls akzeptiert.
- Ein leeres JSON-Objekt (`{}`) enthält keine Datei und kann deshalb keinen Bild- oder Audio-Upload ausführen.
- Pro Medien-Request wird genau eine Bild- beziehungsweise Audiodatei verarbeitet.
- Base64 vergrößert die Requestgröße um ungefähr ein Drittel und benötigt beim Verarbeiten zusätzlichen Arbeitsspeicher. Für große Dateien ist `multipart/form-data` vorzuziehen.
- Zeitstempel werden als SQLite- oder ISO-8601-Text ausgegeben.
- Die Node.js-Anwendung besitzt keine eigene Authentifizierung. Ein vorgeschalteter Webserver kann Basic Auth erzwingen; die mitgelieferten Python-Werkzeuge erkennen `401 Unauthorized`, fragen Zugangsdaten ab und wiederholen den Request.
### Uploadgrenzen und Dateitypen
Das Dateilimit pro Datei wird durch `MAX_UPLOAD_MB` festgelegt und beträgt standardmäßig `50 MB`.
Unterstützte Dateitypen:
| Ressource | Dateiendungen beziehungsweise MIME-Typen |
|---|---|
| GPX | GPX-XML mit `<gpx>`-Wurzelelement |
| Bilder | JPEG, PNG, WebP |
| Audio | MP3, MP4/M4A, AAC, Ogg, WAV, WebM |
Die Uploadfelder heißen:
| Ressource | Multipart-Feld beziehungsweise JSON-Eigenschaft |
|---|---|
| GPX | `gpx` |
| einzelnes Bild | `picture` |
| einzelne Audiodatei | `audio` |
Bei einem JSON-Medienupload ist `picture` beziehungsweise `audio` ein Objekt:
| Eigenschaft | Typ | Pflicht | Beschreibung |
|---|---|---:|---|
| `filename` | Text | nein | ursprünglicher Dateiname für Protokollierung; die Endung wird nicht zur Typbestimmung verwendet |
| `base64` | Text | ja, sofern `dataUrl` fehlt | reiner Base64-Inhalt ohne Präfix |
| `dataUrl` | Text | alternativ zu `base64` | vollständige Base64-Data-URL; der dort genannte Medientyp wird ignoriert |
`base64` und `dataUrl` sind Alternativen. Der Server erkennt Format und Speicherendung ausschließlich anhand der dekodierten Bytes. Ein vom Client mitgesendetes `contentType`- oder `mimeType`-Feld wird ignoriert und ist nicht erforderlich.
### Inhaltsbasierte Dateityperkennung
Binäre Bild- und Audiodateien werden mit `file-type` anhand ihrer Magic Bytes analysiert. Für Audio-/Video-Container wird zusätzlich `@file-type/av` verwendet, damit beispielsweise M4A und WebM möglichst zuverlässig als Audio oder Video unterschieden werden. GPX ist ein textbasiertes XML-Format und wird deshalb separat als UTF-8 gelesen und anhand des `<gpx>`-Wurzelelements validiert; anschließend übernimmt der vorhandene GPX-Parser die fachliche Prüfung.
Daraus folgen zwei wichtige Regeln:
- Eine als `image/png` deklarierte Textdatei wird mit `415 Unsupported Media Type` abgewiesen.
- Eine echte PNG-Datei wird auch dann akzeptiert, wenn sie `datei.bin` heißt oder im Multipart-Request kein Datei-`Content-Type` angegeben ist.
### Fehlerformat
```json
{
"error": "HttpError",
"message": "Strecke nicht gefunden."
}
```
Typische Statuscodes:
| Status | Bedeutung |
|---:|---|
| `200` | Anfrage erfolgreich |
| `201` | Ressource wurde angelegt |
| `400` | Parameter oder Upload fehlt beziehungsweise ist ungültig |
| `401` | vorgeschalteter Webserver verlangt Authentifizierung |
| `404` | Route, POI oder Medienressource wurde nicht gefunden |
| `409` | Ressource existiert bereits oder Dateisystemzustand verhindert die Operation |
| `413` | Datei überschreitet `MAX_UPLOAD_MB` |
| `415` | Dateityp wird nicht unterstützt |
| `500` | Interner Serverfehler |
### IDs
Alle IDs sind positive, von SQLite erzeugte Ganzzahlen.
- `:id` bezeichnet bei `/routes/:id/...` die Route.
- `:pictureId` bezeichnet einen Datensatz aus `poi_images`.
- `:poiId` bezeichnet den POI und zugleich seine höchstens eine Audioressource.
Beispiele verwenden überwiegend Route `1`, POI `7` und Bild `15`.
## 2. Datenmodelle
### Route
```json
{
"id": 1,
"slug": "schulwald-runde",
"name": "Schulwald-Runde",
"description": "Naturkundlicher Rundweg",
"schoolName": "Beispielschule",
"status": "active",
"start": { "lat": 52.5208, "lon": 13.407 },
"center": { "lat": 52.521, "lon": 13.408 },
"bounds": {
"minLat": 52.5208,
"minLon": 13.407,
"maxLat": 52.5212,
"maxLon": 13.409
},
"distanceM": 842.6,
"elevationGainM": 14.2,
"pointCount": 87,
"gpxUrl": "/api/routes/1/gpx",
"createdAt": "2026-06-16 12:00:00",
"updatedAt": "2026-06-16 12:00:00",
"deletedAt": null,
"proximityM": 324.8
}
```
### POI
```json
{
"id": 7,
"routeId": 1,
"title": "Die alte Eiche",
"description": "Hier wird das Alter der Eiche erklärt.",
"lat": 52.5208,
"lon": 13.407,
"triggerRadiusM": 60,
"sequence": 2,
"audioUrl": "/api/routes/1/pois/7/audio",
"images": [
{
"id": 15,
"caption": "Blick auf die Baumkrone",
"sequence": 0,
"url": "/api/routes/1/pois/7/pictures/15"
}
]
}
```
### Bildressource
```json
{
"id": 15,
"routeId": 1,
"poiId": 7,
"poiTitle": "Die alte Eiche",
"caption": "Blick auf die Baumkrone",
"sequence": 0,
"url": "/api/routes/1/pois/7/pictures/15",
"createdAt": "2026-06-16 12:30:00"
}
```
### 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/pois/7/audio",
"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 |
| `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 |
| `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/routes/:routeId/pois/:poiId` | einzelnen POI der Route lesen |
| `POST` | `/api/routes/:id/pois` | POI-Metadaten anlegen |
| `PUT` | `/api/routes/:routeId/pois/:poiId` | POI-Metadaten aktualisieren |
| `DELETE` | `/api/routes/:routeId/pois/:poiId` | POI einschließlich Bildern und Audio löschen |
| `GET` | `/api/routes/:routeId/pois/:poiId/pictures` | Bilder eines POIs auflisten |
| `GET` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` | Bilddatei ausliefern; optional Metadaten mit `?metadata=true` |
| `POST` | `/api/routes/:routeId/pois/:poiId/pictures` | einzelnes Bild für den POI hochladen |
| `PUT` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` | Bilddatei oder Metadaten aktualisieren |
| `DELETE` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` | einzelnes Bild löschen |
| `GET` | `/api/routes/:routeId/pois/:poiId/audio` | Audiodatei ausliefern; optional Metadaten mit `?metadata=true` |
| `POST` | `/api/routes/:routeId/pois/:poiId/audio` | Audiodatei für den POI anlegen |
| `PUT` | `/api/routes/:routeId/pois/:poiId/audio` | Audiodatei des POIs ersetzen |
| `DELETE` | `/api/routes/:routeId/pois/:poiId/audio` | Audiodatei des POIs löschen |
## 4. Systemzustand
### `GET /api/health`
Parameter: keine.
```bash
curl http://127.0.0.1:47145/api/health
```
```json
{
"ok": true,
"service": "wegwichtel",
"socket": "127.0.0.1:47145",
"sqliteVersion": "3.46.1",
"timestamp": "2026-06-16T12:00:00.000Z"
}
```
## 5. Routen lesen
### `GET /api/routes`
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
| Parameter | Typ | Pflicht | Standard | Beschreibung |
|---|---|---:|---:|---|
| `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:
```bash
curl http://127.0.0.1:47145/api/routes
```
Mit allen Query-Parametern:
```bash
curl --get http://127.0.0.1:47145/api/routes \
--data-urlencode 'lat=52.5208' \
--data-urlencode 'lon=13.4070' \
--data-urlencode 'radiusKm=12.5' \
--data-urlencode 'includeDeleted=true'
```
### `GET /api/routes/:id`
Liefert Route, GPX-Punkte und POIs einschließlich Medien-URLs.
#### Pfadparameter
| Parameter | Typ | Pflicht | Beschreibung |
|---|---|---:|---|
| `id` | Ganzzahl | ja | Route |
#### Query-Parameter
| Parameter | Typ | Pflicht | Standard | Beschreibung |
|---|---|---:|---:|---|
| `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
```
```bash
curl 'http://127.0.0.1:47145/api/routes/1?includeDeleted=true'
```
### `GET /api/routes/:id/gpx`
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.
```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; der Dateiname ist unerheblich |
| `name` | Text | bedingt | Name aus GPX | erforderlich, wenn GPX keinen Namen enthält |
| `slug` | Text | nein | aus `name` | interne URL-freundliche Kennung |
| `description` | Text | nein | leer | Routenbeschreibung |
| `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-klasse-7a' \
-F 'description=Naturkundlicher Rundweg der Klasse 7a' \
-F 'schoolName=Beispielschule' \
-F 'gpx=@examples/sample-route.gpx'
```
Erfolg: `201 Created` und `Location: /api/routes/<id>`.
### `PUT /api/routes/:id`
Aktualisiert Metadaten. Eine optionale GPX-Datei ersetzt alle bisherigen Trackpunkte.
| Feld | Typ | Pflicht | Verhalten ohne Feld |
|---|---|---:|---|
| `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 Strecke' \
-F 'schoolName=Beispielschule' \
-F 'gpx=@route-neu.gpx'
```
### `POST /api/routes/:id/append`
Hängt alle Trackpunkte einer GPX-Datei an die Route an und berechnet Streckenwerte neu.
| Feld | Typ | Pflicht | Beschreibung |
|---|---|---:|---|
| `gpx` | Datei | ja | anzuhängende GPX-Datei |
```bash
curl -X POST http://127.0.0.1:47145/api/routes/1/append \
-F 'gpx=@verlaengerung.gpx'
```
## 7. POIs lesen und bearbeiten
### `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/routes/:routeId/pois/:poiId`
Liefert einen einzelnen POI einschließlich seiner aktuellen Bild- und Audio-URLs. Route und POI werden gemeinsam geprüft; der POI muss zur angegebenen aktiven Route gehören.
| Pfadparameter | Typ | Pflicht | Beschreibung |
|---|---|---:|---|
| `routeId` | Ganzzahl | ja | ID der aktiven Route |
| `poiId` | Ganzzahl | ja | ID des POIs innerhalb dieser Route |
```bash
curl http://127.0.0.1:47145/api/routes/1/pois/7
```
Eine unbekannte Route, ein unbekannter POI oder eine falsche Route-POI-Kombination liefert `404 Not Found`.
### `POST /api/routes/:id/pois`
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` | Stationsname |
| `description` | Text | nein | leer | Beschreibung |
| `lat` | Dezimalzahl | ja | | Breitengrad |
| `lon` | Dezimalzahl | ja | | Längengrad |
| `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": "Hier wird das Alter der Eiche erklärt.",
"lat": 52.5208,
"lon": 13.4070,
"triggerRadiusM": 60,
"sequence": 2
}'
```
Erfolg: `201 Created` und `Location: /api/routes/<routeId>/pois/<poiId>`.
### `PUT /api/routes/:routeId/pois/:poiId`
Aktualisiert ausschließlich POI-Metadaten. Nicht übergebene Werte bleiben erhalten. Route und POI werden gemeinsam validiert; ein POI kann über diesen Endpunkt keiner anderen Route zugeordnet werden.
| Pfadparameter | Typ | Pflicht | Beschreibung |
|---|---|---:|---|
| `routeId` | Ganzzahl | ja | ID der aktiven Route |
| `poiId` | Ganzzahl | ja | ID des POIs innerhalb dieser Route |
| 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/routes/1/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": 3
}'
```
### `DELETE /api/routes/:routeId/pois/:poiId`
Löscht den POI-Datensatz sowie alle zugehörigen Bilddatensätze, Bilddateien und die optionale Audiodatei. Dieser Vorgang ist im Gegensatz zum Soft Delete einer vollständigen Route nicht wiederherstellbar.
```bash
curl -X DELETE http://127.0.0.1:47145/api/routes/1/pois/7
```
```json
{
"id": 7,
"routeId": 1,
"deleted": true
}
```
## 8. Bilder eines POIs einzeln verwalten
Die Route und der POI sind Bestandteil jedes Bildpfades. Dadurch ist die Zuordnung eindeutig und beim Upload muss keine zusätzliche `poiId` übergeben werden.
### Gemeinsame Pfadparameter
| Parameter | Typ | Pflicht | Beschreibung |
|---|---|---:|---|
| `routeId` | positive Ganzzahl | ja | ID der aktiven Route |
| `poiId` | positive Ganzzahl | ja | ID eines POIs, der zu dieser Route gehört |
| `pictureId` | positive Ganzzahl | nur bei Einzelressourcen | ID des Bildes, das zu diesem POI gehört |
### `GET /api/routes/:routeId/pois/:poiId/pictures`
Liefert alle Bilder des angegebenen POIs in Diashow-Reihenfolge.
```bash
curl http://127.0.0.1:47145/api/routes/1/pois/7/pictures
```
```json
{
"pictures": [
{
"id": 15,
"routeId": 1,
"poiId": 7,
"poiTitle": "Die alte Eiche",
"caption": "Blick auf die Baumkrone",
"sequence": 0,
"url": "/api/routes/1/pois/7/pictures/15",
"createdAt": "2026-06-16 12:30:00"
}
]
}
```
### `GET /api/routes/:routeId/pois/:poiId/pictures/:pictureId`
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/pois/7/pictures/15 \
--output bild-15.jpg
```
Metadaten statt Dateidaten abrufen:
```bash
curl --get http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 \
--data-urlencode 'metadata=true'
```
| Query-Parameter | Typ | Pflicht | Standard | Beschreibung |
|---|---|---:|---:|---|
| `metadata` | Boolean | nein | `false` | bei `true` JSON-Metadaten statt der Bilddatei liefern |
Die API antwortet mit `404`, wenn Route, POI oder Bild nicht zusammengehören.
### `POST /api/routes/:routeId/pois/:poiId/pictures`
Lädt genau ein Bild für den im Pfad angegebenen POI hoch. Zulässig sind zwei Übertragungsformen.
#### Variante A: `multipart/form-data`
| Feld | Typ | Pflicht | Standard | Beschreibung |
|---|---|---:|---:|---|
| `picture` | Datei | ja | | JPEG-, PNG- oder WebP-Datei; der Typ wird aus dem Inhalt erkannt |
| `caption` | Text | nein | leer | sichtbare Bildbeschreibung und Grundlage für den Alternativtext |
| `sequence` | nichtnegative Ganzzahl | nein | nächster freier Wert des POIs | Reihenfolge in der Diashow |
```bash
curl -X POST http://127.0.0.1:47145/api/routes/1/pois/7/pictures \
-F 'caption=Blick auf die Baumkrone' \
-F 'sequence=0' \
-F 'picture=@eiche.jpg'
```
#### Variante B: JSON mit Base64
Empfohlener Content-Type: `application/json`. Der Server akzeptiert zusätzlich `text/json`.
```json
{
"caption": "Blick auf die Baumkrone",
"sequence": 0,
"picture": {
"filename": "eiche.jpg",
"base64": "/9j/4AAQSkZJRgABAQ..."
}
}
```
Beispiel mit `text/json` und einer separat erzeugten Payload-Datei:
```bash
base64 < eiche.jpg | tr -d '\n' > eiche.jpg.b64
jq -n \
--arg caption 'Blick auf die Baumkrone' \
--argjson sequence 0 \
--rawfile data eiche.jpg.b64 \
'{
caption: $caption,
sequence: $sequence,
picture: {
filename: "eiche.jpg",
base64: $data
}
}' > picture.json
curl -X POST http://127.0.0.1:47145/api/routes/1/pois/7/pictures \
-H 'Content-Type: text/json' \
--data-binary @picture.json
```
Alternativ kann eine Data-URL übertragen werden:
```json
{
"caption": "Blick auf die Baumkrone",
"picture": {
"filename": "eiche.png",
"dataUrl": "data:image/png;base64,iVBORw0KGgoAAA..."
}
}
```
Ein Request mit `-d '{}'` schlägt mit `400 Bad Request` fehl, weil weder eine Multipart-Datei noch ein JSON-Dateiobjekt enthalten ist.
Erfolg: `201 Created` und `Location: /api/routes/1/pois/7/pictures/<pictureId>`.
### `PUT /api/routes/:routeId/pois/:poiId/pictures/:pictureId`
Aktualisiert Metadaten und kann optional die Datei ersetzen. Nicht übergebene Metadaten bleiben erhalten. Ein Bild kann über diesen Endpunkt nicht einem anderen POI zugeordnet werden; dafür muss es beim bisherigen POI gelöscht und beim Ziel-POI neu angelegt werden.
| Feld | Typ | Pflicht | Beschreibung |
|---|---|---:|---|
| `picture` | Multipart-Datei oder JSON-Dateiobjekt | nein | ersetzt die bisherige Bilddatei |
| `caption` | Text | nein | neue Bildbeschreibung; leerer Text entfernt die Beschreibung |
| `sequence` | nichtnegative Ganzzahl | nein | neue Position in der Diashow |
Nur Metadaten per JSON ändern:
```bash
curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 \
-H 'Content-Type: application/json' \
-d '{
"caption": "Nahaufnahme der Eichenblätter",
"sequence": 1
}'
```
Datei und Metadaten per Multipart ersetzen:
```bash
curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 \
-F 'caption=Neue Aufnahme der Eiche' \
-F 'sequence=2' \
-F 'picture=@eiche-neu.webp'
```
Datei und Metadaten per JSON ersetzen:
```json
{
"caption": "Neue Aufnahme der Eiche",
"sequence": 2,
"picture": {
"filename": "eiche-neu.webp",
"base64": "UklGRiQAAABXRUJQVlA4..."
}
}
```
```bash
curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 \
-H 'Content-Type: application/json' \
--data-binary @picture-update.json
```
Wird eine Datei ersetzt, entfernt der Server die bisherige Datei nach erfolgreicher Datenbankaktualisierung.
### `DELETE /api/routes/:routeId/pois/:poiId/pictures/:pictureId`
Entfernt Bilddatensatz und Datei.
```bash
curl -X DELETE http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15
```
```json
{
"id": 15,
"routeId": 1,
"poiId": 7,
"deleted": true
}
```
## 9. Audiodatei eines POIs verwalten
Pro POI ist höchstens eine Audiodatei vorgesehen. Daher ist `/audio` selbst die Einzelressource; eine zusätzliche Audio-ID ist nicht erforderlich.
### Gemeinsame Pfadparameter
| Parameter | Typ | Pflicht | Beschreibung |
|---|---|---:|---|
| `routeId` | positive Ganzzahl | ja | ID der aktiven Route |
| `poiId` | positive Ganzzahl | ja | ID eines POIs, der zu dieser Route gehört |
### `GET /api/routes/:routeId/pois/:poiId/audio`
Liefert standardmäßig die Audiodatei des POIs direkt aus. Genau dieser Pfad wird in `audioUrl` und im Feld `url` der Audioressource ausgegeben. Der Endpunkt unterstützt HTTP-Range-Anfragen, damit Browser innerhalb der Audiodatei springen können.
Audiodatei speichern:
```bash
curl http://127.0.0.1:47145/api/routes/1/pois/7/audio \
--output ansage-7.mp3
```
Metadaten statt Dateidaten abrufen:
```bash
curl --get http://127.0.0.1:47145/api/routes/1/pois/7/audio \
--data-urlencode 'metadata=true'
```
| Query-Parameter | Typ | Pflicht | Standard | Beschreibung |
|---|---|---:|---:|---|
| `metadata` | Boolean | nein | `false` | bei `true` JSON-Metadaten statt der Audiodatei liefern |
Antwortet mit `404`, wenn Route und POI nicht zusammengehören oder der POI keine Audiodatei besitzt.
### `POST /api/routes/:routeId/pois/:poiId/audio`
Legt die Audiodatei des im Pfad angegebenen POIs an. Zulässig sind `multipart/form-data` und JSON mit Base64.
#### Variante A: `multipart/form-data`
| Feld | Typ | Pflicht | Beschreibung |
|---|---|---:|---|
| `audio` | Datei | ja | MP3-, MP4/M4A-, AAC-, Ogg-, WAV- oder WebM-Datei; der Typ wird aus dem Inhalt erkannt |
```bash
curl -X POST http://127.0.0.1:47145/api/routes/1/pois/7/audio \
-F 'audio=@ansage.mp3'
```
#### Variante B: JSON mit Base64
```json
{
"audio": {
"filename": "ansage.mp3",
"base64": "SUQzBAAAAAAAI1RTU0UAAA..."
}
}
```
```bash
base64 < ansage.mp3 | tr -d '\n' > ansage.mp3.b64
jq -n --rawfile data ansage.mp3.b64 \
'{
audio: {
filename: "ansage.mp3",
base64: $data
}
}' > audio.json
curl -X POST http://127.0.0.1:47145/api/routes/1/pois/7/audio \
-H 'Content-Type: application/json' \
--data-binary @audio.json
```
Auch hier wird `Content-Type: text/json` akzeptiert. Ein leeres `{}` enthält keine Audiodatei und liefert `400 Bad Request`.
Erfolg: `201 Created` und `Location: /api/routes/1/pois/7/audio`.
Existiert bereits eine Audiodatei, antwortet der Server mit `409`. Zum Ersetzen ist `PUT` zu verwenden.
### `PUT /api/routes/:routeId/pois/:poiId/audio`
Ersetzt die vorhandene Audiodatei des POIs. Die Datei kann als Multipart-Upload oder als JSON-Dateiobjekt übertragen werden.
| Feld | Typ | Pflicht | Beschreibung |
|---|---|---:|---|
| `audio` | Multipart-Datei oder JSON-Dateiobjekt | ja | neue Audiodatei |
Multipart-Beispiel:
```bash
curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/audio \
-F 'audio=@ansage-neu.ogg'
```
JSON-Beispiel:
```json
{
"audio": {
"filename": "ansage-neu.ogg",
"base64": "T2dnUwACAAAAAAAAAAB..."
}
}
```
```bash
curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/audio \
-H 'Content-Type: text/json' \
--data-binary @audio-update.json
```
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/:routeId/pois/:poiId/audio`
Entfernt die Audiodatei und setzt `audioUrl` des POIs auf `null`.
```bash
curl -X DELETE http://127.0.0.1:47145/api/routes/1/pois/7/audio
```
```json
{
"routeId": 1,
"poiId": 7,
"deleted": true
}
```
## 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/pois/7/pictures/15
GET /api/routes/1/pois/7/audio
```
Die Zuordnung lautet:
| JSON-Feld | Datei-Endpunkt |
|---|---|
| `route.gpxUrl` | `/api/routes/:id/gpx` |
| `poi.audioUrl` | `/api/routes/:routeId/pois/:poiId/audio` |
| `poi.images[].url` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` |
| `picture.url` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` |
| `audio.url` | `/api/routes/:routeId/pois/:poiId/audio` |
Die Dateinamen und relativen Pfade unter `storage/` bleiben ausschließlich interne Implementierungsdetails. Dateien gelöschter Routen liegen im Papierkorb und sind über keinen Datei-Endpunkt erreichbar.
## 12. Python-Werkzeuge
Unter `tools/python/` liegen interaktive Skripte für Anlegen, Ändern, Erweitern, Wiederherstellen und Löschen von Routen, POIs, Bildern und Audio. Sie verwenden ausschließlich die Python-Standardbibliothek. Fehlende Parameter werden abgefragt. Antwortet ein vorgeschalteter Webserver mit HTTP 401, fragt die gemeinsame Request-Schicht Benutzername und Passwort ab und wiederholt den ursprünglichen Request.
Details und Aufrufbeispiele stehen in [`tools/python/README.md`](../tools/python/README.md).

25
docs/legacy-migration.md Normal file
View File

@ -0,0 +1,25 @@
# Migration vom historischen Wegwichtel-Projekt
## Übernommene Fachkonzepte
- ereignisbasierte Verarbeitung neuer GPS-Positionen,
- Distanzberechnung zwischen Standort und POIs,
- Aktivierung einer Station beim Erreichen eines Radius,
- Auswahl verschiedener Strecken,
- Audioansagen und eine mobile Darstellung.
## Ersetzte technische Bestandteile
| Altprojekt | Neues Skelett |
|---|---|
| PHP-Endpunkte und MySQL | Express-REST-API und lokale SQLite-Datei |
| AudioJS/Flash-Fallback | natives HTML5-`audio`-Element |
| globale Zustände auf `document` | gekapselter Clientzustand in `Wegwichtel.App` |
| hart codierte Audiodateinamen | in SQLite gespeicherte relative Medienpfade |
| fester Auslöseradius | Radius pro POI |
| unmittelbar gelöschte/extern verwaltete Dateien | Soft Delete mit Papierkorb und Wiederherstellung |
| statisch eingebundene Skripte | Initialisierungsloader mit Selbsttests und Fehleranzeige |
## Bewusste Übergangstechnologie
jQuery Mobile wurde entfernt. Die responsive Oberfläche verwendet jQuery 4.0.0 und jQuery UI 1.14.2; die Seitenumschaltung erfolgt über eine kleine History-API-basierte Navigation. GPS-, API-, Audio- und Diashowlogik bleiben von der UI-Schicht getrennt.

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Wegwichtel" xmlns="http://www.topografix.com/GPX/1/1">
<metadata><name>Beispielweg</name></metadata>
<trk><name>Beispielweg</name><trkseg>
<trkpt lat="52.5200" lon="13.4050"><ele>34</ele></trkpt>
<trkpt lat="52.5208" lon="13.4070"><ele>37</ele></trkpt>
<trkpt lat="52.5216" lon="13.4090"><ele>35</ele></trkpt>
</trkseg></trk>
</gpx>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 672 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 B

View File

@ -1,47 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="initial-scale=1.0, width=320, user-scalable=no" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<title>SDS Mobile</title>
<script type="text/javascript" src="jquery/jquery-1.10.2.js"></script>
<script type="text/javascript" src="audiojs/audio.min.js"></script>
<script type="text/javascript" src="api/config.js"></script>
<script type="text/javascript" src="api/vibrate.js"></script>
<script type="text/javascript" src="api/geolocation.js"></script>
<script type="text/javascript" src="api/database.js"></script>
<link rel="stylesheet" type="text/css" href="layout.css" />
</head>
<body>
<h1 class="header">Mobiles Ansagesystem</h1>
<div id="hints">
<div id="no-gps">Es konnte noch keine GPS-Position ermittelt werden.</div>
<div id="database-loading">Bitte warten Sie, bis die Datenbank geladen wurde.</div>
</div>
<div id="dump"></div>
<div id="routeSelection">
<label for="routes">Strecken:</label>
<select id="routes" onchange="trackChanged(this.value);">
<option value="" selected>keine Strecke verfügbar</option>
</select>
</div>
<div id="poiViews">
</div>
<ul id="trackDump">
</ul>
<div id="audio"></div>
<ul id="contextMenu">
<li><a href="#" id="contextReload">Seite neu laden</a></li>
<li><a href="#" id="contextPlay">Ansage abspielen</a></li>
</ul>
<script type="text/javascript" src="api/console.js"></script>
<script type="text/javascript" src="script.js"></script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 B

Some files were not shown because too many files have changed in this diff Show More