version 0.4.9
This commit is contained in:
parent
1353e8a110
commit
70a5affd37
11 changed files with 299 additions and 109 deletions
|
@ -1,4 +1,4 @@
|
||||||
/* globals UAParser */
|
/* global UAParser */
|
||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
@ -544,15 +544,15 @@ const onBeforeSendHeaders = d => {
|
||||||
if (platform.toLowerCase().includes('mac')) {
|
if (platform.toLowerCase().includes('mac')) {
|
||||||
platform = 'macOS';
|
platform = 'macOS';
|
||||||
}
|
}
|
||||||
|
else if (platform.toLowerCase().includes('debian')) {
|
||||||
|
platform = 'Linux';
|
||||||
|
}
|
||||||
|
|
||||||
const version = o.userAgentDataBuilder.p?.browser?.major || 107;
|
const version = o.userAgentDataBuilder.p?.browser?.major || 107;
|
||||||
let name = o.userAgentDataBuilder.p?.browser?.name || 'Google Chrome';
|
let name = o.userAgentDataBuilder.p?.browser?.name || 'Google Chrome';
|
||||||
if (name === 'Chrome') {
|
if (name === 'Chrome') {
|
||||||
name = 'Google Chrome';
|
name = 'Google Chrome';
|
||||||
}
|
}
|
||||||
else if (name.includes('debian')) {
|
|
||||||
platform = 'Linux';
|
|
||||||
}
|
|
||||||
|
|
||||||
requestHeaders.push({
|
requestHeaders.push({
|
||||||
name: 'sec-ch-ua-platform',
|
name: 'sec-ch-ua-platform',
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"manifest_version": 2,
|
"manifest_version": 2,
|
||||||
"name": "__MSG_extensionName__",
|
"name": "__MSG_extensionName__",
|
||||||
"version": "0.4.8",
|
"version": "0.4.9",
|
||||||
"default_locale": "en",
|
"default_locale": "en",
|
||||||
"description": "__MSG_extensionDescription__",
|
"description": "__MSG_extensionDescription__",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
|
|
@ -15,3 +15,5 @@ self.prefs = self.prefs || {
|
||||||
platformVersion: '10.0.0'
|
platformVersion: '10.0.0'
|
||||||
};
|
};
|
||||||
Object.assign(port.dataset, self.prefs);
|
Object.assign(port.dataset, self.prefs);
|
||||||
|
|
||||||
|
port.dataset.enabled = self.ingored ? false : true;
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
const port = document.getElementById('ua-port-fgTd9n');
|
const port = document.getElementById('ua-port-fgTd9n');
|
||||||
port.remove();
|
port.remove();
|
||||||
console.log(12, port);
|
|
||||||
|
|
||||||
// overwrite navigator.userAgent
|
// overwrite navigator.userAgent
|
||||||
{
|
{
|
||||||
|
@ -8,8 +7,8 @@ console.log(12, port);
|
||||||
|
|
||||||
Object.defineProperty(Navigator.prototype, 'userAgent', {
|
Object.defineProperty(Navigator.prototype, 'userAgent', {
|
||||||
get: new Proxy(get, {
|
get: new Proxy(get, {
|
||||||
apply() {
|
apply(target, self, args) {
|
||||||
return port.dataset.ua;
|
return port.dataset.enabled === 'true' ? port.dataset.ua : Reflect.apply(target, self, args);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
@ -31,8 +30,8 @@ if (port.dataset.uad) {
|
||||||
|
|
||||||
Object.defineProperty(self.NavigatorUAData.prototype, 'brands', {
|
Object.defineProperty(self.NavigatorUAData.prototype, 'brands', {
|
||||||
get: new Proxy(Object.getOwnPropertyDescriptor(self.NavigatorUAData.prototype, 'brands').get, {
|
get: new Proxy(Object.getOwnPropertyDescriptor(self.NavigatorUAData.prototype, 'brands').get, {
|
||||||
apply() {
|
apply(target, self, args) {
|
||||||
return [{
|
return port.dataset.enabled === 'true' ? [{
|
||||||
brand: port.dataset.name,
|
brand: port.dataset.name,
|
||||||
version: port.dataset.major
|
version: port.dataset.major
|
||||||
}, {
|
}, {
|
||||||
|
@ -41,62 +40,65 @@ if (port.dataset.uad) {
|
||||||
}, {
|
}, {
|
||||||
brand: 'Not=A?Brand',
|
brand: 'Not=A?Brand',
|
||||||
version: '24'
|
version: '24'
|
||||||
}];
|
}] : Reflect.apply(target, self, args);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
Object.defineProperty(self.NavigatorUAData.prototype, 'mobile', {
|
Object.defineProperty(self.NavigatorUAData.prototype, 'mobile', {
|
||||||
get: new Proxy(Object.getOwnPropertyDescriptor(self.NavigatorUAData.prototype, 'mobile').get, {
|
get: new Proxy(Object.getOwnPropertyDescriptor(self.NavigatorUAData.prototype, 'mobile').get, {
|
||||||
apply() {
|
apply(target, self, args) {
|
||||||
return port.dataset.mobile === 'true';
|
return port.dataset.enabled === 'true' ? port.dataset.mobile === 'true' : Reflect.apply(target, self, args);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
Object.defineProperty(self.NavigatorUAData.prototype, 'platform', {
|
Object.defineProperty(self.NavigatorUAData.prototype, 'platform', {
|
||||||
get: new Proxy(Object.getOwnPropertyDescriptor(self.NavigatorUAData.prototype, 'platform').get, {
|
get: new Proxy(Object.getOwnPropertyDescriptor(self.NavigatorUAData.prototype, 'platform').get, {
|
||||||
apply() {
|
apply(target, self, args) {
|
||||||
return port.dataset.platform;
|
return port.dataset.enabled === 'true' ? port.dataset.platform : Reflect.apply(target, self, args);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
self.NavigatorUAData.prototype.toJSON = new Proxy(self.NavigatorUAData.prototype.toJSON, {
|
self.NavigatorUAData.prototype.toJSON = new Proxy(self.NavigatorUAData.prototype.toJSON, {
|
||||||
apply(target, self) {
|
apply(target, self, args) {
|
||||||
return {
|
return port.dataset.enabled === 'true' ? {
|
||||||
brands: self.brands,
|
brands: self.brands,
|
||||||
mobile: self.mobile,
|
mobile: self.mobile,
|
||||||
platform: self.platform
|
platform: self.platform
|
||||||
};
|
} : Reflect.apply(target, self, args);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
self.NavigatorUAData.prototype.getHighEntropyValues = new Proxy(self.NavigatorUAData.prototype.getHighEntropyValues, {
|
self.NavigatorUAData.prototype.getHighEntropyValues = new Proxy(self.NavigatorUAData.prototype.getHighEntropyValues, {
|
||||||
apply(target, self, args) {
|
apply(target, self, args) {
|
||||||
const hints = args[0];
|
if (port.dataset.enabled === 'true') {
|
||||||
|
const hints = args[0];
|
||||||
|
|
||||||
if (!hints || Array.isArray(hints) === false) {
|
if (!hints || Array.isArray(hints) === false) {
|
||||||
return Promise.reject(Error(`Failed to execute 'getHighEntropyValues' on 'NavigatorUAData'`));
|
return Promise.reject(Error(`Failed to execute 'getHighEntropyValues' on 'NavigatorUAData'`));
|
||||||
}
|
}
|
||||||
|
|
||||||
const r = self.toJSON();
|
const r = self.toJSON();
|
||||||
|
|
||||||
if (hints.includes('architecture')) {
|
if (hints.includes('architecture')) {
|
||||||
r.architecture = port.dataset.architecture;
|
r.architecture = port.dataset.architecture;
|
||||||
|
}
|
||||||
|
if (hints.includes('bitness')) {
|
||||||
|
r.bitness = port.dataset.bitness;
|
||||||
|
}
|
||||||
|
if (hints.includes('model')) {
|
||||||
|
r.model = '';
|
||||||
|
}
|
||||||
|
if (hints.includes('platformVersion')) {
|
||||||
|
r.platformVersion = port.dataset.platformVersion;
|
||||||
|
}
|
||||||
|
if (hints.includes('uaFullVersion')) {
|
||||||
|
r.uaFullVersion = self.brands[0].version;
|
||||||
|
}
|
||||||
|
if (hints.includes('fullVersionList')) {
|
||||||
|
r.fullVersionList = this.brands;
|
||||||
|
}
|
||||||
|
return Promise.resolve(r);
|
||||||
}
|
}
|
||||||
if (hints.includes('bitness')) {
|
return Reflect.apply(target, self, args);
|
||||||
r.bitness = port.dataset.bitness;
|
|
||||||
}
|
|
||||||
if (hints.includes('model')) {
|
|
||||||
r.model = '';
|
|
||||||
}
|
|
||||||
if (hints.includes('platformVersion')) {
|
|
||||||
r.platformVersion = port.dataset.platformVersion;
|
|
||||||
}
|
|
||||||
if (hints.includes('uaFullVersion')) {
|
|
||||||
r.uaFullVersion = self.brands[0].version;
|
|
||||||
}
|
|
||||||
if (hints.includes('fullVersionList')) {
|
|
||||||
r.fullVersionList = this.brands;
|
|
||||||
}
|
|
||||||
return Promise.resolve(r);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
2
v3/helper/ReadMe.txt
Normal file
2
v3/helper/ReadMe.txt
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
ua-parser.min.js:
|
||||||
|
https://github.com/faisalman/ua-parser-js/releases/tag/1.0.32
|
4
v3/helper/ua-parser.min.js
vendored
Normal file
4
v3/helper/ua-parser.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
@ -10,6 +10,7 @@
|
||||||
"webNavigation",
|
"webNavigation",
|
||||||
"webRequest",
|
"webRequest",
|
||||||
"declarativeNetRequest",
|
"declarativeNetRequest",
|
||||||
|
"declarativeNetRequestFeedback",
|
||||||
"contextMenus"
|
"contextMenus"
|
||||||
],
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
|
|
74
v3/policy.js
Normal file
74
v3/policy.js
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
/* global UAParser */
|
||||||
|
self.importScripts('./helper/ua-parser.min.js');
|
||||||
|
|
||||||
|
const PREFS = {
|
||||||
|
'enabled': true,
|
||||||
|
'mode': 'blacklist',
|
||||||
|
'blacklist-exception-hosts': [],
|
||||||
|
'whitelist-hosts': [],
|
||||||
|
'custom-routing': {
|
||||||
|
'whatismybrowser.com': 'ff'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const policy = {};
|
||||||
|
|
||||||
|
{
|
||||||
|
const cache = new Map();
|
||||||
|
|
||||||
|
policy.parse = d => {
|
||||||
|
const ua = 'Mozilla/5.0 (Windows NT 6.3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36';
|
||||||
|
|
||||||
|
if (cache.has(ua)) {
|
||||||
|
return cache.get(ua);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise(resolve => chrome.storage.local.get({
|
||||||
|
'userAgentData': true
|
||||||
|
}, prefs => {
|
||||||
|
const p = (new UAParser(ua)).getResult();
|
||||||
|
|
||||||
|
const r = {
|
||||||
|
ua
|
||||||
|
};
|
||||||
|
r.uad = prefs.userAgentData &&
|
||||||
|
p.browser && p.browser.major && ['Opera', 'Chrome', 'Edge'].includes(p.browser.name);
|
||||||
|
|
||||||
|
if (r.uad) {
|
||||||
|
r.platform = p?.os?.name || 'Windows';
|
||||||
|
if (r.platform.toLowerCase().includes('mac')) {
|
||||||
|
r.platform = 'macOS';
|
||||||
|
}
|
||||||
|
else if (r.platform.toLowerCase().includes('debian')) {
|
||||||
|
r.platform = 'Linux';
|
||||||
|
}
|
||||||
|
|
||||||
|
r.major = p?.browser?.major || 100;
|
||||||
|
|
||||||
|
r.name = p?.browser?.name || 'Google Chrome';
|
||||||
|
if (r.name === 'Chrome') {
|
||||||
|
r.name = 'Google Chrome';
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua);
|
||||||
|
|
||||||
|
r.architecture = 'x86';
|
||||||
|
|
||||||
|
r.bitness = '64';
|
||||||
|
|
||||||
|
r.platformVersion = '10.0.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(ua, r);
|
||||||
|
|
||||||
|
resolve(r);
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
policy.configure = (...methods) => new Promise(resolve => chrome.storage.local.get(PREFS, prefs => {
|
||||||
|
for (const method of methods) {
|
||||||
|
method(prefs);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
}));
|
51
v3/request.js
Normal file
51
v3/request.js
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
/* global policy */
|
||||||
|
|
||||||
|
const request = {};
|
||||||
|
|
||||||
|
request.network = async prefs => {
|
||||||
|
const p = await policy.parse();
|
||||||
|
|
||||||
|
const condition = {
|
||||||
|
'isUrlFilterCaseSensitive': false,
|
||||||
|
'resourceTypes': Object.values(chrome.declarativeNetRequest.ResourceType)
|
||||||
|
};
|
||||||
|
const one = {
|
||||||
|
'id': 1,
|
||||||
|
'priority': 1,
|
||||||
|
'action': {
|
||||||
|
'type': 'modifyHeaders',
|
||||||
|
'requestHeaders': [{
|
||||||
|
'operation': 'set',
|
||||||
|
'header': 'user-agent',
|
||||||
|
'value': p.ua
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
'condition': {
|
||||||
|
...condition
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const o = {
|
||||||
|
addRules: [one],
|
||||||
|
removeRuleIds: (await chrome.declarativeNetRequest.getDynamicRules()).map(o => o.id)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (prefs.enabled) {
|
||||||
|
if (prefs.mode === 'blacklist') {
|
||||||
|
one.condition.excludedInitiatorDomains = prefs['blacklist-exception-hosts'];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (prefs['whitelist-hosts'].length) {
|
||||||
|
one.condition.initiatorDomains = prefs['whitelist-hosts'];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
console.info('matching list is empty');
|
||||||
|
o.addRules.length = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.declarativeNetRequest.updateDynamicRules(o);
|
||||||
|
};
|
||||||
|
|
||||||
|
chrome.declarativeNetRequest.onRuleMatchedDebug.addListener(d => console.log(d));
|
||||||
|
|
104
v3/scripting.js
Normal file
104
v3/scripting.js
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
/* global port, policy */
|
||||||
|
|
||||||
|
const scripting = {};
|
||||||
|
|
||||||
|
scripting.page = async prefs => {
|
||||||
|
await chrome.scripting.unregisterContentScripts();
|
||||||
|
|
||||||
|
if (prefs.enabled) {
|
||||||
|
const common = {
|
||||||
|
'allFrames': true,
|
||||||
|
'matchOriginAsFallback': true,
|
||||||
|
'runAt': 'document_start'
|
||||||
|
};
|
||||||
|
if (prefs.mode === 'blacklist') {
|
||||||
|
common.matches = ['*://*/*'];
|
||||||
|
if (prefs['blacklist-exception-hosts'].length) {
|
||||||
|
common.excludeMatches = prefs['blacklist-exception-hosts'].map(h => [`*://${h}/*`, `*://*.${h}/*`]).flat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (prefs['whitelist-hosts'].length) {
|
||||||
|
common.matches = prefs['whitelist-hosts'].map(h => [`*://${h}/*`, `*://*.${h}/*`]).flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (common.matches.length) {
|
||||||
|
await chrome.scripting.registerContentScripts([{
|
||||||
|
...common,
|
||||||
|
'id': 'protected',
|
||||||
|
'js': ['/data/inject/isolated.js'],
|
||||||
|
'world': 'ISOLATED'
|
||||||
|
}, {
|
||||||
|
...common,
|
||||||
|
'id': 'unprotected',
|
||||||
|
'js': ['/data/inject/main.js'],
|
||||||
|
'world': 'MAIN'
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
console.info('matching list is empty');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// web navigation
|
||||||
|
{
|
||||||
|
const onCommitted = async d => {
|
||||||
|
const p = await policy.parse(d);
|
||||||
|
|
||||||
|
if (p) {
|
||||||
|
chrome.scripting.executeScript({
|
||||||
|
target: {
|
||||||
|
tabId: d.tabId,
|
||||||
|
frameIds: [d.frameId]
|
||||||
|
},
|
||||||
|
injectImmediately: true,
|
||||||
|
func: p => {
|
||||||
|
if (typeof port === 'undefined') {
|
||||||
|
self.prefs = p;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Object.assign(port.dataset, p);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
args: [p]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onCommittedIgnore = d => {
|
||||||
|
chrome.scripting.executeScript({
|
||||||
|
target: {
|
||||||
|
tabId: d.tabId,
|
||||||
|
frameIds: [d.frameId]
|
||||||
|
},
|
||||||
|
injectImmediately: true,
|
||||||
|
func: () => {
|
||||||
|
if (typeof port === 'undefined') {
|
||||||
|
port.dataset.enabled = false;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
self.ingored = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
scripting.commit = prefs => {
|
||||||
|
if (prefs.enabled && prefs.mode === 'blacklist') {
|
||||||
|
chrome.webNavigation.onCommitted.addListener(onCommitted);
|
||||||
|
if (prefs['blacklist-exception-hosts'].length) {
|
||||||
|
chrome.webNavigation.onCommitted.addListener(onCommittedIgnore, {
|
||||||
|
url: prefs['blacklist-exception-hosts'].map(hostContains => ({
|
||||||
|
hostContains
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (prefs['whitelist-hosts'].length) {
|
||||||
|
chrome.webNavigation.onCommitted.addListener(onCommitted, {
|
||||||
|
url: prefs['whitelist-hosts'].map(hostContains => ({
|
||||||
|
hostContains
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
86
v3/worker.js
86
v3/worker.js
|
@ -1,72 +1,22 @@
|
||||||
const enable = () => chrome.storage.local.get({
|
/* global policy, scripting, request */
|
||||||
enabled: true
|
|
||||||
}, async prefs => {
|
|
||||||
await chrome.scripting.unregisterContentScripts();
|
|
||||||
|
|
||||||
if (prefs.enabled) {
|
self.importScripts('./policy.js');
|
||||||
const common = {
|
self.importScripts('./scripting.js');
|
||||||
'matches': ['*://*/*'],
|
self.importScripts('./request.js');
|
||||||
'allFrames': true,
|
|
||||||
'matchOriginAsFallback': true,
|
|
||||||
'runAt': 'document_start'
|
|
||||||
};
|
|
||||||
|
|
||||||
await chrome.scripting.registerContentScripts([{
|
// run on each wake up
|
||||||
...common,
|
policy.configure(scripting.commit, request.network);
|
||||||
'id': 'protected',
|
|
||||||
'js': ['/data/inject/isolated.js'],
|
// run once
|
||||||
'world': 'ISOLATED'
|
{
|
||||||
}, {
|
const once = () => policy.configure(scripting.page);
|
||||||
...common,
|
|
||||||
'id': 'unprotected',
|
chrome.runtime.onStartup.addListener(once);
|
||||||
'js': ['/data/inject/main.js'],
|
chrome.runtime.onInstalled.addListener(once);
|
||||||
'world': 'MAIN'
|
}
|
||||||
}]);
|
|
||||||
}
|
chrome.storage.onChanged.addListener(ps => {
|
||||||
});
|
if (ps.enabled || ps.mode || ps['blacklist-exception-hosts'] || ps['whitelist-hosts']) {
|
||||||
chrome.runtime.onStartup.addListener(enable);
|
policy.configure(scripting.commit, scripting.page, request.network);
|
||||||
chrome.runtime.onInstalled.addListener(enable);
|
|
||||||
|
|
||||||
const policy = () => ({
|
|
||||||
ua: 'Mozilla/5.0 (Windows NT 6.3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36',
|
|
||||||
uad: true,
|
|
||||||
major: 100,
|
|
||||||
name: 'Google Chrome',
|
|
||||||
mobile: false,
|
|
||||||
platform: 'Windows',
|
|
||||||
architecture: 'x86',
|
|
||||||
bitness: '64',
|
|
||||||
platformVersion: '10.0.0'
|
|
||||||
});
|
|
||||||
|
|
||||||
// web navigation
|
|
||||||
const onCommitted = d => {
|
|
||||||
const p = policy(d);
|
|
||||||
|
|
||||||
if (p) {
|
|
||||||
chrome.scripting.executeScript({
|
|
||||||
target: {
|
|
||||||
tabId: d.tabId,
|
|
||||||
frameIds: [d.frameId]
|
|
||||||
},
|
|
||||||
injectImmediately: true,
|
|
||||||
func: p => {
|
|
||||||
/* global port */
|
|
||||||
if (typeof port === 'undefined') {
|
|
||||||
self.prefs = p;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Object.assign(port.dataset, p);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
args: [p]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
chrome.storage.local.get({
|
|
||||||
enabled: true
|
|
||||||
}, prefs => {
|
|
||||||
if (prefs.enabled) {
|
|
||||||
chrome.webNavigation.onCommitted.addListener(onCommitted);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Reference in a new issue