diff --git a/Cargo.lock b/Cargo.lock index 103ee61..154109d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -232,6 +232,19 @@ version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" +[[package]] +name = "bcrypt" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d1c9c15093eb224f0baa400f38fcd713fc1391a6f1c389d886beef146d60a3" +dependencies = [ + "base64 0.21.5", + "blowfish", + "getrandom", + "subtle", + "zeroize", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -253,6 +266,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "bumpalo" version = "3.14.0" @@ -338,6 +361,16 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "concurrent-queue" version = "2.3.0" @@ -449,6 +482,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "deranged" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eb30d70a07a3b04884d2677f06bec33509dc67ca60d92949e5535352d3191dc" +dependencies = [ + "powerfmt", +] + [[package]] name = "derive_builder" version = "0.12.0" @@ -672,6 +714,7 @@ dependencies = [ "async-trait", "axum", "axum-macros", + "bcrypt", "chrono", "diesel", "diesel-derive-newtype", @@ -681,11 +724,13 @@ dependencies = [ "env_logger", "futures", "hex", + "jsonwebtoken", "once_cell", "pretty_assertions", "rand", "reqwest", "serde", + "serde_json", "sha2", "tokio", "tracing", @@ -844,8 +889,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe9006bed769170c11f845cf00c7c1e9092aeb3f268e007c3e760ac68008070f" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1088,6 +1135,15 @@ dependencies = [ "hashbrown 0.14.2", ] +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + [[package]] name = "instant" version = "0.1.12" @@ -1138,6 +1194,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7ea04a7c5c055c175f189b6dc6ba036fd62306b58c66c9f6389036c503a3f4" +dependencies = [ + "base64 0.21.5", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -1318,6 +1389,27 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-bigint" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608e7659b5c3d7cba262d894801b9ec9d00de989e8a82bd4bef91d08da45cdc0" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.17" @@ -1431,6 +1523,16 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "pem" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8fcc794035347fb64beda2d3b462595dd2753e3f268d89c5aae77e8cf2c310" +dependencies = [ + "base64 0.21.5", + "serde", +] + [[package]] name = "percent-encoding" version = "2.3.0" @@ -1491,6 +1593,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -1694,6 +1802,20 @@ dependencies = [ "thiserror", ] +[[package]] +name = "ring" +version = "0.17.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688c63d65483050968b2a8937f7995f443e27041a0f7700aa59b0822aedebb74" +dependencies = [ + "cc", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys", +] + [[package]] name = "rustc-demangle" version = "0.1.23" @@ -1907,6 +2029,18 @@ dependencies = [ "libc", ] +[[package]] +name = "simple_asn1" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "skeptic" version = "0.13.7" @@ -1957,12 +2091,24 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "strsim" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + [[package]] name = "syn" version = "1.0.109" @@ -2060,6 +2206,35 @@ dependencies = [ "syn 2.0.39", ] +[[package]] +name = "time" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a34ab300f2dee6e562c10a046fc05e358b29f9bf92277f30c3c8d82275f6f5" +dependencies = [ + "deranged", + "itoa", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ad70d68dba9e1f8aceda7aa6711965dfec1cac869f311a51bd08b3a2ccbce20" +dependencies = [ + "time-core", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -2271,6 +2446,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.4.1" @@ -2555,3 +2736,9 @@ name = "yansi" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" + +[[package]] +name = "zeroize" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" diff --git a/Cargo.toml b/Cargo.toml index 4e3e5b3..b51fe4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ anyhow = "1.0.75" async-trait = "0.1.74" axum = "0.6.20" axum-macros = "0.3.8" +bcrypt = "0.15.0" chrono = { version = "0.4.31", features = ["serde"] } diesel = {version = "2.1.4", features = ["postgres", "chrono", "uuid"] } diesel-derive-newtype = "2.1.0" @@ -18,8 +19,10 @@ enum_delegate = "0.2.0" env_logger = { version = "0.10.1", default-features = false } futures = "0.3.29" hex = "0.4.3" +jsonwebtoken = "9.2.0" rand = "0.8.5" serde = "1.0.192" +serde_json = "1.0.108" sha2 = "0.10.8" tokio = { version = "1.34.0", features = ["full"] } tracing = "0.1.40" diff --git a/migrations/2023-11-28-150402_article/down.sql b/migrations/2023-11-28-150402_fediwiki_setup/down.sql similarity index 71% rename from migrations/2023-11-28-150402_article/down.sql rename to migrations/2023-11-28-150402_fediwiki_setup/down.sql index 9a5a480..06c62b9 100644 --- a/migrations/2023-11-28-150402_article/down.sql +++ b/migrations/2023-11-28-150402_fediwiki_setup/down.sql @@ -2,4 +2,6 @@ drop table conflict; drop table edit; drop table article; drop table instance_follow; +drop table local_user; +drop table person; drop table instance; diff --git a/migrations/2023-11-28-150402_article/up.sql b/migrations/2023-11-28-150402_fediwiki_setup/up.sql similarity index 69% rename from migrations/2023-11-28-150402_article/up.sql rename to migrations/2023-11-28-150402_fediwiki_setup/up.sql index 82801ea..a79d35d 100644 --- a/migrations/2023-11-28-150402_article/up.sql +++ b/migrations/2023-11-28-150402_fediwiki_setup/up.sql @@ -9,10 +9,27 @@ create table instance ( local bool not null ); +create table person ( + id serial primary key, + username text not null, + ap_id varchar(255) not null unique, + inbox_url text not null, + public_key text not null, + private_key text, + last_refreshed_at timestamptz not null default now(), + local bool not null +); + +create table local_user ( + id serial primary key, + password_encrypted text not null, + person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL +); + create table instance_follow ( id serial primary key, instance_id int REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, - follower_id int REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, + follower_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, pending boolean not null, unique(instance_id, follower_id) ); diff --git a/src/api.rs b/src/api/article.rs similarity index 63% rename from src/api.rs rename to src/api/article.rs index 0b9ff32..540b1a5 100644 --- a/src/api.rs +++ b/src/api/article.rs @@ -1,39 +1,23 @@ use crate::database::article::{ArticleView, DbArticle, DbArticleForm}; use crate::database::conflict::{ApiConflict, DbConflict, DbConflictForm}; use crate::database::edit::{DbEdit, DbEditForm}; -use crate::database::instance::{DbInstance, InstanceView}; +use crate::database::instance::DbInstance; +use crate::database::user::LocalUserView; use crate::database::version::EditVersion; use crate::database::MyDataHandle; use crate::error::MyResult; use crate::federation::activities::create_article::CreateArticle; -use crate::federation::activities::follow::Follow; use crate::federation::activities::submit_article_update; use crate::utils::generate_article_version; use activitypub_federation::config::Data; use activitypub_federation::fetch::object_id::ObjectId; use axum::extract::Query; -use axum::routing::{get, post}; -use axum::{Form, Json, Router}; +use axum::Extension; +use axum::Form; +use axum::Json; use axum_macros::debug_handler; use diffy::create_patch; -use futures::future::try_join_all; use serde::{Deserialize, Serialize}; -use url::Url; - -pub fn api_routes() -> Router { - Router::new() - .route( - "/article", - get(get_article).post(create_article).patch(edit_article), - ) - .route("/article/fork", post(fork_article)) - .route("/edit_conflicts", get(edit_conflicts)) - .route("/resolve_instance", get(resolve_instance)) - .route("/resolve_article", get(resolve_article)) - .route("/instance", get(get_local_instance)) - .route("/instance/follow", post(follow_instance)) - .route("/search", get(search_article)) -} #[derive(Deserialize, Serialize)] pub struct CreateArticleData { @@ -42,7 +26,8 @@ pub struct CreateArticleData { /// Create a new article with empty text, and federate it to followers. #[debug_handler] -async fn create_article( +pub(in crate::api) async fn create_article( + Extension(_user): Extension, data: Data, Form(create_article): Form, ) -> MyResult> { @@ -91,7 +76,8 @@ pub struct EditArticleData { /// /// Conflicts are stored in the database so they can be retrieved later from `/api/v3/edit_conflicts`. #[debug_handler] -async fn edit_article( +pub(in crate::api) async fn edit_article( + Extension(_user): Extension, data: Data, Form(edit_form): Form, ) -> MyResult>> { @@ -137,7 +123,7 @@ pub struct GetArticleData { /// Retrieve an article by ID. It must already be stored in the local database. #[debug_handler] -async fn get_article( +pub(in crate::api) async fn get_article( Query(query): Query, data: Data, ) -> MyResult> { @@ -147,97 +133,6 @@ async fn get_article( )?)) } -#[derive(Deserialize, Serialize)] -pub struct ResolveObject { - pub id: Url, -} - -/// Fetch a remote instance actor. This automatically synchronizes the remote articles collection to -/// the local instance, and allows for interactions such as following. -#[debug_handler] -async fn resolve_instance( - Query(query): Query, - data: Data, -) -> MyResult> { - let instance: DbInstance = ObjectId::from(query.id).dereference(&data).await?; - Ok(Json(instance)) -} - -/// Fetch a remote article, including edits collection. Allows viewing and editing. Note that new -/// article changes can only be received if we follow the instance, or if it is refetched manually. -#[debug_handler] -async fn resolve_article( - Query(query): Query, - data: Data, -) -> MyResult> { - let article: DbArticle = ObjectId::from(query.id).dereference(&data).await?; - let edits = DbEdit::read_for_article(&article, &data.db_connection)?; - let latest_version = edits.last().unwrap().hash.clone(); - Ok(Json(ArticleView { - article, - edits, - latest_version, - })) -} - -/// Retrieve the local instance info. -#[debug_handler] -async fn get_local_instance(data: Data) -> MyResult> { - let local_instance = DbInstance::read_local_view(&data.db_connection)?; - Ok(Json(local_instance)) -} - -#[derive(Deserialize, Serialize, Debug)] -pub struct FollowInstance { - pub id: i32, -} - -/// Make the local instance follow a given remote instance, to receive activities about new and -/// updated articles. -#[debug_handler] -async fn follow_instance( - data: Data, - Form(query): Form, -) -> MyResult<()> { - let local_instance = DbInstance::read_local_instance(&data.db_connection)?; - let target = DbInstance::read(query.id, &data.db_connection)?; - let pending = !target.local; - DbInstance::follow(local_instance.id, target.id, pending, &data)?; - let instance = DbInstance::read(query.id, &data.db_connection)?; - Follow::send(local_instance, instance, &data).await?; - Ok(()) -} - -/// Get a list of all unresolved edit conflicts. -#[debug_handler] -async fn edit_conflicts(data: Data) -> MyResult>> { - let conflicts = DbConflict::list(&data.db_connection)?; - let conflicts: Vec = try_join_all(conflicts.into_iter().map(|c| { - let data = data.reset_request_count(); - async move { c.to_api_conflict(&data).await } - })) - .await? - .into_iter() - .flatten() - .collect(); - Ok(Json(conflicts)) -} - -#[derive(Deserialize, Serialize, Clone)] -pub struct SearchArticleData { - pub query: String, -} - -/// Search articles for matching title or body text. -#[debug_handler] -async fn search_article( - Query(query): Query, - data: Data, -) -> MyResult>> { - let article = DbArticle::search(&query.query, &data.db_connection)?; - Ok(Json(article)) -} - #[derive(Deserialize, Serialize)] pub struct ForkArticleData { // TODO: could add optional param new_title so there is no problem with title collision @@ -249,7 +144,8 @@ pub struct ForkArticleData { /// Fork a remote article to local instance. This is useful if there are disagreements about /// how an article should be edited. #[debug_handler] -async fn fork_article( +pub(in crate::api) async fn fork_article( + Extension(_user): Extension, data: Data, Form(fork_form): Form, ) -> MyResult> { diff --git a/src/api/instance.rs b/src/api/instance.rs new file mode 100644 index 0000000..3a53ca2 --- /dev/null +++ b/src/api/instance.rs @@ -0,0 +1,40 @@ +use crate::database::instance::{DbInstance, InstanceView}; +use crate::database::user::LocalUserView; +use crate::database::MyDataHandle; +use crate::error::MyResult; +use crate::federation::activities::follow::Follow; +use activitypub_federation::config::Data; +use axum::Extension; +use axum::{Form, Json}; +use axum_macros::debug_handler; +use serde::{Deserialize, Serialize}; + +/// Retrieve the local instance info. +#[debug_handler] +pub(in crate::api) async fn get_local_instance( + data: Data, +) -> MyResult> { + let local_instance = DbInstance::read_local_view(&data.db_connection)?; + Ok(Json(local_instance)) +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct FollowInstance { + pub id: i32, +} + +/// Make the local instance follow a given remote instance, to receive activities about new and +/// updated articles. +#[debug_handler] +pub(in crate::api) async fn follow_instance( + Extension(user): Extension, + data: Data, + Form(query): Form, +) -> MyResult<()> { + let target = DbInstance::read(query.id, &data.db_connection)?; + let pending = !target.local; + DbInstance::follow(&user.person, &target, pending, &data)?; + let instance = DbInstance::read(query.id, &data.db_connection)?; + Follow::send(user.person, instance, &data).await?; + Ok(()) +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..c0a14a5 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,133 @@ +use crate::api::article::create_article; +use crate::api::article::{edit_article, fork_article, get_article}; +use crate::api::instance::follow_instance; +use crate::api::instance::get_local_instance; +use crate::api::user::login_user; +use crate::api::user::register_user; +use crate::api::user::validate; +use crate::database::article::{ArticleView, DbArticle}; +use crate::database::conflict::{ApiConflict, DbConflict}; +use crate::database::edit::DbEdit; +use crate::database::instance::DbInstance; +use crate::database::MyDataHandle; +use crate::error::MyResult; +use activitypub_federation::config::Data; +use activitypub_federation::fetch::object_id::ObjectId; +use axum::extract::Query; +use axum::routing::{get, post}; +use axum::{ + extract::TypedHeader, + headers::authorization::{Authorization, Bearer}, + http::Request, + http::StatusCode, + middleware::{self, Next}, + response::Response, +}; +use axum::{Json, Router}; +use axum_macros::debug_handler; +use futures::future::try_join_all; +use serde::{Deserialize, Serialize}; +use tracing::warn; +use url::Url; + +pub mod article; +pub mod instance; +pub mod user; + +pub fn api_routes() -> Router { + Router::new() + .route( + "/article", + get(get_article).post(create_article).patch(edit_article), + ) + .route("/article/fork", post(fork_article)) + .route("/edit_conflicts", get(edit_conflicts)) + .route("/resolve_instance", get(resolve_instance)) + .route("/resolve_article", get(resolve_article)) + .route("/instance", get(get_local_instance)) + .route("/instance/follow", post(follow_instance)) + .route("/search", get(search_article)) + .route("/user/register", post(register_user)) + .route("/user/login", post(login_user)) + .route_layer(middleware::from_fn(auth)) +} + +async fn auth( + data: Data, + auth: Option>>, + mut request: Request, + next: Next, +) -> Result { + if let Some(auth) = auth { + let user = validate(auth.token(), &data).await.map_err(|e| { + warn!("Failed to validate auth token: {e}"); + StatusCode::UNAUTHORIZED + })?; + request.extensions_mut().insert(user); + } + let response = next.run(request).await; + Ok(response) +} + +#[derive(Deserialize, Serialize)] +pub struct ResolveObject { + pub id: Url, +} + +/// Fetch a remote instance actor. This automatically synchronizes the remote articles collection to +/// the local instance, and allows for interactions such as following. +#[debug_handler] +async fn resolve_instance( + Query(query): Query, + data: Data, +) -> MyResult> { + let instance: DbInstance = ObjectId::from(query.id).dereference(&data).await?; + Ok(Json(instance)) +} + +/// Fetch a remote article, including edits collection. Allows viewing and editing. Note that new +/// article changes can only be received if we follow the instance, or if it is refetched manually. +#[debug_handler] +async fn resolve_article( + Query(query): Query, + data: Data, +) -> MyResult> { + let article: DbArticle = ObjectId::from(query.id).dereference(&data).await?; + let edits = DbEdit::read_for_article(&article, &data.db_connection)?; + let latest_version = edits.last().unwrap().hash.clone(); + Ok(Json(ArticleView { + article, + edits, + latest_version, + })) +} + +/// Get a list of all unresolved edit conflicts. +#[debug_handler] +async fn edit_conflicts(data: Data) -> MyResult>> { + let conflicts = DbConflict::list(&data.db_connection)?; + let conflicts: Vec = try_join_all(conflicts.into_iter().map(|c| { + let data = data.reset_request_count(); + async move { c.to_api_conflict(&data).await } + })) + .await? + .into_iter() + .flatten() + .collect(); + Ok(Json(conflicts)) +} + +#[derive(Deserialize, Serialize, Clone)] +pub struct SearchArticleData { + pub query: String, +} + +/// Search articles for matching title or body text. +#[debug_handler] +async fn search_article( + Query(query): Query, + data: Data, +) -> MyResult>> { + let article = DbArticle::search(&query.query, &data.db_connection)?; + Ok(Json(article)) +} diff --git a/src/api/user.rs b/src/api/user.rs new file mode 100644 index 0000000..d70a893 --- /dev/null +++ b/src/api/user.rs @@ -0,0 +1,92 @@ +use crate::database::user::{DbLocalUser, DbPerson, LocalUserView}; +use crate::database::MyDataHandle; +use crate::error::MyResult; +use activitypub_federation::config::Data; +use anyhow::anyhow; +use axum::{Form, Json}; +use axum_macros::debug_handler; +use bcrypt::verify; +use chrono::Utc; +use jsonwebtoken::DecodingKey; +use jsonwebtoken::Validation; +use jsonwebtoken::{decode, get_current_timestamp}; +use jsonwebtoken::{encode, EncodingKey, Header}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + /// local_user.id + pub sub: String, + /// hostname + pub iss: String, + /// Creation time as unix timestamp + pub iat: i64, + /// Expiration time + pub exp: u64, +} + +// TODO: move to config +const SECRET: &[u8] = "secret".as_bytes(); + +pub(in crate::api) fn generate_login_token( + local_user: DbLocalUser, + data: &Data, +) -> MyResult { + let hostname = data.domain().to_string(); + let claims = Claims { + sub: local_user.id.to_string(), + iss: hostname, + iat: Utc::now().timestamp(), + exp: get_current_timestamp(), + }; + + let key = EncodingKey::from_secret(SECRET); + let jwt = encode(&Header::default(), &claims, &key)?; + Ok(LoginResponse { jwt }) +} + +pub async fn validate(jwt: &str, data: &Data) -> MyResult { + let validation = Validation::default(); + let key = DecodingKey::from_secret(SECRET); + let claims = decode::(jwt, &key, &validation)?; + DbPerson::read_local_from_id(claims.claims.sub.parse()?, data) +} + +#[derive(Deserialize, Serialize)] +pub struct RegisterUserData { + pub username: String, + pub password: String, +} + +#[derive(Deserialize, Serialize)] +pub struct LoginResponse { + pub jwt: String, +} + +#[debug_handler] +pub(in crate::api) async fn register_user( + data: Data, + Form(form): Form, +) -> MyResult> { + let user = DbPerson::create_local(form.username, form.password, &data)?; + Ok(Json(generate_login_token(user.local_user, &data)?)) +} + +#[derive(Deserialize, Serialize)] +pub struct LoginUserData { + pub username: String, + pub password: String, +} + +#[debug_handler] +pub(in crate::api) async fn login_user( + data: Data, + Form(form): Form, +) -> MyResult> { + let user = DbPerson::read_local_from_name(&form.username, &data)?; + let valid = verify(&form.password, &user.local_user.password_encrypted)?; + if !valid { + return Err(anyhow!("Invalid login").into()); + } + Ok(Json(generate_login_token(user.local_user, &data)?)) +} diff --git a/src/database/edit.rs b/src/database/edit.rs index 7e43497..e35ce9c 100644 --- a/src/database/edit.rs +++ b/src/database/edit.rs @@ -57,7 +57,7 @@ impl DbEditForm { }) } - pub(crate) fn generate_ap_id( + pub fn generate_ap_id( article: &DbArticle, version: &EditVersion, ) -> MyResult> { diff --git a/src/database/instance.rs b/src/database/instance.rs index 6bd9f95..720dc5e 100644 --- a/src/database/instance.rs +++ b/src/database/instance.rs @@ -1,4 +1,5 @@ use crate::database::schema::{instance, instance_follow}; +use crate::database::user::DbPerson; use crate::database::MyDataHandle; use crate::error::MyResult; use crate::federation::objects::articles_collection::DbArticleCollection; @@ -24,11 +25,11 @@ pub struct DbInstance { pub articles_url: CollectionId, pub inbox_url: String, #[serde(skip)] - pub(crate) public_key: String, + pub public_key: String, #[serde(skip)] - pub(crate) private_key: Option, + pub private_key: Option, #[serde(skip)] - pub(crate) last_refreshed_at: DateTime, + pub last_refreshed_at: DateTime, pub local: bool, } @@ -38,9 +39,9 @@ pub struct DbInstanceForm { pub ap_id: ObjectId, pub articles_url: CollectionId, pub inbox_url: String, - pub(crate) public_key: String, - pub(crate) private_key: Option, - pub(crate) last_refreshed_at: DateTime, + pub public_key: String, + pub private_key: Option, + pub last_refreshed_at: DateTime, pub local: bool, } @@ -48,7 +49,7 @@ pub struct DbInstanceForm { #[diesel(table_name = article, check_for_backend(diesel::pg::Pg))] pub struct InstanceView { pub instance: DbInstance, - pub followers: Vec, + pub followers: Vec, pub following: Vec, } @@ -98,36 +99,36 @@ impl DbInstance { } pub fn follow( - follower_id_: i32, - instance_id_: i32, + follower: &DbPerson, + instance: &DbInstance, pending_: bool, data: &Data, ) -> MyResult<()> { - debug_assert_ne!(follower_id_, instance_id_); use instance_follow::dsl::{follower_id, instance_id, pending}; let mut conn = data.db_connection.lock().unwrap(); let form = ( - instance_id.eq(instance_id_), - follower_id.eq(follower_id_), + instance_id.eq(instance.id), + follower_id.eq(follower.id), pending.eq(pending_), ); - dbg!(follower_id_, instance_id_, pending_); - insert_into(instance_follow::table) + let rows = insert_into(instance_follow::table) .values(form) .on_conflict((instance_id, follower_id)) .do_update() .set(form) .execute(conn.deref_mut())?; + assert_eq!(1, rows); Ok(()) } - pub fn read_followers(id_: i32, conn: &Mutex) -> MyResult> { + pub fn read_followers(id_: i32, conn: &Mutex) -> MyResult> { + use crate::database::schema::person; use instance_follow::dsl::{follower_id, instance_id}; let mut conn = conn.lock().unwrap(); Ok(instance_follow::table - .inner_join(instance::table.on(follower_id.eq(instance::dsl::id))) + .inner_join(person::table.on(follower_id.eq(person::dsl::id))) .filter(instance_id.eq(id_)) - .select(instance::all_columns) + .select(person::all_columns) .get_results(conn.deref_mut())?) } diff --git a/src/database/mod.rs b/src/database/mod.rs index fe457ea..ffa8c14 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -9,6 +9,7 @@ pub mod conflict; pub mod edit; pub mod instance; mod schema; +pub mod user; pub mod version; #[derive(Clone)] diff --git a/src/database/schema.rs b/src/database/schema.rs index dcb23de..66637b1 100644 --- a/src/database/schema.rs +++ b/src/database/schema.rs @@ -57,8 +57,39 @@ diesel::table! { } } +diesel::table! { + local_user (id) { + id -> Int4, + password_encrypted -> Text, + person_id -> Int4, + } +} + +diesel::table! { + person (id) { + id -> Int4, + username -> Text, + #[max_length = 255] + ap_id -> Varchar, + inbox_url -> Text, + public_key -> Text, + private_key -> Nullable, + last_refreshed_at -> Timestamptz, + local -> Bool, + } +} + diesel::joinable!(article -> instance (instance_id)); diesel::joinable!(conflict -> article (article_id)); diesel::joinable!(edit -> article (article_id)); +diesel::joinable!(local_user -> person (person_id)); -diesel::allow_tables_to_appear_in_same_query!(article, conflict, edit, instance, instance_follow,); +diesel::allow_tables_to_appear_in_same_query!( + article, + conflict, + edit, + instance, + instance_follow, + local_user, + person, +); diff --git a/src/database/user.rs b/src/database/user.rs new file mode 100644 index 0000000..bbac9dc --- /dev/null +++ b/src/database/user.rs @@ -0,0 +1,149 @@ +use crate::database::schema::{local_user, person}; +use crate::database::MyDataHandle; +use crate::error::MyResult; +use activitypub_federation::config::Data; +use activitypub_federation::fetch::object_id::ObjectId; +use activitypub_federation::http_signatures::generate_actor_keypair; +use bcrypt::hash; +use bcrypt::DEFAULT_COST; +use chrono::{DateTime, Local, Utc}; +use diesel::ExpressionMethods; +use diesel::QueryDsl; +use diesel::{ + insert_into, AsChangeset, Identifiable, Insertable, PgConnection, Queryable, RunQueryDsl, + Selectable, +}; +use serde::{Deserialize, Serialize}; +use std::ops::DerefMut; +use std::sync::Mutex; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Queryable)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct LocalUserView { + pub person: DbPerson, + pub local_user: DbLocalUser, +} + +/// A user with account registered on local instance. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Queryable, Selectable, Identifiable)] +#[diesel(table_name = local_user, check_for_backend(diesel::pg::Pg))] +pub struct DbLocalUser { + pub id: i32, + pub password_encrypted: String, + pub person_id: i32, +} + +#[derive(Debug, Clone, Insertable, AsChangeset)] +#[diesel(table_name = local_user, check_for_backend(diesel::pg::Pg))] +pub struct DbLocalUserForm { + pub password_encrypted: String, + pub person_id: i32, +} + +/// Federation related data from a local or remote user. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Queryable, Selectable, Identifiable)] +#[diesel(table_name = person, check_for_backend(diesel::pg::Pg))] +pub struct DbPerson { + pub id: i32, + pub username: String, + pub ap_id: ObjectId, + pub inbox_url: String, + #[serde(skip)] + pub public_key: String, + #[serde(skip)] + pub private_key: Option, + #[serde(skip)] + pub last_refreshed_at: DateTime, + pub local: bool, +} + +#[derive(Debug, Clone, Insertable, AsChangeset)] +#[diesel(table_name = person, check_for_backend(diesel::pg::Pg))] +pub struct DbPersonForm { + pub username: String, + pub ap_id: ObjectId, + pub inbox_url: String, + pub public_key: String, + pub private_key: Option, + pub last_refreshed_at: DateTime, + pub local: bool, +} + +impl DbPerson { + pub fn create(person_form: &DbPersonForm, conn: &Mutex) -> MyResult { + let mut conn = conn.lock().unwrap(); + Ok(insert_into(person::table) + .values(person_form) + .on_conflict(person::dsl::ap_id) + .do_update() + .set(person_form) + .get_result::(conn.deref_mut())?) + } + + pub fn create_local( + username: String, + password: String, + data: &Data, + ) -> MyResult { + let mut conn = data.db_connection.lock().unwrap(); + let hostname = data.domain(); + let ap_id = ObjectId::parse(&format!("http://{hostname}/user/{username}"))?; + let inbox_url = format!("http://{hostname}/inbox"); + let keypair = generate_actor_keypair()?; + let person_form = DbPersonForm { + username, + ap_id, + inbox_url, + public_key: keypair.public_key, + private_key: Some(keypair.private_key), + last_refreshed_at: Local::now().into(), + local: true, + }; + + let person = insert_into(person::table) + .values(person_form) + .get_result::(conn.deref_mut())?; + + let local_user_form = DbLocalUserForm { + password_encrypted: hash(password, DEFAULT_COST)?, + person_id: person.id, + }; + + let local_user = insert_into(local_user::table) + .values(local_user_form) + .get_result::(conn.deref_mut())?; + + Ok(LocalUserView { local_user, person }) + } + + pub fn read_from_ap_id( + ap_id: &ObjectId, + data: &Data, + ) -> MyResult { + let mut conn = data.db_connection.lock().unwrap(); + Ok(person::table + .filter(person::dsl::ap_id.eq(ap_id)) + .get_result(conn.deref_mut())?) + } + + pub fn read_local_from_name( + username: &str, + data: &Data, + ) -> MyResult { + let mut conn = data.db_connection.lock().unwrap(); + Ok(person::table + .inner_join(local_user::table) + .filter(person::dsl::local) + .filter(person::dsl::username.eq(username)) + .get_result(conn.deref_mut())?) + } + + pub fn read_local_from_id(id: i32, data: &Data) -> MyResult { + let mut conn = data.db_connection.lock().unwrap(); + Ok(person::table + .inner_join(local_user::table) + .filter(person::dsl::local) + .filter(person::dsl::id.eq(id)) + .get_result(conn.deref_mut())?) + } +} diff --git a/src/error.rs b/src/error.rs index a0b7a85..064f4a4 100644 --- a/src/error.rs +++ b/src/error.rs @@ -5,7 +5,7 @@ use std::fmt::{Display, Formatter}; pub type MyResult = Result; #[derive(Debug)] -pub struct Error(pub(crate) anyhow::Error); +pub struct Error(pub anyhow::Error); impl Display for Error { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { diff --git a/src/federation/activities/accept.rs b/src/federation/activities/accept.rs index 85cc0ae..25a4c7e 100644 --- a/src/federation/activities/accept.rs +++ b/src/federation/activities/accept.rs @@ -1,7 +1,9 @@ use crate::database::instance::DbInstance; use crate::error::MyResult; +use crate::federation::send_activity; use crate::utils::generate_activity_id; use crate::{database::MyDataHandle, federation::activities::follow::Follow}; +use activitypub_federation::traits::Actor; use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, kinds::activity::AcceptType, traits::ActivityHandler, }; @@ -19,14 +21,28 @@ pub struct Accept { } impl Accept { - pub fn new(actor: ObjectId, object: Follow) -> MyResult { - let id = generate_activity_id(actor.inner())?; - Ok(Accept { - actor, + pub async fn send( + local_instance: DbInstance, + object: Follow, + data: &Data, + ) -> MyResult<()> { + let id = generate_activity_id(local_instance.ap_id.inner())?; + let follower = object.actor.dereference(data).await?; + let accept = Accept { + actor: local_instance.ap_id.clone(), object, kind: Default::default(), id, - }) + }; + dbg!(&accept); + send_activity( + &local_instance, + accept, + vec![follower.shared_inbox_or_inbox()], + data, + ) + .await?; + Ok(()) } } @@ -48,10 +64,11 @@ impl ActivityHandler for Accept { } async fn receive(self, data: &Data) -> Result<(), Self::Error> { + dbg!(&self); // add to follows - let local_instance = DbInstance::read_local_instance(&data.db_connection)?; - let actor = self.actor.dereference(data).await?; - DbInstance::follow(local_instance.id, actor.id, false, data)?; + let person = self.object.actor.dereference_local(data).await?; + let instance = self.actor.dereference(data).await?; + DbInstance::follow(&person, &instance, false, data)?; Ok(()) } } diff --git a/src/federation/activities/follow.rs b/src/federation/activities/follow.rs index f38a5dd..efbbeca 100644 --- a/src/federation/activities/follow.rs +++ b/src/federation/activities/follow.rs @@ -1,5 +1,7 @@ use crate::database::instance::DbInstance; +use crate::database::user::DbPerson; use crate::error::MyResult; +use crate::federation::send_activity; use crate::{database::MyDataHandle, federation::activities::accept::Accept, generate_activity_id}; use activitypub_federation::{ config::Data, @@ -13,29 +15,24 @@ use url::Url; #[derive(Deserialize, Serialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct Follow { - pub(crate) actor: ObjectId, - pub(crate) object: ObjectId, + pub actor: ObjectId, + pub object: ObjectId, #[serde(rename = "type")] kind: FollowType, id: Url, } impl Follow { - pub async fn send( - local_instance: DbInstance, - to: DbInstance, - data: &Data, - ) -> MyResult<()> { - let id = generate_activity_id(local_instance.ap_id.inner())?; + pub async fn send(actor: DbPerson, to: DbInstance, data: &Data) -> MyResult<()> { + let id = generate_activity_id(actor.ap_id.inner())?; let follow = Follow { - actor: local_instance.ap_id.clone(), + actor: actor.ap_id.clone(), object: to.ap_id.clone(), kind: Default::default(), id, }; - local_instance - .send(follow, vec![to.shared_inbox_or_inbox()], data) - .await?; + + send_activity(&actor, follow, vec![to.shared_inbox_or_inbox()], data).await?; Ok(()) } } @@ -58,16 +55,14 @@ impl ActivityHandler for Follow { } async fn receive(self, data: &Data) -> Result<(), Self::Error> { + dbg!(&self); let actor = self.actor.dereference(data).await?; let local_instance = DbInstance::read_local_instance(&data.db_connection)?; - DbInstance::follow(actor.id, local_instance.id, false, data)?; + dbg!(&actor.ap_id, &local_instance.ap_id); + DbInstance::follow(&actor, &local_instance, false, data)?; // send back an accept - let follower = self.actor.dereference(data).await?; - let accept = Accept::new(local_instance.ap_id.clone(), self)?; - local_instance - .send(accept, vec![follower.shared_inbox_or_inbox()], data) - .await?; + Accept::send(local_instance, self, data).await?; Ok(()) } } diff --git a/src/federation/activities/reject.rs b/src/federation/activities/reject.rs index 75bf66a..c514c15 100644 --- a/src/federation/activities/reject.rs +++ b/src/federation/activities/reject.rs @@ -11,6 +11,7 @@ use activitypub_federation::{ traits::ActivityHandler, }; +use crate::federation::send_activity; use serde::{Deserialize, Serialize}; use url::Url; @@ -41,9 +42,13 @@ impl RejectEdit { kind: Default::default(), id, }; - local_instance - .send(reject, vec![Url::parse(&user_instance.inbox_url)?], data) - .await?; + send_activity( + &local_instance, + reject, + vec![Url::parse(&user_instance.inbox_url)?], + data, + ) + .await?; Ok(()) } } diff --git a/src/federation/activities/update_remote_article.rs b/src/federation/activities/update_remote_article.rs index e04ca35..ad04cfd 100644 --- a/src/federation/activities/update_remote_article.rs +++ b/src/federation/activities/update_remote_article.rs @@ -7,6 +7,7 @@ use crate::database::instance::DbInstance; use crate::federation::activities::reject::RejectEdit; use crate::federation::activities::update_local_article::UpdateLocalArticle; use crate::federation::objects::edit::ApubEdit; +use crate::federation::send_activity; use crate::utils::generate_activity_id; use activitypub_federation::kinds::activity::UpdateType; use activitypub_federation::{ @@ -47,9 +48,13 @@ impl UpdateRemoteArticle { kind: Default::default(), id, }; - local_instance - .send(update, vec![Url::parse(&article_instance.inbox_url)?], data) - .await?; + send_activity( + &local_instance, + update, + vec![Url::parse(&article_instance.inbox_url)?], + data, + ) + .await?; Ok(()) } } diff --git a/src/federation/mod.rs b/src/federation/mod.rs index 67e6c0f..88d8acb 100644 --- a/src/federation/mod.rs +++ b/src/federation/mod.rs @@ -1,3 +1,34 @@ +use crate::database::MyDataHandle; +use activitypub_federation::activity_sending::SendActivityTask; +use activitypub_federation::config::Data; +use activitypub_federation::protocol::context::WithContext; +use activitypub_federation::traits::{ActivityHandler, Actor}; +use serde::Serialize; +use std::fmt::Debug; +use tracing::log::warn; +use url::Url; + pub mod activities; pub mod objects; pub mod routes; + +pub async fn send_activity( + actor: &ActorType, + activity: Activity, + recipients: Vec, + data: &Data, +) -> Result<(), ::Error> +where + Activity: ActivityHandler + Serialize + Debug + Send + Sync, + ::Error: From, +{ + let activity = WithContext::new_default(activity); + let sends = SendActivityTask::prepare(&activity, actor, recipients, data).await?; + for send in sends { + let send = send.sign_and_send(data).await; + if let Err(e) = send { + warn!("Failed to send activity {:?}: {e}", activity); + } + } + Ok(()) +} diff --git a/src/federation/objects/article.rs b/src/federation/objects/article.rs index 45fe11d..59b67d2 100644 --- a/src/federation/objects/article.rs +++ b/src/federation/objects/article.rs @@ -19,11 +19,11 @@ use url::Url; #[serde(rename_all = "camelCase")] pub struct ApubArticle { #[serde(rename = "type")] - pub(crate) kind: ArticleType, - pub(crate) id: ObjectId, - pub(crate) attributed_to: ObjectId, + pub kind: ArticleType, + pub id: ObjectId, + pub attributed_to: ObjectId, #[serde(deserialize_with = "deserialize_one_or_many")] - pub(crate) to: Vec, + pub to: Vec, pub edits: CollectionId, latest_version: EditVersion, content: String, diff --git a/src/federation/objects/articles_collection.rs b/src/federation/objects/articles_collection.rs index d4efeb8..a53d4dd 100644 --- a/src/federation/objects/articles_collection.rs +++ b/src/federation/objects/articles_collection.rs @@ -16,10 +16,10 @@ use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ArticleCollection { - pub(crate) r#type: CollectionType, - pub(crate) id: Url, - pub(crate) total_items: i32, - pub(crate) items: Vec, + pub r#type: CollectionType, + pub id: Url, + pub total_items: i32, + pub items: Vec, } #[derive(Clone, Debug)] diff --git a/src/federation/objects/edits_collection.rs b/src/federation/objects/edits_collection.rs index 3afe139..80b4c66 100644 --- a/src/federation/objects/edits_collection.rs +++ b/src/federation/objects/edits_collection.rs @@ -18,10 +18,10 @@ use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ApubEditCollection { - pub(crate) r#type: OrderedCollectionType, - pub(crate) id: Url, - pub(crate) total_items: i32, - pub(crate) items: Vec, + pub r#type: OrderedCollectionType, + pub id: Url, + pub total_items: i32, + pub items: Vec, } #[derive(Clone, Debug)] diff --git a/src/federation/objects/instance.rs b/src/federation/objects/instance.rs index 6c824c5..f823622 100644 --- a/src/federation/objects/instance.rs +++ b/src/federation/objects/instance.rs @@ -2,6 +2,7 @@ use crate::database::instance::{DbInstance, DbInstanceForm}; use crate::database::MyDataHandle; use crate::error::{Error, MyResult}; use crate::federation::objects::articles_collection::DbArticleCollection; +use crate::federation::send_activity; use activitypub_federation::activity_sending::SendActivityTask; use activitypub_federation::fetch::collection_id::CollectionId; use activitypub_federation::kinds::actor::ServiceType; @@ -62,28 +63,7 @@ impl DbInstance { .into_iter() .map(|i| Url::parse(&i.inbox_url).unwrap()), ); - self.send(activity, inboxes, data).await?; - Ok(()) - } - - pub async fn send( - &self, - activity: Activity, - recipients: Vec, - data: &Data, - ) -> Result<(), ::Error> - where - Activity: ActivityHandler + Serialize + Debug + Send + Sync, - ::Error: From, - { - let activity = WithContext::new_default(activity); - let sends = SendActivityTask::prepare(&activity, self, recipients, data).await?; - for send in sends { - let send = send.sign_and_send(data).await; - if let Err(e) = send { - warn!("Failed to send activity {:?}: {e}", activity); - } - } + send_activity(self, activity, inboxes, data).await?; Ok(()) } } diff --git a/src/federation/objects/mod.rs b/src/federation/objects/mod.rs index 6d5b4b1..0b14474 100644 --- a/src/federation/objects/mod.rs +++ b/src/federation/objects/mod.rs @@ -3,3 +3,4 @@ pub mod articles_collection; pub mod edit; pub mod edits_collection; pub mod instance; +pub mod user; diff --git a/src/federation/objects/user.rs b/src/federation/objects/user.rs new file mode 100644 index 0000000..8cf4812 --- /dev/null +++ b/src/federation/objects/user.rs @@ -0,0 +1,93 @@ +use crate::database::user::{DbPerson, DbPersonForm}; +use crate::database::MyDataHandle; +use crate::error::Error; +use activitypub_federation::kinds::actor::PersonType; +use activitypub_federation::{ + config::Data, + fetch::object_id::ObjectId, + protocol::{public_key::PublicKey, verification::verify_domains_match}, + traits::{Actor, Object}, +}; +use chrono::{DateTime, Local, Utc}; +use serde::{Deserialize, Serialize}; +use std::fmt::Debug; +use url::Url; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ApubUser { + #[serde(rename = "type")] + kind: PersonType, + id: ObjectId, + preferred_username: String, + inbox: Url, + public_key: PublicKey, +} + +#[async_trait::async_trait] +impl Object for DbPerson { + type DataType = MyDataHandle; + type Kind = ApubUser; + type Error = Error; + + fn last_refreshed_at(&self) -> Option> { + Some(self.last_refreshed_at) + } + + async fn read_from_id( + object_id: Url, + data: &Data, + ) -> Result, Self::Error> { + Ok(DbPerson::read_from_ap_id(&object_id.into(), data).ok()) + } + + async fn into_json(self, _data: &Data) -> Result { + Ok(ApubUser { + kind: Default::default(), + id: self.ap_id.clone(), + preferred_username: self.username.clone(), + inbox: Url::parse(&self.inbox_url)?, + public_key: self.public_key(), + }) + } + + async fn verify( + json: &Self::Kind, + expected_domain: &Url, + _data: &Data, + ) -> Result<(), Self::Error> { + verify_domains_match(json.id.inner(), expected_domain)?; + Ok(()) + } + + async fn from_json(json: Self::Kind, data: &Data) -> Result { + let form = DbPersonForm { + username: json.preferred_username, + ap_id: json.id, + inbox_url: json.inbox.to_string(), + public_key: json.public_key.public_key_pem, + private_key: None, + last_refreshed_at: Local::now().into(), + local: false, + }; + DbPerson::create(&form, &data.db_connection) + } +} + +impl Actor for DbPerson { + fn id(&self) -> Url { + self.ap_id.inner().clone() + } + + fn public_key_pem(&self) -> &str { + &self.public_key + } + + fn private_key_pem(&self) -> Option { + self.private_key.clone() + } + + fn inbox(&self) -> Url { + Url::parse(&self.inbox_url).unwrap() + } +} diff --git a/src/federation/routes.rs b/src/federation/routes.rs index 2666e01..f3c0166 100644 --- a/src/federation/routes.rs +++ b/src/federation/routes.rs @@ -1,7 +1,8 @@ use crate::database::article::DbArticle; use crate::database::instance::DbInstance; +use crate::database::user::DbPerson; use crate::database::MyDataHandle; -use crate::error::MyResult; +use crate::error::{Error, MyResult}; use crate::federation::activities::accept::Accept; use crate::federation::activities::create_article::CreateArticle; use crate::federation::activities::follow::Follow; @@ -12,10 +13,12 @@ use crate::federation::objects::article::ApubArticle; use crate::federation::objects::articles_collection::{ArticleCollection, DbArticleCollection}; use crate::federation::objects::edits_collection::{ApubEditCollection, DbEditCollection}; use crate::federation::objects::instance::ApubInstance; +use crate::federation::objects::user::ApubUser; use activitypub_federation::axum::inbox::{receive_activity, ActivityData}; use activitypub_federation::axum::json::FederationJson; use activitypub_federation::config::Data; use activitypub_federation::protocol::context::WithContext; +use activitypub_federation::traits::Actor; use activitypub_federation::traits::Object; use activitypub_federation::traits::{ActivityHandler, Collection}; use axum::extract::Path; @@ -23,12 +26,14 @@ use axum::response::IntoResponse; use axum::routing::{get, post}; use axum::Router; use axum_macros::debug_handler; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use url::Url; pub fn federation_routes() -> Router { Router::new() .route("/", get(http_get_instance)) + .route("/user/:name", get(http_get_person)) .route("/all_articles", get(http_get_all_articles)) .route("/article/:title", get(http_get_article)) .route("/article/:title/edits", get(http_get_article_edits)) @@ -44,6 +49,16 @@ async fn http_get_instance( Ok(FederationJson(WithContext::new_default(json_instance))) } +#[debug_handler] +async fn http_get_person( + Path(name): Path, + data: Data, +) -> MyResult>> { + let person = DbPerson::read_local_from_name(&name, &data)?.person; + let json_person = person.into_json(&data).await?; + Ok(FederationJson(WithContext::new_default(json_person))) +} + #[debug_handler] async fn http_get_all_articles( data: Data, @@ -91,6 +106,119 @@ pub async fn http_post_inbox( data: Data, activity_data: ActivityData, ) -> impl IntoResponse { - receive_activity::, DbInstance, MyDataHandle>(activity_data, &data) - .await + receive_activity::, UserOrInstance, MyDataHandle>( + activity_data, + &data, + ) + .await +} + +#[derive(Clone, Debug)] +pub enum UserOrInstance { + User(DbPerson), + Instance(DbInstance), +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(untagged)] +pub enum PersonOrInstance { + Person(ApubUser), + Instance(ApubInstance), +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum PersonOrInstanceType { + Person, + Group, +} + +#[async_trait::async_trait] +impl Object for UserOrInstance { + type DataType = MyDataHandle; + type Kind = PersonOrInstance; + type Error = Error; + + fn last_refreshed_at(&self) -> Option> { + Some(match self { + UserOrInstance::User(p) => p.last_refreshed_at, + UserOrInstance::Instance(p) => p.last_refreshed_at, + }) + } + + #[tracing::instrument(skip_all)] + async fn read_from_id( + object_id: Url, + data: &Data, + ) -> Result, Error> { + let person = DbPerson::read_from_id(object_id.clone(), data).await?; + Ok(match person { + Some(o) => Some(UserOrInstance::User(o)), + None => DbInstance::read_from_id(object_id, data) + .await? + .map(UserOrInstance::Instance), + }) + } + + #[tracing::instrument(skip_all)] + async fn delete(self, data: &Data) -> Result<(), Error> { + match self { + UserOrInstance::User(p) => p.delete(data).await, + UserOrInstance::Instance(p) => p.delete(data).await, + } + } + + async fn into_json(self, _data: &Data) -> Result { + unimplemented!() + } + + #[tracing::instrument(skip_all)] + async fn verify( + apub: &Self::Kind, + expected_domain: &Url, + data: &Data, + ) -> Result<(), Error> { + match apub { + PersonOrInstance::Person(a) => DbPerson::verify(a, expected_domain, data).await, + PersonOrInstance::Instance(a) => DbInstance::verify(a, expected_domain, data).await, + } + } + + #[tracing::instrument(skip_all)] + async fn from_json(apub: Self::Kind, data: &Data) -> Result { + Ok(match apub { + PersonOrInstance::Person(p) => { + UserOrInstance::User(DbPerson::from_json(p, data).await?) + } + PersonOrInstance::Instance(p) => { + UserOrInstance::Instance(DbInstance::from_json(p, data).await?) + } + }) + } +} + +impl Actor for UserOrInstance { + fn id(&self) -> Url { + match self { + UserOrInstance::User(u) => u.id(), + UserOrInstance::Instance(c) => c.id(), + } + } + + fn public_key_pem(&self) -> &str { + match self { + UserOrInstance::User(p) => p.public_key_pem(), + UserOrInstance::Instance(p) => p.public_key_pem(), + } + } + + fn private_key_pem(&self) -> Option { + match self { + UserOrInstance::User(p) => p.private_key_pem(), + UserOrInstance::Instance(p) => p.private_key_pem(), + } + } + + fn inbox(&self) -> Url { + unimplemented!() + } } diff --git a/src/lib.rs b/src/lib.rs index bf4d242..ecf2299 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,3 @@ -use crate::api::api_routes; use crate::database::instance::{DbInstance, DbInstanceForm}; use crate::database::MyData; use crate::error::MyResult; @@ -8,6 +7,7 @@ use activitypub_federation::config::{FederationConfig, FederationMiddleware}; use activitypub_federation::fetch::collection_id::CollectionId; use activitypub_federation::fetch::object_id::ObjectId; use activitypub_federation::http_signatures::generate_actor_keypair; +use api::api_routes; use axum::{Router, Server}; use chrono::Local; use diesel::Connection; diff --git a/tests/common.rs b/tests/common.rs index 68aa86f..a1b3a7b 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -1,7 +1,9 @@ use anyhow::anyhow; -use fediwiki::api::{ - CreateArticleData, EditArticleData, FollowInstance, GetArticleData, ResolveObject, -}; +use fediwiki::api::article::{CreateArticleData, EditArticleData, ForkArticleData, GetArticleData}; +use fediwiki::api::instance::FollowInstance; +use fediwiki::api::user::RegisterUserData; +use fediwiki::api::user::{LoginResponse, LoginUserData}; +use fediwiki::api::ResolveObject; use fediwiki::database::article::ArticleView; use fediwiki::database::conflict::ApiConflict; use fediwiki::database::instance::DbInstance; @@ -12,6 +14,7 @@ use reqwest::{Client, RequestBuilder, StatusCode}; use serde::de::Deserialize; use serde::ser::Serialize; use std::env::current_dir; +use std::fs::create_dir_all; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicI32, Ordering}; use std::sync::Once; @@ -30,7 +33,7 @@ pub struct TestData { } impl TestData { - pub fn start() -> Self { + pub async fn start() -> Self { static INIT: Once = Once::new(); INIT.call_once(|| { env_logger::builder() @@ -66,9 +69,9 @@ impl TestData { } Self { - alpha: FediwikiInstance::start(alpha_db_path, port_alpha), - beta: FediwikiInstance::start(beta_db_path, port_beta), - gamma: FediwikiInstance::start(gamma_db_path, port_gamma), + alpha: FediwikiInstance::start(alpha_db_path, port_alpha, "alpha").await, + beta: FediwikiInstance::start(beta_db_path, port_beta, "beta").await, + gamma: FediwikiInstance::start(gamma_db_path, port_gamma, "gamma").await, } } @@ -82,14 +85,17 @@ impl TestData { /// Generate a unique db path for each postgres so that tests can run in parallel. fn generate_db_path(name: &'static str, port: i32) -> String { - format!( + let path = format!( "{}/target/test_db/{name}-{port}", current_dir().unwrap().display() - ) + ); + create_dir_all(&path).unwrap(); + path } pub struct FediwikiInstance { pub hostname: String, + pub jwt: String, db_path: String, db_handle: JoinHandle<()>, } @@ -106,16 +112,21 @@ impl FediwikiInstance { }) } - fn start(db_path: String, port: i32) -> Self { + async fn start(db_path: String, port: i32, username: &str) -> Self { let db_url = format!("postgresql://lemmy:password@/lemmy?host={db_path}"); let hostname = format!("localhost:{port}"); let hostname_ = hostname.clone(); let handle = tokio::task::spawn(async move { start(&hostname_, &db_url).await.unwrap(); }); + // wait a moment for the server to start + tokio::time::sleep(Duration::from_millis(100)).await; + let register_res = register(&hostname, username, "hunter2").await.unwrap(); + assert!(!register_res.jwt.is_empty()); Self { - db_path, + jwt: register_res.jwt, hostname, + db_path, db_handle: handle, } } @@ -135,11 +146,16 @@ impl FediwikiInstance { pub const TEST_ARTICLE_DEFAULT_TEXT: &str = "some\nexample\ntext\n"; -pub async fn create_article(hostname: &str, title: String) -> MyResult { +pub async fn create_article(instance: &FediwikiInstance, title: String) -> MyResult { let create_form = CreateArticleData { title: title.clone(), }; - let article: ArticleView = post(hostname, "article", &create_form).await?; + let req = CLIENT + .post(format!("http://{}/api/v1/article", &instance.hostname)) + .form(&create_form) + .bearer_auth(&instance.jwt); + let article: ArticleView = handle_json_res(req).await?; + // create initial edit to ensure that conflicts are generated (there are no conflicts on empty file) let edit_form = EditArticleData { article_id: article.article.id, @@ -147,7 +163,7 @@ pub async fn create_article(hostname: &str, title: String) -> MyResult MyResult { @@ -156,19 +172,23 @@ pub async fn get_article(hostname: &str, article_id: i32) -> MyResult MyResult> { let req = CLIENT - .patch(format!("http://{}/api/v1/article", hostname)) - .form(edit_form); + .patch(format!("http://{}/api/v1/article", instance.hostname)) + .form(edit_form) + .bearer_auth(&instance.jwt); handle_json_res(req).await } -pub async fn edit_article(hostname: &str, edit_form: &EditArticleData) -> MyResult { - let edit_res = edit_article_with_conflict(hostname, edit_form).await?; +pub async fn edit_article( + instance: &FediwikiInstance, + edit_form: &EditArticleData, +) -> MyResult { + let edit_res = edit_article_with_conflict(instance, edit_form).await?; assert!(edit_res.is_none()); - get_article(hostname, edit_form.article_id).await + get_article(&instance.hostname, edit_form.article_id).await } pub async fn get(hostname: &str, endpoint: &str) -> MyResult @@ -190,46 +210,82 @@ where handle_json_res(req).await } -pub async fn post(hostname: &str, endpoint: &str, form: &T) -> MyResult -where - R: for<'de> Deserialize<'de>, -{ +pub async fn fork_article( + instance: &FediwikiInstance, + form: &ForkArticleData, +) -> MyResult { let req = CLIENT - .post(format!("http://{}/api/v1/{}", hostname, endpoint)) - .form(form); + .post(format!("http://{}/api/v1/article/fork", instance.hostname)) + .form(form) + .bearer_auth(&instance.jwt); handle_json_res(req).await } -async fn handle_json_res(req: RequestBuilder) -> MyResult +pub async fn handle_json_res(req: RequestBuilder) -> MyResult where T: for<'de> Deserialize<'de>, { let res = req.send().await?; - if res.status() == StatusCode::OK { - Ok(res.json().await?) + let status = res.status(); + let text = res.text().await?; + if status == StatusCode::OK { + Ok(serde_json::from_str(&text).map_err(|e| anyhow!("Json error on {text}: {e}"))?) } else { - let text = res.text().await?; - Err(anyhow!("Post API response {text}").into()) + Err(anyhow!("API error: {text}").into()) } } -pub async fn follow_instance(api_instance: &str, follow_instance: &str) -> MyResult<()> { +pub async fn follow_instance(instance: &FediwikiInstance, follow_instance: &str) -> MyResult<()> { // fetch beta instance on alpha let resolve_form = ResolveObject { id: Url::parse(&format!("http://{}", follow_instance))?, }; let instance_resolved: DbInstance = - get_query(api_instance, "resolve_instance", Some(resolve_form)).await?; + get_query(&instance.hostname, "resolve_instance", Some(resolve_form)).await?; // send follow let follow_form = FollowInstance { id: instance_resolved.id, }; // cant use post helper because follow doesnt return json - CLIENT - .post(format!("http://{}/api/v1/instance/follow", api_instance)) + let res = CLIENT + .post(format!( + "http://{}/api/v1/instance/follow", + instance.hostname + )) .form(&follow_form) + .bearer_auth(&instance.jwt) .send() .await?; - Ok(()) + if res.status() == StatusCode::OK { + Ok(()) + } else { + Err(anyhow!("API error: {}", res.text().await?).into()) + } +} + +pub async fn register(hostname: &str, username: &str, password: &str) -> MyResult { + let register_form = RegisterUserData { + username: username.to_string(), + password: password.to_string(), + }; + let req = CLIENT + .post(format!("http://{}/api/v1/user/register", hostname)) + .form(®ister_form); + handle_json_res(req).await +} + +pub async fn login( + instance: &FediwikiInstance, + username: &str, + password: &str, +) -> MyResult { + let login_form = LoginUserData { + username: username.to_string(), + password: password.to_string(), + }; + let req = CLIENT + .post(format!("http://{}/api/v1/user/login", instance.hostname)) + .form(&login_form); + handle_json_res(req).await } diff --git a/tests/test.rs b/tests/test.rs index feb0709..589cf58 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -2,27 +2,29 @@ extern crate fediwiki; mod common; +use crate::common::register; use crate::common::{ create_article, edit_article, edit_article_with_conflict, follow_instance, get_article, - get_query, post, TestData, TEST_ARTICLE_DEFAULT_TEXT, + get_query, TestData, CLIENT, TEST_ARTICLE_DEFAULT_TEXT, }; +use crate::common::{fork_article, handle_json_res, login}; use common::get; -use fediwiki::api::{EditArticleData, ForkArticleData, ResolveObject, SearchArticleData}; +use fediwiki::api::article::{CreateArticleData, EditArticleData, ForkArticleData}; +use fediwiki::api::{ResolveObject, SearchArticleData}; use fediwiki::database::article::{ArticleView, DbArticle}; -use fediwiki::error::MyResult; - use fediwiki::database::conflict::ApiConflict; use fediwiki::database::instance::{DbInstance, InstanceView}; +use fediwiki::error::MyResult; use pretty_assertions::{assert_eq, assert_ne}; use url::Url; #[tokio::test] async fn test_create_read_and_edit_article() -> MyResult<()> { - let data = TestData::start(); + let data = TestData::start().await; // create article let title = "Manu_Chao".to_string(); - let create_res = create_article(&data.alpha.hostname, title.clone()).await?; + let create_res = create_article(&data.alpha, title.clone()).await?; assert_eq!(title, create_res.article.title); assert!(create_res.article.local); @@ -43,7 +45,7 @@ async fn test_create_read_and_edit_article() -> MyResult<()> { previous_version_id: get_res.latest_version, resolve_conflict_id: None, }; - let edit_res = edit_article(&data.alpha.hostname, &edit_form).await?; + let edit_res = edit_article(&data.alpha, &edit_form).await?; assert_eq!(edit_form.new_text, edit_res.article.text); assert_eq!(2, edit_res.edits.len()); @@ -60,15 +62,15 @@ async fn test_create_read_and_edit_article() -> MyResult<()> { #[tokio::test] async fn test_create_duplicate_article() -> MyResult<()> { - let data = TestData::start(); + let data = TestData::start().await; // create article let title = "Manu_Chao".to_string(); - let create_res = create_article(&data.alpha.hostname, title.clone()).await?; + let create_res = create_article(&data.alpha, title.clone()).await?; assert_eq!(title, create_res.article.title); assert!(create_res.article.local); - let create_res = create_article(&data.alpha.hostname, title.clone()).await; + let create_res = create_article(&data.alpha, title.clone()).await; assert!(create_res.is_err()); data.stop() @@ -76,7 +78,7 @@ async fn test_create_duplicate_article() -> MyResult<()> { #[tokio::test] async fn test_follow_instance() -> MyResult<()> { - let data = TestData::start(); + let data = TestData::start().await; // check initial state let alpha_instance: InstanceView = get(&data.alpha.hostname, "instance").await?; @@ -86,7 +88,7 @@ async fn test_follow_instance() -> MyResult<()> { assert_eq!(0, beta_instance.followers.len()); assert_eq!(0, beta_instance.following.len()); - follow_instance(&data.alpha.hostname, &data.beta.hostname).await?; + follow_instance(&data.alpha, &data.beta.hostname).await?; // check that follow was federated let alpha_instance: InstanceView = get(&data.alpha.hostname, "instance").await?; @@ -100,9 +102,10 @@ async fn test_follow_instance() -> MyResult<()> { let beta_instance: InstanceView = get(&data.beta.hostname, "instance").await?; assert_eq!(0, beta_instance.following.len()); assert_eq!(1, beta_instance.followers.len()); + // TODO: compare full ap_id of alpha user, but its not available through api yet assert_eq!( - alpha_instance.instance.ap_id, - beta_instance.followers[0].ap_id + alpha_instance.instance.ap_id.inner().domain(), + beta_instance.followers[0].ap_id.inner().domain() ); data.stop() @@ -110,11 +113,11 @@ async fn test_follow_instance() -> MyResult<()> { #[tokio::test] async fn test_synchronize_articles() -> MyResult<()> { - let data = TestData::start(); + let data = TestData::start().await; // create article on alpha let title = "Manu_Chao".to_string(); - let create_res = create_article(&data.alpha.hostname, title.clone()).await?; + let create_res = create_article(&data.alpha, title.clone()).await?; assert_eq!(title, create_res.article.title); assert_eq!(1, create_res.edits.len()); assert!(create_res.article.local); @@ -126,7 +129,7 @@ async fn test_synchronize_articles() -> MyResult<()> { previous_version_id: create_res.latest_version, resolve_conflict_id: None, }; - edit_article(&data.alpha.hostname, &edit_form).await?; + edit_article(&data.alpha, &edit_form).await?; // article is not yet on beta let get_res = get_article(&data.beta.hostname, create_res.article.id).await; @@ -156,13 +159,13 @@ async fn test_synchronize_articles() -> MyResult<()> { #[tokio::test] async fn test_edit_local_article() -> MyResult<()> { - let data = TestData::start(); + let data = TestData::start().await; - follow_instance(&data.alpha.hostname, &data.beta.hostname).await?; + follow_instance(&data.alpha, &data.beta.hostname).await?; // create new article let title = "Manu_Chao".to_string(); - let create_res = create_article(&data.beta.hostname, title.clone()).await?; + let create_res = create_article(&data.beta, title.clone()).await?; assert_eq!(title, create_res.article.title); assert!(create_res.article.local); @@ -180,7 +183,7 @@ async fn test_edit_local_article() -> MyResult<()> { previous_version_id: get_res.latest_version, resolve_conflict_id: None, }; - let edit_res = edit_article(&data.beta.hostname, &edit_form).await?; + let edit_res = edit_article(&data.beta, &edit_form).await?; assert_eq!(edit_res.article.text, edit_form.new_text); assert_eq!(edit_res.edits.len(), 2); assert!(edit_res.edits[0] @@ -199,14 +202,14 @@ async fn test_edit_local_article() -> MyResult<()> { #[tokio::test] async fn test_edit_remote_article() -> MyResult<()> { - let data = TestData::start(); + let data = TestData::start().await; - follow_instance(&data.alpha.hostname, &data.beta.hostname).await?; - follow_instance(&data.gamma.hostname, &data.beta.hostname).await?; + follow_instance(&data.alpha, &data.beta.hostname).await?; + follow_instance(&data.gamma, &data.beta.hostname).await?; // create new article let title = "Manu_Chao".to_string(); - let create_res = create_article(&data.beta.hostname, title.clone()).await?; + let create_res = create_article(&data.beta, title.clone()).await?; assert_eq!(title, create_res.article.title); assert!(create_res.article.local); @@ -226,7 +229,7 @@ async fn test_edit_remote_article() -> MyResult<()> { previous_version_id: get_res.latest_version, resolve_conflict_id: None, }; - let edit_res = edit_article(&data.alpha.hostname, &edit_form).await?; + let edit_res = edit_article(&data.alpha, &edit_form).await?; assert_eq!(edit_form.new_text, edit_res.article.text); assert_eq!(2, edit_res.edits.len()); assert!(!edit_res.article.local); @@ -251,11 +254,11 @@ async fn test_edit_remote_article() -> MyResult<()> { #[tokio::test] async fn test_local_edit_conflict() -> MyResult<()> { - let data = TestData::start(); + let data = TestData::start().await; // create new article let title = "Manu_Chao".to_string(); - let create_res = create_article(&data.alpha.hostname, title.clone()).await?; + let create_res = create_article(&data.alpha, title.clone()).await?; assert_eq!(title, create_res.article.title); assert!(create_res.article.local); @@ -266,7 +269,7 @@ async fn test_local_edit_conflict() -> MyResult<()> { previous_version_id: create_res.latest_version.clone(), resolve_conflict_id: None, }; - let edit_res = edit_article(&data.alpha.hostname, &edit_form).await?; + let edit_res = edit_article(&data.alpha, &edit_form).await?; assert_eq!(edit_res.article.text, edit_form.new_text); assert_eq!(2, edit_res.edits.len()); @@ -277,7 +280,7 @@ async fn test_local_edit_conflict() -> MyResult<()> { previous_version_id: create_res.latest_version, resolve_conflict_id: None, }; - let edit_res = edit_article_with_conflict(&data.alpha.hostname, &edit_form) + let edit_res = edit_article_with_conflict(&data.alpha, &edit_form) .await? .unwrap(); assert_eq!("<<<<<<< ours\nIpsum Lorem\n||||||| original\nsome\nexample\ntext\n=======\nLorem Ipsum\n>>>>>>> theirs\n", edit_res.three_way_merge); @@ -293,7 +296,7 @@ async fn test_local_edit_conflict() -> MyResult<()> { previous_version_id: edit_res.previous_version_id, resolve_conflict_id: Some(edit_res.id), }; - let edit_res = edit_article(&data.alpha.hostname, &edit_form).await?; + let edit_res = edit_article(&data.alpha, &edit_form).await?; assert_eq!(edit_form.new_text, edit_res.article.text); let conflicts: Vec = @@ -305,13 +308,13 @@ async fn test_local_edit_conflict() -> MyResult<()> { #[tokio::test] async fn test_federated_edit_conflict() -> MyResult<()> { - let data = TestData::start(); + let data = TestData::start().await; - follow_instance(&data.alpha.hostname, &data.beta.hostname).await?; + follow_instance(&data.alpha, &data.beta.hostname).await?; // create new article let title = "Manu_Chao".to_string(); - let create_res = create_article(&data.beta.hostname, title.clone()).await?; + let create_res = create_article(&data.beta, title.clone()).await?; assert_eq!(title, create_res.article.title); assert!(create_res.article.local); @@ -334,7 +337,7 @@ async fn test_federated_edit_conflict() -> MyResult<()> { previous_version_id: create_res.latest_version.clone(), resolve_conflict_id: None, }; - let edit_res = edit_article(&data.alpha.hostname, &edit_form).await?; + let edit_res = edit_article(&data.alpha, &edit_form).await?; assert_eq!(edit_res.article.text, edit_form.new_text); assert_eq!(2, edit_res.edits.len()); assert!(!edit_res.article.local); @@ -351,7 +354,7 @@ async fn test_federated_edit_conflict() -> MyResult<()> { previous_version_id: create_res.latest_version, resolve_conflict_id: None, }; - let edit_res = edit_article(&data.gamma.hostname, &edit_form).await?; + let edit_res = edit_article(&data.gamma, &edit_form).await?; assert_ne!(edit_form.new_text, edit_res.article.text); assert_eq!(1, edit_res.edits.len()); assert!(!edit_res.article.local); @@ -367,7 +370,7 @@ async fn test_federated_edit_conflict() -> MyResult<()> { previous_version_id: conflicts[0].previous_version_id.clone(), resolve_conflict_id: Some(conflicts[0].id.clone()), }; - let edit_res = edit_article(&data.gamma.hostname, &edit_form).await?; + let edit_res = edit_article(&data.gamma, &edit_form).await?; assert_eq!(edit_form.new_text, edit_res.article.text); assert_eq!(3, edit_res.edits.len()); @@ -380,11 +383,11 @@ async fn test_federated_edit_conflict() -> MyResult<()> { #[tokio::test] async fn test_overlapping_edits_no_conflict() -> MyResult<()> { - let data = TestData::start(); + let data = TestData::start().await; // create new article let title = "Manu_Chao".to_string(); - let create_res = create_article(&data.alpha.hostname, title.clone()).await?; + let create_res = create_article(&data.alpha, title.clone()).await?; assert_eq!(title, create_res.article.title); assert!(create_res.article.local); @@ -395,7 +398,7 @@ async fn test_overlapping_edits_no_conflict() -> MyResult<()> { previous_version_id: create_res.latest_version.clone(), resolve_conflict_id: None, }; - let edit_res = edit_article(&data.alpha.hostname, &edit_form).await?; + let edit_res = edit_article(&data.alpha, &edit_form).await?; assert_eq!(edit_res.article.text, edit_form.new_text); assert_eq!(2, edit_res.edits.len()); @@ -406,7 +409,7 @@ async fn test_overlapping_edits_no_conflict() -> MyResult<()> { previous_version_id: create_res.latest_version, resolve_conflict_id: None, }; - let edit_res = edit_article(&data.alpha.hostname, &edit_form).await?; + let edit_res = edit_article(&data.alpha, &edit_form).await?; let conflicts: Vec = get_query(&data.alpha.hostname, "edit_conflicts", None::<()>).await?; assert_eq!(0, conflicts.len()); @@ -418,11 +421,11 @@ async fn test_overlapping_edits_no_conflict() -> MyResult<()> { #[tokio::test] async fn test_fork_article() -> MyResult<()> { - let data = TestData::start(); + let data = TestData::start().await; // create article let title = "Manu_Chao".to_string(); - let create_res = create_article(&data.alpha.hostname, title.clone()).await?; + let create_res = create_article(&data.alpha, title.clone()).await?; assert_eq!(title, create_res.article.title); assert!(create_res.article.local); @@ -439,7 +442,7 @@ async fn test_fork_article() -> MyResult<()> { let fork_form = ForkArticleData { article_id: resolved_article.id, }; - let fork_res: ArticleView = post(&data.beta.hostname, "article/fork", &fork_form).await?; + let fork_res = fork_article(&data.beta, &fork_form).await?; let forked_article = fork_res.article; assert_eq!(resolved_article.title, forked_article.title); assert_eq!(resolved_article.text, forked_article.text); @@ -464,3 +467,32 @@ async fn test_fork_article() -> MyResult<()> { data.stop() } + +#[tokio::test] +async fn test_user_registration_login() -> MyResult<()> { + let data = TestData::start().await; + let username = "my_user"; + let password = "hunter2"; + let register = register(&data.alpha.hostname, username, password).await?; + assert!(!register.jwt.is_empty()); + + let invalid_login = login(&data.alpha, username, "asd123").await; + assert!(invalid_login.is_err()); + + let valid_login = login(&data.alpha, username, password).await?; + assert!(!valid_login.jwt.is_empty()); + + let title = "Manu_Chao".to_string(); + let create_form = CreateArticleData { + title: title.clone(), + }; + + let req = CLIENT + .post(format!("http://{}/api/v1/article", &data.alpha.hostname)) + .form(&create_form) + .bearer_auth(valid_login.jwt); + let create_res: ArticleView = handle_json_res(req).await?; + assert_eq!(title, create_res.article.title); + + data.stop() +}