parks/europa/europaparkdb.js

import Database from '../database.js';
import crypto from 'crypto';
import {URL} from 'url';
import {tagType, attractionType, entityType} from '../parkTypes.js';
import {Blowfish} from 'egoroof-blowfish';

const poiEntityTypes = [
  'shows',
  'pois',
];

const entityTypeToAttractionType = {
  'shows': entityType.show,
  'pois': entityType.attraction,
};

const subtypesToAllow = {
  'pois': [
    'attraction',
  ],
};

/**
 * Europa Park Database Class
 */
export class DatabaseEuropaPark extends Database {
  /**
   * @inheritdoc
   * @param {object} options
   */
  constructor(options = {}) {
    options.fbAppId = '';
    options.fbApiKey = '';
    options.fbProjectId = '';
    options.apiBase = '';
    options.encKey = '';
    options.encIV = '';
    options.authURL = '';
    options.userKey = options.userKey || 'v3_live_android_exozet_api_username';
    options.passKey = options.passKey || 'v3_live_android_exozet_api_password';
    options.appVersion = options.appVersion || '10.1.0';

    options.configPrefixes = ['EUROPAPARK'].concat(options.configPrefixes || []);

    super(options);

    if (!this.config.fbApiKey) throw new Error('Missing Europa Park Firebase API Key');
    if (!this.config.fbAppId) throw new Error('Missing Europa Park Firebase App ID');
    if (!this.config.fbProjectId) throw new Error('Missing Europa Park Firebase Project ID');
    if (!this.config.apiBase) throw new Error('Missing Europa Park API Base');
    if (!this.config.encKey) throw new Error('Missing Europa Park Encryption Key');
    if (!this.config.encIV) throw new Error('Missing Europa Park Encryption IV');
    if (!this.config.authURL) throw new Error('Missing Europa Park Token URL');

    this.cache.version = 2;

    this.http.injectForDomain({
      hostname: new URL(this.config.authURL).hostname,
    }, async (method, url, data, options) => {
      options.headers['user-agent'] = `EuropaParkApp/${this.config.appVersion} (Android)`;
    });

    this.http.injectForDomain({
      hostname: new URL(this.config.apiBase).hostname,
    }, async (method, url, data, options) => {
      options.headers['user-agent'] = `EuropaParkApp/${this.config.appVersion} (Android)`;

      const jwtToken = await this.getToken();
      if (jwtToken === undefined) {
        // refetch Firebase settings and try again
        await this.cache.set('auth', undefined, -1);
        const jwtTokenRetry = await this.getToken();
        options.headers['jwtauthorization'] = `Bearer ${jwtTokenRetry}`;
      } else {
        options.headers['jwtauthorization'] = `Bearer ${jwtToken}`;
      }
    });

    this.http.injectForDomainResponse({
      hostname: new URL(this.config.apiBase).hostname,
    }, async (response) => {
      // if error code is unauthorised, clear out our JWT token
      if (response.statusCode === 401) {
        // wipe any existing token
        await this.cache.set('access_token', undefined, -1);
        // this will be regenerated next time injectForDomain is run
        return undefined;
      }

      return response;
    });

    this.bf = new Blowfish(this.config.encKey, Blowfish.MODE.CBC, Blowfish.PADDING.PKCS5);
    this.bf.setIv(this.config.encIV);
  }

