forked from Keelrleek/SoulStream
spaces: a member wears a key minted for that room alone, so a roster stops naming personas
This commit is contained in:
1 parent
b3c121fa94
commit
67e52697b2
6 files changed
+462
-58
No files matched your search
+284
-47
@@ -24,7 +24,7 @@ use persona_state::space as sp;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
use crate::inbox::{self, Cargo, SpaceInvite, SpaceKeyCargo};
|
||||
use crate::inbox::{self, Cargo, SpaceInvite, SpaceJoin, SpaceKeyCargo};
|
||||
use crate::ledger;
|
||||
use crate::persona;
|
||||
use crate::slsk_link;
|
||||
@@ -46,6 +46,21 @@ struct SpaceRecord {
|
||||
keys: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
last_epoch: u64,
|
||||
/// The key we wear in THIS Space (b58 secret), minted on the invitation.
|
||||
/// A member is on the roster under this and nothing else, so the roster
|
||||
/// says nothing about who they are anywhere else. Absent for a Space we
|
||||
/// own: an owner is their persona there by construction, since the
|
||||
/// Space's address is derived from it.
|
||||
#[serde(default)]
|
||||
room_secret: Option<String>,
|
||||
/// OWNER ONLY, and never published: room key (b58) → the persona that
|
||||
/// proved it. This is the mapping the whole scheme exists to keep off the
|
||||
/// network, so it lives here and travels nowhere.
|
||||
#[serde(default)]
|
||||
roster: BTreeMap<String, String>,
|
||||
/// OWNER ONLY: personas we have invited and will accept a join from.
|
||||
#[serde(default)]
|
||||
invited: BTreeMap<String, u64>,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
@@ -190,6 +205,46 @@ async fn my_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> {
|
||||
.ok_or("create a SoulStream identity first")?;
|
||||
key_of(&info.public_key)
|
||||
}
|
||||
/// The secret we sign with inside one Space, minted the first time we need
|
||||
/// it. Custody matches the inbox box key: it sits in the app folder, not in
|
||||
/// the delegate — a persona restored on another device mints fresh room keys
|
||||
/// and has to be re-admitted.
|
||||
fn room_secret_of(rec: &SpaceRecord) -> Option<[u8; 32]> {
|
||||
rec.room_secret.as_deref().and_then(|k| key_of(k).ok())
|
||||
}
|
||||
|
||||
/// The key we wear in this Space: our persona when we own it, our room key
|
||||
/// otherwise. Every membership and authorship check is against THIS.
|
||||
fn worn_key(rec: &SpaceRecord, me: &[u8; 32]) -> [u8; 32] {
|
||||
if rec.my_role == "owner" {
|
||||
return *me;
|
||||
}
|
||||
room_secret_of(rec).map(|s| sp::member_public(&s)).unwrap_or(*me)
|
||||
}
|
||||
|
||||
/// The identity to use inside one Space: the key we wear, and the room
|
||||
/// secret behind it when that is not our persona. The proposal board rides a
|
||||
/// Space's roster, so it has to author under exactly the same key.
|
||||
pub(crate) fn worn_identity(
|
||||
app: &tauri::AppHandle,
|
||||
owner_b58: &str,
|
||||
space_b58: &str,
|
||||
me: &[u8; 32],
|
||||
) -> ([u8; 32], Option<[u8; 32]>) {
|
||||
let l = load(app);
|
||||
match l.spaces.get(&record_id(owner_b58, space_b58)) {
|
||||
Some(rec) if rec.my_role != "owner" => (worn_key(rec, me), room_secret_of(rec)),
|
||||
_ => (*me, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The persona behind a room key, when this device is the one that seated it.
|
||||
/// Nobody else can answer this, which is the point.
|
||||
pub(crate) fn persona_behind(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, room: &str) -> Option<String> {
|
||||
let l = load(app);
|
||||
l.spaces.get(&record_id(owner_b58, space_b58))?.roster.get(room).cloned()
|
||||
}
|
||||
|
||||
async fn sign(app: &tauri::AppHandle, payload: Vec<u8>) -> Result<Vec<u8>, String> {
|
||||
let adapter = app.state::<AppState>().freenet.clone();
|
||||
persona::sign(&adapter, sp::SIGN_DOMAIN, payload).await
|
||||
@@ -269,7 +324,13 @@ fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me
|
||||
sp::Role::Owner => "owner".into(),
|
||||
sp::Role::Member => "member".into(),
|
||||
},
|
||||
username: slsk_link::username_for_key(app, &b58(&m.key)),
|
||||
// Only the owner holds room key → persona, so only the owner
|
||||
// can put a name to a member. To everyone else they are the
|
||||
// key they wear here, which is the whole point.
|
||||
username: slsk_link::username_for_key(
|
||||
app,
|
||||
rec.roster.get(&b58(&m.key)).map(String::as_str).unwrap_or(&b58(&m.key)),
|
||||
),
|
||||
is_me: &m.key == me,
|
||||
since_epoch: m.since_epoch,
|
||||
})
|
||||
@@ -288,7 +349,10 @@ fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me
|
||||
MessageView {
|
||||
id: b58(&m.msg_id),
|
||||
author: b58(&m.author),
|
||||
username: slsk_link::username_for_key(app, &b58(&m.author)),
|
||||
username: slsk_link::username_for_key(
|
||||
app,
|
||||
rec.roster.get(&b58(&m.author)).map(String::as_str).unwrap_or(&b58(&m.author)),
|
||||
),
|
||||
is_me: &m.author == me,
|
||||
created_at: m.created_at,
|
||||
epoch: m.epoch,
|
||||
@@ -366,13 +430,16 @@ pub async fn create(app: &tauri::AppHandle, name: &str, description: &str, priva
|
||||
joined_at: now_secs(),
|
||||
keys,
|
||||
last_epoch: 1,
|
||||
room_secret: None,
|
||||
roster: BTreeMap::new(),
|
||||
invited: BTreeMap::new(),
|
||||
};
|
||||
let _ledger = ledger::hold(LEDGER_FILE).await;
|
||||
let mut l = load(app);
|
||||
l.spaces.insert(record_id(&rec.owner, &rec.space_id), rec.clone());
|
||||
save(app, &l)?;
|
||||
log::info!("spaces: created {:?} ({})", rec.name, if private { "private" } else { "public" });
|
||||
Ok(view_of(app, &rec, &state, &me, true))
|
||||
Ok(view_of(app, &rec, &state, &worn_key(&rec, &me), true))
|
||||
}
|
||||
|
||||
pub async fn open(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<SpaceView, String> {
|
||||
@@ -403,7 +470,7 @@ pub async fn open(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> R
|
||||
}
|
||||
}
|
||||
let rec = l.spaces.get(&rid).cloned().unwrap_or(rec);
|
||||
Ok(view_of(app, &rec, &state, &me, reachable))
|
||||
Ok(view_of(app, &rec, &state, &worn_key(&rec, &me), reachable))
|
||||
}
|
||||
|
||||
pub async fn post(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, text: &str) -> Result<(), String> {
|
||||
@@ -419,29 +486,42 @@ pub async fn post(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, text
|
||||
}
|
||||
let (state, _) = fetch_state(app, &owner, &space_id).await?;
|
||||
let c = state.checkpoint.as_ref().ok_or("the Space has not reached this node yet")?;
|
||||
if !c.members.iter().any(|m| m.key == me) {
|
||||
let (worn, room, epoch_key) = {
|
||||
let l = load(app);
|
||||
let rec = l.spaces.get(&record_id(owner_b58, space_b58)).ok_or("not a Space this device knows")?;
|
||||
let epoch_key = if c.private {
|
||||
Some(
|
||||
rec.keys
|
||||
.get(&c.epoch.to_string())
|
||||
.and_then(|k| key_of(k).ok())
|
||||
.ok_or("no key for this epoch yet — the owner has to hand it to you")?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(worn_key(rec, &me), room_secret_of(rec), epoch_key)
|
||||
};
|
||||
if !c.members.iter().any(|m| m.key == worn) {
|
||||
return Err("you are not in this Space's roster".into());
|
||||
}
|
||||
let mut m = sp::Message {
|
||||
space_id,
|
||||
epoch: c.epoch,
|
||||
msg_id: rand16(),
|
||||
author: me,
|
||||
author: worn,
|
||||
created_at: now_secs(),
|
||||
body: text.as_bytes().to_vec(),
|
||||
sig: vec![],
|
||||
};
|
||||
if c.private {
|
||||
let l = load(app);
|
||||
let rec = l.spaces.get(&record_id(owner_b58, space_b58)).ok_or("not a Space this device knows")?;
|
||||
let key = rec
|
||||
.keys
|
||||
.get(&c.epoch.to_string())
|
||||
.and_then(|k| key_of(k).ok())
|
||||
.ok_or("no key for this epoch yet — the owner has to hand it to you")?;
|
||||
if let Some(key) = epoch_key {
|
||||
m.body = sp::seal_body(&key, &m);
|
||||
}
|
||||
m.sig = sign(app, sp::message_sign_payload(&m)).await?;
|
||||
// A member signs with the key they wear; only an owner reaches the
|
||||
// delegate, because only an owner is their persona here.
|
||||
m.sig = match room {
|
||||
Some(secret) if worn != me => sp::sign_as_member(&secret, &sp::message_sign_payload(&m)),
|
||||
_ => sign(app, sp::message_sign_payload(&m)).await?,
|
||||
};
|
||||
offer(app, &owner, &space_id, &empty_state(space_id, None, vec![m]), false).await
|
||||
}
|
||||
|
||||
@@ -473,25 +553,34 @@ pub async fn invite(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, pe
|
||||
if !inbox::reachable(app, &their).await {
|
||||
return Err("they have not published an encrypted inbox — the invitation has nowhere to go".into());
|
||||
}
|
||||
let c = bump(app, me, owner, space_id, |c| {
|
||||
if !c.members.iter().any(|m| m.key == their) {
|
||||
let e = c.epoch;
|
||||
c.members.push(sp::Member { key: their, role: sp::Role::Member, since_epoch: e });
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
// Nobody joins the roster here any more. We do not yet know the key they
|
||||
// will wear — they mint it — so the invitation is an offer, and the roster
|
||||
// moves when their signed answer comes back (on_inbox_join).
|
||||
let (state, _) = fetch_state(app, &owner, &space_id).await?;
|
||||
let c = state.checkpoint.ok_or("the Space has not reached this node yet")?;
|
||||
if c.owner != me {
|
||||
return Err("only the owner can invite".into());
|
||||
}
|
||||
let rid = record_id(owner_b58, space_b58);
|
||||
let ledger = ledger::hold(LEDGER_FILE).await;
|
||||
let mut l = load(app);
|
||||
let rec = l.spaces.get_mut(&rid).ok_or("not a Space this device knows")?;
|
||||
let mut key = None;
|
||||
if c.private {
|
||||
let prev = rec.keys.get(&(c.epoch - 1).to_string()).cloned().or_else(|| rec.keys.values().last().cloned());
|
||||
let k = prev.unwrap_or_else(|| b58(&rand32()));
|
||||
// They need this to read while they answer; adding them does not
|
||||
// rotate, so the current epoch's key is the right one to hand over.
|
||||
let k = rec
|
||||
.keys
|
||||
.get(&c.epoch.to_string())
|
||||
.cloned()
|
||||
.or_else(|| rec.keys.values().last().cloned())
|
||||
.unwrap_or_else(|| b58(&rand32()));
|
||||
rec.keys.insert(c.epoch.to_string(), k.clone());
|
||||
key = Some(key_of(&k)?);
|
||||
}
|
||||
rec.last_epoch = c.epoch;
|
||||
// Only a persona we actually asked can join, or anyone who learned the
|
||||
// Space's address could put themselves on the roster.
|
||||
rec.invited.insert(b58(&their), now_secs());
|
||||
save(app, &l)?;
|
||||
inbox::send(
|
||||
app,
|
||||
@@ -499,7 +588,7 @@ pub async fn invite(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, pe
|
||||
&Cargo::SpaceInvite(SpaceInvite { space_id, owner, name: c.name.clone(), private: c.private, epoch: c.epoch, key }),
|
||||
)
|
||||
.await?;
|
||||
log::info!("spaces: invited {persona_key} to {:?} (epoch {})", c.name, c.epoch);
|
||||
log::info!("spaces: invited {persona_key} to {:?} — waiting for the key they will wear", c.name);
|
||||
// open() takes this same ledger; the write is done, so let go first.
|
||||
drop(ledger);
|
||||
open(app, owner_b58, space_b58).await
|
||||
@@ -509,10 +598,22 @@ pub async fn remove_member(app: &tauri::AppHandle, owner_b58: &str, space_b58: &
|
||||
let me = my_key(app).await?;
|
||||
let owner = key_of(owner_b58)?;
|
||||
let space_id = id_of(space_b58)?;
|
||||
let gone = key_of(persona_key)?;
|
||||
if gone == me {
|
||||
let asked = key_of(persona_key)?;
|
||||
if asked == me {
|
||||
return Err("the owner cannot be removed".into());
|
||||
}
|
||||
// The caller names a person — a persona key, or the room key the UI shows.
|
||||
// Only the owner's private map can turn one into the other, which is the
|
||||
// point; a Space seated before room keys still holds personas directly.
|
||||
let gone = {
|
||||
let l = load(app);
|
||||
let rec = l.spaces.get(&record_id(owner_b58, space_b58)).ok_or("not a Space this device knows")?;
|
||||
rec.roster
|
||||
.iter()
|
||||
.find(|(_, persona)| persona.as_str() == persona_key)
|
||||
.and_then(|(room, _)| key_of(room).ok())
|
||||
.unwrap_or(asked)
|
||||
};
|
||||
let c = bump(app, me, owner, space_id, |c| c.members.retain(|m| m.key != gone)).await?;
|
||||
let rid = record_id(owner_b58, space_b58);
|
||||
let ledger = ledger::hold(LEDGER_FILE).await;
|
||||
@@ -569,28 +670,113 @@ pub(crate) async fn on_inbox_invite(app: &tauri::AppHandle, sender: [u8; 32], in
|
||||
return;
|
||||
}
|
||||
let rid = record_id(&b58(&inv.owner), &b58(&inv.space_id));
|
||||
let room = {
|
||||
let _ledger = ledger::hold(LEDGER_FILE).await;
|
||||
let mut l = load(app);
|
||||
let rec = l.spaces.entry(rid).or_insert_with(|| SpaceRecord {
|
||||
owner: b58(&inv.owner),
|
||||
space_id: b58(&inv.space_id),
|
||||
name: cap(&inv.name, sp::MAX_NAME_BYTES),
|
||||
private: inv.private,
|
||||
my_role: "member".into(),
|
||||
joined_at: now_secs(),
|
||||
keys: BTreeMap::new(),
|
||||
last_epoch: 0,
|
||||
room_secret: None,
|
||||
roster: BTreeMap::new(),
|
||||
invited: BTreeMap::new(),
|
||||
});
|
||||
rec.name = cap(&inv.name, sp::MAX_NAME_BYTES);
|
||||
rec.private = inv.private;
|
||||
if let Some(k) = inv.key {
|
||||
rec.keys.insert(inv.epoch.to_string(), b58(&k));
|
||||
}
|
||||
// One key per Space, minted once and kept. Re-minting on a second
|
||||
// invitation would strand the roster entry we already have.
|
||||
let secret = match room_secret_of(rec) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let s = rand32();
|
||||
rec.room_secret = Some(b58(&s));
|
||||
s
|
||||
}
|
||||
};
|
||||
if let Err(e) = save(app, &l) {
|
||||
log::warn!("spaces: could not keep an invitation: {e}");
|
||||
return;
|
||||
}
|
||||
secret
|
||||
};
|
||||
log::info!("spaces: invited to {:?} by {}", inv.name, b58(&inv.owner));
|
||||
// The answer: the key we will wear, and a persona signature so the owner
|
||||
// knows it is us. Sealed to the owner — this pairing is exactly what must
|
||||
// never be published.
|
||||
let room_key = sp::member_public(&room);
|
||||
let proof = match sign(app, sp::join_proof_payload(&inv.space_id, &inv.owner, &room_key)).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
log::warn!("spaces: could not sign the join for {:?}: {e}", inv.name);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let join = Cargo::SpaceJoin(SpaceJoin { space_id: inv.space_id, owner: inv.owner, room_key, proof });
|
||||
if let Err(e) = inbox::send(app, &inv.owner, &join).await {
|
||||
log::warn!("spaces: could not answer the invitation to {:?}: {e}", inv.name);
|
||||
}
|
||||
}
|
||||
|
||||
/// The owner's side of the handshake: someone we invited says which key they
|
||||
/// will wear. Verify it is really them, then put THAT key — and only that key
|
||||
/// — on the published roster.
|
||||
pub(crate) async fn on_inbox_join(app: &tauri::AppHandle, sender: [u8; 32], j: SpaceJoin) {
|
||||
let rid = record_id(&b58(&j.owner), &b58(&j.space_id));
|
||||
let owner_b58 = b58(&j.owner);
|
||||
let space_b58 = b58(&j.space_id);
|
||||
{
|
||||
let _ledger = ledger::hold(LEDGER_FILE).await;
|
||||
let l = load(app);
|
||||
let Some(rec) = l.spaces.get(&rid) else { return };
|
||||
if rec.my_role != "owner" {
|
||||
return;
|
||||
}
|
||||
if !rec.invited.contains_key(&b58(&sender)) {
|
||||
log::warn!("spaces: a join arrived from someone this Space never invited");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if !sp::join_proof_valid(&j.space_id, &j.owner, &j.room_key, &sender, &j.proof) {
|
||||
log::warn!("spaces: a join did not prove the room key belongs to its sender");
|
||||
return;
|
||||
}
|
||||
let me = match my_key(app).await {
|
||||
Ok(k) => k,
|
||||
Err(_) => return,
|
||||
};
|
||||
let c = match bump(app, me, j.owner, j.space_id, |c| {
|
||||
if !c.members.iter().any(|m| m.key == j.room_key) {
|
||||
let e = c.epoch;
|
||||
c.members.push(sp::Member { key: j.room_key, role: sp::Role::Member, since_epoch: e });
|
||||
}
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!("spaces: could not seat a joiner: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ledger = ledger::hold(LEDGER_FILE).await;
|
||||
let mut l = load(app);
|
||||
let rec = l.spaces.entry(rid).or_insert_with(|| SpaceRecord {
|
||||
owner: b58(&inv.owner),
|
||||
space_id: b58(&inv.space_id),
|
||||
name: cap(&inv.name, sp::MAX_NAME_BYTES),
|
||||
private: inv.private,
|
||||
my_role: "member".into(),
|
||||
joined_at: now_secs(),
|
||||
keys: BTreeMap::new(),
|
||||
last_epoch: 0,
|
||||
});
|
||||
rec.name = cap(&inv.name, sp::MAX_NAME_BYTES);
|
||||
rec.private = inv.private;
|
||||
if let Some(k) = inv.key {
|
||||
rec.keys.insert(inv.epoch.to_string(), b58(&k));
|
||||
}
|
||||
let Some(rec) = l.spaces.get_mut(&rid) else { return };
|
||||
// Who is behind the key, kept here and nowhere else.
|
||||
rec.roster.insert(b58(&j.room_key), b58(&sender));
|
||||
rec.invited.remove(&b58(&sender));
|
||||
rec.last_epoch = c.epoch;
|
||||
if let Err(e) = save(app, &l) {
|
||||
log::warn!("spaces: could not keep an invitation: {e}");
|
||||
} else {
|
||||
log::info!("spaces: invited to {:?} by {}", inv.name, b58(&inv.owner));
|
||||
log::warn!("spaces: seated a joiner but could not record it: {e}");
|
||||
}
|
||||
log::info!("spaces: seated a member in {:?} at epoch {} ({owner_b58}/{space_b58})", c.name, c.epoch);
|
||||
}
|
||||
|
||||
pub(crate) async fn on_inbox_space_key(app: &tauri::AppHandle, sender: [u8; 32], sk: SpaceKeyCargo) {
|
||||
@@ -644,6 +830,57 @@ pub async fn space_leave(app: tauri::AppHandle, owner: String, space: String) ->
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rec(role: &str, secret: Option<[u8; 32]>) -> SpaceRecord {
|
||||
SpaceRecord {
|
||||
owner: b58(&[1u8; 32]),
|
||||
space_id: b58(&[2u8; 16]),
|
||||
name: "Late Night Ambient".into(),
|
||||
private: true,
|
||||
my_role: role.into(),
|
||||
joined_at: 0,
|
||||
keys: BTreeMap::new(),
|
||||
last_epoch: 1,
|
||||
room_secret: secret.map(|s| b58(&s)),
|
||||
roster: BTreeMap::new(),
|
||||
invited: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Which key we wear where. An owner is their persona — the Space's
|
||||
/// address is derived from it, so there is nothing to hide there — and a
|
||||
/// member is the key minted for that Space and no other.
|
||||
#[test]
|
||||
fn a_member_wears_a_room_key_and_an_owner_wears_their_persona() {
|
||||
let me = [5u8; 32];
|
||||
assert_eq!(worn_key(&rec("owner", None), &me), me, "an owner is their persona here");
|
||||
assert_eq!(worn_key(&rec("owner", Some([9u8; 32])), &me), me, "even holding a room key");
|
||||
|
||||
let worn = worn_key(&rec("member", Some([9u8; 32])), &me);
|
||||
assert_eq!(worn, sp::member_public(&[9u8; 32]));
|
||||
assert_ne!(worn, me, "a member must not appear as their persona");
|
||||
|
||||
// Two Spaces, two keys: one roster cannot be correlated with another.
|
||||
assert_ne!(
|
||||
worn_key(&rec("member", Some([9u8; 32])), &me),
|
||||
worn_key(&rec("member", Some([8u8; 32])), &me),
|
||||
);
|
||||
// Before the mint there is nothing to wear but the persona, and the
|
||||
// roster check then simply fails rather than posting as one.
|
||||
assert_eq!(worn_key(&rec("member", None), &me), me);
|
||||
}
|
||||
|
||||
/// The mapping the design exists to keep off the network stays local, and
|
||||
/// is only ever answerable by the device that seated the member.
|
||||
#[test]
|
||||
fn only_the_seating_device_can_name_a_room_key() {
|
||||
let mut r = rec("owner", None);
|
||||
let room = b58(&sp::member_public(&[9u8; 32]));
|
||||
r.roster.insert(room.clone(), b58(&[7u8; 32]));
|
||||
assert_eq!(r.roster.get(&room).map(String::as_str), Some(b58(&[7u8; 32]).as_str()));
|
||||
// A record that never seated them knows nothing.
|
||||
assert!(rec("member", Some([9u8; 32])).roster.get(&room).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contract_generation_is_pinned() {
|
||||
let ids = FreenetAdapter::contract_ids(SPACE_WASM.to_vec(), &sp::make_params(&[7u8; 32], &[1u8; 16]));
|
||||
|
||||
Reference in new issue
Block a user