diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..350b134 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "webextension/deps/public-suffix-list"] + path = webextension/deps/public-suffix-list + url = https://github.com/wrangr/psl.git diff --git a/webextension/deps/public-suffix-list b/webextension/deps/public-suffix-list new file mode 160000 index 0000000..1fd7810 --- /dev/null +++ b/webextension/deps/public-suffix-list @@ -0,0 +1 @@ +Subproject commit 1fd7810f6ea49b8f7ab029764225c4abaccac031 diff --git a/webextension/deps/wext-options/options.css b/webextension/deps/wext-options/options.css new file mode 100644 index 0000000..7b59e2e --- /dev/null +++ b/webextension/deps/wext-options/options.css @@ -0,0 +1,51 @@ +body { + /* Make font look similar to the add-on page's native style */ + font: message-box; + font-size: 1.25rem; + text-rendering: optimizeLegibility; + text-shadow: 0 1px 1px #fefffe; + + /* Prevent text from being selected as if this was a web page */ + -moz-user-select: none; +} + +/** + * Simple gird layout for option entries (very similar to the JetPack option page) + */ +form > *[data-option] { + display: flex; + flex-direction: row; + align-items: center; + align-content: stretch; + + min-height: 35px; + border-top: 1px solid #c1c1c1; +} +form > *[data-option]:first-child { + border-top: 0; +} +form > *[data-option].hidden { + display: none; +} + +form > *[data-option] > label, +form > *[data-option] > .label { + width: 37%; + + font-size: 1.125em; +} + +form > *[data-option] > .label.description > span { + display: block; + margin: 0 0.5em 0 2em; + + font-size: 90.9%; + color: graytext; +} + +form > *[data-option] > input, +form > *[data-option] > select, +form > *[data-option] > .value { + flex-grow: 1; + margin: 3px 0; +} diff --git a/webextension/deps/wext-options/options.js b/webextension/deps/wext-options/options.js new file mode 100644 index 0000000..a3fea35 --- /dev/null +++ b/webextension/deps/wext-options/options.js @@ -0,0 +1,397 @@ +(function() { + "use strict"; + + + function isDOMNodeFormElement(DOMNode) { + return (DOMNode.tagName === "INPUT" || DOMNode.tagName === "SELECT" || DOMNode.tagName === "TEXTAREA"); + } + + + function makeEventTarget() { + let callbacks = new Set(); + return [ + { + addListener: function(callback) { + callbacks.add(callback); + }, + + removeListener: function(callback) { + callbacks.remove(callback); + }, + + hasListener: function(callback) { + return callbacks.has(callback); + } + }, + + function(event) { + for(let callback of callbacks) { + try { + callback(event); + } catch(error) { + console.exception(error); + } + } + } + ]; + } + + + function makePrivate() { + const items = new WeakMap(); + + return function p(target) { + if(typeof(target) !== "object" || !target) { + return target; + } + + if(!items.has(target)) { + items.set(target, Object.create(p(Object.getPrototypeOf(target) || null))); + } + + return items.get(target); + }; + } + + + const TYPES = { + "checkbox": { + getDefault: function(DOMNode) { + return DOMNode.defaultChecked; + }, + + getValue: function(DOMNode) { + return DOMNode.checked; + }, + + setValue: function(DOMNode, value) { + DOMNode.checked = value; + } + }, + + "color": { + getDefault: function(DOMNode) { + return (DOMNode.defaultValue ? DOMNode.defaultValue : "#000000"); + }, + + getValue: function(DOMNode) { + return DOMNode.value; + }, + + setValue: function(DOMNode, value) { + DOMNode.value = value; + } + }, + + "number": { + getDefault: function(DOMNode) { + let defaultValue = parseFloat(DOMNode.defaultValue); + return (!isNaN(defaultValue) ? defaultValue : 0); + }, + + getValue: function(DOMNode) { + return DOMNode.value; + }, + + setValue: function(DOMNode, value) { + DOMNode.value = value; + } + }, + + "range": { + getDefault: function(DOMNode) { + let defaultValue = parseFloat(DOMNode.defaultValue); + return (!isNaN(defaultValue) ? defaultValue : 0); + }, + + getValue: function(DOMNode) { + return DOMNode.value; + }, + + setValue: function(DOMNode, value) { + DOMNode.value = value; + } + }, + + "radio": { + getSelected: function(DOMNode, defaultSelection) { + for(let DOMRadio of document.getElementsByName(DOMNode.name)) { + if(( defaultSelection && DOMRadio.defaultChecked) + || (!defaultSelection && DOMRadio.checked)) { + return DOMRadio; + } + } + return DOMNode; + }, + + getDefault: function(DOMNode) { + if(DOMNode.name) { + return TYPES["radio"].getSelected(DOMNode, true).value; + } else { + return DOMNode.value; + } + }, + + getValue: function(DOMNode) { + return TYPES["radio"].getSelected(DOMNode).value; + }, + + setValue: function(DOMNode, value) { + for(let DOMRadio of document.getElementsByName(DOMNode.name)) { + if(DOMRadio.value === value) { + DOMRadio.checked = true; + } else { + DOMRadio.checked = false; + } + } + } + }, + + "text": { + getDefault: function(DOMNode) { + return DOMNode.defaultValue; + }, + + getValue: function(DOMNode) { + return DOMNode.value; + }, + + setValue: function(DOMNode, value) { + DOMNode.value = value; + } + }, + + "url": { + getDefault: function(DOMNode) { + return DOMNode.defaultValue; + }, + + getValue: function(DOMNode) { + return DOMNode.value; + }, + + setValue: function(DOMNode, value) { + DOMNode.value = value; + } + }, + + "select": { + getDefault: function(DOMNode) { + let DOMDefaultOption = null; + for(let DOMOption of DOMNode.querySelectorAll("option")) { + if(DOMOption.defaultSelected) { + DOMDefaultOption = DOMOption; + break; + } else if(!DOMDefaultOption) { + DOMDefaultOption = DOMOption; + } + } + return (DOMDefaultOption ? DOMDefaultOption.value : null); + }, + + getValue: function(DOMNode) { + return DOMNode.value; + }, + + setValue: function(DOMNode, value) { + DOMNode.value = value; + } + } + }; + + document.addEventListener("DOMContentLoaded", function() { + // Reloading the add-on while it add-on page is open often causes spurious errors + // because the global `browser` object goes missing + if(typeof(browser) === "undefined") { + window.location.reload(); + return; + } + + const p = makePrivate(); + + let [readyEvent, readyEventTrigger] = makeEventTarget(); + let api = { + options: null, + + onReady: readyEvent + }; + + // Parse the options web page and obtain all the relevant information about the + // available option items + let hooks = OPTION_HOOKS || {}; + let options = {}; + for(let DOMOption of document.querySelectorAll("form > *[data-option]")) { + let name = DOMOption.getAttribute("data-option"); + + // Find option value form element + let DOMOptionTargetContainer = DOMOption; + let DOMOptionTarget = null; + if(DOMOption.hasAttribute("data-option-id")) { + DOMOptionTarget = document.getElementById(DOMOption.getAttribute("data-option-id")); + } else { + while(true) { + // Search for option storage object + for(let DOMNode of DOMOptionTargetContainer.children) { + if(DOMNode.classList.contains("value")) { + // DOM objects with a "value" class are usually containers for + // another object + DOMOptionTarget = DOMNode; + break; + } else if(isDOMNodeFormElement(DOMNode)) { + if(!DOMOptionTarget) { + DOMOptionTarget = DOMNode; + } + } + } + if(!DOMOptionTarget) { + break; + } + + // Assume that option target was actually a container of another form element + if(!isDOMNodeFormElement(DOMOptionTarget)) { + DOMOptionTargetContainer = DOMOptionTarget; + DOMOptionTarget = null; + } else { + break; + } + } + } + if(!DOMOptionTarget) { + console.error(`Option "${name}" does not contain a value storage element`); + continue; + } + + // Determine type of option + let type = DOMOption.getAttribute("data-option-type"); + if(!type) { + if(DOMOptionTarget.tagName === "SELECT") { + type = "select"; + } else if(DOMOptionTarget.tagName === "TEXTAREA") { + type = "text"; + } else if(DOMOptionTarget.hasAttribute("type")) { + type = DOMOptionTarget.getAttribute("type"); + } else { + type = "text"; + } + } + if(!(type in TYPES)) { + console.error(`Option "${name}" has invalid/unknown type: ${type}`); + continue; + } + + // Create event objects that we are going to use + let [userChangeEvent, userChangeEventTrigger] = makeEventTarget(); + let [storageChangeEvent, storageChangeEventTrigger] = makeEventTarget(); + + let option = { + name: name, + target: DOMOptionTarget, + type: type, + + onUserChange: userChangeEvent, + onStorageChange: storageChangeEvent, + + triggerUserChange: (origEvent) => { + let event = new Event("user-change"); + let value = TYPES[type].getValue(DOMOptionTarget); + Object.defineProperties(event, { + "target": { enumerable: true, value: options[name] }, + "value": { enumerable: true, value: value }, + "original": { enumerable: true, value: origEvent } + }); + + userChangeEventTrigger(event); + }, + + get defaultValue() { + if(DOMOption.hasAttribute("data-option-default")) { + return DOMOption.getAttribute("data-option-default"); + } else { + return TYPES[type].getDefault(DOMOptionTarget); + } + }, + + get value() { + return TYPES[type].getValue(DOMOptionTarget); + }, + set value(value) { + return TYPES[type].setValue(DOMOptionTarget, value); + } + }; + p(option).onUserChangeTrigger = userChangeEventTrigger; + p(option).onStorageChangeTrigger = storageChangeEventTrigger; + + // Forward changes on the target object to our event system + DOMOptionTarget.addEventListener("change", option.triggerUserChange, false); + + // Run hook for this option + if(hooks.hasOwnProperty(name)) { + try { + hooks[name](option, api); + } catch(error) { + console.exception(error); + } + } + + options[name] = option; + } + api["options"] = options; + + + browser.storage.local.get().then((items) => { + let expectedStorageChanges = new Set(); + for(let name of Object.keys(options)) { + if(items.hasOwnProperty(name)) { + options[name].value = items[name]; + } else { + options[name].value = options[name].defaultValue; + } + + options[name].onUserChange.addListener(function(event) { + let mapping = {}; + mapping[event.target.name] = event.value; + + expectedStorageChanges.add(event.target.name); + browser.storage.local.set(mapping).catch(console.exception); + }); + } + + browser.storage.onChanged.addListener(function(changes, areaName) { + if(areaName !== "local") { + return; + } + + for(let name of Object.keys(changes)) { + if(expectedStorageChanges.has(name)) { + expectedStorageChanges.delete(name); + continue; + } + + // Dispatch event about this change in the browser's storage + let event = new Event("storage-change", {"cancelable": true}); + Object.defineProperties(event, { + "target": { enumerable: true, value: options[name] }, + "value": { enumerable: true, value: changes[name].newValue } + }); + p(options[name]).onStorageChangeTrigger(event); + + // Allow event client to stop us from updating the user-visible value display + // because of this event + if(!event.defaultPrevented) { + if(typeof(changes[name].newValue) !== "undefined") { + options[name].value = changes[name].newValue; + } else { + options[name].value = options[name].defaultValue; + } + } + } + }); + + let event = new Event("ready"); + Object.defineProperties(event, { + "target": { enumerable: true, value: api } + }); + readyEventTrigger(event); + }).catch(console.exception); + }); +})(); diff --git a/webextension/icon-light.svg b/webextension/icon-light.svg new file mode 100644 index 0000000..26754eb --- /dev/null +++ b/webextension/icon-light.svg @@ -0,0 +1,141 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webextension/icon.js b/webextension/icon.js new file mode 100644 index 0000000..40cba6c --- /dev/null +++ b/webextension/icon.js @@ -0,0 +1,19 @@ +// Monitor settings for changes to the request processing setting +browser.storage.onChanged.addListener((changes, areaName) => { + for(let name of Object.keys(changes)) { + if(areaName === "local" && name === "enable") { + console.log(name, changes[name]); + if(changes[name].newValue === true) { + browser.browserAction.setIcon({ path: { 256: "icon.svg" } }); + } else { + browser.browserAction.setIcon({ path: { 256: "icon-light.svg" } }); + } + } + } +}); + +browser.browserAction.onClicked.addListener((tab) => { + browser.storage.local.get("enable").then((result) => { + return browser.storage.local.set({ enable: !result.enable }); + }).catch(console.exception); +}); diff --git a/webextension/icon.svg b/webextension/icon.svg new file mode 100644 index 0000000..d00f491 --- /dev/null +++ b/webextension/icon.svg @@ -0,0 +1,141 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webextension/main.js b/webextension/main.js new file mode 100644 index 0000000..672c28e --- /dev/null +++ b/webextension/main.js @@ -0,0 +1,217 @@ +/** + * Determine whether the given hostname is actually an IP address + * + * This will detect both IPv4 and IPv6 addresses and will return `true` + * or `false` accordingly. + */ +function isHostnameIPAddress(hostname) { + // Strip delimiting brackets that might have been added to seperate the hostname from the port + if(hostname.startsWith("[") && hostname.endsWith("]")) { + hostname = hostname.substr(1, hostname.length-1); + } + + return (IPv6_PATTERN.test(hostname) || IPv4_PATTERN.test(hostname)); +} +// From https://jsfiddle.net/usmanajmal/AJEzQ/108/ +const IPv6_PATTERN = /^((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?$/; +// From http://stackoverflow.com/a/9221063/277882 +const IPv4_PATTERN = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + + +/** + * Polyfill for `Promise.finally` + */ +Promise.prototype['finally'] = function (f) { + return this.then(function (value) { + return Promise.resolve(f()).then(function () { + return value; + }); + }, function (err) { + return Promise.resolve(f()).then(function () { + throw err; + }); + }); +} + + +/** + * Default values for all options + * + * Defined here so that they are instantly available until the actual value can be loaded from + * storage. + */ +const OPTIONS_DEFAULT = { + "enable": true, + "strict": false, + "mode": "self", + "referer": "", + + // whitelisting + "allow": "", + "whitelist": "http://meh.schizofreni.co/smart-referer/whitelist.txt" +}; + + +/** + * Track current add-on options, so that their are always available when resolving a request + */ +// Start with the default options +let options = Object.assign({}, OPTIONS_DEFAULT); +let policy = new Policy(options.allow); + +document.addEventListener("DOMContentLoaded", function() { + // Now load all currently set options from storage + browser.storage.local.get().then((result) => { + // Update the default options with the real ones loaded from storage + Object.assign(options, result); + + // Write back the final option list so that the defaults are properly displayed on the + // options page as well + return browser.storage.local.set(options); + }).then(() => { + // Keep track of new developments in option land + browser.storage.onChanged.addListener((changes, areaName) => { + if(areaName !== "local") { + return; + } + + // Apply change + for(let name of Object.keys(changes)) { + options[name] = changes[name].newValue; + } + }); + + // Done setting up options + }).then(() => { + // Do initial policy fetch (will cause timer for more updates to be set) + updatePolicy(); + + // Also update policy when its settings are changed + browser.storage.onChanged.addListener((changes, areaName) => { + for(let name of Object.keys(changes)) { + if(areaName === "local" && (name === "allow" || name === "whitelist")) { + updatePolicy(); + } + } + }); + }).catch(console.exception); +}); + + +/** + * Download an updated version of the online whitelist + */ +let policyUpdateHandle = 0; +function updatePolicy() { + if(!options.whitelist) { + policy = new Policy(options.allow); + return; + } + + // Stop any previous policy update timer + if(policyUpdateHandle > 0) { + clearTimeout(policyUpdateHandle); + policyUpdateHandle = -1; + } else if(policyUpdateHandle < 0) { + // Another policy update is already in progress + return; + } + + fetch(options.whitelist).then((response) => response.text()).then((responseText) => { + policy = new Policy(`${options.allow} ${responseText}`); + }).finally(() => { + // Schedule another policy update + policyUpdateHandle = setTimeout(updatePolicy, 86400000); + }).catch(console.exception); +} + + +/** + * Callback function that will process an about-to-be-sent blocking request and modify + * its "Referer"-header accoriding to the current options + */ +function requestListener(request) { + // Find current referer header in request + let referer = null; + for(let header of request.requestHeaders) { + if(header.name === "Referer" && header.value) { + referer = header; + break; + } + } + if(!referer) { + return; + } + + try { + let toURI = new URL(request.url); + let fromURI = new URL(referer.value); + + // Check if this request can be dismissed early by either being a perfect + // source host / target host match OR by being whitelisted somewhere + if(fromURI.host === toURI.host || policy.allows(fromURI.host, toURI.host)) { + return; + } + + // Attempt lax matching only if we, in fact, have hostnames and not IP addresses + let isIPAddress = !isHostnameIPAddress(fromURI.host) && !isHostnameIPAddress(toURI.host); + if(isIPAddress && !options.strict) { + // Parse the domain names and look for each of their base domains + let fromHostBase = psl.get(fromURI.host); + let toHostBase = psl.get(toURI.host); + + // Allow if the both base domain names match + if(fromHostBase && toHostBase && fromHostBase === toHostBase) { + return; + } + } + } catch(e) { + console.exception(e); + } + + console.log(`Rejecting referer "${referer.value}" for "${request.url}"`); + + switch(options.mode) { + case "direct": + referer.value = null; + break; + + case "self": + referer.value = request.url; + break; + + default: + referer.value = options.referer; + } + + return {requestHeaders: request.requestHeaders}; +} + +let requestListenerEnabled = false; +function setRequestListenerStatus(enable) { + if(!requestListenerEnabled && enable) { + console.log("Enabling Smart Referer"); + requestListenerEnabled = true; + browser.webRequest.onBeforeSendHeaders.addListener( + requestListener, + {urls: [""]}, + ["blocking", "requestHeaders"] + ); + } else if(requestListenerEnabled && !enable) { + console.log("Disabling Smart Referer"); + requestListenerEnabled = false; + browser.webRequest.onBeforeSendHeaders.removeListener(requestListener); + } +} + +// Enable request processing by default +setRequestListenerStatus(true); + +// Monitor settings for changes to the request processing setting +browser.storage.onChanged.addListener((changes, areaName) => { + for(let name of Object.keys(changes)) { + if(areaName === "local" && name === "enable") { + setRequestListenerStatus(changes[name].newValue); + } + } +}); diff --git a/webextension/manifest.json b/webextension/manifest.json new file mode 100644 index 0000000..b22bc29 --- /dev/null +++ b/webextension/manifest.json @@ -0,0 +1,38 @@ +{ + "manifest_version": 2, + "name": "Smart Referer", + "version": "0.2", + + "description": "Enable smart referers everywhere (send referer only on same domain)", + "homepage_url": "https://github.com/meh/smart-referer", + "icons": { + "256": "icon.svg" + }, + + "permissions": [ + "*://meh.schizofreni.co/smart-referer/whitelist.txt", + + "storage", + "webRequest", + "webRequestBlocking" + ], + + "options_ui": { + "page": "options.html" + }, + + "browser_action": { + "default_icon": "icon.svg", + "default_title": "Smart Referer" + }, + + "background": { + "scripts": [ + "deps/public-suffix-list/dist/psl.js", + + "icon.js", + "policy.js", + "main.js" + ] + } +} diff --git a/webextension/options.html b/webextension/options.html new file mode 100644 index 0000000..59d342c --- /dev/null +++ b/webextension/options.html @@ -0,0 +1,55 @@ + + + + + + + + + + + +
+
+ +
+ +
+
+ +
+
+ + Treat subdomains as different domains +
+
+ +
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ + +
+ + diff --git a/webextension/options.js b/webextension/options.js new file mode 100644 index 0000000..bed5333 --- /dev/null +++ b/webextension/options.js @@ -0,0 +1,18 @@ +// Some helper code for integrating more nicely with `wext-options` +const OPTION_HOOKS = { + "mode": function(option, api) { + function onChange(event) { + if(event.value === "user") { + document.getElementById("option_referer").disabled = false; + } else { + document.getElementById("option_referer").disabled = true; + } + } + option.onUserChange.addListener(onChange); + option.onStorageChange.addListener(onChange); + + api.onReady.addListener((event) => { + onChange({ value: option.value }); + }); + } +}; diff --git a/webextension/policy.js b/webextension/policy.js new file mode 100644 index 0000000..41f9d9a --- /dev/null +++ b/webextension/policy.js @@ -0,0 +1,52 @@ +const Policy = (function () { + function wildcard (string) { + // Escape special characters that are special in regular expressions + // Do not escape "[" and "]" so that simple character classes are still possible + string = string.replace(/(\\|\^|\$|\{|\}|\(|\)|\+|\||\<|\>|\&|\.)/g, "\\$1"); + // Substitute remaining special characters for their wildcard meanings + string = string.replace(/\*/g, ".*?").replace(/\?/g, "."); + // Compile regular expression object + return new RegExp("^" + string + "$"); + } + + var c = function (string) { + var list = [] + + string.split(/\n/).filter(function (s) s).forEach(function (part) { + part.replace(/#.*$/, '').split(/\s+/).filter(function (s) s).forEach(function (part) { + try { + if (part.indexOf(">") == -1) { + list.push({ + from: wildcard("*"), + to: wildcard(part) + }); + } + else { + var [from, to] = part.split(">"); + + list.push({ + from: wildcard(from), + to: wildcard(to) + }); + } + } catch (e) {} + }); + }); + + this.list = list; + }; + + c.prototype.allows = function (from, to) { + for (var i = 0; i < this.list.length; i++) { + var matchers = this.list[i]; + + if (matchers.from.test(from) && matchers.to.test(to)) { + return true; + } + } + + return false; + }; + + return c; +})();