use crate::database::Storage;
use crate::ApplicationState;
use log::{error, info};
use serde::{Deserialize, Serialize};
use tide::{Next, Request, Response, Result, StatusCode};
static POSTS_PER_PAGE: usize = 20;
mod templates;
use templates::{ErrorPage, MainPage, OnboardingPage, Template};
#[derive(Clone, Serialize, Deserialize)]
pub struct IndiewebEndpoints {
authorization_endpoint: String,
token_endpoint: String,
webmention: Option<String>,
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(code: StatusCode, msg: &str) -> Self {
Self {
msg: msg.to_string(),
source: None,
code,
}
}
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::InternalServerError,
}
}
}
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)
}
}
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::NotFound,
"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()),
},
}
}
#[derive(Deserialize)]
struct OnboardingFeed {
slug: String,
name: String,
}
#[derive(Deserialize)]
struct OnboardingData {
user: serde_json::Value,
first_post: serde_json::Value,
blog_name: String,
feeds: Vec<OnboardingFeed>,
}
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 == "" || &feed.slug == "" { 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())
}
pub async fn coffee<S: Storage>(_: Request<ApplicationState<S>>) -> Result {
Err(FrontendError::with_code(
StatusCode::ImATeapot,
"Someone asked this website to brew them some coffee...",
)
.into())
}
pub async fn mainpage<S: Storage>(mut req: Request<ApplicationState<S>>) -> Result {
// 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();
let backend = &req.state().storage;
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;
#[cfg(any(not(debug_assertions), test))]
let url = req.url();
#[cfg(all(debug_assertions, not(test)))]
let url = url::Url::parse("https://localhost:8080/").unwrap();
let hcard_url = url.as_str();
let feed_url = url.join("feeds/main").unwrap().to_string();
let card = get_post_from_database(backend, hcard_url, None, &user).await;
let feed = get_post_from_database(backend, &feed_url, query.after, &user).await;
if card.is_err() && feed.is_err() {
// Uh-oh! No main feed and no h-card? Need to do onboarding.
// We can do it from inside the app without ever requesting an auth token.
let card_err = card.unwrap_err();
let feed_err = feed.unwrap_err();
if card_err.code == 404 {
// Yes, we definitely need some onboarding here.
Ok(Response::builder(200)
.content_type("text/html; charset=utf-8")
.body(
Template {
title: "Kittybox - Onboarding",
blog_name: "Kitty Box!",
endpoints: IndiewebEndpoints {
authorization_endpoint,
token_endpoint,
webmention: None,
microsub: None,
},
feeds: Vec::default(),
content: OnboardingPage {}.to_string(),
}
.to_string(),
)
.build())
} else {
Err(feed_err.into())
}
} else {
Ok(Response::builder(200)
.content_type("text/html; charset=utf-8")
.body(
Template {
title: &format!("{} - Main page", url.host().unwrap().to_string()),
blog_name: &backend
.get_setting("site_name", hcard_url)
.await
.unwrap_or_else(|_| "Kitty Box!".to_string()),
endpoints: IndiewebEndpoints {
authorization_endpoint,
token_endpoint,
webmention: None,
microsub: None,
},
feeds: backend.get_channels(hcard_url).await.unwrap_or_else(|_| Vec::default()),
content: MainPage {
feed: &feed?,
card: &card?,
}
.to_string(),
}
.to_string(),
)
.build())
}
}
pub async fn render_post<S: Storage>(mut req: Request<ApplicationState<S>>) -> Result {
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;
// 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();
#[cfg(any(not(debug_assertions), test))]
let url = req.url();
#[cfg(all(debug_assertions, not(test)))]
let url = url::Url::parse("https://localhost:8080/")
.unwrap()
.join(req.url().path())
.unwrap();
let mut entry_url = req.url().clone();
entry_url.set_query(None);
let post = get_post_from_database(&req.state().storage, entry_url.as_str(), query.after, &user)
.await?;
let template: String = match post["type"][0]
.as_str()
.expect("Empty type array or invalid type")
{
"h-entry" => templates::Entry { post: &post }.to_string(),
"h-card" => templates::VCard { card: &post }.to_string(),
"h-feed" => templates::Feed { feed: &post }.to_string(),
_ => {
return Err(FrontendError::with_code(
StatusCode::InternalServerError,
"Couldn't render an unknown type",
)
.into())
}
};
let origin = url.origin();
let owner = origin.ascii_serialization() + "/";
Ok(Response::builder(200)
.content_type("text/html; charset=utf-8")
.body(
Template {
title: post["properties"]["name"][0]
.as_str()
.unwrap_or(&format!("Note at {}", url.host().unwrap().to_string())),
blog_name: &req
.state()
.storage
.get_setting("site_name", &owner) // XXX I'm pretty sure this is bound to cause issues with IDN-style domains
.await
.unwrap_or_else(|_| "Kitty Box!".to_string()),
endpoints: IndiewebEndpoints {
authorization_endpoint,
token_endpoint,
webmention: None,
microsub: None,
},
feeds: req.state().storage.get_channels(&owner).await.unwrap_or_else(|e| Vec::default()),
content: template,
}
.to_string(),
)
.build())
}
pub struct ErrorHandlerMiddleware {}
#[async_trait::async_trait]
impl<S> tide::Middleware<ApplicationState<S>> for ErrorHandlerMiddleware
where
S: crate::database::Storage,
{
async fn handle(
&self,
request: Request<ApplicationState<S>>,
next: Next<'_, ApplicationState<S>>,
) -> Result {
let authorization_endpoint = request.state().authorization_endpoint.to_string();
let token_endpoint = request.state().token_endpoint.to_string();
let owner = request.url().origin().ascii_serialization().clone() + "/";
let site_name = &request
.state()
.storage
.get_setting("site_name", &owner)
.await
.unwrap_or_else(|_| "Kitty Box!".to_string());
let feeds = request.state().storage.get_channels(&owner).await.unwrap_or_else(|_| Vec::default());
let mut res = next.run(request).await;
let mut code: Option<StatusCode> = None;
if let Some(err) = res.downcast_error::<FrontendError>() {
code = Some(err.code());
error!("Error caught while processing request: {}", err.msg());
let mut err: &dyn std::error::Error = err;
while let Some(e) = err.source() {
error!("Caused by: {}", e);
err = e;
}
}
if let Some(code) = code {
res.set_status(code);
res.set_content_type("text/html; charset=utf-8");
res.set_body(
Template {
title: "Error",
blog_name: site_name,
endpoints: IndiewebEndpoints {
authorization_endpoint,
token_endpoint,
webmention: None,
microsub: None,
},
feeds: feeds,
content: ErrorPage { code }.to_string(),
}
.to_string(),
);
}
Ok(res)
}
}
static STYLE_CSS: &[u8] = include_bytes!("./style.css");
static ONBOARDING_JS: &[u8] = include_bytes!("./onboarding.js");
static ONBOARDING_CSS: &[u8] = include_bytes!("./onboarding.css");
pub async fn handle_static<S: Storage>(req: Request<ApplicationState<S>>) -> Result {
Ok(match req.param("path") {
Ok("style.css") => Ok(Response::builder(200)
.content_type("text/css; charset=utf-8")
.body(STYLE_CSS)
.build()),
Ok("onboarding.js") => Ok(Response::builder(200)
.content_type("text/javascript; charset=utf-8")
.body(ONBOARDING_JS)
.build()),
Ok("onboarding.css") => Ok(Response::builder(200)
.content_type("text/css; charset=utf-8")
.body(ONBOARDING_CSS)
.build()),
Ok(_) => Err(FrontendError::with_code(
StatusCode::NotFound,
"Static file not found",
)),
Err(_) => panic!("Invalid usage of the frontend::handle_static() function"),
}?)
}