~vpzom/lotide

319e2b6ea9ba06e6082030d8ce0b84d710738fda — Colin Reeder 11 months ago 3178d41 + dc5f1cb
Merge branch 'api-changes' into master
M openapi/openapi.json => openapi/openapi.json +93 -96
@@ 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": {

M res/lang/en.ftl => res/lang/en.ftl +0 -1
@@ 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

M res/lang/eo.ftl => res/lang/eo.ftl +0 -1
@@ 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.

M src/routes/api/comments.rs => src/routes/api/comments.rs +11 -0
@@ 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),
            ),
    )
}

M src/routes/api/mod.rs => src/routes/api/mod.rs +15 -481
@@ 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(&notifications)?;

    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,

M src/routes/api/posts.rs => src/routes/api/posts.rs +11 -0
@@ 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),
                ),
        )
}

A src/routes/api/users.rs => src/routes/api/users.rs +542 -0
@@ 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<JustContentText<'a>>>,
}

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 !super::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 = super::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_patch(
    params: (UserIDOrMe,),
    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 = params.0.require_me(&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_following_posts_list(
    params: (UserIDOrMe,),
    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 = params.0.require_me(&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_notifications_list(
    params: (UserIDOrMe,),
    ctx: Arc<crate::RouteContext>,
    req: hyper::Request<hyper::Body>,
) -> Result<hyper::Response<hyper::Body>, crate::Error> {
    let (user,) = params;

    let mut db = ctx.db_pool.get().await?;

    let user = user.require_me(&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(&notifications)?;

    Ok(hyper::Response::builder()
        .header(hyper::header::CONTENT_TYPE, "application/json")
        .body(body.into())?)
}

async fn route_unstable_users_get(
    params: (UserIDOrMe,),
    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 (user_id, your_note) = if query.include_your {
        let user = crate::require_login(&req, &db).await?;

        let user_id = user_id.resolve(user);

        (
            user_id,
            Some({
                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 {
        let user_id = user_id.try_resolve(&req, &db).await?;
        (user_id, 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: (UserIDOrMe,),
    ctx: Arc<crate::RouteContext>,
    req: hyper::Request<hyper::Body>,
) -> Result<hyper::Response<hyper::Body>, crate::Error> {
    let (target_user,) = params;

    let db = ctx.db_pool.get().await?;
    let login_user = crate::require_login(&req, &db).await?;

    let target_user = target_user.resolve(login_user);

    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",
        &[&login_user, &target_user, &body.content_text],
    ).await?;

    Ok(crate::empty_response())
}

async fn route_unstable_users_things_list(
    params: (UserIDOrMe,),
    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_id = user_id.try_resolve(&req, &db).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())?)
}

pub fn route_users() -> crate::RouteNode<()> {
    crate::RouteNode::new()
        .with_handler_async("POST", route_unstable_users_create)
        .with_child_parse::<UserIDOrMe, _>(
            crate::RouteNode::new()
                .with_handler_async("GET", route_unstable_users_get)
                .with_handler_async("PATCH", route_unstable_users_patch)
                .with_child(
                    "following:posts",
                    crate::RouteNode::new()
                        .with_handler_async("GET", route_unstable_users_following_posts_list),
                )
                .with_child(
                    "notifications",
                    crate::RouteNode::new()
                        .with_handler_async("GET", route_unstable_users_notifications_list),
                )
                .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),
                ),
        )
}