about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/database/file/mod.rs2
-rw-r--r--src/frontend/login.rs292
-rw-r--r--src/frontend/mod.rs26
-rw-r--r--src/frontend/templates/mod.rs43
-rw-r--r--src/lib.rs20
-rw-r--r--src/main.rs19
-rw-r--r--src/micropub/post.rs2
7 files changed, 387 insertions, 17 deletions
diff --git a/src/database/file/mod.rs b/src/database/file/mod.rs
index 4fb7f47..d556f46 100644
--- a/src/database/file/mod.rs
+++ b/src/database/file/mod.rs
@@ -482,11 +482,11 @@ impl Storage for FileStorage {
                         // Hack to unwrap the Option and sieve out broken links
                         // Broken links return None, and Stream::filter_map skips Nones.
                         .try_filter_map(|post: Option<serde_json::Value>| async move { Ok(post) })
+                        .try_filter_map(|post| async move { Ok(filter_post(post, user)) })
                         .and_then(|mut post| async move {
                             hydrate_author(&mut post, user, self).await;
                             Ok(post)
                         })
-                        .try_filter_map(|post| async move { Ok(filter_post(post, user)) })
                         .take(limit);
 
                     match posts.try_collect::<Vec<serde_json::Value>>().await {
diff --git a/src/frontend/login.rs b/src/frontend/login.rs
new file mode 100644
index 0000000..09fa75f
--- /dev/null
+++ b/src/frontend/login.rs
@@ -0,0 +1,292 @@
+use std::convert::TryInto;
+use std::str::FromStr;
+use log::{debug, error};
+use rand::Rng;
+use http_types::Mime;
+use tide::{Request, Response, Result};
+use serde::{Serialize, Deserialize};
+use sha2::{Sha256, Digest};
+
+use crate::frontend::{FrontendError, IndiewebEndpoints};
+use crate::{ApplicationState, database::Storage};
+use crate::frontend::templates::Template;
+
+markup::define! {
+    LoginPage {
+        form[method="POST"] {
+            h1 { "Sign in with your website" }
+            p {
+                "Signing in to Kittybox might allow you to view private content "
+                    "intended for your eyes only."
+            }
+
+            section {
+                label[for="url"] { "Your website URL" }
+                input[id="url", name="url", placeholder="https://example.com/"];
+                input[type="submit"];
+            }
+        }
+    }
+}
+
+pub async fn form<S: Storage>(req: Request<ApplicationState<S>>) -> Result {
+    let owner = req.url().origin().ascii_serialization() + "/";
+    let storage = &req.state().storage;
+    let authorization_endpoint = req.state().authorization_endpoint.to_string();
+    let token_endpoint = req.state().token_endpoint.to_string();
+    let blog_name = storage.get_setting("site_name", &owner).await.unwrap_or_else(|_| "Kitty Box!".to_string());
+    let feeds = storage.get_channels(&owner).await.unwrap_or_default();
+
+    Ok(Response::builder(200)
+       .body(Template {
+           title: "Sign in with IndieAuth",
+           blog_name: &blog_name,
+           endpoints: IndiewebEndpoints {
+               authorization_endpoint,
+               token_endpoint,
+               webmention: None,
+               microsub: None
+           },
+           feeds,
+           user: req.session().get("user"),
+           content: LoginPage {}.to_string(),
+       }.to_string())
+       .content_type("text/html; charset=utf-8")
+       .build())
+}
+
+#[derive(Serialize, Deserialize)]
+struct LoginForm {
+    url: String
+}
+
+#[derive(Serialize, Deserialize)]
+struct IndieAuthClientState {
+    /// A random value to protect from CSRF attacks.
+    nonce: String,
+    /// The user's initial "me" value.
+    me: String,
+    /// Authorization endpoint used.
+    authorization_endpoint: String
+}
+
+
+#[derive(Serialize, Deserialize)]
+struct IndieAuthRequestParams {
+    response_type: String,         // can only have "code". TODO make an enum
+    client_id: String,             // always a URL. TODO consider making a URL
+    redirect_uri: surf::Url,       // callback URI for IndieAuth
+    state: String,                 // CSRF protection, should include randomness and be passed through
+    code_challenge: String,        // base64-encoded PKCE challenge
+    code_challenge_method: String, // usually "S256". TODO make an enum
+    scope: Option<String>,         // oAuth2 scopes to grant,
+    me: surf::Url,                 // User's entered profile URL
+    
+}
+
+/// Handle login requests. Find the IndieAuth authorization endpoint and redirect to it.
+pub async fn handler<S: Storage>(mut req: Request<ApplicationState<S>>) -> Result {
+    let content_type = req.content_type();
+    if content_type.is_none() {
+        return Err(FrontendError::with_code(400, "Use the login form, Luke.").into());
+    }
+    if content_type.unwrap() != Mime::from_str("application/x-www-form-urlencoded").unwrap() {
+        return Err(FrontendError::with_code(400, "Login form results must be a urlencoded form").into());
+    }
+
+    let form = req.body_form::<LoginForm>().await?; // FIXME check if it returns 400 or 500 on error
+    let homepage_uri = surf::Url::parse(&form.url)?;
+    let http = &req.state().http_client;
+    
+    let mut fetch_response = http.get(&homepage_uri).send().await?;
+    if fetch_response.status() != 200 {
+        return Err(FrontendError::with_code(500, "Error fetching your authorization endpoint. Check if your website's okay.").into());
+    }
+
+    let mut authorization_endpoint: Option<surf::Url> = None;
+    if let Some(links) = fetch_response.header("Link") {
+        // NOTE: this is the same Link header parser used in src/micropub/post.rs:459.
+        // One should refactor it to a function to use independently and improve later
+        for link in links.iter().flat_map(|i| i.as_str().split(',')) {
+            debug!("Trying to match {} as authorization_endpoint", link);
+            let mut split_link = link.split(';');
+
+            match split_link.next() {
+                Some(uri) => {
+                    if let Some(uri) = uri.strip_prefix('<').and_then(|uri| uri.strip_suffix('>')) {
+                        debug!("uri: {}", uri);
+                        for prop in split_link {
+                            debug!("prop: {}", prop);
+                            let lowercased = prop.to_ascii_lowercase();
+                            let trimmed = lowercased.trim();
+                            if trimmed == "rel=\"authorization_endpoint\""
+                                || trimmed == "rel=authorization_endpoint"
+                            {
+                                if let Ok(endpoint) = homepage_uri.join(uri) {
+                                    debug!("Found authorization endpoint {} for user {}", endpoint, homepage_uri.as_str());
+                                    authorization_endpoint = Some(endpoint);
+                                    break;
+                                }
+                            }
+                        }
+                    }
+                },
+                None => continue,
+            }
+        }
+    }
+    // If the authorization_endpoint is still not found after the Link parsing gauntlet,
+    // bring out the big guns and parse HTML to find it.
+    if authorization_endpoint.is_none() {
+        let body = fetch_response.body_string().await?;
+        let pattern = easy_scraper::Pattern::new(r#"<link rel="authorization_endpoint" href="{{url}}">"#)
+            .expect("Cannot parse the pattern for authorization_endpoint");
+        let matches = pattern.matches(&body);
+        debug!("Matches for authorization_endpoint in HTML: {:?}", matches);
+        if !matches.is_empty() {
+            if let Ok(endpoint) = homepage_uri.join(&matches[0]["url"]) {
+                debug!("Found authorization endpoint {} for user {}", endpoint, homepage_uri.as_str());
+                authorization_endpoint = Some(endpoint)
+            }
+        }
+    };
+    // If even after this the authorization endpoint is still not found, bail out.
+    if authorization_endpoint.is_none() {
+        error!("Couldn't find authorization_endpoint for {}", homepage_uri.as_str());
+        return Err(FrontendError::with_code(400, "Your website doesn't support the IndieAuth protocol.").into());
+    }
+    let mut authorization_endpoint: surf::Url = authorization_endpoint.unwrap();
+    let mut rng = rand::thread_rng();
+    let state: String = data_encoding::BASE64URL.encode(serde_urlencoded::to_string(IndieAuthClientState {
+        nonce: (0..8).map(|_| {
+            let idx = rng.gen_range(0..INDIEAUTH_PKCE_CHARSET.len());
+            INDIEAUTH_PKCE_CHARSET[idx] as char
+        }).collect(),
+        me: homepage_uri.to_string(),
+        authorization_endpoint: authorization_endpoint.to_string()
+    })?.as_bytes());
+    // PKCE code generation
+    let code_verifier: String = (0..128).map(|_| {
+        let idx = rng.gen_range(0..INDIEAUTH_PKCE_CHARSET.len());
+        INDIEAUTH_PKCE_CHARSET[idx] as char
+    }).collect();
+    let mut hasher = Sha256::new();
+    hasher.update(code_verifier.as_bytes());
+    let code_challenge: String = data_encoding::BASE64URL.encode(&hasher.finalize());
+
+    authorization_endpoint.set_query(Some(&serde_urlencoded::to_string(IndieAuthRequestParams {
+        response_type: "code".to_string(),
+        client_id: req.url().origin().ascii_serialization(),
+        redirect_uri: req.url().join("login/callback")?,
+        state: state.clone(), code_challenge,
+        code_challenge_method: "S256".to_string(),
+        scope: Some("profile".to_string()),
+        me: homepage_uri,
+    })?));
+
+    let cookies = vec![
+        format!(r#"indieauth_state="{}"; Same-Site: None; Secure; Max-Age: 600"#, state),
+        format!(r#"indieauth_code_verifier="{}"; Same-Site: None; Secure; Max-Age: 600"#, code_verifier)
+    ];
+
+    let cookie_header = cookies.iter().map(|i| -> http_types::headers::HeaderValue {
+        (i as &str).try_into().unwrap()
+    }).collect::<Vec<_>>();
+    
+    Ok(Response::builder(302)
+       .header("Location", authorization_endpoint.to_string())
+       .header("Set-Cookie", &*cookie_header)
+       .build())
+}
+
+const INDIEAUTH_PKCE_CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\
+                                        abcdefghijklmnopqrstuvwxyz\
+                                        1234567890-._~";
+
+#[derive(Deserialize)]
+struct IndieAuthCallbackResponse {
+    code: Option<String>,
+    error: Option<String>,
+    error_description: Option<String>,
+    #[allow(dead_code)]
+    error_uri: Option<String>,
+    // This needs to be further decoded to receive state back and will always be present
+    state: String
+}
+
+impl IndieAuthCallbackResponse {
+    fn is_successful(&self) -> bool {
+        !self.code.is_none()
+    }
+}
+
+#[derive(Serialize, Deserialize)]
+struct IndieAuthCodeRedeem {
+    grant_type: String,
+    code: String,
+    client_id: String,
+    redirect_uri: String,
+    code_verifier: String
+}
+
+#[derive(Serialize, Deserialize)]
+struct IndieWebProfile {
+    name: Option<String>,
+    url: Option<String>,
+    email: Option<String>,
+    photo: Option<String>
+}
+
+#[derive(Serialize, Deserialize)]
+struct IndieAuthResponse {
+    me: String,
+    scope: Option<String>,
+    access_token: Option<String>,
+    token_type: Option<String>,
+    profile: Option<IndieWebProfile>
+}
+
+/// Handle IndieAuth parameters, fetch the final h-card and redirect the user to the homepage.
+pub async fn callback<S: Storage>(mut req: Request<ApplicationState<S>>) -> Result {
+    let params: IndieAuthCallbackResponse = req.query()?;
+    let http: &surf::Client = &req.state().http_client;
+    let origin = req.url().origin().ascii_serialization();
+
+    if req.cookie("indieauth_state").unwrap().value() != &params.state {
+        return Err(FrontendError::with_code(400, "The state doesn't match. A possible CSRF attack was prevented. Please try again later.").into());
+    }
+    let state: IndieAuthClientState = serde_urlencoded::from_bytes(
+        &data_encoding::BASE64URL.decode(params.state.as_bytes())?
+    )?;
+
+    if !params.is_successful() {
+        return Err(FrontendError::with_code(400, &format!("The authorization endpoint indicated a following error: {:?}: {:?}", &params.error, &params.error_description)).into())
+    }
+
+    let authorization_endpoint = surf::Url::parse(&state.authorization_endpoint).unwrap();
+    let mut code_response = http.post(authorization_endpoint)
+        .body_string(serde_urlencoded::to_string(IndieAuthCodeRedeem {
+            grant_type: "authorization_code".to_string(),
+            code: params.code.unwrap().to_string(),
+            client_id: origin.to_string(),
+            redirect_uri: origin + "/login/callback",
+            code_verifier: req.cookie("indieauth_code_verifier").unwrap().value().to_string()
+        })?)
+        .header("Content-Type", "application/x-www-form-urlencoded")
+        .header("Accept", "application/json")
+        .send().await?;
+
+    if code_response.status() != 200 {
+        return Err(FrontendError::with_code(code_response.status(), &format!("Authorization endpoint returned an error when redeeming the code: {}", code_response.body_string().await?)).into());
+    }
+
+    let json: IndieAuthResponse = code_response.body_json().await?;
+    drop(http);
+    let session = req.session_mut();
+    session.insert("user", &json.me)?;
+
+    // TODO redirect to the page user came from
+    Ok(Response::builder(302)
+       .header("Location", "/")
+       .build())
+}
diff --git a/src/frontend/mod.rs b/src/frontend/mod.rs
index 5426f7e..76114c5 100644
--- a/src/frontend/mod.rs
+++ b/src/frontend/mod.rs
@@ -1,3 +1,5 @@
+use std::convert::TryInto;
+
 use crate::database::Storage;
 use crate::ApplicationState;
 use log::{error, info};
@@ -6,8 +8,9 @@ use tide::{Next, Request, Response, Result, StatusCode};
 
 static POSTS_PER_PAGE: usize = 20;
 
-mod templates;
+pub mod login;
 
+mod templates;
 use templates::{ErrorPage, MainPage, OnboardingPage, Template};
 
 #[derive(Clone, Serialize, Deserialize)]
@@ -30,11 +33,11 @@ struct FrontendError {
     code: StatusCode,
 }
 impl FrontendError {
-    pub fn with_code(code: StatusCode, msg: &str) -> Self {
+    pub fn with_code<C>(code: C, msg: &str) -> Self where C: TryInto<StatusCode> {
         Self {
             msg: msg.to_string(),
             source: None,
-            code,
+            code: code.try_into().unwrap_or_else(|_| StatusCode::InternalServerError),
         }
     }
     pub fn msg(&self) -> &str {
@@ -227,7 +230,7 @@ pub async fn mainpage<S: Storage>(mut req: Request<ApplicationState<S>>) -> Resu
     let query = req.query::<QueryParams>()?;
     let authorization_endpoint = req.state().authorization_endpoint.to_string();
     let token_endpoint = req.state().token_endpoint.to_string();
-    let user: Option<String> = None;
+    let user: Option<String> = req.session().get("user");
 
     #[cfg(any(not(debug_assertions), test))]
     let url = req.url();
@@ -260,6 +263,7 @@ pub async fn mainpage<S: Storage>(mut req: Request<ApplicationState<S>>) -> Resu
                             microsub: None,
                         },
                         feeds: Vec::default(),
+                        user: None,
                         content: OnboardingPage {}.to_string(),
                     }
                     .to_string(),
@@ -288,6 +292,7 @@ pub async fn mainpage<S: Storage>(mut req: Request<ApplicationState<S>>) -> Resu
                         .get_channels(hcard_url)
                         .await
                         .unwrap_or_else(|_| Vec::default()),
+                    user,
                     content: MainPage {
                         feed: &feed?,
                         card: &card?,
@@ -304,7 +309,7 @@ pub async fn render_post<S: Storage>(mut req: Request<ApplicationState<S>>) -> R
     let query = req.query::<QueryParams>()?;
     let authorization_endpoint = req.state().authorization_endpoint.to_string();
     let token_endpoint = req.state().token_endpoint.to_string();
-    let user: Option<String> = None;
+    let user: Option<String> = req.session().get("user");
 
     // This cannot error out as the URL must be valid. Or there is something horribly wrong
     // and we shouldn't serve this request anyway.
@@ -381,6 +386,7 @@ pub async fn render_post<S: Storage>(mut req: Request<ApplicationState<S>>) -> R
                     .get_channels(&owner)
                     .await
                     .unwrap_or_else(|_| Vec::default()),
+                user,
                 content: template,
             }
             .to_string(),
@@ -415,11 +421,16 @@ where
             .get_channels(&owner)
             .await
             .unwrap_or_else(|_| Vec::default());
+        let user: Option<String> = request.session().get("user");
         let mut res = next.run(request).await;
         let mut code: Option<StatusCode> = None;
+        let mut msg: Option<String> = None;
         if let Some(err) = res.downcast_error::<FrontendError>() {
             code = Some(err.code());
             error!("Error caught while processing request: {}", err.msg());
+            if err.code() == 400 {
+                msg = Some(err.msg().to_string());
+            }
             let mut err: &dyn std::error::Error = err;
             while let Some(e) = err.source() {
                 error!("Caused by: {}", e);
@@ -439,8 +450,9 @@ where
                         webmention: None,
                         microsub: None,
                     },
-                    feeds: feeds,
-                    content: ErrorPage { code }.to_string(),
+                    feeds,
+                    user,
+                    content: ErrorPage { code, msg }.to_string(),
                 }
                 .to_string(),
             );
diff --git a/src/frontend/templates/mod.rs b/src/frontend/templates/mod.rs
index 100e16d..dbc23c9 100644
--- a/src/frontend/templates/mod.rs
+++ b/src/frontend/templates/mod.rs
@@ -25,7 +25,7 @@ mod onboarding;
 pub use onboarding::OnboardingPage;
 
 markup::define! {
-    Template<'a>(title: &'a str, blog_name: &'a str, endpoints: IndiewebEndpoints, feeds: Vec<MicropubChannel>, content: String) {
+    Template<'a>(title: &'a str, blog_name: &'a str, endpoints: IndiewebEndpoints, feeds: Vec<MicropubChannel>, user: Option<String>, content: String) {
         @markup::doctype()
         html {
             head {
@@ -45,14 +45,22 @@ markup::define! {
                 }
             }
             body {
+                // TODO Somehow compress headerbar into a menu when the screen space is tight
                 nav#headerbar {
                     ul {
-                        // TODO print a list of feeds and allow jumping to them
                         li { a#homepage[href="/"] { @blog_name } }
                         @for feed in feeds.iter() {
                             li { a[href=&feed.uid] { @feed.name } }
                         }
-                        li.shiftright { a#login[href="/login"] { "Login" } }
+                        li.shiftright {
+                            @if user.is_none() {
+                                a#login[href="/login"] { "Sign in" }
+                            } else {
+                                span {
+                                    @user.as_ref().unwrap() " - " a#logout[href="/logout"] { "Sign out" }
+                                }
+                            }
+                        }
                     }
                 }
                 main {
@@ -372,7 +380,7 @@ markup::define! {
         }
         @Feed { feed }
     }
-    ErrorPage(code: StatusCode) {
+    ErrorPage(code: StatusCode, msg: Option<String>) {
         h1 { @format!("HTTP {} {}", code, code.canonical_reason()) }
         @match code {
             StatusCode::Unauthorized => {
@@ -399,11 +407,32 @@ markup::define! {
                 p { "Wait, do you seriously expect my website to brew you coffee? It's not a coffee machine!" }
 
                 p {
-                    small { "I could brew you some coffee tho if we meet one day... "
-                    small { i { "i-it's nothing personal, I just like brewing coffee, b-baka!!!~ >.<!" } } }
+                    small {
+                        "I could brew you some coffee tho if we meet one day... "
+                        small {
+                            i {
+                                "i-it's nothing personal, I just like brewing coffee, b-baka!!!~ >.<!"
+                            }
+                        }
+                    }
                 }
             }
-            _ => { p { "It seems like you have found an error. Not to worry, it has already been logged." } }
+            StatusCode::BadRequest => {
+                @if msg.is_none() {
+                    p {
+                        "There was an undescribed error in your request. "
+                        "Please try again later or with a different request."
+                    }
+                } else {
+                    p {
+                        "There was a following error in your request: "
+                        @msg.as_ref().unwrap()
+                    }
+                }
+            }
+            _ => {
+                p { "It seems like you have found an error. Not to worry, it has already been logged." }
+            }
         }
         P { "For now, may I suggest to visit " a[href="/"] {"the main page"} " of this website?" }
 
diff --git a/src/lib.rs b/src/lib.rs
index 817bda7..eb915c2 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -19,6 +19,7 @@ where
     authorization_endpoint: surf::Url,
     media_endpoint: Option<String>,
     internal_token: Option<String>,
+    cookie_secret: String,
     http_client: surf::Client,
     storage: StorageBackend,
 }
@@ -48,6 +49,13 @@ where
         .with(frontend::ErrorHandlerMiddleware {})
         .get(frontend::mainpage)
         .post(frontend::onboarding_receiver);
+    app.at("/login")
+        .with(frontend::ErrorHandlerMiddleware {})
+        .get(frontend::login::form)
+        .post(frontend::login::handler);
+    app.at("/login/callback")
+        .with(frontend::ErrorHandlerMiddleware {})
+        .get(frontend::login::callback);
     app.at("/static/*path")
         .with(frontend::ErrorHandlerMiddleware {})
         .get(frontend::handle_static);
@@ -64,7 +72,14 @@ where
     app.at("/metrics").get(metrics::gather);
 
     app.with(metrics::InstrumentationMiddleware {});
-
+    app.with(
+        tide::sessions::SessionMiddleware::new(
+            tide::sessions::CookieStore::new(),
+            &app.state().cookie_secret.as_bytes()
+        )
+            .with_cookie_name("kittybox_session")
+            .without_save_unchanged()
+    );
     app
 }
 
@@ -93,6 +108,7 @@ pub async fn get_app_with_file(
     authorization_endpoint: surf::Url,
     backend_uri: String,
     media_endpoint: Option<String>,
+    cookie_secret: String,
     internal_token: Option<String>,
 ) -> App<database::FileStorage> {
     let folder = backend_uri.strip_prefix("file://").unwrap();
@@ -102,6 +118,7 @@ pub async fn get_app_with_file(
         media_endpoint,
         authorization_endpoint,
         internal_token,
+        cookie_secret,
         storage: database::FileStorage::new(path).await.unwrap(),
         http_client: surf::Client::new(),
     });
@@ -128,6 +145,7 @@ pub async fn get_app_with_test_file(
         authorization_endpoint: Url::parse("https://indieauth.com/auth").unwrap(),
         storage: backend.clone(),
         internal_token: None,
+        cookie_secret: "1234567890abcdefghijklmnopqrstuvwxyz".to_string(),
         http_client: surf::Client::new(),
     });
     (tempdir, backend, equip_app(app))
diff --git a/src/main.rs b/src/main.rs
index aec3be0..4f5f9ed 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -60,6 +60,24 @@ async fn main() -> Result<(), std::io::Error> {
 
     let internal_token: Option<String> = env::var("KITTYBOX_INTERNAL_TOKEN").ok();
 
+    let cookie_secret: String = match env::var("COOKIE_SECRET").ok() {
+        Some(value) => value,
+        None => {
+            if let Some(filename) = env::var("COOKIE_SECRET_FILE").ok() {
+                use async_std::io::ReadExt;
+
+                let mut file = async_std::fs::File::open(filename).await?;
+                let mut temp_string = String::new();
+                file.read_to_string(&mut temp_string).await?;
+
+                temp_string
+            } else {
+                error!("COOKIE_SECRET or COOKIE_SECRET_FILE is not set, will not be able to log in users securely!");
+                std::process::exit(1);
+            }
+        }
+    };
+
     let host = env::var("SERVE_AT")
         .ok()
         .unwrap_or_else(|| "0.0.0.0:8080".to_string());
@@ -73,6 +91,7 @@ async fn main() -> Result<(), std::io::Error> {
             authorization_endpoint,
             backend_uri,
             media_endpoint,
+            cookie_secret,
             internal_token,
         )
         .await;
diff --git a/src/micropub/post.rs b/src/micropub/post.rs
index 070c822..c465a6f 100644
--- a/src/micropub/post.rs
+++ b/src/micropub/post.rs
@@ -483,7 +483,7 @@ async fn post_process_new_post<S: Storage>(
                 // 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">"#)
+                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() {