diff --git a/Cargo.lock b/Cargo.lock index c2332e3..0f5c273 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -233,6 +233,28 @@ dependencies = [ "tower-service", ] +[[package]] +name = "axum-extra" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93e433be9382c737320af3924f7d5fc6f89c155cf2bf88949d8f5126fab283f" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie 0.17.0", + "futures-util", + "http 0.2.11", + "http-body 0.4.5", + "mime", + "pin-project-lite", + "serde", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + [[package]] name = "axum-macros" version = "0.3.8" @@ -539,6 +561,45 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7efb37c3e1ccb1ff97164ad95ac1606e8ccd35b3fa0a7d99a304c7f4a428cc24" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d606d0fba62e13cf04db20536c05cb7f13673c161cb47a47a82b9b9e7d3f1daa" +dependencies = [ + "cookie 0.16.2", + "idna 0.2.3", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.9.3" @@ -1325,6 +1386,7 @@ dependencies = [ "anyhow", "async-trait", "axum", + "axum-extra", "axum-macros", "bcrypt", "chrono", @@ -1351,6 +1413,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "time", "tokio", "tower-http", "tracing", @@ -1365,6 +1428,27 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "idna" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "idna" version = "0.5.0" @@ -1802,6 +1886,12 @@ dependencies = [ "quote", ] +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + [[package]] name = "matchit" version = "0.7.3" @@ -2245,6 +2335,22 @@ dependencies = [ "yansi 1.0.0-rc.1", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96a8c1bda5ae1af7f99a2962e49df150414a43d62404644d98dd5c3a93d07457" +dependencies = [ + "idna 0.3.0", + "psl-types", +] + [[package]] name = "pulldown-cmark" version = "0.9.3" @@ -2389,6 +2495,8 @@ checksum = "046cd98826c46c2ac8ddecae268eb5c2e58628688a5fc7a2643704a73faba95b" dependencies = [ "base64 0.21.5", "bytes", + "cookie 0.16.2", + "cookie_store", "encoding_rs", "futures-core", "futures-util", @@ -2949,9 +3057,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4a34ab300f2dee6e562c10a046fc05e358b29f9bf92277f30c3c8d82275f6f5" +checksum = "f657ba42c3f86e7680e53c8cd3af8abbe56b5491790b46e22e19c0d57463583e" dependencies = [ "deranged", "itoa", @@ -2969,9 +3077,9 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ad70d68dba9e1f8aceda7aa6711965dfec1cac869f311a51bd08b3a2ccbce20" +checksum = "26197e33420244aeb70c3e8c78376ca46571bc4e701e4791c2cd9f57dcb3a43f" dependencies = [ "time-core", ] @@ -3268,7 +3376,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" dependencies = [ "form_urlencoded", - "idna", + "idna 0.5.0", "percent-encoding", "serde", ] diff --git a/Cargo.toml b/Cargo.toml index c9569b5..4aa2058 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ default = ["ssr"] ssr = [ "axum", "axum-macros", + "axum-extra", "tower-http", "diesel", "diesel-derive-newtype", @@ -28,6 +29,7 @@ anyhow = "1.0.75" async-trait = "0.1.74" axum = { version = "0.6.20", optional = true } axum-macros = { version = "0.3.8", optional = true } +axum-extra = { version = "0.7.7", features = ["cookie"], optional = true } leptos = "0.5.4" leptos_meta = "0.5.4" leptos_router = "0.5.4" @@ -62,10 +64,11 @@ once_cell = "1.18.0" wasm-bindgen = "0.2.89" console_error_panic_hook = "0.1.7" console_log = "1.0.0" +time = "0.3.31" [dev-dependencies] pretty_assertions = "1.4.0" -reqwest = "0.11.22" +reqwest = { version = "0.11.22", features = ["cookies"] } [package.metadata.leptos] output-name = "ibis" diff --git a/README.md b/README.md index 02a7581..3f4fb2d 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,11 @@ The Ibis is a [bird which is related to the Egyptian god of knowledge and scienc You need to install [cargo](https://rustup.rs/) and [trunk](https://trunkrs.dev). Then run the following commands in separate terminals: ``` -# start backend -cargo run +# start backend, with separate target folder to avoid rebuilds from arch change +cargo run --target-dir target/backend -# start frontend -trunk serve +# start frontend, automatic rebuild on changes +trunk serve -w src/frontend/ ``` ## License diff --git a/src/backend/api/article.rs b/src/backend/api/article.rs index e140641..ee21c45 100644 --- a/src/backend/api/article.rs +++ b/src/backend/api/article.rs @@ -2,7 +2,6 @@ use crate::backend::database::article::DbArticleForm; use crate::backend::database::conflict::{ApiConflict, DbConflict, DbConflictForm}; use crate::backend::database::edit::DbEditForm; use crate::backend::database::instance::DbInstance; -use crate::backend::database::user::LocalUserView; use crate::backend::database::MyDataHandle; use crate::backend::error::MyResult; use crate::backend::federation::activities::create_article::CreateArticle; @@ -10,6 +9,7 @@ use crate::backend::federation::activities::submit_article_update; use crate::backend::utils::generate_article_version; use crate::common::EditVersion; use crate::common::GetArticleData; +use crate::common::LocalUserView; use crate::common::{ArticleView, DbArticle, DbEdit}; use activitypub_federation::config::Data; use activitypub_federation::fetch::object_id::ObjectId; diff --git a/src/backend/api/instance.rs b/src/backend/api/instance.rs index cf16fa2..ee9718f 100644 --- a/src/backend/api/instance.rs +++ b/src/backend/api/instance.rs @@ -1,8 +1,8 @@ use crate::backend::database::instance::{DbInstance, InstanceView}; -use crate::backend::database::user::LocalUserView; use crate::backend::database::MyDataHandle; use crate::backend::error::MyResult; use crate::backend::federation::activities::follow::Follow; +use crate::common::LocalUserView; use activitypub_federation::config::Data; use axum::Extension; use axum::{Form, Json}; diff --git a/src/backend/api/mod.rs b/src/backend/api/mod.rs index 236e7df..60b768b 100644 --- a/src/backend/api/mod.rs +++ b/src/backend/api/mod.rs @@ -2,15 +2,16 @@ use crate::backend::api::article::create_article; use crate::backend::api::article::{edit_article, fork_article, get_article}; use crate::backend::api::instance::follow_instance; use crate::backend::api::instance::get_local_instance; -use crate::backend::api::user::login_user; +use crate::backend::api::user::my_profile; use crate::backend::api::user::register_user; use crate::backend::api::user::validate; +use crate::backend::api::user::{login_user, logout_user}; use crate::backend::database::conflict::{ApiConflict, DbConflict}; use crate::backend::database::instance::DbInstance; -use crate::backend::database::user::LocalUserView; use crate::backend::database::MyDataHandle; use crate::backend::error::MyResult; use crate::common::DbEdit; +use crate::common::LocalUserView; use crate::common::{ArticleView, DbArticle}; use activitypub_federation::config::Data; use activitypub_federation::fetch::object_id::ObjectId; @@ -49,8 +50,10 @@ pub fn api_routes() -> Router { .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("/account/register", post(register_user)) + .route("/account/login", post(login_user)) + .route("/account/my_profile", get(my_profile)) + .route("/account/logout", get(logout_user)) .route_layer(middleware::from_fn(auth)) } diff --git a/src/backend/api/user.rs b/src/backend/api/user.rs index 1fdf50a..6fc5a4f 100644 --- a/src/backend/api/user.rs +++ b/src/backend/api/user.rs @@ -1,10 +1,10 @@ -use crate::backend::database::user::{DbLocalUser, DbPerson, LocalUserView}; use crate::backend::database::{read_jwt_secret, MyDataHandle}; use crate::backend::error::MyResult; -use crate::common::{LoginResponse, LoginUserData, RegisterUserData}; +use crate::common::{DbLocalUser, DbPerson, LocalUserView, LoginUserData, RegisterUserData}; use activitypub_federation::config::Data; use anyhow::anyhow; use axum::{Form, Json}; +use axum_extra::extract::cookie::{Cookie, CookieJar, Expiration, SameSite}; use axum_macros::debug_handler; use bcrypt::verify; use chrono::Utc; @@ -13,6 +13,9 @@ use jsonwebtoken::Validation; use jsonwebtoken::{decode, get_current_timestamp}; use jsonwebtoken::{encode, EncodingKey, Header}; use serde::{Deserialize, Serialize}; +use time::{Duration, OffsetDateTime}; + +pub static AUTH_COOKIE: &str = "auth"; #[derive(Debug, Serialize, Deserialize)] struct Claims { @@ -26,22 +29,19 @@ struct Claims { pub exp: u64, } -fn generate_login_token( - local_user: DbLocalUser, - data: &Data, -) -> MyResult { +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(), + exp: get_current_timestamp() + 60 * 60 * 24 * 365, }; let secret = read_jwt_secret(data)?; let key = EncodingKey::from_secret(secret.as_bytes()); let jwt = encode(&Header::default(), &claims, &key)?; - Ok(LoginResponse { jwt }) + Ok(jwt) } pub async fn validate(jwt: &str, data: &Data) -> MyResult { @@ -55,21 +55,58 @@ pub async fn validate(jwt: &str, data: &Data) -> MyResult, + jar: CookieJar, Form(form): Form, -) -> MyResult> { +) -> MyResult<(CookieJar, Json)> { let user = DbPerson::create_local(form.username, form.password, &data)?; - Ok(Json(generate_login_token(user.local_user, &data)?)) + let token = generate_login_token(&user.local_user, &data)?; + let jar = jar.add(create_cookie(token)); + Ok((jar, Json(user))) } #[debug_handler] pub(in crate::backend::api) async fn login_user( data: Data, + jar: CookieJar, Form(form): Form, -) -> MyResult> { +) -> MyResult<(CookieJar, Json)> { 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)?)) + let token = generate_login_token(&user.local_user, &data)?; + let jar = jar.add(create_cookie(token)); + Ok((jar, Json(user))) +} + +fn create_cookie(jwt: String) -> Cookie<'static> { + Cookie::build(AUTH_COOKIE, jwt) + .domain("localhost") + .same_site(SameSite::Strict) + .path("/") + .http_only(true) + .expires(Expiration::DateTime( + OffsetDateTime::now_utc() + Duration::weeks(52), + )) + .finish() +} + +#[debug_handler] +pub(in crate::backend::api) async fn my_profile( + data: Data, + jar: CookieJar, +) -> MyResult> { + let jwt = jar.get(AUTH_COOKIE).map(|c| c.value()); + if let Some(jwt) = jwt { + Ok(Json(validate(jwt, &data).await?)) + } else { + Err(anyhow!("invalid/missing auth").into()) + } +} + +#[debug_handler] +pub(in crate::backend::api) async fn logout_user(jar: CookieJar) -> MyResult { + let jar = jar.remove(Cookie::named(AUTH_COOKIE)); + Ok(jar) } diff --git a/src/backend/database/conflict.rs b/src/backend/database/conflict.rs index 55f4023..2f6f2d5 100644 --- a/src/backend/database/conflict.rs +++ b/src/backend/database/conflict.rs @@ -1,14 +1,13 @@ use crate::backend::database::schema::conflict; -use crate::backend::database::user::DbLocalUser; use crate::backend::database::MyDataHandle; use crate::backend::error::MyResult; use crate::backend::federation::activities::submit_article_update; use crate::backend::utils::generate_article_version; use crate::common::DbArticle; use crate::common::DbEdit; +use crate::common::DbLocalUser; use crate::common::EditVersion; use activitypub_federation::config::Data; -use activitypub_federation::fetch::object_id::ObjectId; use diesel::ExpressionMethods; use diesel::{ delete, insert_into, Identifiable, Insertable, PgConnection, QueryDsl, Queryable, RunQueryDsl, @@ -76,9 +75,7 @@ impl DbConflict { ) -> MyResult> { let article = DbArticle::read(self.article_id, &data.db_connection)?; // Make sure to get latest version from origin so that all conflicts can be resolved - let original_article = ObjectId::parse(&article.ap_id)? - .dereference_forced(data) - .await?; + let original_article = article.ap_id.dereference_forced(data).await?; // create common ancestor version let edits = DbEdit::read_for_article(&original_article, &data.db_connection)?; diff --git a/src/backend/database/instance.rs b/src/backend/database/instance.rs index 1bed6b7..a805fd2 100644 --- a/src/backend/database/instance.rs +++ b/src/backend/database/instance.rs @@ -1,8 +1,8 @@ use crate::backend::database::schema::{instance, instance_follow}; -use crate::backend::database::user::DbPerson; use crate::backend::database::MyDataHandle; use crate::backend::error::MyResult; use crate::backend::federation::objects::articles_collection::DbArticleCollection; +use crate::common::DbPerson; use activitypub_federation::config::Data; use activitypub_federation::fetch::collection_id::CollectionId; use activitypub_federation::fetch::object_id::ObjectId; diff --git a/src/backend/database/user.rs b/src/backend/database/user.rs index 8056a98..70b8356 100644 --- a/src/backend/database/user.rs +++ b/src/backend/database/user.rs @@ -1,6 +1,7 @@ use crate::backend::database::schema::{local_user, person}; use crate::backend::database::MyDataHandle; use crate::backend::error::MyResult; +use crate::common::{DbLocalUser, DbPerson, LocalUserView}; use activitypub_federation::config::Data; use activitypub_federation::fetch::object_id::ObjectId; use activitypub_federation::http_signatures::generate_actor_keypair; @@ -9,30 +10,10 @@ 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 diesel::{insert_into, AsChangeset, Insertable, PgConnection, RunQueryDsl}; 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 { @@ -40,23 +21,6 @@ pub struct DbLocalUserForm { 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 { diff --git a/src/backend/federation/activities/follow.rs b/src/backend/federation/activities/follow.rs index 34d893a..9397593 100644 --- a/src/backend/federation/activities/follow.rs +++ b/src/backend/federation/activities/follow.rs @@ -1,10 +1,10 @@ use crate::backend::database::instance::DbInstance; -use crate::backend::database::user::DbPerson; use crate::backend::error::MyResult; use crate::backend::federation::send_activity; use crate::backend::{ database::MyDataHandle, federation::activities::accept::Accept, generate_activity_id, }; +use crate::common::DbPerson; use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, diff --git a/src/backend/federation/activities/mod.rs b/src/backend/federation/activities/mod.rs index 5e14afb..d30f69b 100644 --- a/src/backend/federation/activities/mod.rs +++ b/src/backend/federation/activities/mod.rs @@ -35,7 +35,7 @@ pub async fn submit_article_update( id: -1, creator_id, hash: form.hash, - ap_id: form.ap_id.to_string(), + ap_id: form.ap_id, diff: form.diff, article_id: form.article_id, previous_version_id: form.previous_version_id, diff --git a/src/backend/federation/objects/article.rs b/src/backend/federation/objects/article.rs index 063e1e4..0e45de6 100644 --- a/src/backend/federation/objects/article.rs +++ b/src/backend/federation/objects/article.rs @@ -49,7 +49,7 @@ impl Object for DbArticle { let local_instance = DbInstance::read_local_instance(&data.db_connection)?; Ok(ApubArticle { kind: Default::default(), - id: ObjectId::parse(&self.ap_id)?, + id: self.ap_id.clone(), attributed_to: local_instance.ap_id.clone(), to: vec![public(), local_instance.followers_url()?], edits: self.edits_id()?, diff --git a/src/backend/federation/objects/edit.rs b/src/backend/federation/objects/edit.rs index 05466b9..7520125 100644 --- a/src/backend/federation/objects/edit.rs +++ b/src/backend/federation/objects/edit.rs @@ -1,7 +1,7 @@ use crate::backend::database::edit::DbEditForm; -use crate::backend::database::user::DbPerson; use crate::backend::database::MyDataHandle; use crate::backend::error::Error; +use crate::common::DbPerson; use crate::common::EditVersion; use crate::common::{DbArticle, DbEdit}; use activitypub_federation::config::Data; @@ -48,11 +48,11 @@ impl Object for DbEdit { let creator = DbPerson::read(self.creator_id, data)?; Ok(ApubEdit { kind: PatchType::Patch, - id: ObjectId::parse(&self.ap_id)?, + id: self.ap_id, content: self.diff, version: self.hash, previous_version: self.previous_version_id, - object: ObjectId::parse(&article.ap_id)?, + object: article.ap_id, attributed_to: creator.ap_id, }) } diff --git a/src/backend/federation/objects/user.rs b/src/backend/federation/objects/user.rs index 704335c..86b765e 100644 --- a/src/backend/federation/objects/user.rs +++ b/src/backend/federation/objects/user.rs @@ -1,6 +1,7 @@ -use crate::backend::database::user::{DbPerson, DbPersonForm}; +use crate::backend::database::user::DbPersonForm; use crate::backend::database::MyDataHandle; use crate::backend::error::Error; +use crate::common::DbPerson; use activitypub_federation::kinds::actor::PersonType; use activitypub_federation::{ config::Data, diff --git a/src/backend/federation/routes.rs b/src/backend/federation/routes.rs index 30bebf8..390073a 100644 --- a/src/backend/federation/routes.rs +++ b/src/backend/federation/routes.rs @@ -1,5 +1,4 @@ use crate::backend::database::instance::DbInstance; -use crate::backend::database::user::DbPerson; use crate::backend::database::MyDataHandle; use crate::backend::error::Error; use crate::backend::error::MyResult; @@ -17,6 +16,7 @@ use crate::backend::federation::objects::edits_collection::{ApubEditCollection, use crate::backend::federation::objects::instance::ApubInstance; use crate::backend::federation::objects::user::ApubUser; use crate::common::DbArticle; +use crate::common::DbPerson; use activitypub_federation::axum::inbox::{receive_activity, ActivityData}; use activitypub_federation::axum::json::FederationJson; use activitypub_federation::config::Data; diff --git a/src/common/mod.rs b/src/common/mod.rs index 8022273..cc7d583 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -1,9 +1,12 @@ -#[cfg(feature = "ssr")] -use crate::backend::database::schema::{article, edit}; -#[cfg(feature = "ssr")] -use diesel::{Identifiable, Queryable, Selectable}; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; +#[cfg(feature = "ssr")] +use { + crate::backend::database::schema::{article, edit, local_user, person}, + activitypub_federation::fetch::object_id::ObjectId, + diesel::{Identifiable, Queryable, Selectable}, +}; #[derive(Deserialize, Serialize, Clone)] pub struct GetArticleData { @@ -26,6 +29,9 @@ pub struct DbArticle { pub id: i32, pub title: String, pub text: String, + #[cfg(feature = "ssr")] + pub ap_id: ObjectId, + #[cfg(not(feature = "ssr"))] pub ap_id: String, pub instance_id: i32, pub local: bool, @@ -42,6 +48,9 @@ pub struct DbEdit { pub creator_id: i32, /// UUID built from sha224 hash of diff pub hash: EditVersion, + #[cfg(feature = "ssr")] + pub ap_id: ObjectId, + #[cfg(not(feature = "ssr"))] pub ap_id: String, pub diff: String, pub article_id: i32, @@ -55,19 +64,53 @@ pub struct DbEdit { #[cfg_attr(feature = "ssr", derive(diesel_derive_newtype::DieselNewType))] pub struct EditVersion(pub(crate) Uuid); -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, Clone)] pub struct RegisterUserData { pub username: String, pub password: String, } -#[derive(Deserialize, Serialize, Clone)] -pub struct LoginResponse { - pub jwt: String, -} - #[derive(Deserialize, Serialize)] pub struct LoginUserData { pub username: String, pub password: String, } + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "ssr", derive(Queryable))] +#[cfg_attr(feature = "ssr", 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)] +#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Identifiable))] +#[cfg_attr(feature = "ssr", 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, +} + +/// Federation related data from a local or remote user. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Identifiable))] +#[cfg_attr(feature = "ssr", diesel(table_name = person, check_for_backend(diesel::pg::Pg)))] +pub struct DbPerson { + pub id: i32, + pub username: String, + #[cfg(feature = "ssr")] + pub ap_id: ObjectId, + #[cfg(not(feature = "ssr"))] + pub ap_id: String, + 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, +} diff --git a/src/frontend/api.rs b/src/frontend/api.rs index a9b9d16..850aabd 100644 --- a/src/frontend/api.rs +++ b/src/frontend/api.rs @@ -1,5 +1,6 @@ use crate::common::GetArticleData; -use crate::common::{ArticleView, LoginResponse, LoginUserData, RegisterUserData}; +use crate::common::LocalUserView; +use crate::common::{ArticleView, LoginUserData, RegisterUserData}; use crate::frontend::error::MyResult; use anyhow::anyhow; use once_cell::sync::Lazy; @@ -39,20 +40,29 @@ where } } -pub async fn register(hostname: &str, register_form: RegisterUserData) -> MyResult { +pub async fn register(hostname: &str, register_form: RegisterUserData) -> MyResult { let req = CLIENT - .post(format!("http://{}/api/v1/user/register", hostname)) + .post(format!("http://{}/api/v1/account/register", hostname)) .form(®ister_form); - handle_json_res(req).await + handle_json_res::(req).await } -pub async fn login(hostname: &str, username: &str, password: &str) -> MyResult { - let login_form = LoginUserData { - username: username.to_string(), - password: password.to_string(), - }; +pub async fn login(hostname: &str, login_form: LoginUserData) -> MyResult { let req = CLIENT - .post(format!("http://{}/api/v1/user/login", hostname)) + .post(format!("http://{}/api/v1/account/login", hostname)) .form(&login_form); - handle_json_res(req).await + handle_json_res::(req).await +} + +pub async fn my_profile(hostname: &str) -> MyResult { + let req = CLIENT.get(format!("http://{}/api/v1/account/my_profile", hostname)); + handle_json_res::(req).await +} + +pub async fn logout(hostname: &str) -> MyResult<()> { + CLIENT + .get(format!("http://{}/api/v1/account/logout", hostname)) + .send() + .await?; + Ok(()) } diff --git a/src/frontend/app.rs b/src/frontend/app.rs index 3fcdeb7..d209190 100644 --- a/src/frontend/app.rs +++ b/src/frontend/app.rs @@ -1,33 +1,62 @@ +use crate::common::LocalUserView; +use crate::frontend::api::my_profile; use crate::frontend::components::nav::Nav; use crate::frontend::pages::article::Article; use crate::frontend::pages::login::Login; use crate::frontend::pages::register::Register; use crate::frontend::pages::Page; -use leptos::{component, provide_context, use_context, view, IntoView}; +use leptos::{ + component, create_local_resource, create_rw_signal, expect_context, provide_context, + use_context, view, IntoView, RwSignal, SignalGetUntracked, SignalUpdate, +}; use leptos_meta::provide_meta_context; use leptos_meta::*; use leptos_router::Route; use leptos_router::Router; use leptos_router::Routes; -// TODO: change to GlobalState and also store auth token here -// https://book.leptos.dev/15_global_state.html +// https://book.leptos.dev/15_global_state.html #[derive(Clone)] -pub struct BackendHostname(String); +pub struct GlobalState { + backend_hostname: String, + pub(crate) my_profile: Option, +} -impl BackendHostname { - pub fn read() -> String { - use_context::() +impl GlobalState { + pub fn read_hostname() -> String { + use_context::>() .expect("backend hostname is provided") - .0 + .get_untracked() + .backend_hostname + } + + pub fn update_my_profile(&self) { + let backend_hostname_ = self.backend_hostname.clone(); + create_local_resource( + move || backend_hostname_.clone(), + |backend_hostname| async move { + if let Ok(my_profile) = my_profile(&backend_hostname).await { + expect_context::>() + .update(|state| state.my_profile = Some(my_profile.clone())) + }; + }, + ); } } #[component] pub fn App() -> impl IntoView { + let backend_hostname = "localhost:8080".to_string(); + provide_meta_context(); - let backend_hostname = BackendHostname("localhost:8080".to_string()); - provide_context(backend_hostname); + let backend_hostname = GlobalState { + backend_hostname, + my_profile: None, + }; + // Load user profile in case we are already logged in + backend_hostname.update_my_profile(); + provide_context(create_rw_signal(backend_hostname)); + view! { <> @@ -42,7 +71,6 @@ pub fn App() -> impl IntoView { - } } diff --git a/src/frontend/components/nav.rs b/src/frontend/components/nav.rs index c73a570..5925207 100644 --- a/src/frontend/components/nav.rs +++ b/src/frontend/components/nav.rs @@ -1,20 +1,55 @@ -use leptos::{component, view, IntoView}; +use crate::frontend::api::logout; +use crate::frontend::app::GlobalState; +use leptos::*; +use leptos::{component, use_context, view, IntoView, RwSignal, SignalWith}; use leptos_router::*; #[component] pub fn Nav() -> impl IntoView { + let global_state = use_context::>().unwrap(); // TODO: use `
  • "Main Page"
  • + "Logged in as: " + { + move || global_state.with(|state| state.my_profile.clone().unwrap().person.username) + } + +

    + } + } + >
  • "Login"
  • "Register"
  • +
    } } + +fn do_logout() { + dbg!("do logout"); + create_action(move |()| async move { + dbg!("run logout action"); + logout(&GlobalState::read_hostname()).await.unwrap(); + expect_context::>() + .get() + .update_my_profile(); + }); +} diff --git a/src/frontend/pages/login.rs b/src/frontend/pages/login.rs index 1e9d3e3..e8df78c 100644 --- a/src/frontend/pages/login.rs +++ b/src/frontend/pages/login.rs @@ -1,51 +1,58 @@ +use crate::common::LoginUserData; use crate::frontend::api::login; -use leptos::ev::SubmitEvent; +use crate::frontend::app::GlobalState; +use crate::frontend::components::credentials::*; use leptos::*; -use log::info; - -// TODO: this seems to be working, but need to implement registration also -// TODO: use leptos_form if possible -// https://github.com/leptos-form/leptos_form/issues/18 -fn do_login(ev: SubmitEvent, username: String, password: String) { - ev.prevent_default(); - spawn_local(async move { - let res = login("localhost:8080", &username, &password).await; - info!("{}", res.unwrap().jwt); - }); -} +use leptos_router::Redirect; #[component] pub fn Login() -> impl IntoView { - let name = RwSignal::new(String::new()); - let password = RwSignal::new(String::new()); + let (login_response, set_login_response) = create_signal(None::<()>); + let (login_error, set_login_error) = create_signal(None::); + let (wait_for_response, set_wait_for_response) = create_signal(false); + + let login_action = create_action(move |(email, password): &(String, String)| { + let username = email.to_string(); + let password = password.to_string(); + let credentials = LoginUserData { username, password }; + async move { + set_wait_for_response.update(|w| *w = true); + let result = login(&GlobalState::read_hostname(), credentials).await; + set_wait_for_response.update(|w| *w = false); + match result { + Ok(res) => { + expect_context::>() + .update(|state| state.my_profile = Some(res)); + set_login_response.update(|v| *v = Some(())); + set_login_error.update(|e| *e = None); + } + Err(err) => { + let msg = err.0.to_string(); + log::warn!("Unable to login: {msg}"); + set_login_error.update(|e| *e = Some(msg)); + } + } + } + }); + + let disabled = Signal::derive(move || wait_for_response.get()); view! { -
    -
    - - -
    - -
    - - -
    - -
    - - -
    -
    + + } + } + > + + } } diff --git a/src/frontend/pages/register.rs b/src/frontend/pages/register.rs index 2aed240..847b41f 100644 --- a/src/frontend/pages/register.rs +++ b/src/frontend/pages/register.rs @@ -1,14 +1,12 @@ -use crate::common::{LoginResponse, RegisterUserData}; +use crate::common::RegisterUserData; use crate::frontend::api::register; -use crate::frontend::app::BackendHostname; +use crate::frontend::app::GlobalState; use crate::frontend::components::credentials::*; -use crate::frontend::pages::Page; use leptos::{logging::log, *}; -use leptos_router::*; #[component] pub fn Register() -> impl IntoView { - let (register_response, set_register_response) = create_signal(None::); + let (register_response, set_register_response) = create_signal(None::<()>); let (register_error, set_register_error) = create_signal(None::); let (wait_for_response, set_wait_for_response) = create_signal(false); @@ -19,11 +17,13 @@ pub fn Register() -> impl IntoView { log!("Try to register new account for {}", credentials.username); async move { set_wait_for_response.update(|w| *w = true); - let result = register(&BackendHostname::read(), credentials).await; + let result = register(&GlobalState::read_hostname(), credentials).await; set_wait_for_response.update(|w| *w = false); match result { Ok(res) => { - set_register_response.update(|v| *v = Some(res)); + expect_context::>() + .update(|state| state.my_profile = Some(res)); + set_register_response.update(|v| *v = Some(())); set_register_error.update(|e| *e = None); } Err(err) => { @@ -53,7 +53,6 @@ pub fn Register() -> impl IntoView { } >

    "You have successfully registered."

    -

    "You can now " "login" " with your new account."

    } } diff --git a/src/main.rs b/src/main.rs index 11cd734..40089c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,5 +23,4 @@ fn main() { mount_to_body(|| { view! { } }); - log::info!("test 2"); } diff --git a/tests/common.rs b/tests/common.rs index 20562a9..734fb3a 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -1,19 +1,18 @@ use anyhow::anyhow; use ibis::backend::api::article::{CreateArticleData, EditArticleData, ForkArticleData}; use ibis::backend::api::instance::FollowInstance; -use ibis::backend::api::user::RegisterUserData; -use ibis::backend::api::user::{LoginResponse, LoginUserData}; +use ibis::backend::api::user::AUTH_COOKIE; use ibis::backend::api::ResolveObject; use ibis::backend::database::conflict::ApiConflict; use ibis::backend::database::instance::DbInstance; use ibis::backend::error::MyResult; use ibis::backend::start; use ibis::common::ArticleView; -use ibis::frontend::api; -use ibis::frontend::api::get_query; -use ibis_lib::frontend::api; +use ibis::common::LoginUserData; +use ibis::common::RegisterUserData; +use ibis::frontend::api::{get_article, get_query, handle_json_res, register}; use once_cell::sync::Lazy; -use reqwest::{Client, StatusCode}; +use reqwest::{Client, ClientBuilder, StatusCode}; use serde::de::Deserialize; use std::env::current_dir; use std::fs::create_dir_all; @@ -97,6 +96,7 @@ fn generate_db_path(name: &'static str, port: i32) -> String { pub struct IbisInstance { pub hostname: String, + pub client: Client, pub jwt: String, db_path: String, db_handle: JoinHandle<()>, @@ -123,11 +123,18 @@ impl IbisInstance { }); // wait a moment for the backend to start tokio::time::sleep(Duration::from_millis(100)).await; - let register_res = api::register(&hostname, username, "hunter2").await.unwrap(); - assert!(!register_res.jwt.is_empty()); + let form = RegisterUserData { + username: username.to_string(), + password: "hunter2".to_string(), + }; + // TODO: use a separate http client for each backend instance, with cookie store for auth + // TODO: how to pass the client/hostname to api client methods? + // probably create a struct ApiClient(hostname, client) with all api methods in impl + let client = ClientBuilder::new().cookie_store(true).build(); + let register_res = register(&hostname, form).await.unwrap(); Self { - jwt: register_res.jwt, hostname, + client, db_path, db_handle: handle, } @@ -156,7 +163,7 @@ pub async fn create_article(instance: &IbisInstance, title: String) -> MyResult< .post(format!("http://{}/api/v1/article", &instance.hostname)) .form(&create_form) .bearer_auth(&instance.jwt); - let article: ArticleView = api::handle_json_res(req).await?; + 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 { @@ -176,7 +183,7 @@ pub async fn edit_article_with_conflict( .patch(format!("http://{}/api/v1/article", instance.hostname)) .form(edit_form) .bearer_auth(&instance.jwt); - api::handle_json_res(req).await + handle_json_res(req).await? } pub async fn get_conflicts(instance: &IbisInstance) -> MyResult> { @@ -186,7 +193,7 @@ pub async fn get_conflicts(instance: &IbisInstance) -> MyResult &instance.hostname )) .bearer_auth(&instance.jwt); - api::handle_json_res(req).await + handle_json_res(req).await? } pub async fn edit_article( @@ -195,7 +202,8 @@ pub async fn edit_article( ) -> MyResult { let edit_res = edit_article_with_conflict(instance, edit_form).await?; assert!(edit_res.is_none()); - api::get_article(&instance.hostname, edit_form.article_id).await + + get_article(&instance.hostname, todo!("{}", edit_form.article_id)).await } pub async fn get(hostname: &str, endpoint: &str) -> MyResult @@ -213,7 +221,7 @@ pub async fn fork_article( .post(format!("http://{}/api/v1/article/fork", instance.hostname)) .form(form) .bearer_auth(&instance.jwt); - api::handle_json_res(req).await + handle_json_res(req).await? } pub async fn follow_instance(instance: &IbisInstance, follow_instance: &str) -> MyResult<()> { @@ -222,7 +230,7 @@ pub async fn follow_instance(instance: &IbisInstance, follow_instance: &str) -> id: Url::parse(&format!("http://{}", follow_instance))?, }; let instance_resolved: DbInstance = - api::get_query(&instance.hostname, "resolve_instance", Some(resolve_form)).await?; + get_query(&instance.hostname, "resolve_instance", Some(resolve_form)).await?; // send follow let follow_form = FollowInstance { diff --git a/tests/test.rs b/tests/test.rs index 071e1b6..3f2d86b 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -2,12 +2,12 @@ extern crate ibis; mod common; +use crate::common::fork_article; +use crate::common::get_conflicts; use crate::common::{ create_article, edit_article, edit_article_with_conflict, follow_instance, get, TestData, CLIENT, TEST_ARTICLE_DEFAULT_TEXT, }; -use crate::common::{fork_article, login}; -use crate::common::{get_conflicts, register}; use ibis::backend::api::article::{CreateArticleData, EditArticleData, ForkArticleData}; use ibis::backend::api::{ResolveObject, SearchArticleData}; use ibis::backend::database::instance::{DbInstance, InstanceView}; @@ -17,6 +17,7 @@ use ibis::common::DbArticle; use ibis::frontend::api::get_article; use ibis::frontend::api::get_query; use ibis::frontend::api::handle_json_res; +use ibis::frontend::api::login; use pretty_assertions::{assert_eq, assert_ne}; use url::Url;