aboutsummaryrefslogtreecommitdiff
path: root/common
diff options
context:
space:
mode:
authorjahoti <jahoti@tilde.team>2021-12-03 00:00:00 +0000
committerjahoti <jahoti@tilde.team>2021-12-03 00:00:00 +0000
commitd16e763e240a2aefe3d4490cddff61893a35a1ea (patch)
tree1e90890a39798f6cd9a1c0886d1234ccc187f5b3 /common
parent591c48a6903bbf324361610f81c628302cae7049 (diff)
parent93dd73600e91eb19e11f5ca57f9429a85cf0150f (diff)
downloadbrowser-extension-d16e763e240a2aefe3d4490cddff61893a35a1ea.tar.gz
browser-extension-d16e763e240a2aefe3d4490cddff61893a35a1ea.zip
Merge branch 'koszko' into jahoti
Diffstat (limited to 'common')
-rw-r--r--common/ajax.js5
-rw-r--r--common/connection_types.js4
-rw-r--r--common/lock.js4
-rw-r--r--common/message_server.js4
-rw-r--r--common/misc.js159
-rw-r--r--common/observable.js33
-rw-r--r--common/once.js5
-rw-r--r--common/patterns.js260
-rw-r--r--common/sanitize_JSON.js5
-rw-r--r--common/settings_query.js31
-rw-r--r--common/storage_client.js4
-rw-r--r--common/storage_light.js131
-rw-r--r--common/storage_raw.js55
-rw-r--r--common/stored_types.js4
14 files changed, 376 insertions, 328 deletions
diff --git a/common/ajax.js b/common/ajax.js
index 8082bbe..7269a8a 100644
--- a/common/ajax.js
+++ b/common/ajax.js
@@ -1,6 +1,7 @@
/**
- * part of Hachette
- * Wrapping XMLHttpRequest into a Promise.
+ * This file is part of Haketilo.
+ *
+ * Function: Wrapping XMLHttpRequest into a Promise.
*
* Copyright (C) 2021 Wojtek Kosior
* Redistribution terms are gathered in the `copyright' file.
diff --git a/common/connection_types.js b/common/connection_types.js
index 88c6964..3e9df56 100644
--- a/common/connection_types.js
+++ b/common/connection_types.js
@@ -1,5 +1,7 @@
/**
- * Hachette background scripts message connection types "enum"
+ * This file is part of Haketilo.
+ *
+ * Function: Define an "enum" of message connection types.
*
* Copyright (C) 2021 Wojtek Kosior
* Redistribution terms are gathered in the `copyright' file.
diff --git a/common/lock.js b/common/lock.js
index 822ad1b..6cf0835 100644
--- a/common/lock.js
+++ b/common/lock.js
@@ -1,5 +1,7 @@
/**
- * Hachette lock (aka binary semaphore aka mutex)
+ * This file is part of Haketilo.
+ *
+ * Function: Implement a lock (aka binary semaphore aka mutex).
*
* Copyright (C) 2021 Wojtek Kosior
* Redistribution terms are gathered in the `copyright' file.
diff --git a/common/message_server.js b/common/message_server.js
index ea40487..c8c6696 100644
--- a/common/message_server.js
+++ b/common/message_server.js
@@ -1,5 +1,7 @@
/**
- * Hachette message server
+ * This file is part of Haketilo.
+ *
+ * Function: Message server.
*
* Copyright (C) 2021 Wojtek Kosior
* Redistribution terms are gathered in the `copyright' file.
diff --git a/common/misc.js b/common/misc.js
index 3c7dc46..5b0addb 100644
--- a/common/misc.js
+++ b/common/misc.js
@@ -1,5 +1,7 @@
/**
- * Hachette miscellaneous operations refactored to a separate file
+ * This file is part of Haketilo.
+ *
+ * Function: Miscellaneous operations refactored to a separate file.
*
* Copyright (C) 2021 Wojtek Kosior
* Copyright (C) 2021 jahoti
@@ -8,9 +10,7 @@
/*
* IMPORTS_START
- * IMPORT sha256
* IMPORT browser
- * IMPORT is_chrome
* IMPORT TYPE_NAME
* IMPORT TYPE_PREFIX
* IMPORTS_END
@@ -38,94 +38,27 @@ function Uint8toHex(data)
return returnValue;
}
-function gen_nonce(length) // Default 16
+function gen_nonce(length=16)
{
- let randomData = new Uint8Array(length || 16);
+ let randomData = new Uint8Array(length);
crypto.getRandomValues(randomData);
return Uint8toHex(randomData);
}
-function gen_unique(url)
-{
- return sha256(get_secure_salt() + url);
-}
-
-function get_secure_salt()
-{
- if (is_chrome)
- return browser.runtime.getManifest().key.substring(0, 50);
- else
- return browser.runtime.getURL("dummy");
-}
-
-/*
- * stripping url from query and target (everything after `#' or `?'
- * gets removed)
- */
-function url_item(url)
-{
- let url_re = /^([^?#]*).*$/;
- let match = url_re.exec(url);
- return match[1];
-}
-
-/*
- * Assume a url like:
- * https://example.com/green?illuminati=confirmed#<injected-policy>#winky
- * This function will make it into an object like:
- * {
- * "base_url": "https://example.com/green?illuminati=confirmed",
- * "target": "#<injected-policy>",
- * "target2": "#winky",
- * "policy": <injected-policy-as-js-object>,
- * "current": <boolean-indicating-whether-policy-url-matches>
- * }
- * In case url doesn't have 2 #'s, target2 and target can be set to undefined.
- */
-function url_extract_target(url)
-{
- const url_re = /^([^#]*)((#[^#]*)(#.*)?)?$/;
- const match = url_re.exec(url);
- const targets = {
- base_url: match[1],
- target: match[3] || "",
- target2: match[4] || ""
- };
- if (!targets.target)
- return targets;
-
- /* %7B -> { */
- const index = targets.target.indexOf('%7B');
- if (index === -1)
- return targets;
-
- const now = new Date();
- const sig = targets.target.substring(1, index);
- const policy = targets.target.substring(index);
- if (sig !== sign_policy(policy, now) &&
- sig !== sign_policy(policy, now, -1))
- return targets;
-
- try {
- targets.policy = JSON.parse(decodeURIComponent(policy));
- targets.current = targets.policy.base_url === targets.base_url;
- } catch (e) {
- /* This should not be reached - it's our self-produced valid JSON. */
- console.log("Unexpected internal error - invalid JSON smuggled!", e);
- }
-
- return targets;
-}
-
-/* csp rule that blocks all scripts except for those injected by us */
-function csp_rule(nonce)
+/* CSP rule that blocks scripts according to policy's needs. */
+function make_csp_rule(policy)
{
- let rule = `script-src 'nonce-${nonce}';`;
- if (is_chrome)
- rule += `script-src-elem 'nonce-${nonce}';`;
+ let rule = "prefetch-src 'none'; script-src-attr 'none';";
+ const script_src = policy.nonce !== undefined ?
+ `'nonce-${policy.nonce}'` : "'none'";
+ rule += ` script-src ${script_src}; script-src-elem ${script_src};`;
return rule;
}
+/* Check if some HTTP header might define CSP rules. */
+const csp_header_regex =
+ /^\s*(content-security-policy|x-webkit-csp|x-content-security-policy)/i;
+
/*
* Print item together with type, e.g.
* nice_name("s", "hello") → "hello (script)"
@@ -143,17 +76,13 @@ function open_in_settings(prefix, name)
window.open(url, "_blank");
}
-/* Check if url corresponds to a browser's special page */
-function is_privileged_url(url)
-{
- return !!/^(chrome(-extension)?|moz-extension):\/\/|^about:/i.exec(url);
-}
-
-/* Sign a given policy for a given time */
-function sign_policy(policy, now, hours_offset) {
- let time = Math.floor(now / 3600000) + (hours_offset || 0);
- return gen_unique(time + policy);
-}
+/*
+ * Check if url corresponds to a browser's special page (or a directory index in
+ * case of `file://' protocol).
+ */
+const privileged_reg =
+ /^(chrome(-extension)?|moz-extension):\/\/|^about:|^file:\/\/.*\/$/;
+const is_privileged_url = url => privileged_reg.test(url);
/* Parse a CSP header */
function parse_csp(csp) {
@@ -174,41 +103,7 @@ function parse_csp(csp) {
return directives;
}
-/* Make CSP headers do our bidding, not interfere */
-function sanitize_csp_header(header, rule, block)
-{
- const csp = parse_csp(header.value);
-
- if (block) {
- /* No snitching */
- delete csp['report-to'];
- delete csp['report-uri'];
-
- delete csp['script-src'];
- delete csp['script-src-elem'];
-
- csp['script-src-attr'] = ["'none'"];
- csp['prefetch-src'] = ["'none'"];
- }
-
- if ('script-src' in csp)
- csp['script-src'].push(rule);
- else
- csp['script-src'] = [rule];
-
- if ('script-src-elem' in csp)
- csp['script-src-elem'].push(rule);
- else
- csp['script-src-elem'] = [rule];
-
- const new_policy = Object.entries(csp).map(
- i => `${i[0]} ${i[1].join(' ')};`
- );
-
- return {name: header.name, value: new_policy.join('')};
-}
-
-/* Regexes and objest to use as/in schemas for parse_json_with_schema(). */
+/* Regexes and objects to use as/in schemas for parse_json_with_schema(). */
const nonempty_string_matcher = /.+/;
const matchers = {
@@ -223,15 +118,11 @@ const matchers = {
/*
* EXPORTS_START
* EXPORT gen_nonce
- * EXPORT gen_unique
- * EXPORT url_item
- * EXPORT url_extract_target
- * EXPORT sign_policy
- * EXPORT csp_rule
+ * EXPORT make_csp_rule
+ * EXPORT csp_header_regex
* EXPORT nice_name
* EXPORT open_in_settings
* EXPORT is_privileged_url
- * EXPORT sanitize_csp_header
* EXPORT matchers
* EXPORTS_END
*/
diff --git a/common/observable.js b/common/observable.js
index 1fb0b0a..ab3b444 100644
--- a/common/observable.js
+++ b/common/observable.js
@@ -1,33 +1,28 @@
/**
- * part of Hachette
- * Facilitate listening to events
+ * This file is part of Haketilo.
+ *
+ * Function: Facilitate listening to (internal, self-generated) events.
*
* Copyright (C) 2021 Wojtek Kosior
* Redistribution terms are gathered in the `copyright' file.
*/
-function make()
-{
- return new Set();
-}
-
-function subscribe(observable, cb)
-{
- observable.add(cb);
-}
+const make = (value=undefined) => ({value, listeners: new Set()});
+const subscribe = (observable, cb) => observable.listeners.add(cb);
+const unsubscribe = (observable, cb) => observable.listeners.delete(cb);
-function unsubscribe(observable, cb)
-{
- observable.delete(cb);
-}
+const silent_set = (observable, value) => observable.value = value;
+const broadcast = (observable, ...values) =>
+ observable.listeners.forEach(cb => cb(...values));
-function broadcast(observable, event)
+function set(observable, value)
{
- for (const callback of observable)
- callback(event);
+ const old_value = observable.value;
+ silent_set(observable, value);
+ broadcast(observable, value, old_value);
}
-const observables = {make, subscribe, unsubscribe, broadcast};
+const observables = {make, subscribe, unsubscribe, broadcast, silent_set, set};
/*
* EXPORTS_START
diff --git a/common/once.js b/common/once.js
index 098b43f..93e842f 100644
--- a/common/once.js
+++ b/common/once.js
@@ -1,5 +1,8 @@
/**
- * Hachette feature initialization promise
+ * This file is part of Haketilo.
+ *
+ * Function: Wrap APIs that depend on some asynchronous initialization into
+ * promises.
*
* Copyright (C) 2021 Wojtek Kosior
* Redistribution terms are gathered in the `copyright' file.
diff --git a/common/patterns.js b/common/patterns.js
index be7c650..635b128 100644
--- a/common/patterns.js
+++ b/common/patterns.js
@@ -1,187 +1,151 @@
/**
- * Hachette operations on page url patterns
+ * This file is part of Haketilo.
+ *
+ * Function: Operations on page URL patterns.
*
* Copyright (C) 2021 Wojtek Kosior
* Redistribution terms are gathered in the `copyright' file.
*/
-const proto_re = "[a-zA-Z]*:\/\/";
-const domain_re = "[^/?#]+";
-const segments_re = "/[^?#]*";
-const query_re = "\\?[^#]*";
-
-const url_regex = new RegExp(`\
-^\
-(${proto_re})\
-(${domain_re})\
-(${segments_re})?\
-(${query_re})?\
-#?.*\$\
-`);
-
-function deconstruct_url(url)
-{
- const regex_match = url_regex.exec(url);
- if (regex_match === null)
- return undefined;
+const MAX = {
+ URL_PATH_LEN: 12,
+ URL_PATH_CHARS: 255,
+ DOMAIN_LEN: 7,
+ DOMAIN_CHARS: 100
+};
- let [_, proto, domain, path, query] = regex_match;
+const proto_regex = /^(\w+):\/\/(.*)$/;
- domain = domain.split(".");
- let path_trailing_dash =
- path && path[path.length - 1] === "/";
- path = (path || "").split("/").filter(s => s !== "");
- path.unshift("");
+const user_re = "[^/?#@]+@"
+const domain_re = "[.a-zA-Z0-9-]+";
+const path_re = "[^?#]*";
+const query_re = "\\??[^#]*";
- return {proto, domain, path, query, path_trailing_dash};
-}
+const http_regex = new RegExp(`^(${domain_re})(${path_re})(${query_re}).*`);
+
+const file_regex = new RegExp(`^(${path_re}).*`);
-/* Be sane: both arguments should be arrays of length >= 2 */
-function domain_matches(url_domain, pattern_domain)
+const ftp_regex = new RegExp(`^(${user_re})?(${domain_re})(${path_re}).*`);
+
+function deconstruct_url(url, use_limits=true)
{
- const length_difference = url_domain.length - pattern_domain.length;
-
- for (let i = 1; i <= url_domain.length; i++) {
- const url_part = url_domain[url_domain.length - i];
- const pattern_part = pattern_domain[pattern_domain.length - i];
-
- if (pattern_domain.length === i) {
- if (pattern_part === "*")
- return length_difference === 0;
- if (pattern_part === "**")
- return length_difference > 0;
- if (pattern_part === "***")
- return true;
- return length_difference === 0 && pattern_part === url_part;
- }
+ const max = MAX;
+ if (!use_limits) {
+ for (key in MAX)
+ max[key] = Infinity;
+ }
- if (pattern_part !== url_part)
- return false;
+ const proto_match = proto_regex.exec(url);
+ if (proto_match === null)
+ throw `bad url '${url}'`;
+
+ const deco = {proto: proto_match[1]};
+
+ if (deco.proto === "file") {
+ deco.path = file_regex.exec(proto_match[2])[1];
+ } else if (deco.proto === "ftp") {
+ [deco.domain, deco.path] = ftp_regex.exec(proto_match[2]).slice(2, 4);
+ } else if (deco.proto === "http" || deco.proto === "https") {
+ const http_match = http_regex.exec(proto_match[2]);
+ if (!http_match)
+ return undefined;
+ [deco.domain, deco.path, deco.query] = http_match.slice(1, 4);
+ deco.domain = deco.domain.toLowerCase();
+ } else {
+ throw `unsupported protocol in url '${url}'`;
}
- return pattern_domain.length === url_domain.length + 1 &&
- pattern_domain[0] === "***";
-}
+ deco.trailing_dash = deco.path[deco.path.length - 1] === "/";
-function path_matches(url_path, url_trailing_dash,
- pattern_path, pattern_trailing_dash)
-{
- const dashes_ok = !(pattern_trailing_dash && !url_trailing_dash);
-
- if (pattern_path.length === 0)
- return url_path.length === 0 && dashes_ok;
-
- const length_difference = url_path.length - pattern_path.length;
-
- for (let i = 0; i < url_path.length; i++) {
- if (pattern_path.length === i + 1) {
- if (pattern_path[i] === "*")
- return length_difference === 0;
- if (pattern_path[i] === "**") {
- return length_difference > 0 ||
- (url_path[i] === "**" && dashes_ok);
- }
- if (pattern_path[i] === "***")
- return length_difference >= 0;
- return length_difference === 0 &&
- pattern_path[i] === url_path[i] && dashes_ok;
+ if (deco.domain) {
+ if (deco.domain.length > max.DOMAIN_CHARS) {
+ const idx = deco.domain.indexOf(".", deco.domain.length -
+ max.DOMAIN_CHARS);
+ if (idx === -1)
+ deco.domain = [];
+ else
+ deco.domain = deco.domain.substring(idx + 1);
+
+ deco.domain_truncated = true;
}
- if (pattern_path[i] !== url_path[i])
- return false;
+ if (deco.path.length > max.URL_PATH_CHARS) {
+ deco.path = deco.path.substring(0, deco.path.lastIndexOf("/"));
+ deco.path_truncated = true;
+ }
}
- return false;
-}
-
-function url_matches(url, pattern)
-{
- const url_deco = deconstruct_url(url);
- const pattern_deco = deconstruct_url(pattern);
-
- if (url_deco === undefined || pattern_deco === undefined) {
- console.log(`bad comparison: ${url} and ${pattern}`);
- return false
+ if (typeof deco.domain === "string") {
+ deco.domain = deco.domain.split(".");
+ if (deco.domain.splice(0, deco.domain.length - max.DOMAIN_LEN).length
+ > 0)
+ deco.domain_truncated = true;
}
- if (pattern_deco.proto !== url_deco.proto)
- return false;
+ deco.path = deco.path.split("/").filter(s => s !== "");
+ if (deco.domain && deco.path.splice(max.URL_PATH_LEN).length > 0)
+ deco.path_truncated = true;
- return domain_matches(url_deco.domain, pattern_deco.domain) &&
- path_matches(url_deco.path, url_deco.path_trailing_dash,
- pattern_deco.path, pattern_deco.path_trailing_dash);
+ return deco;
}
-/*
- * Call callback for every possible pattern that matches url. Return when there
- * are no more patterns or callback returns false.
- */
-function for_each_possible_pattern(url, callback)
+function* each_domain_pattern(deco)
{
- const deco = deconstruct_url(url);
-
- if (deco === undefined) {
- console.log("bad url format", url);
- return;
+ for (let slice = 0; slice < deco.domain.length - 1; slice++) {
+ const domain_part = deco.domain.slice(slice).join(".");
+ const domain_wildcards = [];
+ if (slice === 0 && !deco.domain_truncated)
+ yield domain_part;
+ if (slice === 1 && !deco.domain_truncated)
+ yield "*." + domain_part;
+ if (slice > 1)
+ yield "**." + domain_part;
+ yield "***." + domain_part;
}
+}
- for (let d_slice = 0; d_slice < deco.domain.length; d_slice++) {
- const domain_part = deco.domain.slice(d_slice).join(".");
- const domain_wildcards = [];
- if (d_slice === 0)
- domain_wildcards.push("");
- if (d_slice === 1)
- domain_wildcards.push("*.");
- if (d_slice > 0)
- domain_wildcards.push("**.");
- domain_wildcards.push("***.");
-
- for (const domain_wildcard of domain_wildcards) {
- const domain_pattern = domain_wildcard + domain_part;
-
- for (let s_slice = deco.path.length; s_slice > 0; s_slice--) {
- const path_part = deco.path.slice(0, s_slice).join("/");
- const path_wildcards = [];
- if (s_slice === deco.path.length) {
- if (deco.path_trailing_dash)
- path_wildcards.push("/");
- path_wildcards.push("");
- }
- if (s_slice === deco.path.length - 1 &&
- deco.path[s_slice] !== "*")
- path_wildcards.push("/*");
- if (s_slice < deco.path.length &&
- (deco.path[s_slice] !== "**" ||
- s_slice < deco.path.length - 1))
- path_wildcards.push("/**");
- if (deco.path[s_slice] !== "***" || s_slice < deco.path.length)
- path_wildcards.push("/***");
-
- for (const path_wildcard of path_wildcards) {
- const path_pattern = path_part + path_wildcard;
-
- const pattern = deco.proto + domain_pattern + path_pattern;
-
- if (callback(pattern) === false)
- return;
- }
- }
+function* each_path_pattern(deco)
+{
+ for (let slice = deco.path.length; slice >= 0; slice--) {
+ const path_part = ["", ...deco.path.slice(0, slice)].join("/");
+ const path_wildcards = [];
+ if (slice === deco.path.length && !deco.path_truncated) {
+ if (deco.trailing_dash)
+ yield path_part + "/";
+ if (slice > 0 || deco.proto !== "file")
+ yield path_part;
}
+ if (slice === deco.path.length - 1 && !deco.path_truncated &&
+ deco.path[slice] !== "*")
+ yield path_part + "/*";
+ if (slice < deco.path.length - 1)
+ yield path_part + "/**";
+ if (slice !== deco.path.length - 1 || deco.path_truncated ||
+ deco.path[slice] !== "***")
+ yield path_part + "/***";
}
}
-function possible_patterns(url)
+/* Generate every possible pattern that matches url. */
+function* each_url_pattern(url)
{
- const patterns = [];
- for_each_possible_pattern(url, patterns.push);
+ const deco = deconstruct_url(url);
- return patterns;
+ if (deco === undefined) {
+ console.error("bad url format", url);
+ return false;
+ }
+
+ const all_domains = deco.domain ? each_domain_pattern(deco) : [""];
+ for (const domain of all_domains) {
+ for (const path of each_path_pattern(deco))
+ yield `${deco.proto}://${domain}${path}`;
+ }
}
/*
* EXPORTS_START
- * EXPORT url_matches
- * EXPORT for_each_possible_pattern
- * EXPORT possible_patterns
+ * EXPORT each_url_pattern
+ * EXPORT deconstruct_url
* EXPORTS_END
*/
diff --git a/common/sanitize_JSON.js b/common/sanitize_JSON.js
index 8b86d2d..4cf1ef4 100644
--- a/common/sanitize_JSON.js
+++ b/common/sanitize_JSON.js
@@ -1,6 +1,7 @@
/**
- * part of Hachette
- * Powerful, full-blown format enforcer for externally-obtained JSON
+ * This file is part of Haketilo.
+ *
+ * Function: Powerful, full-blown format enforcer for externally-obtained JSON.
*
* Copyright (C) 2021 Wojtek Kosior
* Redistribution terms are gathered in the `copyright' file.
diff --git a/common/settings_query.js b/common/settings_query.js
index e85ae63..7e1315e 100644
--- a/common/settings_query.js
+++ b/common/settings_query.js
@@ -1,5 +1,7 @@
/**
- * Hachette querying page settings with regard to wildcard records
+ * This file is part of Haketilo.
+ *
+ * Function: Querying page settings.
*
* Copyright (C) 2021 Wojtek Kosior
* Redistribution terms are gathered in the `copyright' file.
@@ -8,30 +10,25 @@
/*
* IMPORTS_START
* IMPORT TYPE_PREFIX
- * IMPORT for_each_possible_pattern
+ * IMPORT each_url_pattern
* IMPORTS_END
*/
-function check_pattern(storage, pattern, multiple, matched)
-{
- const settings = storage.get(TYPE_PREFIX.PAGE, pattern);
-
- if (settings === undefined)
- return;
-
- matched.push([pattern, settings]);
-
- if (!multiple)
- return false;
-}
-
function query(storage, url, multiple)
{
const matched = [];
const cb = p => check_pattern(storage, p, multiple, matched);
- for_each_possible_pattern(url, cb);
+ for (const pattern of each_url_pattern(url)) {
+ const result = [pattern, storage.get(TYPE_PREFIX.PAGE, pattern)];
+ if (result[1] === undefined)
+ continue;
+
+ if (!multiple)
+ return result;
+ matched.push(result);
+ }
- return multiple ? matched : (matched[0] || [undefined, undefined]);
+ return multiple ? matched : [undefined, undefined];
}
function query_best(storage, url)
diff --git a/common/storage_client.js b/common/storage_client.js
index 2b2f495..ef4a0b8 100644
--- a/common/storage_client.js
+++ b/common/storage_client.js
@@ -1,5 +1,7 @@
/**
- * Hachette storage through connection (client side)
+ * This file is part of Haketilo.
+ *
+ * Function: Storage through messages (client side).
*
* Copyright (C) 2021 Wojtek Kosior
* Redistribution terms are gathered in the `copyright' file.
diff --git a/common/storage_light.js b/common/storage_light.js
new file mode 100644
index 0000000..246e5eb
--- /dev/null
+++ b/common/storage_light.js
@@ -0,0 +1,131 @@
+/**
+ * This file is part of Haketilo.
+ *
+ * Function: Storage manager, lighter than the previous one.
+ *
+ * Copyright (C) 2021 Wojtek Kosior
+ * Redistribution terms are gathered in the `copyright' file.
+ */
+
+/*
+ * IMPORTS_START
+ * IMPORT TYPE_PREFIX
+ * IMPORT raw_storage
+ * IMPORT is_mozilla
+ * IMPORT observables
+ * IMPORTS_END
+ */
+
+const reg_spec = new Set(["\\", "[", "]", "(", ")", "{", "}", ".", "*", "+"]);
+const escape_reg_special = c => reg_spec.has(c) ? "\\" + c : c;
+
+function make_regex(name)
+{
+ return new RegExp(`^${name.split("").map(escape_reg_special).join("")}\$`);
+}
+
+const listeners_by_callback = new Map();
+
+function listen(callback, prefix, name)
+{
+ let by_prefix = listeners_by_callback.get(callback);
+ if (!by_prefix) {
+ by_prefix = new Map();
+ listeners_by_callback.set(callback, by_prefix);
+ }
+
+ let by_name = by_prefix.get(prefix);
+ if (!by_name) {
+ by_name = new Map();
+ by_prefix.set(prefix, by_name);
+ }
+
+ let name_reg = by_name.get(name);
+ if (!name_reg) {
+ name_reg = name.test ? name : make_regex(name);
+ by_name.set(name, name_reg);
+ }
+}
+
+function no_listen(callback, prefix, name)
+{
+ const by_prefix = listeners_by_callback.get(callback);
+ if (!by_prefix)
+ return;
+
+ const by_name = by_prefix.get(prefix);
+ if (!by_name)
+ return;
+
+ const name_reg = by_name.get(name);
+ if (!name_reg)
+ return;
+
+ by_name.delete(name);
+
+ if (by_name.size === 0)
+ by_prefix.delete(prefix);
+
+ if (by_prefix.size === 0)
+ listeners_by_callback.delete(callback);
+}
+
+function storage_change_callback(changes, area)
+{
+ if (is_mozilla && area !== "local")
+ {console.log("area", area);return;}
+
+ for (const item of Object.keys(changes)) {
+ for (const [callback, by_prefix] of listeners_by_callback.entries()) {
+ const by_name = by_prefix.get(item[0]);
+ if (!by_name)
+ continue;
+
+ for (const reg of by_name.values()) {
+ if (!reg.test(item.substring(1)))
+ continue;
+
+ try {
+ callback(item, changes[item]);
+ } catch(e) {
+ console.error(e);
+ }
+ }
+ }
+ }
+}
+
+raw_storage.listen(storage_change_callback);
+
+
+const created_observables = new Map();
+
+async function observe(prefix, name)
+{
+ const observable = observables.make();
+ const callback = (it, ch) => observables.set(observable, ch.newValue);
+ listen(callback, prefix, name);
+ created_observables.set(observable, [callback, prefix, name]);
+ observables.silent_set(observable, await raw_storage.get(prefix + name));
+
+ return observable;
+}
+
+const observe_var = name => observe(TYPE_PREFIX.VAR, name);
+
+function no_observe(observable)
+{
+ no_listen(...created_observables.get(observable) || []);
+ created_observables.delete(observable);
+}
+
+const light_storage = {};
+Object.assign(light_storage, raw_storage);
+Object.assign(light_storage,
+ {listen, no_listen, observe, observe_var, no_observe});
+
+/*
+ * EXPORTS_START
+ * EXPORT light_storage
+ * EXPORTS_END
+ */
diff --git a/common/storage_raw.js b/common/storage_raw.js
new file mode 100644
index 0000000..e354b6b
--- /dev/null
+++ b/common/storage_raw.js
@@ -0,0 +1,55 @@
+/**
+ * This file is part of Haketilo.
+ *
+ * Function: Basic wrappers for storage API functions.
+ *
+ * Copyright (C) 2021 Wojtek Kosior
+ * Redistribution terms are gathered in the `copyright' file.
+ */
+
+/*
+ * IMPORTS_START
+ * IMPORT TYPE_PREFIX
+ * IMPORT browser
+ * IMPORT is_chrome
+ * IMPORTS_END
+ */
+
+async function get(key)
+{
+ /* Fix for fact that Chrome does not use promises here */
+ const promise = is_chrome ?
+ new Promise(resolve => chrome.storage.local.get(key, resolve)) :
+ browser.storage.local.get(key);
+
+ return (await promise)[key];
+}
+
+async function set(key_or_object, value)
+{
+ const arg = typeof key_or_object === "object" ?
+ key_or_object : {[key_or_object]: value};
+ return browser.storage.local.set(arg);
+}
+
+async function set_var(name, value)
+{
+ return set(TYPE_PREFIX.VAR + name, value);
+}
+
+async function get_var(name)
+{
+ return get(TYPE_PREFIX.VAR + name);
+}
+
+const on_changed = browser.storage.onChanged || browser.storage.local.onChanged;
+const listen = cb => on_changed.addListener(cb);
+const no_listen = cb => on_changed.removeListener(cb);
+
+const raw_storage = {get, set, get_var, set_var, listen, no_listen};
+
+/*
+ * EXPORTS_START
+ * EXPORT raw_storage
+ * EXPORTS_END
+ */
diff --git a/common/stored_types.js b/common/stored_types.js
index bfceba6..a693b1c 100644
--- a/common/stored_types.js
+++ b/common/stored_types.js
@@ -1,5 +1,7 @@
/**
- * Hachette stored item types "enum"
+ * This file is part of Haketilo.
+ *
+ * Function: Define an "enum" of stored item types.
*
* Copyright (C) 2021 Wojtek Kosior
* Redistribution terms are gathered in the `copyright' file.