1140 lines
38 KiB
JavaScript
1140 lines
38 KiB
JavaScript
/*
|
|
* NoScript - a Firefox extension for whitelist driven safe JavaScript execution
|
|
*
|
|
* Copyright (C) 2005-2021 Giorgio Maone <https://maone.net>
|
|
*
|
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify it under
|
|
* the terms of the GNU General Public License as published by the Free Software
|
|
* Foundation, either version 3 of the License, or (at your option) any later
|
|
* version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful, but WITHOUT
|
|
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
|
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License along with
|
|
* this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
'use strict';
|
|
var UI = (() => {
|
|
|
|
var UI = {
|
|
initialized: false,
|
|
isBrowserAction: false,
|
|
|
|
presets: {
|
|
"DEFAULT": "Default",
|
|
"T_TRUSTED": "Trusted_temporary",
|
|
"TRUSTED": "Trusted_permanent",
|
|
"UNTRUSTED": "Untrusted",
|
|
"CUSTOM": "Custom",
|
|
},
|
|
|
|
async init(tab) {
|
|
UI.tabId = tab ? tab.id : -1;
|
|
document.documentElement.classList.toggle("incognito",
|
|
UI.incognito = tab && tab.incognito
|
|
);
|
|
let scripts = [
|
|
"/nscl/common/Messages.js",
|
|
"/nscl/lib/punycode.js",
|
|
"/nscl/common/tld.js",
|
|
"/nscl/common/Sites.js",
|
|
"/nscl/common/Permissions.js",
|
|
"/nscl/common/Policy.js",
|
|
];
|
|
this.mobile = UA.mobile;
|
|
let root = document.documentElement;
|
|
if (this.mobile) {
|
|
root.classList.add("mobile");
|
|
}
|
|
await include(scripts);
|
|
|
|
let inited = new Promise(resolve => {
|
|
Messages.addHandler({
|
|
async settings(m) {
|
|
if (UI.tabId !== m.tabId) return;
|
|
UI.policy = new Policy(m.policy);
|
|
UI.snapshot = UI.policy.snapshot;
|
|
UI.seen = m.seen;
|
|
UI.unrestrictedTab = m.unrestrictedTab;
|
|
UI.xssUserChoices = m.xssUserChoices;
|
|
UI.xssBlockedInTab = m.xssBlockedInTab;
|
|
UI.local = m.local;
|
|
UI.sync = m.sync;
|
|
UI.forceIncognito = UI.incognito && !UI.sync.overrideTorBrowserPolicy;
|
|
if (UI.local) {
|
|
if (!UI.local.debug) {
|
|
debug = () => {}; // be quiet!
|
|
}
|
|
document.documentElement.classList.toggle("tor", !!UI.local.isTorBrowser);
|
|
if (UI.local.isTorBrowser) {
|
|
Sites.onionSecure = true;
|
|
}
|
|
}
|
|
resolve();
|
|
if (UI.onSettings) UI.onSettings();
|
|
await HighContrast.init();
|
|
if (UI.tabId === -1 || UI.xssBlockedInTab) UI.createXSSChoiceManager();
|
|
}
|
|
});
|
|
UI.pullSettings();
|
|
});
|
|
|
|
await inited;
|
|
|
|
this.initialized = true;
|
|
debug("Imported", Policy);
|
|
},
|
|
async pullSettings() {
|
|
Messages.send("broadcastSettings", {tabId: UI.tabId});
|
|
},
|
|
async updateSettings({policy, xssUserChoices, unrestrictedTab, local, sync, reloadAffected}) {
|
|
if (policy) policy = policy.dry(true);
|
|
return await Messages.send("updateSettings", {
|
|
policy,
|
|
xssUserChoices,
|
|
unrestrictedTab,
|
|
local,
|
|
sync,
|
|
reloadAffected,
|
|
tabId: UI.tabId,
|
|
});
|
|
},
|
|
|
|
async exportSettings() {
|
|
return await Messages.send("exportSettings");
|
|
},
|
|
async importSettings(data) {
|
|
return await Messages.send("importSettings", {data});
|
|
},
|
|
|
|
async revokeTemp(reloadAffected = false) {
|
|
let policy = this.policy;
|
|
Policy.hydrate(policy.dry(), policy);
|
|
if (this.isDirty(true)) {
|
|
await this.updateSettings({policy, reloadAffected});
|
|
}
|
|
},
|
|
|
|
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});
|
|
},
|
|
|
|
wireChoice(name, storage = "sync", onchange) {
|
|
let inputs = document.querySelectorAll(`input[type=radio][name="${name}"]`);
|
|
if (inputs.length === 0) {
|
|
error(`Radio button w/ name "${name}" not found.`);
|
|
return;
|
|
}
|
|
if (typeof storage === "function") {
|
|
(async() => {
|
|
let value = await storage(null);
|
|
for (let i of inputs) {
|
|
i.onchange = e => storage(i);
|
|
i.checked = value === i.value;
|
|
}
|
|
})();
|
|
} else {
|
|
let obj = UI[storage];
|
|
let value = obj[name];
|
|
for (let i of inputs) {
|
|
if (i.value === value) i.checked = true;
|
|
if (onchange) onchange(i);
|
|
i.onchange = async () => {
|
|
obj[name] = i.value;
|
|
await UI.updateSettings({[storage]: obj});
|
|
if (onchange) onchange(i);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
wireOption(name, storage = "sync", onchange) {
|
|
let input = document.querySelector(`#opt-${name}`);
|
|
if (!input) {
|
|
error(`Checkbox w/ id "opt-${name}" not found.`);
|
|
return;
|
|
}
|
|
if (typeof storage === "function") {
|
|
input.onchange = e => storage(input);
|
|
(async () => {
|
|
input.checked = await storage(null);
|
|
})();
|
|
} else {
|
|
let obj = UI[storage];
|
|
input.checked = obj[name];
|
|
if (onchange) onchange(input);
|
|
input.onchange = async () => {
|
|
obj[name] = input.checked;
|
|
await UI.updateSettings({[storage]: obj});
|
|
if (onchange) onchange(input);
|
|
}
|
|
}
|
|
return input;
|
|
},
|
|
|
|
hilite(el) {
|
|
el.classList.add("hilite");
|
|
window.setTimeout(() => {
|
|
el.classList.remove("hilite");
|
|
el.classList.add("hilite-end");
|
|
el.scrollIntoView();
|
|
window.setTimeout(() => {
|
|
el.classList.remove("hilite-end");
|
|
}, 1000)
|
|
}, 50);
|
|
},
|
|
|
|
createXSSChoiceManager(parent = "#xssChoices") {
|
|
if (!UA.isMozilla) return;
|
|
let choicesUI = document.querySelector(parent);
|
|
if (!choicesUI) return;
|
|
choicesUI.classList.remove("populated");
|
|
let choices = Object.entries(UI.xssUserChoices);
|
|
let choiceKeys = UI.xssBlockedInTab;
|
|
if (choiceKeys) {
|
|
choices = choices.filter(([key,])=> choiceKeys.includes(key));
|
|
}
|
|
if (!choices || Object.keys(choices).length === 0) {
|
|
return;
|
|
}
|
|
|
|
choicesUI.classList.add("populated");
|
|
|
|
choices.sort((a, b) => {
|
|
let x = a.join("|"), y = b.join("|");
|
|
return x < y ? -1 : x > y ? 1 : 0;
|
|
});
|
|
let list = choicesUI.querySelector("select") || choicesUI.appendChild(document.createElement("select"));
|
|
list.size = Math.min(choices.length, 6);
|
|
list.multiple = true;
|
|
for (let o of list.options) {
|
|
list.remove(o);
|
|
}
|
|
for (let [originKey, choice] of choices) {
|
|
let [source, destOrigin] = originKey.split(">");
|
|
let opt = document.createElement("option");
|
|
opt.className = choice;
|
|
opt.value = originKey;
|
|
let block = choice === "block";
|
|
opt.defaultSelected = block;
|
|
opt.text = _(`XSS_optAlways${block ? "Block" : "Allow"}`, [source || "[...]", destOrigin]);
|
|
list.add(opt);
|
|
}
|
|
let button = choicesUI.querySelector("button");
|
|
if (!button) {
|
|
button = choicesUI.appendChild(document.createElement("button"));
|
|
button.textContent = _("XSS_clearUserChoices");
|
|
}
|
|
(list.onchange = () => {
|
|
button.disabled = list.selectedOptions.length === 0;
|
|
})();
|
|
button.onclick = () => {
|
|
let xssUserChoices = UI.xssUserChoices;
|
|
for (let o of [...list.selectedOptions]) {
|
|
delete xssUserChoices[o.value];
|
|
o.remove();
|
|
}
|
|
let reloadAffected = false;
|
|
if (list.options.length === 0) {
|
|
choicesUI.classList.remove("populated");
|
|
reloadAffected = true;
|
|
}
|
|
UI.updateSettings({
|
|
xssUserChoices,
|
|
reloadAffected,
|
|
});
|
|
};
|
|
}
|
|
};
|
|
|
|
var HighContrast = {
|
|
css: null,
|
|
async init() {
|
|
this.widget = UI.wireOption("highContrast", "local", o => {
|
|
UI.highContrast = o.checked;
|
|
this.toggle();
|
|
});
|
|
await this.toggle();
|
|
},
|
|
async toggle() {
|
|
let hc = "highContrast" in UI ? UI.highContrast : await this.detect();
|
|
if (UI.toolbarInit) UI.toolbarInit();
|
|
if (hc) {
|
|
if (this.css) {
|
|
document.documentElement.appendChild(this.css);
|
|
} else {
|
|
this.css = await include("/ui/ui-hc.css")
|
|
}
|
|
} else if (this.css) {
|
|
this.css.remove();
|
|
}
|
|
document.documentElement.classList.toggle("hc", hc);
|
|
if (this.widget) {
|
|
this.widget.checked = hc;
|
|
}
|
|
},
|
|
|
|
detect() {
|
|
if ("highContrast" in UI.local) {
|
|
UI.highContrast = UI.local.highContrast;
|
|
} else {
|
|
// auto-detect
|
|
let canary = document.createElement("input");
|
|
canary.className="https-only";
|
|
canary.style.display = "none";
|
|
document.body.appendChild(canary);
|
|
UI.highContrast = window.getComputedStyle(canary).backgroundImage === "none";
|
|
canary.remove();
|
|
}
|
|
return UI.highContrast;
|
|
}
|
|
};
|
|
|
|
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];
|
|
if (x.endsWith(":")) {
|
|
if (!y.endsWith(":")) {
|
|
return this.mainDomain ? 1 : -1;
|
|
}
|
|
} else if (y.endsWith(":")) {
|
|
return this.mainDomain ? -1 : 1;
|
|
}
|
|
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>
|
|
<input tabindex="-1" id="temp" class="temp" type="checkbox"><label for="temp">Temporary</label></input>
|
|
</span>
|
|
</td>
|
|
|
|
<td class="url" data-key="secure">
|
|
<input tabindex="0" class="https-only" id="https-only" type="checkbox"><label for="https-only" class="https-only"></label>
|
|
<span tabindex="0" class="full-address" aria-role="button">
|
|
<span class="protocol">https://</span><span class="sub">www.</span><span class="domain">noscript.net</span><span class="path"></span>
|
|
</span>
|
|
</td>
|
|
|
|
|
|
|
|
</tr>
|
|
<tr tabindex="-1" class="customizer">
|
|
<td colspan="2">
|
|
<div class="customizer-controls">
|
|
<fieldset>
|
|
<legend class="capsContext">
|
|
<label></label>
|
|
<div>
|
|
<select><option>ANY SITE</option></select>
|
|
<button class="reset" disabled>Reset</button>
|
|
</div>
|
|
</legend>
|
|
<div class="caps">
|
|
<span class="cap">
|
|
<input class="cap" type="checkbox" value="script" />
|
|
<label class="cap">script</label>
|
|
</span>
|
|
</div>
|
|
|
|
</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,
|
|
};
|
|
const INCOGNITO_PRESETS = ["DEFAULT", "T_TRUSTED", "CUSTOM"];
|
|
|
|
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] = presets.querySelectorAll("span.preset, input.preset, label.preset");
|
|
span.remove();
|
|
for (let [preset, customizable] of Object.entries(this.presets)) {
|
|
let messageKey = UI.presets[preset];
|
|
input.value = preset;
|
|
label.textContent = label.title = input.title = _(messageKey);
|
|
input.disabled = UI.forceIncognito && !INCOGNITO_PRESETS.includes(preset);
|
|
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;
|
|
temp.disabled = UI.forceIncognito;
|
|
} else {
|
|
temp.nextElementSibling.remove();
|
|
temp.remove();
|
|
}
|
|
|
|
presets.appendChild(clone);
|
|
}
|
|
|
|
this.correctSize(presets);
|
|
}
|
|
|
|
// URL
|
|
{
|
|
let [input, label] = row.querySelectorAll("input.https-only, label.https-only");
|
|
input.title = label.title = label.textContent = _("httpsOnly");
|
|
}
|
|
|
|
// CUSTOMIZER ROW
|
|
{
|
|
let [customizer, capsContext, cap, capInput, capLabel] = table.querySelectorAll(".customizer, .capsContext, span.cap, input.cap, label.cap");
|
|
row._customizer = customizer;
|
|
customizer.remove();
|
|
customizer.capsContext = capsContext;
|
|
let capParent = cap.parentNode;
|
|
capParent.removeChild(cap);
|
|
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}`) || capability.replace(/_/g, ' ');
|
|
let clone = capParent.appendChild(cap.cloneNode(true));
|
|
clone.classList.add(capability);
|
|
}
|
|
}
|
|
|
|
// debug(table.outerHTML);
|
|
return row;
|
|
}
|
|
|
|
correctSize(presets) {
|
|
if (this.sizeCorrected) return;
|
|
this.sizeCorrected = true;
|
|
// adapt button to label if needed
|
|
let sizer = document.createElement("div");
|
|
sizer.id = "presets-sizer";
|
|
sizer.classList.add("sites");
|
|
sizer.appendChild(presets.cloneNode(true));
|
|
document.body.appendChild(sizer);
|
|
let labelWidth = 0;
|
|
let biggest = "";
|
|
for (let l of sizer.querySelectorAll("label.preset")) {
|
|
let lw = l.offsetWidth;
|
|
if (lw > labelWidth) {
|
|
labelWidth = lw;
|
|
biggest = l.textContent;
|
|
}
|
|
}
|
|
this.parentNode.style.setProperty("--preset-label-width", (labelWidth) + "px");
|
|
sizer.remove();
|
|
}
|
|
|
|
allSiteRows() {
|
|
return this.table.querySelectorAll("tr.site");
|
|
}
|
|
|
|
anyPermissionsChanged() {
|
|
return Array.from(this.allSiteRows()).some(row => row.permissionsChanged);
|
|
}
|
|
|
|
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.remove();
|
|
}
|
|
|
|
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;
|
|
|
|
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 {siteMatch, contextMatch, perms} = row;
|
|
|
|
let isCap = customizer && target.matches(".cap");
|
|
let tempToggle = preset.parentNode.querySelector("input.temp");
|
|
|
|
if (ev.type === "change") {
|
|
let {policy} = UI;
|
|
if (!row._originalPerms) {
|
|
row._originalPerms = row.perms.clone();
|
|
Object.defineProperty(row, "permissionsChanged", {
|
|
get() {
|
|
return this.perms && !this.perms.sameAs(this._originalPerms);
|
|
}
|
|
});
|
|
}
|
|
if (target.matches(".capsContext select")) {
|
|
let opt = target.querySelector("option:checked");
|
|
if (!opt) return;
|
|
let context = opt.value;
|
|
if (context === "*") context = null;
|
|
({siteMatch, perms, contextMatch} = policy.get(siteMatch, context));
|
|
if (!context) {
|
|
row._customPerms = perms;
|
|
} else if (contextMatch !== context) {
|
|
let temp = row.perms.temp || UI.forceIncognito;
|
|
perms = new Permissions(new Set(row.perms.capabilities), temp);
|
|
row.perms.contextual.set(context, perms);
|
|
fireOnChange(this, row);
|
|
}
|
|
row.perms = perms;
|
|
row.siteMatch = siteMatch;
|
|
row.contextMatch = context;
|
|
this.setupCaps(perms, preset, row);
|
|
tempToggle.checked = perms.temp;
|
|
return;
|
|
}
|
|
|
|
|
|
let presetValue = preset.value;
|
|
let policyPreset = presetValue.startsWith("T_") ? policy[presetValue.substring(2)].tempTwin : policy[presetValue];
|
|
|
|
if (policyPreset && row.perms !== policyPreset) {
|
|
row.perms = policyPreset;
|
|
}
|
|
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;
|
|
if (siteMatch) {
|
|
policy.set(siteMatch, policyPreset);
|
|
} else {
|
|
this.customize(policyPreset, preset, row);
|
|
}
|
|
|
|
} else if (preset.value === "CUSTOM") {
|
|
if (isTemp) {
|
|
row.perms.temp = target.checked || UI.forceIncognito;
|
|
} else {
|
|
let temp = row.perms.temp || UI.forceIncognito;
|
|
tempToggle.checked = temp;
|
|
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 || customizer) && ev.type === "click") {
|
|
this.customize(row.perms, preset, row);
|
|
}
|
|
}
|
|
|
|
setupCaps(perms, preset, row) {
|
|
let immutable = Permissions.IMMUTABLE[preset.value] || {};
|
|
let customizer = this.rowTemplate._customizer;
|
|
customizer.lastInput = null;
|
|
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;
|
|
customizer.lastInput = input;
|
|
}
|
|
input.parentNode.classList.toggle("needed", this.siteNeeds(row._site, type));
|
|
}
|
|
}
|
|
|
|
customize(perms, preset, row) {
|
|
let customizer = this.rowTemplate._customizer;
|
|
if (!preset) {
|
|
preset = customizer._preset;
|
|
delete customizer._preset;
|
|
}
|
|
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);
|
|
}
|
|
customizer.classList.toggle("closed", true);
|
|
|
|
if (!(perms && row && preset &&
|
|
row.dataset.preset === preset.value &&
|
|
this.presets[preset.value] &&
|
|
preset !== customizer._preset)) {
|
|
delete customizer._preset;
|
|
customizer.onkeydown = null;
|
|
customizer.remove();
|
|
return;
|
|
}
|
|
|
|
customizer._preset = preset;
|
|
row.classList.toggle("customizing", true);
|
|
this.setupCaps(perms, preset, row);
|
|
|
|
customizer.classList.toggle("closed", false);
|
|
let temp = preset.parentNode.querySelector("input.temp");
|
|
let contextual = !!temp;
|
|
customizer.classList.toggle("contextual", contextual);
|
|
let [ctxLabel, ctxSelect, ctxReset] = customizer.capsContext.querySelectorAll("label, select, .reset");
|
|
ctxLabel.textContent = _(contextual ? "capsContext" : "caps");
|
|
ctxReset.textContent = _("Reset");
|
|
if (contextual) {
|
|
// contextual settings
|
|
let entry = (value, label = value) => {
|
|
let opt = document.createElement("option");
|
|
opt.value = value;
|
|
opt.textContent = label;
|
|
return opt;
|
|
}
|
|
let toLabel = site => {
|
|
let label = Sites.toExternal(site);
|
|
return label.includes(":") ? label : `…${label}`;
|
|
};
|
|
for (let child; child = ctxSelect.firstChild;) child.remove();
|
|
ctxSelect.appendChild(entry("*", _("anySite")));
|
|
if (this.mainDomain) {
|
|
let key = Sites.optimalKey(this.mainUrl);
|
|
ctxSelect.appendChild(entry(key, toLabel(key))).selected = key === row.contextMatch;
|
|
} else {
|
|
if (!row._customPerms) row._customPerms = row.perms;
|
|
let ctxSites = row._customPerms.contextual;
|
|
if (ctxSites) {
|
|
for (let [site, ctxPerms] of ctxSites.entries()) {
|
|
ctxSelect.appendChild(entry(site, toLabel(site))).selected = perms === ctxPerms;
|
|
}
|
|
}
|
|
}
|
|
let handleSelection = () => {
|
|
let selected = ctxSelect.querySelector("option:checked");
|
|
ctxReset.disabled = !(selected && selected.value !== "*");
|
|
ctxReset.onclick = () => {
|
|
let perms = UI.policy.get(row.siteMatch).perms;
|
|
perms.contextual.delete(row.contextMatch);
|
|
fireOnChange(this, row);
|
|
selected.previousElementSibling.selected = true;
|
|
if (!this.mainDomain) selected.remove();
|
|
ctxSelect.dispatchEvent(new Event("change"));
|
|
}
|
|
}
|
|
ctxSelect.onchange = e => {
|
|
let caps = customizer.querySelector(".caps");
|
|
let pageTurn = caps.cloneNode(true);
|
|
let s = pageTurn.style;
|
|
s.top = `${caps.offsetTop}px`;
|
|
s.left =`${caps.offsetLeft}px`;
|
|
s.width = `${caps.offsetWidth}px`;
|
|
s.height = `${caps.offsetHeight}px`;
|
|
pageTurn.classList.add("pageTurn");
|
|
caps.parentNode.appendChild(pageTurn);
|
|
setTimeout(() => pageTurn.classList.add("endgame"), 1);
|
|
setTimeout(() => pageTurn.remove(), 500);
|
|
handleSelection();
|
|
}
|
|
|
|
handleSelection();
|
|
}
|
|
|
|
row.parentNode.insertBefore(customizer, row.nextElementSibling);
|
|
customizer.onkeydown = e => {
|
|
if (e.shiftKey) return true;
|
|
switch(e.code) {
|
|
case "Tab":
|
|
if (document.activeElement === customizer.lastInput) {
|
|
if (temp) {
|
|
temp.tabIndex = "0";
|
|
temp.onblur = () => this.customize(null);
|
|
setTimeout(() => temp.tabIndex = "-1", 50);
|
|
preset.focus();
|
|
}
|
|
}
|
|
return true;
|
|
case "ArrowUp":
|
|
if (document.activeElement === ctxSelect)
|
|
return; // avoid closing the customizer on context selection change
|
|
case "ArrowLeft":
|
|
case "ArrowRight":
|
|
this.onkeydown = null;
|
|
this.customize(null);
|
|
preset.focus();
|
|
if (!temp) return true;
|
|
switch(e.code.substring(5)) {
|
|
case "Left":
|
|
return false;
|
|
case "Right":
|
|
temp.focus();
|
|
}
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
return false;
|
|
case "KeyT":
|
|
if (temp) temp.checked = !temp.checked || UI.forceIncognito;
|
|
}
|
|
}
|
|
window.setTimeout(() => customizer.querySelector("input:not(:disabled)").focus(), 50);
|
|
}
|
|
|
|
render(sites = this.sites, sorter = this.sorter) {
|
|
let parentNode = this.parentNode;
|
|
if (sites) this._populate(sites, sorter);
|
|
|
|
parentNode.innerHTML = "";
|
|
parentNode.appendChild(this.fragment);
|
|
let root = parentNode.querySelector("table.sites");
|
|
if (!root.wiredBy) {
|
|
root.addEventListener("keydown", e => this._keyNavHandler(e), true);
|
|
root.addEventListener("keyup", e => {
|
|
// we use a keyup listener to open the customizer from other presets
|
|
// because space repetition may lead to unintendedly "click" on the
|
|
// first cap checkbox once focused from keydown
|
|
switch(e.code) {
|
|
case "Space": {
|
|
let focused = document.activeElement;
|
|
if (focused.matches("tr .preset")) {
|
|
focused.closest("tr").querySelector(".preset[value='CUSTOM']").click();
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
}
|
|
}, true);
|
|
root.addEventListener("click", this, true);
|
|
root.addEventListener("change", this, true);
|
|
root.wiredBy = this;
|
|
}
|
|
return root;
|
|
}
|
|
|
|
_keyNavHandler(e) {
|
|
let focused = document.activeElement;
|
|
if (!focused || e.ctrlKey || e.metaKey) return;
|
|
let row = focused.closest("tr");
|
|
if (!row || row.matches(".customizer")) return;
|
|
let dir = "next";
|
|
let newRow;
|
|
let mappedPreset = ({
|
|
"+": "TRUSTED",
|
|
"-": "UNTRUSTED",
|
|
"0": "DEFAULT",
|
|
"t": "T_TRUSTED",
|
|
"c": "CUSTOM"
|
|
})[e.key];
|
|
|
|
if (mappedPreset) {
|
|
let p = row.querySelector(`.preset[value='${mappedPreset}']`);
|
|
if (p) {
|
|
p.focus();
|
|
p.click();
|
|
e.preventDefault();
|
|
}
|
|
return;
|
|
}
|
|
|
|
switch(e.code) {
|
|
case "Delete":
|
|
case "Backspace":
|
|
row.querySelector(".preset[value='DEFAULT']").click();
|
|
e.preventDefault();
|
|
break;
|
|
case "Enter":
|
|
case "Space":
|
|
if (focused.matches(".full-address")) {
|
|
UI.openSiteInfo(row.domain);
|
|
}
|
|
break;
|
|
case "Home":
|
|
newRow = row;
|
|
case "ArrowUp":
|
|
dir = "previous";
|
|
case "ArrowDown":
|
|
if (!newRow) {
|
|
this.customize(null);
|
|
let prop = `${dir}ElementSibling`;
|
|
newRow = row[prop];
|
|
if (!(newRow && newRow.matches("tr"))) newRow = row;
|
|
}
|
|
|
|
if (newRow === row) {
|
|
let topButton = document.querySelector("#top > .icon");
|
|
if (topButton) topButton.focus();
|
|
} else {
|
|
newRow.querySelector("input.preset:checked").focus();
|
|
}
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
break;
|
|
case "KeyS":
|
|
row.querySelector(".https-only").click();
|
|
break;
|
|
case "KeyI":
|
|
UI.openSiteInfo(row.domain);
|
|
break;
|
|
}
|
|
}
|
|
|
|
_populate(sites, sorter) {
|
|
this.clear();
|
|
let hasTemp = false;
|
|
if (sites instanceof Sites) {
|
|
for (let [site, perms] of sites) {
|
|
this.append(site, site, perms);
|
|
if (!hasTemp) hasTemp = perms.temp;
|
|
}
|
|
} else {
|
|
let top = Sites.optimalKey(this.mainUrl);
|
|
for (let site of sites) {
|
|
let context = top;
|
|
if (site.site) {
|
|
site = site.site;
|
|
context = site.context;
|
|
}
|
|
let {siteMatch, perms, contextMatch} = UI.policy.get(site, context);
|
|
this.append(site, siteMatch, perms, contextMatch);
|
|
if (!hasTemp) hasTemp = perms.temp;
|
|
}
|
|
this.sites = sites;
|
|
}
|
|
this.hasTemp = hasTemp;
|
|
this.sort(sorter);
|
|
}
|
|
|
|
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.call(this, a, b);
|
|
}
|
|
}
|
|
let rows = [...this.allSiteRows()].sort(sorter.bind(this));
|
|
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);
|
|
}
|
|
|
|
sorter(a, b) {
|
|
let cb = compareBy.bind(this);
|
|
return cb("domain", a, b) || cb("_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 policy = UI.policy;
|
|
let row = this.rowTemplate.cloneNode(true);
|
|
row.sitesCount = sitesCount;
|
|
let url;
|
|
try {
|
|
url = new URL(site);
|
|
if (siteMatch !== site && siteMatch === url.protocol) {
|
|
perms = policy.DEFAULT;
|
|
}
|
|
} catch (e) {
|
|
if (/^(\w+:)\/*$/.test(site)) {
|
|
let hostname = "";
|
|
url = {protocol: RegExp.$1, hostname, origin: site, pathname:""};
|
|
debug("Lonely %o", url);
|
|
} else {
|
|
debug("Domain %s (%s)", site, siteMatch);
|
|
let protocol = Sites.isSecureDomainKey(site) ? "https:" : "http:";
|
|
let hostname = Sites.toggleSecureDomainKey(site, false);
|
|
url = {protocol, hostname, origin: `${protocol}//${site}`, pathname: "/"};
|
|
}
|
|
}
|
|
|
|
let {hostname} = url;
|
|
let overrideDefault = site && url.protocol && site !== url.protocol ?
|
|
policy.get(url.protocol, contextMatch) : null;
|
|
if (overrideDefault && !overrideDefault.siteMatch) overrideDefault = null;
|
|
|
|
let domain = tld.getDomain(hostname);
|
|
let disableDefault = false;
|
|
if (!siteMatch || siteMatch === url.protocol && site !== siteMatch) {
|
|
siteMatch = site;
|
|
}
|
|
let secure = Sites.isSecureDomainKey(siteMatch);
|
|
let isOnion = UI.local.isTorBrowser && hostname && hostname.endsWith(".onion");
|
|
let keyStyle = secure ? "secure"
|
|
: !domain || /^\w+:/.test(siteMatch) ?
|
|
(url.protocol === "https:" || isOnion ? "full" : "unsafe")
|
|
: isOnion ? "secure" : 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;
|
|
if (domain) { // "normal" URL
|
|
let justDomain = hostname === domain;
|
|
let domainEntry = secure || domain === site;
|
|
let unicodeDomain = row.domain = punycode.toUnicode(domain);
|
|
row._label = domainEntry ? `.${unicodeDomain}` : Sites.toExternal(site);
|
|
row.querySelector(".protocol").textContent = `${url.protocol}//`;
|
|
row.querySelector(".sub").textContent = justDomain ?
|
|
(keyStyle === "full" || keyStyle == "unsafe"
|
|
? "" : "…")
|
|
: punycode.toUnicode(hostname.substring(0, hostname.length - domain.length));
|
|
|
|
row.querySelector(".domain").textContent = unicodeDomain;
|
|
row.querySelector(".path").textContent = siteMatch.length > url.origin.length ? url.pathname : "";
|
|
} else {
|
|
urlContainer.querySelector(".full-address").textContent =
|
|
row._label = row.domain = siteMatch;
|
|
}
|
|
let httpsOnly = row.querySelector("input.https-only");
|
|
httpsOnly.checked = keyStyle === "full" || keyStyle === "secure";
|
|
|
|
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 getPresetName = perms => {
|
|
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;
|
|
}
|
|
}
|
|
return presetName;
|
|
}
|
|
|
|
let presetName = getPresetName(perms);
|
|
if (overrideDefault) {
|
|
let overrideName = getPresetName(overrideDefault.perms);
|
|
if (overrideName) {
|
|
let override = row.querySelector(`.presets input[value="${overrideName}"]`);
|
|
if (override) {
|
|
let def = row.querySelector(`.presets input[value="DEFAULT"]`);
|
|
if (def && def !== override) {
|
|
let label = def.nextElementSibling;
|
|
label.title = def.title = `${override.title} (${overrideDefault.siteMatch})`;
|
|
label.textContent = override.nextElementSibling.textContent + "*";
|
|
label.classList.toggle("override", true);
|
|
def.dataset.override = overrideName;
|
|
def.style.backgroundImage = window.getComputedStyle(override, null).backgroundImage;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
preset.disabled = false;
|
|
}
|
|
return row;
|
|
}
|
|
|
|
append(site, siteMatch, perms, contextMatch) {
|
|
this.table.appendChild(this.createSiteRow(...arguments));
|
|
}
|
|
|
|
toggleSecure(row, secure = !!row.querySelector("https-only:checked")) {
|
|
let site = row.siteMatch;
|
|
site = site.replace(/^https?:/, secure ? "https:" : "http:");
|
|
if (site === row.siteMatch) {
|
|
site = Sites.toggleSecureDomainKey(site, secure);
|
|
}
|
|
if (site !== row.siteMatch) {
|
|
this.customize(null);
|
|
let focused = document.activeElement;
|
|
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.remove();
|
|
}
|
|
}
|
|
let newRow = this.createSiteRow(site, site, row.perms, row.contextMatch, row.sitesCount);
|
|
row.parentNode.replaceChild(newRow, row);
|
|
if (focused) {
|
|
let selector = focused.matches(".preset[value]") ?
|
|
`.preset[value="${focused.value}"]` : ".https-only";
|
|
newRow.querySelector(selector).focus();
|
|
}
|
|
}
|
|
}
|
|
|
|
hilite(key) {
|
|
key = Sites.toExternal(key);
|
|
for (let r of this.allSiteRows()) {
|
|
if (key === r.siteMatch) {
|
|
UI.hilite(r);
|
|
r.querySelector("input.preset:checked").focus();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
filterSites(key) {
|
|
key = Sites.toExternal(key);
|
|
for (let r of this.allSiteRows()) {
|
|
if (r.querySelector(".full-address").textContent.trim().includes(key)) {
|
|
r.classList.remove("filtered");
|
|
} else {
|
|
r.classList.add("filtered");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return UI;
|
|
})();
|