registration and login working

This commit is contained in:
Felix Ableitner 2024-01-16 16:07:01 +01:00
parent 89a71c7fcd
commit 8ca01dee07
25 changed files with 424 additions and 181 deletions

118
Cargo.lock generated
View File

@ -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",
]

View File

@ -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"

View File

@ -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

View File

@ -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;

View File

@ -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};

View File

@ -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))
}

View File

@ -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<MyDataHandle>,
) -> MyResult<LoginResponse> {
fn generate_login_token(local_user: &DbLocalUser, data: &Data<MyDataHandle>) -> MyResult<String> {
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<MyDataHandle>) -> MyResult<LocalUserView> {
@ -55,21 +55,58 @@ pub async fn validate(jwt: &str, data: &Data<MyDataHandle>) -> MyResult<LocalUse
#[debug_handler]
pub(in crate::backend::api) async fn register_user(
data: Data<MyDataHandle>,
jar: CookieJar,
Form(form): Form<RegisterUserData>,
) -> MyResult<Json<LoginResponse>> {
) -> MyResult<(CookieJar, Json<LocalUserView>)> {
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<MyDataHandle>,
jar: CookieJar,
Form(form): Form<LoginUserData>,
) -> MyResult<Json<LoginResponse>> {
) -> MyResult<(CookieJar, Json<LocalUserView>)> {
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<MyDataHandle>,
jar: CookieJar,
) -> MyResult<Json<LocalUserView>> {
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<CookieJar> {
let jar = jar.remove(Cookie::named(AUTH_COOKIE));
Ok(jar)
}

View File

@ -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<Option<ApiConflict>> {
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)?;

View File

@ -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;

View File

@ -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<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 {

View File

@ -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,

View File

@ -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,

View File

@ -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()?,

View File

@ -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,
})
}

View File

@ -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,

View File

@ -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;

View File

@ -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<DbArticle>,
#[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<DbEdit>,
#[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<DbPerson>,
#[cfg(not(feature = "ssr"))]
pub ap_id: String,
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,
}

View File

@ -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<LoginResponse> {
pub async fn register(hostname: &str, register_form: RegisterUserData) -> MyResult<LocalUserView> {
let req = CLIENT
.post(format!("http://{}/api/v1/user/register", hostname))
.post(format!("http://{}/api/v1/account/register", hostname))
.form(&register_form);
handle_json_res(req).await
handle_json_res::<LocalUserView>(req).await
}
pub async fn login(hostname: &str, username: &str, password: &str) -> MyResult<LoginResponse> {
let login_form = LoginUserData {
username: username.to_string(),
password: password.to_string(),
};
pub async fn login(hostname: &str, login_form: LoginUserData) -> MyResult<LocalUserView> {
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::<LocalUserView>(req).await
}
pub async fn my_profile(hostname: &str) -> MyResult<LocalUserView> {
let req = CLIENT.get(format!("http://{}/api/v1/account/my_profile", hostname));
handle_json_res::<LocalUserView>(req).await
}
pub async fn logout(hostname: &str) -> MyResult<()> {
CLIENT
.get(format!("http://{}/api/v1/account/logout", hostname))
.send()
.await?;
Ok(())
}

View File

@ -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<LocalUserView>,
}
impl BackendHostname {
pub fn read() -> String {
use_context::<BackendHostname>()
impl GlobalState {
pub fn read_hostname() -> String {
use_context::<RwSignal<GlobalState>>()
.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::<RwSignal<GlobalState>>()
.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! {
<>
<Stylesheet id="simple" href="/assets/simple.css"/>
@ -42,7 +71,6 @@ pub fn App() -> impl IntoView {
</Routes>
</main>
</Router>
</>
}
}

View File

@ -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::<RwSignal<GlobalState>>().unwrap();
// TODO: use `<Show when` based on auth token for login/register/logout
view! {
<nav class="inner">
<li>
<A href="/">"Main Page"</A>
</li>
<Show
when=move || global_state.with(|state| state.my_profile.is_none())
fallback=move || {
view! {
<p>"Logged in as: "
{
move || global_state.with(|state| state.my_profile.clone().unwrap().person.username)
}
<button on:click=move |_| {
// TODO: not executed
dbg!(1);
do_logout()
}>
Logout
</button>
</p>
}
}
>
<li>
<A href="/login">"Login"</A>
</li>
<li>
<A href="/register">"Register"</A>
</li>
</Show>
</nav>
}
}
fn do_logout() {
dbg!("do logout");
create_action(move |()| async move {
dbg!("run logout action");
logout(&GlobalState::read_hostname()).await.unwrap();
expect_context::<RwSignal<GlobalState>>()
.get()
.update_my_profile();
});
}

View File

@ -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::<String>);
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::<RwSignal<GlobalState>>()
.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! {
<form on:submit=move |ev| do_login(ev, name.get(), password.get())>
<div>
<label for="username">Username: </label>
<input
id="username"
type="text"
on:input=move |ev| name.set(event_target_value(&ev))
label="Username"
/>
</div>
<div>
<label for="password">Password: </label>
<input
id="password"
type="password"
on:input=move |ev| password.set(event_target_value(&ev))
/>
</div>
<div>
<button type="submit">
"Login"
</button>
</div>
</form>
<Show
when=move || login_response.get().is_some()
fallback=move || {
view! {
<CredentialsForm
title="Please enter the desired credentials"
action_label="Login"
action=login_action
error=login_error.into()
disabled
/>
}
}
>
<Redirect path="/"/>
</Show>
}
}

View File

@ -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::<LoginResponse>);
let (register_response, set_register_response) = create_signal(None::<()>);
let (register_error, set_register_error) = create_signal(None::<String>);
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::<RwSignal<GlobalState>>()
.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 {
}
>
<p>"You have successfully registered."</p>
<p>"You can now " <A href=Page::Login.path()>"login"</A> " with your new account."</p>
</Show>
}
}

View File

@ -23,5 +23,4 @@ fn main() {
mount_to_body(|| {
view! { <App/> }
});
log::info!("test 2");
}

View File

@ -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<Vec<ApiConflict>> {
@ -186,7 +193,7 @@ pub async fn get_conflicts(instance: &IbisInstance) -> MyResult<Vec<ApiConflict>
&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<ArticleView> {
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<T>(hostname: &str, endpoint: &str) -> MyResult<T>
@ -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 {

View File

@ -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;