1
0
Fork 0
mirror of https://github.com/Nutomic/ibis.git synced 2024-11-29 12:01:09 +00:00

Merge pull request #13 from Nutomic/add-user

Add user
This commit is contained in:
Nutomic 2023-12-14 17:08:50 +01:00 committed by GitHub
commit e568f109f9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1182 additions and 287 deletions

187
Cargo.lock generated
View file

@ -232,6 +232,19 @@ version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" 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]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@ -253,6 +266,16 @@ dependencies = [
"generic-array", "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]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.14.0" version = "3.14.0"
@ -338,6 +361,16 @@ dependencies = [
"windows-targets", "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]] [[package]]
name = "concurrent-queue" name = "concurrent-queue"
version = "2.3.0" version = "2.3.0"
@ -449,6 +482,15 @@ dependencies = [
"syn 1.0.109", "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]] [[package]]
name = "derive_builder" name = "derive_builder"
version = "0.12.0" version = "0.12.0"
@ -672,6 +714,7 @@ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"axum-macros", "axum-macros",
"bcrypt",
"chrono", "chrono",
"diesel", "diesel",
"diesel-derive-newtype", "diesel-derive-newtype",
@ -681,11 +724,13 @@ dependencies = [
"env_logger", "env_logger",
"futures", "futures",
"hex", "hex",
"jsonwebtoken",
"once_cell", "once_cell",
"pretty_assertions", "pretty_assertions",
"rand", "rand",
"reqwest", "reqwest",
"serde", "serde",
"serde_json",
"sha2", "sha2",
"tokio", "tokio",
"tracing", "tracing",
@ -844,8 +889,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe9006bed769170c11f845cf00c7c1e9092aeb3f268e007c3e760ac68008070f" checksum = "fe9006bed769170c11f845cf00c7c1e9092aeb3f268e007c3e760ac68008070f"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi", "wasi",
"wasm-bindgen",
] ]
[[package]] [[package]]
@ -1088,6 +1135,15 @@ dependencies = [
"hashbrown 0.14.2", "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]] [[package]]
name = "instant" name = "instant"
version = "0.1.12" version = "0.1.12"
@ -1138,6 +1194,21 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.4.0" version = "1.4.0"
@ -1318,6 +1389,27 @@ dependencies = [
"winapi", "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]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.17" version = "0.2.17"
@ -1431,6 +1523,16 @@ dependencies = [
"windows-targets", "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]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.0" version = "2.3.0"
@ -1491,6 +1593,12 @@ dependencies = [
"windows-sys", "windows-sys",
] ]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.17" version = "0.2.17"
@ -1694,6 +1802,20 @@ dependencies = [
"thiserror", "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]] [[package]]
name = "rustc-demangle" name = "rustc-demangle"
version = "0.1.23" version = "0.1.23"
@ -1907,6 +2029,18 @@ dependencies = [
"libc", "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]] [[package]]
name = "skeptic" name = "skeptic"
version = "0.13.7" version = "0.13.7"
@ -1957,12 +2091,24 @@ dependencies = [
"windows-sys", "windows-sys",
] ]
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.10.0" version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "subtle"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc"
[[package]] [[package]]
name = "syn" name = "syn"
version = "1.0.109" version = "1.0.109"
@ -2060,6 +2206,35 @@ dependencies = [
"syn 2.0.39", "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]] [[package]]
name = "tinyvec" name = "tinyvec"
version = "1.6.0" version = "1.6.0"
@ -2271,6 +2446,12 @@ dependencies = [
"tinyvec", "tinyvec",
] ]
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.4.1" version = "2.4.1"
@ -2555,3 +2736,9 @@ name = "yansi"
version = "0.5.1" version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec"
[[package]]
name = "zeroize"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d"

View file

@ -9,6 +9,7 @@ anyhow = "1.0.75"
async-trait = "0.1.74" async-trait = "0.1.74"
axum = "0.6.20" axum = "0.6.20"
axum-macros = "0.3.8" axum-macros = "0.3.8"
bcrypt = "0.15.0"
chrono = { version = "0.4.31", features = ["serde"] } chrono = { version = "0.4.31", features = ["serde"] }
diesel = {version = "2.1.4", features = ["postgres", "chrono", "uuid"] } diesel = {version = "2.1.4", features = ["postgres", "chrono", "uuid"] }
diesel-derive-newtype = "2.1.0" diesel-derive-newtype = "2.1.0"
@ -18,8 +19,10 @@ enum_delegate = "0.2.0"
env_logger = { version = "0.10.1", default-features = false } env_logger = { version = "0.10.1", default-features = false }
futures = "0.3.29" futures = "0.3.29"
hex = "0.4.3" hex = "0.4.3"
jsonwebtoken = "9.2.0"
rand = "0.8.5" rand = "0.8.5"
serde = "1.0.192" serde = "1.0.192"
serde_json = "1.0.108"
sha2 = "0.10.8" sha2 = "0.10.8"
tokio = { version = "1.34.0", features = ["full"] } tokio = { version = "1.34.0", features = ["full"] }
tracing = "0.1.40" tracing = "0.1.40"

View file

@ -2,4 +2,6 @@ drop table conflict;
drop table edit; drop table edit;
drop table article; drop table article;
drop table instance_follow; drop table instance_follow;
drop table local_user;
drop table person;
drop table instance; drop table instance;

View file

@ -9,10 +9,27 @@ create table instance (
local bool not null 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 ( create table instance_follow (
id serial primary key, id serial primary key,
instance_id int REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, 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, pending boolean not null,
unique(instance_id, follower_id) unique(instance_id, follower_id)
); );

View file

@ -1,39 +1,23 @@
use crate::database::article::{ArticleView, DbArticle, DbArticleForm}; use crate::database::article::{ArticleView, DbArticle, DbArticleForm};
use crate::database::conflict::{ApiConflict, DbConflict, DbConflictForm}; use crate::database::conflict::{ApiConflict, DbConflict, DbConflictForm};
use crate::database::edit::{DbEdit, DbEditForm}; 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::version::EditVersion;
use crate::database::MyDataHandle; use crate::database::MyDataHandle;
use crate::error::MyResult; use crate::error::MyResult;
use crate::federation::activities::create_article::CreateArticle; use crate::federation::activities::create_article::CreateArticle;
use crate::federation::activities::follow::Follow;
use crate::federation::activities::submit_article_update; use crate::federation::activities::submit_article_update;
use crate::utils::generate_article_version; use crate::utils::generate_article_version;
use activitypub_federation::config::Data; use activitypub_federation::config::Data;
use activitypub_federation::fetch::object_id::ObjectId; use activitypub_federation::fetch::object_id::ObjectId;
use axum::extract::Query; use axum::extract::Query;
use axum::routing::{get, post}; use axum::Extension;
use axum::{Form, Json, Router}; use axum::Form;
use axum::Json;
use axum_macros::debug_handler; use axum_macros::debug_handler;
use diffy::create_patch; use diffy::create_patch;
use futures::future::try_join_all;
use serde::{Deserialize, Serialize}; 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)] #[derive(Deserialize, Serialize)]
pub struct CreateArticleData { pub struct CreateArticleData {
@ -42,7 +26,8 @@ pub struct CreateArticleData {
/// Create a new article with empty text, and federate it to followers. /// Create a new article with empty text, and federate it to followers.
#[debug_handler] #[debug_handler]
async fn create_article( pub(in crate::api) async fn create_article(
Extension(_user): Extension<LocalUserView>,
data: Data<MyDataHandle>, data: Data<MyDataHandle>,
Form(create_article): Form<CreateArticleData>, Form(create_article): Form<CreateArticleData>,
) -> MyResult<Json<ArticleView>> { ) -> MyResult<Json<ArticleView>> {
@ -91,7 +76,8 @@ pub struct EditArticleData {
/// ///
/// Conflicts are stored in the database so they can be retrieved later from `/api/v3/edit_conflicts`. /// Conflicts are stored in the database so they can be retrieved later from `/api/v3/edit_conflicts`.
#[debug_handler] #[debug_handler]
async fn edit_article( pub(in crate::api) async fn edit_article(
Extension(_user): Extension<LocalUserView>,
data: Data<MyDataHandle>, data: Data<MyDataHandle>,
Form(edit_form): Form<EditArticleData>, Form(edit_form): Form<EditArticleData>,
) -> MyResult<Json<Option<ApiConflict>>> { ) -> MyResult<Json<Option<ApiConflict>>> {
@ -137,7 +123,7 @@ pub struct GetArticleData {
/// Retrieve an article by ID. It must already be stored in the local database. /// Retrieve an article by ID. It must already be stored in the local database.
#[debug_handler] #[debug_handler]
async fn get_article( pub(in crate::api) async fn get_article(
Query(query): Query<GetArticleData>, Query(query): Query<GetArticleData>,
data: Data<MyDataHandle>, data: Data<MyDataHandle>,
) -> MyResult<Json<ArticleView>> { ) -> MyResult<Json<ArticleView>> {
@ -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<ResolveObject>,
data: Data<MyDataHandle>,
) -> MyResult<Json<DbInstance>> {
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<ResolveObject>,
data: Data<MyDataHandle>,
) -> MyResult<Json<ArticleView>> {
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<MyDataHandle>) -> MyResult<Json<InstanceView>> {
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<MyDataHandle>,
Form(query): Form<FollowInstance>,
) -> 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<MyDataHandle>) -> MyResult<Json<Vec<ApiConflict>>> {
let conflicts = DbConflict::list(&data.db_connection)?;
let conflicts: Vec<ApiConflict> = 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<SearchArticleData>,
data: Data<MyDataHandle>,
) -> MyResult<Json<Vec<DbArticle>>> {
let article = DbArticle::search(&query.query, &data.db_connection)?;
Ok(Json(article))
}
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
pub struct ForkArticleData { pub struct ForkArticleData {
// TODO: could add optional param new_title so there is no problem with title collision // 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 /// Fork a remote article to local instance. This is useful if there are disagreements about
/// how an article should be edited. /// how an article should be edited.
#[debug_handler] #[debug_handler]
async fn fork_article( pub(in crate::api) async fn fork_article(
Extension(_user): Extension<LocalUserView>,
data: Data<MyDataHandle>, data: Data<MyDataHandle>,
Form(fork_form): Form<ForkArticleData>, Form(fork_form): Form<ForkArticleData>,
) -> MyResult<Json<ArticleView>> { ) -> MyResult<Json<ArticleView>> {

40
src/api/instance.rs Normal file
View file

@ -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<MyDataHandle>,
) -> MyResult<Json<InstanceView>> {
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<LocalUserView>,
data: Data<MyDataHandle>,
Form(query): Form<FollowInstance>,
) -> 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(())
}

133
src/api/mod.rs Normal file
View file

@ -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<B>(
data: Data<MyDataHandle>,
auth: Option<TypedHeader<Authorization<Bearer>>>,
mut request: Request<B>,
next: Next<B>,
) -> Result<Response, StatusCode> {
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<ResolveObject>,
data: Data<MyDataHandle>,
) -> MyResult<Json<DbInstance>> {
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<ResolveObject>,
data: Data<MyDataHandle>,
) -> MyResult<Json<ArticleView>> {
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<MyDataHandle>) -> MyResult<Json<Vec<ApiConflict>>> {
let conflicts = DbConflict::list(&data.db_connection)?;
let conflicts: Vec<ApiConflict> = 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<SearchArticleData>,
data: Data<MyDataHandle>,
) -> MyResult<Json<Vec<DbArticle>>> {
let article = DbArticle::search(&query.query, &data.db_connection)?;
Ok(Json(article))
}

92
src/api/user.rs Normal file
View file

@ -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<MyDataHandle>,
) -> MyResult<LoginResponse> {
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<MyDataHandle>) -> MyResult<LocalUserView> {
let validation = Validation::default();
let key = DecodingKey::from_secret(SECRET);
let claims = decode::<Claims>(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<MyDataHandle>,
Form(form): Form<RegisterUserData>,
) -> MyResult<Json<LoginResponse>> {
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<MyDataHandle>,
Form(form): Form<LoginUserData>,
) -> MyResult<Json<LoginResponse>> {
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)?))
}

View file

@ -57,7 +57,7 @@ impl DbEditForm {
}) })
} }
pub(crate) fn generate_ap_id( pub fn generate_ap_id(
article: &DbArticle, article: &DbArticle,
version: &EditVersion, version: &EditVersion,
) -> MyResult<ObjectId<DbEdit>> { ) -> MyResult<ObjectId<DbEdit>> {

View file

@ -1,4 +1,5 @@
use crate::database::schema::{instance, instance_follow}; use crate::database::schema::{instance, instance_follow};
use crate::database::user::DbPerson;
use crate::database::MyDataHandle; use crate::database::MyDataHandle;
use crate::error::MyResult; use crate::error::MyResult;
use crate::federation::objects::articles_collection::DbArticleCollection; use crate::federation::objects::articles_collection::DbArticleCollection;
@ -24,11 +25,11 @@ pub struct DbInstance {
pub articles_url: CollectionId<DbArticleCollection>, pub articles_url: CollectionId<DbArticleCollection>,
pub inbox_url: String, pub inbox_url: String,
#[serde(skip)] #[serde(skip)]
pub(crate) public_key: String, pub public_key: String,
#[serde(skip)] #[serde(skip)]
pub(crate) private_key: Option<String>, pub private_key: Option<String>,
#[serde(skip)] #[serde(skip)]
pub(crate) last_refreshed_at: DateTime<Utc>, pub last_refreshed_at: DateTime<Utc>,
pub local: bool, pub local: bool,
} }
@ -38,9 +39,9 @@ pub struct DbInstanceForm {
pub ap_id: ObjectId<DbInstance>, pub ap_id: ObjectId<DbInstance>,
pub articles_url: CollectionId<DbArticleCollection>, pub articles_url: CollectionId<DbArticleCollection>,
pub inbox_url: String, pub inbox_url: String,
pub(crate) public_key: String, pub public_key: String,
pub(crate) private_key: Option<String>, pub private_key: Option<String>,
pub(crate) last_refreshed_at: DateTime<Utc>, pub last_refreshed_at: DateTime<Utc>,
pub local: bool, pub local: bool,
} }
@ -48,7 +49,7 @@ pub struct DbInstanceForm {
#[diesel(table_name = article, check_for_backend(diesel::pg::Pg))] #[diesel(table_name = article, check_for_backend(diesel::pg::Pg))]
pub struct InstanceView { pub struct InstanceView {
pub instance: DbInstance, pub instance: DbInstance,
pub followers: Vec<DbInstance>, pub followers: Vec<DbPerson>,
pub following: Vec<DbInstance>, pub following: Vec<DbInstance>,
} }
@ -98,36 +99,36 @@ impl DbInstance {
} }
pub fn follow( pub fn follow(
follower_id_: i32, follower: &DbPerson,
instance_id_: i32, instance: &DbInstance,
pending_: bool, pending_: bool,
data: &Data<MyDataHandle>, data: &Data<MyDataHandle>,
) -> MyResult<()> { ) -> MyResult<()> {
debug_assert_ne!(follower_id_, instance_id_);
use instance_follow::dsl::{follower_id, instance_id, pending}; use instance_follow::dsl::{follower_id, instance_id, pending};
let mut conn = data.db_connection.lock().unwrap(); let mut conn = data.db_connection.lock().unwrap();
let form = ( let form = (
instance_id.eq(instance_id_), instance_id.eq(instance.id),
follower_id.eq(follower_id_), follower_id.eq(follower.id),
pending.eq(pending_), pending.eq(pending_),
); );
dbg!(follower_id_, instance_id_, pending_); let rows = insert_into(instance_follow::table)
insert_into(instance_follow::table)
.values(form) .values(form)
.on_conflict((instance_id, follower_id)) .on_conflict((instance_id, follower_id))
.do_update() .do_update()
.set(form) .set(form)
.execute(conn.deref_mut())?; .execute(conn.deref_mut())?;
assert_eq!(1, rows);
Ok(()) Ok(())
} }
pub fn read_followers(id_: i32, conn: &Mutex<PgConnection>) -> MyResult<Vec<Self>> { pub fn read_followers(id_: i32, conn: &Mutex<PgConnection>) -> MyResult<Vec<DbPerson>> {
use crate::database::schema::person;
use instance_follow::dsl::{follower_id, instance_id}; use instance_follow::dsl::{follower_id, instance_id};
let mut conn = conn.lock().unwrap(); let mut conn = conn.lock().unwrap();
Ok(instance_follow::table 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_)) .filter(instance_id.eq(id_))
.select(instance::all_columns) .select(person::all_columns)
.get_results(conn.deref_mut())?) .get_results(conn.deref_mut())?)
} }

View file

@ -9,6 +9,7 @@ pub mod conflict;
pub mod edit; pub mod edit;
pub mod instance; pub mod instance;
mod schema; mod schema;
pub mod user;
pub mod version; pub mod version;
#[derive(Clone)] #[derive(Clone)]

View file

@ -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<Text>,
last_refreshed_at -> Timestamptz,
local -> Bool,
}
}
diesel::joinable!(article -> instance (instance_id)); diesel::joinable!(article -> instance (instance_id));
diesel::joinable!(conflict -> article (article_id)); diesel::joinable!(conflict -> article (article_id));
diesel::joinable!(edit -> 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,
);

149
src/database/user.rs Normal file
View file

@ -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<DbPerson>,
pub inbox_url: String,
#[serde(skip)]
pub public_key: String,
#[serde(skip)]
pub private_key: Option<String>,
#[serde(skip)]
pub last_refreshed_at: DateTime<Utc>,
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<DbPerson>,
pub inbox_url: String,
pub public_key: String,
pub private_key: Option<String>,
pub last_refreshed_at: DateTime<Utc>,
pub local: bool,
}
impl DbPerson {
pub fn create(person_form: &DbPersonForm, conn: &Mutex<PgConnection>) -> MyResult<Self> {
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::<DbPerson>(conn.deref_mut())?)
}
pub fn create_local(
username: String,
password: String,
data: &Data<MyDataHandle>,
) -> MyResult<LocalUserView> {
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::<DbPerson>(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::<DbLocalUser>(conn.deref_mut())?;
Ok(LocalUserView { local_user, person })
}
pub fn read_from_ap_id(
ap_id: &ObjectId<DbPerson>,
data: &Data<MyDataHandle>,
) -> MyResult<DbPerson> {
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<MyDataHandle>,
) -> MyResult<LocalUserView> {
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<MyDataHandle>) -> MyResult<LocalUserView> {
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())?)
}
}

