community: spaces, an owner-signed roster with epochs, a bounded window of signed lines, bodies sealed to a rotating key when private
This commit is contained in:
1 parent
4378c4c0cc
commit
8b511b20e7
14 files changed
+2913
-13
No files matched your search
Binary file not shown.
@@ -49,6 +49,29 @@ pub enum Cargo {
|
||||
FriendAssertion(fr::FriendAssertion),
|
||||
FriendDecline([u8; 16]),
|
||||
Share(ShareCargo),
|
||||
SpaceInvite(SpaceInvite),
|
||||
SpaceKey(SpaceKeyCargo),
|
||||
}
|
||||
|
||||
/// "You are on the roster now, here is where the Space is" — and, for a
|
||||
/// private Space, the epoch's key.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SpaceInvite {
|
||||
pub space_id: [u8; 16],
|
||||
pub owner: [u8; 32],
|
||||
pub name: String,
|
||||
pub private: bool,
|
||||
pub epoch: u64,
|
||||
pub key: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
/// A rotated space key for members who stay after a removal.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SpaceKeyCargo {
|
||||
pub space_id: [u8; 16],
|
||||
pub owner: [u8; 32],
|
||||
pub epoch: u64,
|
||||
pub key: [u8; 32],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -410,6 +433,8 @@ async fn dispatch(app: &tauri::AppHandle, sender: [u8; 32], cargo: Cargo) {
|
||||
Cargo::FriendAssertion(a) => crate::friends::on_inbox_assertion(app, sender, a).await,
|
||||
Cargo::FriendDecline(id) => crate::friends::on_inbox_decline(app, sender, id),
|
||||
Cargo::Share(s) => crate::shared_playlists::on_inbox_share(app, sender, s),
|
||||
Cargo::SpaceInvite(i) => crate::spaces::on_inbox_invite(app, sender, i),
|
||||
Cargo::SpaceKey(k) => crate::spaces::on_inbox_space_key(app, sender, k),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ mod shared_playlists;
|
||||
mod shazam;
|
||||
mod slsk_link;
|
||||
mod smtc;
|
||||
mod spaces;
|
||||
mod state;
|
||||
mod thumbbar;
|
||||
mod vocal_sep;
|
||||
@@ -150,6 +151,14 @@ pub fn run() {
|
||||
inbox::inbox_status,
|
||||
inbox::inbox_publish,
|
||||
inbox::inbox_check,
|
||||
spaces::spaces_list,
|
||||
spaces::space_create,
|
||||
spaces::space_open,
|
||||
spaces::space_post,
|
||||
spaces::space_invite,
|
||||
spaces::space_remove_member,
|
||||
spaces::space_set_pinned,
|
||||
spaces::space_leave,
|
||||
commands::buddy_list,
|
||||
commands::buddy_add,
|
||||
commands::buddy_remove,
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
//! Spaces, product side (F6 decision → the app). One contract per Space at
|
||||
//! (owner, space id): the owner's signed CHECKPOINT is the authority —
|
||||
//! name, mode, roster, pinned playlist — with an epoch that moves on every
|
||||
//! roster change; members post signed MESSAGES into a bounded window; a
|
||||
//! private Space seals bodies to a per-epoch space key the owner hands out
|
||||
//! through members' encrypted inboxes and rotates on every removal.
|
||||
//! Invitations are owner-authored: to invite is to add the key to the
|
||||
//! roster and tell the person, sealed, where the Space is.
|
||||
//!
|
||||
//! What this device knows about its Spaces — which ones, its role, the
|
||||
//! epoch keys it was given — lives in `<app_data>/freenet/spaces.json`.
|
||||
//! Losing that file loses the keys; the owner can re-send them.
|
||||
//!
|
||||
//! v1 keeps members as their persona keys (a joined Space is a linked
|
||||
//! identity); the room-local member key the design describes for
|
||||
//! unlinkability is the next step, not a different contract.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use freenet_adapter::FreenetAdapter;
|
||||
use persona_state::space as sp;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
use crate::inbox::{self, Cargo, SpaceInvite, SpaceKeyCargo};
|
||||
use crate::persona;
|
||||
use crate::slsk_link;
|
||||
use crate::state::AppState;
|
||||
|
||||
const SPACE_WASM: &[u8] = include_bytes!("../assets/space_contract.wasm");
|
||||
pub const EVENT: &str = "community:spaces";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct SpaceRecord {
|
||||
owner: String,
|
||||
space_id: String,
|
||||
name: String,
|
||||
private: bool,
|
||||
my_role: String,
|
||||
joined_at: u64,
|
||||
/// epoch → space key (b58), for private Spaces.
|
||||
#[serde(default)]
|
||||
keys: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
last_epoch: u64,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct Ledger {
|
||||
#[serde(default)]
|
||||
spaces: BTreeMap<String, SpaceRecord>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SpaceListItem {
|
||||
pub id: String,
|
||||
pub owner: String,
|
||||
pub space_id: String,
|
||||
pub name: String,
|
||||
pub private: bool,
|
||||
pub my_role: String,
|
||||
pub last_epoch: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MemberView {
|
||||
pub key: String,
|
||||
pub role: String,
|
||||
pub username: Option<String>,
|
||||
pub is_me: bool,
|
||||
pub since_epoch: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MessageView {
|
||||
pub id: String,
|
||||
pub author: String,
|
||||
pub username: Option<String>,
|
||||
pub is_me: bool,
|
||||
pub created_at: u64,
|
||||
pub epoch: u64,
|
||||
/// None when sealed to a key this device does not hold.
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SpaceView {
|
||||
pub id: String,
|
||||
pub owner: String,
|
||||
pub space_id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub private: bool,
|
||||
pub epoch: u64,
|
||||
pub my_role: String,
|
||||
pub message_cap: u32,
|
||||
pub members: Vec<MemberView>,
|
||||
pub pinned_owner: Option<String>,
|
||||
pub pinned_uuid: Option<String>,
|
||||
pub messages: Vec<MessageView>,
|
||||
pub can_post: bool,
|
||||
pub has_key: bool,
|
||||
/// The contract exists on the network (a Space someone invited us to
|
||||
/// may not have propagated to this node yet).
|
||||
pub reachable: bool,
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
fn rand16() -> [u8; 16] {
|
||||
use rand::RngCore;
|
||||
let mut n = [0u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut n);
|
||||
n
|
||||
}
|
||||
fn rand32() -> [u8; 32] {
|
||||
use rand::RngCore;
|
||||
let mut n = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut n);
|
||||
n
|
||||
}
|
||||
fn b58(bytes: &[u8]) -> String {
|
||||
bs58::encode(bytes).into_string()
|
||||
}
|
||||
fn key_of(s: &str) -> Result<[u8; 32], String> {
|
||||
inbox::key_of(s)
|
||||
}
|
||||
fn id_of(s: &str) -> Result<[u8; 16], String> {
|
||||
bs58::decode(s)
|
||||
.into_vec()
|
||||
.map_err(|e| format!("bad id: {e}"))?
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| "id is not 16 bytes".to_string())
|
||||
}
|
||||
fn cap(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut end = max;
|
||||
while end > 0 && !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
s[..end].to_string()
|
||||
}
|
||||
fn record_id(owner: &str, space_id: &str) -> String {
|
||||
format!("{owner}:{space_id}")
|
||||
}
|
||||
|
||||
fn path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("freenet")
|
||||
.join("spaces.json"))
|
||||
}
|
||||
fn load(app: &tauri::AppHandle) -> Ledger {
|
||||
path(app)
|
||||
.ok()
|
||||
.and_then(|p| std::fs::read(p).ok())
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
fn save(app: &tauri::AppHandle, l: &Ledger) -> Result<(), String> {
|
||||
let p = path(app)?;
|
||||
if let Some(dir) = p.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(&p, serde_json::to_vec_pretty(l).map_err(|e| e.to_string())?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let _ = app.emit(EVENT, ());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn my_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> {
|
||||
let adapter = app.state::<AppState>().freenet.clone();
|
||||
let info = persona::current(&adapter)
|
||||
.await?
|
||||
.ok_or("create a SoulStream identity first")?;
|
||||
key_of(&info.public_key)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
async fn fetch_state(app: &tauri::AppHandle, owner: &[u8; 32], space_id: &[u8; 16]) -> Result<(sp::SpaceState, bool), String> {
|
||||
let adapter = app.state::<AppState>().freenet.clone();
|
||||
let ids = FreenetAdapter::contract_ids(SPACE_WASM.to_vec(), &sp::make_params(owner, space_id));
|
||||
match adapter.contract_get(&ids.instance, false).await {
|
||||
Ok(bytes) => Ok((sp::merge_space(owner, space_id, &[&bytes]), true)),
|
||||
Err(e) if e.to_string().contains("not found") => Ok((sp::SpaceState::default(), false)),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn offer(app: &tauri::AppHandle, owner: &[u8; 32], space_id: &[u8; 16], state: &sp::SpaceState, first: bool) -> Result<(), String> {
|
||||
let adapter = app.state::<AppState>().freenet.clone();
|
||||
let bytes = bincode::serialize(state).map_err(|e| e.to_string())?;
|
||||
let params = sp::make_params(owner, space_id);
|
||||
if first {
|
||||
adapter.contract_put(SPACE_WASM.to_vec(), ¶ms, bytes, false).await.map(|_| ())
|
||||
} else {
|
||||
adapter.get_then_update(SPACE_WASM.to_vec(), ¶ms, bytes).await.map(|_| ())
|
||||
}
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn empty_state(space_id: [u8; 16], checkpoint: Option<sp::Checkpoint>, messages: Vec<sp::Message>) -> sp::SpaceState {
|
||||
sp::SpaceState { schema_version: sp::SCHEMA_VERSION, space_id, checkpoint, messages }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- views
|
||||
|
||||
fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me: &[u8; 32], reachable: bool) -> SpaceView {
|
||||
let me_b58 = b58(me);
|
||||
let key_for = |epoch: u64| -> Option<[u8; 32]> {
|
||||
rec.keys.get(&epoch.to_string()).and_then(|k| key_of(k).ok())
|
||||
};
|
||||
let Some(c) = &state.checkpoint else {
|
||||
return SpaceView {
|
||||
id: record_id(&rec.owner, &rec.space_id),
|
||||
owner: rec.owner.clone(),
|
||||
space_id: rec.space_id.clone(),
|
||||
name: rec.name.clone(),
|
||||
description: String::new(),
|
||||
private: rec.private,
|
||||
epoch: 0,
|
||||
my_role: rec.my_role.clone(),
|
||||
message_cap: sp::DEFAULT_MESSAGE_CAP,
|
||||
members: vec![],
|
||||
pinned_owner: None,
|
||||
pinned_uuid: None,
|
||||
messages: vec![],
|
||||
can_post: false,
|
||||
has_key: false,
|
||||
reachable,
|
||||
};
|
||||
};
|
||||
let is_member = c.members.iter().any(|m| &m.key == me);
|
||||
let has_key = !c.private || key_for(c.epoch).is_some();
|
||||
SpaceView {
|
||||
id: record_id(&rec.owner, &rec.space_id),
|
||||
owner: rec.owner.clone(),
|
||||
space_id: rec.space_id.clone(),
|
||||
name: c.name.clone(),
|
||||
description: c.description.clone(),
|
||||
private: c.private,
|
||||
epoch: c.epoch,
|
||||
my_role: if &c.owner == me { "owner".into() } else if is_member { "member".into() } else { "none".into() },
|
||||
message_cap: c.message_cap,
|
||||
members: c
|
||||
.members
|
||||
.iter()
|
||||
.map(|m| MemberView {
|
||||
key: b58(&m.key),
|
||||
role: match m.role {
|
||||
sp::Role::Owner => "owner".into(),
|
||||
sp::Role::Member => "member".into(),
|
||||
},
|
||||
username: slsk_link::username_for_key(app, &b58(&m.key)),
|
||||
is_me: &m.key == me,
|
||||
since_epoch: m.since_epoch,
|
||||
})
|
||||
.collect(),
|
||||
pinned_owner: c.pinned.as_ref().map(|p| b58(&p.owner)),
|
||||
pinned_uuid: c.pinned.as_ref().map(|p| b58(&p.uuid)),
|
||||
messages: state
|
||||
.messages
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let text = if c.private {
|
||||
key_for(m.epoch).and_then(|k| sp::open_body(&k, m)).and_then(|b| String::from_utf8(b).ok())
|
||||
} else {
|
||||
String::from_utf8(m.body.clone()).ok()
|
||||
};
|
||||
MessageView {
|
||||
id: b58(&m.msg_id),
|
||||
author: b58(&m.author),
|
||||
username: slsk_link::username_for_key(app, &b58(&m.author)),
|
||||
is_me: &m.author == me,
|
||||
created_at: m.created_at,
|
||||
epoch: m.epoch,
|
||||
text,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
can_post: is_member && has_key,
|
||||
has_key,
|
||||
reachable,
|
||||
}
|
||||
.with_me(me_b58)
|
||||
}
|
||||
|
||||
impl SpaceView {
|
||||
fn with_me(self, _me: String) -> Self {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list(app: &tauri::AppHandle) -> Vec<SpaceListItem> {
|
||||
let l = load(app);
|
||||
let mut v: Vec<_> = l
|
||||
.spaces
|
||||
.values()
|
||||
.map(|r| SpaceListItem {
|
||||
id: record_id(&r.owner, &r.space_id),
|
||||
owner: r.owner.clone(),
|
||||
space_id: r.space_id.clone(),
|
||||
name: r.name.clone(),
|
||||
private: r.private,
|
||||
my_role: r.my_role.clone(),
|
||||
last_epoch: r.last_epoch,
|
||||
})
|
||||
.collect();
|
||||
v.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
v
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- actions
|
||||
|
||||
pub async fn create(app: &tauri::AppHandle, name: &str, description: &str, private: bool) -> Result<SpaceView, String> {
|
||||
let me = my_key(app).await?;
|
||||
let name = cap(name.trim(), sp::MAX_NAME_BYTES);
|
||||
if name.is_empty() {
|
||||
return Err("a Space needs a name".into());
|
||||
}
|
||||
let space_id = rand16();
|
||||
let mut c = sp::Checkpoint {
|
||||
schema_version: sp::SCHEMA_VERSION,
|
||||
space_id,
|
||||
owner: me,
|
||||
epoch: 1,
|
||||
name: name.clone(),
|
||||
description: cap(description.trim(), sp::MAX_DESC_BYTES),
|
||||
private,
|
||||
members: vec![sp::Member { key: me, role: sp::Role::Owner, since_epoch: 1 }],
|
||||
pinned: None,
|
||||
message_cap: sp::DEFAULT_MESSAGE_CAP,
|
||||
sig: vec![],
|
||||
};
|
||||
c.sig = sign(app, sp::checkpoint_sign_payload(&c)).await?;
|
||||
let state = empty_state(space_id, Some(c), vec![]);
|
||||
offer(app, &me, &space_id, &state, true).await?;
|
||||
let mut keys = BTreeMap::new();
|
||||
if private {
|
||||
keys.insert("1".to_string(), b58(&rand32()));
|
||||
}
|
||||
let rec = SpaceRecord {
|
||||
owner: b58(&me),
|
||||
space_id: b58(&space_id),
|
||||
name,
|
||||
private,
|
||||
my_role: "owner".into(),
|
||||
joined_at: now_secs(),
|
||||
keys,
|
||||
last_epoch: 1,
|
||||
};
|
||||
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))
|
||||
}
|
||||
|
||||
pub async fn open(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<SpaceView, String> {
|
||||
let me = my_key(app).await?;
|
||||
let owner = key_of(owner_b58)?;
|
||||
let space_id = id_of(space_b58)?;
|
||||
let mut l = load(app);
|
||||
let rid = record_id(owner_b58, space_b58);
|
||||
let rec = l.spaces.get(&rid).cloned().ok_or("not a Space this device knows")?;
|
||||
let (state, reachable) = fetch_state(app, &owner, &space_id).await?;
|
||||
if let Some(c) = &state.checkpoint {
|
||||
let r = l.spaces.get_mut(&rid).unwrap();
|
||||
let changed = r.name != c.name || r.last_epoch != c.epoch || r.private != c.private;
|
||||
r.name = c.name.clone();
|
||||
r.last_epoch = c.epoch;
|
||||
r.private = c.private;
|
||||
// Adding members never rotates the key: the previous epoch's key
|
||||
// carries forward until a removal issues a new one.
|
||||
if c.private && !r.keys.contains_key(&c.epoch.to_string()) {
|
||||
if let Some((_, k)) = r.keys.iter().filter(|(e, _)| e.parse::<u64>().unwrap_or(0) < c.epoch).last() {
|
||||
let k = k.clone();
|
||||
r.keys.insert(c.epoch.to_string(), k);
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
let _ = save(app, &l);
|
||||
}
|
||||
}
|
||||
let rec = l.spaces.get(&rid).cloned().unwrap_or(rec);
|
||||
Ok(view_of(app, &rec, &state, &me, reachable))
|
||||
}
|
||||
|
||||
pub async fn post(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, text: &str) -> Result<(), String> {
|
||||
let me = my_key(app).await?;
|
||||
let owner = key_of(owner_b58)?;
|
||||
let space_id = id_of(space_b58)?;
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Err("nothing to say".into());
|
||||
}
|
||||
if text.len() > sp::MAX_BODY_BYTES {
|
||||
return Err(format!("over {} bytes", sp::MAX_BODY_BYTES));
|
||||
}
|
||||
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) {
|
||||
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,
|
||||
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")?;
|
||||
m.body = sp::seal_body(&key, &m);
|
||||
}
|
||||
m.sig = sign(app, sp::message_sign_payload(&m)).await?;
|
||||
offer(app, &owner, &space_id, &empty_state(space_id, None, vec![m]), false).await
|
||||
}
|
||||
|
||||
/// The owner rewrites the checkpoint: a new epoch with `mutate` applied.
|
||||
async fn bump(app: &tauri::AppHandle, me: [u8; 32], owner: [u8; 32], space_id: [u8; 16], mutate: impl FnOnce(&mut sp::Checkpoint)) -> Result<sp::Checkpoint, String> {
|
||||
if me != owner {
|
||||
return Err("only the owner can change a Space".into());
|
||||
}
|
||||
let (state, _) = fetch_state(app, &owner, &space_id).await?;
|
||||
let mut c = state.checkpoint.ok_or("the Space has not reached this node yet")?;
|
||||
c.epoch += 1;
|
||||
mutate(&mut c);
|
||||
if c.members.len() > sp::MAX_MEMBERS {
|
||||
return Err(format!("over {} members", sp::MAX_MEMBERS));
|
||||
}
|
||||
c.sig = sign(app, sp::checkpoint_sign_payload(&c)).await?;
|
||||
offer(app, &owner, &space_id, &empty_state(space_id, Some(c.clone()), vec![]), false).await?;
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
pub async fn invite(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, persona_key: &str) -> Result<SpaceView, String> {
|
||||
let me = my_key(app).await?;
|
||||
let owner = key_of(owner_b58)?;
|
||||
let space_id = id_of(space_b58)?;
|
||||
let their = key_of(persona_key)?;
|
||||
if their == me {
|
||||
return Err("that is your own identity".into());
|
||||
}
|
||||
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?;
|
||||
let rid = record_id(owner_b58, space_b58);
|
||||
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()));
|
||||
rec.keys.insert(c.epoch.to_string(), k.clone());
|
||||
key = Some(key_of(&k)?);
|
||||
}
|
||||
rec.last_epoch = c.epoch;
|
||||
save(app, &l)?;
|
||||
inbox::send(
|
||||
app,
|
||||
&their,
|
||||
&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);
|
||||
open(app, owner_b58, space_b58).await
|
||||
}
|
||||
|
||||
pub async fn remove_member(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, persona_key: &str) -> Result<SpaceView, String> {
|
||||
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 {
|
||||
return Err("the owner cannot be removed".into());
|
||||
}
|
||||
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 mut l = load(app);
|
||||
let rec = l.spaces.get_mut(&rid).ok_or("not a Space this device knows")?;
|
||||
rec.last_epoch = c.epoch;
|
||||
if c.private {
|
||||
// A removal rotates: everyone who stays gets the new epoch's key.
|
||||
let k = rand32();
|
||||
rec.keys.insert(c.epoch.to_string(), b58(&k));
|
||||
save(app, &l)?;
|
||||
for m in c.members.iter().filter(|m| m.key != me) {
|
||||
let cargo = Cargo::SpaceKey(SpaceKeyCargo { space_id, owner, epoch: c.epoch, key: k });
|
||||
if let Err(e) = inbox::send(app, &m.key, &cargo).await {
|
||||
log::warn!("spaces: could not hand the epoch-{} key to {}: {e}", c.epoch, b58(&m.key));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
save(app, &l)?;
|
||||
}
|
||||
open(app, owner_b58, space_b58).await
|
||||
}
|
||||
|
||||
pub async fn set_pinned(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, pl_owner: Option<String>, pl_uuid: Option<String>) -> Result<SpaceView, String> {
|
||||
let me = my_key(app).await?;
|
||||
let owner = key_of(owner_b58)?;
|
||||
let space_id = id_of(space_b58)?;
|
||||
let pinned = match (pl_owner, pl_uuid) {
|
||||
(Some(o), Some(u)) => Some(sp::PlaylistRef { owner: key_of(&o)?, uuid: id_of(&u)? }),
|
||||
_ => None,
|
||||
};
|
||||
bump(app, me, owner, space_id, |c| c.pinned = pinned).await?;
|
||||
open(app, owner_b58, space_b58).await
|
||||
}
|
||||
|
||||
pub fn leave(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<(), String> {
|
||||
let mut l = load(app);
|
||||
let rid = record_id(owner_b58, space_b58);
|
||||
if l.spaces.get(&rid).is_some_and(|r| r.my_role == "owner") {
|
||||
return Err("an owner keeps the Space; remove members instead".into());
|
||||
}
|
||||
l.spaces.remove(&rid);
|
||||
save(app, &l)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- inbox cargo
|
||||
|
||||
pub(crate) fn on_inbox_invite(app: &tauri::AppHandle, sender: [u8; 32], inv: SpaceInvite) {
|
||||
if sender != inv.owner {
|
||||
log::warn!("spaces: an invitation was sealed by someone other than the Space's owner");
|
||||
return;
|
||||
}
|
||||
let rid = record_id(&b58(&inv.owner), &b58(&inv.space_id));
|
||||
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));
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_inbox_space_key(app: &tauri::AppHandle, sender: [u8; 32], sk: SpaceKeyCargo) {
|
||||
if sender != sk.owner {
|
||||
return;
|
||||
}
|
||||
let rid = record_id(&b58(&sk.owner), &b58(&sk.space_id));
|
||||
let mut l = load(app);
|
||||
let Some(rec) = l.spaces.get_mut(&rid) else { return };
|
||||
rec.keys.insert(sk.epoch.to_string(), b58(&sk.key));
|
||||
let _ = save(app, &l);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- commands
|
||||
|
||||
#[tauri::command]
|
||||
pub fn spaces_list(app: tauri::AppHandle) -> Vec<SpaceListItem> {
|
||||
list(&app)
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn space_create(app: tauri::AppHandle, name: String, description: String, private: bool) -> Result<SpaceView, String> {
|
||||
create(&app, &name, &description, private).await
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn space_open(app: tauri::AppHandle, owner: String, space: String) -> Result<SpaceView, String> {
|
||||
open(&app, &owner, &space).await
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn space_post(app: tauri::AppHandle, owner: String, space: String, text: String) -> Result<(), String> {
|
||||
post(&app, &owner, &space, &text).await
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn space_invite(app: tauri::AppHandle, owner: String, space: String, persona_key: String) -> Result<SpaceView, String> {
|
||||
invite(&app, &owner, &space, &persona_key).await
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn space_remove_member(app: tauri::AppHandle, owner: String, space: String, persona_key: String) -> Result<SpaceView, String> {
|
||||
remove_member(&app, &owner, &space, &persona_key).await
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn space_set_pinned(app: tauri::AppHandle, owner: String, space: String, playlist_owner: Option<String>, playlist_uuid: Option<String>) -> Result<SpaceView, String> {
|
||||
set_pinned(&app, &owner, &space, playlist_owner, playlist_uuid).await
|
||||
}
|
||||
#[tauri::command]
|
||||
pub fn space_leave(app: tauri::AppHandle, owner: String, space: String) -> Result<(), String> {
|
||||
leave(&app, &owner, &space)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn contract_generation_is_pinned() {
|
||||
let ids = FreenetAdapter::contract_ids(SPACE_WASM.to_vec(), &sp::make_params(&[7u8; 32], &[1u8; 16]));
|
||||
assert_eq!(ids.code_hash, "BQc5bg1QEfTsgkog1WsS3di7fkwVSd8VJQpNUHdEMH3a");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user