diff --git a/Cargo.lock b/Cargo.lock index f0a77acd9c4..1794e723c5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "bitflags" version = "1.3.2" @@ -53,6 +59,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa" + [[package]] name = "bytes" version = "1.6.0" @@ -65,6 +77,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cpufeatures" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +dependencies = [ + "libc", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -77,9 +98,9 @@ dependencies = [ [[package]] name = "digest" -version = "0.10.5" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adfbc57365a37acbd2ebf2b64d7e69bb766e2fea813521ed536f5d0520dcf86c" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", @@ -102,6 +123,43 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "headers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9" +dependencies = [ + "base64", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + [[package]] name = "heck" version = "0.4.1" @@ -125,6 +183,12 @@ dependencies = [ "itoa", ] +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "indoc" version = "2.0.4" @@ -137,6 +201,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4217ad341ebadf8d8e724e264f13e593e0648f5b3e94b3896a5df283be015ecc" +[[package]] +name = "js-sys" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +dependencies = [ + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -145,9 +218,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.135" +version = "0.2.153" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68783febc7782c6c5cb401fbda4de5a9898be1762314da0bb2c10ced61f18b0c" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" [[package]] name = "lock_api" @@ -180,6 +253,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "once_cell" version = "1.15.0" @@ -215,6 +294,12 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7170ef9988bc169ba16dd36a7fa041e5c4cbeb6a35b76d4c03daded371eae7c0" +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + [[package]] name = "proc-macro2" version = "1.0.76" @@ -318,6 +403,36 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + [[package]] name = "redox_syscall" version = "0.2.16" @@ -399,6 +514,28 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "smallvec" version = "1.10.0" @@ -427,18 +564,23 @@ name = "synapse" version = "0.1.0" dependencies = [ "anyhow", + "base64", "blake2", "bytes", + "headers", "hex", "http", "lazy_static", "log", + "mime", "pyo3", "pyo3-log", "pythonize", "regex", "serde", "serde_json", + "sha2", + "ulid", ] [[package]] @@ -453,6 +595,17 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" +[[package]] +name = "ulid" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34778c17965aa2a08913b57e1f34db9b4a63f5de31768b55bf20d2795f921259" +dependencies = [ + "getrandom", + "rand", + "web-time", +] + [[package]] name = "unicode-ident" version = "1.0.5" @@ -471,6 +624,76 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "windows-sys" version = "0.36.1" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 64ae3b7657a..e6e96482805 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -23,10 +23,13 @@ name = "synapse.synapse_rust" [dependencies] anyhow = "1.0.63" +base64 = "0.21.7" bytes = "1.6.0" +headers = "0.4.0" http = "1.1.0" lazy_static = "1.4.0" log = "0.4.17" +mime = "0.3.17" pyo3 = { version = "0.20.0", features = [ "macros", "anyhow", @@ -36,8 +39,10 @@ pyo3 = { version = "0.20.0", features = [ pyo3-log = "0.9.0" pythonize = "0.20.0" regex = "1.6.0" +sha2 = "0.10.8" serde = { version = "1.0.144", features = ["derive"] } serde_json = "1.0.85" +ulid = "1.1.2" [features] extension-module = ["pyo3/extension-module"] diff --git a/rust/src/rendezvous/mod.rs b/rust/src/rendezvous/mod.rs index 40f7e0196e7..123b82334d9 100644 --- a/rust/src/rendezvous/mod.rs +++ b/rust/src/rendezvous/mod.rs @@ -10,33 +10,137 @@ * * See the GNU Affero General Public License for more details: * . - * - * Originally licensed under the Apache License, Version 2.0: - * . - * - * [This file includes modifications made by New Vector Limited] - * */ -use log::info; -use pyo3::{pyclass, pymethods, types::PyModule, PyResult, Python}; +use std::{collections::HashMap, time::Duration}; + +use bytes::Bytes; +use headers::{ContentLength, ContentType, HeaderMapExt}; +use http::{Response, StatusCode}; +use pyo3::{pyclass, pymethods, types::PyModule, PyAny, PyResult, Python}; +use ulid::Ulid; + +use crate::{ + errors::{NotFoundError, SynapseError}, + http::{http_request_from_twisted, http_response_to_twisted}, +}; +mod session; + +use self::session::Session; + +// TODO: handle eviction +#[derive(Default)] #[pyclass] -struct RendezVous {} +struct RendezVous { + sessions: HashMap, +} #[pymethods] impl RendezVous { #[new] fn new() -> Self { - RendezVous {} + RendezVous::default() } - fn store_session(&mut self, content_type: String, body: Vec) -> PyResult<()> { - info!( - "Received new rendezvous message: content_type: {}, len(body): {}", - content_type, - body.len() - ); + fn handle_post(&mut self, twisted_request: &PyAny) -> PyResult<()> { + let request = http_request_from_twisted(twisted_request)?; + + let ContentLength(content_length) = request.headers().typed_get().ok_or_else(|| { + SynapseError::new(StatusCode::BAD_REQUEST, "Missing Content-Length header") + })?; + + if content_length > 1024 * 100 { + return Err(SynapseError::new( + StatusCode::BAD_REQUEST, + "Content-Length too large", + )); + } + + let content_type: ContentType = request.headers().typed_get().ok_or_else(|| { + SynapseError::new(StatusCode::BAD_REQUEST, "Missing Content-Type header") + })?; + + let id = Ulid::new(); + + // XXX: this is lazy + let source_uri = request.uri(); + let uri = format!("{source_uri}/{id}"); + + let body = request.into_body(); + + let session = Session::new(body, content_type.into(), Duration::from_secs(5 * 60)); + + let response = serde_json::json!({ + "uri": uri, + }) + .to_string(); + + let mut response = Response::new(response.as_bytes()); + *response.status_mut() = StatusCode::CREATED; + response.headers_mut().typed_insert(ContentType::json()); + response.headers_mut().typed_insert(session.etag()); + response.headers_mut().typed_insert(session.expires()); + response.headers_mut().typed_insert(session.last_modified()); + http_response_to_twisted(twisted_request, response)?; + + self.sessions.insert(id, session); + + Ok(()) + } + + fn handle_get(&mut self, twisted_request: &PyAny, id: &str) -> PyResult<()> { + let _request = http_request_from_twisted(twisted_request)?; + + // TODO: handle If-None-Match + + let id: Ulid = id.parse().map_err(|_| NotFoundError::new())?; + let session = self.sessions.get(&id).ok_or_else(NotFoundError::new)?; + + let mut response = Response::new(session.data()); + *response.status_mut() = StatusCode::OK; + response.headers_mut().typed_insert(session.content_type()); + response.headers_mut().typed_insert(session.etag()); + response.headers_mut().typed_insert(session.expires()); + response.headers_mut().typed_insert(session.last_modified()); + http_response_to_twisted(twisted_request, response)?; + + Ok(()) + } + + fn handle_put(&mut self, twisted_request: &PyAny, id: &str) -> PyResult<()> { + let request = http_request_from_twisted(twisted_request)?; + + // TODO: handle If-Match + + let content_type: ContentType = request.headers().typed_get().ok_or_else(|| { + SynapseError::new(StatusCode::BAD_REQUEST, "Missing Content-Type header") + })?; + let data = request.into_body(); + + let id: Ulid = id.parse().map_err(|_| NotFoundError::new())?; + let session = self.sessions.get_mut(&id).ok_or_else(NotFoundError::new)?; + session.update(data, content_type.into()); + + let mut response = Response::new(Bytes::new()); + *response.status_mut() = StatusCode::ACCEPTED; + response.headers_mut().typed_insert(session.etag()); + response.headers_mut().typed_insert(session.expires()); + response.headers_mut().typed_insert(session.last_modified()); + http_response_to_twisted(twisted_request, response)?; + + Ok(()) + } + + fn handle_delete(&mut self, twisted_request: &PyAny, id: &str) -> PyResult<()> { + let _request = http_request_from_twisted(twisted_request)?; + + let id: Ulid = id.parse().map_err(|_| NotFoundError::new())?; + let _session = self.sessions.remove(&id).ok_or_else(NotFoundError::new)?; + + let mut response = Response::new(Bytes::new()); + *response.status_mut() = StatusCode::NO_CONTENT; + http_response_to_twisted(twisted_request, response)?; Ok(()) } diff --git a/rust/src/rendezvous/session.rs b/rust/src/rendezvous/session.rs new file mode 100644 index 00000000000..7f80fd04650 --- /dev/null +++ b/rust/src/rendezvous/session.rs @@ -0,0 +1,74 @@ +/* + * This file is licensed under the Affero General Public License (AGPL) version 3. + * + * Copyright (C) 2024 New Vector, Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * See the GNU Affero General Public License for more details: + * . + */ + +use std::time::{Duration, SystemTime}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use bytes::Bytes; +use headers::{ContentType, ETag, Expires, LastModified}; +use mime::Mime; +use sha2::{Digest, Sha256}; + +pub struct Session { + hash: [u8; 32], + data: Bytes, + content_type: Mime, + last_modified: SystemTime, + expires: SystemTime, +} + +impl Session { + pub fn new(data: Bytes, content_type: Mime, ttl: Duration) -> Self { + let hash = Sha256::digest(&data).into(); + let now = SystemTime::now(); + Self { + hash, + data, + content_type, + expires: now + ttl, + last_modified: now, + } + } + + pub fn update(&mut self, data: Bytes, content_type: Mime) { + self.hash = Sha256::digest(&data).into(); + self.data = data; + self.content_type = content_type; + self.last_modified = SystemTime::now(); + } + + pub fn content_type(&self) -> ContentType { + self.content_type.clone().into() + } + + pub fn etag(&self) -> ETag { + let encoded = URL_SAFE_NO_PAD.encode(self.hash); + // SAFETY: Base64 encoding is URL-safe, so ETag-safe + format!("\"{encoded}\"") + .parse() + .expect("base64-encoded hash should be URL-safe") + } + + pub fn data(&self) -> Bytes { + self.data.clone() + } + + pub fn last_modified(&self) -> LastModified { + self.last_modified.into() + } + + pub fn expires(&self) -> Expires { + self.expires.into() + } +} diff --git a/synapse/rest/client/rendezvous.py b/synapse/rest/client/rendezvous.py index 9ddcb076902..0848dadc851 100644 --- a/synapse/rest/client/rendezvous.py +++ b/synapse/rest/client/rendezvous.py @@ -84,50 +84,40 @@ class MSC4108RendezvousServlet(RestServlet): "/org.matrix.msc4108/rendezvous$", releases=[], v1=False, unstable=True ) - def __init__(self, hs: "HomeServer") -> None: + def __init__(self, store: RendezVous) -> None: super().__init__() + self._store = store - self.max_upload_size = 100_000 - self._store = RendezVous() + def on_POST(self, request: SynapseRequest) -> None: + self._store.handle_post(request) - async def on_POST(self, request: SynapseRequest) -> None: - content_type = request.getHeader("Content-Type") - if content_type is None: - raise SynapseError(msg="Request must specify a Content-Type", code=400, - errcode=Codes.MISSING_PARAM) - - raw_content_length = request.getHeader("Content-Length") - if raw_content_length is None: - raise SynapseError(msg="Request must specify a Content-Length", code=400, - errcode=Codes.MISSING_PARAM) - try: - content_length = int(raw_content_length) - except ValueError: - raise SynapseError(msg="Content-Length value is invalid", code=400) - if content_length > self.max_upload_size: - raise SynapseError( - msg="Upload request body is too large", - code=413, - errcode=Codes.TOO_LARGE, - ) - - body = request.content.read(content_length + 1) - if len(body) != content_length: - raise SynapseError( - msg="Request body does not match Content-Length", - code=400, - errcode=Codes.INVALID_PARAM - ) - - self._store.handle_new(content_type, body) - - respond_with_json(request, 200, {"success": True}) +class MSC4108RendezvousSessionServlet(RestServlet): + PATTERNS = client_patterns( + "/org.matrix.msc4108/rendezvous/(?P[^/]+)$", + releases=[], + v1=False, + unstable=True, + ) + + def __init__(self, store: RendezVous) -> None: + super().__init__() + self._store = store + + def on_GET(self, request: SynapseRequest, session_id: str) -> None: + self._store.handle_get(request, session_id) + + def on_PUT(self, request: SynapseRequest, session_id: str) -> None: + self._store.handle_put(request, session_id) + def on_DELETE(self, request: SynapseRequest, session_id: str) -> None: + self._store.handle_delete(request, session_id) def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None: if hs.config.experimental.msc3886_endpoint is not None: MSC3886RendezvousServlet(hs).register(http_server) - # TODO: gate this behind a feature flag - MSC4108RendezvousServlet(hs).register(http_server) + # TODO: gate this behind a feature flag and store the rendezvous object in the HS + rendezvous = RendezVous() + MSC4108RendezvousServlet(rendezvous).register(http_server) + MSC4108RendezvousSessionServlet(rendezvous).register(http_server) diff --git a/synapse/synapse_rust/rendezvous.pyi b/synapse/synapse_rust/rendezvous.pyi index e4c1d780ed1..7b46f24b6f6 100644 --- a/synapse/synapse_rust/rendezvous.pyi +++ b/synapse/synapse_rust/rendezvous.pyi @@ -10,6 +10,11 @@ # See the GNU Affero General Public License for more details: # . +from twisted.web.iweb import IRequest + class RendezVous: def __init__(self): ... - def store_session(self, content_type: str, body: bytes) -> str: ... + def handle_post(self, request: IRequest): ... + def handle_get(self, request: IRequest, session_id: str): ... + def handle_put(self, request: IRequest, session_id: str): ... + def handle_delete(self, request: IRequest, session_id: str): ...