use std::convert::TryInto;
use crate::database::Storage;
use serde::{Deserialize, Serialize};
use futures_util::TryFutureExt;
use warp::{http::StatusCode, Filter, host::Authority, path::FullPath};
static POSTS_PER_PAGE: usize = 20;
//pub mod login;
mod templates;
#[allow(unused_imports)]
use templates::{ErrorPage, MainPage, OnboardingPage, Template};
#[derive(Clone, Serialize, Deserialize)]
pub struct IndiewebEndpoints {
pub authorization_endpoint: String,
pub token_endpoint: String,
pub webmention: Option<String>,
pub microsub: Option<String>,
}
#[derive(Deserialize)]
struct QueryParams {
after: Option<String>,
}
#[derive(Debug)]
struct FrontendError {
msg: String,
source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
code: StatusCode,
}
impl FrontendError {
pub fn with_code<C>(code: C, msg: &str) -> Self
where
C: TryInto<StatusCode>,
{
Self {
msg: msg.to_string(),
source: None,
code: code.try_into().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
}
}
pub fn msg(&self) -> &str {
&self.msg
}
pub fn code(&self) -> StatusCode {
self.code
}
}
impl From<crate::database::StorageError> for FrontendError {
fn from(err: crate::database::StorageError) -> Self {
Self {
msg: "Database error".to_string(),
source: Some(Box::new(err)),
code: StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
impl std::error::Error for FrontendError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.source
.as_ref()
.map(|e| e.as_ref() as &(dyn std::error::Error + 'static))
}
}
impl std::fmt::Display for FrontendError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.msg)
}
}
impl warp::reject::Reject for FrontendError {}
async fn get_post_from_database<S: Storage>(
db: &S,
url: &str,
after: Option<String>,
user: &Option<String>,
) -> std::result::Result<serde_json::Value, FrontendError> {
match db
.read_feed_with_limit(url, &after, POSTS_PER_PAGE, user)
.await
{
Ok(result) => match result {
Some(post) => Ok(post),
None => Err(FrontendError::with_code(
StatusCode::NOT_FOUND,
"Post not found in the database",
)),
},
Err(err) => match err.kind() {
crate::database::ErrorKind::PermissionDenied => {
// TODO: Authentication
if user.is_some() {
Err(FrontendError::with_code(
StatusCode::FORBIDDEN,
"User authenticated AND forbidden to access this resource",
))
} else {
Err(FrontendError::with_code(
StatusCode::UNAUTHORIZED,
"User needs to authenticate themselves",
))
}
}
_ => Err(err.into()),
},
}
}
#[allow(dead_code)]
#[derive(Deserialize)]
struct OnboardingFeed {
slug: String,
name: String,
}
#[allow(dead_code)]
#[derive(Deserialize)]
struct OnboardingData {
user: serde_json::Value,
first_post: serde_json::Value,
#[serde(default = "OnboardingData::default_blog_name")]
blog_name: String,
feeds: Vec<OnboardingFeed>,
}
impl OnboardingData {
fn default_blog_name() -> String {
"Kitty Box!".to_owned()
}
}
/*pub async fn onboarding_receiver<S: Storage>(mut req: Request<ApplicationState<S>>) -> Result {
use serde_json::json;
log::debug!("Entering onboarding receiver...");
// This cannot error out as the URL must be valid. Or there is something horribly wrong
// and we shouldn't serve this request anyway.
<dyn AsMut<tide::http::Request>>::as_mut(&mut req)
.url_mut()
.set_scheme("https")
.unwrap();
log::debug!("Parsing the body...");
let body = req.body_json::<OnboardingData>().await?;
log::debug!("Body parsed!");
let backend = &req.state().storage;
#[cfg(any(not(debug_assertions), test))]
let me = req.url();
#[cfg(all(debug_assertions, not(test)))]
let me = url::Url::parse("https://localhost:8080/").unwrap();
log::debug!("me value: {:?}", me);
if get_post_from_database(backend, me.as_str(), None, &None)
.await
.is_ok()
{
return Err(FrontendError::with_code(
StatusCode::Forbidden,
"Onboarding is over. Are you trying to take over somebody's website?!",
)
.into());
}
info!("Onboarding new user: {}", me);
let user = crate::indieauth::User::new(me.as_str(), "https://kittybox.fireburn.ru/", "create");
log::debug!("Setting the site name to {}", &body.blog_name);
backend
.set_setting("site_name", user.me.as_str(), &body.blog_name)
.await?;
if body.user["type"][0] != "h-card" || body.first_post["type"][0] != "h-entry" {
return Err(FrontendError::with_code(
StatusCode::BadRequest,
"user and first_post should be h-card and h-entry",
)
.into());
}
info!("Validated body.user and body.first_post as microformats2");
let mut hcard = body.user;
let hentry = body.first_post;
// Ensure the h-card's UID is set to the main page, so it will be fetchable.
hcard["properties"]["uid"] = json!([me.as_str()]);
// Normalize the h-card - note that it should preserve the UID we set here.
let (_, hcard) = crate::micropub::normalize_mf2(hcard, &user);
// The h-card is written directly - all the stuff in the Micropub's
// post function is just to ensure that the posts will be syndicated
// and inserted into proper feeds. Here, we don't have a need for this,
// since the h-card is DIRECTLY accessible via its own URL.
log::debug!("Saving the h-card...");
backend.put_post(&hcard, me.as_str()).await?;
log::debug!("Creating feeds...");
for feed in body.feeds {
if feed.name.is_empty() || feed.slug.is_empty() {
continue;
};
log::debug!("Creating feed {} with slug {}", &feed.name, &feed.slug);
let (_, feed) = crate::micropub::normalize_mf2(
json!({
"type": ["h-feed"],
"properties": {"name": [feed.name], "mp-slug": [feed.slug]}
}),
&user,
);
backend.put_post(&feed, me.as_str()).await?;
}
log::debug!("Saving the h-entry...");
// This basically puts the h-entry post through the normal creation process.
// We need to insert it into feeds and optionally send a notification to everywhere.
req.set_ext(user);
crate::micropub::post::new_post(req, hentry).await?;
Ok(Response::builder(201).header("Location", "/").build())
}
*/
fn request_uri() -> impl Filter<Extract = (String,), Error = warp::Rejection> + Copy {
crate::util::require_host()
.and(warp::path::full())
.map(|host: Authority, path: FullPath| "https://".to_owned() + host.as_str() + path.as_str())
}
#[forbid(clippy::unwrap_used)]
pub fn homepage<D: Storage>(db: D, endpoints: IndiewebEndpoints) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
let inject_db = move || db.clone();
warp::any()
.map(inject_db.clone())
.and(crate::util::require_host())
.and(warp::query())
.and_then(|db: D, host: Authority, q: QueryParams| async move {
let path = format!("https://{}/", host.to_string());
let feed_path = format!("https://{}/feeds/main", host.to_string());
match tokio::try_join!(
get_post_from_database(&db, &path, None, &None),
get_post_from_database(&db, &feed_path, q.after, &None)
) {
Ok((hcard, hfeed)) => Ok((
Some(hcard),
Some(hfeed),
StatusCode::OK
)),
Err(err) => {
if err.code == StatusCode::NOT_FOUND {
// signal for onboarding flow
Ok((None, None, err.code))
} else {
Err(warp::reject::custom(err))
}
}
}
})
.and(warp::any().map(move || endpoints.clone()))
.and(crate::util::require_host())
.and(warp::any().map(inject_db))
.then(|content: (Option<serde_json::Value>, Option<serde_json::Value>, StatusCode), endpoints: IndiewebEndpoints, host: Authority, db: D| async move {
let owner = format!("https://{}/", host.as_str());
let blog_name = db.get_setting(crate::database::Settings::SiteName, &owner).await
.unwrap_or_else(|_| "Kitty Box!".to_string());
let feeds = db.get_channels(&owner).await.unwrap_or_default();
match content {
(Some(card), Some(feed), StatusCode::OK) => {
Box::new(warp::reply::html(Template {
title: &blog_name,
blog_name: &blog_name,
endpoints: Some(endpoints),
feeds,
user: None, // TODO
content: MainPage { feed: &feed, card: &card }.to_string()
}.to_string())) as Box<dyn warp::Reply>
},
(None, None, StatusCode::NOT_FOUND) => {
// TODO Onboarding
Box::new(warp::redirect::found(
hyper::Uri::from_static("/onboarding")
)) as Box<dyn warp::Reply>
}
_ => unreachable!()
}
})
}
pub fn onboarding<D: Storage, T: hyper::client::connect::Connect + Clone + Send + Sync + 'static>(
db: D, endpoints: IndiewebEndpoints, http: hyper::Client<T, hyper::Body>
) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
let inject_db = move || db.clone();
warp::get()
.map(move || warp::reply::html(Template {
title: "Kittybox - Onboarding",
blog_name: "Kittybox",
endpoints: Some(endpoints.clone()),
feeds: vec![],
user: None,
content: OnboardingPage {}.to_string()
}.to_string()))
.or(warp::post()
.and(crate::util::require_host())
.and(warp::any().map(inject_db))
.and(warp::body::json::<OnboardingData>())
.and(warp::any().map(move || http.clone()))
.and_then(|host: warp::host::Authority, db: D, body: OnboardingData, http: _| async move {
let user_uid = format!("https://{}/", host.as_str());
if db.post_exists(&user_uid).await.map_err(FrontendError::from)? {
return Ok(warp::redirect(hyper::Uri::from_static("/")));
}
let user = crate::indieauth::User::new(&user_uid, "https://kittybox.fireburn.ru/", "create");
if body.user["type"][0] != "h-card" || body.first_post["type"][0] != "h-entry" {
return Err(FrontendError::with_code(StatusCode::BAD_REQUEST, "user and first_post should be an h-card and an h-entry").into());
}
db.set_setting(crate::database::Settings::SiteName, user.me.as_str(), &body.blog_name)
.await
.map_err(FrontendError::from)?;
let (_, hcard) = {
let mut hcard = body.user;
hcard["properties"]["uid"] = serde_json::json!([&user_uid]);
crate::micropub::normalize_mf2(hcard, &user)
};
db.put_post(&hcard, &user_uid).await.map_err(FrontendError::from)?;
let (uid, post) = crate::micropub::normalize_mf2(body.first_post, &user);
crate::micropub::_post(user, uid, post, db, http).await.map_err(|e| {
FrontendError {
msg: "Error while posting the first post".to_string(),
source: Some(Box::new(e)),
code: StatusCode::INTERNAL_SERVER_ERROR
}
})?;
Ok::<_, warp::Rejection>(warp::redirect(hyper::Uri::from_static("/")))
}))
}
#[forbid(clippy::unwrap_used)]
pub fn catchall<D: Storage>(db: D, endpoints: IndiewebEndpoints) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
let inject_db = move || db.clone();
warp::any()
.map(inject_db.clone())
.and(request_uri())
.and(warp::query())
.and_then(|db: D, path: String, query: QueryParams| async move {
get_post_from_database(&db, &path, query.after, &None).map_err(warp::reject::custom).await
})
// Rendering pipeline
.and_then(|post: serde_json::Value| async move {
let post_name = &post["properties"]["name"][0].as_str().to_owned();
match post["type"][0]
.as_str()
{
Some("h-entry") => Ok((
post_name.unwrap_or("Note").to_string(),
templates::Entry { post: &post }.to_string(),
StatusCode::OK
)),
Some("h-card") => Ok((
post_name.unwrap_or("Contact card").to_string(),
templates::VCard { card: &post }.to_string(),
StatusCode::OK
)),
Some("h-feed") => Ok((
post_name.unwrap_or("Feed").to_string(),
templates::Feed { feed: &post }.to_string(),
StatusCode::OK
)),
_ => Err(warp::reject::custom(FrontendError::with_code(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Couldn't render an unknown type: {}", post["type"][0]),
)))
}
})
.recover(|err: warp::Rejection| {
use warp::Rejection;
use futures_util::future;
if let Some(err) = err.find::<FrontendError>() {
return future::ok::<(String, String, StatusCode), Rejection>((
format!("Error: HTTP {}", err.code().as_u16()),
ErrorPage { code: err.code(), msg: Some(err.msg().to_string()) }.to_string(),
err.code()
));
}
future::err::<(String, String, StatusCode), Rejection>(err)
})
.unify()
.and(warp::any().map(move || endpoints.clone()))
.and(crate::util::require_host())
.and(warp::any().map(inject_db))
.then(|content: (String, String, StatusCode), endpoints: IndiewebEndpoints, host: Authority, db: D| async move {
let owner = format!("https://{}/", host.as_str());
let blog_name = db.get_setting(crate::database::Settings::SiteName, &owner).await
.unwrap_or_else(|_| "Kitty Box!".to_string());
let feeds = db.get_channels(&owner).await.unwrap_or_default();
let (title, content, code) = content;
warp::reply::with_status(warp::reply::html(Template {
title: &title,
blog_name: &blog_name,
endpoints: Some(endpoints),
feeds,
user: None, // TODO
content,
}.to_string()), code)
})
}
static STYLE_CSS: &[u8] = include_bytes!("./style.css");
static ONBOARDING_JS: &[u8] = include_bytes!("./onboarding.js");
static ONBOARDING_CSS: &[u8] = include_bytes!("./onboarding.css");
static MIME_JS: &str = "application/javascript";
static MIME_CSS: &str = "text/css";
fn _dispatch_static(name: &str) -> Option<(&'static [u8], &'static str)> {
match name {
"style.css" => Some((STYLE_CSS, MIME_CSS)),
"onboarding.js" => Some((ONBOARDING_JS, MIME_JS)),
"onboarding.css" => Some((ONBOARDING_CSS, MIME_CSS)),
_ => None
}
}
pub fn static_files() -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Copy {
use futures_util::future;
warp::get()
.and(warp::path::param()
.and_then(|filename: String| {
match _dispatch_static(&filename) {
Some((buf, content_type)) => future::ok(
warp::reply::with_header(
buf, "Content-Type", content_type
)
),
None => future::err(warp::reject())
}
}))
.or(warp::head()
.and(warp::path::param()
.and_then(|filename: String| {
match _dispatch_static(&filename) {
Some((buf, content_type)) => future::ok(
warp::reply::with_header(
warp::reply::with_header(
warp::reply(), "Content-Type", content_type
),
"Content-Length", buf.len()
)
),
None => future::err(warp::reject())
}
})))
}