hive-c0re: provision rooms through swarm-matrix-client
This commit is contained in:
parent
8d262f4d8a
commit
1d22041f82
5 changed files with 530 additions and 254 deletions
|
|
@ -429,6 +429,237 @@ pub async fn send_text(
|
|||
})
|
||||
}
|
||||
|
||||
/// What the homeserver says a token is, with the device it belongs to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WhoamiSession {
|
||||
/// A live token: the user id it authenticates as, and its device id, which
|
||||
/// the spec lets a homeserver omit.
|
||||
Session {
|
||||
user_id: String,
|
||||
device_id: Option<String>,
|
||||
},
|
||||
/// `M_UNKNOWN_TOKEN`, as for [`Whoami::UnknownToken`].
|
||||
UnknownToken,
|
||||
}
|
||||
|
||||
/// [`whoami`] for a caller that restores a session and so needs the device id
|
||||
/// too.
|
||||
///
|
||||
/// # Errors
|
||||
/// As [`whoami`].
|
||||
pub async fn whoami_session(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
token: &str,
|
||||
) -> Result<WhoamiSession> {
|
||||
let (status, json) = get(
|
||||
client,
|
||||
&format!("{base}/_matrix/client/v3/account/whoami"),
|
||||
token,
|
||||
)
|
||||
.await
|
||||
.context("GET /account/whoami")?;
|
||||
whoami_session_verdict(status, &json)
|
||||
}
|
||||
|
||||
/// [`whoami_session`]'s reading of a response, apart from the IO.
|
||||
fn whoami_session_verdict(
|
||||
status: reqwest::StatusCode,
|
||||
json: &serde_json::Value,
|
||||
) -> Result<WhoamiSession> {
|
||||
match whoami_verdict(status, json)? {
|
||||
Whoami::User(user_id) => Ok(WhoamiSession::Session {
|
||||
user_id,
|
||||
device_id: json["device_id"].as_str().map(str::to_owned),
|
||||
}),
|
||||
Whoami::UnknownToken => Ok(WhoamiSession::UnknownToken),
|
||||
}
|
||||
}
|
||||
|
||||
/// The ids of the rooms `token`'s user has joined.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the request cannot be sent, the response will not decode, the
|
||||
/// homeserver refuses, or the answer carries no `joined_rooms`.
|
||||
pub async fn joined_rooms(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
token: &str,
|
||||
) -> Result<Vec<String>> {
|
||||
let (status, json) = get(
|
||||
client,
|
||||
&format!("{base}/_matrix/client/v3/joined_rooms"),
|
||||
token,
|
||||
)
|
||||
.await
|
||||
.context("GET /joined_rooms")?;
|
||||
if !status.is_success() {
|
||||
bail!(
|
||||
"the homeserver refused to list joined rooms: {}",
|
||||
why(status, &json)
|
||||
);
|
||||
}
|
||||
Ok(json["joined_rooms"]
|
||||
.as_array()
|
||||
.context("the homeserver's answer carried no `joined_rooms`")?
|
||||
.iter()
|
||||
.filter_map(|room| room.as_str().map(str::to_owned))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The content of `room_id`'s `event_type` state event under `state_key`
|
||||
/// (empty for most), or `None` when the room has none.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the request cannot be sent, the response will not decode, or the
|
||||
/// homeserver refuses with anything other than 404. A 404 is the expected
|
||||
/// answer for a state event never set, so it is `None`; every other refusal
|
||||
/// says nothing about the event, so it is an error.
|
||||
pub async fn room_state(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
token: &str,
|
||||
room_id: &str,
|
||||
event_type: &str,
|
||||
state_key: &str,
|
||||
) -> Result<Option<serde_json::Value>> {
|
||||
let (status, json) = get(
|
||||
client,
|
||||
&state_url(base, room_id, event_type, state_key),
|
||||
token,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("GET {event_type} state of {room_id}"))?;
|
||||
if status.is_success() {
|
||||
return Ok(Some(json));
|
||||
}
|
||||
if status == reqwest::StatusCode::NOT_FOUND {
|
||||
return Ok(None);
|
||||
}
|
||||
bail!(
|
||||
"the homeserver refused to read {event_type} of {room_id}: {}",
|
||||
why(status, &json)
|
||||
)
|
||||
}
|
||||
|
||||
/// Set `room_id`'s `event_type` state event under `state_key` to `content`.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the request cannot be sent, the response will not decode, or the
|
||||
/// homeserver refuses.
|
||||
pub async fn put_state(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
token: &str,
|
||||
room_id: &str,
|
||||
event_type: &str,
|
||||
state_key: &str,
|
||||
content: &serde_json::Value,
|
||||
) -> Result<()> {
|
||||
let (status, json) = exchange(
|
||||
client
|
||||
.put(state_url(base, room_id, event_type, state_key))
|
||||
.bearer_auth(token)
|
||||
.json(content),
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("PUT {event_type} state into {room_id}"))?;
|
||||
if !status.is_success() {
|
||||
bail!(
|
||||
"the homeserver refused to set {event_type} in {room_id}: {}",
|
||||
why(status, &json)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The state-event URL for `room_id`, `event_type` and `state_key`.
|
||||
fn state_url(base: &str, room_id: &str, event_type: &str, state_key: &str) -> String {
|
||||
format!(
|
||||
"{base}/_matrix/client/v3/rooms/{}/state/{event_type}/{}",
|
||||
path_segment(room_id),
|
||||
path_segment(state_key)
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a room from the `createRoom` request `body` and return its id.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the request cannot be sent, the response will not decode, the
|
||||
/// homeserver refuses, or the answer carries no `room_id`.
|
||||
pub async fn create_room(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
token: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<String> {
|
||||
let (status, json) = post(
|
||||
client,
|
||||
&format!("{base}/_matrix/client/v3/createRoom"),
|
||||
token,
|
||||
body,
|
||||
)
|
||||
.await
|
||||
.context("POST /createRoom")?;
|
||||
if !status.is_success() {
|
||||
bail!(
|
||||
"the homeserver refused to create the room: {}",
|
||||
why(status, &json)
|
||||
);
|
||||
}
|
||||
json["room_id"]
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.context("the homeserver's createRoom answer carried no `room_id`")
|
||||
}
|
||||
|
||||
/// What the homeserver did with an invite.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Invited {
|
||||
/// The invite went out.
|
||||
Sent,
|
||||
/// HTTP 403. `M_FORBIDDEN` covers both "already in the room" and a real
|
||||
/// refusal (banned target, sender lacks power), so the caller has to
|
||||
/// tell them apart by the target's membership.
|
||||
Forbidden,
|
||||
}
|
||||
|
||||
/// Invite `user_id` to `room_id`.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the request cannot be sent, the response will not decode, or the
|
||||
/// homeserver refuses with anything other than 403, which is
|
||||
/// [`Invited::Forbidden`].
|
||||
pub async fn invite(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
token: &str,
|
||||
room_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<Invited> {
|
||||
let (status, json) = post(
|
||||
client,
|
||||
&format!(
|
||||
"{base}/_matrix/client/v3/rooms/{}/invite",
|
||||
path_segment(room_id)
|
||||
),
|
||||
token,
|
||||
&serde_json::json!({ "user_id": user_id }),
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("POST /rooms/{room_id}/invite for {user_id}"))?;
|
||||
if status.is_success() {
|
||||
return Ok(Invited::Sent);
|
||||
}
|
||||
if status == reqwest::StatusCode::FORBIDDEN {
|
||||
return Ok(Invited::Forbidden);
|
||||
}
|
||||
bail!(
|
||||
"the homeserver refused to invite {user_id} to {room_id}: {}",
|
||||
why(status, &json)
|
||||
)
|
||||
}
|
||||
|
||||
/// `value` percent-encoded for one URL path segment: room ids, aliases and
|
||||
/// user ids carry `!`, `#`, `@` and `:`, and `#` would otherwise end the path.
|
||||
fn path_segment(value: &str) -> String {
|
||||
|
|
@ -882,4 +1113,153 @@ mod tests {
|
|||
assert!(format!("{e:#}").contains(&status.to_string()), "{e:#}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A homeserver on a loopback port that answers every request with
|
||||
/// `status` and `body`.
|
||||
async fn any_stub(status: u16, body: &'static str) -> String {
|
||||
let app = axum::Router::new().fallback(move || async move {
|
||||
(
|
||||
axum::http::StatusCode::from_u16(status).expect("a status"),
|
||||
[(axum::http::header::CONTENT_TYPE, "application/json")],
|
||||
body,
|
||||
)
|
||||
});
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("a loopback port");
|
||||
let addr = listener.local_addr().expect("a bound address");
|
||||
tokio::spawn(async move { axum::serve(listener, app).await });
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
const LEAKY: &str =
|
||||
r#"{"errcode":"M_UNKNOWN","error":"free-form text","access_token":"syt_leak"}"#;
|
||||
|
||||
/// A refusal names the status and errcode and quotes nothing else of the
|
||||
/// body.
|
||||
fn assert_refusal_hides_body(e: &anyhow::Error) {
|
||||
let message = format!("{e:#}");
|
||||
assert!(message.contains("500"), "{message}");
|
||||
assert!(message.contains("M_UNKNOWN"), "{message}");
|
||||
assert!(!message.contains("free-form"), "{message}");
|
||||
assert!(!message.contains("syt_leak"), "{message}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_room_call_refuses_without_quoting_the_body() {
|
||||
let base = any_stub(500, LEAKY).await;
|
||||
let c = client().expect("a client");
|
||||
let body = serde_json::json!({});
|
||||
assert_refusal_hides_body(&joined_rooms(&c, &base, "t").await.expect_err("refused"));
|
||||
assert_refusal_hides_body(
|
||||
&room_state(&c, &base, "t", "!r:x", "m.room.name", "")
|
||||
.await
|
||||
.expect_err("refused"),
|
||||
);
|
||||
assert_refusal_hides_body(
|
||||
&put_state(&c, &base, "t", "!r:x", "m.room.name", "", &body)
|
||||
.await
|
||||
.expect_err("refused"),
|
||||
);
|
||||
assert_refusal_hides_body(
|
||||
&create_room(&c, &base, "t", &body)
|
||||
.await
|
||||
.expect_err("refused"),
|
||||
);
|
||||
assert_refusal_hides_body(
|
||||
&invite(&c, &base, "t", "!r:x", "@a:x")
|
||||
.await
|
||||
.expect_err("refused"),
|
||||
);
|
||||
// Control: the same body behind a 403 is the one verdict `invite` returns.
|
||||
let forbidden = any_stub(403, LEAKY).await;
|
||||
assert_eq!(
|
||||
invite(&c, &forbidden, "t", "!r:x", "@a:x")
|
||||
.await
|
||||
.expect("a verdict"),
|
||||
Invited::Forbidden
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn room_state_reads_content_and_treats_404_as_absent() {
|
||||
let c = client().expect("a client");
|
||||
let base = any_stub(200, r#"{"name":"shared"}"#).await;
|
||||
assert_eq!(
|
||||
room_state(&c, &base, "t", "!r:x", "m.room.name", "")
|
||||
.await
|
||||
.expect("content"),
|
||||
Some(serde_json::json!({"name":"shared"}))
|
||||
);
|
||||
let base = any_stub(404, r#"{"errcode":"M_NOT_FOUND"}"#).await;
|
||||
assert_eq!(
|
||||
room_state(&c, &base, "t", "!r:x", "m.room.name", "")
|
||||
.await
|
||||
.expect("absent"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn joined_rooms_and_create_room_read_their_fields() {
|
||||
let c = client().expect("a client");
|
||||
let base = any_stub(200, r#"{"joined_rooms":["!a:x","!b:x"]}"#).await;
|
||||
assert_eq!(
|
||||
joined_rooms(&c, &base, "t").await.expect("rooms"),
|
||||
["!a:x", "!b:x"]
|
||||
);
|
||||
assert!(
|
||||
create_room(&c, &base, "t", &serde_json::json!({}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let base = any_stub(200, r#"{"room_id":"!new:x"}"#).await;
|
||||
assert_eq!(
|
||||
create_room(&c, &base, "t", &serde_json::json!({}))
|
||||
.await
|
||||
.expect("room"),
|
||||
"!new:x"
|
||||
);
|
||||
assert!(joined_rooms(&c, &base, "t").await.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_state_url_encodes_room_and_key_and_keeps_an_empty_key_slash() {
|
||||
assert_eq!(
|
||||
state_url("http://h", "!r:x", "m.room.create", ""),
|
||||
"http://h/_matrix/client/v3/rooms/%21r%3Ax/state/m.room.create/"
|
||||
);
|
||||
assert_eq!(
|
||||
state_url("http://h", "!r:x", "m.space.child", "!c:x"),
|
||||
"http://h/_matrix/client/v3/rooms/%21r%3Ax/state/m.space.child/%21c%3Ax"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whoami_session_carries_the_device_id_when_the_homeserver_sends_one() {
|
||||
let ok = reqwest::StatusCode::OK;
|
||||
assert_eq!(
|
||||
whoami_session_verdict(ok, &serde_json::json!({"user_id":"@a:x","device_id":"D"}))
|
||||
.expect("live"),
|
||||
WhoamiSession::Session {
|
||||
user_id: "@a:x".to_owned(),
|
||||
device_id: Some("D".to_owned())
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
whoami_session_verdict(ok, &serde_json::json!({"user_id":"@a:x"})).expect("live"),
|
||||
WhoamiSession::Session {
|
||||
user_id: "@a:x".to_owned(),
|
||||
device_id: None
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
whoami_session_verdict(
|
||||
reqwest::StatusCode::UNAUTHORIZED,
|
||||
&serde_json::json!({"errcode":"M_UNKNOWN_TOKEN"})
|
||||
)
|
||||
.expect("a verdict"),
|
||||
WhoamiSession::UnknownToken
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue