links.js (8225B)
1 import L from 'leaflet'; 2 3 import {urlViaCorsProxy} from '~/lib/CORSProxy'; 4 import {fetch} from '~/lib/xhr-promise'; 5 6 const MAX_ZOOM = 18; 7 const MESSAGE_LINK_MALFORMED = 'Invalid coordinates in {name} link'; 8 const MESSAGE_SHORT_LINK_MALFORMED = 'Broken {name} short link'; 9 10 function makeSearchResult(lat, lon, zoom, title) { 11 if ( 12 isNaN(zoom) || 13 isNaN(lat) || 14 isNaN(lon) || 15 zoom < 0 || 16 zoom > 25 || 17 lat < -90 || 18 lat > 90 || 19 lon < -180 || 20 lon > 180 21 ) { 22 throw new Error('Invalid view state value'); 23 } 24 25 return { 26 latlng: L.latLng(lat, lon), 27 zoom: zoom > MAX_ZOOM ? MAX_ZOOM : zoom, 28 title, 29 category: null, 30 address: null, 31 icon: null, 32 }; 33 } 34 35 function makeSearchResults(lat, lon, zoom, title) { 36 return {results: [makeSearchResult(lat, lon, zoom, title)]}; 37 } 38 39 const YandexMapsUrl = { 40 isOurUrl: function (url) { 41 return ( 42 (url.hostname.match(/\byandex\./u) && url.pathname.match(/^\/maps\//u)) || 43 url.hostname.match(/static-maps\.yandex\./u) 44 ); 45 }, 46 47 getResults: async function (url) { 48 try { 49 const paramLl = url.searchParams.get('ll'); 50 const paramZ = url.searchParams.get('z'); 51 const [lon, lat] = paramLl.split(',').map(parseFloat); 52 const zoom = Math.round(parseFloat(paramZ)); 53 return makeSearchResults(lat, lon, zoom, 'Yandex map view'); 54 } catch (_) { 55 return {error: L.Util.template(MESSAGE_LINK_MALFORMED, {name: 'Yandex'})}; 56 } 57 }, 58 }; 59 60 const GoogleMapsSimpleMapUrl = { 61 viewRe: /\/@([-\d.]+),([-\d.]+),(?:([\d.]+)([mz]))?/u, 62 placeRe: /\/place\/([^/]+)/u, 63 placeZoom: 14, 64 panoramaZoom: 16, 65 66 isOurUrl: function (url) { 67 return Boolean(url.pathname.match(this.viewRe)) || Boolean(url.pathname.match(this.placeRe)); 68 }, 69 70 getResults: function (url) { 71 const results = []; 72 const path = url.pathname; 73 74 try { 75 const placeTitleMatch = path.match(this.placeRe); 76 const placeCoordinatesMatch = path.match(/\/data=[^/]*!8m2!3d([-\d.]+)!4d([-\d.]+)/u); 77 const title = 'Google map - ' + decodeURIComponent(placeTitleMatch[1]).replace(/\+/gu, ' '); 78 const lat = parseFloat(placeCoordinatesMatch[1]); 79 const lon = parseFloat(placeCoordinatesMatch[2]); 80 results.push(makeSearchResult(lat, lon, this.placeZoom, title)); 81 } catch (e) { 82 // pass 83 } 84 85 try { 86 const viewMatch = path.match(this.viewRe); 87 const lat = parseFloat(viewMatch[1]); 88 const lon = parseFloat(viewMatch[2]); 89 let zoom; 90 // no need to check viewMatch[4] as they are together in same group 91 if (viewMatch[3] === undefined) { 92 zoom = this.panoramaZoom; 93 } else { 94 zoom = parseFloat(viewMatch[3]); 95 // zoom for satellite images is expressed in meters 96 if (viewMatch[4] === 'm') { 97 zoom = Math.log2((149175296 / zoom) * Math.cos((lat / 180) * Math.PI)); 98 } 99 zoom = Math.round(zoom); 100 } 101 results.push(makeSearchResult(lat, lon, zoom, 'Google map view')); 102 } catch (e) { 103 // pass 104 } 105 if (results.length === 0) { 106 throw new Error('No results extracted from Google link'); 107 } 108 return {results}; 109 }, 110 }; 111 112 const GoogleMapsQueryUrl = { 113 zoom: 17, 114 title: 'Google map view', 115 116 isOurUrl: function (url) { 117 return url.searchParams.has('q'); 118 }, 119 120 getResults: function (url) { 121 const data = url.searchParams.get('q'); 122 const m = data.match(/^(?:loc:)?([-\d.]+),([-\d.]+)$/u); 123 const lat = parseFloat(m[1]); 124 const lon = parseFloat(m[2]); 125 return makeSearchResults(lat, lon, this.zoom, this.title); 126 }, 127 }; 128 129 const GoogleMapsUrl = { 130 subprocessors: [GoogleMapsSimpleMapUrl, GoogleMapsQueryUrl], 131 132 isOurUrl: function (url) { 133 return (url.hostname.match(/\bgoogle\./u) || url.hostname === 'goo.gl') && url.pathname.match(/^\/maps(\/|$)/u); 134 }, 135 136 getResults: async function (url) { 137 let isShort = false; 138 let actualUrl; 139 try { 140 if (url.hostname === 'goo.gl') { 141 isShort = true; 142 const xhr = await fetch(urlViaCorsProxy(url.toString()), {method: 'HEAD'}); 143 actualUrl = new URL(xhr.responseURL); 144 } else { 145 actualUrl = url; 146 } 147 } catch (e) { 148 // pass 149 } 150 for (const subprocessor of this.subprocessors) { 151 try { 152 if (subprocessor.isOurUrl(actualUrl)) { 153 return subprocessor.getResults(actualUrl); 154 } 155 } catch (e) { 156 // pass 157 } 158 } 159 return { 160 error: L.Util.template(isShort ? MESSAGE_SHORT_LINK_MALFORMED : MESSAGE_LINK_MALFORMED, {name: 'Google'}), 161 }; 162 }, 163 }; 164 165 const MapyCzUrl = { 166 isOurUrl: function (url) { 167 return Boolean(url.hostname.match(/\bmapy\.(cz|com)$/u)); 168 }, 169 170 getResults: async function (url) { 171 let isShort = false; 172 let actualUrl; 173 try { 174 if (url.pathname.match(/^\/s\//u)) { 175 // Remove lang subdomain as Seznam broke support for such links when transitioned to mapy.com 176 url.host = url.host.replace(/^[a-z]{2}\./u, ''); 177 isShort = true; 178 const xhr = await fetch(urlViaCorsProxy(url.toString()), {method: 'HEAD'}); 179 actualUrl = new URL(xhr.responseURL); 180 } else { 181 actualUrl = url; 182 } 183 const lon = parseFloat(actualUrl.searchParams.get('x')); 184 const lat = parseFloat(actualUrl.searchParams.get('y')); 185 const zoom = Math.round(parseFloat(actualUrl.searchParams.get('z'))); 186 return makeSearchResults(lat, lon, zoom, 'Mapy.com view'); 187 } catch (_) { 188 return { 189 error: L.Util.template(isShort ? MESSAGE_SHORT_LINK_MALFORMED : MESSAGE_LINK_MALFORMED, { 190 name: 'Mapy.com', 191 }), 192 }; 193 } 194 }, 195 }; 196 197 const OpenStreetMapUrl = { 198 isOurUrl: function (url) { 199 return Boolean(url.hostname.match(/\bopenstreetmap\./u)); 200 }, 201 202 getResults: function (url) { 203 const m = url.hash.match(/map=([\d.]+)\/([\d.-]+)\/([\d.-]+)/u); 204 try { 205 const zoom = Math.round(parseFloat(m[1])); 206 const lat = parseFloat(m[2]); 207 const lon = parseFloat(m[3]); 208 return makeSearchResults(lat, lon, zoom, 'OpenStreetMap view'); 209 } catch (_) { 210 return {error: L.Util.template(MESSAGE_LINK_MALFORMED, {name: 'OpenStreetMap'})}; 211 } 212 }, 213 }; 214 215 const NakarteUrl = { 216 isOurUrl: function (url) { 217 return url.hostname.match(/\bnakarte\b/u) || !this.getResults(url).error; 218 }, 219 220 getResults: function (url) { 221 const m = url.hash.match(/\bm=([\d]+)\/([\d.-]+)\/([\d.-]+)/u); 222 try { 223 const zoom = Math.round(parseFloat(m[1])); 224 const lat = parseFloat(m[2]); 225 const lon = parseFloat(m[3]); 226 return makeSearchResults(lat, lon, zoom, 'Nakarte view'); 227 } catch (_) { 228 return {error: L.Util.template(MESSAGE_LINK_MALFORMED, {name: 'Nakarte'})}; 229 } 230 }, 231 }; 232 233 const urlProcessors = [YandexMapsUrl, GoogleMapsUrl, MapyCzUrl, OpenStreetMapUrl, NakarteUrl]; 234 235 class LinksProvider { 236 name = 'Links'; 237 238 isOurQuery(query) { 239 return Boolean(query.match(/^https?:\/\//u)); 240 } 241 242 async search(query) { 243 let url; 244 try { 245 url = new URL(query); 246 } catch (e) { 247 return {error: 'Invalid link'}; 248 } 249 for (const processor of urlProcessors) { 250 if (processor.isOurUrl(url)) { 251 return processor.getResults(url); 252 } 253 } 254 return {error: 'Unsupported link'}; 255 } 256 } 257 258 export {LinksProvider};