links.js (8042B)
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$/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 isShort = true; 176 const xhr = await fetch(urlViaCorsProxy(url.toString()), {method: 'HEAD'}); 177 actualUrl = new URL(xhr.responseURL); 178 } else { 179 actualUrl = url; 180 } 181 const lon = parseFloat(actualUrl.searchParams.get('x')); 182 const lat = parseFloat(actualUrl.searchParams.get('y')); 183 const zoom = Math.round(parseFloat(actualUrl.searchParams.get('z'))); 184 return makeSearchResults(lat, lon, zoom, 'Mapy.cz view'); 185 } catch (_) { 186 return { 187 error: L.Util.template(isShort ? MESSAGE_SHORT_LINK_MALFORMED : MESSAGE_LINK_MALFORMED, { 188 name: 'Mapy.cz', 189 }), 190 }; 191 } 192 }, 193 }; 194 195 const OpenStreetMapUrl = { 196 isOurUrl: function (url) { 197 return Boolean(url.hostname.match(/\bopenstreetmap\./u)); 198 }, 199 200 getResults: function (url) { 201 const m = url.hash.match(/map=([\d.]+)\/([\d.-]+)\/([\d.-]+)/u); 202 try { 203 const zoom = Math.round(parseFloat(m[1])); 204 const lat = parseFloat(m[2]); 205 const lon = parseFloat(m[3]); 206 return makeSearchResults(lat, lon, zoom, 'OpenStreetMap view'); 207 } catch (_) { 208 return {error: L.Util.template(MESSAGE_LINK_MALFORMED, {name: 'OpenStreetMap'})}; 209 } 210 }, 211 }; 212 213 const NakarteUrl = { 214 isOurUrl: function (url) { 215 return url.hostname.match(/\bnakarte\b/u) || !this.getResults(url).error; 216 }, 217 218 getResults: function (url) { 219 const m = url.hash.match(/\bm=([\d]+)\/([\d.-]+)\/([\d.-]+)/u); 220 try { 221 const zoom = Math.round(parseFloat(m[1])); 222 const lat = parseFloat(m[2]); 223 const lon = parseFloat(m[3]); 224 return makeSearchResults(lat, lon, zoom, 'Nakarte view'); 225 } catch (_) { 226 return {error: L.Util.template(MESSAGE_LINK_MALFORMED, {name: 'Nakarte'})}; 227 } 228 }, 229 }; 230 231 const urlProcessors = [YandexMapsUrl, GoogleMapsUrl, MapyCzUrl, OpenStreetMapUrl, NakarteUrl]; 232 233 class LinksProvider { 234 name = 'Links'; 235 236 isOurQuery(query) { 237 return Boolean(query.match(/^https?:\/\//u)); 238 } 239 240 async search(query) { 241 let url; 242 try { 243 url = new URL(query); 244 } catch (e) { 245 return {error: 'Invalid link'}; 246 } 247 for (const processor of urlProcessors) { 248 if (processor.isOurUrl(url)) { 249 return processor.getResults(url); 250 } 251 } 252 return {error: 'Unsupported link'}; 253 } 254 } 255 256 export {LinksProvider};