mirror of
https://forgejo.ellis.link/continuwuation/continuwuity.git
synced 2025-07-04 03:24:41 +02:00
Compare commits
3 commits
d7514178ab
...
f183f99b07
Author | SHA1 | Date | |
---|---|---|---|
|
f183f99b07 | ||
|
78f0031c34 | ||
|
bb69ee68d0 |
5 changed files with 91 additions and 37 deletions
11
Cargo.lock
generated
11
Cargo.lock
generated
|
@ -3695,7 +3695,6 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruma"
|
name = "ruma"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=d6870a7fb7f6cccff63f7fd0ff6c581bad80e983#d6870a7fb7f6cccff63f7fd0ff6c581bad80e983"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"assign",
|
"assign",
|
||||||
"js_int",
|
"js_int",
|
||||||
|
@ -3715,7 +3714,6 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruma-appservice-api"
|
name = "ruma-appservice-api"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=d6870a7fb7f6cccff63f7fd0ff6c581bad80e983#d6870a7fb7f6cccff63f7fd0ff6c581bad80e983"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js_int",
|
"js_int",
|
||||||
"ruma-common",
|
"ruma-common",
|
||||||
|
@ -3727,7 +3725,6 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruma-client-api"
|
name = "ruma-client-api"
|
||||||
version = "0.18.0"
|
version = "0.18.0"
|
||||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=d6870a7fb7f6cccff63f7fd0ff6c581bad80e983#d6870a7fb7f6cccff63f7fd0ff6c581bad80e983"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"as_variant",
|
"as_variant",
|
||||||
"assign",
|
"assign",
|
||||||
|
@ -3750,7 +3747,6 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruma-common"
|
name = "ruma-common"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=d6870a7fb7f6cccff63f7fd0ff6c581bad80e983#d6870a7fb7f6cccff63f7fd0ff6c581bad80e983"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"as_variant",
|
"as_variant",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
@ -3782,7 +3778,6 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruma-events"
|
name = "ruma-events"
|
||||||
version = "0.28.1"
|
version = "0.28.1"
|
||||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=d6870a7fb7f6cccff63f7fd0ff6c581bad80e983#d6870a7fb7f6cccff63f7fd0ff6c581bad80e983"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"as_variant",
|
"as_variant",
|
||||||
"indexmap 2.9.0",
|
"indexmap 2.9.0",
|
||||||
|
@ -3807,7 +3802,6 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruma-federation-api"
|
name = "ruma-federation-api"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=d6870a7fb7f6cccff63f7fd0ff6c581bad80e983#d6870a7fb7f6cccff63f7fd0ff6c581bad80e983"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"headers",
|
"headers",
|
||||||
|
@ -3829,7 +3823,6 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruma-identifiers-validation"
|
name = "ruma-identifiers-validation"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=d6870a7fb7f6cccff63f7fd0ff6c581bad80e983#d6870a7fb7f6cccff63f7fd0ff6c581bad80e983"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js_int",
|
"js_int",
|
||||||
"thiserror 2.0.12",
|
"thiserror 2.0.12",
|
||||||
|
@ -3838,7 +3831,6 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruma-identity-service-api"
|
name = "ruma-identity-service-api"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=d6870a7fb7f6cccff63f7fd0ff6c581bad80e983#d6870a7fb7f6cccff63f7fd0ff6c581bad80e983"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js_int",
|
"js_int",
|
||||||
"ruma-common",
|
"ruma-common",
|
||||||
|
@ -3848,7 +3840,6 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruma-macros"
|
name = "ruma-macros"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=d6870a7fb7f6cccff63f7fd0ff6c581bad80e983#d6870a7fb7f6cccff63f7fd0ff6c581bad80e983"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"proc-macro-crate",
|
"proc-macro-crate",
|
||||||
|
@ -3863,7 +3854,6 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruma-push-gateway-api"
|
name = "ruma-push-gateway-api"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=d6870a7fb7f6cccff63f7fd0ff6c581bad80e983#d6870a7fb7f6cccff63f7fd0ff6c581bad80e983"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js_int",
|
"js_int",
|
||||||
"ruma-common",
|
"ruma-common",
|
||||||
|
@ -3875,7 +3865,6 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruma-signatures"
|
name = "ruma-signatures"
|
||||||
version = "0.15.0"
|
version = "0.15.0"
|
||||||
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=d6870a7fb7f6cccff63f7fd0ff6c581bad80e983#d6870a7fb7f6cccff63f7fd0ff6c581bad80e983"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
|
|
|
@ -348,9 +348,9 @@ version = "0.1.2"
|
||||||
|
|
||||||
# Used for matrix spec type definitions and helpers
|
# Used for matrix spec type definitions and helpers
|
||||||
[workspace.dependencies.ruma]
|
[workspace.dependencies.ruma]
|
||||||
git = "https://forgejo.ellis.link/continuwuation/ruwuma"
|
#git = "https://forgejo.ellis.link/continuwuation/ruwuma"
|
||||||
#branch = "conduwuit-changes"
|
#rev = "b1a55ab8fa3d2e3db3240d04339835f71cfc84d4"
|
||||||
rev = "d6870a7fb7f6cccff63f7fd0ff6c581bad80e983"
|
path = "../ruwuma/crates/ruma" # nex: temp
|
||||||
features = [
|
features = [
|
||||||
"compat",
|
"compat",
|
||||||
"rand",
|
"rand",
|
||||||
|
|
|
@ -5,7 +5,7 @@ use futures::{
|
||||||
future::{OptionFuture, join3},
|
future::{OptionFuture, join3},
|
||||||
};
|
};
|
||||||
use ruma::{
|
use ruma::{
|
||||||
Int, OwnedUserId, RoomVersionId, UserId,
|
EventId, Int, OwnedUserId, RoomVersionId, UserId,
|
||||||
events::room::{
|
events::room::{
|
||||||
create::RoomCreateEventContent,
|
create::RoomCreateEventContent,
|
||||||
join_rules::{JoinRule, RoomJoinRulesEventContent},
|
join_rules::{JoinRule, RoomJoinRulesEventContent},
|
||||||
|
@ -56,6 +56,7 @@ pub fn auth_types_for_event(
|
||||||
sender: &UserId,
|
sender: &UserId,
|
||||||
state_key: Option<&str>,
|
state_key: Option<&str>,
|
||||||
content: &RawJsonValue,
|
content: &RawJsonValue,
|
||||||
|
room_version: &RoomVersion,
|
||||||
) -> serde_json::Result<Vec<(StateEventType, StateKey)>> {
|
) -> serde_json::Result<Vec<(StateEventType, StateKey)>> {
|
||||||
if kind == &TimelineEventType::RoomCreate {
|
if kind == &TimelineEventType::RoomCreate {
|
||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
|
@ -64,8 +65,11 @@ pub fn auth_types_for_event(
|
||||||
let mut auth_types = vec![
|
let mut auth_types = vec![
|
||||||
(StateEventType::RoomPowerLevels, StateKey::new()),
|
(StateEventType::RoomPowerLevels, StateKey::new()),
|
||||||
(StateEventType::RoomMember, sender.as_str().into()),
|
(StateEventType::RoomMember, sender.as_str().into()),
|
||||||
(StateEventType::RoomCreate, StateKey::new()),
|
|
||||||
];
|
];
|
||||||
|
if !room_version.create_id_as_room_id {
|
||||||
|
auth_types.push((StateEventType::RoomCreate, StateKey::new()))
|
||||||
|
// m.room.create is only referenced if it isn't the room ID
|
||||||
|
}
|
||||||
|
|
||||||
if kind == &TimelineEventType::RoomMember {
|
if kind == &TimelineEventType::RoomMember {
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
@ -136,14 +140,16 @@ pub fn auth_types_for_event(
|
||||||
event_id = incoming_event.event_id().as_str(),
|
event_id = incoming_event.event_id().as_str(),
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub async fn auth_check<F, Fut, Fetched, Incoming>(
|
pub async fn auth_check<FS, FE, Fut, Fetched, Incoming>(
|
||||||
room_version: &RoomVersion,
|
room_version: &RoomVersion,
|
||||||
incoming_event: &Incoming,
|
incoming_event: &Incoming,
|
||||||
current_third_party_invite: Option<&Incoming>,
|
current_third_party_invite: Option<&Incoming>,
|
||||||
fetch_state: F,
|
fetch_state: FS,
|
||||||
|
fetch_event: FE,
|
||||||
) -> Result<bool, Error>
|
) -> Result<bool, Error>
|
||||||
where
|
where
|
||||||
F: Fn(&StateEventType, &str) -> Fut + Send,
|
FS: Fn(&StateEventType, &str) -> Fut + Send,
|
||||||
|
FE: Fn(&EventId) -> Fut + Send,
|
||||||
Fut: Future<Output = Option<Fetched>> + Send,
|
Fut: Future<Output = Option<Fetched>> + Send,
|
||||||
Fetched: Event + Send,
|
Fetched: Event + Send,
|
||||||
Incoming: Event + Send + Sync,
|
Incoming: Event + Send + Sync,
|
||||||
|
@ -183,15 +189,19 @@ where
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the domain of the room_id does not match the domain of the sender, reject
|
if room_version.create_id_as_room_id {
|
||||||
let Some(room_id_server_name) = incoming_event.room_id().server_name() else {
|
let expected =
|
||||||
warn!("room ID has no servername");
|
format!("!{}:{}", incoming_event.event_id().localpart(), sender.server_name());
|
||||||
return Ok(false);
|
if incoming_event.room_id().as_str() != expected {
|
||||||
};
|
warn!("room create included a room ID that does not match the event ID");
|
||||||
|
return Ok(false);
|
||||||
if room_id_server_name != sender.server_name() {
|
}
|
||||||
warn!("servername of room ID does not match servername of sender");
|
} else {
|
||||||
return Ok(false);
|
// If the domain of the room_id does not match the domain of the sender, reject
|
||||||
|
let Some(_room_id_server_name) = incoming_event.room_id().server_name() else {
|
||||||
|
warn!("room ID has no servername");
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// If content.room_version is present and is not a recognized version, reject
|
// If content.room_version is present and is not a recognized version, reject
|
||||||
|
@ -241,16 +251,39 @@ where
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let (room_create_event, power_levels_event, sender_member_event) = join3(
|
let (mut room_create_event, power_levels_event, sender_member_event) = join3(
|
||||||
fetch_state(&StateEventType::RoomCreate, ""),
|
fetch_state(&StateEventType::RoomCreate, ""),
|
||||||
fetch_state(&StateEventType::RoomPowerLevels, ""),
|
fetch_state(&StateEventType::RoomPowerLevels, ""),
|
||||||
fetch_state(&StateEventType::RoomMember, sender.as_str()),
|
fetch_state(&StateEventType::RoomMember, sender.as_str()),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let room_create_event = match room_create_event {
|
if room_version.create_id_as_room_id {
|
||||||
|
// TODO: fetch the create event from the room ID
|
||||||
|
let create_event_id = &EventId::parse(incoming_event.room_id().localpart());
|
||||||
|
// if let Err(e) = create_event_id {
|
||||||
|
// error!(?e, "invalid room ID for create event");
|
||||||
|
// return Ok(false);
|
||||||
|
// }
|
||||||
|
// room_create_event = fetch_event(create_event_id).await;
|
||||||
|
match create_event_id {
|
||||||
|
| Ok(id) => {
|
||||||
|
room_create_event = fetch_event(id).await;
|
||||||
|
if room_create_event.is_none() {
|
||||||
|
warn!("could not find m.room.create event for PDU");
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
room_create_event = room_create_event;
|
||||||
|
},
|
||||||
|
| Err(e) => {
|
||||||
|
error!(?e, "invalid room ID for create event");
|
||||||
|
return Ok(false);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let real_room_create_event = match room_create_event {
|
||||||
| None => {
|
| None => {
|
||||||
warn!("no m.room.create event in auth chain");
|
warn!("could not find an applicable m.room.create event for PDU");
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
},
|
},
|
||||||
| Some(e) => e,
|
| Some(e) => e,
|
||||||
|
@ -259,7 +292,8 @@ where
|
||||||
// 3. If event does not have m.room.create in auth_events reject
|
// 3. If event does not have m.room.create in auth_events reject
|
||||||
if !incoming_event
|
if !incoming_event
|
||||||
.auth_events()
|
.auth_events()
|
||||||
.any(|id| id == room_create_event.event_id())
|
.any(|id| id == real_room_create_event.event_id())
|
||||||
|
&& !room_version.create_id_as_room_id
|
||||||
{
|
{
|
||||||
warn!("no m.room.create event in auth events");
|
warn!("no m.room.create event in auth events");
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
|
@ -275,9 +309,9 @@ where
|
||||||
federate: bool,
|
federate: bool,
|
||||||
}
|
}
|
||||||
let room_create_content: RoomCreateContentFederate =
|
let room_create_content: RoomCreateContentFederate =
|
||||||
from_json_str(room_create_event.content().get())?;
|
from_json_str(real_room_create_event.content().get())?;
|
||||||
if !room_create_content.federate
|
if !room_create_content.federate
|
||||||
&& room_create_event.sender().server_name() != incoming_event.sender().server_name()
|
&& real_room_create_event.sender().server_name() != incoming_event.sender().server_name()
|
||||||
{
|
{
|
||||||
warn!(
|
warn!(
|
||||||
"room is not federated and event's sender domain does not match create event's \
|
"room is not federated and event's sender domain does not match create event's \
|
||||||
|
@ -362,7 +396,7 @@ where
|
||||||
join_rules_event.as_ref(),
|
join_rules_event.as_ref(),
|
||||||
user_for_join_auth.as_deref(),
|
user_for_join_auth.as_deref(),
|
||||||
&user_for_join_auth_membership,
|
&user_for_join_auth_membership,
|
||||||
&room_create_event,
|
&real_room_create_event,
|
||||||
)? {
|
)? {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
@ -411,10 +445,10 @@ where
|
||||||
| _ => {
|
| _ => {
|
||||||
// If no power level event found the creator gets 100 everyone else gets 0
|
// If no power level event found the creator gets 100 everyone else gets 0
|
||||||
let is_creator = if room_version.use_room_create_sender {
|
let is_creator = if room_version.use_room_create_sender {
|
||||||
room_create_event.sender() == sender
|
real_room_create_event.sender() == sender
|
||||||
} else {
|
} else {
|
||||||
#[allow(deprecated)]
|
#[allow(deprecated)]
|
||||||
from_json_str::<RoomCreateEventContent>(room_create_event.content().get())
|
from_json_str::<RoomCreateEventContent>(real_room_create_event.content().get())
|
||||||
.is_ok_and(|create| create.creator.unwrap() == *sender)
|
.is_ok_and(|create| create.creator.unwrap() == *sender)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -29,6 +29,8 @@ pub enum StateResolutionVersion {
|
||||||
V1,
|
V1,
|
||||||
/// State resolution for room at version 2 or later.
|
/// State resolution for room at version 2 or later.
|
||||||
V2,
|
V2,
|
||||||
|
/// State resolution for hydra rooms
|
||||||
|
V2_1,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
|
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
|
||||||
|
@ -80,9 +82,29 @@ pub struct RoomVersion {
|
||||||
///
|
///
|
||||||
/// See: [MSC2175](https://github.com/matrix-org/matrix-spec-proposals/pull/2175) for more information.
|
/// See: [MSC2175](https://github.com/matrix-org/matrix-spec-proposals/pull/2175) for more information.
|
||||||
pub use_room_create_sender: bool,
|
pub use_room_create_sender: bool,
|
||||||
|
/// Whether the room creator is a superuser.
|
||||||
|
/// A superuser will always have infinite power level and gains special
|
||||||
|
/// privileges.
|
||||||
|
///
|
||||||
|
/// See: [MSC4289](https://github.com/matrix-org/matrix-spec-proposals/pull/4289) for more information.
|
||||||
|
pub room_creator_is_superuser: bool,
|
||||||
|
/// Whether the room version supports estoppel events.
|
||||||
|
///
|
||||||
|
/// See: [MSC4290](https://github.com/matrix-org/matrix-spec-proposals/pull/4290)
|
||||||
|
pub estoppel_events: bool,
|
||||||
|
/// Whether the room's m.room.create event ID is itself the room ID.
|
||||||
|
///
|
||||||
|
/// See: [MSC4291](https://github.com/matrix-org/matrix-spec-proposals/pull/4291)
|
||||||
|
pub create_id_as_room_id: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RoomVersion {
|
impl RoomVersion {
|
||||||
|
pub const HYDRA_V11: Self = Self {
|
||||||
|
room_creator_is_superuser: true,
|
||||||
|
estoppel_events: true,
|
||||||
|
create_id_as_room_id: true,
|
||||||
|
..Self::V11
|
||||||
|
};
|
||||||
pub const V1: Self = Self {
|
pub const V1: Self = Self {
|
||||||
disposition: RoomDisposition::Stable,
|
disposition: RoomDisposition::Stable,
|
||||||
event_format: EventFormatVersion::V1,
|
event_format: EventFormatVersion::V1,
|
||||||
|
@ -97,6 +119,9 @@ impl RoomVersion {
|
||||||
knock_restricted_join_rule: false,
|
knock_restricted_join_rule: false,
|
||||||
integer_power_levels: false,
|
integer_power_levels: false,
|
||||||
use_room_create_sender: false,
|
use_room_create_sender: false,
|
||||||
|
room_creator_is_superuser: false,
|
||||||
|
estoppel_events: false,
|
||||||
|
create_id_as_room_id: false,
|
||||||
};
|
};
|
||||||
pub const V10: Self = Self {
|
pub const V10: Self = Self {
|
||||||
knock_restricted_join_rule: true,
|
knock_restricted_join_rule: true,
|
||||||
|
@ -144,6 +169,7 @@ impl RoomVersion {
|
||||||
| RoomVersionId::V9 => Self::V9,
|
| RoomVersionId::V9 => Self::V9,
|
||||||
| RoomVersionId::V10 => Self::V10,
|
| RoomVersionId::V10 => Self::V10,
|
||||||
| RoomVersionId::V11 => Self::V11,
|
| RoomVersionId::V11 => Self::V11,
|
||||||
|
| RoomVersionId::HydraV11 => Self::HYDRA_V11,
|
||||||
| ver => return Err(Error::Unsupported(format!("found version `{ver}`"))),
|
| ver => return Err(Error::Unsupported(format!("found version `{ver}`"))),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -130,12 +130,17 @@ pub(super) async fn handle_outlier_pdu<'a>(
|
||||||
let key = (ty.to_owned(), sk.into());
|
let key = (ty.to_owned(), sk.into());
|
||||||
ready(auth_events.get(&key))
|
ready(auth_events.get(&key))
|
||||||
};
|
};
|
||||||
|
let event_fetch = |id: &EventId| {
|
||||||
|
let id = id.to_owned();
|
||||||
|
Box::pin(self.services.timeline.get_pdu(&id))
|
||||||
|
};
|
||||||
|
|
||||||
let auth_check = state_res::event_auth::auth_check(
|
let auth_check = state_res::event_auth::auth_check(
|
||||||
&to_room_version(&room_version_id),
|
&to_room_version(&room_version_id),
|
||||||
&incoming_pdu,
|
&incoming_pdu,
|
||||||
None, // TODO: third party invite
|
None, // TODO: third party invite
|
||||||
state_fetch,
|
state_fetch,
|
||||||
|
event_fetch,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| err!(Request(Forbidden("Auth check failed: {e:?}"))))?;
|
.map_err(|e| err!(Request(Forbidden("Auth check failed: {e:?}"))))?;
|
||||||
|
|
Loading…
Add table
Reference in a new issue