diff options
author | hackademix | 2018-07-01 01:01:23 +0200 |
---|---|---|
committer | hackademix | 2018-07-01 01:01:23 +0200 |
commit | eceae7187a6f0e9510bc1165f6977256b87f490f (patch) | |
tree | d943f1ec73c09efa70954dcedb55eac82a726148 /src/ui/ui.js | |
download | noscript-eceae7187a6f0e9510bc1165f6977256b87f490f.tar.gz noscript-eceae7187a6f0e9510bc1165f6977256b87f490f.tar.xz noscript-eceae7187a6f0e9510bc1165f6977256b87f490f.zip |
Initial commit starting at version 10.1.8.3rc4.
Diffstat (limited to 'src/ui/ui.js')
-rw-r--r-- | src/ui/ui.js | 661 |
1 files changed, 661 insertions, 0 deletions
diff --git a/src/ui/ui.js b/src/ui/ui.js new file mode 100644 index 0000000..99943d7 --- /dev/null +++ b/src/ui/ui.js @@ -0,0 +1,661 @@ +'use strict'; +var UI = (() => { + + var UI = { + initialized: false, + + presets: { + "DEFAULT": "Default", + "T_TRUSTED": "Trusted_temporary", + "TRUSTED": "Trusted_permanent", + "UNTRUSTED": "Untrusted", + "CUSTOM": "Custom", + }, + + async init(tabId = -1) { + UI.tabId = tabId; + let scripts = [ + "/ui/ui.css", + "/lib/punycode.js", + "/lib/tld.js", + "/common/Policy.js", + ]; + this.mobile = !("windows" in browser); + if (this.mobile) { + document.documentElement.classList.toggle("mobile", true); + scripts.push("/lib/fastclick.js"); + } + await include(scripts); + + detectHighContrast(); + + let inited = new Promise(resolve => { + let listener = m => { + if (m.type === "settings") { + UI.policy = new Policy(m.policy); + UI.snapshot = UI.policy.snapshot; + UI.seen = m.seen; + UI.unrestrictedTab = m.unrestrictedTab; + UI.xssUserChoices = m.xssUserChoices; + UI.local = m.local; + UI.sync = m.sync; + if (UI.local && !UI.local.debug) { + debug = () => {}; // be quiet! + } + resolve(); + if (UI.onSettings) UI.onSettings(); + } + }; + browser.runtime.onMessage.addListener(listener); + + if (this.mobile) FastClick.attach(document.body); + UI.pullSettings(); + }); + + await inited; + + this.initialized = true; + debug("Imported", Policy); + }, + async pullSettings() { + browser.runtime.sendMessage({type: "NoScript.broadcastSettings", tabId: UI.tabId}); + }, + async updateSettings({policy, xssUserChoices, unrestrictedTab, local, sync, reloadAffected}) { + if (policy) policy = policy.dry(true); + return await browser.runtime.sendMessage({type: "NoScript.updateSettings", + policy, + xssUserChoices, + unrestrictedTab, + local, + sync, + reloadAffected, + tabId: UI.tabId, + }); + }, + + async exportSettings() { + return await browser.runtime.sendMessage({type: "NoScript.exportSettings"}); + }, + async importSettings(data) { + return await browser.runtime.sendMessage({type: "NoScript.importSettings", data}); + }, + + async revokeTemp() { + let policy = this.policy; + Policy.hydrate(policy.dry(), policy); + if (this.isDirty(true)) { + await this.updateSettings({policy, reloadAffected: true}); + } + }, + + isDirty(reset = false) { + let currentSnapshot = this.policy.snapshot; + let dirty = currentSnapshot != this.snapshot; + if (reset) this.snapshot = currentSnapshot; + return dirty; + }, + + async openSiteInfo(domain) { + let url = `/ui/siteInfo.html#${encodeURIComponent(domain)};${UI.tabId}`; + browser.tabs.create({url}); + } + }; + + function detectHighContrast() { + // detect high contrast + let canary = document.createElement("input"); + canary.className="https-only"; + canary.style.display = "none"; + document.body.appendChild(canary); + if (UI.highContrast = window.getComputedStyle(canary).backgroundImage === "none") { + include("/ui/ui-hc.css"); + document.documentElement.classList.toggle("hc"); + } + canary.parentNode.removeChild(canary); + } + + function fireOnChange(sitesUI, data) { + if (UI.isDirty(true)) { + UI.updateSettings({policy: UI.policy}); + if (sitesUI.onChange) sitesUI.onChange(data, this); + } + } + + function compareBy(prop, a, b) { + let x = a[prop], y = b[prop]; + return x > y ? 1 : x < y ? -1 : 0; + } + + const TEMPLATE = ` + <table class="sites"> + <tr class="site"> + + <td class="presets"> + <span class="preset"> + <input id="preset" class="preset" type="radio" name="preset"><label for="preset" class="preset">PRESET</label> + <button class="options tiny">⚙</button> + <input id="temp" class="temp" type="checkbox"><label for="temp">Temporary</input> + </span> + </td> + + <td class="url" data-key="secure"> + <input class="https-only" id="https-only" type="checkbox"><label for="https-only" class="https-only"></label> + <span class="full-address"> + <span class="protocol">https://</span><span class="sub">www.</span><span class="domain">noscript.net</span><span class="path"></span> + </span> + </td> + + + + </tr> + <tr class="customizer"> + <td colspan="2"> + <div class="customizer-controls"> + <fieldset><legend></legend> + <span class="cap"> + <input class="cap" type="checkbox" value="script" /> + <label class="cap">script</label> + </span> + </fieldset> + </div> + </td> + </tr> + </table> + `; + + const TEMP_PRESETS = ["CUSTOM"]; + const DEF_PRESETS = { + // name: customizable, + "DEFAULT": false, + "T_TRUSTED": false, + "TRUSTED": false, + "UNTRUSTED": false, + "CUSTOM": true, + }; + + UI.Sites = class { + constructor(parentNode, presets = DEF_PRESETS) { + this.parentNode = parentNode; + let policy = UI.policy; + this.uiCount = UI.Sites.count = (UI.Sites.count || 0) + 1; + this.sites = policy.sites; + this.presets = presets; + this.customizing = null; + this.typesMap = new Map(); + this.clear(); + } + + initRow(table = this.table) { + let row = table.querySelector("tr.site"); + + // PRESETS + { + let presets = row.querySelector(".presets"); + let [span, input, label, options] = presets.querySelectorAll("span.preset, input.preset, label.preset, .options"); + span.remove(); + options.title = _("Options"); + for (let [preset, customizable] of Object.entries(this.presets)) { + let messageKey = UI.presets[preset]; + input.value = preset; + label.textContent = label.title = input.title = _(messageKey); + let clone = span.cloneNode(true); + clone.classList.add(preset); + let temp = clone.querySelector(".temp"); + if (TEMP_PRESETS.includes(preset)) { + temp.title = _("allowTemp", `(${label.title.toUpperCase()})`); + temp.nextElementSibling.textContent = _("allowTemp", ""); // label; + } else { + temp.nextElementSibling.remove(); + temp.remove(); + } + if (customizable) { + clone.querySelector(".options").remove(); + } + presets.appendChild(clone); + } + } + + // URL + { + let [input, label] = row.querySelectorAll("input.https-only, label.https-only"); + input.title = label.title = label.textContent = _("httpsOnly"); + } + + // CUSTOMIZER ROW + { + let [customizer, legend, cap, capInput, capLabel] = table.querySelectorAll(".customizer, legend, span.cap, input.cap, label.cap"); + row._customizer = customizer; + customizer.remove(); + let capParent = cap.parentNode; + capParent.removeChild(cap); + legend.textContent = _("allow"); + let idSuffix = UI.Sites.count; + for (let capability of Permissions.ALL) { + capInput.id = `capability-${capability}-${idSuffix}` + capLabel.setAttribute("for", capInput.id); + capInput.value = capability; + capInput.title = capLabel.textContent = _(`cap_${capability}`); + let clone = capParent.appendChild(cap.cloneNode(true)); + clone.classList.add(capability); + } + } + + // debug(table.outerHTML); + return row; + } + + allSiteRows() { + return this.table.querySelectorAll("tr.site"); + } + clear() { + debug("Clearing list", this.table); + + this.template = document.createElement("template"); + this.template.innerHTML = TEMPLATE; + this.fragment = this.template.content; + this.table = this.fragment.querySelector("table.sites"); + this.rowTemplate = this.initRow(); + + for (let r of this.allSiteRows()) { + r.parentNode.removeChild(r); + } + this.customize(null); + this.sitesCount = 0; + } + + siteNeeds(site, type) { + let siteTypes = this.typesMap && this.typesMap.get(site); + return !!siteTypes && siteTypes.has(type); + } + + handleEvent(ev) { + let target = ev.target; + let customizer = target.closest(".customizer"); + let row = customizer ? customizer.parentNode.querySelector("tr.customizing") : target.closest("tr.site"); + if (!row) return; + row.temp2perm = false; + let isTemp = target.matches("input.temp"); + let preset = target.matches("input.preset") ? target + : customizer || isTemp ? row.querySelector("input.preset:checked") + : target.closest("input.preset"); + debug("%s target %o\n\trow %s, perms %o\npreset %s %s", + ev.type, + target, row && row.siteMatch, row && row.perms, + preset && preset.value, preset && preset.checked); + + if (!preset) { + if (target.matches("input.https-only") && ev.type === "change") { + this.toggleSecure(row, target.checked); + fireOnChange(this, row); + } else if (target.matches(".domain")) { + UI.openSiteInfo(row.domain); + } + return; + } + + let policy = UI.policy; + let {siteMatch, contextMatch, perms} = row; + let presetValue = preset.value; + let policyPreset = presetValue.startsWith("T_") ? policy[presetValue.substring(2)].tempTwin : policy[presetValue]; + + if (policyPreset) { + if (row.perms !== policyPreset) { + row.temp2perm = row.perms && policyPreset.tempTwin === row.perms; + row.perms = policyPreset; + } + } + + + let isCap = customizer && target.matches(".cap"); + let tempToggle = preset.parentNode.querySelector("input.temp"); + + if (ev.type === "change") { + if (preset.checked) { + row.dataset.preset = preset.value; + } + if (isCap) { + perms.set(target.value, target.checked); + } else if (policyPreset) { + if (tempToggle && tempToggle.checked) { + policyPreset = policyPreset.tempTwin; + } + row.contextMatch = null; + row.perms = policyPreset; + delete row._customPerms; + debug("Site match", siteMatch); + if (siteMatch) { + policy.set(siteMatch, policyPreset); + } else { + this.customize(policyPreset, preset, row); + } + + } else if (preset.value === "CUSTOM") { + if (isTemp) { + row.perms.temp = target.checked; + } else { + let temp = preset.parentNode.querySelector("input.temp").checked; + let perms = row._customPerms || + (row._customPerms = new Permissions(new Set(row.perms.capabilities), temp)); + row.perms = perms; + policy.set(siteMatch, perms); + this.customize(perms, preset, row); + } + } + fireOnChange(this, row); + } else if (!(isCap || isTemp) && ev.type === "click") { + this.customize(row.perms, preset, row); + } + } + + customize(perms, preset, row) { + debug("Customize preset %s (%o) - Dirty: %s", preset && preset.value, perms, this.dirty); + for(let r of this.table.querySelectorAll("tr.customizing")) { + r.classList.toggle("customizing", false); + } + let customizer = this.rowTemplate._customizer; + customizer.classList.toggle("closed", true); + + if (!(perms && row && preset && + row.dataset.preset === preset.value && + this.presets[preset.value] && + preset !== customizer._preset)) { + delete customizer._preset; + return; + } + + customizer._preset = preset; + row.classList.toggle("customizing", true); + let immutable = Permissions.IMMUTABLE[preset.value] || {}; + for (let input of customizer.querySelectorAll("input")) { + let type = input.value; + if (type in immutable) { + input.disabled = true; + input.checked = immutable[type]; + } else { + input.checked = perms.allowing(type); + input.disabled = false; + } + input.parentNode.classList.toggle("needed", this.siteNeeds(row._site, type)); + row.parentNode.insertBefore(customizer, row.nextElementSibling); + customizer.classList.toggle("closed", false); + customizer.onkeydown = e => { + switch(e.keyCode) { + case 38: + case 8: + e.preventDefault(); + this.onkeydown = null; + this.customize(null); + preset.focus(); + return false; + } + } + window.setTimeout(() => customizer.querySelector("input").focus(), 50); + } + } + + render(sites = this.sites, sorter = this.sorter) { + let parentNode = this.parentNode; + debug("Rendering %o inside %o", sites, parentNode); + if (sites) this._populate(sites, sorter); + parentNode.innerHTML = ""; + parentNode.appendChild(this.fragment); + let root = parentNode.querySelector("table.sites"); + debug("Wiring", root); + if (!root.wiredBy) { + root.addEventListener("click", this, true); + root.addEventListener("change", this, true); + root.wiredBy = this; + } + return root; + } + + _populate(sites, sorter) { + this.clear(); + if (sites instanceof Sites) { + for (let [site, perms] of sites) { + this.append(site, site, perms); + } + } else { + for (let site of sites) { + let context = null; + if (site.site) { + site = site.site; + context = site.context; + } + let {siteMatch, perms, contextMatch} = UI.policy.get(site, context); + this.append(site, siteMatch, perms, contextMatch); + } + this.sites = sites; + } + this.sort(sorter); + window.setTimeout(() => this.focus(), 50); + } + + focus() { + let firstPreset = this.table.querySelector("input.preset:checked"); + if (firstPreset) firstPreset.focus(); + } + + sort(sorter = this.sorter) { + if (this.mainDomain) { + let md = this.mainDomain; + let wrappedCompare = sorter; + sorter = (a, b) => { + let x = a.domain, y = b.domain; + if (x === md) { + if (y !== md) { + return -1; + } + } else if (y === md) { + return 1; + } + return wrappedCompare(a, b); + } + } + let rows = [...this.allSiteRows()].sort(sorter); + if (this.mainSite) { + let mainLabel = "." + this.mainDomain; + let topIdx = rows.findIndex(r => r._label === mainLabel); + if (topIdx === -1) rows.findIndex(r => r._site === this.mainSite); + if (topIdx !== -1) { + // move the row to the top + let topRow = rows.splice(topIdx, 1)[0]; + rows.unshift(topRow); + topRow.classList.toggle("main", true); + } + } + this.clear(); + for (let row of rows) this.table.appendChild(row); + this.table.appendChild(this.rowTemplate._customizer); + } + + sorter(a, b) { + return compareBy("domain", a, b) || compareBy("_label", a, b); + } + + async tempTrustAll() { + let {policy} = UI; + let changed = 0; + for (let row of this.allSiteRows()) { + if (row._preset === "DEFAULT") { + policy.set(row._site, policy.TRUSTED.tempTwin); + changed++; + } + } + if (changed && UI.isDirty(true)) { + await UI.updateSettings({policy, reloadAffected: true}); + } + return changed; + } + + createSiteRow(site, siteMatch, perms, contextMatch = null, sitesCount = this.sitesCount++) { + debug("Creating row for site: %s, matching %s / %s, %o", site, siteMatch, contextMatch, perms); + + let row = this.rowTemplate.cloneNode(true); + row.sitesCount = sitesCount; + let url; + try { + url = new URL(site); + } catch (e) { + let protocol = Sites.isSecureDomainKey(site) ? "https:" : "http:"; + let hostname = Sites.toggleSecureDomainKey(site, false); + url = {protocol, hostname, origin: `${protocol}://${site}`, pathname: "/"}; + } + + let hostname = Sites.toExternal(url.hostname); + let domain = tld.getDomain(hostname); + + if (!siteMatch) { + // siteMatch = url.protocol === "https:" ? Sites.secureDomainKey(domain) : site; + siteMatch = site; + } + let secure = Sites.isSecureDomainKey(siteMatch); + let keyStyle = secure ? "secure" + : !domain || /^\w+:/.test(siteMatch) ? + (url.protocol === "https:" ? "full" : "unsafe") + : domain === hostname ? "domain" : "host"; + + let urlContainer = row.querySelector(".url"); + urlContainer.dataset.key = keyStyle; + row._site = site; + + row.siteMatch = siteMatch; + row.contextMatch = contextMatch; + row.perms = perms; + row.domain = domain || siteMatch; + if (domain) { // "normal" URL + let justDomain = hostname === domain; + let domainEntry = secure || domain === site; + row._label = domainEntry ? "." + domain : site; + row.querySelector(".protocol").textContent = `${url.protocol}//`; + row.querySelector(".sub").textContent = justDomain ? + (keyStyle === "full" || keyStyle == "unsafe" + ? "" : "…") + : hostname.substring(0, hostname.length - domain.length); + + row.querySelector(".domain").textContent = domain; + row.querySelector(".path").textContent = siteMatch.length > url.origin.length ? url.pathname : ""; + let httpsOnly = row.querySelector("input.https-only"); + httpsOnly.checked = keyStyle === "full" || keyStyle === "secure"; + } else { + row._label = siteMatch; + urlContainer.querySelector(".full-address").textContent = siteMatch; + } + + let presets = row.querySelectorAll("input.preset"); + let idSuffix = `-${this.uiCount}-${sitesCount}`; + for (let p of presets) { + p.id = `${p.value}${idSuffix}`; + p.name = `preset${idSuffix}`; + let label = p.nextElementSibling; + label.setAttribute("for", p.id); + let temp = p.parentNode.querySelector("input.temp"); + if (temp) { + temp.id = `temp-${p.id}`; + label = temp.nextElementSibling; + label.setAttribute("for", temp.id); + } + } + let policy = UI.policy; + + let presetName = "CUSTOM"; + for (let p of ["TRUSTED", "UNTRUSTED", "DEFAULT"]) { + let preset = policy[p]; + switch (perms) { + case preset: + presetName = p; + break; + case preset.tempTwin: + presetName = `T_${p}`; + if (!presetName in UI.presets) { + presetName = p; + } + break; + } + } + let tempFirst = true; // TODO: make it a preference + let unsafeMatch = keyStyle !== "secure" && keyStyle !== "full"; + if (presetName === "DEFAULT" && (tempFirst || unsafeMatch)) { + // prioritize temporary privileges over permanent + for (let p of TEMP_PRESETS) { + if (p in this.presets && (unsafeMatch || tempFirst && p === "TRUSTED")) { + row.querySelector(`.presets input[value="${p}"]`).parentNode.querySelector("input.temp").checked = true; + perms = policy.TRUSTED.tempTwin; + } + } + } + let preset = row.querySelector(`.presets input[value="${presetName}"]`); + if (!preset) { + debug(`Preset %s not found in %s!`, presetName, row.innerHTML); + } else { + preset.checked = true; + row.dataset.preset = row._preset = presetName; + if (TEMP_PRESETS.includes(presetName)) { + let temp = preset.parentNode.querySelector("input.temp"); + if (temp) { + temp.checked = perms.temp; + } + } + } + return row; + } + + append(site, siteMatch, perms, contextMatch) { + this.table.appendChild(this.createSiteRow(...arguments)); + } + + toggleSecure(row, secure = !!row.querySelector("https-only:checked")) { + this.customize(null); + let site = row.siteMatch; + site = site.replace(/^https?:/, secure ? "https:" : "http:"); + if (site === row.siteMatch) { + site = Sites.toggleSecureDomainKey(site, secure); + } + if (site !== row.siteMatch) { + let {policy} = UI; + policy.set(row.siteMatch, policy.DEFAULT); + policy.set(site, row.perms); + for(let r of this.allSiteRows()) { + if (r !== row && r.siteMatch === site && r.contextMatch === row.contextMatch) { + r.parentNode.removeChild(r); + } + } + let newRow = this.createSiteRow(site, site, row.perms, row.contextMatch, row.sitesCount); + row.parentNode.replaceChild(newRow, row); + } + } + + highlight(key) { + key = Sites.toExternal(key); + for (let r of this.allSiteRows()) { + if (r.querySelector(".full-address").textContent.trim().includes(key)) { + let url = r.lastElementChild; + url.style.transition = r.style.transition = "none"; + r.style.backgroundColor = "#850"; + url.style.transform = "scale(2)"; + r.querySelector("input.preset:checked").focus(); + window.setTimeout(() => { + r.style.transition = "1s background-color"; + url.style.transition = "1s transform"; + r.style.backgroundColor = ""; + url.style.transform = "none"; + r.scrollIntoView(); + }, 50); + } + } + } + + filterSites(key) { + key = Sites.toExternal(key); + for (let r of this.allSiteRows()) { + if (r.querySelector(".full-address").textContent.trim().includes(key)) { + r.style.display = ""; + } else { + r.style.display = "none"; + } + } + } + } + + return UI; +})(); |