~vpzom/lotide

8fd8314a9d2098073590e61a35db320a9d1f47c7 — Colin Reeder 9 months ago 893d26d
Implement translation using Fluent
M Cargo.lock => Cargo.lock +157 -0
@@ 314,6 314,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7"

[[package]]
name = "fluent"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3b6132d1377d8776409a337c6851d342aee4e85277c96ecd2755c4e0efde1d"
dependencies = [
 "fluent-bundle",
 "unic-langid",
]

[[package]]
name = "fluent-bundle"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a094d494ab2ed06077e9a95f4e47f446c376de95f6c93045dd88c499bfcd70"
dependencies = [
 "fluent-langneg",
 "fluent-syntax",
 "intl-memoizer",
 "intl_pluralrules",
 "rental",
 "smallvec",
 "unic-langid",
]

[[package]]
name = "fluent-langneg"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c4ad0989667548f06ccd0e306ed56b61bd4d35458d54df5ec7587c0e8ed5e94"
dependencies = [
 "unic-langid",
]

[[package]]
name = "fluent-syntax"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac0f7e83d14cccbf26e165d8881dcac5891af0d85a88543c09dd72ebd31d91ba"

[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 446,6 486,15 @@ dependencies = [
]

[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
 "byteorder",
]

[[package]]
name = "generator"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 662,6 711,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b141fdc7836c525d4d594027d318c84161ca17aaf8113ab1f81ab93ae897485"

[[package]]
name = "intl-memoizer"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a0ed58ba6089d49f8a9a7d5e16fc9b9e2019cdf40ef270f3d465fa244d9630b"
dependencies = [
 "type-map",
 "unic-langid",
]

[[package]]
name = "intl_pluralrules"
version = "7.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c271cdb1f12a9feb3a017619c3ee681f971f270f6757341d6abe1f9f7a98bc3"
dependencies = [
 "tinystr",
 "unic-langid",
]

[[package]]
name = "iovec"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 751,6 820,8 @@ dependencies = [
 "chrono",
 "deadpool-postgres",
 "either",
 "fluent",
 "fluent-langneg",
 "futures",
 "hancock",
 "headers",


@@ 771,6 842,7 @@ dependencies = [
 "tokio-postgres",
 "trout",
 "unic-char-range",
 "unic-langid",
 "url",
 "uuid",
]


@@ 1193,6 1265,27 @@ dependencies = [
]

[[package]]
name = "rental"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8545debe98b2b139fb04cad8618b530e9b07c152d99a5de83c860b877d67847f"
dependencies = [
 "rental-impl",
 "stable_deref_trait",
]

[[package]]
name = "rental-impl"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "475e68978dc5b743f2f40d8e0a8fdc83f1c5e78cbf4b8fa5e74e73beebc340de"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "rustc_version"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 1366,6 1459,12 @@ dependencies = [
]

[[package]]
name = "stable_deref_trait"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"

[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 1443,6 1542,12 @@ dependencies = [
]

[[package]]
name = "tinystr"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bac79c4b51eda1b090b1edebfb667821bbb51f713855164dc7cec2cb8ac2ba3"

[[package]]
name = "tinyvec"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 1548,6 1653,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e604eb7b43c06650e854be16a2a03155743d3752dd1c943f6829e26b7a36e382"

[[package]]
name = "type-map"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d2741b1474c327d95c1f1e3b0a2c3977c8e128409c572a33af2914e7d636717"
dependencies = [
 "fxhash",
]

[[package]]
name = "typenum"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 1560,6 1674,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc"

[[package]]
name = "unic-langid"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73328fcd730a030bdb19ddf23e192187a6b01cd98be6d3140622a89129459ce5"
dependencies = [
 "unic-langid-impl",
 "unic-langid-macros",
]

[[package]]
name = "unic-langid-impl"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a4a8eeaf0494862c1404c95ec2f4c33a2acff5076f64314b465e3ddae1b934d"
dependencies = [
 "tinystr",
]

[[package]]
name = "unic-langid-macros"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18f980d6d87e8805f2836d64b4138cc95aa7986fa63b1f51f67d5fbff64dd6e5"
dependencies = [
 "proc-macro-hack",
 "tinystr",
 "unic-langid-impl",
 "unic-langid-macros-impl",
]

[[package]]
name = "unic-langid-macros-impl"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29396ffd97e27574c3e01368b1a64267d3064969e4848e2e130ff668be9daa9f"
dependencies = [
 "proc-macro-hack",
 "quote",
 "syn",
 "unic-langid-impl",
]

[[package]]
name = "unicase"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"

M Cargo.toml => Cargo.toml +3 -0
@@ 37,3 37,6 @@ lazy_static = "1.4.0"
unic-char-range = "0.9.0"
http = "0.2.1"
pulldown-cmark = "0.7.2"
fluent = "0.12.0"
fluent-langneg = "0.13.0"
unic-langid = { version = "0.9.0", features = ["macros"] }

A res/lang/en.flt => res/lang/en.flt +18 -0
@@ 0,0 1,18 @@
comment_content_conflict = Exactly one of content_markdown and content_text must be specified
comment_not_yours = That's not your comment
community_edit_denied = You are not authorized to modify this community
community_name_disallowed_chars = Community name contains disallowed characters
name_in_use = That name is already in use
no_password = No password set for this user
no_such_comment = No such comment
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_needs_content = Post must contain one of href, content_text, or content_markdown
post_not_yours = That's not your post
root = lotide is running. Note that lotide itself does not include a frontend, and you'll need to install one separately.
user_name_disallowed_chars = Username contains disallowed characters

A res/lang/eo.flt => res/lang/eo.flt +18 -0
@@ 0,0 1,18 @@
comment_content_conflict = Precize unu el content_markdown kaj content_text devas esti
comment_not_yours = Tio ne estas via komento
community_edit_denied = Vi ne rajtas ŝanĝi ĉi tiun komunumo
community_name_disallowed_chars = Nomo enhavas malpermesitajn signojn
name_in_use = Tio nomo estas jam uzata
no_password = Ĉi tiu uzanto ne havas pasvorton
no_such_comment = Neniu tia komento
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_needs_content = Poŝto devas enhavi unu el href, content_text, kaj content_markdown
post_not_yours = Tio ne estas via poŝto
root = lotide funkcias. Notu: lotide ne enhavas klienton, kaj vi devos instali tiun aparte.
user_name_disallowed_chars = Uzantnomo enhavas malpermesitajn signojn

M src/main.rs => src/main.rs +76 -0
@@ 1,5 1,6 @@
use serde_derive::{Deserialize, Serialize};
use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::Arc;
use trout::hyper::RoutingFailureExtHyper;



@@ 275,6 276,81 @@ pub async fn res_to_error(
    }
}

lazy_static::lazy_static! {
    static ref LANG_MAP: HashMap<unic_langid::LanguageIdentifier, fluent::FluentResource> = {
        let mut result = HashMap::new();

        result.insert(unic_langid::langid!("en"), fluent::FluentResource::try_new(include_str!("../res/lang/en.flt").to_owned()).expect("Failed to parse translation"));
        result.insert(unic_langid::langid!("eo"), fluent::FluentResource::try_new(include_str!("../res/lang/eo.flt").to_owned()).expect("Failed to parse translation"));

        result
    };

    static ref LANGS: Vec<unic_langid::LanguageIdentifier> = {
        LANG_MAP.keys().cloned().collect()
    };
}

pub struct Translator {
    bundle: fluent::concurrent::FluentBundle<&'static fluent::FluentResource>,
}
impl Translator {
    pub fn tr<'a>(&'a self, key: &str, args: Option<&'a fluent::FluentArgs>) -> Cow<'a, str> {
        let mut errors = Vec::with_capacity(0);
        let out = self.bundle.format_pattern(
            self.bundle
                .get_message(key)
                .expect("Missing message in translation")
                .value
                .expect("Missing value for translation key"),
            args,
            &mut errors,
        );
        if !errors.is_empty() {
            eprintln!("Errors in translation: {:?}", errors);
        }

        out
    }
}

pub fn get_lang_for_req(req: &hyper::Request<hyper::Body>) -> Translator {
    let default = unic_langid::langid!("en");
    let languages = match req
        .headers()
        .get(hyper::header::ACCEPT_LANGUAGE)
        .and_then(|x| x.to_str().ok())
    {
        Some(accept_language) => {
            let requested = fluent_langneg::accepted_languages::parse(accept_language);
            fluent_langneg::negotiate_languages(
                &requested,
                &LANGS,
                Some(&default),
                fluent_langneg::NegotiationStrategy::Filtering,
            )
        }
        None => vec![&default],
    };

    let mut bundle = fluent::concurrent::FluentBundle::new(languages.iter().map(|x| *x));
    for lang in languages {
        if let Err(errors) = bundle.add_resource(&LANG_MAP[lang]) {
            for err in errors {
                match err {
                    fluent::FluentError::Overriding { .. } => {}
                    _ => {
                        eprintln!("Failed to add language resource: {:?}", err);
                        break;
                    }
                }
            }
        }
    }

    Translator { bundle }
}

pub async fn authenticate(
    req: &hyper::Request<hyper::Body>,
    db: &tokio_postgres::Client,

M src/routes/api/communities.rs => src/routes/api/communities.rs +15 -8
@@ 65,6 65,8 @@ async fn route_unstable_communities_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 user = crate::require_login(&req, &db).await?;


@@ 81,7 83,8 @@ async fn route_unstable_communities_create(
        if !super::USERNAME_ALLOWED_CHARS.contains(&ch) {
            return Err(crate::Error::UserError(crate::simple_response(
                hyper::StatusCode::BAD_REQUEST,
                "Community name contains disallowed characters",
                lang.tr("community_name_disallowed_chars", None)
                    .into_owned(),
            )));
        }
    }


@@ 103,7 106,7 @@ async fn route_unstable_communities_create(
                if err.code() == Some(&tokio_postgres::error::SqlState::UNIQUE_VIOLATION) {
                    crate::Error::UserError(crate::simple_response(
                        hyper::StatusCode::BAD_REQUEST,
                        "That name is already in use",
                        lang.tr("name_in_use", None).into_owned(),
                    ))
                } else {
                    err.into()


@@ 138,6 141,7 @@ async fn route_unstable_communities_get(

    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 row = {


@@ 156,7 160,7 @@ async fn route_unstable_communities_get(
        .ok_or_else(|| {
            crate::Error::UserError(crate::simple_response(
                hyper::StatusCode::NOT_FOUND,
                "No such community",
                lang.tr("no_such_community", None).into_owned(),
            ))
        })?
    };


@@ 209,6 213,7 @@ async fn route_unstable_communities_patch(
) -> Result<hyper::Response<hyper::Body>, crate::Error> {
    let (community_id,) = params;

    let lang = crate::get_lang_for_req(&req);
    let db = ctx.db_pool.get().await?;

    let user = crate::require_login(&req, &db).await?;


@@ 231,7 236,7 @@ async fn route_unstable_communities_patch(
        match row {
            None => Err(crate::Error::UserError(crate::simple_response(
                hyper::StatusCode::NOT_FOUND,
                "No such community",
                lang.tr("no_such_community", None).into_owned(),
            ))),
            Some(row) => {
                let created_by = row.get::<_, Option<_>>(0).map(UserLocalID);


@@ 240,7 245,7 @@ async fn route_unstable_communities_patch(
                } else {
                    Err(crate::Error::UserError(crate::simple_response(
                        hyper::StatusCode::FORBIDDEN,
                        "You are not authorized to modify this community",
                        lang.tr("community_edit_denied", None).into_owned(),
                    )))
                }
            }


@@ 267,6 272,7 @@ async fn route_unstable_communities_follow(
) -> Result<hyper::Response<hyper::Body>, crate::Error> {
    let (community,) = params;

    let lang = crate::get_lang_for_req(&req);
    let db = ctx.db_pool.get().await?;

    let user = crate::require_login(&req, &db).await?;


@@ 286,7 292,7 @@ async fn route_unstable_communities_follow(
        .ok_or_else(|| {
            crate::Error::UserError(crate::simple_response(
                hyper::StatusCode::NOT_FOUND,
                "No such community",
                lang.tr("no_such_community", None).into_owned(),
            ))
        })?;



@@ 379,12 385,13 @@ async fn route_unstable_communities_unfollow(
async fn route_unstable_communities_posts_list(
    params: (CommunityLocalID,),
    ctx: Arc<crate::RouteContext>,
    _req: hyper::Request<hyper::Body>,
    req: hyper::Request<hyper::Body>,
) -> Result<hyper::Response<hyper::Body>, crate::Error> {
    let (community_id,) = params;

    use futures::stream::TryStreamExt;

    let lang = crate::get_lang_for_req(&req);
    let db = ctx.db_pool.get().await?;

    let community_row = db


@@ 396,7 403,7 @@ async fn route_unstable_communities_posts_list(
        .ok_or_else(|| {
            crate::Error::UserError(crate::simple_response(
                hyper::StatusCode::NOT_FOUND,
                "No such community",
                lang.tr("no_such_community", None).into_owned(),
            ))
        })?;


M src/routes/api/mod.rs => src/routes/api/mod.rs +29 -18
@@ 255,11 255,12 @@ 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)?;


@@ 327,7 328,7 @@ async fn route_unstable_actors_lookup(
    } else {
        Ok(crate::simple_response(
            hyper::StatusCode::BAD_REQUEST,
            "Not a group",
            lang.tr("not_group", None).into_owned(),
        ))
    }
}


@@ 337,6 338,7 @@ async fn route_unstable_logins_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 db = ctx.db_pool.get().await?;

    let body = hyper::body::to_bytes(req.into_body()).await?;


@@ 358,7 360,7 @@ async fn route_unstable_logins_create(
        .ok_or_else(|| {
            crate::Error::UserError(crate::simple_response(
                hyper::StatusCode::BAD_REQUEST,
                "No local user found by that name",
                lang.tr("no_such_local_user_by_name", None).into_owned(),
            ))
        })?;



@@ 368,7 370,7 @@ async fn route_unstable_logins_create(
    let passhash = passhash.ok_or_else(|| {
        crate::Error::UserError(crate::simple_response(
            hyper::StatusCode::BAD_REQUEST,
            "No password set for this user",
            lang.tr("no_password", None).into_owned(),
        ))
    })?;



@@ 387,7 389,7 @@ async fn route_unstable_logins_create(
    } else {
        Ok(crate::simple_response(
            hyper::StatusCode::FORBIDDEN,
            "Incorrect password",
            lang.tr("password_incorrect", None).into_owned(),
        ))
    }
}


@@ 511,6 513,7 @@ async fn route_unstable_posts_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 db = ctx.db_pool.get().await?;

    let user = crate::require_login(&req, &db).await?;


@@ 531,14 534,14 @@ async fn route_unstable_posts_create(
    if body.href.is_none() && body.content_text.is_none() && body.content_markdown.is_none() {
        return Err(crate::Error::UserError(crate::simple_response(
            hyper::StatusCode::BAD_REQUEST,
            "Post must contain one of href, content_text, or content_markdown",
            lang.tr("post_needs_content", None).into_owned(),
        )));
    }

    if body.content_markdown.is_some() && body.content_text.is_some() {
        return Err(crate::Error::UserError(crate::simple_response(
            hyper::StatusCode::BAD_REQUEST,
            "content_markdown and content_text are mutually exclusive",
            lang.tr("post_content_conflict", None).into_owned(),
        )));
    }



@@ 842,6 845,7 @@ async fn route_unstable_posts_get(

    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 include_your_for = if query.include_your {


@@ 887,7 891,7 @@ async fn route_unstable_posts_get(
    match row {
        None => Ok(crate::simple_response(
            hyper::StatusCode::NOT_FOUND,
            "No such post",
            lang.tr("no_such_post", None).into_owned(),
        )),
        Some(row) => {
            let href = row.get(1);


@@ 965,6 969,7 @@ async fn route_unstable_posts_delete(
) -> Result<hyper::Response<hyper::Body>, crate::Error> {
    let (post_id,) = params;

    let lang = crate::get_lang_for_req(&req);
    let db = ctx.db_pool.get().await?;

    let user = crate::require_login(&req, &db).await?;


@@ 982,7 987,7 @@ async fn route_unstable_posts_delete(
            if author != Some(user) {
                return Err(crate::Error::UserError(crate::simple_response(
                    hyper::StatusCode::FORBIDDEN,
                    "That's not your post",
                    lang.tr("post_not_yours", None).into_owned(),
                )));
            }



@@ 1226,6 1231,7 @@ async fn route_unstable_posts_replies_create(
) -> Result<hyper::Response<hyper::Body>, crate::Error> {
    let (post_id,) = params;

    let lang = crate::get_lang_for_req(&req);
    let db = ctx.db_pool.get().await?;

    let user = crate::require_login(&req, &db).await?;


@@ 1243,7 1249,7 @@ async fn route_unstable_posts_replies_create(
    if !(body.content_markdown.is_some() ^ body.content_text.is_some()) {
        return Err(crate::Error::UserError(crate::simple_response(
            hyper::StatusCode::BAD_REQUEST,
            "Exactly one of content_markdown and content_text must be specified",
            lang.tr("comment_content_conflict", None).into_owned(),
        )));
    }



@@ 1304,6 1310,7 @@ async fn route_unstable_comments_get(

    let (comment_id,) = params;

    let lang = crate::get_lang_for_req(&req);
    let db = ctx.db_pool.get().await?;

    let include_your_for = if query.include_your {


@@ 1336,7 1343,7 @@ async fn route_unstable_comments_get(
    match row {
        None => Ok(crate::simple_response(
            hyper::StatusCode::NOT_FOUND,
            "No such comment",
            lang.tr("no_such_comment", None).into_owned(),
        )),
        Some(row) => {
            let created: chrono::DateTime<chrono::FixedOffset> = row.get(3);


@@ 1404,6 1411,7 @@ async fn route_unstable_comments_delete(
) -> Result<hyper::Response<hyper::Body>, crate::Error> {
    let (comment_id,) = params;

    let lang = crate::get_lang_for_req(&req);
    let db = ctx.db_pool.get().await?;

    let user = crate::require_login(&req, &db).await?;


@@ 1421,7 1429,7 @@ async fn route_unstable_comments_delete(
            if author != Some(user) {
                return Err(crate::Error::UserError(crate::simple_response(
                    hyper::StatusCode::FORBIDDEN,
                    "That's not your comment",
                    lang.tr("comment_not_yours", None).into_owned(),
                )));
            }



@@ 1669,6 1677,7 @@ async fn route_unstable_comments_replies_create(
) -> Result<hyper::Response<hyper::Body>, crate::Error> {
    let (parent_id,) = params;

    let lang = crate::get_lang_for_req(&req);
    let db = ctx.db_pool.get().await?;

    let user = crate::require_login(&req, &db).await?;


@@ 1685,7 1694,7 @@ async fn route_unstable_comments_replies_create(
    if !(body.content_markdown.is_some() ^ body.content_text.is_some()) {
        return Err(crate::Error::UserError(crate::simple_response(
            hyper::StatusCode::BAD_REQUEST,
            "Exactly one of content_markdown and content_text must be specified",
            lang.tr("comment_content_conflict", None).into_owned(),
        )));
    }



@@ 1707,7 1716,7 @@ async fn route_unstable_comments_replies_create(
    {
        None => Err(crate::Error::UserError(crate::simple_response(
            hyper::StatusCode::NOT_FOUND,
            "No such comment",
            lang.tr("no_such_comment", None).into_owned(),
        ))),
        Some(row) => Ok(PostLocalID(row.get(0))),
    }?;


@@ 1747,6 1756,7 @@ 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?;


@@ 1765,7 1775,7 @@ async fn route_unstable_users_create(
        if !USERNAME_ALLOWED_CHARS.contains(&ch) {
            return Err(crate::Error::UserError(crate::simple_response(
                hyper::StatusCode::BAD_REQUEST,
                "Username contains disallowed characters",
                lang.tr("user_name_disallowed_chars", None).into_owned(),
            )));
        }
    }


@@ 1787,7 1797,7 @@ async fn route_unstable_users_create(
                if err.code() == Some(&tokio_postgres::error::SqlState::UNIQUE_VIOLATION) {
                    crate::Error::UserError(crate::simple_response(
                        hyper::StatusCode::BAD_REQUEST,
                        "That name is already in use",
                        lang.tr("name_in_use", None).into_owned(),
                    ))
                } else {
                    err.into()


@@ 1875,10 1885,11 @@ async fn route_unstable_users_me_following_posts_list(
async fn route_unstable_users_get(
    params: (UserLocalID,),
    ctx: Arc<crate::RouteContext>,
    _req: hyper::Request<hyper::Body>,
    req: hyper::Request<hyper::Body>,
) -> Result<hyper::Response<hyper::Body>, crate::Error> {
    let (user_id,) = params;

    let lang = crate::get_lang_for_req(&req);
    let db = ctx.db_pool.get().await?;

    let row = db


@@ 1891,7 1902,7 @@ async fn route_unstable_users_get(
    let row = row.ok_or_else(|| {
        crate::Error::UserError(crate::simple_response(
            hyper::StatusCode::NOT_FOUND,
            "No such user",
            lang.tr("no_such_user", None).into_owned(),
        ))
    })?;


M src/routes/mod.rs => src/routes/mod.rs +6 -7
@@ 4,13 4,12 @@ mod well_known;

pub fn route_root() -> crate::RouteNode<()> {
    crate::RouteNode::new()
        .with_handler_async("GET", |_, _, _| {
            futures::future::err(crate::Error::UserError(
                crate::simple_response(
                    hyper::StatusCode::METHOD_NOT_ALLOWED,
                    "lotide is running. Note that lotide itself does not include a frontend, and you'll need to install one separately."
                )
            ))
        .with_handler_async("GET", |_, _, req| {
            let lang = crate::get_lang_for_req(&req);
            futures::future::err(crate::Error::UserError(crate::simple_response(
                hyper::StatusCode::METHOD_NOT_ALLOWED,
                lang.tr("root", None).into_owned(),
            )))
        })
        .with_child("apub", apub::route_apub())
        .with_child("api", api::route_api())