use crate::{algorithms::ptd, mf2, traits::as_string_or_list};
use http::{
header::{ACCEPT, CONTENT_TYPE},
StatusCode,
};
use mf2::types::{FindItemById, FindItemByProperty, FindItemByUrl, PropertyValue};
use serde::{Deserialize, Serialize};
use std::cmp::PartialEq;
use url::Url;
#[tracing::instrument(skip(client))]
pub async fn endpoint_for(
client: &impl crate::http::Client,
url: &Url,
) -> Result<url::Url, crate::Error> {
let rels = crate::algorithms::link_rel::for_url(client, url, &["webmention"], "GET")
.await?
.get("webmention")
.cloned()
.unwrap_or_default();
if let Some(endpoint_url) = rels.first().cloned() {
tracing::debug!(
rels = format!("{:?}", rels),
url = format!("{endpoint_url}"),
"Found the relations for Webmention; selecting the first URL"
);
Ok(endpoint_url)
} else {
tracing::trace!(
url = format!("{}", url),
"No Webmention endpoints were found."
);
Err(crate::Error::NoEndpointsFound {
url: url.to_string(),
rel: "webmention".to_owned(),
})
}
}
#[tracing::instrument(skip(client))]
pub async fn send(
client: &impl crate::http::Client,
endpoint: &Url,
request: &Request,
) -> Result<Response, crate::Error> {
use std::str::FromStr;
let local_request = request.clone();
let mut req: http::Request<crate::http::Body> = local_request.try_into()?;
*req.uri_mut() =
http::Uri::from_str(endpoint.as_str()).map_err(|e| crate::Error::Http(e.into()))?;
client.send_request(req).await.and_then(|r| r.try_into())
}
#[derive(Clone, Debug, Serialize, Deserialize, Eq)]
#[serde(untagged, rename_all = "lowercase")]
pub enum Status {
Rejected,
Accepted,
Sent,
SenderError(u16),
ReceiverError(u16),
}
impl PartialEq for Status {
fn eq(&self, other: &Self) -> bool {
std::mem::discriminant(self) == std::mem::discriminant(other)
}
}
impl Status {
pub fn is_ok(&self) -> bool {
match self {
Status::Accepted | Status::Sent => true,
Status::SenderError(_) | Status::ReceiverError(_) | Status::Rejected => false,
}
}
}
impl From<u16> for Status {
fn from(code: u16) -> Self {
match code {
200 => Self::Sent,
201 | 202 => Self::Accepted,
400..=499 => Self::SenderError(code),
_ => Self::ReceiverError(code),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Request {
pub source: Url,
pub target: Url,
#[serde(skip_serializing_if = "Option::is_none", flatten)]
pub private: Option<PrivateRequest>,
#[serde(
default,
skip_serializing_if = "Vec::is_empty",
with = "as_string_or_list"
)]
pub vouch: Vec<String>,
#[serde(skip)]
pub token: Option<String>,
}
impl TryInto<http::Request<crate::http::Body>> for Request {
type Error = crate::Error;
fn try_into(mut self) -> Result<http::Request<crate::http::Body>, Self::Error> {
let mut request_builder = http::Request::builder()
.method("POST")
.header(
::http::header::ACCEPT,
"text/plain; q=0.8, text/html, application/json; q=0.8, application/mf2+json; q=0.9, *.*; q=0.7",
)
.header(
::http::header::CONTENT_TYPE,
crate::http::CONTENT_TYPE_FORM_URLENCODED,
);
if let Some(token) = self.token.take() {
request_builder =
request_builder.header(::http::header::AUTHORIZATION, format!("Bearer {}", token));
}
let req_qs =
serde_qs::to_string(&self).map(|s| crate::http::Body::Bytes(s.into_bytes()))?;
request_builder.body(req_qs).map_err(crate::Error::Http)
}
}
impl Default for Request {
fn default() -> Self {
Self {
source: "urn:indieweb:invalid-source".parse().unwrap(),
target: "urn:indieweb:invalid-target".parse().unwrap(),
private: None,
vouch: Default::default(),
token: None,
}
}
}
impl Request {
pub fn validate(&self) -> Result<(), crate::Error> {
if self.source == self.target {
return Err(crate::Error::WebmentionSourceAndTargetAreSame {
url: self.source.clone(),
});
}
Ok(())
}
}
#[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct PrivateRequest {
#[serde(skip_serializing_if = "String::is_empty")]
pub code: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub realm: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Response {
pub status: Status,
pub location: Option<Url>,
pub body: Option<String>,
}
impl PartialEq for Response {
fn eq(&self, other: &Self) -> bool {
self.status == other.status && self.location == other.location && self.body == other.body
}
}
impl TryFrom<http::Response<crate::http::Body>> for Response {
type Error = crate::Error;
fn try_from(resp: http::Response<crate::http::Body>) -> Result<Self, Self::Error> {
let locations = resp.headers().get_all("location");
let status = resp.status();
let location = locations
.into_iter()
.filter(|&_| Status::from(status.as_u16()) == Status::Accepted)
.cloned()
.filter_map(|v| v.to_str().ok().map(ToString::to_string))
.collect::<Vec<_>>()
.first()
.filter(|l| !l.is_empty())
.and_then(|v| v.as_str().parse().ok());
let body = Some(String::from_utf8(resp.into_body().as_bytes().to_vec())?)
.filter(|b| !b.is_empty());
match status.as_u16() {
200..=299 | 400..=499 | 500..=599 => {
let status = match status.as_u16() {
201 | 202 => Status::Accepted,
200 | 203..=299 => Status::Sent,
400..=499 => Status::SenderError(status.as_u16()),
_ => Status::ReceiverError(status.as_u16()),
};
Ok(Self {
body,
location,
status,
})
}
_ => Err(crate::Error::WebmentionUnsupportedStatusCode(
status.as_u16(),
)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Relationship {
pub r#type: ptd::Type,
pub document: mf2::types::Document,
pub source: Option<microformats::types::Item>,
}
const ACCEPT_HEADER_VALUE: &str = "text/html,text/mf2+html";
#[tracing::instrument(skip(client), ret, err)]
pub async fn process_incoming_webmention(
client: &impl crate::http::Client,
Request {
source,
target,
token,
..
}: &Request,
) -> Result<Relationship, crate::Error> {
let mut req_builder = http::Request::builder().method("GET").uri(source.as_str());
req_builder = req_builder.header(ACCEPT, ACCEPT_HEADER_VALUE);
if let Some(token) = token {
req_builder = req_builder.header(::http::header::AUTHORIZATION, format!("Bearer {}", token));
}
let req = req_builder
.body(crate::http::Body::default())
.map_err(crate::Error::Http)?;
tracing::trace!("Executing Webmention request");
let resp = client.send_request(req).await?;
if resp.status() == StatusCode::UNAUTHORIZED {
return Err(crate::Error::WebmentionUnauthorized {
url: source.to_owned(),
});
} else if resp.status() == StatusCode::NOT_FOUND {
return Err(crate::Error::WebmentionNotFound {
url: source.to_owned(),
});
} else if resp.status() == StatusCode::GONE {
return Err(crate::Error::WebmentionDeleted {
url: source.to_owned(),
});
}
let incoming_content_type_header_value = resp
.headers()
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(ToString::to_string)
.unwrap_or_default();
let matches_provided_content_type = !incoming_content_type_header_value.is_empty()
&& ACCEPT_HEADER_VALUE.contains(incoming_content_type_header_value.as_str());
if !matches_provided_content_type {
return Err(crate::Error::WebmentionUnsupportedContentType {
url: source.to_owned(),
content_type: incoming_content_type_header_value,
});
}
let document = crate::mf2::http::to_mf2_document(
resp.map(|b| b.as_bytes().to_vec()),
source.as_str(),
)
.map_err(crate::Error::from)?;
let property_names = document
.find_items_with_matching_property_value(PropertyValue::Url(target.clone().into()))
.into_iter()
.map(|(property_name, _)| property_name)
.collect::<Vec<_>>();
tracing::trace!(
property_names = format!("{property_names:?}"),
"Mapped matching items by their property name"
);
let r#type = if property_names.is_empty() {
let item = document.find_item_by_url(source).or_else(|| {
source
.fragment()
.and_then(|fragment| document.find_item_by_id(fragment))
});
tracing::trace!(
source_has_url = item.is_some(),
url = source.to_string(),
"Did the source expose itself in its MF2?"
);
item.and_then(ptd::resolve_from_object)
.unwrap_or(ptd::Type::Mention)
} else {
ptd::resolve_reaction_property_name(
&property_names
.iter()
.map(|property_name| property_name.as_str())
.collect::<Vec<_>>(),
)
.unwrap_or(ptd::Type::Mention)
};
let source_item = document.find_item_by_url(source);
let source_item = if source_item.is_none() && document.items.len() == 1 {
tracing::warn!("The source item is not in the MF2 by its URL; used the only root item");
Some(document.items[0].clone())
} else {
source_item
};
Ok(Relationship {
document,
r#type,
source: source_item,
})
}
mod test;