Add test about PeerRecord
This commit is contained in:
parent
7842e4eb3e
commit
88d87bd25d
5 changed files with 147 additions and 55 deletions
|
|
@ -2,7 +2,7 @@ use caretta_id::{DoubleId, SingleId};
|
||||||
use chrono::{DateTime, Local, NaiveDateTime};
|
use chrono::{DateTime, Local, NaiveDateTime};
|
||||||
use iroh::{NodeId, PublicKey};
|
use iroh::{NodeId, PublicKey};
|
||||||
|
|
||||||
use crate::{data::local::LocalModel, global::LOCAL_DATABASE_CONNECTION};
|
use crate::{data::local::LocalRecord, global::LOCAL_DATABASE_CONNECTION};
|
||||||
|
|
||||||
/// Response of node authentication.
|
/// Response of node authentication.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -28,7 +28,7 @@ impl ReceivedAuthorizationRequest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LocalModel for ReceivedAuthorizationRequest {
|
impl LocalRecord for ReceivedAuthorizationRequest {
|
||||||
const TABLE_NAME: &str = "received_authorization_request";
|
const TABLE_NAME: &str = "received_authorization_request";
|
||||||
const DEFAULT_COLUMNS: &[&str] = &[
|
const DEFAULT_COLUMNS: &[&str] = &[
|
||||||
"request_id",
|
"request_id",
|
||||||
|
|
@ -37,6 +37,14 @@ impl LocalModel for ReceivedAuthorizationRequest {
|
||||||
"created_at",
|
"created_at",
|
||||||
"responded_at",
|
"responded_at",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type DefaultParams<'a> = (&'a SingleId, &'a [u8;32], &'a str, NaiveDateTime, Option<NaiveDateTime>)
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
|
||||||
|
fn as_default_params<'a>(&'a self) -> Self::DefaultParams<'a> {
|
||||||
|
(&self.request_id,&self.public_key.as_bytes(), &self.node_info, self.created_at.naive_utc(), self.responded_at.map(|x| x.naive_utc()))
|
||||||
|
}
|
||||||
fn from_default_row(row: &rusqlite::Row<'_>) -> Result<Self, rusqlite::Error> {
|
fn from_default_row(row: &rusqlite::Row<'_>) -> Result<Self, rusqlite::Error> {
|
||||||
let created_at: NaiveDateTime = row.get(3)?;
|
let created_at: NaiveDateTime = row.get(3)?;
|
||||||
let responded_at: Option<NaiveDateTime> = row.get(4)?;
|
let responded_at: Option<NaiveDateTime> = row.get(4)?;
|
||||||
|
|
@ -77,4 +85,6 @@ impl LocalModel for ReceivedAuthorizationRequest {
|
||||||
}
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ use chrono::{DateTime, Local, NaiveDateTime};
|
||||||
use iroh::{NodeId, PublicKey};
|
use iroh::{NodeId, PublicKey};
|
||||||
use rusqlite::types::FromSqlError;
|
use rusqlite::types::FromSqlError;
|
||||||
|
|
||||||
use crate::{data::local::LocalModel, global::LOCAL_DATABASE_CONNECTION};
|
use crate::{data::local::LocalRecord, global::LOCAL_DATABASE_CONNECTION};
|
||||||
|
|
||||||
/// Request of node authentication.
|
/// Request of node authentication.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -12,10 +12,10 @@ pub struct SentAuthorizationRequest {
|
||||||
public_key: PublicKey,
|
public_key: PublicKey,
|
||||||
passcode: String,
|
passcode: String,
|
||||||
created_at: DateTime<Local>,
|
created_at: DateTime<Local>,
|
||||||
sent_at: Option<DateTime<Local>>,
|
responded_at: Option<DateTime<Local>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LocalModel for SentAuthorizationRequest {
|
impl LocalRecord for SentAuthorizationRequest {
|
||||||
|
|
||||||
const TABLE_NAME: &str = "sent_authorization";
|
const TABLE_NAME: &str = "sent_authorization";
|
||||||
const DEFAULT_COLUMNS: &[&str] = &[
|
const DEFAULT_COLUMNS: &[&str] = &[
|
||||||
|
|
@ -23,17 +23,20 @@ impl LocalModel for SentAuthorizationRequest {
|
||||||
"public_key",
|
"public_key",
|
||||||
"passcode",
|
"passcode",
|
||||||
"created_at",
|
"created_at",
|
||||||
"sent_at"
|
"responded_at"
|
||||||
];
|
];
|
||||||
|
type DefaultParams<'a> = (&'a SingleId, &'a [u8;32], &'a str, NaiveDateTime, Option<NaiveDateTime>)
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
fn from_default_row(row: &rusqlite::Row<'_>) -> Result<Self, rusqlite::Error> {
|
fn from_default_row(row: &rusqlite::Row<'_>) -> Result<Self, rusqlite::Error> {
|
||||||
let created_at: NaiveDateTime = row.get(2)?;
|
let created_at: NaiveDateTime = row.get(2)?;
|
||||||
let sent_at: Option<NaiveDateTime> = row.get(3)?;
|
let responded_at: Option<NaiveDateTime> = row.get(3)?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
request_id: row.get(0)?,
|
request_id: row.get(0)?,
|
||||||
public_key: PublicKey::from_bytes(&row.get(1)?).map_err(|e| FromSqlError::Other(Box::new(e)))?,
|
public_key: PublicKey::from_bytes(&row.get(1)?).map_err(|e| FromSqlError::Other(Box::new(e)))?,
|
||||||
passcode: row.get(2)?,
|
passcode: row.get(2)?,
|
||||||
created_at: DateTime::from(created_at.and_utc()),
|
created_at: DateTime::from(created_at.and_utc()),
|
||||||
sent_at: sent_at.map(|x| DateTime::from(x.and_utc())),
|
responded_at: responded_at.map(|x| DateTime::from(x.and_utc())),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
fn insert(&self) -> Result<(), rusqlite::Error> {
|
fn insert(&self) -> Result<(), rusqlite::Error> {
|
||||||
|
|
@ -45,7 +48,7 @@ impl LocalModel for SentAuthorizationRequest {
|
||||||
&self.public_key.as_bytes(),
|
&self.public_key.as_bytes(),
|
||||||
&self.passcode,
|
&self.passcode,
|
||||||
&self.created_at.naive_utc(),
|
&self.created_at.naive_utc(),
|
||||||
&self.sent_at.map(|x| x.naive_utc())
|
&self.responded_at.map(|x| x.naive_utc())
|
||||||
),
|
),
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -64,4 +67,8 @@ impl LocalModel for SentAuthorizationRequest {
|
||||||
}
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn as_default_params<'a>(&'a self) -> Self::DefaultParams<'a> {
|
||||||
|
(&self.request_id, &self.public_key.as_bytes(), &self.passcode, self.created_at.naive_utc(), self.responded_at.map(|x| x.naive_utc()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,11 +3,10 @@ use rusqlite::{Error, Connection};
|
||||||
pub fn migrate(con: &mut Connection) -> Result<(), Error>{
|
pub fn migrate(con: &mut Connection) -> Result<(), Error>{
|
||||||
let tx = con.transaction()?;
|
let tx = con.transaction()?;
|
||||||
tx.execute_batch(
|
tx.execute_batch(
|
||||||
"BEGIN;
|
"CREATE TABLE peer (
|
||||||
CREATE TABLE peer (
|
id INTEGER PRIMARY KEY,
|
||||||
id INTEGER PRIMARY KEY,
|
local_peer_id INTEGER NOT NULL UNIQUE,
|
||||||
local_id INTEGER NOT NULL UNIQUE,
|
public_key BLOB UNIQUE NOT NULL
|
||||||
public_key BLOB UNIQUE NOT NULL,
|
|
||||||
);
|
);
|
||||||
CREATE TABLE received_authorization_request (
|
CREATE TABLE received_authorization_request (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
|
|
@ -20,27 +19,26 @@ pub fn migrate(con: &mut Connection) -> Result<(), Error>{
|
||||||
CREATE TABLE sent_authorization_request (
|
CREATE TABLE sent_authorization_request (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
request_id INTEGER NOT NULL UNIQUE,
|
request_id INTEGER NOT NULL UNIQUE,
|
||||||
public_key. BLOB NOT NULL UNIQUE,
|
public_key BLOB NOT NULL UNIQUE,
|
||||||
passcode TEXT NOT NULL,
|
passcode TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
sent_at TEXT
|
responded_at TEXT
|
||||||
);
|
);
|
||||||
CREATE TABLE authorized_peer (
|
CREATE TABLE authorized_peer (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
node_id BLOB NOT NULL UNIQUE,
|
node_id BLOB NOT NULL UNIQUE,
|
||||||
last_synced_at TEXT,
|
last_synced_at TEXT,
|
||||||
last_sent_version_vector BLOB
|
last_sent_version_vector BLOB,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE TABLE authorization (
|
CREATE TABLE authorization (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
node_id BLOB UNIQUE NOT NULL,
|
node_id BLOB UNIQUE NOT NULL,
|
||||||
passcode TEXT NOT NULL,
|
passcode TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL
|
||||||
);
|
);",
|
||||||
COMMIT;",
|
|
||||||
)?;
|
)?;
|
||||||
tx.pragma_update(None, "user_version", 1)?;
|
tx.pragma_update(None, "user_version", 1)?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ pub mod migration;
|
||||||
use std::{cell::OnceCell, iter::Map, path::Path, sync::{LazyLock, OnceLock}};
|
use std::{cell::OnceCell, iter::Map, path::Path, sync::{LazyLock, OnceLock}};
|
||||||
|
|
||||||
use migration::migrate;
|
use migration::migrate;
|
||||||
use rusqlite::{ffi::Error, Connection, MappedRows, Row};
|
use rusqlite::{ffi::Error, params, Connection, MappedRows, OptionalExtension, Params, Row, ToSql};
|
||||||
|
|
||||||
use crate::{config::StorageConfig, global::{CONFIG, LOCAL_DATABASE_CONNECTION}};
|
use crate::{config::StorageConfig, global::{CONFIG, LOCAL_DATABASE_CONNECTION}};
|
||||||
|
|
||||||
|
|
@ -13,10 +13,63 @@ pub use authorization_request::*;
|
||||||
|
|
||||||
/// Model trait for local database data.
|
/// Model trait for local database data.
|
||||||
/// use LOCAL_DATABASE_CONNECTION for database connection.
|
/// use LOCAL_DATABASE_CONNECTION for database connection.
|
||||||
pub trait LocalModel: Sized {
|
pub trait LocalRecord: Sized {
|
||||||
const TABLE_NAME: &str;
|
const TABLE_NAME: &str;
|
||||||
const DEFAULT_COLUMNS: &[&str];
|
const DEFAULT_COLUMNS: &[&str];
|
||||||
fn insert(&self) -> Result<(), rusqlite::Error>;
|
|
||||||
|
const DEFAULT_SELECT_STATEMENT: LazyLock<String> = LazyLock::new(|| {
|
||||||
|
String::from("SELECT ") + &Self::DEFAULT_COLUMNS.join(", ") + " FROM " + Self::TABLE_NAME
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEFAULT_PLACEHOLDER: LazyLock<String> = LazyLock::new(|| {
|
||||||
|
let mut result : Vec<String> = Vec::new();
|
||||||
|
for i in 0..Self::DEFAULT_COLUMNS.len() {
|
||||||
|
result.push(String::from("?") + &(i+1).to_string());
|
||||||
|
}
|
||||||
|
result.join(", ")
|
||||||
|
});
|
||||||
|
|
||||||
|
type DefaultParams<'a>: Params
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
|
||||||
|
fn as_default_params<'a>(&'a self) -> Self::DefaultParams<'a>;
|
||||||
|
|
||||||
|
fn insert(&self) -> Result<(), rusqlite::Error> {
|
||||||
|
let connection = LOCAL_DATABASE_CONNECTION.get_unchecked();
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
&("INSERT INTO ".to_owned() + Self::TABLE_NAME + " (" + &Self::DEFAULT_COLUMNS.join(", ") + ") VALUES (" + &*Self::DEFAULT_PLACEHOLDER + ")"),
|
||||||
|
self.as_default_params()
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_one_where<P>(where_statement: &str, params: P) -> Result<Option<Self>, rusqlite::Error>
|
||||||
|
where P: Params
|
||||||
|
{
|
||||||
|
let connection = LOCAL_DATABASE_CONNECTION.get_unchecked();
|
||||||
|
Ok(connection.query_row(
|
||||||
|
&(String::new() + &Self::DEFAULT_SELECT_STATEMENT + " " + where_statement),
|
||||||
|
params,
|
||||||
|
Self::from_default_row
|
||||||
|
).optional()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_one_by<T>(field_name: &str, field_value: T) -> Result<Option<Self>, rusqlite::Error>
|
||||||
|
where
|
||||||
|
T: ToSql
|
||||||
|
{
|
||||||
|
let connection = LOCAL_DATABASE_CONNECTION.get_unchecked();
|
||||||
|
Ok(Some(connection.query_row(
|
||||||
|
&("SELECT ".to_string() + &Self::DEFAULT_COLUMNS.join(", ") + " FROM " + Self::TABLE_NAME + " WHERE " + field_name + "=(?1)"),
|
||||||
|
params![field_value],
|
||||||
|
Self::from_default_row
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
fn get_one_by_id(id: u32) -> Result<Option<Self>, rusqlite::Error> {
|
||||||
|
Self::get_one_by("id", id )
|
||||||
|
}
|
||||||
fn from_default_row(row: &Row<'_>) -> Result<Self, rusqlite::Error>;
|
fn from_default_row(row: &Row<'_>) -> Result<Self, rusqlite::Error>;
|
||||||
fn get_all() -> Result<Vec<Self>, rusqlite::Error>;
|
fn get_all() -> Result<Vec<Self>, rusqlite::Error>;
|
||||||
}
|
}
|
||||||
|
|
@ -8,7 +8,7 @@ use iroh::{NodeId, PublicKey};
|
||||||
use rusqlite::{params, types::FromSqlError, Connection};
|
use rusqlite::{params, types::FromSqlError, Connection};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{data::local::LocalModel, global::LOCAL_DATABASE_CONNECTION};
|
use crate::{data::local::{self, LocalRecord}, global::LOCAL_DATABASE_CONNECTION};
|
||||||
|
|
||||||
/// Peer information cached in local database.
|
/// Peer information cached in local database.
|
||||||
///
|
///
|
||||||
|
|
@ -17,53 +17,57 @@ use crate::{data::local::LocalModel, global::LOCAL_DATABASE_CONNECTION};
|
||||||
/// - Actual peer information is managed by iroh endpoint and not contained in this model.
|
/// - Actual peer information is managed by iroh endpoint and not contained in this model.
|
||||||
/// - Once a peer is authorized, it is assigned a global (=synced) ID as authorized_peer so essentially this local id targets unauthorized peers.
|
/// - Once a peer is authorized, it is assigned a global (=synced) ID as authorized_peer so essentially this local id targets unauthorized peers.
|
||||||
///
|
///
|
||||||
pub struct Peer {
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub local_id: DoubleId,
|
pub struct PeerRecord {
|
||||||
|
|
||||||
|
/// local id of peer.
|
||||||
|
/// this id is use only the node itself and not synced so another node has different local_peer_id even if its public_key is same.
|
||||||
|
pub local_peer_id: DoubleId,
|
||||||
pub public_key: PublicKey,
|
pub public_key: PublicKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Peer {
|
impl PeerRecord {
|
||||||
pub fn get_by_local_id(local_id: DoubleId) -> Result<Option<Self>, rusqlite::Error> {
|
pub fn get_or_insert_by_public_key(public_key: &PublicKey) -> Result<Self, rusqlite::Error> {
|
||||||
let connection = LOCAL_DATABASE_CONNECTION.get_unchecked();
|
match Self::get_by_public_key(public_key)? {
|
||||||
Ok(Some(connection.query_row(
|
Some(x) => Ok(x),
|
||||||
&("SELECT ".to_string() + &Self::DEFAULT_COLUMNS.join(", ") + " FROM " + Self::TABLE_NAME + " WHERE local_id=(?1)"),
|
None => {
|
||||||
params![local_id],
|
let new = Self{
|
||||||
Self::from_default_row
|
local_peer_id: rand::random(),
|
||||||
)?))
|
public_key: public_key.clone(),
|
||||||
|
};
|
||||||
|
new.insert()?;
|
||||||
|
Ok(new)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
pub fn get_by_public_key(public_key: PublicKey) -> Result<Option<Self>, rusqlite::Error> {
|
pub fn get_by_local_id(local_id: &DoubleId) -> Result<Option<Self>, rusqlite::Error> {
|
||||||
let connection = LOCAL_DATABASE_CONNECTION.get_unchecked();
|
Self::get_one_where("WHERE local_peer_id = ?1", (local_id,))
|
||||||
Ok(Some(connection.query_row(
|
}
|
||||||
&("SELECT ".to_string() + &Self::DEFAULT_COLUMNS.join(", ") + " FROM " + Self::TABLE_NAME + " WHERE public_key=(?1)"),
|
pub fn get_by_public_key(public_key: &PublicKey) -> Result<Option<Self>, rusqlite::Error> {
|
||||||
params![public_key.as_bytes()],
|
Self::get_one_where("WHERE public_Key = ?1", (public_key.as_bytes(),))
|
||||||
Self::from_default_row
|
|
||||||
)?))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LocalModel for Peer {
|
impl LocalRecord for PeerRecord {
|
||||||
const TABLE_NAME: &str = "peer";
|
const TABLE_NAME: &str = "peer";
|
||||||
const DEFAULT_COLUMNS: &[&str] = &[
|
const DEFAULT_COLUMNS: &[&str] = &[
|
||||||
"local_id",
|
"local_peer_id",
|
||||||
"public_key"
|
"public_key"
|
||||||
];
|
];
|
||||||
|
type DefaultParams<'a> = (&'a DoubleId, &'a [u8;32]);
|
||||||
|
fn as_default_params<'a>(&'a self) -> Self::DefaultParams<'a>
|
||||||
|
{
|
||||||
|
(&self.local_peer_id, &self.public_key.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
fn from_default_row(row: &rusqlite::Row<'_>) -> Result<Self, rusqlite::Error> {
|
fn from_default_row(row: &rusqlite::Row<'_>) -> Result<Self, rusqlite::Error> {
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
local_id: row.get(0)?,
|
local_peer_id: row.get(0)?,
|
||||||
public_key: PublicKey::from_bytes(&row.get(1)?).map_err(|e| FromSqlError::Other(Box::new(e)))?
|
public_key: PublicKey::from_bytes(&row.get(1)?).map_err(|e| FromSqlError::Other(Box::new(e)))?
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
fn insert(&self) -> Result<(), rusqlite::Error> {
|
|
||||||
let connection = LOCAL_DATABASE_CONNECTION.get_unchecked();
|
|
||||||
|
|
||||||
connection.execute(
|
|
||||||
&("INSERT INTO ".to_owned() + Self::TABLE_NAME + " (" + &Self::DEFAULT_COLUMNS.join(", ") + ") VALUES (?1, ?2, ?3, ?4)"),
|
|
||||||
(&self.local_id, &self.public_key.as_bytes()),
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
fn get_all() -> Result<Vec<Self>, rusqlite::Error> {
|
fn get_all() -> Result<Vec<Self>, rusqlite::Error> {
|
||||||
let connection = LOCAL_DATABASE_CONNECTION.get_unchecked();
|
let connection = LOCAL_DATABASE_CONNECTION.get_unchecked();
|
||||||
let mut stmt = connection.prepare(&("SELECT ".to_string() + &Self::DEFAULT_COLUMNS.join(", ") + " FROM " + Self::TABLE_NAME))?;
|
let mut stmt = connection.prepare(&("SELECT ".to_string() + &Self::DEFAULT_COLUMNS.join(", ") + " FROM " + Self::TABLE_NAME))?;
|
||||||
|
|
@ -77,4 +81,24 @@ impl LocalModel for Peer {
|
||||||
}
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use iroh::SecretKey;
|
||||||
|
|
||||||
|
use crate::tests::TEST_CONFIG;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_get_peer_record() {
|
||||||
|
LOCAL_DATABASE_CONNECTION.get_or_init(&TEST_CONFIG.storage.get_local_database_path());
|
||||||
|
let key = SecretKey::generate(&mut rand::rngs::OsRng);
|
||||||
|
let pubkey = key.public();
|
||||||
|
let record = PeerRecord::get_or_insert_by_public_key(&pubkey).unwrap();
|
||||||
|
assert_eq!(record, PeerRecord::get_by_local_id(&record.local_peer_id).unwrap().unwrap());
|
||||||
|
assert_eq!(record, PeerRecord::get_by_public_key(&record.public_key).unwrap().unwrap());
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue