Amélioration avec des fonctions d'un vrai dashboard automotive et containerisation du projet pour faciliter le déploiement
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { normalizePreferences, safeWebUrl, wazeUrl } from '../ui/applications.js';
|
||||
import { embedUrl } from '../ui/embeds.js';
|
||||
test('launcher preferences migrate defaults and reject duplicate/unknown/hidden favorites', () => {
|
||||
assert.deepEqual(normalizePreferences(null).favorites,['home','drive','map','music']);
|
||||
const p=normalizePreferences({favorites:['youtube-music','youtube-music','invalid','spotify','home'],hidden:['spotify','invalid'],modes:{youtube:'same',spotify:'invalid'}});
|
||||
assert.deepEqual(p.favorites,['youtube-music',null,null,null]);
|
||||
assert.deepEqual(p.hidden,['spotify']); assert.equal(p.modes.youtube,'same'); assert.equal(p.modes.spotify,'new');
|
||||
assert.deepEqual(normalizePreferences({favorites:[]}).favorites,[null,null,null,null]);
|
||||
});
|
||||
test('official embed URLs reject foreign origins and unsupported content',()=>{
|
||||
assert.equal(embedUrl('spotify','https://open.spotify.com/playlist/abc123'),'https://open.spotify.com/embed/playlist/abc123');
|
||||
assert.equal(embedUrl('spotify','https://open.spotify.com/'),'');
|
||||
assert.equal(embedUrl('spotify','https://evil.example/playlist/abc123'),'');
|
||||
assert.equal(embedUrl('youtube-music','https://music.youtube.com/watch?v=M7lc1UVf-VE'),'https://www.youtube.com/embed/M7lc1UVf-VE');
|
||||
assert.equal(embedUrl('youtube-music','https://music.youtube.com/'),'');
|
||||
assert.equal(embedUrl('stream','javascript:alert(1)'),'');
|
||||
assert.equal(normalizePreferences({modes:{spotify:'embed',waze:'embed'}}).modes.spotify,'embed');
|
||||
assert.equal(normalizePreferences({modes:{waze:'embed'}}).modes.waze,'new');
|
||||
});
|
||||
test('external links only allow web URLs and valid Waze destination coordinates', () => {
|
||||
for (const value of ['javascript:alert(1)','file:///x','https://name:pass@example.org','bad']) assert.equal(safeWebUrl(value),'');
|
||||
assert.equal(safeWebUrl('https://media.cunatbrule.fr/web/'),'https://media.cunatbrule.fr/web/');
|
||||
const url = new URL(wazeUrl({lat:48.85,lon:2.35}));
|
||||
assert.equal(url.searchParams.get('ll'),'48.85,2.35'); assert.equal(url.searchParams.get('navigate'),'yes');
|
||||
assert.equal(new URL(wazeUrl({lat:NaN,lon:0})).searchParams.has('ll'),false);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServices } from './services.mjs';
|
||||
import { distance, validateZones, nearbyZones, routeProgress, instruction } from '../ui/geo.js';
|
||||
import { palette } from '../ui/theme.js';
|
||||
|
||||
test('geographic distances, segment projection and French instructions', () => {
|
||||
assert.equal(distance({lat:48,lon:2},{lat:48,lon:2}), 0);
|
||||
assert.ok(Math.abs(distance({lat:0,lon:0},{lat:0,lon:1}) - 111195) < 1);
|
||||
const p = routeProgress([[2,48],[2.1,48]], {lon:2.05,lat:48});
|
||||
assert.ok(p.away < 1 && Math.abs(p.fraction - .5) < .001);
|
||||
assert.match(instruction({maneuver:{type:'turn',modifier:'left'},name:'Rue A'}), /gauche.*Rue A/);
|
||||
});
|
||||
const now = Date.UTC(2026,8,12);
|
||||
const collection = {type:'FeatureCollection',features:[{type:'Feature',geometry:{type:'Point',coordinates:[2,48]},properties:{id:'a',country:'FR',radiusM:1000,source:'Test',updatedAt:'2026-09-01',expiresAt:'2026-10-01'}}]};
|
||||
test('zone validation and alerts exclude expired, foreign, stale and inaccurate positions', () => {
|
||||
const zones = validateZones(collection);
|
||||
const gps = {lat:48,lon:2,source:'gps',accuracy:10,timestamp:now};
|
||||
assert.equal(nearbyZones(zones,gps,'FR',now).length,1);
|
||||
for (const change of [{source:'manual'},{accuracy:200},{timestamp:now-16000}]) assert.equal(nearbyZones(zones,{...gps,...change},'FR',now).length,0);
|
||||
assert.equal(nearbyZones(zones,gps,'BE',now).length,0);
|
||||
assert.equal(nearbyZones(zones,{...gps,timestamp:now+40*86400000},'FR',now+40*86400000).length,0);
|
||||
assert.throws(()=>validateZones({type:'FeatureCollection',features:[{}]}));
|
||||
const invalid = structuredClone(collection); invalid.features[0].properties.radiusM = 0;
|
||||
assert.throws(()=>validateZones(invalid));
|
||||
});
|
||||
test('custom extreme colors are adjusted for light and dark contrast', () => {
|
||||
assert.notEqual(palette('#ffffff',true).accent,'#ffffff');
|
||||
assert.notEqual(palette('#000000',false).accent,'#000000');
|
||||
assert.equal(palette('invalid').accent,palette('#d2ef92').accent);
|
||||
});
|
||||
test('weather cache, coordinate validation, upstream errors, search normalization', async () => {
|
||||
let calls = 0, time = 100000;
|
||||
const service = createServices({now:()=>time,env:{},fetcher:async url=>{
|
||||
calls++;
|
||||
if (url.hostname.includes('photon')) return {ok:true,json:async()=>({features:[{geometry:{type:'Point',coordinates:[2,48]},properties:{name:'Paris',city:'Paris',country:'France'}}]})};
|
||||
return {ok:true,json:async()=>({current:{temperature_2m:20,wind_speed_10m:5,weather_code:0,time:'2026-09-12T12:00'},daily:{temperature_2m_min:[12],temperature_2m_max:[22]}})};
|
||||
}});
|
||||
const params = new URLSearchParams({lat:'48',lon:'2'});
|
||||
const responses = await Promise.all([service('/api/weather',params),service('/api/weather',params)]);
|
||||
assert.equal(responses[0].temperature,20); assert.equal(calls,1);
|
||||
await service('/api/weather',params); assert.equal(calls,1);
|
||||
await assert.rejects(service('/api/weather',new URLSearchParams({lat:'',lon:'2'})),{status:400});
|
||||
await assert.rejects(service('/api/weather',new URLSearchParams({lat:'91',lon:'2'})),{status:400});
|
||||
await assert.rejects(service('/api/weather',new URLSearchParams({lat:'49',lon:'2'})),{status:429});
|
||||
time += 2000;
|
||||
const places = await service('/api/search',new URLSearchParams({q:'Paris'}));
|
||||
assert.equal(places[0].label,'Paris, France');
|
||||
const broken = createServices({env:{},fetcher:async()=>{throw new Error('Offline');}});
|
||||
await assert.rejects(broken('/api/weather',params),{status:502});
|
||||
});
|
||||
test('routing coordinates and NoRoute response are handled', async () => {
|
||||
let target;
|
||||
const service = createServices({env:{},fetcher:async url=>{target=url;return {ok:true,json:async()=>({code:'NoRoute'})};}});
|
||||
await assert.rejects(service('/api/route',new URLSearchParams({fromLat:'48',fromLon:'2',toLat:'49',toLon:'3'})),{status:422});
|
||||
assert.match(target.pathname,/2,48;3,49$/);
|
||||
});
|
||||
+14
-2
@@ -3,12 +3,20 @@ import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolve } from 'node:path';
|
||||
import { telemetry, profiles } from './telemetry.mjs';
|
||||
import { createServices } from './services.mjs';
|
||||
|
||||
const assets = new Map([
|
||||
['/', ['index.html', 'text/html']], ['/index.html', ['index.html', 'text/html']],
|
||||
['/app.js', ['app.js', 'text/javascript']], ['/styles.css', ['styles.css', 'text/css']]
|
||||
['/app.js', ['app.js', 'text/javascript']], ['/styles.css', ['styles.css', 'text/css']],
|
||||
...['journey.js', 'geo.js', 'theme.js', 'applications.js', 'cockpit.js', 'embeds.js'].map(name => [`/${name}`, [name, 'text/javascript']]),
|
||||
['/cockpit.css', ['cockpit.css', 'text/css']],
|
||||
['/applications.css', ['applications.css', 'text/css']],
|
||||
['/journey.css', ['journey.css', 'text/css']],
|
||||
['/vendor/leaflet/leaflet.js', ['vendor/leaflet/leaflet.js', 'text/javascript']],
|
||||
['/vendor/leaflet/leaflet.css', ['vendor/leaflet/leaflet.css', 'text/css']]
|
||||
]);
|
||||
export function createServer() {
|
||||
export function createServer(options) {
|
||||
const services = createServices(options);
|
||||
return http.createServer(async (req, res) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
@@ -23,6 +31,10 @@ export function createServer() {
|
||||
const profile = url.searchParams.get('profile') || 'route';
|
||||
return profiles.includes(profile) ? send(200, telemetry(profile)) : send(400, { error: 'Profil inconnu' });
|
||||
}
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
try { return send(200, await services(url.pathname, url.searchParams)); }
|
||||
catch (error) { return send(error.status || 502, { error: error.status ? error.message : 'Service indisponible' }); }
|
||||
}
|
||||
const asset = assets.get(url.pathname);
|
||||
if (!asset) return send(404, { error: 'Introuvable' });
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// Public prototype providers, replaceable with self-hosted instances.
|
||||
export class ServiceError extends Error {
|
||||
constructor(status, message) { super(message); this.status = status; }
|
||||
}
|
||||
function number(params, name, min, max) {
|
||||
const raw = params.get(name), n = raw?.trim() ? Number(raw) : NaN;
|
||||
if (!Number.isFinite(n) || n < min || n > max) throw new ServiceError(400, `Coordonnée ${name} invalide`);
|
||||
return n;
|
||||
}
|
||||
export function createServices({ fetcher = fetch, now = Date.now, env = process.env } = {}) {
|
||||
const cache = new Map(), inflight = new Map(), last = new Map();
|
||||
const endpoints = {
|
||||
weather: env.WEATHER_URL || 'https://api.open-meteo.com/v1/forecast',
|
||||
search: env.GEOCODER_URL || 'https://photon.komoot.io/api/',
|
||||
route: env.ROUTER_URL || 'https://router.project-osrm.org/route/v1/driving/'
|
||||
};
|
||||
async function request(kind, url, ttl) {
|
||||
const key = url.href, saved = cache.get(key);
|
||||
if (saved && now() - saved.time < ttl) return saved.data;
|
||||
if (inflight.has(key)) return inflight.get(key);
|
||||
if (now() - (last.get(kind) ?? -Infinity) < 1100 || inflight.size >= 6) throw new ServiceError(429, 'Patientez un instant avant de réessayer.');
|
||||
last.set(kind, now());
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const response = await fetcher(url, { signal: AbortSignal.timeout(8000), headers: { 'User-Agent': 'CARIA-Cockpit/0.2 (personal automotive interface)', Accept: 'application/json' } });
|
||||
if (!response.ok) throw new Error('Upstream');
|
||||
const data = await response.json();
|
||||
if (cache.size >= 128) cache.delete(cache.keys().next().value);
|
||||
cache.set(key, { time: now(), data }); return data;
|
||||
} catch { throw new ServiceError(502, 'Service externe indisponible. Réessayez avec une connexion Internet.'); }
|
||||
finally { inflight.delete(key); }
|
||||
})();
|
||||
inflight.set(key, promise); return promise;
|
||||
}
|
||||
return async (path, params) => {
|
||||
if (path === '/api/map-config') return {
|
||||
tileUrl: env.TILE_URL || 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
tileAttribution: env.TILE_ATTRIBUTION || '© OpenStreetMap contributors'
|
||||
};
|
||||
if (path === '/api/weather') {
|
||||
const lat = number(params, 'lat', -90, 90), lon = number(params, 'lon', -180, 180);
|
||||
const url = new URL(endpoints.weather);
|
||||
url.search = new URLSearchParams({ latitude: lat.toFixed(2), longitude: lon.toFixed(2), current: 'temperature_2m,weather_code,wind_speed_10m', daily: 'temperature_2m_max,temperature_2m_min', timezone: 'auto', forecast_days: '1' });
|
||||
const d = await request('weather', url, 15 * 60000);
|
||||
if (![d.current?.temperature_2m, d.current?.wind_speed_10m, d.current?.weather_code, d.daily?.temperature_2m_min?.[0], d.daily?.temperature_2m_max?.[0]].every(Number.isFinite)) throw new ServiceError(502, 'Réponse météo incomplète');
|
||||
return { source: 'Open-Meteo', fetchedAt: now(), observedAt: d.current.time, temperature: d.current.temperature_2m, code: d.current.weather_code, wind: d.current.wind_speed_10m, min: d.daily.temperature_2m_min[0], max: d.daily.temperature_2m_max[0] };
|
||||
}
|
||||
if (path === '/api/search') {
|
||||
const q = params.get('q')?.trim();
|
||||
if (!q || q.length < 3 || q.length > 180) throw new ServiceError(400, 'Saisissez entre 3 et 180 caractères.');
|
||||
const url = new URL(endpoints.search); url.search = new URLSearchParams({ q, lang: 'fr', limit: '5' });
|
||||
const d = await request('search', url, 24 * 3600000);
|
||||
if (!Array.isArray(d.features)) throw new ServiceError(502, 'Réponse de recherche invalide');
|
||||
return d.features.slice(0, 5).filter(f => f.geometry?.type === 'Point' && f.geometry.coordinates?.length === 2 && f.geometry.coordinates.every(Number.isFinite)).map(f => ({
|
||||
lon: f.geometry.coordinates[0], lat: f.geometry.coordinates[1],
|
||||
label: [...new Set([f.properties?.name, f.properties?.street, f.properties?.housenumber, f.properties?.postcode, f.properties?.city, f.properties?.country].filter(Boolean))].join(', ').slice(0, 300)
|
||||
}));
|
||||
}
|
||||
if (path === '/api/route') {
|
||||
const fromLat = number(params, 'fromLat', -85, 85), fromLon = number(params, 'fromLon', -180, 180);
|
||||
const toLat = number(params, 'toLat', -85, 85), toLon = number(params, 'toLon', -180, 180);
|
||||
const url = new URL(`${endpoints.route.replace(/\/$/, '')}/${fromLon},${fromLat};${toLon},${toLat}`);
|
||||
url.search = new URLSearchParams({ overview: 'full', geometries: 'geojson', steps: 'true' });
|
||||
const d = await request('route', url, 60000), route = d.routes?.[0];
|
||||
if (d.code !== 'Ok' || !route || route.geometry?.type !== 'LineString' || !Array.isArray(route.geometry.coordinates) || route.geometry.coordinates.length < 2 || route.geometry.coordinates.length > 100000 || route.geometry.coordinates.some(c => !Array.isArray(c) || c.length !== 2 || !c.every(Number.isFinite)) || !Number.isFinite(route.distance) || !Number.isFinite(route.duration) || !Array.isArray(route.legs)) throw new ServiceError(422, 'Aucun itinéraire routier trouvé.');
|
||||
const steps = route.legs.flatMap(leg => leg.steps || []);
|
||||
if (!steps.length || steps.some(s => !s.maneuver || !Number.isFinite(s.distance) || !Number.isFinite(s.duration))) throw new ServiceError(502, 'Étapes de navigation incomplètes');
|
||||
return { source: 'OSRM', distance: route.distance, duration: route.duration, geometry: route.geometry, steps: steps.map(s => ({ distance: s.distance, duration: s.duration, name: s.name, maneuver: s.maneuver })) };
|
||||
}
|
||||
throw new ServiceError(404, 'Introuvable');
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user