about summary refs log tree commit diff
path: root/kittybox-rs/src/micropub/post.rs
diff options
context:
space:
mode:
Diffstat (limited to 'kittybox-rs/src/micropub/post.rs')
-rw-r--r--kittybox-rs/src/micropub/post.rs944
1 files changed, 944 insertions, 0 deletions
diff --git a/kittybox-rs/src/micropub/post.rs b/kittybox-rs/src/micropub/post.rs
new file mode 100644
index 0000000..cf9f3d9
--- /dev/null
+++ b/kittybox-rs/src/micropub/post.rs
@@ -0,0 +1,944 @@
+use crate::database::Storage;
+use crate::indieauth::User;
+use chrono::prelude::*;
+use core::iter::Iterator;
+use newbase60::num_to_sxg;
+use std::convert::TryInto;
+use serde_json::json;
+
+pub(crate) static DEFAULT_CHANNEL_PATH: &str = "/feeds/main";
+static DEFAULT_CHANNEL_NAME: &str = "Main feed";
+pub(crate) static CONTACTS_CHANNEL_PATH: &str = "/feeds/vcards";
+static CONTACTS_CHANNEL_NAME: &str = "My address book";
+pub(crate) static FOOD_CHANNEL_PATH: &str = "/feeds/food";
+static FOOD_CHANNEL_NAME: &str = "My recipe book";
+
+fn get_folder_from_type(post_type: &str) -> String {
+    (match post_type {
+        "h-feed" => "feeds/",
+        "h-card" => "vcards/",
+        "h-event" => "events/",
+        "h-food" => "food/",
+        _ => "posts/",
+    })
+    .to_string()
+}
+
+/// Reset the datetime to a proper datetime.
+/// Do not attempt to recover the information.
+/// Do not pass GO. Do not collect $200.                
+fn reset_dt(post: &mut serde_json::Value) -> DateTime<FixedOffset> {
+    let curtime: DateTime<Local> = Local::now();
+    post["properties"]["published"] = json!([curtime.to_rfc3339()]);
+    chrono::DateTime::from(curtime)
+}
+
+pub fn normalize_mf2(mut body: serde_json::Value, user: &User) -> (String, serde_json::Value) {
+    // Normalize the MF2 object here.
+    let me = &user.me;
+    let folder = get_folder_from_type(body["type"][0].as_str().unwrap());
+    let published: DateTime<FixedOffset> = if let Some(dt) = body["properties"]["published"][0].as_str() {
+        // Check if the datetime is parsable.
+        match DateTime::parse_from_rfc3339(dt) {
+            Ok(dt) => dt,
+            Err(_) => reset_dt(&mut body)
+        }
+    } else {
+        // Set the datetime.
+        // Note: this code block duplicates functionality with the above failsafe.
+        // Consider refactoring it to a helper function?
+        reset_dt(&mut body)
+    };
+    match body["properties"]["uid"][0].as_str() {
+        None => {
+            let uid = serde_json::Value::String(
+                me.join(
+                    &(folder.clone()
+                        + &num_to_sxg(published.timestamp_millis().try_into().unwrap())),
+                )
+                .unwrap()
+                .to_string(),
+            );
+            body["properties"]["uid"] = serde_json::Value::Array(vec![uid.clone()]);
+            match body["properties"]["url"].as_array_mut() {
+                Some(array) => array.push(uid),
+                None => body["properties"]["url"] = body["properties"]["uid"].clone(),
+            }
+        }
+        Some(uid_str) => {
+            let uid = uid_str.to_string();
+            match body["properties"]["url"].as_array_mut() {
+                Some(array) => {
+                    if !array.iter().any(|i| i.as_str().unwrap_or("") == uid) {
+                        array.push(serde_json::Value::String(uid))
+                    }
+                }
+                None => body["properties"]["url"] = body["properties"]["uid"].clone(),
+            }
+        }
+    }
+    if let Some(slugs) = body["properties"]["mp-slug"].as_array() {
+        let new_urls = slugs
+            .iter()
+            .map(|i| i.as_str().unwrap_or(""))
+            .filter(|i| i != &"")
+            .map(|i| me.join(&((&folder).clone() + i)).unwrap().to_string())
+            .collect::<Vec<String>>();
+        let urls = body["properties"]["url"].as_array_mut().unwrap();
+        new_urls.iter().for_each(|i| urls.push(json!(i)));
+    }
+    let props = body["properties"].as_object_mut().unwrap();
+    props.remove("mp-slug");
+
+    if body["properties"]["content"][0].is_string() {
+        // Convert the content to HTML using the `markdown` crate
+        body["properties"]["content"] = json!([{
+            "html": markdown::to_html(body["properties"]["content"][0].as_str().unwrap()),
+            "value": body["properties"]["content"][0]
+        }])
+    }
+    // TODO: apply this normalization to editing too
+    if body["properties"]["mp-channel"].is_array() {
+        let mut additional_channels = body["properties"]["mp-channel"].as_array().unwrap().clone();
+        if let Some(array) = body["properties"]["channel"].as_array_mut() {
+            array.append(&mut additional_channels);
+        } else {
+            body["properties"]["channel"] = json!(additional_channels)
+        }
+        body["properties"].as_object_mut().unwrap().remove("mp-channel");
+    } else if body["properties"]["mp-channel"].is_string() {
+        let chan = body["properties"]["mp-channel"].as_str().unwrap().to_owned();
+        if let Some(array) = body["properties"]["channel"].as_array_mut() {
+            array.push(json!(chan))
+        } else {
+            body["properties"]["channel"] = json!([chan]);
+        }
+        body["properties"].as_object_mut().unwrap().remove("mp-channel");
+    }
+    if body["properties"]["channel"][0].as_str().is_none() {
+        match body["type"][0].as_str() {
+            Some("h-entry") => {
+                // Set the channel to the main channel...
+                let default_channel = me.join(DEFAULT_CHANNEL_PATH).unwrap().to_string();
+
+                body["properties"]["channel"] = json!([default_channel]);
+            }
+            Some("h-card") => {
+                let default_channel = me.join(CONTACTS_CHANNEL_PATH).unwrap().to_string();
+
+                body["properties"]["channel"] = json!([default_channel]);
+            }
+            Some("h-food") => {
+                let default_channel = me.join(FOOD_CHANNEL_PATH).unwrap().to_string();
+
+                body["properties"]["channel"] = json!([default_channel]);
+            }
+            // TODO h-event
+            /*"h-event" => {
+                let default_channel
+            },*/
+            _ => {
+                body["properties"]["channel"] = json!([]);
+            }
+        }
+    }
+    body["properties"]["posted-with"] = json!([user.client_id]);
+    if body["properties"]["author"][0].as_str().is_none() {
+        body["properties"]["author"] = json!([me.as_str()])
+    }
+    // TODO: maybe highlight #hashtags?
+    // Find other processing to do and insert it here
+    return (
+        body["properties"]["uid"][0].as_str().unwrap().to_string(),
+        body,
+    );
+}
+
+/*pub async fn new_post<S: Storage>(
+    req: Request<ApplicationState<S>>,
+    body: serde_json::Value,
+) -> Result {
+    // First, check for rights.
+    let user = req.ext::<User>().unwrap();
+    let storage = &req.state().storage;
+    if !user.check_scope("create") {
+        return error_json!(
+            401,
+            "invalid_scope",
+            "Not enough privileges to post. Try a token with a \"create\" scope instead."
+        );
+    }
+    let (uid, post) = normalize_mf2(body, user);
+
+    // Security check!
+    // This software might also be used in a multi-user setting
+    // where several users or identities share one Micropub server
+    // (maybe a family website or a shitpost sideblog?)
+    if !post["properties"]["uid"][0]
+        .as_str()
+        .unwrap()
+        .starts_with(user.me.as_str())
+        || post["properties"]["channel"]
+            .as_array()
+            .unwrap()
+            .iter()
+            .any(|url| !url.as_str().unwrap().starts_with(user.me.as_str()))
+    {
+        return error_json!(
+            403,
+            "forbidden",
+            "You're trying to post to someone else's website..."
+        );
+    }
+
+    match storage.post_exists(&uid).await {
+        Ok(exists) => {
+            if exists {
+                return error_json!(
+                    409,
+                    "already_exists",
+                    format!(
+                        "A post with the exact same UID already exists in the database: {}",
+                        uid
+                    )
+                );
+            }
+        }
+        Err(err) => return Ok(err.into()),
+    }
+
+    if let Err(err) = storage.put_post(&post, user.me.as_str()).await {
+        return error_json!(500, "database_error", format!("{}", err));
+    }
+
+    // It makes sense to use a loop here, because you wouldn't post to a hundred channels at once
+    // Mostly one or two, and even those ones will be the ones picked for you by software
+    for channel in post["properties"]["channel"]
+        .as_array()
+        .unwrap()
+        .iter()
+        .map(|i| i.as_str().unwrap_or("").to_string())
+        .filter(|i| !i.is_empty())
+        .collect::<Vec<_>>()
+    {
+        let default_channel = user.me.join(DEFAULT_CHANNEL_PATH).unwrap().to_string();
+        let vcards_channel = user.me.join(CONTACTS_CHANNEL_PATH).unwrap().to_string();
+        let food_channel = user.me.join(FOOD_CHANNEL_PATH).unwrap().to_string();
+        match storage.post_exists(&channel).await {
+            Ok(exists) => {
+                if exists {
+                    if let Err(err) = storage
+                        .update_post(&channel, json!({"add": {"children": [uid]}}))
+                        .await
+                    {
+                        return error_json!(
+                            500,
+                            "database_error",
+                            format!(
+                                "Couldn't insert post into the channel due to a database error: {}",
+                                err
+                            )
+                        );
+                    }
+                } else if channel == default_channel
+                    || channel == vcards_channel
+                    || channel == food_channel
+                {
+                    if let Err(err) = create_feed(storage, &uid, &channel, user).await {
+                        return error_json!(
+                            500,
+                            "database_error",
+                            format!("Couldn't save feed: {}", err)
+                        );
+                    }
+                } else {
+                    warn!(
+                        "Ignoring request to post to a non-existent feed: {}",
+                        channel
+                    );
+                }
+            }
+            Err(err) => return error_json!(500, "database_error", err),
+        }
+    }
+    // END WRITE BOUNDARY
+
+    // do background processing on the post
+    async_std::task::spawn(post_process_new_post(req, post));
+
+    Ok(Response::builder(202)
+        .header("Location", &uid)
+        .body(json!({"status": "accepted", "location": &uid}))
+        .build())
+}*/
+
+pub(crate) async fn create_feed(
+    storage: &impl Storage,
+    uid: &str,
+    channel: &str,
+    user: &User,
+) -> crate::database::Result<()> {
+    let path = url::Url::parse(channel).unwrap().path().to_string();
+
+    // Note to Future Vika: DO NOT CONVERT THIS TO A MATCH BLOCK
+    // It will get treated as a binding instead of a const
+    // See `rustc --explain E0530` for more info
+    let name = if path == DEFAULT_CHANNEL_PATH {
+        DEFAULT_CHANNEL_NAME
+    } else if path == CONTACTS_CHANNEL_PATH {
+        CONTACTS_CHANNEL_NAME
+    } else if path == FOOD_CHANNEL_PATH {
+        FOOD_CHANNEL_NAME
+    } else {
+        panic!("Tried to create an unknown default feed!")
+    };
+
+    let (_, feed) = normalize_mf2(
+        json!({
+            "type": ["h-feed"],
+            "properties": {
+                "name": [name],
+                "uid": [channel]
+            },
+            "children": [uid]
+        }),
+        user,
+    );
+    storage.put_post(&feed, user.me.as_str()).await
+}
+
+/*async fn post_process_new_post<S: Storage>(
+    req: Request<ApplicationState<S>>,
+    post: serde_json::Value,
+) {
+    // TODO: Post-processing the post (aka second write pass)
+    // - [-] Download rich reply contexts
+    // - [-] Syndicate the post if requested, add links to the syndicated copies
+    // - [ ] Send WebSub notifications to the hub (if we happen to have one)
+    // - [x] Send webmentions
+    let http = &req.state().http_client;
+    let uid = post["properties"]["uid"][0].as_str().unwrap().to_string();
+    // 1. Download rich reply contexts
+    //    This needs to be done first, because at this step we can also determine webmention endpoints
+    //    and save them for later use. Additionally, the richer our content is, the better.
+    //    This needs to be done asynchronously, so the posting experience for the author will be as fast
+    //    as possible without making them wait for potentially slow downstream websites to load
+    // 1.1. Collect the list of contextually-significant post to load context from.
+    //      This will include reply-tos, liked, reposted and bookmarked content
+    //
+    //      TODO: Fetch links mentioned in a post, since we need to send webmentions to those as mentions
+    let mut contextually_significant_posts: Vec<surf::Url> = vec![];
+    for prop in &["in-reply-to", "like-of", "repost-of", "bookmark-of"] {
+        if let Some(array) = post["properties"][prop].as_array() {
+            contextually_significant_posts.extend(
+                array
+                    .iter()
+                    .filter_map(|v| v.as_str().and_then(|v| surf::Url::parse(v).ok())),
+            );
+        }
+    }
+    // 1.2. Deduplicate the list
+    contextually_significant_posts.sort_unstable();
+    contextually_significant_posts.dedup();
+
+    // 1.3. Fetch the posts with their bodies and save them in a new Vec<(surf::Url, String)>
+    let posts_with_bodies: Vec<(surf::Url, surf::Response, String)> =
+        stream::iter(contextually_significant_posts.into_iter())
+            .filter_map(|v: surf::Url| async move {
+                if let Ok(res) = http.get(&v).send().await {
+                    if res.status() != 200 {
+                        None
+                    } else {
+                        Some((v, res))
+                    }
+                } else {
+                    None
+                }
+            })
+            .filter_map(|(v, mut res): (surf::Url, surf::Response)| async move {
+                if let Ok(body) = res.body_string().await {
+                    Some((v, res, body))
+                } else {
+                    None
+                }
+            })
+            .collect()
+            .await;
+    // 1.4. Parse the bodies and include them in relevant places on the MF2 struct
+    //      This requires an MF2 parser, and there are none for Rust at the moment.
+    //
+    // TODO: integrate https://gitlab.com/vikanezrimaya/mf2-parser when it's ready
+
+    // 2. Syndicate the post
+    let syndicated_copies: Vec<serde_json::Value>;
+    if let Some(syndication_targets) = post["properties"]["syndicate-to"].as_array() {
+        syndicated_copies = stream::iter(
+            syndication_targets
+                .iter()
+                .filter_map(|v| v.as_str())
+                .filter_map(|t| surf::Url::parse(t).ok())
+                .collect::<Vec<_>>()
+                .into_iter()
+                .map(|_t: surf::Url| async move {
+                    // TODO: Define supported syndication methods
+                    // and syndicate the endpoint there
+                    // Possible ideas:
+                    //  - indieweb.xyz (might need a lot of space for the buttons though, investigate proposing grouping syndication targets)
+                    //  - news.indieweb.org (IndieNews - needs a category linking to #indienews)
+                    //  - Twitter via brid.gy (do I really need Twitter syndication tho?)
+                    if false {
+                        Some("")
+                    } else {
+                        None
+                    }
+                }),
+        )
+        .buffer_unordered(3)
+        .filter_map(|v| async move { v })
+        .map(|v| serde_json::Value::String(v.to_string()))
+        .collect::<Vec<_>>()
+        .await;
+    } else {
+        syndicated_copies = vec![]
+    }
+    // Save the post a second time here after syndication
+    // We use update_post here to prevent race conditions since its required to be atomic
+    let mut update = json!({
+        "action": "update",
+        "url": &uid
+    });
+    if !syndicated_copies.is_empty() {
+        update["add"] = json!({});
+        update["add"]["syndication"] = serde_json::Value::Array(syndicated_copies);
+    }
+    if !posts_with_bodies.is_empty() {
+        error!("Replacing context links with parsed MF2-JSON data is not yet implemented (but it's ok! it'll just be less pretty)")
+        /* TODO: Replace context links with parsed MF2-JSON data * /
+        update["replace"] = {}
+        update["replace"]["like-of"] = []
+        update["replace"]["in-reply-to"] = []
+        update["replace"]["bookmark-of"] = []
+        update["replace"]["repost-of"] = []
+        // */
+    }
+    // We don't need the original copy of the post anymore... I hope!
+    // This will act as a safeguard so I can't read stale data by accident anymore...
+    drop(post);
+    if let Err(err) = req.state().storage.update_post(&uid, update).await {
+        error!("Encountered error while post-processing a post: {}", err)
+        // At this point, we can still continue, we just won't have rich data for the post
+        // I wonder why could it even happen except in case of a database disconnection?
+    }
+    // 3. Send WebSub notifications
+    // TODO WebSub support
+
+    // 4. Send webmentions
+    //    We'll need the bodies here to get their endpoints
+    let source = &uid;
+    stream::iter(posts_with_bodies.into_iter())
+        .filter_map(
+            |(url, response, body): (surf::Url, surf::Response, String)| async move {
+                // Check Link headers first
+                // the first webmention endpoint will be returned
+                if let Some(values) = response.header("Link") {
+                    let iter = values.iter().flat_map(|i| i.as_str().split(','));
+
+                    // Honestly I don't like this parser. It's very crude.
+                    // But it should do the job. But I don't like it.
+                    for link in iter {
+                        let mut split = link.split(';');
+
+                        match split.next() {
+                            Some(uri) => {
+                                if let Some(uri) = uri.strip_prefix('<') {
+                                    if let Some(uri) = uri.strip_suffix('>') {
+                                        for prop in split {
+                                            let lowercased = prop.to_ascii_lowercase();
+                                            if &lowercased == "rel=\"webmention\""
+                                                || &lowercased == "rel=webmention"
+                                            {
+                                                if let Ok(endpoint) = url.join(uri) {
+                                                    return Some((url, endpoint));
+                                                }
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                            None => continue,
+                        }
+                    }
+                }
+                // TODO: Replace this function once the MF2 parser is ready
+                // A compliant parser's output format includes rels,
+                // we could just find a Webmention one in there
+                let pattern =
+                    easy_scraper::Pattern::new(r#"<link href="{{url}}" rel="webmention">"#)
+                        .expect("Pattern for webmentions couldn't be parsed");
+                let matches = pattern.matches(&body);
+                if matches.is_empty() {
+                    return None;
+                }
+                let endpoint = &matches[0]["url"];
+                if let Ok(endpoint) = url.join(endpoint) {
+                    Some((url, endpoint))
+                } else {
+                    None
+                }
+            },
+        )
+        .map(|(target, endpoint)| async move {
+            info!(
+                "Sending webmention to {} about {}",
+                source,
+                &target.to_string()
+            );
+            let response = http
+                .post(&endpoint)
+                .content_type("application/x-www-form-urlencoded")
+                .body(
+                    serde_urlencoded::to_string(vec![
+                        ("source", source),
+                        ("target", &target.to_string()),
+                    ])
+                    .expect("Couldn't construct webmention form"),
+                )
+                .send()
+                .await;
+            match response {
+                Ok(response) => {
+                    if response.status() == 200
+                        || response.status() == 201
+                        || response.status() == 202
+                    {
+                        info!("Sent webmention for {} to {}", target, endpoint);
+                        Ok(())
+                    } else {
+                        error!(
+                            "Sending webmention for {} to {} failed: Endpoint replied with HTTP {}",
+                            target,
+                            endpoint,
+                            response.status()
+                        );
+                        Err(())
+                    }
+                }
+                Err(err) => {
+                    error!(
+                        "Sending webmention for {} to {} failed: {}",
+                        target, endpoint, err
+                    );
+                    Err(())
+                }
+            }
+        })
+        .buffer_unordered(3)
+        .collect::<Vec<_>>()
+        .await;
+}*/
+
+/*async fn process_json<S: Storage>(
+    req: Request<ApplicationState<S>>,
+    body: serde_json::Value,
+) -> Result {
+    let is_action = body["action"].is_string() && body["url"].is_string();
+    if is_action {
+        // This could be an update, a deletion or an undeletion request.
+        // Process it separately.
+        let action = body["action"].as_str().unwrap();
+        let url = body["url"].as_str().unwrap();
+        let user = req.ext::<User>().unwrap();
+        match action {
+            "delete" => {
+                if !user.check_scope("delete") {
+                    return error_json!(
+                        401,
+                        "insufficient_scope",
+                        "You need a `delete` scope to delete posts."
+                    );
+                }
+                // This special scope is not available through a token endpoint, since the
+                // authorization endpoint is supposed to reject any auth request trying to get this
+                // scope. It is intended for TRUSTED external services that need to modify the
+                // database while ignoring any access controls
+                if (url::Url::parse(url)?.origin().ascii_serialization() + "/") != user.me.as_str()
+                    && !user.check_scope("kittybox_internal:do_what_thou_wilt")
+                {
+                    return error_json!(
+                        403,
+                        "forbidden",
+                        "You're not allowed to delete someone else's posts."
+                    );
+                }
+                if let Err(error) = req.state().storage.delete_post(url).await {
+                    return Ok(error.into());
+                }
+                Ok(Response::builder(200).build())
+            }
+            "update" => {
+                if !user.check_scope("update") {
+                    return error_json!(
+                        401,
+                        "insufficient_scope",
+                        "You need an `update` scope to update posts."
+                    );
+                }
+                if (url::Url::parse(url)?.origin().ascii_serialization() + "/") != user.me.as_str()
+                    && !user.check_scope("kittybox_internal:do_what_thou_wilt")
+                {
+                    return error_json!(
+                        403,
+                        "forbidden",
+                        "You're not allowed to delete someone else's posts."
+                    );
+                }
+                if let Err(error) = req.state().storage.update_post(url, body.clone()).await {
+                    Ok(error.into())
+                } else {
+                    Ok(Response::builder(204).build())
+                }
+            }
+            _ => return error_json!(400, "invalid_request", "This action is not supported."),
+        }
+    } else if body["type"][0].is_string() {
+        // This is definitely an h-entry or something similar. Check if it has properties?
+        if body["properties"].is_object() {
+            // Ok, this is definitely a new h-entry. Let's save it.
+            return new_post(req, body).await;
+        } else {
+            return error_json!(
+                400,
+                "invalid_request",
+                "This MF2-JSON object has a type, but not properties. This makes no sense to post."
+            );
+        }
+    } else {
+        return error_json!(
+            400,
+            "invalid_request",
+            "Try sending MF2-structured data or an object with an \"action\" and \"url\" keys."
+        );
+    }
+}*/
+
+/*async fn process_form<S: Storage>(
+    req: Request<ApplicationState<S>>,
+    form: Vec<(String, String)>,
+) -> Result {
+    if let Some((_, v)) = form.iter().find(|(k, _)| k == "action") {
+        if v == "delete" {
+            let user = req.ext::<User>().unwrap();
+            if !user.check_scope("delete") {
+                return error_json!(
+                    401,
+                    "insufficient_scope",
+                    "You cannot delete posts without a `delete` scope."
+                );
+            }
+            match form.iter().find(|(k, _)| k == "url") {
+                Some((_, url)) => {
+                    if (url::Url::parse(url)?.origin().ascii_serialization() + "/")
+                        != user.me.as_str()
+                        && !user.check_scope("kittybox_internal:do_what_thou_wilt")
+                    {
+                        return error_json!(
+                            403,
+                            "forbidden",
+                            "You're not allowed to delete someone else's posts."
+                        );
+                    }
+                    if let Err(error) = req.state().storage.delete_post(url).await {
+                        return error_json!(500, "database_error", error);
+                    }
+                    return Ok(Response::builder(200).build());
+                }
+                None => {
+                    return error_json!(
+                        400,
+                        "invalid_request",
+                        "Please provide an `url` to delete."
+                    )
+                }
+            }
+        } else {
+            return error_json!(400, "invalid_request", "This action is not supported in form-encoded mode. (JSON requests support more actions, use JSON!)");
+        }
+    }
+
+    let mf2 = convert_form_to_mf2_json(form);
+
+    if mf2["properties"].as_object().unwrap().keys().len() > 0 {
+        return new_post(req, mf2).await;
+    }
+    return error_json!(
+        400,
+        "invalid_request",
+        "Try sending h=entry&content=something%20interesting"
+    );
+}*/
+
+/*pub async fn post_handler<S: Storage>(mut req: Request<ApplicationState<S>>) -> Result {
+    match req.content_type() {
+        Some(value) => {
+            if value == Mime::from_str("application/json").unwrap() {
+                match req.body_json::<serde_json::Value>().await {
+                    Ok(parsed) => return process_json(req, parsed).await,
+                    Err(err) => {
+                        return error_json!(
+                            400,
+                            "invalid_request",
+                            format!("Parsing JSON failed: {:?}", err)
+                        )
+                    }
+                }
+            } else if value == Mime::from_str("application/x-www-form-urlencoded").unwrap() {
+                match req.body_form::<Vec<(String, String)>>().await {
+                    Ok(parsed) => return process_form(req, parsed).await,
+                    Err(err) => {
+                        return error_json!(
+                            400,
+                            "invalid_request",
+                            format!("Parsing form failed: {:?}", err)
+                        )
+                    }
+                }
+            } else {
+                return error_json!(
+                    415, "unsupported_media_type",
+                    "What's this? Try sending JSON instead. (urlencoded form also works but is less cute)"
+                );
+            }
+        }
+        _ => {
+            return error_json!(
+                415, "unsupported_media_type",
+                "You didn't send a Content-Type header, so we don't know how to parse your request."
+            );
+        }
+    }
+}*/
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_no_replace_uid() {
+        let mf2 = json!({
+            "type": ["h-card"],
+            "properties": {
+                "uid": ["https://fireburn.ru/"],
+                "name": ["Vika Nezrimaya"],
+                "note": ["A crazy programmer girl who wants some hugs"]
+            }
+        });
+
+        let (uid, normalized) = normalize_mf2(
+            mf2.clone(),
+            &User::new(
+                "https://fireburn.ru/",
+                "https://quill.p3k.io/",
+                "create update media",
+            ),
+        );
+        assert_eq!(
+            normalized["properties"]["uid"][0], mf2["properties"]["uid"][0],
+            "UID was replaced"
+        );
+        assert_eq!(
+            normalized["properties"]["uid"][0], uid,
+            "Returned post location doesn't match UID"
+        );
+    }
+
+    #[test]
+    fn test_mp_channel() {
+        let mf2 = json!({
+            "type": ["h-entry"],
+            "properties": {
+                "uid": ["https://fireburn.ru/posts/test"],
+                "content": [{"html": "<p>Hello world!</p>"}],
+                "mp-channel": ["https://fireburn.ru/feeds/test"]
+            }
+        });
+
+        let (_, normalized) = normalize_mf2(
+            mf2.clone(),
+            &User::new(
+                "https://fireburn.ru/",
+                "https://quill.p3k.io/",
+                "create update media",
+            )
+        );
+
+        assert_eq!(
+            normalized["properties"]["channel"],
+            mf2["properties"]["mp-channel"]
+        );
+    }
+
+    #[test]
+    fn test_mp_channel_as_string() {
+        let mf2 = json!({
+            "type": ["h-entry"],
+            "properties": {
+                "uid": ["https://fireburn.ru/posts/test"],
+                "content": [{"html": "<p>Hello world!</p>"}],
+                "mp-channel": "https://fireburn.ru/feeds/test"
+            }
+        });
+
+        let (_, normalized) = normalize_mf2(
+            mf2.clone(),
+            &User::new(
+                "https://fireburn.ru/",
+                "https://quill.p3k.io/",
+                "create update media",
+            )
+        );
+
+        assert_eq!(
+            normalized["properties"]["channel"][0],
+            mf2["properties"]["mp-channel"]
+        );
+    }
+
+    #[test]
+    fn test_normalize_mf2() {
+        let mf2 = json!({
+            "type": ["h-entry"],
+            "properties": {
+                "content": ["This is content!"]
+            }
+        });
+
+        let (uid, post) = normalize_mf2(
+            mf2,
+            &User::new(
+                "https://fireburn.ru/",
+                "https://quill.p3k.io/",
+                "create update media",
+            ),
+        );
+        assert_eq!(
+            post["properties"]["published"]
+                .as_array()
+                .expect("post['published'] is undefined")
+                .len(),
+            1,
+            "Post doesn't have a published time"
+        );
+        DateTime::parse_from_rfc3339(post["properties"]["published"][0].as_str().unwrap())
+            .expect("Couldn't parse date from rfc3339");
+        assert!(
+            !post["properties"]["url"]
+                .as_array()
+                .expect("post['url'] is undefined")
+                .is_empty(),
+            "Post doesn't have any URLs"
+        );
+        assert_eq!(
+            post["properties"]["uid"]
+                .as_array()
+                .expect("post['uid'] is undefined")
+                .len(),
+            1,
+            "Post doesn't have a single UID"
+        );
+        assert_eq!(
+            post["properties"]["uid"][0], uid,
+            "UID of a post and its supposed location don't match"
+        );
+        assert!(
+            uid.starts_with("https://fireburn.ru/posts/"),
+            "The post namespace is incorrect"
+        );
+        assert_eq!(
+            post["properties"]["content"][0]["html"]
+                .as_str()
+                .expect("Post doesn't have a rich content object")
+                .trim(),
+            "<p>This is content!</p>",
+            "Parsed Markdown content doesn't match expected HTML"
+        );
+        assert_eq!(
+            post["properties"]["channel"][0], "https://fireburn.ru/feeds/main",
+            "Post isn't posted to the main channel"
+        );
+        assert_eq!(
+            post["properties"]["author"][0], "https://fireburn.ru/",
+            "Post author is unknown"
+        );
+    }
+
+    #[test]
+    fn test_mp_slug() {
+        let mf2 = json!({
+            "type": ["h-entry"],
+            "properties": {
+                "content": ["This is content!"],
+                "mp-slug": ["hello-post"]
+            },
+        });
+
+        let (_, post) = normalize_mf2(
+            mf2,
+            &User::new(
+                "https://fireburn.ru/",
+                "https://quill.p3k.io/",
+                "create update media",
+            ),
+        );
+        assert!(
+            post["properties"]["url"]
+                .as_array()
+                .unwrap()
+                .iter()
+                .map(|i| i.as_str().unwrap())
+                .any(|i| i == "https://fireburn.ru/posts/hello-post"),
+            "Didn't found an URL pointing to the location expected by the mp-slug semantics"
+        );
+        assert!(
+            post["properties"]["mp-slug"].as_array().is_none(),
+            "mp-slug wasn't deleted from the array!"
+        )
+    }
+
+    #[test]
+    fn test_normalize_feed() {
+        let mf2 = json!({
+            "type": ["h-feed"],
+            "properties": {
+                "name": "Main feed",
+                "mp-slug": ["main"]
+            }
+        });
+
+        let (uid, post) = normalize_mf2(
+            mf2,
+            &User::new(
+                "https://fireburn.ru/",
+                "https://quill.p3k.io/",
+                "create update media",
+            ),
+        );
+        assert_eq!(
+            post["properties"]["uid"][0], uid,
+            "UID of a post and its supposed location don't match"
+        );
+        assert_eq!(post["properties"]["author"][0], "https://fireburn.ru/");
+        assert!(
+            post["properties"]["url"]
+                .as_array()
+                .unwrap()
+                .iter()
+                .map(|i| i.as_str().unwrap())
+                .any(|i| i == "https://fireburn.ru/feeds/main"),
+            "Didn't found an URL pointing to the location expected by the mp-slug semantics"
+        );
+        assert!(
+            post["properties"]["mp-slug"].as_array().is_none(),
+            "mp-slug wasn't deleted from the array!"
+        )
+    }
+}