use crate::routes::well_known::{FingerRequestQuery, FingerResponse}; use crate::{CommentLocalID, CommunityLocalID, PostLocalID, UserLocalID}; use serde_derive::{Deserialize, Serialize}; use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::future::Future; use std::sync::Arc; mod comments; mod communities; mod forgot_password; mod media; mod posts; mod users; lazy_static::lazy_static! { static ref USERNAME_ALLOWED_CHARS: HashSet = { use unic_char_range::chars; chars!('a'..='z').into_iter().chain(chars!('A'..='Z')).chain(chars!('0'..='9')).chain(std::iter::once('_')) .collect() }; } #[derive(Deserialize)] #[serde(rename_all = "snake_case")] enum SortType { Hot, New, } impl SortType { pub fn post_sort_sql(&self) -> &'static str { match self { SortType::Hot => "hot_rank((SELECT COUNT(*) FROM post_like WHERE post = post.id AND person != post.author), post.created) DESC", SortType::New => "post.created DESC", } } } #[derive(Serialize)] struct JustID { pub id: T, } #[derive(Deserialize)] struct MaybeIncludeYour { #[serde(default)] pub include_your: bool, } #[derive(Serialize)] struct RespAvatarInfo<'a> { url: Cow<'a, str>, } #[derive(Serialize)] struct RespMinimalAuthorInfo<'a> { id: UserLocalID, username: Cow<'a, str>, local: bool, host: Cow<'a, str>, remote_url: Option>, #[serde(skip_serializing_if = "Option::is_none")] avatar: Option>, } #[derive(Serialize)] struct RespLoginUserInfo<'a> { id: UserLocalID, username: &'a str, is_site_admin: bool, has_unread_notifications: bool, } #[derive(Serialize)] struct JustUser<'a> { user: RespMinimalAuthorInfo<'a>, } #[derive(Serialize)] struct RespMinimalCommunityInfo<'a> { id: CommunityLocalID, name: &'a str, local: bool, host: Cow<'a, str>, remote_url: Option<&'a str>, } #[derive(Serialize)] struct RespMinimalPostInfo<'a> { id: PostLocalID, title: &'a str, } #[derive(Serialize)] struct RespPostListPost<'a> { id: PostLocalID, title: &'a str, href: Option>, content_text: Option<&'a str>, content_html: Option<&'a str>, author: Option<&'a RespMinimalAuthorInfo<'a>>, created: &'a str, community: &'a RespMinimalCommunityInfo<'a>, score: i64, #[serde(skip_serializing_if = "Option::is_none")] your_vote: Option>, } #[derive(Serialize)] struct RespMinimalCommentInfo<'a> { id: CommentLocalID, content_text: Option>, content_html: Option>, } #[derive(Serialize)] struct RespPostCommentInfo<'a> { #[serde(flatten)] base: RespMinimalCommentInfo<'a>, author: Option>, created: String, deleted: bool, replies: Option>>, has_replies: bool, #[serde(skip_serializing_if = "Option::is_none")] your_vote: Option>, } #[derive(Serialize)] #[serde(tag = "type")] enum RespThingInfo<'a> { #[serde(rename = "post")] Post { id: PostLocalID, href: Option<&'a str>, title: &'a str, created: String, community: RespMinimalCommunityInfo<'a>, }, #[serde(rename = "comment")] Comment { #[serde(flatten)] base: RespMinimalCommentInfo<'a>, created: String, post: RespMinimalPostInfo<'a>, }, } pub fn route_api() -> crate::RouteNode<()> { crate::RouteNode::new().with_child( "unstable", crate::RouteNode::new() .with_child( "actors:lookup", crate::RouteNode::new().with_child_str( crate::RouteNode::new().with_handler_async("GET", route_unstable_actors_lookup), ), ) .with_child( "logins", crate::RouteNode::new() .with_handler_async("POST", route_unstable_logins_create) .with_child( "~current", crate::RouteNode::new() .with_handler_async("GET", route_unstable_logins_current_get) .with_handler_async("DELETE", route_unstable_logins_current_delete), ), ) .with_child("media", media::route_media()) .with_child( "nodeinfo/2.0", crate::RouteNode::new().with_handler_async("GET", route_unstable_nodeinfo_20_get), ) .with_child("communities", communities::route_communities()) .with_child( "instance", crate::RouteNode::new() .with_handler_async("GET", route_unstable_instance_get) .with_handler_async("PATCH", route_unstable_instance_patch), ) .with_child( "misc", crate::RouteNode::new().with_child( "render_markdown", crate::RouteNode::new() .with_handler_async("POST", route_unstable_misc_render_markdown), ), ) .with_child("posts", posts::route_posts()) .with_child("comments", comments::route_comments()) .with_child("users", users::route_users()) .with_child("forgot_password", forgot_password::route_forgot_password()), ) } async fn insert_token( user_id: UserLocalID, db: &tokio_postgres::Client, ) -> Result { let token = uuid::Uuid::new_v4(); db.execute( "INSERT INTO login (token, person, created) VALUES ($1, $2, current_timestamp)", &[&token, &user_id], ) .await?; Ok(token) } enum Lookup<'a> { URL(url::Url), WebFinger { user: &'a str, host: &'a str }, } fn parse_lookup(src: &str) -> Result { if src.starts_with("http") { return Ok(Lookup::URL(src.parse()?)); } if let Some(at_idx) = src.rfind('@') { let mut user = &src[..at_idx]; let host = &src[(at_idx + 1)..]; if user.starts_with("acct:") { user = &user[5..]; } else if user.starts_with('@') { user = &user[1..]; } return Ok(Lookup::WebFinger { user, host }); } Err(crate::Error::InternalStrStatic( "Unrecognized lookup format", )) } async fn route_unstable_actors_lookup( params: (String,), ctx: Arc, _req: hyper::Request, ) -> Result, crate::Error> { let (query,) = params; println!("lookup {}", query); let db = ctx.db_pool.get().await?; let lookup = parse_lookup(&query)?; let uri = match lookup { Lookup::URL(uri) => Some(uri), Lookup::WebFinger { user, host } => { let uri = format!( "https://{}/.well-known/webfinger?{}", host, serde_urlencoded::to_string(FingerRequestQuery { resource: format!("acct:{}@{}", user, host).into(), rel: Some("self".into()), })? ); println!("{}", uri); let res = ctx .http_client .request(hyper::Request::get(uri).body(Default::default())?) .await?; if res.status() == hyper::StatusCode::NOT_FOUND { println!("not found"); None } else { let res = crate::res_to_error(res).await?; let res = hyper::body::to_bytes(res.into_body()).await?; let res: FingerResponse = serde_json::from_slice(&res)?; let mut found_uri = None; for entry in res.links { if entry.rel == "self" && entry.type_.as_deref() == Some(crate::apub_util::ACTIVITY_TYPE) { if let Some(href) = entry.href { found_uri = Some(href.parse()?); break; } } } found_uri } } }; let uri = match uri { Some(uri) => uri, None => { return Ok(crate::common_response_builder() .header(hyper::header::CONTENT_TYPE, "application/json") .body("[]".into())?); } }; let actor = crate::apub_util::fetch_actor(&uri, &db, &ctx.http_client).await?; let info = match actor { crate::apub_util::ActorLocalInfo::Community { id, .. } => { serde_json::json!({"id": id, "type": "community"}) } crate::apub_util::ActorLocalInfo::User { id, .. } => { serde_json::json!({"id": id, "type": "user"}) } }; crate::json_response(&[info]) } async fn route_unstable_logins_create( _: (), ctx: Arc, req: hyper::Request, ) -> Result, crate::Error> { let lang = crate::get_lang_for_req(&req); let db = ctx.db_pool.get().await?; let body = hyper::body::to_bytes(req.into_body()).await?; #[derive(Deserialize)] struct LoginsCreateBody<'a> { username: Cow<'a, str>, password: Cow<'a, str>, } let body: LoginsCreateBody<'_> = serde_json::from_slice(&body)?; let row = db .query_opt( "SELECT id, username, passhash, is_site_admin, EXISTS(SELECT 1 FROM notification WHERE to_user = person.id AND created_at > person.last_checked_notifications) FROM person WHERE LOWER(username)=LOWER($1) AND local", &[&body.username], ) .await? .ok_or_else(|| { crate::Error::UserError(crate::simple_response( hyper::StatusCode::BAD_REQUEST, lang.tr("no_such_local_user_by_name", None).into_owned(), )) })?; let id = UserLocalID(row.get(0)); let username: &str = row.get(1); let passhash: Option = row.get(2); let passhash = passhash.ok_or_else(|| { crate::Error::UserError(crate::simple_response( hyper::StatusCode::BAD_REQUEST, lang.tr("no_password", None).into_owned(), )) })?; let req_password = body.password.to_owned(); let correct = tokio::task::spawn_blocking(move || bcrypt::verify(req_password.as_ref(), &passhash)) .await??; if correct { let token = insert_token(id, &db).await?; crate::json_response( &serde_json::json!({"token": token.to_string(), "user": RespLoginUserInfo { id, username, is_site_admin: row.get(3), has_unread_notifications: row.get(4), }}), ) } else { Ok(crate::simple_response( hyper::StatusCode::FORBIDDEN, lang.tr("password_incorrect", None).into_owned(), )) } } async fn route_unstable_logins_current_get( _: (), ctx: Arc, req: hyper::Request, ) -> Result, crate::Error> { let db = ctx.db_pool.get().await?; let user = crate::require_login(&req, &db).await?; let row = db.query_one("SELECT username, is_site_admin, EXISTS(SELECT 1 FROM notification WHERE to_user = person.id AND created_at > person.last_checked_notifications) FROM person WHERE id=$1", &[&user]).await?; crate::json_response(&serde_json::json!({ "user": RespLoginUserInfo { id: user, username: row.get(0), is_site_admin: row.get(1), has_unread_notifications: row.get(2), }, })) } async fn route_unstable_logins_current_delete( _: (), ctx: Arc, req: hyper::Request, ) -> Result, crate::Error> { if let Some(token) = crate::get_auth_token(&req) { let db = ctx.db_pool.get().await?; db.execute("DELETE FROM login WHERE token=$1", &[&token]) .await?; } Ok(crate::empty_response()) } async fn route_unstable_nodeinfo_20_get( _: (), ctx: Arc, _req: hyper::Request, ) -> Result, crate::Error> { let db = ctx.db_pool.get().await?; let local_posts = { let row = db .query_one("SELECT COUNT(*) FROM post WHERE local", &[]) .await?; row.get::<_, i64>(0) }; let local_comments = { let row = db .query_one("SELECT COUNT(*) FROM reply WHERE local", &[]) .await?; row.get::<_, i64>(0) }; let local_users = { let row = db .query_one("SELECT COUNT(*) FROM person WHERE local", &[]) .await?; row.get::<_, i64>(0) }; let body = serde_json::json!({ "version": "2.0", "software": { "name": "lotide", "version": env!("CARGO_PKG_VERSION") }, "protocols": ["activitypub"], "services": { "inbound": [], "outbound": [] }, "openRegistrations": true, "usage": { "users": { "total": local_users, }, "localPosts": local_posts, "localComments": local_comments }, "metadata": {} }); let body = serde_json::to_vec(&body)?.into(); Ok(crate::common_response_builder() .header( hyper::header::CONTENT_TYPE, "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#", ) .body(body)?) } async fn route_unstable_instance_get( _: (), ctx: Arc, _req: hyper::Request, ) -> Result, crate::Error> { let db = ctx.db_pool.get().await?; let row = db .query_one("SELECT description FROM site WHERE local = TRUE", &[]) .await?; let description: &str = row.get(0); let body = serde_json::json!({ "description": description, "software": { "name": "lotide", "version": env!("CARGO_PKG_VERSION"), } }); crate::json_response(&body) } async fn route_unstable_instance_patch( _: (), ctx: Arc, req: hyper::Request, ) -> Result, crate::Error> { #[derive(Deserialize)] struct InstanceEditBody<'a> { description: Option>, } let lang = crate::get_lang_for_req(&req); let (req_parts, body) = req.into_parts(); let body = hyper::body::to_bytes(body).await?; let body: InstanceEditBody = serde_json::from_slice(&body)?; let db = ctx.db_pool.get().await?; let user = crate::require_login(&req_parts, &db).await?; let is_site_admin: bool = { let row = db .query_one("SELECT is_site_admin FROM person WHERE id=$1", &[&user]) .await?; row.get(0) }; if is_site_admin { if let Some(description) = body.description { db.execute("UPDATE site SET description=$1", &[&description]) .await?; } Ok(crate::empty_response()) } else { Ok(crate::simple_response( hyper::StatusCode::FORBIDDEN, lang.tr("not_admin", None).into_owned(), )) } } async fn apply_comments_replies<'a, T>( comments: &mut Vec<(T, RespPostCommentInfo<'a>)>, include_your_for: Option, depth: u8, db: &tokio_postgres::Client, local_hostname: &'a str, ) -> Result<(), crate::Error> { let ids = comments .iter() .map(|(_, comment)| comment.base.id) .collect::>(); if depth > 0 { let mut replies = get_comments_replies_box(&ids, include_your_for, depth - 1, db, local_hostname).await?; for (_, comment) in comments.iter_mut() { let current = replies.remove(&comment.base.id).unwrap_or_else(Vec::new); comment.has_replies = !current.is_empty(); comment.replies = Some(current); } } else { use futures::stream::TryStreamExt; let stream = crate::query_stream( &db, "SELECT DISTINCT parent FROM reply WHERE parent = ANY($1)", &[&ids], ) .await?; let with_replies: HashSet = stream .map_err(crate::Error::from) .map_ok(|row| CommentLocalID(row.get(0))) .try_collect() .await?; for (_, comment) in comments.iter_mut() { comment.has_replies = with_replies.contains(&comment.base.id); } } comments.retain(|(_, comment)| !comment.deleted || comment.has_replies); Ok(()) } fn get_comments_replies_box<'a: 'b, 'b>( parents: &'b [CommentLocalID], include_your_for: Option, depth: u8, db: &'b tokio_postgres::Client, local_hostname: &'a str, ) -> std::pin::Pin< Box< dyn Future< Output = Result< HashMap>>, crate::Error, >, > + Send + 'b, >, > { Box::pin(get_comments_replies( parents, include_your_for, depth, db, local_hostname, )) } async fn get_comments_replies<'a>( parents: &[CommentLocalID], include_your_for: Option, depth: u8, db: &tokio_postgres::Client, local_hostname: &'a str, ) -> Result>>, crate::Error> { use futures::TryStreamExt; let sql1 = "SELECT reply.id, reply.author, reply.content_text, reply.created, reply.parent, reply.content_html, person.username, person.local, person.ap_id, reply.deleted, person.avatar"; let (sql2, values): (_, Vec<&(dyn tokio_postgres::types::ToSql + Sync)>) = if include_your_for.is_some() { ( ", EXISTS(SELECT 1 FROM reply_like WHERE reply = reply.id AND person = $2)", vec![&parents, &include_your_for], ) } else { ("", vec![&parents]) }; let sql3 = " FROM reply LEFT OUTER JOIN person ON (person.id = reply.author) WHERE parent = ANY($1::BIGINT[]) ORDER BY hot_rank((SELECT COUNT(*) FROM reply_like WHERE reply = reply.id AND person != reply.author), reply.created) DESC"; let sql: &str = &format!("{}{}{}", sql1, sql2, sql3); let stream = crate::query_stream(db, sql, &values).await?; let mut comments: Vec<_> = stream .map_err(crate::Error::from) .and_then(|row| { let id = CommentLocalID(row.get(0)); let content_text: Option = row.get(2); let content_html: Option = row.get(5); let created: chrono::DateTime = row.get(3); let parent = CommentLocalID(row.get(4)); let author_username: Option = row.get(6); let author = author_username.map(|author_username| { let author_id = UserLocalID(row.get(1)); let author_local: bool = row.get(7); let author_ap_id: Option<&str> = row.get(8); let author_avatar: Option<&str> = row.get(10); RespMinimalAuthorInfo { id: author_id, username: author_username.into(), local: author_local, host: crate::get_actor_host_or_unknown( author_local, author_ap_id, &local_hostname, ), remote_url: author_ap_id.map(|x| x.to_owned().into()), avatar: author_avatar.map(|url| RespAvatarInfo { url: url.to_owned().into(), }), } }); futures::future::ok(( parent, RespPostCommentInfo { base: RespMinimalCommentInfo { id, content_text: content_text.map(From::from), content_html: content_html.map(From::from), }, author, created: created.to_rfc3339(), deleted: row.get(9), replies: None, has_replies: false, your_vote: match include_your_for { None => None, Some(_) => Some(if row.get(11) { Some(crate::Empty {}) } else { None }), }, }, )) }) .try_collect() .await?; apply_comments_replies(&mut comments, include_your_for, depth, db, local_hostname).await?; let mut result = HashMap::new(); for (parent, comment) in comments { result.entry(parent).or_insert_with(Vec::new).push(comment); } Ok(result) } async fn route_unstable_misc_render_markdown( _: (), _ctx: Arc, req: hyper::Request, ) -> Result, crate::Error> { let body = hyper::body::to_bytes(req.into_body()).await?; #[derive(Deserialize)] struct RenderMarkdownBody<'a> { content_markdown: Cow<'a, str>, } let body: RenderMarkdownBody = serde_json::from_slice(&body)?; let html = tokio::task::spawn_blocking(move || crate::render_markdown(&body.content_markdown)).await?; crate::json_response(&serde_json::json!({ "content_html": html })) } async fn handle_common_posts_list( stream: impl futures::stream::TryStream + Send, ctx: &crate::RouteContext, include_your: bool, ) -> Result, crate::Error> { use futures::stream::TryStreamExt; let posts: Vec = stream .map_err(crate::Error::from) .and_then(|row| { let id = PostLocalID(row.get(0)); let author_id = row.get::<_, Option<_>>(1).map(UserLocalID); let href: Option<&str> = row.get(2); let content_text: Option<&str> = row.get(3); let content_html: Option<&str> = row.get(6); let title: &str = row.get(4); let created: chrono::DateTime = row.get(5); let community_id = CommunityLocalID(row.get(7)); let community_name: &str = row.get(8); let community_local: bool = row.get(9); let community_ap_id: Option<&str> = row.get(10); let author = author_id.map(|id| { let author_name: &str = row.get(11); let author_local: bool = row.get(12); let author_ap_id: Option<&str> = row.get(13); let author_avatar: Option<&str> = row.get(14); RespMinimalAuthorInfo { id, username: author_name.into(), local: author_local, host: crate::get_actor_host_or_unknown( author_local, author_ap_id, &ctx.local_hostname, ), remote_url: author_ap_id.map(|x| x.to_owned().into()), avatar: author_avatar.map(|url| RespAvatarInfo { url: url.to_owned().into(), }), } }); let community = RespMinimalCommunityInfo { id: community_id, name: community_name, local: community_local, host: crate::get_actor_host_or_unknown( community_local, community_ap_id, &ctx.local_hostname, ), remote_url: community_ap_id, }; let post = RespPostListPost { id, title, href: ctx.process_href_opt(href, id), content_text, content_html, author: author.as_ref(), created: &created.to_rfc3339(), community: &community, score: row.get(15), your_vote: if include_your { Some(if row.get(16) { Some(crate::Empty {}) } else { None }) } else { None }, }; futures::future::ready(serde_json::to_value(&post).map_err(Into::into)) }) .try_collect() .await?; Ok(posts) } pub async fn process_comment_content<'a>( lang: &crate::Translator, content_text: Option>, content_markdown: Option, ) -> Result<(Option>, Option, Option), crate::Error> { if !(content_markdown.is_some() ^ content_text.is_some()) { return Err(crate::Error::UserError(crate::simple_response( hyper::StatusCode::BAD_REQUEST, lang.tr("comment_content_conflict", None).into_owned(), ))); } Ok(match content_markdown { Some(md) => { if md.trim().is_empty() { return Err(crate::Error::UserError(crate::simple_response( hyper::StatusCode::BAD_REQUEST, lang.tr("comment_empty", None).into_owned(), ))); } let (html, md) = tokio::task::spawn_blocking(move || (crate::render_markdown(&md), md)).await?; (None, Some(md), Some(html)) } None => match content_text { Some(text) => { if text.trim().is_empty() { return Err(crate::Error::UserError(crate::simple_response( hyper::StatusCode::BAD_REQUEST, lang.tr("comment_empty", None).into_owned(), ))); } (Some(text), None, None) } None => (None, None, None), }, }) }