View file

@ -5,7 +5,7 @@ use std::fmt::{Display, Formatter};
pub type MyResult<T> = Result<T, Error>; pub type MyResult<T> = Result<T, Error>;
#[derive(Debug)] #[derive(Debug)]
pub struct Error(pub(crate) anyhow::Error); pub struct Error(pub anyhow::Error);
impl Display for Error { impl Display for Error {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {

View file

@ -1,7 +1,9 @@
use crate::database::instance::DbInstance; use crate::database::instance::DbInstance;
use crate::error::MyResult; use crate::error::MyResult;
use crate::federation::send_activity;
use crate::utils::generate_activity_id; use crate::utils::generate_activity_id;
use crate::{database::MyDataHandle, federation::activities::follow::Follow}; use crate::{database::MyDataHandle, federation::activities::follow::Follow};
use activitypub_federation::traits::Actor;
use activitypub_federation::{ use activitypub_federation::{
config::Data, fetch::object_id::ObjectId, kinds::activity::AcceptType, traits::ActivityHandler, config::Data, fetch::object_id::ObjectId, kinds::activity::AcceptType, traits::ActivityHandler,
}; };
@ -19,14 +21,28 @@ pub struct Accept {
} }
impl Accept { impl Accept {
pub fn new(actor: ObjectId<DbInstance>, object: Follow) -> MyResult<Accept> { pub async fn send(
let id = generate_activity_id(actor.inner())?; local_instance: DbInstance,
Ok(Accept { object: Follow,
actor, data: &Data<MyDataHandle>,
) -> 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, object,
kind: Default::default(), kind: Default::default(),
id, 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<Self::DataType>) -> Result<(), Self::Error> { async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
dbg!(&self);
// add to follows // add to follows
let local_instance = DbInstance::read_local_instance(&data.db_connection)?; let person = self.object.actor.dereference_local(data).await?;
let actor = self.actor.dereference(data).await?; let instance = self.actor.dereference(data).await?;
DbInstance::follow(local_instance.id, actor.id, false, data)?; DbInstance::follow(&person, &instance, false, data)?;
Ok(()) Ok(())
} }
} }

View file

@ -1,5 +1,7 @@
use crate::database::instance::DbInstance; use crate::database::instance::DbInstance;
use crate::database::user::DbPerson;
use crate::error::MyResult; use crate::error::MyResult;
use crate::federation::send_activity;
use crate::{database::MyDataHandle, federation::activities::accept::Accept, generate_activity_id}; use crate::{database::MyDataHandle, federation::activities::accept::Accept, generate_activity_id};
use activitypub_federation::{ use activitypub_federation::{
config::Data, config::Data,
@ -13,29 +15,24 @@ use url::Url;
#[derive(Deserialize, Serialize, Clone, Debug)] #[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Follow { pub struct Follow {
pub(crate) actor: ObjectId<DbInstance>, pub actor: ObjectId<DbPerson>,
pub(crate) object: ObjectId<DbInstance>, pub object: ObjectId<DbInstance>,
#[serde(rename = "type")] #[serde(rename = "type")]
kind: FollowType, kind: FollowType,
id: Url, id: Url,
} }
impl Follow { impl Follow {
pub async fn send( pub async fn send(actor: DbPerson, to: DbInstance, data: &Data<MyDataHandle>) -> MyResult<()> {
local_instance: DbInstance, let id = generate_activity_id(actor.ap_id.inner())?;
to: DbInstance,
data: &Data<MyDataHandle>,
) -> MyResult<()> {
let id = generate_activity_id(local_instance.ap_id.inner())?;
let follow = Follow { let follow = Follow {
actor: local_instance.ap_id.clone(), actor: actor.ap_id.clone(),
object: to.ap_id.clone(), object: to.ap_id.clone(),
kind: Default::default(), kind: Default::default(),
id, id,
}; };
local_instance
.send(follow, vec![to.shared_inbox_or_inbox()], data) send_activity(&actor, follow, vec![to.shared_inbox_or_inbox()], data).await?;
.await?;
Ok(()) Ok(())
} }
} }
@ -58,16 +55,14 @@ impl ActivityHandler for Follow {
} }
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> { async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
dbg!(&self);
let actor = self.actor.dereference(data).await?; let actor = self.actor.dereference(data).await?;
let local_instance = DbInstance::read_local_instance(&data.db_connection)?; 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 // send back an accept
let follower = self.actor.dereference(data).await?; Accept::send(local_instance, self, data).await?;
let accept = Accept::new(local_instance.ap_id.clone(), self)?;
local_instance
.send(accept, vec![follower.shared_inbox_or_inbox()], data)
.await?;
Ok(()) Ok(())
} }
} }

