Add scenic waypoints for drive legs (OSRM via-routing)

- Transport entries carry an optional ordered waypoints array of
  lat/lng/name points; /route attaches them to the ground leg the
  transport bridges, and /api/directions accepts a via param so the
  drawn road route detours through them
- Day editor gains a geocoded "Scenic waypoints" list on transport
  entries; the map draws leg-coloured waypoint dots
- Escape waypoint names in the Leaflet tooltip (stored-XSS fix flagged
  by security review: names are user-typed and Leaflet renders string
  tooltips as HTML)
This commit is contained in:
2026-07-20 14:57:26 +07:00
parent 9369c82e56
commit e2c3089c25
15 changed files with 594 additions and 16 deletions
+2
View File
@@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS entries (
rental TEXT,
transport_mode TEXT,
auto_ref TEXT,
waypoints TEXT,
created_at TEXT DEFAULT current_timestamp
);
@@ -75,6 +76,7 @@ const MIGRATIONS = [
{ table: 'entries', column: 'end_date', ddl: 'ALTER TABLE entries ADD COLUMN end_date TEXT' },
{ table: 'entries', column: 'transport_mode', ddl: 'ALTER TABLE entries ADD COLUMN transport_mode TEXT' },
{ table: 'entries', column: 'auto_ref', ddl: 'ALTER TABLE entries ADD COLUMN auto_ref TEXT' },
{ table: 'entries', column: 'waypoints', ddl: 'ALTER TABLE entries ADD COLUMN waypoints TEXT' },
{ table: 'trip_members', column: 'sort_order', ddl: 'ALTER TABLE trip_members ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0' },
];
+27 -4
View File
@@ -20,27 +20,50 @@ function round1(n) {
return Math.round(n * 10) / 10;
}
const MAX_VIA = 8;
// Parses the optional `via` query param: up to 8 "lat,lng" pairs separated by
// '|'. Returns { points: [...] } (points is [] when via is absent) or
// { error: true } when any pair is malformed/out of range or there are too many.
function parseVia(value) {
if (value === undefined) return { points: [] };
const parts = String(value).split('|');
if (parts.length > MAX_VIA) return { error: true };
const points = [];
for (const part of parts) {
const point = parseLatLng(part);
if (!point) return { error: true };
points.push(point);
}
return { points };
}
export default function directionsRoutes() {
const router = express.Router();
const cache = new Map(); // "lat,lng;lat,lng" (rounded) -> { at, data }
const cache = new Map(); // "lat,lng;lat,lng;..." (rounded) -> { at, data }
// GET /api/directions?from=lat,lng&to=lat,lng
// GET /api/directions?from=lat,lng&to=lat,lng[&via=lat,lng|lat,lng...]
router.get('/', async (req, res) => {
const from = parseLatLng(req.query.from);
const to = parseLatLng(req.query.to);
if (!from || !to) {
return res.status(400).json({ error: 'from and to must be lat,lng' });
}
const via = parseVia(req.query.via);
if (via.error) {
return res.status(400).json({ error: 'via must be up to 8 lat,lng pairs' });
}
const osrmUrl = process.env.OSRM_URL || 'https://router.project-osrm.org';
const key = `${from.lat.toFixed(5)},${from.lng.toFixed(5)};${to.lat.toFixed(5)},${to.lng.toFixed(5)}`;
const coords = [from, ...via.points, to];
const key = coords.map((p) => `${p.lat.toFixed(5)},${p.lng.toFixed(5)}`).join(';');
const cached = cache.get(key);
if (cached && Date.now() - cached.at < CACHE_TTL_MS) {
return res.status(200).json(cached.data);
}
const url = `${osrmUrl}/route/v1/driving/${from.lng},${from.lat};${to.lng},${to.lat}?overview=full&geometries=geojson`;
const url = `${osrmUrl}/route/v1/driving/${coords.map((p) => `${p.lng},${p.lat}`).join(';')}?overview=full&geometries=geojson`;
let data;
try {
const upstream = await fetch(url, {
+21 -3
View File
@@ -4,6 +4,7 @@ import { membership } from '../util/access.js';
import { ENTRY_COLUMNS, attachParticipants } from '../util/entrySerialize.js';
import { validateSegments } from '../util/segments.js';
import { validateRental } from '../util/rental.js';
import { validateWaypoints } from '../util/waypoints.js';
import { autoTransportTitle } from '../util/autoTransport.js';
const ENTRY_TYPES = new Set(['flight', 'transport', 'activity', 'rental', 'stay', 'note']);
@@ -208,6 +209,22 @@ function validateEntry(body, { partial, existing, memberIds }) {
}
}
// waypoints: transport-only scenic via-points (null or [] clears them).
if (has('waypoints')) {
const v = body.waypoints;
if (v === null || (Array.isArray(v) && v.length === 0)) {
fields.waypoints = null;
} else {
const effType = 'type' in fields ? fields.type : existing?.type;
if (effType !== 'transport') {
return { error: 'waypoints are only allowed on transport entries' };
}
const checked = validateWaypoints(v);
if (checked.error) return { error: checked.error };
fields.waypoints = JSON.stringify(checked.value);
}
}
return { fields, participants, hasParticipants: has('participants') };
}
@@ -306,8 +323,8 @@ export default function entriesRoutes(db) {
`INSERT INTO entries
(trip_id, date, end_date, type, title, details, start_time, end_time,
location_name, lat, lng, sort_order, price, paid_by, split_mode, segments, rental,
transport_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
transport_mode, waypoints)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
tripId,
@@ -327,7 +344,8 @@ export default function entriesRoutes(db) {
f.split_mode ?? 'equal',
f.segments ?? null,
f.rental ?? null,
f.transport_mode ?? null
f.transport_mode ?? null,
f.waypoints ?? null
);
const id = Number(info.lastInsertRowid);
// participants provided as an array -> store rows; null/absent -> all members.
+5
View File
@@ -8,9 +8,11 @@ import {
attachParticipantsAll,
parseSegments,
parseRental,
parseWaypoints,
} from '../util/entrySerialize.js';
import { generateJoinCode, formatJoinCode, normalizeCode } from '../util/token.js';
import { regenerateAutoTransports } from '../util/autoTransport.js';
import { buildLegWaypointsResolver } from '../util/legWaypoints.js';
const MAX_RANGE_DAYS = 365;
const MIN_LEG_KM = 0.05;
@@ -316,9 +318,11 @@ export default function tripsRoutes(db) {
...r,
segments: parseSegments(r.segments),
rental: parseRental(r.rental),
waypoints: parseWaypoints(r.waypoints),
}));
const stops = buildStops(allEntries);
const waypointsForLeg = buildLegWaypointsResolver(allEntries);
const legs = [];
let totalKm = 0;
@@ -338,6 +342,7 @@ export default function tripsRoutes(db) {
toEntryId: b.entryId,
km: Math.round(km * 10) / 10,
mode,
waypoints: isAir ? [] : waypointsForLeg(a.date, b.date),
});
totalKm += km;
if (isAir) kmAir += km;
+13 -1
View File
@@ -4,7 +4,7 @@
export const ENTRY_COLUMNS =
'id, trip_id, date, end_date, type, title, details, start_time, end_time, ' +
'location_name, lat, lng, sort_order, price, paid_by, split_mode, segments, rental, transport_mode, auto_ref';
'location_name, lat, lng, sort_order, price, paid_by, split_mode, segments, rental, transport_mode, auto_ref, waypoints';
// Parse the stored segments JSON text into an array, or null if absent/invalid.
export function parseSegments(value) {
@@ -41,6 +41,17 @@ export function parseAutoRef(value) {
}
}
// Parse the stored waypoints JSON text into an array, or null if absent/invalid.
export function parseWaypoints(value) {
if (!value) return null;
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}
export function attachParticipants(db, row) {
if (!row) return row;
const rows = db
@@ -50,6 +61,7 @@ export function attachParticipants(db, row) {
row.segments = parseSegments(row.segments);
row.rental = parseRental(row.rental);
row.auto_ref = parseAutoRef(row.auto_ref);
row.waypoints = parseWaypoints(row.waypoints);
return row;
}
+23
View File
@@ -0,0 +1,23 @@
// Attach scenic waypoints (see "Scenic waypoints" in docs/API.md) to route
// legs. A ground leg's waypoints come from the transport entry that bridges
// it: the earliest (by date, id) transport entry with a non-empty waypoints
// array whose date falls within the leg's stop date range, inclusive. A given
// transport bridges at most one leg — once claimed by a leg it's excluded
// from later legs, so the first leg in stop order wins.
export function buildLegWaypointsResolver(entries) {
const candidates = entries
.filter((e) => e.type === 'transport' && Array.isArray(e.waypoints) && e.waypoints.length > 0)
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : a.id - b.id));
const used = new Set();
return function waypointsForLeg(fromDate, toDate) {
for (const e of candidates) {
if (used.has(e.id)) continue;
if (e.date >= fromDate && e.date <= toDate) {
used.add(e.id);
return e.waypoints;
}
}
return [];
};
}
+42
View File
@@ -0,0 +1,42 @@
// Validation + normalization for transport-entry scenic waypoints (see
// "Scenic waypoints" in docs/API.md).
const MAX_WAYPOINTS = 8;
const MAX_NAME_LEN = 120;
function validCoord(v, min, max) {
return typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max;
}
// Validate a waypoints array (already known non-empty/array by the caller's
// clearing check). Returns { error } or { value: normalizedArray }.
export function validateWaypoints(waypoints) {
if (!Array.isArray(waypoints)) {
return { error: 'waypoints must be an array' };
}
if (waypoints.length > MAX_WAYPOINTS) {
return { error: 'at most 8 waypoints' };
}
const out = [];
for (const w of waypoints) {
if (!w || typeof w !== 'object' || Array.isArray(w)) {
return { error: 'waypoint lat/lng out of range' };
}
if (!validCoord(w.lat, -90, 90) || !validCoord(w.lng, -180, 180)) {
return { error: 'waypoint lat/lng out of range' };
}
const point = { lat: w.lat, lng: w.lng };
if (w.name !== undefined && w.name !== null) {
if (typeof w.name !== 'string' || w.name.length > MAX_NAME_LEN) {
return { error: 'waypoint name must be a string of at most 120 characters' };
}
const trimmed = w.name.trim();
if (trimmed.length > MAX_NAME_LEN) {
return { error: 'waypoint name must be a string of at most 120 characters' };
}
if (trimmed !== '') point.name = trimmed;
}
out.push(point);
}
return { value: out };
}