links.js (9080B)
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 let isShort = false; 49 let actualUrl; 50 try { 51 if (url.pathname.match(/^\/maps\/-\//u)) { 52 isShort = true; 53 const pageText = (await fetch(urlViaCorsProxy(url.toString()))).response; 54 try { 55 const dom = new DOMParser().parseFromString(pageText, 'text/html'); 56 actualUrl = new URL(dom.querySelector('meta[property="og:image:secure_url"]').content); 57 } catch (_) { 58 let propertyContent = pageText.match( 59 /<meta\s+property\s*=\s*["']?og:image:secure_url["']?\s+content\s*=\s*["']?([^"' >]+)/u 60 )[1]; 61 propertyContent = propertyContent.replaceAll('&', '&'); 62 actualUrl = new URL(decodeURIComponent(propertyContent)); 63 } 64 } else { 65 actualUrl = url; 66 } 67 const paramLl = actualUrl.searchParams.get('ll'); 68 const paramZ = actualUrl.searchParams.get('z'); 69 const [lon, lat] = paramLl.split(',').map(parseFloat); 70 const zoom = Math.round(parseFloat(paramZ)); 71 return makeSearchResults(lat, lon, zoom, 'Yandex map view'); 72 } catch (_) { 73 return { 74 error: L.Util.template(isShort ? MESSAGE_SHORT_LINK_MALFORMED : MESSAGE_LINK_MALFORMED, { 75 name: 'Yandex', 76 }), 77 }; 78 } 79 }, 80 }; 81 82 const GoogleMapsSimpleMapUrl = { 83 viewRe: /\/@([-\d.]+),([-\d.]+),(?:([\d.]+)([mz]))?/u, 84 placeRe: /\/place\/([^/]+)/u, 85 placeZoom: 14, 86 panoramaZoom: 16, 87 88 isOurUrl: function (url) { 89 return Boolean(url.pathname.match(this.viewRe)) || Boolean(url.pathname.match(this.placeRe)); 90 }, 91 92 getResults: function (url) { 93 const results = []; 94 const path = url.pathname; 95 96 try { 97 const placeTitleMatch = path.match(this.placeRe); 98 const placeCoordinatesMatch = path.match(/\/data=[^/]*!8m2!3d([-\d.]+)!4d([-\d.]+)/u); 99 const title = 'Google map - ' + decodeURIComponent(placeTitleMatch[1]).replace(/\+/gu, ' '); 100 const lat = parseFloat(placeCoordinatesMatch[1]); 101 const lon = parseFloat(placeCoordinatesMatch[2]); 102 results.push(makeSearchResult(lat, lon, this.placeZoom, title)); 103 } catch (e) { 104 // pass 105 } 106 107 try { 108 const viewMatch = path.match(this.viewRe); 109 const lat = parseFloat(viewMatch[1]); 110 const lon = parseFloat(viewMatch[2]); 111 let zoom; 112 // no need to check viewMatch[4] as they are together in same group 113 if (viewMatch[3] === undefined) { 114 zoom = this.panoramaZoom; 115 } else { 116 zoom = parseFloat(viewMatch[3]); 117 // zoom for satellite images is expressed in meters 118 if (viewMatch[4] === 'm') { 119 zoom = Math.log2((149175296 / zoom) * Math.cos((lat / 180) * Math.PI)); 120 } 121 zoom = Math.round(zoom); 122 } 123 results.push(makeSearchResult(lat, lon, zoom, 'Google map view')); 124 } catch (e) { 125 // pass 126 } 127 if (results.length === 0) { 128 throw new Error('No results extracted from Google link'); 129 } 130 return {results}; 131 }, 132 }; 133 134 const GoogleMapsQueryUrl = { 135 zoom: 17, 136 title: 'Google map view', 137 138 isOurUrl: function (url) { 139 return url.searchParams.has('q'); 140 }, 141 142 getResults: function (url) { 143 const data = url.searchParams.get('q'); 144 const m = data.match(/^(?:loc:)?([-\d.]+),([-\d.]+)$/u); 145 const lat = parseFloat(m[1]); 146 const lon = parseFloat(m[2]); 147 return makeSearchResults(lat, lon, this.zoom, this.title); 148 }, 149 }; 150 151 const GoogleMapsUrl = { 152 subprocessors: [GoogleMapsSimpleMapUrl, GoogleMapsQueryUrl], 153 154 isOurUrl: function (url) { 155 return (url.hostname.match(/\bgoogle\./u) || url.hostname === 'goo.gl') && url.pathname.match(/^\/maps(\/|$)/u); 156 }, 157 158 getResults: async function (url) { 159 let isShort = false; 160 let actualUrl; 161 try { 162 if (url.hostname === 'goo.gl') { 163 isShort = true; 164 const xhr = await fetch(urlViaCorsProxy(url.toString()), {method: 'HEAD'}); 165 actualUrl = new URL(xhr.responseURL); 166 } else { 167 actualUrl = url; 168 } 169 } catch (e) { 170 // pass 171 } 172 for (const subprocessor of this.subprocessors) { 173 try { 174 if (subprocessor.isOurUrl(actualUrl)) { 175 return subprocessor.getResults(actualUrl); 176 } 177 } catch (e) { 178 // pass 179 } 180 } 181 return { 182 error: L.Util.template(isShort ? MESSAGE_SHORT_LINK_MALFORMED : MESSAGE_LINK_MALFORMED, {name: 'Google'}), 183 }; 184 }, 185 }; 186 187 const MapyCzUrl = { 188 isOurUrl: function (url) { 189 return Boolean(url.hostname.match(/\bmapy\.cz$/u)); 190 }, 191 192 getResults: async function (url) { 193 let isShort = false; 194 let actualUrl; 195 try { 196 if (url.pathname.match(/^\/s\//u)) { 197 isShort = true; 198 const xhr = await fetch(urlViaCorsProxy(url.toString()), {method: 'HEAD'}); 199 actualUrl = new URL(xhr.responseURL); 200 } else { 201 actualUrl = url; 202 } 203 const lon = parseFloat(actualUrl.searchParams.get('x')); 204 const lat = parseFloat(actualUrl.searchParams.get('y')); 205 const zoom = Math.round(parseFloat(actualUrl.searchParams.get('z'))); 206 return makeSearchResults(lat, lon, zoom, 'Mapy.cz view'); 207 } catch (_) { 208 return { 209 error: L.Util.template(isShort ? MESSAGE_SHORT_LINK_MALFORMED : MESSAGE_LINK_MALFORMED, { 210 name: 'Mapy.cz', 211 }), 212 }; 213 } 214 }, 215 }; 216 217 const OpenStreetMapUrl = { 218 isOurUrl: function (url) { 219 return Boolean(url.hostname.match(/\bopenstreetmap\./u)); 220 }, 221 222 getResults: function (url) { 223 const m = url.hash.match(/map=([\d.]+)\/([\d.-]+)\/([\d.-]+)/u); 224 try { 225 const zoom = Math.round(parseFloat(m[1])); 226 const lat = parseFloat(m[2]); 227 const lon = parseFloat(m[3]); 228 return makeSearchResults(lat, lon, zoom, 'OpenStreetMap view'); 229 } catch (_) { 230 return {error: L.Util.template(MESSAGE_LINK_MALFORMED, {name: 'OpenStreetMap'})}; 231 } 232 }, 233 }; 234 235 const NakarteUrl = { 236 isOurUrl: function (url) { 237 return url.hostname.match(/\bnakarte\b/u) || !this.getResults(url).error; 238 }, 239 240 getResults: function (url) { 241 const m = url.hash.match(/\bm=([\d]+)\/([\d.-]+)\/([\d.-]+)/u); 242 try { 243 const zoom = Math.round(parseFloat(m[1])); 244 const lat = parseFloat(m[2]); 245 const lon = parseFloat(m[3]); 246 return makeSearchResults(lat, lon, zoom, 'Nakarte view'); 247 } catch (_) { 248 return {error: L.Util.template(MESSAGE_LINK_MALFORMED, {name: 'Nakarte'})}; 249 } 250 }, 251 }; 252 253 const urlProcessors = [YandexMapsUrl, GoogleMapsUrl, MapyCzUrl, OpenStreetMapUrl, NakarteUrl]; 254 255 class LinksProvider { 256 name = 'Links'; 257 258 isOurQuery(query) { 259 return Boolean(query.match(/^https?:\/\//u)); 260 } 261 262 async search(query) { 263 let url; 264 try { 265 url = new URL(query); 266 } catch (e) { 267 return {error: 'Invalid link'}; 268 } 269 for (const processor of urlProcessors) { 270 if (processor.isOurUrl(url)) { 271 return processor.getResults(url); 272 } 273 } 274 return {error: 'Unsupported link'}; 275 } 276 } 277 278 export {LinksProvider};