tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

aboutRulesets.js (11624B)


      1 "use strict";
      2 
      3 const Orders = Object.freeze({
      4  Name: "name",
      5  NameDesc: "name-desc",
      6  LastUpdate: "last-update",
      7 });
      8 
      9 const States = Object.freeze({
     10  Warning: "warning",
     11  Details: "details",
     12  Edit: "edit",
     13  NoRulesets: "noRulesets",
     14 });
     15 
     16 function setUpdateDate(ruleset, element) {
     17  if (!ruleset.enabled) {
     18    document.l10n.setAttributes(element, "rulesets-update-rule-disabled");
     19    return;
     20  }
     21  if (!ruleset.currentTimestamp) {
     22    document.l10n.setAttributes(element, "rulesets-update-never");
     23    return;
     24  }
     25 
     26  document.l10n.setAttributes(element, "rulesets-update-last", {
     27    date: ruleset.currentTimestamp * 1000,
     28  });
     29 }
     30 
     31 // UI states
     32 
     33 /**
     34 * This is the initial warning shown when the user opens about:rulesets.
     35 */
     36 class WarningState {
     37  elements = {
     38    enableCheckbox: document.getElementById("warning-enable-checkbox"),
     39    button: document.getElementById("warning-button"),
     40  };
     41 
     42  constructor() {
     43    this.elements.enableCheckbox.addEventListener(
     44      "change",
     45      this.onEnableChange.bind(this)
     46    );
     47 
     48    this.elements.button.addEventListener(
     49      "click",
     50      this.onButtonClick.bind(this)
     51    );
     52  }
     53 
     54  show() {
     55    this.elements.button.focus();
     56  }
     57 
     58  hide() {}
     59 
     60  onEnableChange() {
     61    RPMSendAsyncMessage(
     62      "rulesets:set-show-warning",
     63      this.elements.enableCheckbox.checked
     64    );
     65  }
     66 
     67  onButtonClick() {
     68    gAboutRulesets.selectFirst();
     69  }
     70 }
     71 
     72 /**
     73 * State shown when the user clicks on a channel to see its details.
     74 */
     75 class DetailsState {
     76  elements = {
     77    title: document.getElementById("ruleset-title"),
     78    jwkValue: document.getElementById("ruleset-jwk-value"),
     79    pathPrefixValue: document.getElementById("ruleset-path-prefix-value"),
     80    scopeValue: document.getElementById("ruleset-scope-value"),
     81    enableCheckbox: document.getElementById("ruleset-enable-checkbox"),
     82    updateButton: document.getElementById("ruleset-update-button"),
     83    updated: document.getElementById("ruleset-updated"),
     84  };
     85 
     86  constructor() {
     87    document
     88      .getElementById("ruleset-edit")
     89      .addEventListener("click", this.onEdit.bind(this));
     90    this.elements.enableCheckbox.addEventListener(
     91      "change",
     92      this.onEnable.bind(this)
     93    );
     94    this.elements.updateButton.addEventListener(
     95      "click",
     96      this.onUpdate.bind(this)
     97    );
     98  }
     99 
    100  show(ruleset) {
    101    const elements = this.elements;
    102    elements.title.textContent = ruleset.name;
    103    elements.jwkValue.textContent = JSON.stringify(ruleset.jwk);
    104    elements.pathPrefixValue.setAttribute("href", ruleset.pathPrefix);
    105    elements.pathPrefixValue.textContent = ruleset.pathPrefix;
    106    elements.scopeValue.textContent = ruleset.scope;
    107    elements.enableCheckbox.checked = ruleset.enabled;
    108    if (ruleset.enabled) {
    109      elements.updateButton.removeAttribute("disabled");
    110    } else {
    111      elements.updateButton.setAttribute("disabled", "disabled");
    112    }
    113    setUpdateDate(ruleset, elements.updated);
    114    this._showing = ruleset;
    115 
    116    gAboutRulesets.list.setItemSelected(ruleset.name);
    117  }
    118 
    119  hide() {
    120    this._showing = null;
    121  }
    122 
    123  onEdit() {
    124    gAboutRulesets.setState(States.Edit, this._showing);
    125  }
    126 
    127  async onEnable() {
    128    await RPMSendAsyncMessage("rulesets:enable-channel", {
    129      name: this._showing.name,
    130      enabled: this.elements.enableCheckbox.checked,
    131    });
    132  }
    133 
    134  async onUpdate() {
    135    try {
    136      await RPMSendQuery("rulesets:update-channel", this._showing.name);
    137    } catch (err) {
    138      console.error("Could not update the rulesets", err);
    139    }
    140  }
    141 }
    142 
    143 /**
    144 * State to edit a channel.
    145 */
    146 class EditState {
    147  elements = {
    148    form: document.getElementById("edit-ruleset-form"),
    149    title: document.getElementById("edit-title"),
    150    jwkTextarea: document.getElementById("edit-jwk-textarea"),
    151    pathPrefixInput: document.getElementById("edit-path-prefix-input"),
    152    scopeInput: document.getElementById("edit-scope-input"),
    153    enableCheckbox: document.getElementById("edit-enable-checkbox"),
    154  };
    155 
    156  constructor() {
    157    document
    158      .getElementById("edit-save")
    159      .addEventListener("click", this.onSave.bind(this));
    160    document
    161      .getElementById("edit-cancel")
    162      .addEventListener("click", this.onCancel.bind(this));
    163  }
    164 
    165  show(ruleset) {
    166    const elements = this.elements;
    167    elements.form.reset();
    168    elements.title.textContent = ruleset.name;
    169    elements.jwkTextarea.value = JSON.stringify(ruleset.jwk);
    170    elements.pathPrefixInput.value = ruleset.pathPrefix;
    171    elements.scopeInput.value = ruleset.scope;
    172    elements.enableCheckbox.checked = ruleset.enabled;
    173    this._editing = ruleset;
    174  }
    175 
    176  hide() {
    177    this.elements.form.reset();
    178    this._editing = null;
    179  }
    180 
    181  async onSave(e) {
    182    e.preventDefault();
    183    const elements = this.elements;
    184 
    185    let valid = true;
    186    const name = this._editing.name;
    187 
    188    let jwk;
    189    try {
    190      jwk = JSON.parse(elements.jwkTextarea.value);
    191      await crypto.subtle.importKey(
    192        "jwk",
    193        jwk,
    194        {
    195          name: "RSA-PSS",
    196          saltLength: 32,
    197          hash: { name: "SHA-256" },
    198        },
    199        true,
    200        ["verify"]
    201      );
    202      elements.jwkTextarea.setCustomValidity("");
    203    } catch (err) {
    204      console.error("Invalid JSON or invalid JWK", err);
    205      elements.jwkTextarea.setCustomValidity(
    206        await document.l10n.formatValue("rulesets-details-jwk-input-invalid")
    207      );
    208      valid = false;
    209    }
    210 
    211    const pathPrefix = elements.pathPrefixInput.value.trim();
    212    try {
    213      const url = URL.parse(pathPrefix);
    214      if (url?.protocol !== "http:" && url?.protocol !== "https:") {
    215        elements.pathPrefixInput.setCustomValidity(
    216          await document.l10n.formatValue("rulesets-details-path-input-invalid")
    217        );
    218        valid = false;
    219      } else {
    220        elements.pathPrefixInput.setCustomValidity("");
    221      }
    222    } catch (err) {
    223      console.error("The path prefix is not a valid URL", err);
    224      elements.pathPrefixInput.setCustomValidity(
    225        await document.l10n.formatValue("rulesets-details-path-input-invalid")
    226      );
    227      valid = false;
    228    }
    229 
    230    let scope;
    231    try {
    232      scope = new RegExp(elements.scopeInput.value.trim());
    233      elements.scopeInput.setCustomValidity("");
    234    } catch (err) {
    235      elements.scopeInput.setCustomValidity(
    236        await document.l10n.formatValue("rulesets-details-scope-input-invalid")
    237      );
    238      valid = false;
    239    }
    240 
    241    if (!valid) {
    242      return;
    243    }
    244 
    245    const enabled = elements.enableCheckbox.checked;
    246 
    247    const rulesetData = { name, jwk, pathPrefix, scope, enabled };
    248    const ruleset = await RPMSendQuery("rulesets:set-channel", rulesetData);
    249    gAboutRulesets.setState(States.Details, ruleset);
    250    if (enabled) {
    251      try {
    252        await RPMSendQuery("rulesets:update-channel", name);
    253      } catch (err) {
    254        console.warn("Could not update the ruleset after adding it", err);
    255      }
    256    }
    257  }
    258 
    259  onCancel(e) {
    260    e.preventDefault();
    261    if (this._editing === null) {
    262      gAboutRulesets.selectFirst();
    263    } else {
    264      gAboutRulesets.setState(States.Details, this._editing);
    265    }
    266  }
    267 }
    268 
    269 /**
    270 * State shown when no rulesets are available.
    271 * Currently, the only way to reach it is to delete all the channels manually.
    272 */
    273 class NoRulesetsState {
    274  show() {}
    275  hide() {}
    276 }
    277 
    278 /**
    279 * Manages the sidebar with the list of the various channels, and keeps it in
    280 * sync with the data we receive from the backend.
    281 */
    282 class RulesetList {
    283  elements = {
    284    list: document.getElementById("ruleset-list"),
    285    emptyContainer: document.getElementById("ruleset-list-empty"),
    286    itemTemplate: document.getElementById("ruleset-template"),
    287  };
    288 
    289  nameAttribute = "data-name";
    290 
    291  rulesets = [];
    292 
    293  constructor() {
    294    RPMAddMessageListener(
    295      "rulesets:channels-change",
    296      this.onRulesetsChanged.bind(this)
    297    );
    298  }
    299 
    300  getSelectedRuleset() {
    301    const name = this.elements.list
    302      .querySelector(".selected")
    303      ?.getAttribute(this.nameAttribute);
    304    for (const ruleset of this.rulesets) {
    305      if (ruleset.name == name) {
    306        return ruleset;
    307      }
    308    }
    309    return null;
    310  }
    311 
    312  isEmpty() {
    313    return !this.rulesets.length;
    314  }
    315 
    316  async update() {
    317    this.rulesets = await RPMSendQuery("rulesets:get-channels");
    318    await this._populateRulesets();
    319  }
    320 
    321  setItemSelected(name) {
    322    name = name.replace(/["\\]/g, "\\$&");
    323    const item = this.elements.list.querySelector(
    324      `.item[${this.nameAttribute}="${name}"]`
    325    );
    326    this._selectItem(item);
    327  }
    328 
    329  async _populateRulesets() {
    330    if (this.isEmpty()) {
    331      this.elements.emptyContainer.classList.remove("hidden");
    332    } else {
    333      this.elements.emptyContainer.classList.add("hidden");
    334    }
    335 
    336    const list = this.elements.list;
    337    const selName = list
    338      .querySelector(".item.selected")
    339      ?.getAttribute(this.nameAttribute);
    340    const items = list.querySelectorAll(".item");
    341    for (const item of items) {
    342      item.remove();
    343    }
    344 
    345    for (const ruleset of this.rulesets) {
    346      const item = this._addItem(ruleset);
    347      if (ruleset.name === selName) {
    348        this._selectItem(item);
    349      }
    350    }
    351  }
    352 
    353  _addItem(ruleset) {
    354    const item = this.elements.itemTemplate.cloneNode(true);
    355    item.removeAttribute("id");
    356    item.classList.add("item");
    357    item.querySelector(".name").textContent = ruleset.name;
    358    const descr = item.querySelector(".description");
    359    setUpdateDate(ruleset, descr);
    360    item.classList.toggle("disabled", !ruleset.enabled);
    361    item.setAttribute(this.nameAttribute, ruleset.name);
    362    item.addEventListener("click", () => {
    363      this.onRulesetClick(ruleset);
    364    });
    365    this.elements.list.append(item);
    366    return item;
    367  }
    368 
    369  _selectItem(item) {
    370    this.elements.list.querySelector(".selected")?.classList.remove("selected");
    371    item?.classList.add("selected");
    372  }
    373 
    374  onRulesetClick(ruleset) {
    375    gAboutRulesets.setState(States.Details, ruleset);
    376  }
    377 
    378  onRulesetsChanged(data) {
    379    this.rulesets = data.data;
    380    this._populateRulesets();
    381    const selected = this.getSelectedRuleset();
    382    if (selected !== null) {
    383      gAboutRulesets.setState(States.Details, selected);
    384    }
    385  }
    386 }
    387 
    388 /**
    389 * The entry point of about:rulesets.
    390 * It initializes the various states and allows to switch between them.
    391 */
    392 class AboutRulesets {
    393  _state = null;
    394 
    395  async init() {
    396    const args = await RPMSendQuery("rulesets:get-init-args");
    397    const showWarning = args.showWarning;
    398 
    399    this.list = new RulesetList();
    400    this._states = {};
    401    this._states[States.Warning] = new WarningState();
    402    this._states[States.Details] = new DetailsState();
    403    this._states[States.Edit] = new EditState();
    404    this._states[States.NoRulesets] = new NoRulesetsState();
    405 
    406    await this.refreshRulesets();
    407 
    408    if (showWarning) {
    409      this.setState(States.Warning);
    410    } else {
    411      this.selectFirst();
    412    }
    413  }
    414 
    415  setState(state, ...args) {
    416    document.querySelector("body").className = `state-${state}`;
    417    this._state?.hide();
    418    this._state = this._states[state];
    419    this._state.show(...args);
    420  }
    421 
    422  async refreshRulesets() {
    423    await this.list.update();
    424    if (this._state === this._states[States.Details]) {
    425      const ruleset = this.list.getSelectedRuleset();
    426      if (ruleset !== null) {
    427        this.setState(States.Details, ruleset);
    428      } else {
    429        this.selectFirst();
    430      }
    431    } else if (this.list.isEmpty()) {
    432      this.setState(States.NoRulesets);
    433    }
    434  }
    435 
    436  selectFirst() {
    437    if (this.list.isEmpty()) {
    438      this.setState(States.NoRulesets);
    439    } else {
    440      this.setState("details", this.list.rulesets[0]);
    441    }
    442  }
    443 }
    444 
    445 const gAboutRulesets = new AboutRulesets();
    446 gAboutRulesets.init();