parks/europa/europaparkdb.js

  1. import Database from '../database.js';
  2. import crypto from 'crypto';
  3. import {URL} from 'url';
  4. import {tagType, attractionType, entityType} from '../parkTypes.js';
  5. import {Blowfish} from 'egoroof-blowfish';
  6. const poiEntityTypes = [
  7. 'attraction',
  8. 'showlocation',
  9. 'shows',
  10. 'pois',
  11. ];
  12. const entityTypeToAttractionType = {
  13. 'shows': entityType.show,
  14. 'pois': entityType.attraction,
  15. 'attraction': entityType.attraction,
  16. 'showlocation': entityType.show,
  17. };
  18. const subtypesToAllow = {
  19. 'pois': [
  20. 'attraction',
  21. ],
  22. };
  23. /**
  24. * Europa Park Database Class
  25. */
  26. export class DatabaseEuropaPark extends Database {
  27. /**
  28. * @inheritdoc
  29. * @param {object} options
  30. */
  31. constructor(options = {}) {
  32. options.fbAppId = '';
  33. options.fbApiKey = '';
  34. options.fbProjectId = '';
  35. options.apiBase = '';
  36. options.encKey = '';
  37. options.encIV = '';
  38. options.authURL = '';
  39. options.userKey = options.userKey || 'v3_live_android_exozet_api_username';
  40. options.passKey = options.passKey || 'v3_live_android_exozet_api_password';
  41. options.appVersion = options.appVersion || '10.1.0';
  42. options.configPrefixes = ['EUROPAPARK'].concat(options.configPrefixes || []);
  43. super(options);
  44. if (!this.config.fbApiKey) throw new Error('Missing Europa Park Firebase API Key');
  45. if (!this.config.fbAppId) throw new Error('Missing Europa Park Firebase App ID');
  46. if (!this.config.fbProjectId) throw new Error('Missing Europa Park Firebase Project ID');
  47. if (!this.config.apiBase) throw new Error('Missing Europa Park API Base');
  48. if (!this.config.encKey) throw new Error('Missing Europa Park Encryption Key');
  49. if (!this.config.encIV) throw new Error('Missing Europa Park Encryption IV');
  50. if (!this.config.authURL) throw new Error('Missing Europa Park Token URL');
  51. this.cache.version = 2;
  52. this.http.injectForDomain({
  53. hostname: new URL(this.config.authURL).hostname,
  54. }, async (method, url, data, options) => {
  55. options.headers['user-agent'] = `EuropaParkApp/${this.config.appVersion} (Android)`;
  56. });
  57. this.http.injectForDomain({
  58. hostname: new URL(this.config.apiBase).hostname,
  59. }, async (method, url, data, options) => {
  60. options.headers['user-agent'] = `EuropaParkApp/${this.config.appVersion} (Android)`;
  61. const jwtToken = await this.getToken();
  62. if (jwtToken === undefined) {
  63. // refetch Firebase settings and try again
  64. await this.cache.set('auth', undefined, -1);
  65. const jwtTokenRetry = await this.getToken();
  66. options.headers['jwtauthorization'] = `Bearer ${jwtTokenRetry}`;
  67. } else {
  68. options.headers['jwtauthorization'] = `Bearer ${jwtToken}`;
  69. options.headers['Accept-Language'] = 'en';
  70. }
  71. });
  72. this.http.injectForDomainResponse({
  73. hostname: new URL(this.config.apiBase).hostname,
  74. }, async (response) => {
  75. // if error code is unauthorised, clear out our JWT token
  76. if (response.statusCode === 401) {
  77. // wipe any existing token
  78. await this.cache.set('access_token', undefined, -1);
  79. // this will be regenerated next time injectForDomain is run
  80. return undefined;
  81. }
  82. return response;
  83. });
  84. this.bf = new Blowfish(this.config.encKey, Blowfish.MODE.CBC, Blowfish.PADDING.PKCS5);
  85. this.bf.setIv(this.config.encIV);
  86. }
  87. /**
  88. * Get or generate a Firebase device ID
  89. */
  90. async getFirebaseID() {
  91. return await this.cache.wrap('fid', async () => {
  92. try {
  93. const fidByteArray = crypto.randomBytes(17).toJSON().data;
  94. fidByteArray[0] = 0b01110000 + (fidByteArray[0] % 0b00010000);
  95. const b64String = Buffer.from(String.fromCharCode(...fidByteArray))
  96. .toString('base64')
  97. .replace(/\+/g, '-')
  98. .replace(/\//g, '_');
  99. const fid = b64String.substr(0, 22);
  100. return /^[cdef][\w-]{21}$/.test(fid) ? fid : '';
  101. } catch (e) {
  102. this.emit('error', e);
  103. console.log(e);
  104. return '';
  105. }
  106. }, 1000 * 60 * 60 * 24 * 8); // 8days
  107. }
  108. /**
  109. * Get Europa Park config keys
  110. */
  111. async getConfig() {
  112. return await this.cache.wrap('auth', async () => {
  113. const fid = await this.getFirebaseID();
  114. const resp = await this.http(
  115. 'POST',
  116. `https://firebaseremoteconfig.googleapis.com/v1/projects/${this.config.fbProjectId}/namespaces/firebase:fetch`,
  117. {
  118. 'appInstanceId': fid,
  119. 'appId': this.config.fbAppId,
  120. 'packageName': 'com.EuropaParkMackKG.EPGuide',
  121. 'languageCode': 'en_GB',
  122. }, {
  123. headers: {
  124. 'X-Goog-Api-Key': this.config.fbApiKey,
  125. },
  126. },
  127. );
  128. const decrypt = (str) => {
  129. return this.bf.decode(Buffer.from(str, 'base64'), Blowfish.TYPE.STRING);
  130. };
  131. const ret = {};
  132. Object.keys(resp.body.entries).forEach((key) => {
  133. ret[key] = decrypt(resp.body.entries[key]);
  134. });
  135. return ret;
  136. }, 1000 * 60 * 60 * 6); // 6 hours
  137. }
  138. /**
  139. * Get our JWT Token
  140. */
  141. async getToken() {
  142. let expiresIn = 1000 * 60 * 60 * 24; // default: 1 day
  143. return await this.cache.wrap('access_token', async () => {
  144. const config = await this.getConfig();
  145. const resp = await this.http(
  146. 'POST',
  147. this.config.authURL,
  148. {
  149. client_id: config[this.config.userKey],
  150. client_secret: config[this.config.passKey],
  151. grant_type: 'client_credentials',
  152. },
  153. {
  154. json: true,
  155. },
  156. );
  157. if (!resp || !resp.body) {
  158. throw new Error('Failed to fetch credentials for Europa API');
  159. }
  160. expiresIn = resp.body.expires_in * 1000;
  161. const token = resp.body.access_token;
  162. return token;
  163. }, () => {
  164. return expiresIn;
  165. });
  166. }
  167. /**
  168. * Get static data for all park entities
  169. */
  170. async getParkData() {
  171. // TODO - figure out when to invalidate this cache and request new data
  172. // check our cache first
  173. const lastUpdated = await this.cache.get('poi_last_updated');
  174. const existingData = await this.cache.get('poi_store');
  175. // if we haven't fetched in 12 hours or ever, fetch new data
  176. if (!existingData || !lastUpdated || (Date.now() - lastUpdated) > 1000 * 60 * 60 * 12) {
  177. const newPOIData = (await this.http('GET', `${this.config.apiBase}/api/v2/poi-group`, {
  178. status: ['live'],
  179. })).body.pois.map((x) => {
  180. return {
  181. ...x,
  182. entityType: x.type,
  183. };
  184. });
  185. await this.cache.set('poi_store', newPOIData, Number.MAX_SAFE_INTEGER);
  186. await this.cache.set('poi_last_updated', Date.now());
  187. return newPOIData;
  188. }
  189. // return existing data
  190. return existingData;
  191. }
  192. /**
  193. * @inheritdoc
  194. */
  195. async _init() {
  196. }
  197. /**
  198. * Get waiting time data from API
  199. */
  200. async getWaitingTimes() {
  201. return this.cache.wrap('waittingtimes', async () => {
  202. return (await this.http('GET', `${this.config.apiBase}/api/v2/waiting-times`)).body;
  203. }, 1000 * 60);
  204. }
  205. /**
  206. * Get Europa Park calendar data
  207. */
  208. async getCalendar() {
  209. return this.cache.wrap('seasons', async () => {
  210. return (await this.http('GET', `${this.config.apiBase}/api/v2/seasons`, {
  211. status: ['live'],
  212. })).body;
  213. }, 1000 * 60 * 60 * 6);
  214. }
  215. /**
  216. * Get Europa Park live opening hours
  217. */
  218. async getLiveCalendar() {
  219. return this.cache.wrap('livecalendar', async () => {
  220. return (await this.http('GET', `${this.config.apiBase}/api/v2/season-opentime-details/europapark`)).body;
  221. }, 1000 * 60 * 5); // cache for 5 minutes
  222. }
  223. /**
  224. * Get Europa Park show times
  225. */
  226. async getShowTimes() {
  227. return this.cache.wrap('showtimes', async () => {
  228. // TODO - other languages? does this only include English performances?
  229. return (await this.http('GET', `${this.config.apiBase}/api/v2/show-times`, {
  230. status: 'live',
  231. })).body;
  232. }, 1000 * 60 * 60 * 6);
  233. }
  234. /**
  235. * @inheritdoc
  236. */
  237. async _getEntities() {
  238. const poiData = await this.getParkData();
  239. const pois = [];
  240. const addPoiData = (poi) => {
  241. if (!poi.name) return undefined;
  242. // which types do we want to return?
  243. if (!poiEntityTypes.includes(poi.entityType)) {
  244. return undefined;
  245. }
  246. // recurse into sub-entities for showlocations
  247. if (poi.entityType === 'showlocation') {
  248. poi.shows.forEach((show) => {
  249. addPoiData({
  250. ...show,
  251. entityType: 'shows',
  252. location: poi.location,
  253. });
  254. });
  255. // don't return the showlocation itself
  256. return;
  257. }
  258. // filter by subtypes, if we have any
  259. const subTypes = subtypesToAllow[poi.entityType];
  260. if (subTypes) {
  261. if (!subTypes.includes(poi.type)) return undefined;
  262. }
  263. // TODO - ignore entities that are no longer valid
  264. // if (poi.validTo !== null)
  265. // "queueing" entries are pretend entities for virtual queues
  266. if (poi.queueing) return undefined;
  267. // ignore queue map pointers
  268. if (poi.name.indexOf('Queue - ') === 0) return undefined;
  269. delete poi.versions;
  270. // check for virtual queue
  271. const nameLower = poi.name.toLowerCase();
  272. const vQueueData = poiData.find((x) => {
  273. return x.queueing && x.name.toLowerCase().indexOf(nameLower) > 0;
  274. });
  275. // virtual queue waitingtimes data
  276. // code === vQueueData.code
  277. // time can only ever be between 0-90, anything >90 is a special code
  278. // if time == 90, wait time is reported as 90+ in-app
  279. // time == 91, virtual queue is open
  280. // time == 999, down
  281. // time == 222, closed refurb
  282. // time == 333, closed
  283. // time == 444, closed becaue weather
  284. // time == 555, closed because ice
  285. // time == 666, virtual queue is "temporarily full"
  286. // time == 777, virtual queue is completely full
  287. // startAt/endAt - current virtual queue window
  288. const tags = [];
  289. tags.push({
  290. key: 'location',
  291. type: tagType.location,
  292. value: {
  293. longitude: poi.longitude,
  294. latitude: poi.latitude,
  295. },
  296. });
  297. if (poi.minHeight) {
  298. tags.push({
  299. key: 'minimumHeight',
  300. type: tagType.minimumHeight,
  301. value: {
  302. unit: 'cm',
  303. height: poi.minHeight,
  304. },
  305. });
  306. }
  307. if (poi.maxHeight) {
  308. tags.push({
  309. key: 'maximumHeight',
  310. type: tagType.maximumHeight,
  311. value: {
  312. unit: 'cm',
  313. height: poi.maxHeight,
  314. },
  315. });
  316. }
  317. // use old style IDs to migrate over to new API gracefull
  318. let entityTypeStr = poi.entityType;
  319. if (poi.entityType === 'attraction') {
  320. entityTypeStr = 'pois';
  321. } else if (poi.entityType === 'showlocation' || poi.entityType === 'shows') {
  322. entityTypeStr = 'shows';
  323. }
  324. pois.push({
  325. id: `${entityTypeStr}_${poi.id}`,
  326. name: poi.name,
  327. type: entityTypeToAttractionType[poi.entityType] == entityType.attraction ? attractionType.ride : undefined,
  328. entityType: entityTypeToAttractionType[poi.entityType] || entityType.attraction,
  329. tags,
  330. _src: {
  331. ...poi,
  332. vQueue: vQueueData,
  333. },
  334. });
  335. };
  336. poiData.map((poi) => {
  337. addPoiData(poi);
  338. });
  339. return pois;
  340. }
  341. }
  342. export default DatabaseEuropaPark;