View file

@ -11,6 +11,7 @@ use activitypub_federation::{
traits::ActivityHandler, traits::ActivityHandler,
}; };
use crate::federation::send_activity;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
@ -41,9 +42,13 @@ impl RejectEdit {
kind: Default::default(), kind: Default::default(),
id, id,
}; };
local_instance send_activity(
.send(reject, vec![Url::parse(&user_instance.inbox_url)?], data) &local_instance,
.await?; reject,
vec![Url::parse(&user_instance.inbox_url)?],
data,
)
.await?;
Ok(()) Ok(())
} }
} }

View file

@ -7,6 +7,7 @@ use crate::database::instance::DbInstance;
use crate::federation::activities::reject::RejectEdit; use crate::federation::activities::reject::RejectEdit;
use crate::federation::activities::update_local_article::UpdateLocalArticle; use crate::federation::activities::update_local_article::UpdateLocalArticle;
use crate::federation::objects::edit::ApubEdit; use crate::federation::objects::edit::ApubEdit;
use crate::federation::send_activity;
use crate::utils::generate_activity_id; use crate::utils::generate_activity_id;
use activitypub_federation::kinds::activity::UpdateType; use activitypub_federation::kinds::activity::UpdateType;
use activitypub_federation::{ use activitypub_federation::{
@ -47,9 +48,13 @@ impl UpdateRemoteArticle {
kind: Default::default(), kind: Default::default(),
id, id,
}; };
local_instance send_activity(
.send(update, vec![Url::parse(&article_instance.inbox_url)?], data) &local_instance,
.await?; update,
vec![Url::parse(&article_instance.inbox_url)?],
data,
)
.await?;
Ok(()) Ok(())
} }
} }