  /**
   * Get or generate a Firebase device ID
   */
  async getFirebaseID() {
    return await this.cache.wrap('fid', async () => {
      try {
        const fidByteArray = crypto.randomBytes(17).toJSON().data;
        fidByteArray[0] = 0b01110000 + (fidByteArray[0] % 0b00010000);
        const b64String = Buffer.from(String.fromCharCode(...fidByteArray))
          .toString('base64')
          .replace(/\+/g, '-')
          .replace(/\//g, '_');
        const fid = b64String.substr(0, 22);
        return /^[cdef][\w-]{21}$/.test(fid) ? fid : '';
      } catch (e) {
        this.emit('error', e);
        console.log(e);
        return '';
      }
    }, 1000 * 60 * 60 * 24 * 8); // 8days
  }

  /**
   * Get Europa Park config keys
   */
  async getConfig() {
    return await this.cache.wrap('auth', async () => {
      const fid = await this.getFirebaseID();

      const resp = await this.http(
        'POST',
        `https://firebaseremoteconfig.googleapis.com/v1/projects/${this.config.fbProjectId}/namespaces/firebase:fetch`,
        {
          'appInstanceId': fid,
          'appId': this.config.fbAppId,
          'packageName': 'com.EuropaParkMackKG.EPGuide',
          'languageCode': 'en_GB',
        }, {
        headers: {
          'X-Goog-Api-Key': this.config.fbApiKey,
        },
      },
      );

      const decrypt = (str) => {
        return this.bf.decode(Buffer.from(str, 'base64'), Blowfish.TYPE.STRING);
      };

      const ret = {};
      Object.keys(resp.body.entries).forEach((key) => {
        ret[key] = decrypt(resp.body.entries[key]);
      });
      return ret;
    }, 1000 * 60 * 60 * 6); // 6 hours
  }

  /**
   * Get our JWT Token
   */
  async getToken() {
    let expiresIn = 1000 * 60 * 60 * 24; // default: 1 day
    return await this.cache.wrap('access_token', async () => {
      const config = await this.getConfig();
      const resp = await this.http(
        'POST',
        this.config.authURL,
        {
          client_id: config[this.config.userKey],
          client_secret: config[this.config.passKey],
          grant_type: 'client_credentials',
        },
        {
          json: true,
        },
      );

      if (!resp || !resp.body) {
        throw new Error('Failed to fetch credentials for Europa API');
      }

      expiresIn = resp.body.expires_in * 1000;
      const token = resp.body.access_token;
      return token;
    }, () => {
      return expiresIn;
    });
  }

  /**
   * Get static data for all park entities
   */
  async getParkData() {
    return await this.cache.wrap('poi', async () => {
      // get the last checksum we received
      const checksum = (await this.cache.get('poi_checksum')) || 0;
      const data = await this.http(
        'GET',
        `${this.config.apiBase}/api/v1/latest/en/live/${checksum}`,
        undefined,
        {
          json: true,
          ignoreErrors: true, // we want 404 errors
        },
      );

      if (data.body?.error?.code === 404 && checksum > 0) {
        // return old data, hasn't changed
        return await this.cache.get('poi_store');
      }

      if (!data.body.package) return undefined;

      // collapse sub fields into one array
      const entities = [];
      Object.keys(data.body.package.data).forEach((key) => {
        data.body.package.data[key].forEach((x) => {
          entities.push({
            ...x,
            entityType: key,
          });
        });
      });

      // store this data indefinitely, we'll only override it if the checksum changes
      await this.cache.set('poi_store', entities, Number.MAX_SAFE_INTEGER);
      await this.cache.set('poi_checksum', data.body.package.checksum);

      return entities;
    }, 1000 * 60 * 60 * 2); // check every 2 hours for updates
  }

  /**
   * @inheritdoc
   */
  async _init() {

  }

  /**
   * Get waiting time data from API
   */
  async getWaitingTimes() {
    return this.cache.wrap('waittingtimes', async () => {
      return (await this.http('GET', `${this.config.apiBase}/api/v2/waiting-times`)).body;
    }, 1000 * 60);
  }

  /**
   * Get Europa Park calendar data
   */
  async getCalendar() {
    return this.cache.wrap('seasons', async () => {
      return (await this.http('GET', `${this.config.apiBase}/api/v1/seasons/en`)).body;
    }, 1000 * 60 * 60 * 6);
  }

  /**
   * Get Europa Park live opening hours
   */
  async getLiveCalendar() {
    return this.cache.wrap('livecalendar', async () => {
      return (await this.http('GET', `${this.config.apiBase}/api/v2/season-opentime-details/europapark`)).body;
    }, 1000 * 60 * 5); // cache for 5 minutes
  }

  /**
   * Get Europa Park show times
   */
  async getShowTimes() {
    return this.cache.wrap('showtimes', async () => {
      // TODO - other languages? does this only include English performances?
      return (await this.http('GET', `${this.config.apiBase}/api/v2/show-times`, {
        status: 'live',
      })).body;
    }, 1000 * 60 * 60 * 6);
  }

  /**
   * @inheritdoc
   */
  async _getEntities() {
    const poiData = await this.getParkData();

    const ret = poiData.map((poi) => {
      if (!poi.name) return undefined;

      // which types do we want to return?
      if (!poiEntityTypes.includes(poi.entityType)) return undefined;

      // filter by subtypes, if we have any
      const subTypes = subtypesToAllow[poi.entityType];
      if (subTypes) {
        if (!subTypes.includes(poi.type)) return undefined;
      }

      // TODO - ignore entities that are no longer valid
      //  if (poi.validTo !== null) 

      // "queueing" entries are pretend entities for virtual queues
      if (poi.queueing) return undefined;

      // ignore queue map pointers
      if (poi.name.indexOf('Queue - ') === 0) return undefined;

      delete poi.versions;

      // check for virtual queue
      const nameLower = poi.name.toLowerCase();
      const vQueueData = poiData.find((x) => {
        return x.queueing && x.name.toLowerCase().indexOf(nameLower) > 0;
      });
      // virtual queue waitingtimes data
      // code === vQueueData.code
      // time can only ever be between 0-90, anything >90 is a special code
      // if time == 90, wait time is reported as 90+ in-app
      // time == 91, virtual queue is open
      // time == 999, down
      // time == 222, closed refurb
      // time == 333, closed
      // time == 444, closed becaue weather
      // time == 555, closed because ice
      // time == 666, virtual queue is "temporarily full"
      // time == 777, virtual queue is completely full
      // startAt/endAt - current virtual queue window

      const tags = [];

      tags.push({
        key: 'location',
        type: tagType.location,
        value: {
          longitude: poi.longitude,
          latitude: poi.latitude,
        },
      });

      if (poi.minHeight) {
        tags.push({
          key: 'minimumHeight',
          type: tagType.minimumHeight,
          value: {
            unit: 'cm',
            height: poi.minHeight,
          },
        });
      }

      if (poi.maxHeight) {
        tags.push({
          key: 'maximumHeight',
          type: tagType.maximumHeight,
          value: {
            unit: 'cm',
            height: poi.maxHeight,
          },
        });
      }

      return {
        id: `${poi.entityType}_${poi.id}`,
        name: poi.name,
        type: entityTypeToAttractionType[poi.entityType] == entityType.attraction ? attractionType.ride : undefined,
        entityType: entityTypeToAttractionType[poi.entityType] || entityType.attraction,
        tags,
        _src: {
          ...poi,
          vQueue: vQueueData,
        },
      };
    }).filter((x) => x !== undefined);

    return ret;
  }
}

export default DatabaseEuropaPark;