mirror of
https://github.com/Nutomic/ibis.git
synced 2024-11-22 15:51:09 +00:00
Basic user profile page
This commit is contained in:
parent
a2b808ce57
commit
47362b52da
24 changed files with 225 additions and 105 deletions
|
@ -140,11 +140,11 @@ pub(in crate::backend::api) async fn get_article(
|
||||||
match (query.title, query.id) {
|
match (query.title, query.id) {
|
||||||
(Some(title), None) => Ok(Json(DbArticle::read_view_title(
|
(Some(title), None) => Ok(Json(DbArticle::read_view_title(
|
||||||
&title,
|
&title,
|
||||||
query.instance_domain,
|
query.domain,
|
||||||
&data.db_connection,
|
&data.db_connection,
|
||||||
)?)),
|
)?)),
|
||||||
(None, Some(id)) => {
|
(None, Some(id)) => {
|
||||||
if query.instance_domain.is_some() {
|
if query.domain.is_some() {
|
||||||
return Err(anyhow!("Cant combine id and instance_domain").into());
|
return Err(anyhow!("Cant combine id and instance_domain").into());
|
||||||
}
|
}
|
||||||
Ok(Json(DbArticle::read_view(id, &data.db_connection)?))
|
Ok(Json(DbArticle::read_view(id, &data.db_connection)?))
|
||||||
|
|
|
@ -4,8 +4,8 @@ use crate::backend::api::article::{
|
||||||
use crate::backend::api::article::{edit_article, fork_article, get_article};
|
use crate::backend::api::article::{edit_article, fork_article, get_article};
|
||||||
use crate::backend::api::instance::get_local_instance;
|
use crate::backend::api::instance::get_local_instance;
|
||||||
use crate::backend::api::instance::{follow_instance, resolve_instance};
|
use crate::backend::api::instance::{follow_instance, resolve_instance};
|
||||||
use crate::backend::api::user::register_user;
|
|
||||||
use crate::backend::api::user::validate;
|
use crate::backend::api::user::validate;
|
||||||
|
use crate::backend::api::user::{get_user, register_user};
|
||||||
use crate::backend::api::user::{login_user, logout_user};
|
use crate::backend::api::user::{login_user, logout_user};
|
||||||
use crate::backend::api::user::{my_profile, AUTH_COOKIE};
|
use crate::backend::api::user::{my_profile, AUTH_COOKIE};
|
||||||
use crate::backend::database::conflict::DbConflict;
|
use crate::backend::database::conflict::DbConflict;
|
||||||
|
@ -45,6 +45,7 @@ pub fn api_routes() -> Router {
|
||||||
.route("/instance/follow", post(follow_instance))
|
.route("/instance/follow", post(follow_instance))
|
||||||
.route("/instance/resolve", get(resolve_instance))
|
.route("/instance/resolve", get(resolve_instance))
|
||||||
.route("/search", get(search_article))
|
.route("/search", get(search_article))
|
||||||
|
.route("/user", get(get_user))
|
||||||
.route("/account/register", post(register_user))
|
.route("/account/register", post(register_user))
|
||||||
.route("/account/login", post(login_user))
|
.route("/account/login", post(login_user))
|
||||||
.route("/account/my_profile", get(my_profile))
|
.route("/account/my_profile", get(my_profile))
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
use crate::backend::database::{read_jwt_secret, IbisData};
|
use crate::backend::database::{read_jwt_secret, IbisData};
|
||||||
use crate::backend::error::MyResult;
|
use crate::backend::error::MyResult;
|
||||||
use crate::common::{DbLocalUser, DbPerson, LocalUserView, LoginUserData, RegisterUserData};
|
use crate::common::{DbPerson, GetUserData, LocalUserView, LoginUserData, RegisterUserData};
|
||||||
use activitypub_federation::config::Data;
|
use activitypub_federation::config::Data;
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
|
use axum::extract::Query;
|
||||||
use axum::{Form, Json};
|
use axum::{Form, Json};
|
||||||
use axum_extra::extract::cookie::{Cookie, CookieJar, Expiration, SameSite};
|
use axum_extra::extract::cookie::{Cookie, CookieJar, Expiration, SameSite};
|
||||||
use axum_macros::debug_handler;
|
use axum_macros::debug_handler;
|
||||||
|
@ -19,7 +20,7 @@ pub static AUTH_COOKIE: &str = "auth";
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
struct Claims {
|
struct Claims {
|
||||||
/// local_user.id
|
/// person.username
|
||||||
pub sub: String,
|
pub sub: String,
|
||||||
/// hostname
|
/// hostname
|
||||||
pub iss: String,
|
pub iss: String,
|
||||||
|
@ -29,10 +30,10 @@ struct Claims {
|
||||||
pub exp: u64,
|
pub exp: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_login_token(local_user: &DbLocalUser, data: &Data<IbisData>) -> MyResult<String> {
|
fn generate_login_token(person: &DbPerson, data: &Data<IbisData>) -> MyResult<String> {
|
||||||
let hostname = data.domain().to_string();
|
let hostname = data.domain().to_string();
|
||||||
let claims = Claims {
|
let claims = Claims {
|
||||||
sub: local_user.id.to_string(),
|
sub: person.username.clone(),
|
||||||
iss: hostname,
|
iss: hostname,
|
||||||
iat: Utc::now().timestamp(),
|
iat: Utc::now().timestamp(),
|
||||||
exp: get_current_timestamp() + 60 * 60 * 24 * 365,
|
exp: get_current_timestamp() + 60 * 60 * 24 * 365,
|
||||||
|
@ -49,7 +50,7 @@ pub async fn validate(jwt: &str, data: &Data<IbisData>) -> MyResult<LocalUserVie
|
||||||
let secret = read_jwt_secret(data)?;
|
let secret = read_jwt_secret(data)?;
|
||||||
let key = DecodingKey::from_secret(secret.as_bytes());
|
let key = DecodingKey::from_secret(secret.as_bytes());
|
||||||
let claims = decode::<Claims>(jwt, &key, &validation)?;
|
let claims = decode::<Claims>(jwt, &key, &validation)?;
|
||||||
DbPerson::read_local_from_id(claims.claims.sub.parse()?, data)
|
DbPerson::read_local_from_name(&claims.claims.sub, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
|
@ -62,7 +63,7 @@ pub(in crate::backend::api) async fn register_user(
|
||||||
return Err(anyhow!("Registration is closed").into());
|
return Err(anyhow!("Registration is closed").into());
|
||||||
}
|
}
|
||||||
let user = DbPerson::create_local(form.username, form.password, false, &data)?;
|
let user = DbPerson::create_local(form.username, form.password, false, &data)?;
|
||||||
let token = generate_login_token(&user.local_user, &data)?;
|
let token = generate_login_token(&user.person, &data)?;
|
||||||
let jar = jar.add(create_cookie(token, &data));
|
let jar = jar.add(create_cookie(token, &data));
|
||||||
Ok((jar, Json(user)))
|
Ok((jar, Json(user)))
|
||||||
}
|
}
|
||||||
|
@ -78,7 +79,7 @@ pub(in crate::backend::api) async fn login_user(
|
||||||
if !valid {
|
if !valid {
|
||||||
return Err(anyhow!("Invalid login").into());
|
return Err(anyhow!("Invalid login").into());
|
||||||
}
|
}
|
||||||
let token = generate_login_token(&user.local_user, &data)?;
|
let token = generate_login_token(&user.person, &data)?;
|
||||||
let jar = jar.add(create_cookie(token, &data));
|
let jar = jar.add(create_cookie(token, &data));
|
||||||
Ok((jar, Json(user)))
|
Ok((jar, Json(user)))
|
||||||
}
|
}
|
||||||
|
@ -122,3 +123,15 @@ pub(in crate::backend::api) async fn logout_user(
|
||||||
let jar = jar.remove(create_cookie(String::new(), &data));
|
let jar = jar.remove(create_cookie(String::new(), &data));
|
||||||
Ok(jar)
|
Ok(jar)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub(in crate::backend::api) async fn get_user(
|
||||||
|
params: Query<GetUserData>,
|
||||||
|
data: Data<IbisData>,
|
||||||
|
) -> MyResult<Json<DbPerson>> {
|
||||||
|
Ok(Json(DbPerson::read_from_name(
|
||||||
|
¶ms.name,
|
||||||
|
¶ms.domain,
|
||||||
|
&data,
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
|
|
@ -77,7 +77,7 @@ impl DbArticle {
|
||||||
|
|
||||||
pub fn read_view_title(
|
pub fn read_view_title(
|
||||||
title: &str,
|
title: &str,
|
||||||
instance_domain: Option<String>,
|
domain: Option<String>,
|
||||||
conn: &Mutex<PgConnection>,
|
conn: &Mutex<PgConnection>,
|
||||||
) -> MyResult<ArticleView> {
|
) -> MyResult<ArticleView> {
|
||||||
let article: DbArticle = {
|
let article: DbArticle = {
|
||||||
|
@ -86,9 +86,9 @@ impl DbArticle {
|
||||||
.inner_join(instance::table)
|
.inner_join(instance::table)
|
||||||
.filter(article::dsl::title.eq(title))
|
.filter(article::dsl::title.eq(title))
|
||||||
.into_boxed();
|
.into_boxed();
|
||||||
let query = if let Some(instance_domain) = instance_domain {
|
let query = if let Some(domain) = domain {
|
||||||
query
|
query
|
||||||
.filter(instance::dsl::domain.eq(instance_domain))
|
.filter(instance::dsl::domain.eq(domain))
|
||||||
.filter(instance::dsl::local.eq(false))
|
.filter(instance::dsl::local.eq(false))
|
||||||
} else {
|
} else {
|
||||||
query.filter(article::dsl::local.eq(true))
|
query.filter(article::dsl::local.eq(true))
|
||||||
|
|
|
@ -34,7 +34,7 @@ impl DbInstance {
|
||||||
let mut conn = conn.lock().unwrap();
|
let mut conn = conn.lock().unwrap();
|
||||||
Ok(insert_into(instance::table)
|
Ok(insert_into(instance::table)
|
||||||
.values(form)
|
.values(form)
|
||||||
.on_conflict(instance::dsl::ap_id)
|
.on_conflict(instance::ap_id)
|
||||||
.do_update()
|
.do_update()
|
||||||
.set(form)
|
.set(form)
|
||||||
.get_result(conn.deref_mut())?)
|
.get_result(conn.deref_mut())?)
|
||||||
|
@ -51,14 +51,14 @@ impl DbInstance {
|
||||||
) -> MyResult<DbInstance> {
|
) -> MyResult<DbInstance> {
|
||||||
let mut conn = data.db_connection.lock().unwrap();
|
let mut conn = data.db_connection.lock().unwrap();
|
||||||
Ok(instance::table
|
Ok(instance::table
|
||||||
.filter(instance::dsl::ap_id.eq(ap_id))
|
.filter(instance::ap_id.eq(ap_id))
|
||||||
.get_result(conn.deref_mut())?)
|
.get_result(conn.deref_mut())?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_local_instance(conn: &Mutex<PgConnection>) -> MyResult<Self> {
|
pub fn read_local_instance(conn: &Mutex<PgConnection>) -> MyResult<Self> {
|
||||||
let mut conn = conn.lock().unwrap();
|
let mut conn = conn.lock().unwrap();
|
||||||
Ok(instance::table
|
Ok(instance::table
|
||||||
.filter(instance::dsl::local.eq(true))
|
.filter(instance::local.eq(true))
|
||||||
.get_result(conn.deref_mut())?)
|
.get_result(conn.deref_mut())?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -101,7 +101,7 @@ impl DbInstance {
|
||||||
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(person::table.on(follower_id.eq(person::dsl::id)))
|
.inner_join(person::table.on(follower_id.eq(person::id)))
|
||||||
.filter(instance_id.eq(id_))
|
.filter(instance_id.eq(id_))
|
||||||
.select(person::all_columns)
|
.select(person::all_columns)
|
||||||
.get_results(conn.deref_mut())?)
|
.get_results(conn.deref_mut())?)
|
||||||
|
|
|
@ -9,9 +9,9 @@ use activitypub_federation::http_signatures::generate_actor_keypair;
|
||||||
use bcrypt::hash;
|
use bcrypt::hash;
|
||||||
use bcrypt::DEFAULT_COST;
|
use bcrypt::DEFAULT_COST;
|
||||||
use chrono::{DateTime, Local, Utc};
|
use chrono::{DateTime, Local, Utc};
|
||||||
use diesel::QueryDsl;
|
|
||||||
use diesel::{insert_into, AsChangeset, Insertable, PgConnection, RunQueryDsl};
|
use diesel::{insert_into, AsChangeset, Insertable, PgConnection, RunQueryDsl};
|
||||||
use diesel::{ExpressionMethods, JoinOnDsl};
|
use diesel::{ExpressionMethods, JoinOnDsl};
|
||||||
|
use diesel::{PgTextExpressionMethods, QueryDsl};
|
||||||
use std::ops::DerefMut;
|
use std::ops::DerefMut;
|
||||||
use std::sync::{Mutex, MutexGuard};
|
use std::sync::{Mutex, MutexGuard};
|
||||||
|
|
||||||
|
@ -103,6 +103,27 @@ impl DbPerson {
|
||||||
.get_result(conn.deref_mut())?)
|
.get_result(conn.deref_mut())?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn read_from_name(
|
||||||
|
username: &str,
|
||||||
|
domain: &Option<String>,
|
||||||
|
data: &Data<IbisData>,
|
||||||
|
) -> MyResult<DbPerson> {
|
||||||
|
let mut conn = data.db_connection.lock().unwrap();
|
||||||
|
let mut query = person::table
|
||||||
|
.filter(person::username.eq(username))
|
||||||
|
.select(person::all_columns)
|
||||||
|
.into_boxed();
|
||||||
|
query = if let Some(domain) = domain {
|
||||||
|
let domain_pattern = format!("http://{domain}/%");
|
||||||
|
query
|
||||||
|
.filter(person::ap_id.ilike(domain_pattern))
|
||||||
|
.filter(person::local.eq(false))
|
||||||
|
} else {
|
||||||
|
query.filter(person::local.eq(true))
|
||||||
|
};
|
||||||
|
Ok(query.get_result(conn.deref_mut())?)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn read_local_from_name(username: &str, data: &Data<IbisData>) -> MyResult<LocalUserView> {
|
pub fn read_local_from_name(username: &str, data: &Data<IbisData>) -> MyResult<LocalUserView> {
|
||||||
let mut conn = data.db_connection.lock().unwrap();
|
let mut conn = data.db_connection.lock().unwrap();
|
||||||
let (person, local_user) = person::table
|
let (person, local_user) = person::table
|
||||||
|
@ -119,22 +140,6 @@ impl DbPerson {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_local_from_id(id: i32, data: &Data<IbisData>) -> MyResult<LocalUserView> {
|
|
||||||
let mut conn = data.db_connection.lock().unwrap();
|
|
||||||
let (person, local_user) = person::table
|
|
||||||
.inner_join(local_user::table)
|
|
||||||
.filter(person::dsl::local)
|
|
||||||
.filter(person::dsl::id.eq(id))
|
|
||||||
.get_result::<(DbPerson, DbLocalUser)>(conn.deref_mut())?;
|
|
||||||
// TODO: handle this in single query
|
|
||||||
let following = Self::read_following(person.id, conn)?;
|
|
||||||
Ok(LocalUserView {
|
|
||||||
person,
|
|
||||||
local_user,
|
|
||||||
following,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_following(id_: i32, mut conn: MutexGuard<PgConnection>) -> MyResult<Vec<DbInstance>> {
|
fn read_following(id_: i32, mut conn: MutexGuard<PgConnection>) -> MyResult<Vec<DbInstance>> {
|
||||||
use instance_follow::dsl::{follower_id, instance_id};
|
use instance_follow::dsl::{follower_id, instance_id};
|
||||||
Ok(instance_follow::table
|
Ok(instance_follow::table
|
||||||
|
|
|
@ -26,7 +26,7 @@ impl Accept {
|
||||||
object: Follow,
|
object: Follow,
|
||||||
data: &Data<IbisData>,
|
data: &Data<IbisData>,
|
||||||
) -> MyResult<()> {
|
) -> MyResult<()> {
|
||||||
let id = generate_activity_id(local_instance.ap_id.inner())?;
|
let id = generate_activity_id(&local_instance.ap_id)?;
|
||||||
let follower = object.actor.dereference(data).await?;
|
let follower = object.actor.dereference(data).await?;
|
||||||
let accept = Accept {
|
let accept = Accept {
|
||||||
actor: local_instance.ap_id.clone(),
|
actor: local_instance.ap_id.clone(),
|
||||||
|
|
|
@ -30,7 +30,7 @@ impl CreateArticle {
|
||||||
pub async fn send_to_followers(article: DbArticle, data: &Data<IbisData>) -> MyResult<()> {
|
pub async fn send_to_followers(article: DbArticle, data: &Data<IbisData>) -> MyResult<()> {
|
||||||
let local_instance = DbInstance::read_local_instance(&data.db_connection)?;
|
let local_instance = DbInstance::read_local_instance(&data.db_connection)?;
|
||||||
let object = article.clone().into_json(data).await?;
|
let object = article.clone().into_json(data).await?;
|
||||||
let id = generate_activity_id(local_instance.ap_id.inner())?;
|
let id = generate_activity_id(&local_instance.ap_id)?;
|
||||||
let to = local_instance.follower_ids(data)?;
|
let to = local_instance.follower_ids(data)?;
|
||||||
let create = CreateArticle {
|
let create = CreateArticle {
|
||||||
actor: local_instance.ap_id.clone(),
|
actor: local_instance.ap_id.clone(),
|
||||||
|
|
|
@ -27,7 +27,7 @@ pub struct Follow {
|
||||||
|
|
||||||
impl Follow {
|
impl Follow {
|
||||||
pub async fn send(actor: DbPerson, to: &DbInstance, data: &Data<IbisData>) -> MyResult<()> {
|
pub async fn send(actor: DbPerson, to: &DbInstance, data: &Data<IbisData>) -> MyResult<()> {
|
||||||
let id = generate_activity_id(actor.ap_id.inner())?;
|
let id = generate_activity_id(&actor.ap_id)?;
|
||||||
let follow = Follow {
|
let follow = Follow {
|
||||||
actor: actor.ap_id.clone(),
|
actor: actor.ap_id.clone(),
|
||||||
object: to.ap_id.clone(),
|
object: to.ap_id.clone(),
|
||||||
|
|
|
@ -34,7 +34,7 @@ impl RejectEdit {
|
||||||
data: &Data<IbisData>,
|
data: &Data<IbisData>,
|
||||||
) -> MyResult<()> {
|
) -> MyResult<()> {
|
||||||
let local_instance = DbInstance::read_local_instance(&data.db_connection)?;
|
let local_instance = DbInstance::read_local_instance(&data.db_connection)?;
|
||||||
let id = generate_activity_id(local_instance.ap_id.inner())?;
|
let id = generate_activity_id(&local_instance.ap_id)?;
|
||||||
let reject = RejectEdit {
|
let reject = RejectEdit {
|
||||||
actor: local_instance.ap_id.clone(),
|
actor: local_instance.ap_id.clone(),
|
||||||
to: vec![user_instance.ap_id.into_inner()],
|
to: vec![user_instance.ap_id.into_inner()],
|
||||||
|
|
|
@ -38,7 +38,7 @@ impl UpdateLocalArticle {
|
||||||
) -> MyResult<()> {
|
) -> MyResult<()> {
|
||||||
debug_assert!(article.local);
|
debug_assert!(article.local);
|
||||||
let local_instance = DbInstance::read_local_instance(&data.db_connection)?;
|
let local_instance = DbInstance::read_local_instance(&data.db_connection)?;
|
||||||
let id = generate_activity_id(local_instance.ap_id.inner())?;
|
let id = generate_activity_id(&local_instance.ap_id)?;
|
||||||
let mut to = local_instance.follower_ids(data)?;
|
let mut to = local_instance.follower_ids(data)?;
|
||||||
to.extend(extra_recipients.iter().map(|i| i.ap_id.inner().clone()));
|
to.extend(extra_recipients.iter().map(|i| i.ap_id.inner().clone()));
|
||||||
let update = UpdateLocalArticle {
|
let update = UpdateLocalArticle {
|
||||||
|
|
|
@ -40,7 +40,7 @@ impl UpdateRemoteArticle {
|
||||||
data: &Data<IbisData>,
|
data: &Data<IbisData>,
|
||||||
) -> MyResult<()> {
|
) -> MyResult<()> {
|
||||||
let local_instance = DbInstance::read_local_instance(&data.db_connection)?;
|
let local_instance = DbInstance::read_local_instance(&data.db_connection)?;
|
||||||
let id = generate_activity_id(local_instance.ap_id.inner())?;
|
let id = generate_activity_id(&local_instance.ap_id)?;
|
||||||
let update = UpdateRemoteArticle {
|
let update = UpdateRemoteArticle {
|
||||||
actor: local_instance.ap_id.clone(),
|
actor: local_instance.ap_id.clone(),
|
||||||
to: vec![article_instance.ap_id.into_inner()],
|
to: vec![article_instance.ap_id.into_inner()],
|
||||||
|
|
|
@ -1,14 +1,13 @@
|
||||||
use crate::backend::database::instance::DbInstanceForm;
|
use crate::backend::database::instance::DbInstanceForm;
|
||||||
use crate::backend::database::IbisData;
|
use crate::backend::database::IbisData;
|
||||||
use crate::backend::error::Error;
|
use crate::backend::error::Error;
|
||||||
|
use crate::backend::error::MyResult;
|
||||||
use crate::backend::federation::objects::articles_collection::DbArticleCollection;
|
use crate::backend::federation::objects::articles_collection::DbArticleCollection;
|
||||||
use crate::backend::federation::send_activity;
|
use crate::backend::federation::send_activity;
|
||||||
|
use crate::common::utils::extract_domain;
|
||||||
|
use crate::common::DbInstance;
|
||||||
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;
|
||||||
|
|
||||||
use crate::backend::error::MyResult;
|
|
||||||
use crate::common::DbInstance;
|
|
||||||
use activitypub_federation::traits::ActivityHandler;
|
use activitypub_federation::traits::ActivityHandler;
|
||||||
use activitypub_federation::{
|
use activitypub_federation::{
|
||||||
config::Data,
|
config::Data,
|
||||||
|
@ -108,10 +107,7 @@ impl Object for DbInstance {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> {
|
async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> {
|
||||||
let mut domain = json.id.inner().host_str().unwrap().to_string();
|
let domain = extract_domain(&json.id);
|
||||||
if let Some(port) = json.id.inner().port() {
|
|
||||||
domain = format!("{domain}:{port}");
|
|
||||||
}
|
|
||||||
let form = DbInstanceForm {
|
let form = DbInstanceForm {
|
||||||
domain,
|
domain,
|
||||||
ap_id: json.id,
|
ap_id: json.id,
|
||||||
|
|
|
@ -1,24 +1,28 @@
|
||||||
use crate::backend::error::MyResult;
|
use crate::backend::error::MyResult;
|
||||||
use crate::common::DbEdit;
|
use crate::common::DbEdit;
|
||||||
use crate::common::EditVersion;
|
use crate::common::EditVersion;
|
||||||
|
use activitypub_federation::fetch::object_id::ObjectId;
|
||||||
|
use activitypub_federation::traits::Object;
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
use diffy::{apply, Patch};
|
use diffy::{apply, Patch};
|
||||||
use rand::{distributions::Alphanumeric, thread_rng, Rng};
|
use rand::{distributions::Alphanumeric, thread_rng, Rng};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::common::utils::extract_domain;
|
||||||
use url::{ParseError, Url};
|
use url::{ParseError, Url};
|
||||||
|
|
||||||
pub fn generate_activity_id(domain: &Url) -> Result<Url, ParseError> {
|
pub fn generate_activity_id<T>(for_url: &ObjectId<T>) -> Result<Url, ParseError>
|
||||||
let port = match domain.port() {
|
where
|
||||||
Some(p) => format!(":{p}"),
|
T: Object + Send + 'static,
|
||||||
None => String::new(),
|
for<'de2> <T as Object>::Kind: Deserialize<'de2>,
|
||||||
};
|
{
|
||||||
let domain = domain.host_str().unwrap();
|
let domain = extract_domain(for_url);
|
||||||
let id: String = thread_rng()
|
let id: String = thread_rng()
|
||||||
.sample_iter(&Alphanumeric)
|
.sample_iter(&Alphanumeric)
|
||||||
.take(7)
|
.take(7)
|
||||||
.map(char::from)
|
.map(char::from)
|
||||||
.collect();
|
.collect();
|
||||||
Url::parse(&format!("http://{}{}/objects/{}", domain, port, id))
|
Url::parse(&format!("http://{}/objects/{}", domain, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starting from empty string, apply edits until the specified version is reached. If no version is
|
/// Starting from empty string, apply edits until the specified version is reached. If no version is
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod utils;
|
||||||
pub mod validation;
|
pub mod validation;
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
@ -21,7 +22,7 @@ pub const MAIN_PAGE_NAME: &str = "Main_Page";
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug)]
|
#[derive(Deserialize, Serialize, Clone, Debug)]
|
||||||
pub struct GetArticleData {
|
pub struct GetArticleData {
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
pub instance_domain: Option<String>,
|
pub domain: Option<String>,
|
||||||
pub id: Option<i32>,
|
pub id: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -245,6 +246,12 @@ pub struct InstanceView {
|
||||||
pub registration_open: bool,
|
pub registration_open: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone, Debug)]
|
||||||
|
pub struct GetUserData {
|
||||||
|
pub name: String,
|
||||||
|
pub domain: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_edit_versions() {
|
fn test_edit_versions() {
|
||||||
let default = EditVersion::default();
|
let default = EditVersion::default();
|
||||||
|
|
22
src/common/utils.rs
Normal file
22
src/common/utils.rs
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub fn extract_domain<T>(url: &activitypub_federation::fetch::object_id::ObjectId<T>) -> String
|
||||||
|
where
|
||||||
|
T: activitypub_federation::traits::Object + Send + 'static,
|
||||||
|
for<'de2> <T as activitypub_federation::traits::Object>::Kind: serde::Deserialize<'de2>,
|
||||||
|
{
|
||||||
|
let mut port = String::new();
|
||||||
|
if let Some(port_) = url.inner().port() {
|
||||||
|
port = format!(":{port_}");
|
||||||
|
}
|
||||||
|
format!("{}{port}", url.inner().host_str().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "ssr"))]
|
||||||
|
pub fn extract_domain(url: &String) -> String {
|
||||||
|
let url = url::Url::parse(url).unwrap();
|
||||||
|
let mut port = String::new();
|
||||||
|
if let Some(port_) = url.port() {
|
||||||
|
port = format!(":{port_}");
|
||||||
|
}
|
||||||
|
format!("{}{port}", url.host_str().unwrap())
|
||||||
|
}
|
|
@ -1,9 +1,9 @@
|
||||||
use crate::common::ResolveObject;
|
|
||||||
use crate::common::{ApiConflict, ListArticlesData};
|
use crate::common::{ApiConflict, ListArticlesData};
|
||||||
use crate::common::{ArticleView, LoginUserData, RegisterUserData};
|
use crate::common::{ArticleView, LoginUserData, RegisterUserData};
|
||||||
use crate::common::{CreateArticleData, EditArticleData, ForkArticleData, LocalUserView};
|
use crate::common::{CreateArticleData, EditArticleData, ForkArticleData, LocalUserView};
|
||||||
use crate::common::{DbArticle, GetArticleData};
|
use crate::common::{DbArticle, GetArticleData};
|
||||||
use crate::common::{DbInstance, FollowInstance, InstanceView, SearchArticleData};
|
use crate::common::{DbInstance, FollowInstance, InstanceView, SearchArticleData};
|
||||||
|
use crate::common::{DbPerson, GetUserData, ResolveObject};
|
||||||
use crate::frontend::error::MyResult;
|
use crate::frontend::error::MyResult;
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
use reqwest::{Client, RequestBuilder, StatusCode};
|
use reqwest::{Client, RequestBuilder, StatusCode};
|
||||||
|
@ -84,7 +84,7 @@ impl ApiClient {
|
||||||
|
|
||||||
self.get_article(GetArticleData {
|
self.get_article(GetArticleData {
|
||||||
title: None,
|
title: None,
|
||||||
instance_domain: None,
|
domain: None,
|
||||||
id: Some(edit_form.article_id),
|
id: Some(edit_form.article_id),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
@ -175,6 +175,9 @@ impl ApiClient {
|
||||||
self.get_query("instance/resolve", Some(resolve_object))
|
self.get_query("instance/resolve", Some(resolve_object))
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
pub async fn get_user(&self, data: GetUserData) -> MyResult<DbPerson> {
|
||||||
|
self.get_query("user", Some(data)).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_json_res<T>(#[allow(unused_mut)] mut req: RequestBuilder) -> MyResult<T>
|
async fn handle_json_res<T>(#[allow(unused_mut)] mut req: RequestBuilder) -> MyResult<T>
|
||||||
|
|
|
@ -11,6 +11,7 @@ use crate::frontend::pages::instance_details::InstanceDetails;
|
||||||
use crate::frontend::pages::login::Login;
|
use crate::frontend::pages::login::Login;
|
||||||
use crate::frontend::pages::register::Register;
|
use crate::frontend::pages::register::Register;
|
||||||
use crate::frontend::pages::search::Search;
|
use crate::frontend::pages::search::Search;
|
||||||
|
use crate::frontend::pages::user_profile::UserProfile;
|
||||||
use leptos::{
|
use leptos::{
|
||||||
component, create_local_resource, create_rw_signal, expect_context, provide_context,
|
component, create_local_resource, create_rw_signal, expect_context, provide_context,
|
||||||
use_context, view, IntoView, RwSignal, SignalGet, SignalGetUntracked, SignalUpdate,
|
use_context, view, IntoView, RwSignal, SignalGet, SignalGetUntracked, SignalUpdate,
|
||||||
|
@ -97,6 +98,7 @@ pub fn App() -> impl IntoView {
|
||||||
<Route path="/article/create" view=CreateArticle/>
|
<Route path="/article/create" view=CreateArticle/>
|
||||||
<Route path="/article/list" view=ListArticles/>
|
<Route path="/article/list" view=ListArticles/>
|
||||||
<Route path="/instance/:hostname" view=InstanceDetails/>
|
<Route path="/instance/:hostname" view=InstanceDetails/>
|
||||||
|
<Route path="/user/:name" view=UserProfile/>
|
||||||
<Route path="/login" view=Login/>
|
<Route path="/login" view=Login/>
|
||||||
<Route path="/register" view=Register/>
|
<Route path="/register" view=Register/>
|
||||||
<Route path="/search" view=Search/>
|
<Route path="/search" view=Search/>
|
||||||
|
|
|
@ -55,20 +55,9 @@ pub fn Nav() -> impl IntoView {
|
||||||
</form>
|
</form>
|
||||||
</li>
|
</li>
|
||||||
<Show
|
<Show
|
||||||
when=move || global_state.with(|state| state.my_profile.is_none())
|
when=move || global_state.with(|state| state.my_profile.is_some())
|
||||||
fallback=move || {
|
fallback=move || {
|
||||||
view! {
|
view! {
|
||||||
<p>"Logged in as: "
|
|
||||||
{
|
|
||||||
move || global_state.with(|state| state.my_profile.clone().unwrap().person.username)
|
|
||||||
}
|
|
||||||
<button on:click=move |_| logout_action.dispatch(())>
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<li>
|
<li>
|
||||||
<A href="/login">"Login"</A>
|
<A href="/login">"Login"</A>
|
||||||
</li>
|
</li>
|
||||||
|
@ -77,6 +66,23 @@ pub fn Nav() -> impl IntoView {
|
||||||
<A href="/register">"Register"</A>
|
<A href="/register">"Register"</A>
|
||||||
</li>
|
</li>
|
||||||
</Show>
|
</Show>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
let my_profile = global_state.with(|state| state.my_profile.clone().unwrap());
|
||||||
|
let profile_link = format!("/user/{}", my_profile.person.username);
|
||||||
|
view ! {
|
||||||
|
<p>"Logged in as "
|
||||||
|
<a href=profile_link style="border: none; padding: 0; color: var(--accent) !important;">
|
||||||
|
{my_profile.person.username}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<button on:click=move |_| logout_action.dispatch(())>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
</Show>
|
</Show>
|
||||||
</nav>
|
</nav>
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
|
use crate::common::utils::extract_domain;
|
||||||
use crate::common::DbArticle;
|
use crate::common::DbArticle;
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod app;
|
pub mod app;
|
||||||
|
@ -11,28 +11,15 @@ pub mod pages;
|
||||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
pub fn hydrate() {}
|
pub fn hydrate() {}
|
||||||
|
|
||||||
fn extract_hostname(article: &DbArticle) -> String {
|
|
||||||
let ap_id: Url;
|
|
||||||
#[cfg(not(feature = "ssr"))]
|
|
||||||
{
|
|
||||||
ap_id = article.ap_id.parse().unwrap();
|
|
||||||
}
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
{
|
|
||||||
ap_id = article.ap_id.inner().clone();
|
|
||||||
}
|
|
||||||
let mut port = String::new();
|
|
||||||
if let Some(port_) = ap_id.port() {
|
|
||||||
port = format!(":{port_}");
|
|
||||||
}
|
|
||||||
format!("{}{port}", ap_id.host_str().unwrap())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn article_link(article: &DbArticle) -> String {
|
fn article_link(article: &DbArticle) -> String {
|
||||||
if article.local {
|
if article.local {
|
||||||
format!("/article/{}", article.title)
|
format!("/article/{}", article.title)
|
||||||
} else {
|
} else {
|
||||||
format!("/article/{}@{}", article.title, extract_hostname(article))
|
format!(
|
||||||
|
"/article/{}@{}",
|
||||||
|
article.title,
|
||||||
|
extract_domain(&article.ap_id)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,6 +28,6 @@ fn article_title(article: &DbArticle) -> String {
|
||||||
if article.local {
|
if article.local {
|
||||||
title
|
title
|
||||||
} else {
|
} else {
|
||||||
format!("{}@{}", title, extract_hostname(article))
|
format!("{}@{}", title, extract_domain(&article.ap_id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ pub(crate) mod instance_details;
|
||||||
pub(crate) mod login;
|
pub(crate) mod login;
|
||||||
pub(crate) mod register;
|
pub(crate) mod register;
|
||||||
pub(crate) mod search;
|
pub(crate) mod search;
|
||||||
|
pub(crate) mod user_profile;
|
||||||
|
|
||||||
fn article_resource(
|
fn article_resource(
|
||||||
title: impl Fn() -> Option<String> + 'static,
|
title: impl Fn() -> Option<String> + 'static,
|
||||||
|
@ -22,7 +23,7 @@ fn article_resource(
|
||||||
GlobalState::api_client()
|
GlobalState::api_client()
|
||||||
.get_article(GetArticleData {
|
.get_article(GetArticleData {
|
||||||
title: Some(title),
|
title: Some(title),
|
||||||
instance_domain: domain,
|
domain,
|
||||||
id: None,
|
id: None,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|
45
src/frontend/pages/user_profile.rs
Normal file
45
src/frontend/pages/user_profile.rs
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
use crate::common::{DbPerson, GetUserData};
|
||||||
|
use crate::frontend::app::GlobalState;
|
||||||
|
use crate::frontend::extract_domain;
|
||||||
|
use leptos::*;
|
||||||
|
use leptos_router::use_params_map;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn UserProfile() -> impl IntoView {
|
||||||
|
let params = use_params_map();
|
||||||
|
let name = move || params.get().get("name").cloned().unwrap();
|
||||||
|
let (error, set_error) = create_signal(None::<String>);
|
||||||
|
let user_profile = create_resource(name, move |mut name| async move {
|
||||||
|
set_error.set(None);
|
||||||
|
let mut domain = None;
|
||||||
|
if let Some((title_, domain_)) = name.clone().split_once('@') {
|
||||||
|
name = title_.to_string();
|
||||||
|
domain = Some(domain_.to_string());
|
||||||
|
}
|
||||||
|
let params = GetUserData { name, domain };
|
||||||
|
GlobalState::api_client().get_user(params).await.unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
{move || {
|
||||||
|
error
|
||||||
|
.get()
|
||||||
|
.map(|err| {
|
||||||
|
view! { <p style="color:red;">{err}</p> }
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
<Suspense fallback=|| view! { "Loading..." }> {
|
||||||
|
move || user_profile.get().map(|person: DbPerson| {
|
||||||
|
let name =
|
||||||
|
if person.local {
|
||||||
|
person.username
|
||||||
|
} else {
|
||||||
|
format!("{}@{}", person.username, extract_domain(&person.ap_id))
|
||||||
|
};
|
||||||
|
view! {
|
||||||
|
<h1>{name}</h1>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}</Suspense>
|
||||||
|
}
|
||||||
|
}
|
|
@ -56,6 +56,7 @@ impl TestData {
|
||||||
] {
|
] {
|
||||||
j.join().unwrap();
|
j.join().unwrap();
|
||||||
}
|
}
|
||||||
|
dbg!(&alpha_db_path);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
alpha: IbisInstance::start(alpha_db_path, port_alpha, "alpha").await,
|
alpha: IbisInstance::start(alpha_db_path, port_alpha, "alpha").await,
|
||||||
|
|
|
@ -4,7 +4,7 @@ mod common;
|
||||||
|
|
||||||
use crate::common::{TestData, TEST_ARTICLE_DEFAULT_TEXT};
|
use crate::common::{TestData, TEST_ARTICLE_DEFAULT_TEXT};
|
||||||
use ibis_lib::common::{
|
use ibis_lib::common::{
|
||||||
ArticleView, EditArticleData, ForkArticleData, GetArticleData, ListArticlesData,
|
ArticleView, EditArticleData, ForkArticleData, GetArticleData, GetUserData, ListArticlesData,
|
||||||
};
|
};
|
||||||
use ibis_lib::common::{CreateArticleData, SearchArticleData};
|
use ibis_lib::common::{CreateArticleData, SearchArticleData};
|
||||||
use ibis_lib::common::{LoginUserData, RegisterUserData};
|
use ibis_lib::common::{LoginUserData, RegisterUserData};
|
||||||
|
@ -29,7 +29,7 @@ async fn test_create_read_and_edit_local_article() -> MyResult<()> {
|
||||||
// now article can be read
|
// now article can be read
|
||||||
let get_article_data = GetArticleData {
|
let get_article_data = GetArticleData {
|
||||||
title: Some(create_res.article.title.clone()),
|
title: Some(create_res.article.title.clone()),
|
||||||
instance_domain: None,
|
domain: None,
|
||||||
id: None,
|
id: None,
|
||||||
};
|
};
|
||||||
let get_res = data.alpha.get_article(get_article_data.clone()).await?;
|
let get_res = data.alpha.get_article(get_article_data.clone()).await?;
|
||||||
|
@ -152,7 +152,7 @@ async fn test_synchronize_articles() -> MyResult<()> {
|
||||||
|
|
||||||
let mut get_article_data = GetArticleData {
|
let mut get_article_data = GetArticleData {
|
||||||
title: Some(create_res.article.title),
|
title: Some(create_res.article.title),
|
||||||
instance_domain: None,
|
domain: None,
|
||||||
id: None,
|
id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -161,7 +161,7 @@ async fn test_synchronize_articles() -> MyResult<()> {
|
||||||
assert!(get_res.is_err());
|
assert!(get_res.is_err());
|
||||||
|
|
||||||
// get the article with instance id and compare
|
// get the article with instance id and compare
|
||||||
get_article_data.instance_domain = Some(instance.domain);
|
get_article_data.domain = Some(instance.domain);
|
||||||
let get_res = data.beta.get_article(get_article_data).await?;
|
let get_res = data.beta.get_article(get_article_data).await?;
|
||||||
assert_eq!(create_res.article.ap_id, get_res.article.ap_id);
|
assert_eq!(create_res.article.ap_id, get_res.article.ap_id);
|
||||||
assert_eq!(create_form.title, get_res.article.title);
|
assert_eq!(create_form.title, get_res.article.title);
|
||||||
|
@ -194,7 +194,7 @@ async fn test_edit_local_article() -> MyResult<()> {
|
||||||
// article should be federated to alpha
|
// article should be federated to alpha
|
||||||
let get_article_data = GetArticleData {
|
let get_article_data = GetArticleData {
|
||||||
title: Some(create_res.article.title.to_string()),
|
title: Some(create_res.article.title.to_string()),
|
||||||
instance_domain: Some(beta_instance.domain),
|
domain: Some(beta_instance.domain),
|
||||||
id: None,
|
id: None,
|
||||||
};
|
};
|
||||||
let get_res = data.alpha.get_article(get_article_data.clone()).await?;
|
let get_res = data.alpha.get_article(get_article_data.clone()).await?;
|
||||||
|
@ -254,7 +254,7 @@ async fn test_edit_remote_article() -> MyResult<()> {
|
||||||
// article should be federated to alpha and gamma
|
// article should be federated to alpha and gamma
|
||||||
let get_article_data_alpha = GetArticleData {
|
let get_article_data_alpha = GetArticleData {
|
||||||
title: Some(create_res.article.title.to_string()),
|
title: Some(create_res.article.title.to_string()),
|
||||||
instance_domain: Some(beta_id_on_alpha.domain),
|
domain: Some(beta_id_on_alpha.domain),
|
||||||
id: None,
|
id: None,
|
||||||
};
|
};
|
||||||
let get_res = data
|
let get_res = data
|
||||||
|
@ -267,7 +267,7 @@ async fn test_edit_remote_article() -> MyResult<()> {
|
||||||
|
|
||||||
let get_article_data_gamma = GetArticleData {
|
let get_article_data_gamma = GetArticleData {
|
||||||
title: Some(create_res.article.title.to_string()),
|
title: Some(create_res.article.title.to_string()),
|
||||||
instance_domain: Some(beta_id_on_gamma.domain),
|
domain: Some(beta_id_on_gamma.domain),
|
||||||
id: None,
|
id: None,
|
||||||
};
|
};
|
||||||
let get_res = data
|
let get_res = data
|
||||||
|
@ -397,7 +397,7 @@ async fn test_federated_edit_conflict() -> MyResult<()> {
|
||||||
// alpha edits article
|
// alpha edits article
|
||||||
let get_article_data = GetArticleData {
|
let get_article_data = GetArticleData {
|
||||||
title: Some(create_form.title.to_string()),
|
title: Some(create_form.title.to_string()),
|
||||||
instance_domain: Some(beta_id_on_alpha.domain),
|
domain: Some(beta_id_on_alpha.domain),
|
||||||
id: None,
|
id: None,
|
||||||
};
|
};
|
||||||
let get_res = data.alpha.get_article(get_article_data).await?;
|
let get_res = data.alpha.get_article(get_article_data).await?;
|
||||||
|
@ -582,3 +582,30 @@ async fn test_user_registration_login() -> MyResult<()> {
|
||||||
|
|
||||||
data.stop()
|
data.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_user_profile() -> MyResult<()> {
|
||||||
|
let data = TestData::start().await;
|
||||||
|
|
||||||
|
// Create an article and federate it, in order to federate the user who created it
|
||||||
|
let create_form = CreateArticleData {
|
||||||
|
title: "Manu_Chao".to_string(),
|
||||||
|
text: TEST_ARTICLE_DEFAULT_TEXT.to_string(),
|
||||||
|
summary: "create article".to_string(),
|
||||||
|
};
|
||||||
|
let create_res = data.alpha.create_article(&create_form).await?;
|
||||||
|
data.beta
|
||||||
|
.resolve_article(create_res.article.ap_id.into_inner())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Now we can fetch the remote user from local api
|
||||||
|
let params = GetUserData {
|
||||||
|
name: "alpha".to_string(),
|
||||||
|
domain: Some("localhost:8100".to_string()),
|
||||||
|
};
|
||||||
|
let user = data.beta.get_user(params).await?;
|
||||||
|
assert_eq!("alpha", user.username);
|
||||||
|
assert!(!user.local);
|
||||||
|
|
||||||
|
data.stop()
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue