web/configcontroller.mjs

import log from 'loglevel';

const map = [
  'dict',
  'num_words',
  'word_length_min',
  'word_length_max',

  'case_transform',

  'separator_type',
  'separator_character',
  'separator_alphabet',

  'padding_digits_before',
  'padding_digits_after',
  'padding_type',
  'pad_to_length',
  'padding_character_type',
  'padding_character',
  'padding_alphabet',
  'padding_characters_before',
  'padding_characters_after',
];

/**
 * @class This class handles the loading/saving of custom password
 * configurations.
 *
 * There are 2 ways of loading/saving:
 *
 * - the origin of this class: using a base64encoded uri
 * - importing and exporting a JSON file
 *
 *
 * @constructor
 */
class ConfigController {
  /**
   * {XKPasswd} model - reference to password model
   * @private
   */
  #model;

  /**
   * {ConfigView} view - reference to ConfigView
   * @private
   */
  #view;

  /**
   * {SettingsController} settingsController - reference to SettingsController
   * @private
   */
  #settingsController;

  /**
   * The default class constructor
   *
   * @param {XKPasswd} model - reference to PasswordModel
   * @param {ConfigView} view - reference to ConfigView
   * @param {SettingsController} settingsController - reference to
   *   SettingsController
   */
  constructor(model, view, settingsController) {
    this.#model = model;
    this.#view = view;
    this.#settingsController = settingsController;

    this.#view.bindLoadConfig(this.importSettings);
    this.#view.bindSaveConfig(this.exportSettings);
    this.#view.bindConfigUrlBox(this.copyUrl);

    log.trace('ConfigController constructor executed');
  }

  /**
   *  Import the settings from the uploaded file
   *
   * @param {Object} settings - the object containing the uploaded settings
   * @function
   */
  importSettings = (settings) => {
    log.trace(`importSettings: ${JSON.stringify(settings)}`);

    this.#model.setCustomPreset(settings);

    // yes, config should be the same as settings, but there is some
    // conversion going on in the preset class, so we use the one that is
    // actually stored.

    const config = this.#model.getPreset().config();
    this.#settingsController.updateSettings(config);
  };

  /**
   * Convert the settings to a JSON object
   *
   * @return {string} - the JSON version of the settings
   * @function
   */
  exportSettings = () => {
    log.trace(`exportSettings`);
    const settings = this.#model.getPreset().config();
    const jsonBlob = new Blob([JSON.stringify(settings)],
      {type: 'application/json'});

    return URL.createObjectURL(jsonBlob);
  };

  /**
   * Update the configUrl content
   *
   * @param {object} settings - configuration to convert to a URL
   * @param {string} preset - (Optional) The name of the preset
   */
  updateLink(settings, preset = null) {
    log.trace(`updateLink: ${JSON.stringify(settings)}`);

    const link = new URL(window.location);

    // Build the link with the correct search parameters
    if (preset == null) {
      const url = this.toUrl(settings);
      link.href = url;
      link.searchParams.delete('p');
    } else {
      link.searchParams.delete('c');
      link.searchParams.set('p', preset.toUpperCase());
    }

    this.#view.updateConfigUrl(link);
  }

  /**
   * Copy the parameter to the clipboard
   *
   * @param {string} url - the url to be copied
   */
  copyUrl(url) {
    navigator.clipboard.writeText(url);
  }


  /**
   * Loads the settings from a URL and store them in the model,
   * and returns the name of the preset if specified.
   *
   * @param {string} url - The URL to try to extract the settings from
   * @return {string} - The name of the preset or CUSTOM if custom URL.
   */
  loadFromUrl(url) {
    log.trace(`loadFromUrl: ${url}`);

    const validParams = ['c', 'p'];
    const link = new URL(url);
    const params = link.searchParams;
    const queryParam = validParams.find(x => params.get(x) !== null);

    switch(queryParam) {
      case "c":
        const settings = this.fromUrl(url);
        if (JSON.stringify(settings) !== '{}') {
          // somehow I cannot get the settings object to match an empty object
          // without doing this stringify action
          this.#model.setCustomPreset(settings);
          this.#settingsController.updateSettings(settings);
        }
        return "CUSTOM";
      case "p":
        const preset = params.get(queryParam).toUpperCase();
        this.#model.setPreset(preset);
        link.searchParams.set('p', preset);
        this.#view.updateConfigUrl(link, preset);
        return preset;
      default:
        break;
    }
  }

  /**
   * Return a URL string with the encoded settings string
   *
   * @param {Object} settings - The settings to encode
   * @returns {string} - The URL as string
   */
  toUrl(settings) {
    log.trace(`toUrl: ${JSON.stringify(settings)}`);
    const encodedSettings = this.__getEncodedSettings(settings);
    const url = new URL(window.location);

    url.searchParams.set('c', encodedSettings);

    return url.toString();
  }

  /**
   * Convert an url into a settings object for further processing
   * Return an empty object if there is no parameter in the url.
   *
   * @param url - the URL from the window.location or from the configUrl
   * @return {Object} - empty object if something went wrong, or settings object
   */
  fromUrl(url) {
    log.trace(`fromUrl: ${url}`);
    const params = new URL(url).searchParams;
    const settings = {};
    let values = null;

    if (params.get('c') == null && params.get('p') == null) {
      return {};
    }

    values = this.__base64URLdecode(params.get('c')).split(',');

    map.forEach(element => {
      if (
        element === 'separator_character' || element === 'separator_alphabet' ||
        element === 'padding_character' || element === 'padding_alphabet'
      ) {
        settings[element] = this.__base64URLdecode(values.shift());
      }
      else {
        settings[element] = values.shift();
        settings[element] =
          (parseInt(settings[element])) ?
            parseInt(settings[element]) : settings[element];
      }
    });

    return settings;
  }

  /**
   * Converts the settings object to a CSV array and returns it as a
   * URL safe base64 encoded string
   *
   * @returns {string} - A base64 encoded string with the settings
   *
   * @private
   */
  __getEncodedSettings(settings) {
    let values = [];

    map.forEach(element => {
      if (
        element === 'separator_character' || element === 'separator_alphabet' ||
        element === 'padding_character' || element === 'padding_alphabet'
      ) {
        values.push(this.__base64URLencode(settings[element]));
      }
      else {
        values.push(settings[element]);
      }
    });

    return this.__base64URLencode(values.join(','));
  }

  /**
   * Return the string as a URL safe base64 encoded string
   *
   * @param {string} str - string to encode as base64 string
   * @returns {string} - A base64 encoded string
   *
   * @private
   */
  __base64URLencode(str) {
    const base64Encoded = btoa(str);
    return base64Encoded
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/, '');
  }

  /**
   * Return the string decoded from a URL safe base64 encoded string
   *
   * @param {string} str - A base64 encoded string
   * @returns {string} - The decoded string
   *
   * @private
   */
  __base64URLdecode(str) {
    const base64Encoded = str
      .replace(/-/g, '+')
      .replace(/_/g, '/');
    const padding =
      str.length % 4 === 0 ? '' : '='.repeat(4 - (str.length % 4));
    const base64WithPadding = base64Encoded + padding;
    return (atob(base64WithPadding)
      .split('')
      .map(char => String.fromCharCode(char.charCodeAt(0)))).join('');
  }
}

export {ConfigController};