View file

@ -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 activities;
pub mod objects; pub mod objects;
pub mod routes; pub mod routes;
pub async fn send_activity<Activity, ActorType: Actor>(
actor: &ActorType,
activity: Activity,
recipients: Vec<Url>,
data: &Data<MyDataHandle>,
) -> Result<(), <Activity as ActivityHandler>::Error>
where
Activity: ActivityHandler + Serialize + Debug + Send + Sync,
<Activity as ActivityHandler>::Error: From<activitypub_federation::error::Error>,
{
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(())
}

View file

@ -19,11 +19,11 @@ use url::Url;
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ApubArticle { pub struct ApubArticle {
#[serde(rename = "type")] #[serde(rename = "type")]
pub(crate) kind: ArticleType, pub kind: ArticleType,
pub(crate) id: ObjectId<DbArticle>, pub id: ObjectId<DbArticle>,
pub(crate) attributed_to: ObjectId<DbInstance>, pub attributed_to: ObjectId<DbInstance>,
#[serde(deserialize_with = "deserialize_one_or_many")] #[serde(deserialize_with = "deserialize_one_or_many")]
pub(crate) to: Vec<Url>, pub to: Vec<Url>,
pub edits: CollectionId<DbEditCollection>, pub edits: CollectionId<DbEditCollection>,
latest_version: EditVersion, latest_version: EditVersion,
content: String, content: String,

View file

@ -16,10 +16,10 @@ use url::Url;
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ArticleCollection { pub struct ArticleCollection {
pub(crate) r#type: CollectionType, pub r#type: CollectionType,
pub(crate) id: Url, pub id: Url,
pub(crate) total_items: i32, pub total_items: i32,
pub(crate) items: Vec<ApubArticle>, pub items: Vec<ApubArticle>,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]

View file

@ -18,10 +18,10 @@ use url::Url;
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ApubEditCollection { pub struct ApubEditCollection {
pub(crate) r#type: OrderedCollectionType, pub r#type: OrderedCollectionType,
pub(crate) id: Url, pub id: Url,
pub(crate) total_items: i32, pub total_items: i32,
pub(crate) items: Vec<ApubEdit>, pub items: Vec<ApubEdit>,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]

View file

@ -2,6 +2,7 @@ use crate::database::instance::{DbInstance, DbInstanceForm};
use crate::database::MyDataHandle; use crate::database::MyDataHandle;
use crate::error::{Error, MyResult}; use crate::error::{Error, MyResult};
use crate::federation::objects::articles_collection::DbArticleCollection; use crate::federation::objects::articles_collection::DbArticleCollection;
use crate::federation::send_activity;
use activitypub_federation::activity_sending::SendActivityTask; use activitypub_federation::activity_sending::SendActivityTask;
use activitypub_federation::fetch::collection_id::CollectionId; use activitypub_federation::fetch::collection_id::CollectionId;
use activitypub_federation::kinds::actor::ServiceType; use activitypub_federation::kinds::actor::ServiceType;
@ -62,28 +63,7 @@ impl DbInstance {
.into_iter() .into_iter()
.map(|i| Url::parse(&i.inbox_url).unwrap()), .map(|i| Url::parse(&i.inbox_url).unwrap()),
); );
self.send(activity, inboxes, data).await?; send_activity(self, activity, inboxes, data).await?;
Ok(())
}
pub async fn send<Activity>(
&self,
activity: Activity,
recipients: Vec<Url>,
data: &Data<MyDataHandle>,
) -> Result<(), <Activity as ActivityHandler>::Error>
where
Activity: ActivityHandler + Serialize + Debug + Send + Sync,
<Activity as ActivityHandler>::Error: From<activitypub_federation::error::Error>,
{
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);
}
}
Ok(()) Ok(())
} }
} }

