This commit is contained in:
Vincent van der Wal
2026-07-19 14:57:26 +02:00
parent 9e1944396e
commit 6d81af8df5
26 changed files with 408 additions and 466 deletions
+84 -68
View File
@@ -1,13 +1,21 @@
import { error, redirect } from '@sveltejs/kit';
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
import type { GeoLocation } from '$lib/stores/settings';
export const geoLocationNameToRoute = (name: string) => {
const lowerCase = name.toLowerCase().replaceAll(' ', '-');
return lowerCase.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
};
// coordinate routes look like "52.52N13.41E" (negative values for S/W); GPS
// selections navigate here directly, no geocoding id involved
const COORD_ROUTE = /^(-?\d+(?:\.\d+)?)N(-?\d+(?:\.\d+)?)E$/i;
export function buildLocationRoute(location: GeoLocation): string {
// coordinate-only locations (GPS) have no real geocoding id
if (location.feature_code === 'COORD' || !location.id) {
return `${location.latitude.toFixed(4)}N${location.longitude.toFixed(4)}E`;
}
const locationRoute = geoLocationNameToRoute(location.name);
if (location.population && location.population > 543000) {
return locationRoute;
@@ -15,6 +23,41 @@ export function buildLocationRoute(location: GeoLocation): string {
return locationRoute + '_' + location.id;
}
export const coordinateLocation = (latitude: number, longitude: number): GeoLocation => ({
id: 0,
name: `${latitude.toFixed(2)}°N ${longitude.toFixed(2)}°E`,
latitude,
longitude,
elevation: 0,
feature_code: 'COORD',
country_code: undefined,
admin1_id: undefined,
admin3_id: undefined,
admin4_id: undefined,
timezone: 'UTC',
population: undefined,
postcodes: undefined,
country_id: undefined,
country: undefined,
admin1: undefined,
admin3: undefined,
admin4: undefined
});
// the geocoding API response is untrusted input: it can be an error object or
// (with a crafted URL) something else entirely, so the shape is checked before
// anything downstream dereferences it
const isGeoLocation = (value: unknown): value is GeoLocation => {
if (typeof value !== 'object' || value === null) return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.name === 'string' &&
typeof candidate.id === 'number' &&
Number.isFinite(candidate.latitude) &&
Number.isFinite(candidate.longitude)
);
};
interface ResolveLocationOptions {
urlLocation: string;
routePrefix: string;
@@ -29,80 +72,53 @@ export async function resolveLocationFromRoute({
routePrefix,
event
}: ResolveLocationOptions): Promise<GeoLocation> {
let location: GeoLocation;
const coordMatch = urlLocation.match(COORD_ROUTE);
if (coordMatch) {
return coordinateLocation(parseFloat(coordMatch[1]), parseFloat(coordMatch[2]));
}
if (urlLocation.includes('N') && urlLocation.includes('E')) {
const parts = urlLocation.split(/N|E/);
const latitude = parseFloat(parts[0]);
const longitude = parseFloat(parts[1]);
let urlLocationName: string;
let urlLocationId: string | undefined;
location = {
id: 0,
name: `${latitude}${longitude}`,
latitude,
longitude,
elevation: 0,
feature_code: 'COORD',
country_code: undefined,
admin1_id: undefined,
admin3_id: undefined,
admin4_id: undefined,
timezone: 'UTC',
population: undefined,
postcodes: undefined,
country_id: undefined,
country: undefined,
admin1: undefined,
admin3: undefined,
admin4: undefined
};
if (urlLocation.includes('_')) {
const split = urlLocation.split('_');
urlLocationName = split[0];
urlLocationId = split[1];
} else if (/^\d+$/.test(urlLocation)) {
urlLocationName = '';
urlLocationId = urlLocation;
} else {
let urlLocationName: string;
let urlLocationId: string | undefined;
urlLocationName = urlLocation.includes('-') ? urlLocation.replace(/-/g, ' ') : urlLocation;
urlLocationId = undefined;
}
if (urlLocation.includes('_')) {
const split = urlLocation.split('_');
urlLocationName = split[0];
urlLocationId = split[1];
} else if (urlLocation.includes('-')) {
urlLocationName = urlLocation.replace(/-/g, ' ');
} else if (/^\d+$/.test(urlLocation)) {
urlLocationName = '';
urlLocationId = urlLocation;
} else {
urlLocationName = urlLocation;
urlLocationId = undefined;
}
let location: GeoLocation;
if (urlLocationId) {
const res = await event.fetch(
`https://geocoding-api.open-meteo.com/v1/get?id=${urlLocationId}`
);
location = await res.json();
} else {
const res = await event.fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${urlLocationName}&count=1&language=en&format=json`
);
const geocodingResponse = await res.json();
if (geocodingResponse.results) {
location = geocodingResponse.results[0];
} else {
error(404, 'Location not found');
}
}
const locationRoute = geoLocationNameToRoute(location.name);
const canonicalSuffix =
location.population && location.population > 543000
? locationRoute
: locationRoute + '_' + location.id;
const canonicalPath = `${routePrefix}${canonicalSuffix}`;
// route params are attacker-controlled: ids must be numeric and names are
// URL-encoded so nothing can be injected into the API query string
if (urlLocationId && /^\d+$/.test(urlLocationId)) {
const res = await event.fetch(
`https://geocoding-api.open-meteo.com/v1/get?id=${encodeURIComponent(urlLocationId)}`
);
if (!res.ok) error(404, 'Location not found');
const candidate = await res.json();
if (!isGeoLocation(candidate)) error(404, 'Location not found');
location = candidate;
} else {
const res = await event.fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(urlLocationName)}&count=1&language=en&format=json`
);
if (!res.ok) error(404, 'Location not found');
const geocodingResponse = await res.json();
const candidate = geocodingResponse?.results?.[0];
if (!isGeoLocation(candidate)) error(404, 'Location not found');
location = candidate;
}
if (event.url.pathname !== canonicalPath) {
throw redirect(303, canonicalPath);
}
const canonicalPath = `${routePrefix}${buildLocationRoute(location)}`;
if (event.url.pathname !== canonicalPath) {
throw redirect(303, canonicalPath);
}
storedLocation.set(location);
return location;
}