2024-05-21 04:59:47 +02:00
|
|
|
use activitypub_federation::{fetch::object_id::ObjectId, http_signatures::generate_actor_keypair};
|
2024-05-19 07:17:13 +02:00
|
|
|
use activitystreams_kinds::public;
|
2024-06-18 03:43:59 +02:00
|
|
|
use anyhow::{anyhow, Ok};
|
|
|
|
|
use async_recursion::async_recursion;
|
2024-06-15 02:06:01 +02:00
|
|
|
use chrono::{DateTime, TimeZone, Utc};
|
2024-05-21 04:59:47 +02:00
|
|
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
2024-07-16 19:35:04 +02:00
|
|
|
use time::OffsetDateTime;
|
2024-05-19 07:17:13 +02:00
|
|
|
use url::Url;
|
|
|
|
|
|
2024-06-18 03:43:59 +02:00
|
|
|
use crate::{
|
|
|
|
|
database::State,
|
|
|
|
|
entities::{self, post, prelude, user},
|
|
|
|
|
objects::post::Mention,
|
2024-07-16 19:35:04 +02:00
|
|
|
utils::{generate_lysand_post_url, generate_object_id, generate_user_id},
|
2024-06-18 03:43:59 +02:00
|
|
|
API_DOMAIN, DB, FEDERATION_CONFIG, LYSAND_DOMAIN,
|
|
|
|
|
};
|
2024-05-19 07:17:13 +02:00
|
|
|
|
2024-06-18 03:43:59 +02:00
|
|
|
use super::{
|
2024-07-16 19:35:04 +02:00
|
|
|
objects::{CategoryType, ContentEntry, ContentFormat, Note, PublicKey},
|
2024-06-18 03:43:59 +02:00
|
|
|
superx::request_client,
|
|
|
|
|
};
|
2024-05-19 07:17:13 +02:00
|
|
|
|
2024-05-21 04:59:47 +02:00
|
|
|
pub async fn fetch_user_from_url(url: Url) -> anyhow::Result<super::objects::User> {
|
|
|
|
|
let req_client = request_client();
|
|
|
|
|
let request = req_client.get(url).send().await?;
|
|
|
|
|
Ok(request.json::<super::objects::User>().await?)
|
|
|
|
|
}
|
|
|
|
|
|
2024-07-16 19:35:04 +02:00
|
|
|
pub async fn lysand_post_from_db(
|
|
|
|
|
post: entities::post::Model,
|
|
|
|
|
) -> anyhow::Result<super::objects::Note> {
|
|
|
|
|
let data = FEDERATION_CONFIG.get().unwrap();
|
|
|
|
|
let domain = data.domain();
|
|
|
|
|
let url = generate_lysand_post_url(domain, &post.id)?;
|
|
|
|
|
let author = Url::parse(&post.creator.to_string())?;
|
|
|
|
|
let visibility = match post.visibility.as_str() {
|
|
|
|
|
"public" => super::objects::VisibilityType::Public,
|
|
|
|
|
"followers" => super::objects::VisibilityType::Followers,
|
|
|
|
|
"direct" => super::objects::VisibilityType::Direct,
|
|
|
|
|
"unlisted" => super::objects::VisibilityType::Unlisted,
|
|
|
|
|
_ => super::objects::VisibilityType::Public,
|
|
|
|
|
};
|
|
|
|
|
//let mut mentions = Vec::new();
|
|
|
|
|
//for obj in post.tag.clone() {
|
|
|
|
|
// mentions.push(obj.href.clone());
|
|
|
|
|
//}
|
|
|
|
|
let mut content = ContentFormat::default();
|
|
|
|
|
content.x.insert(
|
|
|
|
|
"text/html".to_string(),
|
|
|
|
|
ContentEntry::from_string(post.content),
|
|
|
|
|
);
|
|
|
|
|
let note = super::objects::Note {
|
|
|
|
|
rtype: super::objects::LysandType::Note,
|
|
|
|
|
id: uuid::Uuid::parse_str(&post.id)?,
|
|
|
|
|
author: author.clone(),
|
|
|
|
|
uri: url.clone(),
|
|
|
|
|
created_at: OffsetDateTime::from_unix_timestamp(post.created_at.timestamp()).unwrap(),
|
|
|
|
|
content: Some(content),
|
|
|
|
|
mentions: None,
|
|
|
|
|
category: Some(CategoryType::Microblog),
|
|
|
|
|
device: None,
|
|
|
|
|
visibility: Some(visibility),
|
|
|
|
|
previews: None,
|
|
|
|
|
replies_to: None,
|
|
|
|
|
quotes: None,
|
|
|
|
|
group: None,
|
|
|
|
|
attachments: None,
|
|
|
|
|
subject: post.title,
|
|
|
|
|
is_sensitive: Some(post.sensitive),
|
|
|
|
|
};
|
|
|
|
|
Ok(note)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn lysand_user_from_db(
|
|
|
|
|
user: entities::user::Model,
|
|
|
|
|
) -> anyhow::Result<super::objects::User> {
|
|
|
|
|
let url = Url::parse(&user.url)?;
|
|
|
|
|
let inbox_url = Url::parse("https://ap.lysand.org/apbridge/lysand/inbox")?;
|
|
|
|
|
let outbox_url = Url::parse(
|
|
|
|
|
("https://ap.lysand.org/apbridge/lysand/outbox/".to_string() + &user.id).as_str(),
|
|
|
|
|
)?;
|
|
|
|
|
let followers_url = Url::parse(
|
|
|
|
|
("https://ap.lysand.org/apbridge/lysand/followers/".to_string() + &user.id).as_str(),
|
|
|
|
|
)?;
|
|
|
|
|
let following_url = Url::parse(
|
|
|
|
|
("https://ap.lysand.org/apbridge/lysand/following/".to_string() + &user.id).as_str(),
|
|
|
|
|
)?;
|
|
|
|
|
let featured_url = Url::parse(
|
|
|
|
|
("https://ap.lysand.org/apbridge/lysand/featured/".to_string() + &user.id).as_str(),
|
|
|
|
|
)?;
|
|
|
|
|
let likes_url = Url::parse(
|
|
|
|
|
("https://ap.lysand.org/apbridge/lysand/likes/".to_string() + &user.id).as_str(),
|
|
|
|
|
)?;
|
|
|
|
|
let dislikes_url = Url::parse(
|
|
|
|
|
("https://ap.lysand.org/apbridge/lysand/dislikes/".to_string() + &user.id).as_str(),
|
|
|
|
|
)?;
|
|
|
|
|
let og_displayname_ref = user.name.clone();
|
|
|
|
|
let og_username_ref = user.username.clone();
|
|
|
|
|
let empty = "".to_owned();
|
|
|
|
|
// linter was having a stroke
|
|
|
|
|
let display_name = match og_displayname_ref {
|
|
|
|
|
og_username_ref => None,
|
|
|
|
|
empty => None,
|
|
|
|
|
_ => Some(user.name),
|
|
|
|
|
};
|
|
|
|
|
let mut bio = ContentFormat::default();
|
|
|
|
|
bio.x.insert(
|
|
|
|
|
"text/html".to_string(),
|
|
|
|
|
ContentEntry::from_string(user.summary.unwrap_or_default()),
|
|
|
|
|
);
|
|
|
|
|
let user = super::objects::User {
|
|
|
|
|
rtype: super::objects::LysandType::User,
|
2024-07-17 01:07:23 +02:00
|
|
|
id: uuid::Uuid::try_parse(&user.id)?,
|
2024-07-16 19:35:04 +02:00
|
|
|
uri: url.clone(),
|
|
|
|
|
username: user.username,
|
|
|
|
|
display_name,
|
|
|
|
|
inbox: inbox_url,
|
|
|
|
|
outbox: outbox_url,
|
|
|
|
|
followers: followers_url,
|
|
|
|
|
following: following_url,
|
|
|
|
|
featured: featured_url,
|
|
|
|
|
likes: likes_url,
|
|
|
|
|
dislikes: dislikes_url,
|
|
|
|
|
bio: Some(bio),
|
|
|
|
|
avatar: None,
|
|
|
|
|
header: None,
|
|
|
|
|
fields: None,
|
2024-07-16 23:22:52 +02:00
|
|
|
indexable: false,
|
2024-07-16 19:35:04 +02:00
|
|
|
created_at: OffsetDateTime::from_unix_timestamp(user.created_at.timestamp()).unwrap(),
|
|
|
|
|
public_key: PublicKey {
|
|
|
|
|
actor: url.clone(),
|
2024-07-17 01:07:23 +02:00
|
|
|
public_key: "AAAAC3NzaC1lZDI1NTE5AAAAIMxsX+lEWkHZt9NOvn9yYFP0Z++186LY4b97C4mwj/f2"
|
|
|
|
|
.to_string(), // dummy key
|
2024-07-16 19:35:04 +02:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
Ok(user)
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-21 04:59:47 +02:00
|
|
|
pub async fn option_content_format_text(opt: Option<ContentFormat>) -> Option<String> {
|
|
|
|
|
if let Some(format) = opt {
|
|
|
|
|
return Some(format.select_rich_text().await.unwrap());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
None
|
|
|
|
|
}
|
2024-06-17 21:40:25 +02:00
|
|
|
#[async_recursion]
|
2024-06-17 21:35:21 +02:00
|
|
|
pub async fn db_post_from_url(url: Url) -> anyhow::Result<entities::post::Model> {
|
|
|
|
|
if !url.domain().eq(&Some(LYSAND_DOMAIN.as_str())) {
|
|
|
|
|
return Err(anyhow!("not lysands domain"));
|
|
|
|
|
}
|
|
|
|
|
let str_url = url.to_string();
|
2024-06-18 03:43:59 +02:00
|
|
|
let post_res: Option<post::Model> = prelude::Post::find()
|
|
|
|
|
.filter(entities::post::Column::Url.eq(str_url.clone()))
|
|
|
|
|
.one(DB.get().unwrap())
|
|
|
|
|
.await?;
|
2024-06-17 21:35:21 +02:00
|
|
|
|
|
|
|
|
if let Some(post) = post_res {
|
|
|
|
|
Ok(post)
|
|
|
|
|
} else {
|
|
|
|
|
let post = fetch_note_from_url(url.clone()).await?;
|
2024-07-16 19:35:04 +02:00
|
|
|
let res = receive_lysand_note(post, "https://ap.lysand.org/example".to_string()).await?; // TODO: Replace user id with actual user id
|
2024-06-17 21:55:42 +02:00
|
|
|
Ok(res)
|
2024-06-17 21:35:21 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-21 04:59:47 +02:00
|
|
|
pub async fn db_user_from_url(url: Url) -> anyhow::Result<entities::user::Model> {
|
2024-06-27 09:48:47 +02:00
|
|
|
if !url.domain().eq(&Some(LYSAND_DOMAIN.as_str()))
|
|
|
|
|
&& !url.domain().eq(&Some(API_DOMAIN.as_str()))
|
|
|
|
|
{
|
2024-05-21 04:59:47 +02:00
|
|
|
return Err(anyhow!("not lysands domain"));
|
|
|
|
|
}
|
2024-06-18 03:43:59 +02:00
|
|
|
let user_res: Option<user::Model> = prelude::User::find()
|
|
|
|
|
.filter(entities::user::Column::Url.eq(url.to_string()))
|
|
|
|
|
.one(DB.get().unwrap())
|
|
|
|
|
.await?;
|
2024-05-21 04:59:47 +02:00
|
|
|
|
|
|
|
|
if let Some(user) = user_res {
|
|
|
|
|
Ok(user)
|
|
|
|
|
} else {
|
|
|
|
|
let ls_user = fetch_user_from_url(url).await?;
|
|
|
|
|
let keypair = generate_actor_keypair()?;
|
|
|
|
|
let user = entities::user::ActiveModel {
|
|
|
|
|
id: Set(ls_user.id.to_string()),
|
|
|
|
|
username: Set(ls_user.username.clone()),
|
|
|
|
|
name: Set(ls_user.display_name.unwrap_or(ls_user.username)),
|
|
|
|
|
inbox: Set(ls_user.inbox.to_string()),
|
|
|
|
|
public_key: Set(keypair.public_key.clone()),
|
|
|
|
|
private_key: Set(Some(keypair.private_key.clone())),
|
|
|
|
|
last_refreshed_at: Set(Utc::now()),
|
|
|
|
|
follower_count: Set(0),
|
|
|
|
|
following_count: Set(0),
|
|
|
|
|
url: Set(ls_user.uri.to_string()),
|
|
|
|
|
local: Set(true),
|
2024-06-18 03:43:59 +02:00
|
|
|
created_at: Set(
|
|
|
|
|
DateTime::from_timestamp(ls_user.created_at.unix_timestamp(), 0).unwrap(),
|
|
|
|
|
),
|
2024-05-21 04:59:47 +02:00
|
|
|
summary: Set(option_content_format_text(ls_user.bio).await),
|
|
|
|
|
updated_at: Set(Some(Utc::now())),
|
|
|
|
|
followers: Set(Some(ls_user.followers.to_string())),
|
|
|
|
|
following: Set(Some(ls_user.following.to_string())),
|
|
|
|
|
..Default::default()
|
|
|
|
|
};
|
|
|
|
|
let db = DB.get().unwrap();
|
|
|
|
|
Ok(user.insert(db).await?)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn fetch_note_from_url(url: Url) -> anyhow::Result<super::objects::Note> {
|
|
|
|
|
let req_client = request_client();
|
|
|
|
|
let request = req_client.get(url).send().await?;
|
|
|
|
|
Ok(request.json::<super::objects::Note>().await?)
|
|
|
|
|
}
|
2024-06-17 21:40:25 +02:00
|
|
|
#[async_recursion]
|
2024-06-18 03:43:59 +02:00
|
|
|
pub async fn receive_lysand_note(
|
|
|
|
|
note: Note,
|
|
|
|
|
db_id: String,
|
|
|
|
|
) -> anyhow::Result<entities::post::Model> {
|
2024-06-15 02:06:01 +02:00
|
|
|
let lysand_author: entities::user::Model = db_user_from_url(note.author.clone()).await?;
|
2024-06-18 03:43:59 +02:00
|
|
|
let user_res = prelude::User::find_by_id(db_id)
|
|
|
|
|
.one(DB.get().unwrap())
|
|
|
|
|
.await;
|
2024-05-19 07:17:13 +02:00
|
|
|
if user_res.is_err() {
|
2024-05-21 04:59:47 +02:00
|
|
|
println!("{}", user_res.as_ref().unwrap_err());
|
|
|
|
|
return Err(user_res.err().unwrap().into());
|
2024-05-19 07:17:13 +02:00
|
|
|
}
|
2024-05-21 04:59:47 +02:00
|
|
|
if let Some(target) = user_res? {
|
2024-05-19 07:17:13 +02:00
|
|
|
let data = FEDERATION_CONFIG.get().unwrap();
|
2024-06-18 03:43:59 +02:00
|
|
|
let id: ObjectId<post::Model> =
|
|
|
|
|
generate_object_id(data.domain(), ¬e.id.to_string())?.into();
|
2024-05-21 04:59:47 +02:00
|
|
|
let user_id = generate_user_id(data.domain(), &target.id.to_string())?;
|
2024-06-15 02:06:01 +02:00
|
|
|
let user = fetch_user_from_url(note.author.clone()).await?;
|
2024-05-21 04:59:47 +02:00
|
|
|
let mut tag: Vec<Mention> = Vec::new();
|
|
|
|
|
for l_tag in note.mentions.clone().unwrap_or_default() {
|
2024-06-18 03:43:59 +02:00
|
|
|
tag.push(Mention {
|
|
|
|
|
href: l_tag, //TODO convert to ap url
|
|
|
|
|
kind: Default::default(),
|
|
|
|
|
})
|
2024-05-21 04:59:47 +02:00
|
|
|
}
|
2024-06-27 17:29:55 +02:00
|
|
|
let mut mentions = Vec::new();
|
|
|
|
|
for obj in tag.clone() {
|
|
|
|
|
mentions.push(obj.href.clone());
|
|
|
|
|
}
|
2024-06-18 03:43:59 +02:00
|
|
|
let to = match note
|
|
|
|
|
.visibility
|
|
|
|
|
.clone()
|
|
|
|
|
.unwrap_or(super::objects::VisibilityType::Public)
|
|
|
|
|
{
|
|
|
|
|
super::objects::VisibilityType::Public => {
|
2024-06-27 17:29:55 +02:00
|
|
|
let mut vec = vec![public(), Url::parse(&user.followers.to_string().as_str())?];
|
|
|
|
|
vec.append(&mut mentions.clone());
|
|
|
|
|
vec
|
2024-06-18 03:43:59 +02:00
|
|
|
}
|
|
|
|
|
super::objects::VisibilityType::Followers => {
|
2024-06-27 17:29:55 +02:00
|
|
|
let mut vec = vec![Url::parse(&user.followers.to_string().as_str())?];
|
|
|
|
|
vec.append(&mut mentions.clone());
|
|
|
|
|
vec
|
2024-06-18 03:43:59 +02:00
|
|
|
}
|
2024-07-16 19:35:04 +02:00
|
|
|
super::objects::VisibilityType::Direct => mentions.clone(),
|
2024-06-18 03:43:59 +02:00
|
|
|
super::objects::VisibilityType::Unlisted => {
|
2024-06-27 17:29:55 +02:00
|
|
|
let mut vec = vec![Url::parse(&user.followers.to_string().as_str())?];
|
|
|
|
|
vec.append(&mut mentions.clone());
|
|
|
|
|
vec
|
2024-06-18 03:43:59 +02:00
|
|
|
}
|
2024-05-19 07:17:13 +02:00
|
|
|
};
|
2024-06-18 03:43:59 +02:00
|
|
|
let cc = match note
|
|
|
|
|
.visibility
|
|
|
|
|
.clone()
|
|
|
|
|
.unwrap_or(super::objects::VisibilityType::Public)
|
|
|
|
|
{
|
2024-05-19 07:17:13 +02:00
|
|
|
super::objects::VisibilityType::Unlisted => Some(vec![public()]),
|
2024-06-18 03:43:59 +02:00
|
|
|
_ => None,
|
2024-05-19 07:17:13 +02:00
|
|
|
};
|
2024-06-18 03:43:59 +02:00
|
|
|
let reply: Option<ObjectId<entities::post::Model>> =
|
|
|
|
|
if let Some(rep) = note.replies_to.clone() {
|
|
|
|
|
let note = fetch_note_from_url(rep).await?;
|
|
|
|
|
let fake_rep_url = Url::parse(&format!(
|
|
|
|
|
"https://{}/apbridge/object/{}",
|
|
|
|
|
API_DOMAIN.to_string(),
|
|
|
|
|
¬e.id.to_string()
|
|
|
|
|
))?;
|
|
|
|
|
Some(fake_rep_url.into())
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
};
|
|
|
|
|
let quote: Option<ObjectId<entities::post::Model>> = if let Some(rep) = note.quotes.clone()
|
|
|
|
|
{
|
2024-06-17 19:52:51 +02:00
|
|
|
let note = fetch_note_from_url(rep).await?;
|
|
|
|
|
let fake_rep_url = Url::parse(&format!(
|
|
|
|
|
"https://{}/apbridge/object/{}",
|
2024-06-15 02:06:01 +02:00
|
|
|
API_DOMAIN.to_string(),
|
|
|
|
|
¬e.id.to_string()
|
|
|
|
|
))?;
|
|
|
|
|
Some(fake_rep_url.into())
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
};
|
2024-06-17 22:33:30 +02:00
|
|
|
let reply_uuid: Option<String> = if let Some(rep) = note.replies_to.clone() {
|
|
|
|
|
Some(db_post_from_url(rep).await?.id)
|
2024-06-15 02:06:01 +02:00
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
};
|
2024-06-17 22:33:30 +02:00
|
|
|
let quote_uuid: Option<String> = if let Some(rep) = note.quotes.clone() {
|
|
|
|
|
Some(db_post_from_url(rep).await?.id)
|
2024-05-21 04:59:47 +02:00
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
};
|
2024-05-19 07:17:13 +02:00
|
|
|
let ap_note = crate::objects::post::Note {
|
|
|
|
|
kind: Default::default(),
|
|
|
|
|
id,
|
2024-07-16 20:04:53 +02:00
|
|
|
sensitive: Some(note.is_sensitive.unwrap_or(false)),
|
2024-05-19 07:17:13 +02:00
|
|
|
cc,
|
|
|
|
|
to,
|
|
|
|
|
tag,
|
2024-06-15 02:06:01 +02:00
|
|
|
attributed_to: Url::parse(user.uri.clone().as_str()).unwrap().into(),
|
2024-06-18 03:43:59 +02:00
|
|
|
content: option_content_format_text(note.content)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap_or_default(),
|
|
|
|
|
in_reply_to: reply.clone(),
|
2024-05-21 04:59:47 +02:00
|
|
|
};
|
|
|
|
|
|
2024-06-18 03:43:59 +02:00
|
|
|
let visibility = match note
|
|
|
|
|
.visibility
|
|
|
|
|
.clone()
|
|
|
|
|
.unwrap_or(super::objects::VisibilityType::Public)
|
|
|
|
|
{
|
2024-06-15 02:06:01 +02:00
|
|
|
super::objects::VisibilityType::Public => "public",
|
|
|
|
|
super::objects::VisibilityType::Followers => "followers",
|
|
|
|
|
super::objects::VisibilityType::Direct => "direct",
|
|
|
|
|
super::objects::VisibilityType::Unlisted => "unlisted",
|
|
|
|
|
};
|
2024-06-17 21:44:29 +02:00
|
|
|
if let Some(obj) = note.replies_to {
|
|
|
|
|
println!("Quoting: {}", db_post_from_url(obj).await?.url);
|
2024-06-17 19:52:51 +02:00
|
|
|
}
|
2024-06-17 21:44:29 +02:00
|
|
|
if let Some(obj) = note.quotes {
|
|
|
|
|
println!("Replying to: {}", db_post_from_url(obj).await?.url);
|
2024-06-17 19:52:51 +02:00
|
|
|
}
|
2024-06-15 02:06:01 +02:00
|
|
|
let post = entities::post::ActiveModel {
|
|
|
|
|
id: Set(note.id.to_string()),
|
|
|
|
|
creator: Set(lysand_author.id.clone()),
|
|
|
|
|
content: Set(ap_note.content.clone()),
|
2024-07-16 20:04:53 +02:00
|
|
|
sensitive: Set(ap_note.sensitive.unwrap_or_default()),
|
2024-06-18 03:43:59 +02:00
|
|
|
created_at: Set(Utc
|
|
|
|
|
.timestamp_micros(note.created_at.unix_timestamp())
|
|
|
|
|
.unwrap()),
|
2024-06-15 02:06:01 +02:00
|
|
|
local: Set(true),
|
|
|
|
|
updated_at: Set(Some(Utc::now())),
|
|
|
|
|
content_type: Set("Note".to_string()),
|
|
|
|
|
visibility: Set(visibility.to_string()),
|
|
|
|
|
title: Set(note.subject.clone()),
|
2024-06-17 22:39:03 +02:00
|
|
|
url: Set(note.uri.clone().to_string()),
|
2024-06-17 22:33:30 +02:00
|
|
|
reply_id: Set(reply_uuid),
|
|
|
|
|
quoting_id: Set(quote_uuid),
|
2024-06-15 02:06:01 +02:00
|
|
|
spoiler_text: Set(note.subject),
|
2024-06-27 05:13:38 +02:00
|
|
|
ap_json: Set(Some(serde_json::to_string(&ap_note).unwrap())),
|
2024-06-15 02:06:01 +02:00
|
|
|
..Default::default()
|
|
|
|
|
};
|
2024-06-17 21:55:42 +02:00
|
|
|
let res = post.insert(DB.get().unwrap()).await?;
|
|
|
|
|
Ok(res)
|
2024-06-15 02:06:01 +02:00
|
|
|
} else {
|
|
|
|
|
Err(anyhow!("User not found"))
|
|
|
|
|
}
|
2024-06-18 03:43:59 +02:00
|
|
|
}
|