View file

@ -3,3 +3,4 @@ pub mod articles_collection;
pub mod edit; pub mod edit;
pub mod edits_collection; pub mod edits_collection;
pub mod instance; pub mod instance;
pub mod user;

View file

@ -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<DbPerson>,
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<DateTime<Utc>> {
Some(self.last_refreshed_at)
}
async fn read_from_id(
object_id: Url,
data: &Data<Self::DataType>,
) -> Result<Option<Self>, Self::Error> {
Ok(DbPerson::read_from_ap_id(&object_id.into(), data).ok())
}
async fn into_json(self, _data: &Data<Self::DataType>) -> Result<Self::Kind, Self::Error> {
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<Self::DataType>,
) -> Result<(), Self::Error> {
verify_domains_match(json.id.inner(), expected_domain)?;
Ok(())
}
async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> {
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<String> {
self.private_key.clone()
}
fn inbox(&self) -> Url {
Url::parse(&self.inbox_url).unwrap()
}
}

View file

@ -1,7 +1,8 @@
use crate::database::article::DbArticle; use crate::database::article::DbArticle;
use crate::database::instance::DbInstance; use crate::database::instance::DbInstance;
use crate::database::user::DbPerson;
use crate::database::MyDataHandle; use crate::database::MyDataHandle;
use crate::error::MyResult; use crate::error::{Error, MyResult};
use crate::federation::activities::accept::Accept; use crate::federation::activities::accept::Accept;
use crate::federation::activities::create_article::CreateArticle; use crate::federation::activities::create_article::CreateArticle;
use crate::federation::activities::follow::Follow; 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::articles_collection::{ArticleCollection, DbArticleCollection};
use crate::federation::objects::edits_collection::{ApubEditCollection, DbEditCollection}; use crate::federation::objects::edits_collection::{ApubEditCollection, DbEditCollection};
use crate::federation::objects::instance::ApubInstance; use crate::federation::objects::instance::ApubInstance;
use crate::federation::objects::user::ApubUser;
use activitypub_federation::axum::inbox::{receive_activity, ActivityData}; use activitypub_federation::axum::inbox::{receive_activity, ActivityData};
use activitypub_federation::axum::json::FederationJson; use activitypub_federation::axum::json::FederationJson;
use activitypub_federation::config::Data; use activitypub_federation::config::Data;
use activitypub_federation::protocol::context::WithContext; use activitypub_federation::protocol::context::WithContext;
use activitypub_federation::traits::Actor;
use activitypub_federation::traits::Object; use activitypub_federation::traits::Object;
use activitypub_federation::traits::{ActivityHandler, Collection}; use activitypub_federation::traits::{ActivityHandler, Collection};
use axum::extract::Path; use axum::extract::Path;
@ -23,12 +26,14 @@ use axum::response::IntoResponse;
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::Router; use axum::Router;
use axum_macros::debug_handler; use axum_macros::debug_handler;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
pub fn federation_routes() -> Router { pub fn federation_routes() -> Router {
Router::new() Router::new()
.route("/", get(http_get_instance)) .route("/", get(http_get_instance))
.route("/user/:name", get(http_get_person))
.route("/all_articles", get(http_get_all_articles)) .route("/all_articles", get(http_get_all_articles))
.route("/article/:title", get(http_get_article)) .route("/article/:title", get(http_get_article))
.route("/article/:title/edits", get(http_get_article_edits)) .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))) Ok(FederationJson(WithContext::new_default(json_instance)))
} }
#[debug_handler]
async fn http_get_person(
Path(name): Path<String>,
data: Data<MyDataHandle>,
) -> MyResult<FederationJson<WithContext<ApubUser>>> {
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] #[debug_handler]
async fn http_get_all_articles( async fn http_get_all_articles(
data: Data<MyDataHandle>, data: Data<MyDataHandle>,
@ -91,6 +106,119 @@ pub async fn http_post_inbox(
data: Data<MyDataHandle>, data: Data<MyDataHandle>,
activity_data: ActivityData, activity_data: ActivityData,
) -> impl IntoResponse { ) -> impl IntoResponse {
receive_activity::<WithContext<InboxActivities>, DbInstance, MyDataHandle>(activity_data, &data) receive_activity::<WithContext<InboxActivities>, UserOrInstance, MyDataHandle>(
.await 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<DateTime<Utc>> {
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<Self::DataType>,
) -> Result<Option<Self>, 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<Self::DataType>) -> 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<Self::DataType>) -> Result<Self::Kind, Error> {
unimplemented!()
}
#[tracing::instrument(skip_all)]
async fn verify(
apub: &Self::Kind,
expected_domain: &Url,
data: &Data<Self::DataType>,
) -> 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<Self::DataType>) -> Result<Self, Error> {
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<String> {
match self {
UserOrInstance::User(p) => p.private_key_pem(),
UserOrInstance::Instance(p) => p.private_key_pem(),
}
}
fn inbox(&self) -> Url {
unimplemented!()
}
} }

