refactor
This commit is contained in:
+84
-68
@@ -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}N° ${longitude}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
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user