diff --git a/docs/REST-API.md b/docs/REST-API.md index 0e86fba..2933013 100644 --- a/docs/REST-API.md +++ b/docs/REST-API.md @@ -28,9 +28,13 @@ https://wegwichtel.example.org/api - Listen- und Metadatenzugriffe liefern JSON. - Die Einzelendpunkte für GPX, Bilder und Audio liefern die jeweilige Datei direkt aus. Mit `?metadata=true` liefern die Bild- und Audioendpunkte stattdessen JSON-Metadaten. -- Schreibzugriffe ohne Dateien akzeptieren JSON, `application/x-www-form-urlencoded` oder `multipart/form-data`. -- Schreibzugriffe mit Dateien verwenden `multipart/form-data`. +- Schreibzugriffe ohne Dateien akzeptieren `application/json`, `application/x-www-form-urlencoded` oder `multipart/form-data`. +- GPX-Dateien werden weiterhin ausschließlich als `multipart/form-data` übertragen. +- Bild- und Audioendpunkte akzeptieren wahlweise `multipart/form-data` oder ein JSON-Objekt mit Base64-kodierten Dateidaten. +- Für JSON wird `Content-Type: application/json` empfohlen. `Content-Type: text/json` wird aus Kompatibilitätsgründen ebenfalls akzeptiert. +- Ein leeres JSON-Objekt (`{}`) enthält keine Datei und kann deshalb keinen Bild- oder Audio-Upload ausführen. - Pro Medien-Request wird genau eine Bild- beziehungsweise Audiodatei verarbeitet. +- Base64 vergrößert die Requestgröße um ungefähr ein Drittel und benötigt beim Verarbeiten zusätzlichen Arbeitsspeicher. Für große Dateien ist `multipart/form-data` vorzuziehen. - Zeitstempel werden als SQLite- oder ISO-8601-Text ausgegeben. - Die aktuelle API besitzt keine Authentifizierung. Schreibzugriffe dürfen nicht ungeschützt öffentlich erreichbar sein. @@ -48,12 +52,23 @@ Unterstützte Dateitypen: Die Uploadfelder heißen: -| Ressource | Feldname | +| 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 | ja | ursprünglicher Dateiname einschließlich passender Endung | +| `contentType` | Text | ja, sofern keine `dataUrl` den Typ enthält | MIME-Typ, beispielsweise `image/jpeg` oder `audio/mpeg` | +| `base64` | Text | ja, sofern `dataUrl` fehlt | reiner Base64-Inhalt ohne Präfix | +| `dataUrl` | Text | alternativ zu `base64` | vollständige Base64-Data-URL, beispielsweise `data:image/png;base64,...` | + +`base64` und `dataUrl` sind Alternativen. Wird `dataUrl` verwendet, kann `contentType` daraus übernommen werden. `filename` bleibt erforderlich, weil die Dateiendung für Ablage und Auslieferung benötigt wird. + ### Fehlerformat ```json @@ -184,9 +199,9 @@ Pro POI kann höchstens eine Audiodatei existieren. Deshalb wird die Audioressou | `DELETE` | `/api/routes/:id` | Route weich löschen | | `POST` | `/api/routes/:id/restore` | Route wiederherstellen | | `GET` | `/api/routes/:id/pois` | POIs einer Route auflisten | -| `GET` | `/api/pois/:id` | einzelnen POI lesen | +| `GET` | `/api/routes/:routeId/pois/:poiId` | einzelnen POI der Route lesen | | `POST` | `/api/routes/:id/pois` | POI-Metadaten anlegen | -| `PUT` | `/api/pois/:id` | POI-Metadaten aktualisieren | +| `PUT` | `/api/routes/:routeId/pois/:poiId` | POI-Metadaten aktualisieren | | `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 | @@ -352,14 +367,21 @@ Liefert alle POIs einer aktiven Route nach `sequence` und `id`. curl http://127.0.0.1:47145/api/routes/1/pois ``` -### `GET /api/pois/:id` +### `GET /api/routes/:routeId/pois/:poiId` -Liefert einen einzelnen POI einschließlich seiner aktuellen Bild- und Audio-URLs. +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/pois/7 +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. @@ -388,11 +410,16 @@ curl -X POST http://127.0.0.1:47145/api/routes/1/pois \ }' ``` -Erfolg: `201 Created` und `Location: /api/pois/`. +Erfolg: `201 Created` und `Location: /api/routes//pois/`. -### `PUT /api/pois/:id` +### `PUT /api/routes/:routeId/pois/:poiId` -Aktualisiert ausschließlich POI-Metadaten. Nicht übergebene Werte bleiben erhalten. +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 | |---|---|---:|---| @@ -404,7 +431,7 @@ Aktualisiert ausschließlich POI-Metadaten. Nicht übergebene Werte bleiben erha | `sequence` | nichtnegative Ganzzahl | nein | neue Reihenfolge | ```bash -curl -X PUT http://127.0.0.1:47145/api/pois/7 \ +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", @@ -479,9 +506,9 @@ 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. +Lädt genau ein Bild für den im Pfad angegebenen POI hoch. Zulässig sind zwei Übertragungsformen. -Content-Type: `multipart/form-data` +#### Variante A: `multipart/form-data` | Feld | Typ | Pflicht | Standard | Beschreibung | |---|---|---:|---:|---| @@ -496,6 +523,60 @@ curl -X POST http://127.0.0.1:47145/api/routes/1/pois/7/pictures \ -F 'picture=@eiche.jpg;type=image/jpeg' ``` +#### 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", + "contentType": "image/jpeg", + "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", + contentType: "image/jpeg", + 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/`. ### `PUT /api/routes/:routeId/pois/:poiId/pictures/:pictureId` @@ -504,7 +585,7 @@ Aktualisiert Metadaten und kann optional die Datei ersetzen. Nicht übergebene M | Feld | Typ | Pflicht | Beschreibung | |---|---|---:|---| -| `picture` | Datei | nein | ersetzt die bisherige Bilddatei | +| `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 | @@ -519,7 +600,7 @@ curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 \ }' ``` -Datei und Metadaten ersetzen: +Datei und Metadaten per Multipart ersetzen: ```bash curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 \ @@ -528,6 +609,26 @@ curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 \ -F 'picture=@eiche-neu.webp;type=image/webp' ``` +Datei und Metadaten per JSON ersetzen: + +```json +{ + "caption": "Neue Aufnahme der Eiche", + "sequence": 2, + "picture": { + "filename": "eiche-neu.webp", + "contentType": "image/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` @@ -584,9 +685,9 @@ Antwortet mit `404`, wenn Route und POI nicht zusammengehören oder der POI kein ### `POST /api/routes/:routeId/pois/:poiId/audio` -Legt die Audiodatei des im Pfad angegebenen POIs an. +Legt die Audiodatei des im Pfad angegebenen POIs an. Zulässig sind `multipart/form-data` und JSON mit Base64. -Content-Type: `multipart/form-data` +#### Variante A: `multipart/form-data` | Feld | Typ | Pflicht | Beschreibung | |---|---|---:|---| @@ -597,23 +698,74 @@ curl -X POST http://127.0.0.1:47145/api/routes/1/pois/7/audio \ -F 'audio=@ansage.mp3;type=audio/mpeg' ``` +#### Variante B: JSON mit Base64 + +```json +{ + "audio": { + "filename": "ansage.mp3", + "contentType": "audio/mpeg", + "base64": "SUQzBAAAAAAAI1RTU0UAAA..." + } +} +``` + +```bash +base64 < ansage.mp3 | tr -d '\n' > ansage.mp3.b64 + +jq -n --rawfile data ansage.mp3.b64 \ + '{ + audio: { + filename: "ansage.mp3", + contentType: "audio/mpeg", + 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. +Ersetzt die vorhandene Audiodatei des POIs. Die Datei kann als Multipart-Upload oder als JSON-Dateiobjekt übertragen werden. | Feld | Typ | Pflicht | Beschreibung | |---|---|---:|---| -| `audio` | Datei | ja | neue Audiodatei | +| `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;type=audio/ogg' ``` +JSON-Beispiel: + +```json +{ + "audio": { + "filename": "ansage-neu.ogg", + "contentType": "audio/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` diff --git a/package-lock.json b/package-lock.json index 41b878e..6f2c8b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wegwichtel-next", - "version": "0.10.0", + "version": "0.11.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wegwichtel-next", - "version": "0.10.0", + "version": "0.11.1", "hasInstallScript": true, "dependencies": { "express": "5.2.1", diff --git a/package.json b/package.json index eb15a80..280f360 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wegwichtel-next", - "version": "0.10.0", + "version": "0.11.1", "private": true, "description": "GPS-gestützte Lern- und Wanderwege mit GPX, POIs, Bildern und Audioansagen.", "type": "module", diff --git a/server.js b/server.js index 239ecd3..bafc6bd 100644 --- a/server.js +++ b/server.js @@ -12,7 +12,10 @@ const db = openDatabase(); const app = express(); app.disable('x-powered-by'); -app.use(express.json({ limit: '2mb' })); +app.use(express.json({ + limit: config.maxJsonBodyBytes, + type: ['application/json', 'application/*+json', 'text/json'] +})); app.use(express.urlencoded({ extended: true, limit: '2mb' })); app.use('/api', createApiRouter(db)); app.use(express.static(path.join(__dirname, 'public'), { extensions: ['html'] })); diff --git a/src/config.js b/src/config.js index b7cf12f..9f8a373 100644 --- a/src/config.js +++ b/src/config.js @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const resolveFromRoot = (value, fallback) => path.resolve(projectRoot, value || fallback); +const maxUploadBytes = Number.parseInt(process.env.MAX_UPLOAD_MB || '50', 10) * 1024 * 1024; export const config = Object.freeze({ projectRoot, @@ -11,7 +12,8 @@ export const config = Object.freeze({ port: Number.parseInt(process.env.PORT || '47145', 10), dataDir: resolveFromRoot(process.env.DATA_DIR, 'data'), storageDir: resolveFromRoot(process.env.STORAGE_DIR, 'storage'), - maxUploadBytes: Number.parseInt(process.env.MAX_UPLOAD_MB || '50', 10) * 1024 * 1024, + maxUploadBytes, + maxJsonBodyBytes: Math.ceil(maxUploadBytes * 4 / 3) + 1024 * 1024, defaultRouteRadiusKm: Number.parseFloat(process.env.DEFAULT_ROUTE_RADIUS_KM || '25'), defaultPoiTriggerMeters: Number.parseFloat(process.env.DEFAULT_POI_TRIGGER_METERS || '80') }); diff --git a/src/middleware/upload.js b/src/middleware/upload.js index 721ad0b..ec72d2a 100644 --- a/src/middleware/upload.js +++ b/src/middleware/upload.js @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import multer from 'multer'; @@ -28,3 +29,87 @@ export const upload = multer({ limits: { fileSize: config.maxUploadBytes, files: 25 }, fileFilter }); + + +function decodeBase64(value, fieldName) { + if (typeof value !== 'string' || !value.trim()) { + throw new HttpError(400, `${fieldName}.base64 muss eine nichtleere Base64-Zeichenkette sein.`); + } + + const compact = value.replace(/\s+/g, ''); + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(compact) || compact.length % 4 === 1) { + throw new HttpError(400, `${fieldName}.base64 enthält keine gültigen Base64-Daten.`); + } + + const buffer = Buffer.from(compact, 'base64'); + const canonicalInput = compact.replace(/=+$/, ''); + const canonicalOutput = buffer.toString('base64').replace(/=+$/, ''); + if (canonicalInput !== canonicalOutput) { + throw new HttpError(400, `${fieldName}.base64 enthält keine gültigen Base64-Daten.`); + } + if (buffer.length > config.maxUploadBytes) { + throw new HttpError(413, `Die Datei überschreitet das Limit von ${Math.floor(config.maxUploadBytes / 1024 / 1024)} MB.`); + } + return buffer; +} + +function jsonFileDescriptor(value, fieldName) { + if (value == null) return null; + if (typeof value !== 'object' || Array.isArray(value)) { + throw new HttpError(400, `${fieldName} muss ein JSON-Objekt sein.`); + } + + const originalname = String(value.filename ?? '').trim(); + if (!originalname) throw new HttpError(400, `${fieldName}.filename ist erforderlich.`); + + let mimetype = String(value.contentType ?? value.mimeType ?? '').trim().toLowerCase(); + let base64 = value.base64; + + if (value.dataUrl != null) { + if (typeof value.dataUrl !== 'string') { + throw new HttpError(400, `${fieldName}.dataUrl muss eine Zeichenkette sein.`); + } + const match = /^data:([^;,]+);base64,(.*)$/s.exec(value.dataUrl.trim()); + if (!match) { + throw new HttpError(400, `${fieldName}.dataUrl muss eine Base64-Data-URL sein.`); + } + if (!mimetype) mimetype = match[1].trim().toLowerCase(); + base64 = match[2]; + } + + if (!mimetype) throw new HttpError(400, `${fieldName}.contentType ist erforderlich.`); + return { originalname, mimetype, buffer: decodeBase64(base64, fieldName) }; +} + +export function jsonUpload(fieldName) { + return function parseJsonUpload(req, res, next) { + try { + if (req.file || req.body?.[fieldName] == null) return next(); + + const descriptor = jsonFileDescriptor(req.body[fieldName], fieldName); + if (!descriptor) return next(); + + const destination = path.join(config.storageDir, 'uploads'); + fs.mkdirSync(destination, { recursive: true }); + const safe = descriptor.originalname.replace(/[^a-zA-Z0-9._-]/g, '_'); + const filename = `${Date.now()}-${crypto.randomUUID()}-${safe}`; + const filePath = path.join(destination, filename); + fs.writeFileSync(filePath, descriptor.buffer, { flag: 'wx' }); + + req.file = { + fieldname: fieldName, + originalname: descriptor.originalname, + encoding: '7bit', + mimetype: descriptor.mimetype, + destination, + filename, + path: filePath, + size: descriptor.buffer.length + }; + delete req.body[fieldName]; + next(); + } catch (error) { + next(error); + } + }; +} diff --git a/src/routes/api.js b/src/routes/api.js index cffc4d1..4818709 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { upload } from '../middleware/upload.js'; +import { jsonUpload, upload } from '../middleware/upload.js'; import { config } from '../config.js'; import { listRoutes, getRoute, getRouteGpxFile, createRoute, updateRoute, appendRoute, @@ -64,8 +64,8 @@ export function createApiRouter(db) { res.json({ pois: listPois(db, Number(req.params.id)) }); }); - api.get('/pois/:id', (req, res) => { - res.json(getPoi(db, Number(req.params.id))); + api.get('/routes/:routeId/pois/:poiId', (req, res) => { + res.json(getPoi(db, Number(req.params.routeId), Number(req.params.poiId))); }); api.post('/routes', upload.single('gpx'), (req, res) => { @@ -83,11 +83,18 @@ export function createApiRouter(db) { api.post('/routes/:id/pois', upload.none(), (req, res) => { const poi = createPoi(db, Number(req.params.id), req.body); - res.status(201).location(`/api/pois/${poi.id}`).json(poi); + res.status(201) + .location(`/api/routes/${req.params.id}/pois/${poi.id}`) + .json(poi); }); - api.put('/pois/:id', upload.none(), (req, res) => { - res.json(updatePoi(db, Number(req.params.id), req.body)); + api.put('/routes/:routeId/pois/:poiId', upload.none(), (req, res) => { + res.json(updatePoi( + db, + Number(req.params.routeId), + Number(req.params.poiId), + req.body + )); }); api.get('/routes/:routeId/pois/:poiId/pictures', (req, res) => { @@ -105,7 +112,7 @@ export function createApiRouter(db) { sendFileResource(res, getPictureFile(db, routeId, poiId, pictureId)); }); - api.post('/routes/:routeId/pois/:poiId/pictures', upload.single('picture'), (req, res) => { + api.post('/routes/:routeId/pois/:poiId/pictures', upload.single('picture'), jsonUpload('picture'), (req, res) => { const routeId = Number(req.params.routeId); const poiId = Number(req.params.poiId); const picture = createPicture(db, routeId, poiId, req.body, req.file); @@ -114,7 +121,7 @@ export function createApiRouter(db) { .json(picture); }); - api.put('/routes/:routeId/pois/:poiId/pictures/:pictureId', upload.single('picture'), (req, res) => { + api.put('/routes/:routeId/pois/:poiId/pictures/:pictureId', upload.single('picture'), jsonUpload('picture'), (req, res) => { res.json(updatePicture( db, Number(req.params.routeId), @@ -144,7 +151,7 @@ export function createApiRouter(db) { sendFileResource(res, getAudioFile(db, routeId, poiId)); }); - api.post('/routes/:routeId/pois/:poiId/audio', upload.single('audio'), (req, res) => { + api.post('/routes/:routeId/pois/:poiId/audio', upload.single('audio'), jsonUpload('audio'), (req, res) => { const routeId = Number(req.params.routeId); const poiId = Number(req.params.poiId); const audio = createAudio(db, routeId, poiId, req.file); @@ -153,7 +160,7 @@ export function createApiRouter(db) { .json(audio); }); - api.put('/routes/:routeId/pois/:poiId/audio', upload.single('audio'), (req, res) => { + api.put('/routes/:routeId/pois/:poiId/audio', upload.single('audio'), jsonUpload('audio'), (req, res) => { res.json(updateAudio(db, Number(req.params.routeId), Number(req.params.poiId), req.file)); }); diff --git a/src/services/routes-service.js b/src/services/routes-service.js index 1d94659..ceaf6ce 100644 --- a/src/services/routes-service.js +++ b/src/services/routes-service.js @@ -151,10 +151,11 @@ export function listPois(db, routeId) { })); } -export function getPoi(db, poiId) { - const row = db.prepare(`SELECT p.route_id AS routeId FROM pois p JOIN routes r ON r.id = p.route_id WHERE p.id = ? AND r.status = 'active'`).get(poiId); +export function getPoi(db, routeId, poiId) { + const row = db.prepare(`SELECT p.id FROM pois p JOIN routes r ON r.id = p.route_id + WHERE p.id = ? AND p.route_id = ? AND r.status = 'active'`).get(poiId, routeId); if (!row) throw new HttpError(404, 'POI nicht gefunden.'); - return listPois(db, row.routeId).find(poi => poi.id === Number(poiId)); + return listPois(db, routeId).find(poi => poi.id === Number(poiId)); } export function createPoi(db, routeId, fields) { @@ -171,8 +172,9 @@ export function createPoi(db, routeId, fields) { return listPois(db, routeId).find(poi => poi.id === Number(result.lastInsertRowid)); } -export function updatePoi(db, poiId, fields) { - const existing = db.prepare(`SELECT p.*, r.status FROM pois p JOIN routes r ON r.id = p.route_id WHERE p.id = ?`).get(poiId); +export function updatePoi(db, routeId, poiId, fields) { + const existing = db.prepare(`SELECT p.*, r.status FROM pois p JOIN routes r ON r.id = p.route_id + WHERE p.id = ? AND p.route_id = ?`).get(poiId, routeId); if (!existing || existing.status !== 'active') throw new HttpError(404, 'POI nicht gefunden.'); const lat = fields.lat == null ? existing.lat : Number.parseFloat(fields.lat); const lon = fields.lon == null ? existing.lon : Number.parseFloat(fields.lon); diff --git a/test/api-docs.test.js b/test/api-docs.test.js index 7890fa6..c92a45a 100644 --- a/test/api-docs.test.js +++ b/test/api-docs.test.js @@ -20,12 +20,12 @@ test('complete REST API documentation is kept outside the README', async () => { 'GET /api/routes/:id', 'GET /api/routes/:id/gpx', 'GET /api/routes/:id/pois', - 'GET /api/pois/:id', + 'GET /api/routes/:routeId/pois/:poiId', 'POST /api/routes', 'PUT /api/routes/:id', 'POST /api/routes/:id/append', 'POST /api/routes/:id/pois', - 'PUT /api/pois/:id', + 'PUT /api/routes/:routeId/pois/:poiId', 'GET /api/routes/:routeId/pois/:poiId/pictures', 'GET /api/routes/:routeId/pois/:poiId/pictures/:pictureId', 'POST /api/routes/:routeId/pois/:poiId/pictures', @@ -44,7 +44,8 @@ test('complete REST API documentation is kept outside the README', async () => { for (const parameter of [ 'lat', 'lon', 'radiusKm', 'includeDeleted', 'gpx', 'name', 'slug', 'description', 'schoolName', 'title', 'triggerRadiusM', 'sequence', - 'picture', 'pictureId', 'caption', 'audio', 'poiId', 'metadata' + 'picture', 'pictureId', 'caption', 'audio', 'poiId', 'metadata', + 'filename', 'contentType', 'base64', 'dataUrl' ]) { assert.match(api, new RegExp(`\\b${parameter}\\b`), `missing parameter documentation: ${parameter}`); } @@ -54,16 +55,20 @@ test('POI media uploads use POI-scoped single-resource endpoints', async () => { const router = await read('src/routes/api.js'); const routesService = await read('src/services/routes-service.js'); const mediaService = await read('src/services/media-service.js'); + const api = await read('docs/REST-API.md'); - assert.match(router, /post\('\/routes\/:routeId\/pois\/:poiId\/pictures', upload\.single\('picture'\)/); - assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId', upload\.single\('picture'\)/); + assert.match(router, /post\('\/routes\/:routeId\/pois\/:poiId\/pictures', upload\.single\('picture'\), jsonUpload\('picture'\)/); + assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId', upload\.single\('picture'\), jsonUpload\('picture'\)/); assert.match(router, /delete\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId'/); assert.match(router, /get\('\/routes\/:routeId\/pois\/:poiId\/audio'/); - assert.match(router, /post\('\/routes\/:routeId\/pois\/:poiId\/audio', upload\.single\('audio'\)/); - assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/audio', upload\.single\('audio'\)/); + assert.match(router, /post\('\/routes\/:routeId\/pois\/:poiId\/audio', upload\.single\('audio'\), jsonUpload\('audio'\)/); + assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/audio', upload\.single\('audio'\), jsonUpload\('audio'\)/); assert.match(router, /delete\('\/routes\/:routeId\/pois\/:poiId\/audio'/); assert.doesNotMatch(router, /\/routes\/:id\/(?:pictures|audio)/); assert.match(router, /post\('\/routes\/:id\/pois', upload\.none\(\),/); + assert.match(router, /get\('\/routes\/:routeId\/pois\/:poiId'/); + assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId', upload\.none\(\)/); + assert.doesNotMatch(router, /['"]\/pois\/:id['"]/); assert.doesNotMatch(routesService, /files\.images|files\.audio/); assert.match(mediaService, /caption/); assert.match(mediaService, /removeStoredFile/); @@ -73,4 +78,8 @@ test('POI media uploads use POI-scoped single-resource endpoints', async () => { assert.doesNotMatch(router, /['"`]\/media\//); assert.doesNotMatch(routesService, /safeMediaUrl|\/media\//); assert.doesNotMatch(mediaService, /safeMediaUrl|\/media\//); + assert.match(router, /jsonUpload/); + assert.match(api, /Content-Type: `application\/json`/); + assert.match(api, /`text\/json`/); + assert.match(api, /Base64/); }); diff --git a/test/media-api.test.js b/test/media-api.test.js index d0676b1..39bc8bb 100644 --- a/test/media-api.test.js +++ b/test/media-api.test.js @@ -114,6 +114,27 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3 }) }, 201); + const { body: fetchedPoi } = await requestJson( + `${baseUrl}/api/routes/${route.id}/pois/${poi.id}` + ); + assert.equal(fetchedPoi.id, poi.id); + assert.equal(fetchedPoi.routeId, route.id); + assert.equal(fetchedPoi.title, 'Teststation'); + + const { body: updatedPoi } = await requestJson( + `${baseUrl}/api/routes/${route.id}/pois/${poi.id}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'Aktualisierte Teststation' }) + } + ); + assert.equal(updatedPoi.title, 'Aktualisierte Teststation'); + + await requestJson(`${baseUrl}/api/routes/999999/pois/${poi.id}`, {}, 404); + await requestJson(`${baseUrl}/api/routes/${route.id}/pois/999999`, {}, 404); + await requestJson(`${baseUrl}/api/pois/${poi.id}`, {}, 404); + const pictureForm = new FormData(); pictureForm.append('poiId', String(otherPoi.id)); pictureForm.append('caption', 'Erste Bildbeschreibung'); @@ -144,6 +165,40 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3 assert.equal(pictureList.pictures.length, 1); assert.equal(pictureList.pictures[0].id, picture.id); + const jsonPicturePayload = { + caption: 'Bild aus JSON', + sequence: 1, + picture: { + filename: 'bild-json.webp', + contentType: 'image/webp', + base64: Buffer.from('picture-json').toString('base64') + } + }; + const { body: jsonPicture } = await requestJson( + `${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures`, + { + method: 'POST', + headers: { 'Content-Type': 'text/json' }, + body: JSON.stringify(jsonPicturePayload) + }, + 201 + ); + assert.equal(jsonPicture.poiId, otherPoi.id); + assert.equal(jsonPicture.caption, 'Bild aus JSON'); + const { response: jsonPictureResponse, body: jsonPictureFile } = await requestBuffer(`${baseUrl}${jsonPicture.url}`); + assert.match(jsonPictureResponse.headers.get('content-type') || '', /image\/webp/); + assert.equal(jsonPictureFile.toString(), 'picture-json'); + + await requestJson( + `${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures`, + { + method: 'POST', + headers: { 'Content-Type': 'text/json' }, + body: '{}' + }, + 400 + ); + const { body: updatedPicture } = await requestJson( `${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`, { @@ -178,11 +233,20 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3 body: duplicateAudio }, 409); - const replacementAudio = new FormData(); - replacementAudio.append('audio', new Blob(['audio-two'], { type: 'audio/ogg' }), 'ansage-neu.ogg'); + const replacementAudio = { + audio: { + filename: 'ansage-neu.ogg', + contentType: 'audio/ogg', + base64: Buffer.from('audio-two').toString('base64') + } + }; const { body: replacedAudio } = await requestJson( `${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`, - { method: 'PUT', body: replacementAudio } + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(replacementAudio) + } ); assert.equal(replacedAudio.url, `/api/routes/${route.id}/pois/${poi.id}/audio`); const { response: replacedAudioResponse, body: replacedAudioFile } = await requestBuffer(`${baseUrl}${replacedAudio.url}`); @@ -214,6 +278,12 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3 ); assert.equal(deletedPicture.deleted, true); + const { body: deletedJsonPicture } = await requestJson( + `${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures/${jsonPicture.id}`, + { method: 'DELETE' } + ); + assert.equal(deletedJsonPicture.deleted, true); + await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`, {}, 404); await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`, {}, 404); });