View file

@ -1,4 +1,3 @@
use crate::api::api_routes;
use crate::database::instance::{DbInstance, DbInstanceForm}; use crate::database::instance::{DbInstance, DbInstanceForm};
use crate::database::MyData; use crate::database::MyData;
use crate::error::MyResult; 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::collection_id::CollectionId;
use activitypub_federation::fetch::object_id::ObjectId; use activitypub_federation::fetch::object_id::ObjectId;
use activitypub_federation::http_signatures::generate_actor_keypair; use activitypub_federation::http_signatures::generate_actor_keypair;
use api::api_routes;
use axum::{Router, Server}; use axum::{Router, Server};
use chrono::Local; use chrono::Local;
use diesel::Connection; use diesel::Connection;

View file

@ -1,7 +1,9 @@
use anyhow::anyhow; use anyhow::anyhow;
use fediwiki::api::{ use fediwiki::api::article::{CreateArticleData, EditArticleData, ForkArticleData, GetArticleData};
CreateArticleData, EditArticleData, FollowInstance, GetArticleData, ResolveObject, 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::article::ArticleView;
use fediwiki::database::conflict::ApiConflict; use fediwiki::database::conflict::ApiConflict;
use fediwiki::database::instance::DbInstance; use fediwiki::database::instance::DbInstance;
@ -12,6 +14,7 @@ use reqwest::{Client, RequestBuilder, StatusCode};
use serde::de::Deserialize; use serde::de::Deserialize;
use serde::ser::Serialize; use serde::ser::Serialize;
use std::env::current_dir; use std::env::current_dir;
use std::fs::create_dir_all;
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicI32, Ordering}; use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Once; use std::sync::Once;
@ -30,7 +33,7 @@ pub struct TestData {
} }
impl TestData { impl TestData {
pub fn start() -> Self { pub async fn start() -> Self {
static INIT: Once = Once::new(); static INIT: Once = Once::new();
INIT.call_once(|| { INIT.call_once(|| {
env_logger::builder() env_logger::builder()
@ -66,9 +69,9 @@ impl TestData {
} }
Self { Self {
alpha: FediwikiInstance::start(alpha_db_path, port_alpha), alpha: FediwikiInstance::start(alpha_db_path, port_alpha, "alpha").await,
beta: FediwikiInstance::start(beta_db_path, port_beta), beta: FediwikiInstance::start(beta_db_path, port_beta, "beta").await,
gamma: FediwikiInstance::start(gamma_db_path, port_gamma), 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. /// 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 { fn generate_db_path(name: &'static str, port: i32) -> String {
format!( let path = format!(
"{}/target/test_db/{name}-{port}", "{}/target/test_db/{name}-{port}",
current_dir().unwrap().display() current_dir().unwrap().display()
) );
create_dir_all(&path).unwrap();
path
} }
pub struct FediwikiInstance { pub struct FediwikiInstance {
pub hostname: String, pub hostname: String,
pub jwt: String,
db_path: String, db_path: String,
db_handle: JoinHandle<()>, 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 db_url = format!("postgresql://lemmy:password@/lemmy?host={db_path}");
let hostname = format!("localhost:{port}"); let hostname = format!("localhost:{port}");
let hostname_ = hostname.clone(); let hostname_ = hostname.clone();
let handle = tokio::task::spawn(async move { let handle = tokio::task::spawn(async move {
start(&hostname_, &db_url).await.unwrap(); 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 { Self {
db_path, jwt: register_res.jwt,
hostname, hostname,
db_path,
db_handle: handle, db_handle: handle,
} }
} }
@ -135,11 +146,16 @@ impl FediwikiInstance {
pub const TEST_ARTICLE_DEFAULT_TEXT: &str = "some\nexample\ntext\n"; pub const TEST_ARTICLE_DEFAULT_TEXT: &str = "some\nexample\ntext\n";
pub async fn create_article(hostname: &str, title: String) -> MyResult<ArticleView> { pub async fn create_article(instance: &FediwikiInstance, title: String) -> MyResult<ArticleView> {
let create_form = CreateArticleData { let create_form = CreateArticleData {
title: title.clone(), 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) // create initial edit to ensure that conflicts are generated (there are no conflicts on empty file)
let edit_form = EditArticleData { let edit_form = EditArticleData {
article_id: article.article.id, article_id: article.article.id,
@ -147,7 +163,7 @@ pub async fn create_article(hostname: &str, title: String) -> MyResult<ArticleVi
previous_version_id: article.latest_version, previous_version_id: article.latest_version,
resolve_conflict_id: None, resolve_conflict_id: None,
}; };
edit_article(hostname, &edit_form).await edit_article(&instance, &edit_form).await
} }
pub async fn get_article(hostname: &str, article_id: i32) -> MyResult<ArticleView> { pub async fn get_article(hostname: &str, article_id: i32) -> MyResult<ArticleView> {
@ -156,19 +172,23 @@ pub async fn get_article(hostname: &str, article_id: i32) -> MyResult<ArticleVie
} }
pub async fn edit_article_with_conflict( pub async fn edit_article_with_conflict(
hostname: &str, instance: &FediwikiInstance,
edit_form: &EditArticleData, edit_form: &EditArticleData,
) -> MyResult<Option<ApiConflict>> { ) -> MyResult<Option<ApiConflict>> {
let req = CLIENT let req = CLIENT
.patch(format!("http://{}/api/v1/article", hostname)) .patch(format!("http://{}/api/v1/article", instance.hostname))
.form(edit_form); .form(edit_form)
.bearer_auth(&instance.jwt);
handle_json_res(req).await handle_json_res(req).await
} }
pub async fn edit_article(hostname: &str, edit_form: &EditArticleData) -> MyResult<ArticleView> { pub async fn edit_article(
let edit_res = edit_article_with_conflict(hostname, edit_form).await?; instance: &FediwikiInstance,
edit_form: &EditArticleData,
) -> MyResult<ArticleView> {
let edit_res = edit_article_with_conflict(instance, edit_form).await?;
assert!(edit_res.is_none()); 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<T>(hostname: &str, endpoint: &str) -> MyResult<T> pub async fn get<T>(hostname: &str, endpoint: &str) -> MyResult<T>
@ -190,46 +210,82 @@ where
handle_json_res(req).await handle_json_res(req).await
} }
pub async fn post<T: Serialize, R>(hostname: &str, endpoint: &str, form: &T) -> MyResult<R> pub async fn fork_article(
where instance: &FediwikiInstance,
R: for<'de> Deserialize<'de>, form: &ForkArticleData,
{ ) -> MyResult<ArticleView> {
let req = CLIENT let req = CLIENT
.post(format!("http://{}/api/v1/{}", hostname, endpoint)) .post(format!("http://{}/api/v1/article/fork", instance.hostname))
.form(form); .form(form)
.bearer_auth(&instance.jwt);
handle_json_res(req).await handle_json_res(req).await
} }
async fn handle_json_res<T>(req: RequestBuilder) -> MyResult<T> pub async fn handle_json_res<T>(req: RequestBuilder) -> MyResult<T>
where where
T: for<'de> Deserialize<'de>, T: for<'de> Deserialize<'de>,
{ {
let res = req.send().await?; let res = req.send().await?;
if res.status() == StatusCode::OK { let status = res.status();
Ok(res.json().await?) 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 { } else {
let text = res.text().await?; Err(anyhow!("API error: {text}").into())
Err(anyhow!("Post API response {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 // fetch beta instance on alpha
let resolve_form = ResolveObject { let resolve_form = ResolveObject {
id: Url::parse(&format!("http://{}", follow_instance))?, id: Url::parse(&format!("http://{}", follow_instance))?,
}; };
let instance_resolved: DbInstance = 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 // send follow
let follow_form = FollowInstance { let follow_form = FollowInstance {
id: instance_resolved.id, id: instance_resolved.id,
}; };
// cant use post helper because follow doesnt return json // cant use post helper because follow doesnt return json
CLIENT let res = CLIENT
.post(format!("http://{}/api/v1/instance/follow", api_instance)) .post(format!(
"http://{}/api/v1/instance/follow",
instance.hostname
))
.form(&follow_form) .form(&follow_form)
.bearer_auth(&instance.jwt)
.send() .send()
.await?; .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<LoginResponse> {
let register_form = RegisterUserData {
username: username.to_string(),
password: password.to_string(),
};
let req = CLIENT
.post(format!("http://{}/api/v1/user/register", hostname))
.form(&register_form);
handle_json_res(req).await
}
pub async fn login(
instance: &FediwikiInstance,
username: &str,
password: &str,
) -> MyResult<LoginResponse> {
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
} }

View file

@ -2,27 +2,29 @@ extern crate fediwiki;
mod common; mod common;
use crate::common::register;
use crate::common::{ use crate::common::{
create_article, edit_article, edit_article_with_conflict, follow_instance, get_article, 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 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::database::article::{ArticleView, DbArticle};
use fediwiki::error::MyResult;
use fediwiki::database::conflict::ApiConflict; use fediwiki::database::conflict::ApiConflict;
use fediwiki::database::instance::{DbInstance, InstanceView}; use fediwiki::database::instance::{DbInstance, InstanceView};
use fediwiki::error::MyResult;
use pretty_assertions::{assert_eq, assert_ne}; use pretty_assertions::{assert_eq, assert_ne};
use url::Url; use url::Url;
#[tokio::test] #[tokio::test]
async fn test_create_read_and_edit_article() -> MyResult<()> { async fn test_create_read_and_edit_article() -> MyResult<()> {
let data = TestData::start(); let data = TestData::start().await;
// create article // create article
let title = "Manu_Chao".to_string(); 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!(title, create_res.article.title);
assert!(create_res.article.local); 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, previous_version_id: get_res.latest_version,
resolve_conflict_id: None, 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!(edit_form.new_text, edit_res.article.text);
assert_eq!(2, edit_res.edits.len()); assert_eq!(2, edit_res.edits.len());
@ -60,15 +62,15 @@ async fn test_create_read_and_edit_article() -> MyResult<()> {
#[tokio::test] #[tokio::test]
async fn test_create_duplicate_article() -> MyResult<()> { async fn test_create_duplicate_article() -> MyResult<()> {
let data = TestData::start(); let data = TestData::start().await;
// create article // create article
let title = "Manu_Chao".to_string(); 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!(title, create_res.article.title);
assert!(create_res.article.local); 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()); assert!(create_res.is_err());
data.stop() data.stop()
@ -76,7 +78,7 @@ async fn test_create_duplicate_article() -> MyResult<()> {
#[tokio::test] #[tokio::test]
async fn test_follow_instance() -> MyResult<()> { async fn test_follow_instance() -> MyResult<()> {
let data = TestData::start(); let data = TestData::start().await;
// check initial state // check initial state
let alpha_instance: InstanceView = get(&data.alpha.hostname, "instance").await?; 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.followers.len());
assert_eq!(0, beta_instance.following.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 // check that follow was federated
let alpha_instance: InstanceView = get(&data.alpha.hostname, "instance").await?; 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?; let beta_instance: InstanceView = get(&data.beta.hostname, "instance").await?;
assert_eq!(0, beta_instance.following.len()); assert_eq!(0, beta_instance.following.len());
assert_eq!(1, beta_instance.followers.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!( assert_eq!(
alpha_instance.instance.ap_id, alpha_instance.instance.ap_id.inner().domain(),
beta_instance.followers[0].ap_id beta_instance.followers[0].ap_id.inner().domain()
); );
data.stop() data.stop()
@ -110,11 +113,11 @@ async fn test_follow_instance() -> MyResult<()> {
#[tokio::test] #[tokio::test]
async fn test_synchronize_articles() -> MyResult<()> { async fn test_synchronize_articles() -> MyResult<()> {
let data = TestData::start(); let data = TestData::start().await;
// create article on alpha // create article on alpha
let title = "Manu_Chao".to_string(); 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!(title, create_res.article.title);
assert_eq!(1, create_res.edits.len()); assert_eq!(1, create_res.edits.len());
assert!(create_res.article.local); assert!(create_res.article.local);
@ -126,7 +129,7 @@ async fn test_synchronize_articles() -> MyResult<()> {
previous_version_id: create_res.latest_version, previous_version_id: create_res.latest_version,
resolve_conflict_id: None, 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 // article is not yet on beta
let get_res = get_article(&data.beta.hostname, create_res.article.id).await; 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] #[tokio::test]
async fn test_edit_local_article() -> MyResult<()> { 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 // create new article
let title = "Manu_Chao".to_string(); 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_eq!(title, create_res.article.title);
assert!(create_res.article.local); assert!(create_res.article.local);
@ -180,7 +183,7 @@ async fn test_edit_local_article() -> MyResult<()> {
previous_version_id: get_res.latest_version, previous_version_id: get_res.latest_version,
resolve_conflict_id: None, 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.article.text, edit_form.new_text);
assert_eq!(edit_res.edits.len(), 2); assert_eq!(edit_res.edits.len(), 2);
assert!(edit_res.edits[0] assert!(edit_res.edits[0]
@ -199,14 +202,14 @@ async fn test_edit_local_article() -> MyResult<()> {
#[tokio::test] #[tokio::test]
async fn test_edit_remote_article() -> MyResult<()> { 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.alpha, &data.beta.hostname).await?;
follow_instance(&data.gamma.hostname, &data.beta.hostname).await?; follow_instance(&data.gamma, &data.beta.hostname).await?;
// create new article // create new article
let title = "Manu_Chao".to_string(); 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_eq!(title, create_res.article.title);
assert!(create_res.article.local); assert!(create_res.article.local);
@ -226,7 +229,7 @@ async fn test_edit_remote_article() -> MyResult<()> {
previous_version_id: get_res.latest_version, previous_version_id: get_res.latest_version,
resolve_conflict_id: None, 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!(edit_form.new_text, edit_res.article.text);
assert_eq!(2, edit_res.edits.len()); assert_eq!(2, edit_res.edits.len());
assert!(!edit_res.article.local); assert!(!edit_res.article.local);
@ -251,11 +254,11 @@ async fn test_edit_remote_article() -> MyResult<()> {
#[tokio::test] #[tokio::test]
async fn test_local_edit_conflict() -> MyResult<()> { async fn test_local_edit_conflict() -> MyResult<()> {
let data = TestData::start(); let data = TestData::start().await;
// create new article // create new article
let title = "Manu_Chao".to_string(); 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!(title, create_res.article.title);
assert!(create_res.article.local); assert!(create_res.article.local);
@ -266,7 +269,7 @@ async fn test_local_edit_conflict() -> MyResult<()> {
previous_version_id: create_res.latest_version.clone(), previous_version_id: create_res.latest_version.clone(),
resolve_conflict_id: None, 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!(edit_res.article.text, edit_form.new_text);
assert_eq!(2, edit_res.edits.len()); 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, previous_version_id: create_res.latest_version,
resolve_conflict_id: None, 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? .await?
.unwrap(); .unwrap();
assert_eq!("<<<<<<< ours\nIpsum Lorem\n||||||| original\nsome\nexample\ntext\n=======\nLorem Ipsum\n>>>>>>> theirs\n", edit_res.three_way_merge); 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, previous_version_id: edit_res.previous_version_id,
resolve_conflict_id: Some(edit_res.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); assert_eq!(edit_form.new_text, edit_res.article.text);
let conflicts: Vec<ApiConflict> = let conflicts: Vec<ApiConflict> =
@ -305,13 +308,13 @@ async fn test_local_edit_conflict() -> MyResult<()> {
#[tokio::test] #[tokio::test]
async fn test_federated_edit_conflict() -> MyResult<()> { 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 // create new article
let title = "Manu_Chao".to_string(); 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_eq!(title, create_res.article.title);
assert!(create_res.article.local); assert!(create_res.article.local);
@ -334,7 +337,7 @@ async fn test_federated_edit_conflict() -> MyResult<()> {
previous_version_id: create_res.latest_version.clone(), previous_version_id: create_res.latest_version.clone(),
resolve_conflict_id: None, 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!(edit_res.article.text, edit_form.new_text);
assert_eq!(2, edit_res.edits.len()); assert_eq!(2, edit_res.edits.len());
assert!(!edit_res.article.local); assert!(!edit_res.article.local);
@ -351,7 +354,7 @@ async fn test_federated_edit_conflict() -> MyResult<()> {
previous_version_id: create_res.latest_version, previous_version_id: create_res.latest_version,
resolve_conflict_id: None, 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_ne!(edit_form.new_text, edit_res.article.text);
assert_eq!(1, edit_res.edits.len()); assert_eq!(1, edit_res.edits.len());
assert!(!edit_res.article.local); 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(), previous_version_id: conflicts[0].previous_version_id.clone(),
resolve_conflict_id: Some(conflicts[0].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!(edit_form.new_text, edit_res.article.text);
assert_eq!(3, edit_res.edits.len()); assert_eq!(3, edit_res.edits.len());
@ -380,11 +383,11 @@ async fn test_federated_edit_conflict() -> MyResult<()> {
#[tokio::test] #[tokio::test]
async fn test_overlapping_edits_no_conflict() -> MyResult<()> { async fn test_overlapping_edits_no_conflict() -> MyResult<()> {
let data = TestData::start(); let data = TestData::start().await;
// create new article // create new article
let title = "Manu_Chao".to_string(); 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!(title, create_res.article.title);
assert!(create_res.article.local); 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(), previous_version_id: create_res.latest_version.clone(),
resolve_conflict_id: None, 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!(edit_res.article.text, edit_form.new_text);
assert_eq!(2, edit_res.edits.len()); 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, previous_version_id: create_res.latest_version,
resolve_conflict_id: None, 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<ApiConflict> = let conflicts: Vec<ApiConflict> =
get_query(&data.alpha.hostname, "edit_conflicts", None::<()>).await?; get_query(&data.alpha.hostname, "edit_conflicts", None::<()>).await?;
assert_eq!(0, conflicts.len()); assert_eq!(0, conflicts.len());
@ -418,11 +421,11 @@ async fn test_overlapping_edits_no_conflict() -> MyResult<()> {
#[tokio::test] #[tokio::test]
async fn test_fork_article() -> MyResult<()> { async fn test_fork_article() -> MyResult<()> {
let data = TestData::start(); let data = TestData::start().await;
// create article // create article
let title = "Manu_Chao".to_string(); 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!(title, create_res.article.title);
assert!(create_res.article.local); assert!(create_res.article.local);
@ -439,7 +442,7 @@ async fn test_fork_article() -> MyResult<()> {
let fork_form = ForkArticleData { let fork_form = ForkArticleData {
article_id: resolved_article.id, 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; let forked_article = fork_res.article;
assert_eq!(resolved_article.title, forked_article.title); assert_eq!(resolved_article.title, forked_article.title);
assert_eq!(resolved_article.text, forked_article.text); assert_eq!(resolved_article.text, forked_article.text);
@ -464,3 +467,32 @@ async fn test_fork_article() -> MyResult<()> {
data.stop() 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()
}