mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 05:41:47 +00:00
5cc81ae4b0
Replace node-fetch v2 with Node 22's built-in fetch API across the entire server.
Add undici as an explicit dependency to provide the dispatcher API needed for
DNS pinning (SSRF rebinding prevention) in ssrfGuard.ts. All seven service files
that used a plain `import fetch from 'node-fetch'` are updated to use the global.
The ssrfGuard safeFetch/createPinnedAgent is rewritten as createPinnedDispatcher
using an undici Agent, with correct handling of the `all: true` lookup callback
required by Node 18+. The collabService dynamic require() and notifications agent
option are updated to use the dispatcher pattern. Test mocks are migrated from
vi.mock('node-fetch') to vi.stubGlobal('fetch'), and streaming test fixtures are
updated to use Web ReadableStream instead of Node Readable.
Fix several bugs in the Synology and Immich photo integrations:
- pipeAsset: guard against setting headers after stream has already started
- _getSynologySession: clear stale SID and re-login when decrypt_api_key returns null
instead of propagating success(null) downstream
- _requestSynologyApi: return retrySession error (not stale session) on retry failure;
also retry on error codes 106 (timeout) and 107 (duplicate login), not only 119
- searchSynologyPhotos: fix incorrect total field type (Synology list_item returns no
total); hasMore correctly uses allItems.length === limit
- _splitPackedSynologyId: validate cache_key format before use; callers return 400
- getImmichCredentials / _getSynologyCredentials: treat null from decrypt_api_key as
a missing-credentials condition rather than casting null to string
- Synology size param: enforce allowlist ['sm', 'm', 'xl'] per API documentation
531 lines
22 KiB
TypeScript
531 lines
22 KiB
TypeScript
import { db } from '../db/database';
|
|
import { decrypt_api_key } from './apiKeyCrypto';
|
|
import { checkSsrf } from '../utils/ssrfGuard';
|
|
|
|
// ── Interfaces ───────────────────────────────────────────────────────────────
|
|
|
|
interface NominatimResult {
|
|
osm_type: string;
|
|
osm_id: string;
|
|
name?: string;
|
|
display_name?: string;
|
|
lat: string;
|
|
lon: string;
|
|
}
|
|
|
|
interface OverpassElement {
|
|
tags?: Record<string, string>;
|
|
}
|
|
|
|
interface WikiCommonsPage {
|
|
imageinfo?: { url?: string; extmetadata?: { Artist?: { value?: string } } }[];
|
|
}
|
|
|
|
interface GooglePlaceResult {
|
|
id: string;
|
|
displayName?: { text: string };
|
|
formattedAddress?: string;
|
|
location?: { latitude: number; longitude: number };
|
|
rating?: number;
|
|
websiteUri?: string;
|
|
nationalPhoneNumber?: string;
|
|
types?: string[];
|
|
}
|
|
|
|
interface GooglePlaceDetails extends GooglePlaceResult {
|
|
userRatingCount?: number;
|
|
regularOpeningHours?: { weekdayDescriptions?: string[]; openNow?: boolean };
|
|
googleMapsUri?: string;
|
|
editorialSummary?: { text: string };
|
|
reviews?: { authorAttribution?: { displayName?: string; photoUri?: string }; rating?: number; text?: { text?: string }; relativePublishTimeDescription?: string }[];
|
|
photos?: { name: string; authorAttributions?: { displayName?: string }[] }[];
|
|
}
|
|
|
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
|
|
const UA = 'TREK Travel Planner (https://github.com/mauriceboe/NOMAD)';
|
|
|
|
// ── Photo cache ──────────────────────────────────────────────────────────────
|
|
|
|
const photoCache = new Map<string, { photoUrl: string; attribution: string | null; fetchedAt: number; error?: boolean }>();
|
|
const PHOTO_TTL = 12 * 60 * 60 * 1000; // 12 hours
|
|
const ERROR_TTL = 5 * 60 * 1000; // 5 min for errors
|
|
const CACHE_MAX_ENTRIES = 1000;
|
|
const CACHE_PRUNE_TARGET = 500;
|
|
const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
|
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [key, entry] of photoCache) {
|
|
if (now - entry.fetchedAt > PHOTO_TTL) photoCache.delete(key);
|
|
}
|
|
if (photoCache.size > CACHE_MAX_ENTRIES) {
|
|
const entries = [...photoCache.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
|
|
const toDelete = entries.slice(0, entries.length - CACHE_PRUNE_TARGET);
|
|
toDelete.forEach(([key]) => photoCache.delete(key));
|
|
}
|
|
}, CACHE_CLEANUP_INTERVAL);
|
|
|
|
// ── API key retrieval ────────────────────────────────────────────────────────
|
|
|
|
export function getMapsKey(userId: number): string | null {
|
|
const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(userId) as { maps_api_key: string | null } | undefined;
|
|
const user_key = decrypt_api_key(user?.maps_api_key);
|
|
if (user_key) return user_key;
|
|
const admin = db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get() as { maps_api_key: string } | undefined;
|
|
return decrypt_api_key(admin?.maps_api_key) || null;
|
|
}
|
|
|
|
// ── Nominatim search ─────────────────────────────────────────────────────────
|
|
|
|
export async function searchNominatim(query: string, lang?: string) {
|
|
const params = new URLSearchParams({
|
|
q: query,
|
|
format: 'json',
|
|
addressdetails: '1',
|
|
limit: '10',
|
|
'accept-language': lang || 'en',
|
|
});
|
|
const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, {
|
|
headers: { 'User-Agent': UA },
|
|
});
|
|
if (!response.ok) throw new Error('Nominatim API error');
|
|
const data = await response.json() as NominatimResult[];
|
|
return data.map(item => ({
|
|
google_place_id: null,
|
|
osm_id: `${item.osm_type}:${item.osm_id}`,
|
|
name: item.name || item.display_name?.split(',')[0] || '',
|
|
address: item.display_name || '',
|
|
lat: parseFloat(item.lat) || null,
|
|
lng: parseFloat(item.lon) || null,
|
|
rating: null,
|
|
website: null,
|
|
phone: null,
|
|
source: 'openstreetmap',
|
|
}));
|
|
}
|
|
|
|
// ── Overpass API (OSM details) ───────────────────────────────────────────────
|
|
|
|
export async function fetchOverpassDetails(osmType: string, osmId: string): Promise<OverpassElement | null> {
|
|
const typeMap: Record<string, string> = { node: 'node', way: 'way', relation: 'rel' };
|
|
const oType = typeMap[osmType];
|
|
if (!oType) return null;
|
|
const query = `[out:json][timeout:5];${oType}(${osmId});out tags;`;
|
|
try {
|
|
const res = await fetch('https://overpass-api.de/api/interpreter', {
|
|
method: 'POST',
|
|
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: `data=${encodeURIComponent(query)}`,
|
|
});
|
|
if (!res.ok) return null;
|
|
const data = await res.json() as { elements?: OverpassElement[] };
|
|
return data.elements?.[0] || null;
|
|
} catch { return null; }
|
|
}
|
|
|
|
// ── Opening hours parsing ────────────────────────────────────────────────────
|
|
|
|
export function parseOpeningHours(ohString: string): { weekdayDescriptions: string[]; openNow: boolean | null } {
|
|
const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
|
|
const LONG = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
|
const result: string[] = LONG.map(d => `${d}: ?`);
|
|
|
|
// Parse segments like "Mo-Fr 09:00-18:00; Sa 10:00-14:00"
|
|
for (const segment of ohString.split(';')) {
|
|
const trimmed = segment.trim();
|
|
if (!trimmed) continue;
|
|
const match = trimmed.match(/^((?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?(?:\s*,\s*(?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?)*)\s+(.+)$/i);
|
|
if (!match) continue;
|
|
const [, daysPart, timePart] = match;
|
|
const dayIndices = new Set<number>();
|
|
for (const range of daysPart.split(',')) {
|
|
const parts = range.trim().split('-').map(d => DAYS.indexOf(d.trim()));
|
|
if (parts.length === 2 && parts[0] >= 0 && parts[1] >= 0) {
|
|
for (let i = parts[0]; i !== (parts[1] + 1) % 7; i = (i + 1) % 7) dayIndices.add(i);
|
|
dayIndices.add(parts[1]);
|
|
} else if (parts[0] >= 0) {
|
|
dayIndices.add(parts[0]);
|
|
}
|
|
}
|
|
for (const idx of dayIndices) {
|
|
result[idx] = `${LONG[idx]}: ${timePart.trim()}`;
|
|
}
|
|
}
|
|
|
|
// Compute openNow
|
|
let openNow: boolean | null = null;
|
|
try {
|
|
const now = new Date();
|
|
const jsDay = now.getDay();
|
|
const dayIdx = jsDay === 0 ? 6 : jsDay - 1;
|
|
const todayLine = result[dayIdx];
|
|
const timeRanges = [...todayLine.matchAll(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g)];
|
|
if (timeRanges.length > 0) {
|
|
const nowMins = now.getHours() * 60 + now.getMinutes();
|
|
openNow = timeRanges.some(m => {
|
|
const start = parseInt(m[1]) * 60 + parseInt(m[2]);
|
|
const end = parseInt(m[3]) * 60 + parseInt(m[4]);
|
|
return end > start ? nowMins >= start && nowMins < end : nowMins >= start || nowMins < end;
|
|
});
|
|
}
|
|
} catch { /* best effort */ }
|
|
|
|
return { weekdayDescriptions: result, openNow };
|
|
}
|
|
|
|
// ── Build standardized OSM details ───────────────────────────────────────────
|
|
|
|
export function buildOsmDetails(tags: Record<string, string>, osmType: string, osmId: string) {
|
|
let opening_hours: string[] | null = null;
|
|
let open_now: boolean | null = null;
|
|
if (tags.opening_hours) {
|
|
const parsed = parseOpeningHours(tags.opening_hours);
|
|
const hasData = parsed.weekdayDescriptions.some(line => !line.endsWith('?'));
|
|
if (hasData) {
|
|
opening_hours = parsed.weekdayDescriptions;
|
|
open_now = parsed.openNow;
|
|
}
|
|
}
|
|
return {
|
|
website: tags['contact:website'] || tags.website || null,
|
|
phone: tags['contact:phone'] || tags.phone || null,
|
|
opening_hours,
|
|
open_now,
|
|
osm_url: `https://www.openstreetmap.org/${osmType}/${osmId}`,
|
|
summary: tags.description || null,
|
|
source: 'openstreetmap' as const,
|
|
};
|
|
}
|
|
|
|
// ── Wikimedia Commons photo lookup ───────────────────────────────────────────
|
|
|
|
export async function fetchWikimediaPhoto(lat: number, lng: number, name?: string): Promise<{ photoUrl: string; attribution: string | null } | null> {
|
|
// Strategy 1: Search Wikipedia for the place name -> get the article image
|
|
if (name) {
|
|
try {
|
|
const searchParams = new URLSearchParams({
|
|
action: 'query', format: 'json',
|
|
titles: name,
|
|
prop: 'pageimages',
|
|
piprop: 'thumbnail',
|
|
pithumbsize: '400',
|
|
pilimit: '1',
|
|
redirects: '1',
|
|
});
|
|
const res = await fetch(`https://en.wikipedia.org/w/api.php?${searchParams}`, { headers: { 'User-Agent': UA } });
|
|
if (res.ok) {
|
|
const data = await res.json() as { query?: { pages?: Record<string, { thumbnail?: { source?: string } }> } };
|
|
const pages = data.query?.pages;
|
|
if (pages) {
|
|
for (const page of Object.values(pages)) {
|
|
if (page.thumbnail?.source) {
|
|
return { photoUrl: page.thumbnail.source, attribution: 'Wikipedia' };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch { /* fall through to geosearch */ }
|
|
}
|
|
|
|
// Strategy 2: Wikimedia Commons geosearch by coordinates
|
|
const params = new URLSearchParams({
|
|
action: 'query', format: 'json',
|
|
generator: 'geosearch',
|
|
ggsprimary: 'all',
|
|
ggsnamespace: '6',
|
|
ggsradius: '300',
|
|
ggscoord: `${lat}|${lng}`,
|
|
ggslimit: '5',
|
|
prop: 'imageinfo',
|
|
iiprop: 'url|extmetadata|mime',
|
|
iiurlwidth: '400',
|
|
});
|
|
try {
|
|
const res = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`, { headers: { 'User-Agent': UA } });
|
|
if (!res.ok) return null;
|
|
const data = await res.json() as { query?: { pages?: Record<string, WikiCommonsPage & { imageinfo?: { mime?: string }[] }> } };
|
|
const pages = data.query?.pages;
|
|
if (!pages) return null;
|
|
for (const page of Object.values(pages)) {
|
|
const info = page.imageinfo?.[0];
|
|
// Only use actual photos (JPEG/PNG), skip SVGs and PDFs
|
|
const mime = (info as { mime?: string })?.mime || '';
|
|
if (info?.url && (mime.startsWith('image/jpeg') || mime.startsWith('image/png'))) {
|
|
const attribution = info.extmetadata?.Artist?.value?.replace(/<[^>]+>/g, '').trim() || null;
|
|
return { photoUrl: info.url, attribution };
|
|
}
|
|
}
|
|
return null;
|
|
} catch { return null; }
|
|
}
|
|
|
|
// ── Search places (Google or Nominatim fallback) ─────────────────────────────
|
|
|
|
export async function searchPlaces(userId: number, query: string, lang?: string): Promise<{ places: Record<string, unknown>[]; source: string }> {
|
|
const apiKey = getMapsKey(userId);
|
|
|
|
if (!apiKey) {
|
|
const places = await searchNominatim(query, lang);
|
|
return { places, source: 'openstreetmap' };
|
|
}
|
|
|
|
const response = await fetch('https://places.googleapis.com/v1/places:searchText', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Goog-Api-Key': apiKey,
|
|
'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types',
|
|
},
|
|
body: JSON.stringify({ textQuery: query, languageCode: lang || 'en' }),
|
|
});
|
|
|
|
const data = await response.json() as { places?: GooglePlaceResult[]; error?: { message?: string } };
|
|
|
|
if (!response.ok) {
|
|
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
|
|
err.status = response.status;
|
|
throw err;
|
|
}
|
|
|
|
const places = (data.places || []).map((p: GooglePlaceResult) => ({
|
|
google_place_id: p.id,
|
|
name: p.displayName?.text || '',
|
|
address: p.formattedAddress || '',
|
|
lat: p.location?.latitude || null,
|
|
lng: p.location?.longitude || null,
|
|
rating: p.rating || null,
|
|
website: p.websiteUri || null,
|
|
phone: p.nationalPhoneNumber || null,
|
|
source: 'google',
|
|
}));
|
|
|
|
return { places, source: 'google' };
|
|
}
|
|
|
|
// ── Place details (Google or OSM) ────────────────────────────────────────────
|
|
|
|
export async function getPlaceDetails(userId: number, placeId: string, lang?: string): Promise<{ place: Record<string, unknown> }> {
|
|
// OSM details: placeId is "node:123456" or "way:123456" etc.
|
|
if (placeId.includes(':')) {
|
|
const [osmType, osmId] = placeId.split(':');
|
|
const element = await fetchOverpassDetails(osmType, osmId);
|
|
if (!element?.tags) return { place: buildOsmDetails({}, osmType, osmId) };
|
|
return { place: buildOsmDetails(element.tags, osmType, osmId) };
|
|
}
|
|
|
|
// Google details
|
|
const apiKey = getMapsKey(userId);
|
|
if (!apiKey) {
|
|
throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
|
|
}
|
|
|
|
const response = await fetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${lang || 'de'}`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'X-Goog-Api-Key': apiKey,
|
|
'X-Goog-FieldMask': 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri,reviews,editorialSummary',
|
|
},
|
|
});
|
|
|
|
const data = await response.json() as GooglePlaceDetails & { error?: { message?: string } };
|
|
|
|
if (!response.ok) {
|
|
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
|
|
err.status = response.status;
|
|
throw err;
|
|
}
|
|
|
|
const place = {
|
|
google_place_id: data.id,
|
|
name: data.displayName?.text || '',
|
|
address: data.formattedAddress || '',
|
|
lat: data.location?.latitude || null,
|
|
lng: data.location?.longitude || null,
|
|
rating: data.rating || null,
|
|
rating_count: data.userRatingCount || null,
|
|
website: data.websiteUri || null,
|
|
phone: data.nationalPhoneNumber || null,
|
|
opening_hours: data.regularOpeningHours?.weekdayDescriptions || null,
|
|
open_now: data.regularOpeningHours?.openNow ?? null,
|
|
google_maps_url: data.googleMapsUri || null,
|
|
summary: data.editorialSummary?.text || null,
|
|
reviews: (data.reviews || []).slice(0, 5).map((r: NonNullable<GooglePlaceDetails['reviews']>[number]) => ({
|
|
author: r.authorAttribution?.displayName || null,
|
|
rating: r.rating || null,
|
|
text: r.text?.text || null,
|
|
time: r.relativePublishTimeDescription || null,
|
|
photo: r.authorAttribution?.photoUri || null,
|
|
})),
|
|
source: 'google' as const,
|
|
};
|
|
|
|
return { place };
|
|
}
|
|
|
|
// ── Place photo (Google or Wikimedia, with caching + DB persistence) ─────────
|
|
|
|
export async function getPlacePhoto(
|
|
userId: number,
|
|
placeId: string,
|
|
lat: number,
|
|
lng: number,
|
|
name?: string,
|
|
): Promise<{ photoUrl: string; attribution: string | null }> {
|
|
// Check cache first
|
|
const cached = photoCache.get(placeId);
|
|
if (cached) {
|
|
const ttl = cached.error ? ERROR_TTL : PHOTO_TTL;
|
|
if (Date.now() - cached.fetchedAt < ttl) {
|
|
if (cached.error) throw Object.assign(new Error('(Cache) No photo available'), { status: 404 });
|
|
return { photoUrl: cached.photoUrl, attribution: cached.attribution };
|
|
}
|
|
photoCache.delete(placeId);
|
|
}
|
|
|
|
const apiKey = getMapsKey(userId);
|
|
const isCoordLookup = placeId.startsWith('coords:');
|
|
|
|
// No Google key or coordinate-only lookup -> try Wikimedia
|
|
if (!apiKey || isCoordLookup) {
|
|
if (!isNaN(lat) && !isNaN(lng)) {
|
|
try {
|
|
const wiki = await fetchWikimediaPhoto(lat, lng, name);
|
|
if (wiki) {
|
|
photoCache.set(placeId, { ...wiki, fetchedAt: Date.now() });
|
|
return wiki;
|
|
} else {
|
|
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
|
|
}
|
|
} catch { /* fall through */ }
|
|
}
|
|
throw Object.assign(new Error('(Wikimedia) No photo available'), { status: 404 });
|
|
}
|
|
|
|
// Google Photos
|
|
const detailsRes = await fetch(`https://places.googleapis.com/v1/places/${placeId}`, {
|
|
headers: {
|
|
'X-Goog-Api-Key': apiKey,
|
|
'X-Goog-FieldMask': 'photos',
|
|
},
|
|
});
|
|
const details = await detailsRes.json() as GooglePlaceDetails & { error?: { message?: string } };
|
|
|
|
if (!detailsRes.ok) {
|
|
console.error('Google Places photo details error:', details.error?.message || detailsRes.status);
|
|
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
|
|
throw Object.assign(new Error('(Google Places) Photo could not be retrieved'), { status: 404 });
|
|
}
|
|
|
|
if (!details.photos?.length) {
|
|
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
|
|
throw Object.assign(new Error('(Google Places) No photo available'), { status: 404 });
|
|
}
|
|
|
|
const photo = details.photos[0];
|
|
const photoName = photo.name;
|
|
const attribution = photo.authorAttributions?.[0]?.displayName || null;
|
|
|
|
const mediaRes = await fetch(
|
|
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400&skipHttpRedirect=true`,
|
|
{ headers: { 'X-Goog-Api-Key': apiKey } }
|
|
);
|
|
const mediaData = await mediaRes.json() as { photoUri?: string };
|
|
const photoUrl = mediaData.photoUri;
|
|
|
|
if (!photoUrl) {
|
|
photoCache.set(placeId, { photoUrl: '', attribution, fetchedAt: Date.now(), error: true });
|
|
throw Object.assign(new Error('(Google Places) Photo URL not available'), { status: 404 });
|
|
}
|
|
|
|
photoCache.set(placeId, { photoUrl, attribution, fetchedAt: Date.now() });
|
|
|
|
// Persist photo URL to database
|
|
try {
|
|
db.prepare(
|
|
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = ?)'
|
|
).run(photoUrl, placeId, '');
|
|
} catch (dbErr) {
|
|
console.error('Failed to persist photo URL to database:', dbErr);
|
|
}
|
|
|
|
return { photoUrl, attribution };
|
|
}
|
|
|
|
// ── Reverse geocoding ────────────────────────────────────────────────────────
|
|
|
|
export async function reverseGeocode(lat: string, lng: string, lang?: string): Promise<{ name: string | null; address: string | null }> {
|
|
const params = new URLSearchParams({
|
|
lat, lon: lng, format: 'json', addressdetails: '1', zoom: '18',
|
|
'accept-language': lang || 'en',
|
|
});
|
|
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, {
|
|
headers: { 'User-Agent': UA },
|
|
});
|
|
if (!response.ok) return { name: null, address: null };
|
|
const data = await response.json() as { name?: string; display_name?: string; address?: Record<string, string> };
|
|
const addr = data.address || {};
|
|
const name = data.name || addr.tourism || addr.amenity || addr.shop || addr.building || addr.road || null;
|
|
return { name, address: data.display_name || null };
|
|
}
|
|
|
|
// ── Resolve Google Maps URL ──────────────────────────────────────────────────
|
|
|
|
export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number; lng: number; name: string | null; address: string | null }> {
|
|
let resolvedUrl = url;
|
|
|
|
// Follow redirects for short URLs (goo.gl, maps.app.goo.gl) with SSRF protection
|
|
const parsed = new URL(url);
|
|
if (['goo.gl', 'maps.app.goo.gl'].includes(parsed.hostname)) {
|
|
const ssrf = await checkSsrf(url, true);
|
|
if (!ssrf.allowed) throw Object.assign(new Error('URL blocked by SSRF check'), { status: 403 });
|
|
const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) });
|
|
resolvedUrl = redirectRes.url;
|
|
}
|
|
|
|
// Extract coordinates from Google Maps URL patterns:
|
|
// /@48.8566,2.3522,15z or /place/.../@48.8566,2.3522
|
|
// ?q=48.8566,2.3522 or ?ll=48.8566,2.3522
|
|
let lat: number | null = null;
|
|
let lng: number | null = null;
|
|
let placeName: string | null = null;
|
|
|
|
// Pattern: /@lat,lng
|
|
const atMatch = resolvedUrl.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/);
|
|
if (atMatch) { lat = parseFloat(atMatch[1]); lng = parseFloat(atMatch[2]); }
|
|
|
|
// Pattern: !3dlat!4dlng (Google Maps data params)
|
|
if (!lat) {
|
|
const dataMatch = resolvedUrl.match(/!3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)/);
|
|
if (dataMatch) { lat = parseFloat(dataMatch[1]); lng = parseFloat(dataMatch[2]); }
|
|
}
|
|
|
|
// Pattern: ?q=lat,lng or &q=lat,lng
|
|
if (!lat) {
|
|
const qMatch = resolvedUrl.match(/[?&]q=(-?\d+\.?\d*),(-?\d+\.?\d*)/);
|
|
if (qMatch) { lat = parseFloat(qMatch[1]); lng = parseFloat(qMatch[2]); }
|
|
}
|
|
|
|
// Extract place name from URL path: /place/Place+Name/@...
|
|
const placeMatch = resolvedUrl.match(/\/place\/([^/@]+)/);
|
|
if (placeMatch) {
|
|
placeName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
|
|
}
|
|
|
|
if (!lat || !lng || isNaN(lat) || isNaN(lng)) {
|
|
throw Object.assign(new Error('Could not extract coordinates from URL'), { status: 400 });
|
|
}
|
|
|
|
// Reverse geocode to get address
|
|
const nominatimRes = await fetch(
|
|
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`,
|
|
{ headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' }, signal: AbortSignal.timeout(8000) }
|
|
);
|
|
const nominatim = await nominatimRes.json() as { display_name?: string; name?: string; address?: Record<string, string> };
|
|
|
|
const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null;
|
|
const address = nominatim.display_name || null;
|
|
|
|
return { lat, lng, name, address };
|
|
}
|