diff options
author | Colin Reeder <colin@vpzom.click> | 2020-08-08 10:01:39 -0600 |
---|---|---|
committer | Colin Reeder <colin@vpzom.click> | 2020-08-08 10:01:39 -0600 |
commit | 319e2b6ea9ba06e6082030d8ce0b84d710738fda (patch) | |
tree | 97c6a0305e464745ff5802ef337bc9121e032491 | |
parent | 3178d41ec7579e96d3d01488fd06510cdfa84b06 (diff) | |
parent | dc5f1cb6f6c4a4ea3fc9c007d1efb7afdfc74088 (diff) |
Merge branch 'api-changes' into master
-rw-r--r-- | openapi/openapi.json | 189 | ||||
-rw-r--r-- | res/lang/en.ftl | 1 | ||||
-rw-r--r-- | res/lang/eo.ftl | 1 | ||||
-rw-r--r-- | src/routes/api/comments.rs | 11 | ||||
-rw-r--r-- | src/routes/api/mod.rs | 496 | ||||
-rw-r--r-- | src/routes/api/posts.rs | 11 | ||||
-rw-r--r-- | src/routes/api/users.rs | 542 |
7 files changed, 672 insertions, 579 deletions
diff --git a/openapi/openapi.json b/openapi/openapi.json index d4b7329..0386158 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -140,7 +140,7 @@ "paths": { "/api/unstable/actors:lookup/{remoteID}": { "get": { - "summary": "Look up a remote community by WebFinger or ActivityPub ID", + "summary": "Look up a remote actor by WebFinger or ActivityPub ID", "parameters": [ { "name": "remoteID", @@ -162,7 +162,8 @@ "type": "object", "required": ["id"], "properties": { - "id": {"type": "integer"} + "id": {"type": "integer"}, + "type": {"type": "string", "enum": ["community", "user"]} } } } @@ -248,9 +249,9 @@ "security": [{"bearer": []}] } }, - "/api/unstable/comments/{commentID}/like": { + "/api/unstable/comments/{commentID}/replies": { "post": { - "summary": "Like a comment", + "summary": "Reply to a comment", "parameters": [ { "name": "commentID", @@ -259,15 +260,46 @@ "schema": {"type": "integer"} } ], - "responses": { - "204": { - "description": "Successfully liked." + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content_text": {"type": "string"}, + "content_markdown": {"type": "string"} + } + } + } } }, - "security": [{"bearer": []}] + "responses": { + "200": { + "description": "Successfully created reply.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["id", "post"], + "properties": { + "id": {"type": "integer"}, + "post": { + "type": "object", + "required": ["id"], + "properties": { + "id": {"type": "integer"} + } + } + } + } + } + } + } + } } }, - "/api/unstable/comments/{commentID}/likes": { + "/api/unstable/comments/{commentID}/votes": { "get": { "summary": "List likers of a comment", "parameters": [ @@ -315,9 +347,9 @@ } } }, - "/api/unstable/comments/{commentID}/replies": { - "post": { - "summary": "Reply to a comment", + "/api/unstable/comments/{commentID}/your_vote": { + "put": { + "summary": "Like a comment", "parameters": [ { "name": "commentID", @@ -326,47 +358,14 @@ "schema": {"type": "integer"} } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "content_text": {"type": "string"}, - "content_markdown": {"type": "string"} - } - } - } - } - }, "responses": { - "200": { - "description": "Successfully created reply.", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["id", "post"], - "properties": { - "id": {"type": "integer"}, - "post": { - "type": "object", - "required": ["id"], - "properties": { - "id": {"type": "integer"} - } - } - } - } - } - } + "204": { + "description": "Successfully liked." } - } - } - }, - "/api/unstable/comments/{commentID}/unlike": { - "post": { + }, + "security": [{"bearer": []}] + }, + "delete": { "summary": "Retract a like of a comment", "parameters": [ { @@ -903,9 +902,9 @@ "security": [{"bearer": []}] } }, - "/api/unstable/posts/{postID}/like": { + "/api/unstable/posts/{postID}/replies": { "post": { - "summary": "Like a post", + "summary": "Reply to a post", "parameters": [ { "name": "postID", @@ -914,15 +913,39 @@ "schema": {"type": "integer"} } ], - "responses": { - "204": { - "description": "Successfully liked." + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content_text": {"type": "string"}, + "content_markdown": {"type": "string"} + } + } + } } }, - "security": [{"bearer": []}] + "responses": { + "200": { + "description": "Successfully created reply.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["id", "post"], + "properties": { + "id": {"type": "integer"} + } + } + } + } + } + } } }, - "/api/unstable/posts/{postID}/likes": { + "/api/unstable/posts/{postID}/votes": { "get": { "summary": "List likers of a post", "parameters": [ @@ -970,9 +993,9 @@ } } }, - "/api/unstable/posts/{postID}/replies": { - "post": { - "summary": "Reply to a post", + "/api/unstable/posts/{postID}/your_vote": { + "put": { + "summary": "Like a post", "parameters": [ { "name": "postID", @@ -981,40 +1004,14 @@ "schema": {"type": "integer"} } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "content_text": {"type": "string"}, - "content_markdown": {"type": "string"} - } - } - } - } - }, "responses": { - "200": { - "description": "Successfully created reply.", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["id", "post"], - "properties": { - "id": {"type": "integer"} - } - } - } - } + "204": { + "description": "Successfully liked." } - } - } - }, - "/api/unstable/posts/{postID}/unlike": { - "post": { + }, + "security": [{"bearer": []}] + }, + "delete": { "summary": "Retract a like of a post", "parameters": [ { @@ -1214,7 +1211,7 @@ "security": [{"bearer": []}] } }, - "/api/unstable/users/me": { + "/api/unstable/users/~me": { "patch": { "summary": "Edit your account settings", "requestBody": { @@ -1238,7 +1235,7 @@ "security": [{"bearer": []}] } }, - "/api/unstable/users/me/following:posts": { + "/api/unstable/users/~me/following:posts": { "get": { "summary": "Fetch posts from all the communities you follow", "responses": { @@ -1259,7 +1256,7 @@ "security": [{"bearer": []}] } }, - "/api/unstable/users/me/notifications": { + "/api/unstable/users/~me/notifications": { "get": { "summary": "Fetch your notifications. Will also clear `has_unread_notifications`.", "responses": { diff --git a/res/lang/en.ftl b/res/lang/en.ftl index 809aeb5..f1f462a 100644 --- a/res/lang/en.ftl +++ b/res/lang/en.ftl @@ -9,7 +9,6 @@ no_such_community = No such community no_such_local_user_by_name = No local user found by that name no_such_post = No such post no_such_user = No such user -not_group = Not a group password_incorrect = Incorrect password post_content_conflict = content_markdown and content_text are mutually exclusive post_href_invalid = Specified URL is not valid diff --git a/res/lang/eo.ftl b/res/lang/eo.ftl index 855af95..51cbdd6 100644 --- a/res/lang/eo.ftl +++ b/res/lang/eo.ftl @@ -9,7 +9,6 @@ no_such_community = Neniu tia komunumo no_such_local_user_by_name = Neniu uzanto trovita per tiu nomo no_such_post = Neniu tia poŝto no_such_user = Neniu tia uzanto -not_group = Ne estas grupo password_incorrect = Pasvorto malĝustas post_content_conflict = content_markdown kaj content_text konfliktas post_href_invalid = URL nevalidas. diff --git a/src/routes/api/comments.rs b/src/routes/api/comments.rs index b608b80..f4c9fc6 100644 --- a/src/routes/api/comments.rs +++ b/src/routes/api/comments.rs @@ -605,6 +605,17 @@ pub fn route_comments() -> crate::RouteNode<()> { "replies", crate::RouteNode::new() .with_handler_async("POST", route_unstable_comments_replies_create), + ) + .with_child( + "votes", + crate::RouteNode::new() + .with_handler_async("GET", route_unstable_comments_likes_list), + ) + .with_child( + "your_vote", + crate::RouteNode::new() + .with_handler_async("PUT", route_unstable_comments_like) + .with_handler_async("DELETE", route_unstable_comments_unlike), ), ) } diff --git a/src/routes/api/mod.rs b/src/routes/api/mod.rs index 831ea5e..10a47d0 100644 --- a/src/routes/api/mod.rs +++ b/src/routes/api/mod.rs @@ -9,6 +9,7 @@ use std::sync::Arc; mod comments; mod communities; mod posts; +mod users; lazy_static::lazy_static! { static ref USERNAME_ALLOWED_CHARS: HashSet<char> = { @@ -74,21 +75,6 @@ struct RespMinimalPostInfo<'a> { title: &'a str, } -#[derive(Deserialize, Serialize)] -struct JustContentText<'a> { - content_text: Cow<'a, str>, -} - -#[derive(Serialize)] -struct RespUserInfo<'a> { - #[serde(flatten)] - base: RespMinimalAuthorInfo<'a>, - - description: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - your_note: Option<Option<JustContentText<'a>>>, -} - #[derive(Serialize)] struct RespPostListPost<'a> { id: PostLocalID, @@ -181,44 +167,7 @@ pub fn route_api() -> crate::RouteNode<()> { ) .with_child("posts", posts::route_posts()) .with_child("comments", comments::route_comments()) - .with_child( - "users", - crate::RouteNode::new() - .with_handler_async("POST", route_unstable_users_create) - .with_child( - "me", - crate::RouteNode::new() - .with_handler_async("PATCH", route_unstable_users_me_patch) - .with_child( - "following:posts", - crate::RouteNode::new().with_handler_async( - "GET", - route_unstable_users_me_following_posts_list, - ), - ) - .with_child( - "notifications", - crate::RouteNode::new().with_handler_async( - "GET", - route_unstable_users_me_notifications_list, - ), - ), - ) - .with_child_parse::<UserLocalID, _>( - crate::RouteNode::new() - .with_handler_async("GET", route_unstable_users_get) - .with_child( - "things", - crate::RouteNode::new() - .with_handler_async("GET", route_unstable_users_things_list), - ) - .with_child( - "your_note", - crate::RouteNode::new() - .with_handler_async("PUT", route_unstable_users_your_note_put), - ), - ), - ), + .with_child("users", users::route_users()), ) } @@ -264,12 +213,11 @@ fn parse_lookup(src: &str) -> Result<Lookup, crate::Error> { async fn route_unstable_actors_lookup( params: (String,), ctx: Arc<crate::RouteContext>, - req: hyper::Request<hyper::Body>, + _req: hyper::Request<hyper::Body>, ) -> Result<hyper::Response<hyper::Body>, crate::Error> { let (query,) = params; println!("lookup {}", query); - let lang = crate::get_lang_for_req(&req); let db = ctx.db_pool.get().await?; let lookup = parse_lookup(&query)?; @@ -328,16 +276,18 @@ async fn route_unstable_actors_lookup( let actor = crate::apub_util::fetch_actor(&uri, &db, &ctx.http_client).await?; - if let crate::apub_util::ActorLocalInfo::Community { id, .. } = actor { - Ok(hyper::Response::builder() - .header(hyper::header::CONTENT_TYPE, "application/json") - .body(serde_json::to_vec(&serde_json::json!([{ "id": id }]))?.into())?) - } else { - Ok(crate::simple_response( - hyper::StatusCode::BAD_REQUEST, - lang.tr("not_group", None).into_owned(), - )) - } + 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"}) + } + }; + + Ok(hyper::Response::builder() + .header(hyper::header::CONTENT_TYPE, "application/json") + .body(serde_json::to_vec(&[info])?.into())?) } async fn route_unstable_logins_create( @@ -685,422 +635,6 @@ async fn route_unstable_misc_render_markdown( .body(output.into())?) } -async fn route_unstable_users_create( - _: (), - ctx: Arc<crate::RouteContext>, - req: hyper::Request<hyper::Body>, -) -> Result<hyper::Response<hyper::Body>, crate::Error> { - let lang = crate::get_lang_for_req(&req); - let mut db = ctx.db_pool.get().await?; - - let body = hyper::body::to_bytes(req.into_body()).await?; - - #[derive(Deserialize)] - struct UsersCreateBody<'a> { - username: Cow<'a, str>, - password: String, - #[serde(default)] - login: bool, - } - - let body: UsersCreateBody<'_> = serde_json::from_slice(&body)?; - - for ch in body.username.chars() { - if !USERNAME_ALLOWED_CHARS.contains(&ch) { - return Err(crate::Error::UserError(crate::simple_response( - hyper::StatusCode::BAD_REQUEST, - lang.tr("user_name_disallowed_chars", None).into_owned(), - ))); - } - } - - let req_password = body.password; - let passhash = - tokio::task::spawn_blocking(move || bcrypt::hash(req_password, bcrypt::DEFAULT_COST)) - .await??; - - let user_id = { - let trans = db.transaction().await?; - trans - .execute( - "INSERT INTO local_actor_name (name) VALUES ($1)", - &[&body.username], - ) - .await - .map_err(|err| { - if err.code() == Some(&tokio_postgres::error::SqlState::UNIQUE_VIOLATION) { - crate::Error::UserError(crate::simple_response( - hyper::StatusCode::BAD_REQUEST, - lang.tr("name_in_use", None).into_owned(), - )) - } else { - err.into() - } - })?; - let row = trans.query_one( - "INSERT INTO person (username, local, created_local, passhash) VALUES ($1, TRUE, current_timestamp, $2) RETURNING id", - &[&body.username, &passhash], - ).await?; - - trans.commit().await?; - - UserLocalID(row.get(0)) - }; - - let output = if body.login { - let token = insert_token(user_id, &db).await?; - serde_json::json!({"user": {"id": user_id}, "token": token.to_string()}) - } else { - serde_json::json!({"user": {"id": user_id}}) - }; - - Ok(hyper::Response::builder() - .header(hyper::header::CONTENT_TYPE, "application/json") - .body(serde_json::to_vec(&output)?.into())?) -} - -async fn route_unstable_users_me_patch( - _: (), - ctx: Arc<crate::RouteContext>, - req: hyper::Request<hyper::Body>, -) -> Result<hyper::Response<hyper::Body>, crate::Error> { - let db = ctx.db_pool.get().await?; - - let user = crate::require_login(&req, &db).await?; - - #[derive(Deserialize)] - struct UsersEditBody<'a> { - description: Option<Cow<'a, str>>, - } - - let body = hyper::body::to_bytes(req.into_body()).await?; - let body: UsersEditBody = serde_json::from_slice(&body)?; - - if let Some(description) = body.description { - db.execute( - "UPDATE person SET description=$1 WHERE id=$2", - &[&description, &user], - ) - .await?; - - // TODO maybe send this somewhere? - } - - Ok(crate::empty_response()) -} - -async fn route_unstable_users_me_following_posts_list( - _: (), - ctx: Arc<crate::RouteContext>, - req: hyper::Request<hyper::Body>, -) -> Result<hyper::Response<hyper::Body>, crate::Error> { - let db = ctx.db_pool.get().await?; - - let user = crate::require_login(&req, &db).await?; - - let limit: i64 = 30; // TODO make configurable - - let values: &[&(dyn tokio_postgres::types::ToSql + Sync)] = &[&user, &limit]; - - let stream = db.query_raw( - "SELECT post.id, post.author, post.href, post.content_text, post.title, post.created, post.content_html, community.id, community.name, community.local, community.ap_id, person.username, person.local, person.ap_id FROM community, post LEFT OUTER JOIN person ON (person.id = post.author) WHERE post.community = community.id AND post.approved AND post.deleted=FALSE AND community.id IN (SELECT community FROM community_follow WHERE follower=$1 AND accepted) ORDER BY hot_rank((SELECT COUNT(*) FROM post_like WHERE post = post.id AND person != post.author), post.created) DESC LIMIT $2", - values.iter().map(|s| *s as _) - ).await?; - - let posts = handle_common_posts_list(stream, &ctx.local_hostname).await?; - - let body = serde_json::to_vec(&posts)?; - - Ok(hyper::Response::builder() - .header(hyper::header::CONTENT_TYPE, "application/json") - .body(body.into())?) -} - -async fn route_unstable_users_me_notifications_list( - _: (), - ctx: Arc<crate::RouteContext>, - req: hyper::Request<hyper::Body>, -) -> Result<hyper::Response<hyper::Body>, crate::Error> { - let mut db = ctx.db_pool.get().await?; - - let user = crate::require_login(&req, &db).await?; - - let limit: i64 = 30; - - let rows = { - let trans = db.transaction().await?; - - let rows = trans.query( - "SELECT notification.kind, (notification.created_at > (SELECT last_checked_notifications FROM person WHERE id=$1)), reply.id, reply.content_text, reply.content_html, parent_reply.id, parent_reply_post.id, parent_reply_post.title, parent_post.id, parent_post.title FROM notification LEFT OUTER JOIN reply ON (reply.id = notification.reply) LEFT OUTER JOIN reply AS parent_reply ON (parent_reply.id = notification.parent_reply) LEFT OUTER JOIN post AS parent_reply_post ON (parent_reply_post.id = parent_reply.post) LEFT OUTER JOIN post AS parent_post ON (parent_post.id = notification.parent_post) WHERE notification.to_user = $1 AND NOT COALESCE(reply.deleted OR parent_reply.deleted OR parent_reply_post.deleted OR parent_post.deleted, FALSE) ORDER BY created_at DESC LIMIT $2", - &[&user, &limit], - ).await?; - trans - .execute( - "UPDATE person SET last_checked_notifications=current_timestamp WHERE id=$1", - &[&user], - ) - .await?; - - trans.commit().await?; - - rows - }; - - #[derive(Serialize)] - #[serde(tag = "type")] - #[serde(rename_all = "snake_case")] - enum RespNotificationInfo<'a> { - PostReply { - reply: RespMinimalCommentInfo<'a>, - post: RespMinimalPostInfo<'a>, - }, - CommentReply { - reply: RespMinimalCommentInfo<'a>, - comment: CommentLocalID, - post: Option<RespMinimalPostInfo<'a>>, - }, - } - - #[derive(Serialize)] - struct RespNotification<'a> { - #[serde(flatten)] - info: RespNotificationInfo<'a>, - - unseen: bool, - } - - let notifications: Vec<_> = rows - .iter() - .filter_map(|row| { - let kind: &str = row.get(0); - let unseen: bool = row.get(1); - let info = match kind { - "post_reply" => { - if let Some(reply_id) = row.get(2) { - if let Some(post_id) = row.get(8) { - let comment = RespMinimalCommentInfo { - id: CommentLocalID(reply_id), - content_text: row.get::<_, Option<_>>(3).map(Cow::Borrowed), - content_html: row.get::<_, Option<_>>(4).map(Cow::Borrowed), - }; - let post = RespMinimalPostInfo { - id: PostLocalID(post_id), - title: row.get(9), - }; - - Some(RespNotificationInfo::PostReply { - reply: comment, - post, - }) - } else { - None - } - } else { - None - } - } - "reply_reply" => { - if let Some(reply_id) = row.get(2) { - if let Some(parent_id) = row.get(5) { - let reply = RespMinimalCommentInfo { - id: CommentLocalID(reply_id), - content_text: row.get::<_, Option<_>>(3).map(Cow::Borrowed), - content_html: row.get::<_, Option<_>>(4).map(Cow::Borrowed), - }; - let parent_id = CommentLocalID(parent_id); - let post = - row.get::<_, Option<_>>(6) - .map(|post_id| RespMinimalPostInfo { - id: PostLocalID(post_id), - title: row.get(7), - }); - - Some(RespNotificationInfo::CommentReply { - reply, - comment: parent_id, - post, - }) - } else { - None - } - } else { - None - } - } - _ => None, - }; - - info.map(|info| RespNotification { info, unseen }) - }) - .collect(); - - let body = serde_json::to_vec(¬ifications)?; - - Ok(hyper::Response::builder() - .header(hyper::header::CONTENT_TYPE, "application/json") - .body(body.into())?) -} - -async fn route_unstable_users_get( - params: (UserLocalID,), - ctx: Arc<crate::RouteContext>, - req: hyper::Request<hyper::Body>, -) -> Result<hyper::Response<hyper::Body>, crate::Error> { - let (user_id,) = params; - - let query: MaybeIncludeYour = serde_urlencoded::from_str(req.uri().query().unwrap_or(""))?; - - let lang = crate::get_lang_for_req(&req); - let db = ctx.db_pool.get().await?; - - let your_note_row; - - let your_note = if query.include_your { - Some({ - let user = crate::require_login(&req, &db).await?; - - your_note_row = db - .query_opt( - "SELECT content_text FROM person_note WHERE author=$1 AND target=$2", - &[&user, &user_id], - ) - .await?; - - your_note_row.as_ref().map(|row| JustContentText { - content_text: Cow::Borrowed(row.get(0)), - }) - }) - } else { - None - }; - - let row = db - .query_opt( - "SELECT username, local, ap_id, description FROM person WHERE id=$1", - &[&user_id], - ) - .await?; - - let row = row.ok_or_else(|| { - crate::Error::UserError(crate::simple_response( - hyper::StatusCode::NOT_FOUND, - lang.tr("no_such_user", None).into_owned(), - )) - })?; - - let local = row.get(1); - let ap_id = row.get(2); - - let info = RespMinimalAuthorInfo { - id: user_id, - local, - username: Cow::Borrowed(row.get(0)), - host: crate::get_actor_host_or_unknown(local, ap_id, &ctx.local_hostname), - remote_url: ap_id.map(From::from), - }; - - let info = RespUserInfo { - base: info, - description: row.get(3), - your_note, - }; - - let body = serde_json::to_vec(&info)?; - - Ok(hyper::Response::builder() - .header(hyper::header::CONTENT_TYPE, "application/json") - .body(body.into())?) -} - -async fn route_unstable_users_your_note_put( - params: (UserLocalID,), - ctx: Arc<crate::RouteContext>, - req: hyper::Request<hyper::Body>, -) -> Result<hyper::Response<hyper::Body>, crate::Error> { - let (user_id,) = params; - - let db = ctx.db_pool.get().await?; - let user = crate::require_login(&req, &db).await?; - - let body = hyper::body::to_bytes(req.into_body()).await?; - let body: JustContentText = serde_json::from_slice(&body)?; - - db.execute( - "INSERT INTO person_note (author, target, content_text) VALUES ($1, $2, $3) ON CONFLICT (author, target) DO UPDATE SET content_text=$3", - &[&user, &user_id, &body.content_text], - ).await?; - - Ok(crate::empty_response()) -} - -async fn route_unstable_users_things_list( - params: (UserLocalID,), - ctx: Arc<crate::RouteContext>, - _req: hyper::Request<hyper::Body>, -) -> Result<hyper::Response<hyper::Body>, crate::Error> { - let (user_id,) = params; - - let db = ctx.db_pool.get().await?; - - let limit: i64 = 30; - - let rows = db.query( - "(SELECT TRUE, post.id, post.href, post.title, post.created, community.id, community.name, community.local, community.ap_id FROM post, community WHERE post.community = community.id AND post.author = $1 AND NOT post.deleted) UNION ALL (SELECT FALSE, reply.id, reply.content_text, reply.content_html, reply.created, post.id, post.title, NULL, NULL FROM reply, post WHERE post.id = reply.post AND reply.author = $1 AND NOT reply.deleted) ORDER BY created DESC LIMIT $2", - &[&user_id, &limit], - ) - .await?; - - let things: Vec<RespThingInfo> = rows - .iter() - .map(|row| { - let created: chrono::DateTime<chrono::FixedOffset> = row.get(4); - let created = created.to_rfc3339(); - - if row.get(0) { - let community_local = row.get(7); - let community_ap_id = row.get(8); - - RespThingInfo::Post { - id: PostLocalID(row.get(1)), - href: row.get(2), - title: row.get(3), - created, - community: RespMinimalCommunityInfo { - id: CommunityLocalID(row.get(5)), - name: row.get(6), - local: community_local, - host: crate::get_actor_host_or_unknown( - community_local, - community_ap_id, - &ctx.local_hostname, - ), - remote_url: community_ap_id, - }, - } - } else { - RespThingInfo::Comment { - base: RespMinimalCommentInfo { - id: CommentLocalID(row.get(1)), - content_text: row.get::<_, Option<_>>(2).map(Cow::Borrowed), - content_html: row.get::<_, Option<_>>(3).map(Cow::Borrowed), - }, - created, - post: RespMinimalPostInfo { - id: PostLocalID(row.get(5)), - title: row.get(6), - }, - } - } - }) - .collect(); - - let body = serde_json::to_vec(&things)?; - - Ok(hyper::Response::builder() - .header(hyper::header::CONTENT_TYPE, "application/json") - .body(body.into())?) -} - async fn handle_common_posts_list( stream: impl futures::stream::TryStream<Ok = tokio_postgres::Row, Error = tokio_postgres::Error> + Send, diff --git a/src/routes/api/posts.rs b/src/routes/api/posts.rs index c26609a..4562444 100644 --- a/src/routes/api/posts.rs +++ b/src/routes/api/posts.rs @@ -819,6 +819,17 @@ pub fn route_posts() -> crate::RouteNode<()> { "replies", crate::RouteNode::new() .with_handler_async("POST", route_unstable_posts_replies_create), + ) + .with_child( + "votes", + crate::RouteNode::new() + .with_handler_async("GET", route_unstable_posts_likes_list), + ) + .with_child( + "your_vote", + crate::RouteNode::new() + .with_handler_async("PUT", route_unstable_posts_like) + .with_handler_async("DELETE", route_unstable_posts_unlike), ), ) } diff --git a/src/routes/api/users.rs b/src/routes/api/users.rs new file mode 100644 index 0000000..c861aa6 --- /dev/null +++ b/src/routes/api/users.rs @@ -0,0 +1,542 @@ +use super::{ + handle_common_posts_list, MaybeIncludeYour, RespMinimalAuthorInfo, RespMinimalCommentInfo, + RespMinimalCommunityInfo, RespMinimalPostInfo, RespThingInfo, +}; +use crate::{CommentLocalID, CommunityLocalID, PostLocalID, UserLocalID}; +use serde_derive::{Deserialize, Serialize}; +use std::borrow::Cow; +use std::sync::Arc; + +#[derive(Clone, Copy, PartialEq, Debug)] +enum UserIDOrMe { + User(UserLocalID), + Me, +} + +impl UserIDOrMe { + pub fn resolve(self, me: UserLocalID) -> UserLocalID { + match self { + UserIDOrMe::User(id) => id, + UserIDOrMe::Me => me, + } + } + + pub async fn try_resolve( + self, + req: &hyper::Request<hyper::Body>, + db: &tokio_postgres::Client, + ) -> Result<UserLocalID, crate::Error> { + match self { + UserIDOrMe::User(id) => Ok(id), + UserIDOrMe::Me => crate::require_login(req, db).await, + } + } + + pub async fn require_me( + self, + req: &hyper::Request<hyper::Body>, + db: &tokio_postgres::Client, + ) -> Result<UserLocalID, crate::Error> { + let login_user = crate::require_login(req, db).await?; + match self { + UserIDOrMe::Me => Ok(login_user), + UserIDOrMe::User(id) => { + if id == login_user { + Ok(login_user) + } else { + Err(crate::Error::UserError(crate::simple_response( + hyper::StatusCode::FORBIDDEN, + "This endpoint is only available for the current user", + ))) + } + } + } + } +} + +impl std::str::FromStr for UserIDOrMe { + type Err = std::num::ParseIntError; + + fn from_str(src: &str) -> Result<Self, Self::Err> { + if src == "~me" || src == "me" + /* temporary backward compat */ + { + Ok(UserIDOrMe::Me) + } else { + src.parse().map(UserIDOrMe::User) + } + } +} + +#[derive(Deserialize, Serialize)] +struct JustContentText<'a> { + content_text: Cow<'a, str>, +} + +#[derive(Serialize)] +struct RespUserInfo<'a> { + #[serde(flatten)] + base: RespMinimalAuthorInfo<'a>, + + description: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + your_note: Option<Option<Just |