diff --git a/public/index.html b/public/index.html index f028c9a..fa7a0b0 100644 --- a/public/index.html +++ b/public/index.html @@ -77,7 +77,7 @@
- +
diff --git a/public/js/app.js b/public/js/app.js index 8c881c7..a050b1a 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -712,7 +712,7 @@ } function refreshCurrentLocalRoute(id) { - if (!state.route || state.route.source !== 'local' || String(state.route.id) !== String(id)) return; + if (state.route?.source !== 'local' || String(state.route.id) !== String(id)) return; persistRouteProgress(); try { state.route = ns.LocalRoutes.getAppRoute(id); diff --git a/public/js/editor-ui.js b/public/js/editor-ui.js index 5e2b219..faf4907 100644 --- a/public/js/editor-ui.js +++ b/public/js/editor-ui.js @@ -530,7 +530,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; id, gpx, route: { - ...(state.currentRoute || {}), + ...state.currentRoute, id, name: values.name, description: values.description @@ -622,14 +622,14 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; async function saveLocalPoi(values) { if (!Number.isFinite(values.lat) || !Number.isFinite(values.lon)) { - throw new Error('Für einen GPX-POI sind gültige Breiten- und Längengrade erforderlich.'); + throw new TypeError('Für einen GPX-POI sind gültige Breiten- und Längengrade erforderlich.'); } const currentId = Number(byId(ids.poiId).value); const identifier = Number.isSafeInteger(currentId) ? currentId : ns.LocalRoutes.createPoiId(); const existing = state.pois.find(poi => Number(poi.id) === identifier) || state.currentPoi; const media = await mediaFromLocalForm(existing, identifier); const savedPoi = { - ...(existing || {}), + ...existing, ...values, ...media, id: identifier, diff --git a/public/js/local-routes.js b/public/js/local-routes.js index 97e2dec..03fe719 100644 --- a/public/js/local-routes.js +++ b/public/js/local-routes.js @@ -34,9 +34,32 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; storage().setItem(INDEX_KEY, JSON.stringify([...new Set(ids.map(String))])); } + function uuidFromRandomValues(cryptoApi) { + const bytes = new Uint8Array(16); + cryptoApi.getRandomValues(bytes); + + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')); + return [ + hex.slice(0, 4).join(''), + hex.slice(4, 6).join(''), + hex.slice(6, 8).join(''), + hex.slice(8, 10).join(''), + hex.slice(10, 16).join('') + ].join('-'); + } + function createRouteId() { - if (globalThis.crypto?.randomUUID) return `local-${globalThis.crypto.randomUUID()}`; - return `local-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const cryptoApi = globalThis.crypto; + if (typeof cryptoApi?.randomUUID === 'function') { + return `local-${cryptoApi.randomUUID()}`; + } + if (typeof cryptoApi?.getRandomValues === 'function') { + return `local-${uuidFromRandomValues(cryptoApi)}`; + } + throw new Error('Keine sichere Zufallsquelle für eine lokale Strecken-ID verfügbar.'); } function createPoiId() { @@ -138,13 +161,14 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; const orderedPois = [...(pois || [])].sort((left, right) => Number(left.sequence) - Number(right.sequence)); orderedPois.forEach((poi, index) => { + const title = poi.title || `Station ${index + 1}`; if (!Number.isFinite(Number(poi.lat)) || !Number.isFinite(Number(poi.lon))) { - throw new Error(`Für ${poi.title || `Station ${index + 1}`} fehlen gültige Koordinaten.`); + throw new TypeError(`Für ${title} fehlen gültige Koordinaten.`); } const waypoint = createElement(document, 'wpt'); waypoint.setAttribute('lat', String(Number(poi.lat))); waypoint.setAttribute('lon', String(Number(poi.lon))); - waypoint.appendChild(createElement(document, 'name', poi.title || `Station ${index + 1}`)); + waypoint.appendChild(createElement(document, 'name', title)); if (poi.description) waypoint.appendChild(createElement(document, 'desc', poi.description)); const extensions = createElement(document, 'extensions'); const poiId = document.createElementNS(POI_NAMESPACE, 'wegwichtel:poiId'); @@ -154,7 +178,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; const routeOrTrack = directChildrenByLocalName(root, 'rte')[0] || directChildrenByLocalName(root, 'trk')[0] || null; - root.insertBefore(waypoint, routeOrTrack); + if (routeOrTrack) routeOrTrack.before(waypoint); + else root.appendChild(waypoint); }); return new XMLSerializer().serializeToString(document); @@ -375,25 +400,28 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; function readFileAsText(file) { if (!file) return Promise.resolve(''); - if (typeof file.text === 'function') return file.text(); - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.addEventListener('load', () => resolve(String(reader.result || '')), { once: true }); - reader.addEventListener('error', () => reject(reader.error || new Error('Datei konnte nicht gelesen werden.')), { once: true }); - reader.readAsText(file); - }); + if (typeof file.text !== 'function') { + return Promise.reject(new TypeError('Die ausgewählte Datei unterstützt Blob.text() nicht.')); + } + return file.text(); } function fileToDataUrl(file) { if (!file) return Promise.resolve(null); return new Promise((resolve, reject) => { const reader = new FileReader(); - reader.addEventListener('load', () => resolve({ - dataUrl: String(reader.result || ''), - name: file.name || 'Datei', - size: Number(file.size) || 0, - type: file.type || 'application/octet-stream' - }), { once: true }); + reader.addEventListener('load', () => { + if (typeof reader.result !== 'string') { + reject(new TypeError('Die Datei konnte nicht als Data-URL gelesen werden.')); + return; + } + resolve({ + dataUrl: reader.result, + name: file.name || 'Datei', + size: Number(file.size) || 0, + type: file.type || 'application/octet-stream' + }); + }, { once: true }); reader.addEventListener('error', () => reject(reader.error || new Error('Datei konnte nicht gelesen werden.')), { once: true }); reader.readAsDataURL(file); }); diff --git a/test/local-routes.test.js b/test/local-routes.test.js index 690ae71..4f23c10 100644 --- a/test/local-routes.test.js +++ b/test/local-routes.test.js @@ -64,6 +64,14 @@ class XmlElement { return child; } + before(...nodes) { + if (!this.parentNode) return; + const index = this.parentNode.children.indexOf(this); + if (index < 0) return; + for (const node of nodes) node.parentNode = this.parentNode; + this.parentNode.children.splice(index, 0, ...nodes); + } + remove() { if (!this.parentNode) return; this.parentNode.children = this.parentNode.children.filter(child => child !== this); @@ -227,13 +235,13 @@ class FakeFileReader extends EventTarget { readAsDataURL(file) { queueMicrotask(() => { const type = file.type || 'application/octet-stream'; - this.result = `data:${type};base64,VEVTVA==`; + this.result = file.invalidReaderResult ? { type } : `data:${type};base64,VEVTVA==`; this.dispatchEvent(new Event('load')); }); } } -async function loadLocalRoutes() { +async function loadLocalRoutes(options = {}) { const sourcePath = path.join(root, 'public/js/local-routes.js'); const source = await fs.readFile(sourcePath, 'utf8'); const localStorage = new FakeStorage(); @@ -248,7 +256,7 @@ async function loadLocalRoutes() { EventTarget, FileReader: FakeFileReader, XMLSerializer: FakeXMLSerializer, - crypto: { randomUUID: () => '11111111-2222-4333-8444-555555555555' }, + crypto: options.crypto || { randomUUID: () => '11111111-2222-4333-8444-555555555555' }, document, globalThis: null, localStorage, @@ -270,6 +278,45 @@ const sampleGpx = ` `; +test('route id generation uses randomUUID when available', async () => { + const { api } = await loadLocalRoutes(); + + assert.equal( + api.createRouteId(), + 'local-11111111-2222-4333-8444-555555555555' + ); +}); + +test('route id generation falls back to crypto.getRandomValues with UUID v4 bits', async () => { + let callCount = 0; + const { api } = await loadLocalRoutes({ + crypto: { + getRandomValues(bytes) { + callCount += 1; + bytes.set([ + 0x00, 0x11, 0x22, 0x33, + 0x44, 0x55, 0x66, 0x77, + 0x88, 0x99, 0xaa, 0xbb, + 0xcc, 0xdd, 0xee, 0xff + ]); + return bytes; + } + } + }); + + assert.equal(api.createRouteId(), 'local-00112233-4455-4677-8899-aabbccddeeff'); + assert.equal(callCount, 1); +}); + +test('route id generation fails when no secure crypto source is available', async () => { + const { api } = await loadLocalRoutes({ crypto: {} }); + + assert.throws( + () => api.createRouteId(), + /Keine sichere Zufallsquelle/ + ); +}); + test('local route package stores GPX and JSON separately and reconstructs an app route', async () => { const { api, events, localStorage } = await loadLocalRoutes(); const routeId = api.createRouteId(); @@ -347,6 +394,13 @@ test('GPX waypoint import reads stable POI ids and validates coordinates', async assert.match(api.emptyGpx('A & B'), /A & B<\/name>/); assert.throws(() => api.parseWaypoints(''), /GPX-Datei ist nicht gültig/); assert.throws(() => api.syncGpx(sampleGpx, { name: 'Fehler' }, [{ id: 1, title: 'Ohne Position' }]), /gültige Koordinaten/); + + const waypointOnly = api.syncGpx( + ``, + { name: 'Nur POI' }, + [{ id: 2, title: 'Einzelner POI', lat: 50.2, lon: 10.2, sequence: 0 }] + ); + assert.match(waypointOnly, //); }); test('remove updates index and dispatches a local-route change event', async () => { @@ -386,16 +440,18 @@ test('quota failure rolls back package writes and keeps the previous package rea assert.equal(api.get('local-quota').route.name, 'Vorher'); }); -test('file helpers support native text and FileReader-backed media data URLs', async () => { +test('file helpers support Blob.text and FileReader-backed media data URLs', async () => { const { api } = await loadLocalRoutes(); assert.equal(await api.readFileAsText({ text: async () => '' }), ''); assert.equal(await api.readFileAsText(null), ''); + await assert.rejects(api.readFileAsText({}), { name: 'TypeError' }); const media = await api.fileToDataUrl({ name: 'ton.mp3', size: 4, type: 'audio/mpeg' }); assert.equal(media.name, 'ton.mp3'); assert.equal(media.size, 4); assert.equal(media.type, 'audio/mpeg'); assert.match(media.dataUrl, /^data:audio\/mpeg;base64,/); + await assert.rejects(api.fileToDataUrl({ invalidReaderResult: true }), { name: 'TypeError' }); assert.equal(await api.fileToDataUrl(null), null); });