~whbboyd/russet

e2c83c24f00213c7fc01c5d6796ab758c4517200 — Will Boyd 3 months ago dffcd77
Error handling on the HTTP side
M src/http/entry.rs => src/http/entry.rs +6 -15
@@ 1,29 1,20 @@
use axum::extract::{ Path, State };
use axum::http::StatusCode;
use axum::response::Redirect;
use crate::http::{ AppState, AuthenticatedUser };
use crate::http::error::HttpError;
use crate::model::EntryId;
use crate::persistence::RussetPersistenceLayer;
use tracing::error;

#[tracing::instrument]
pub async fn mark_read_redirect<Persistence>(
	Path(entry_id): Path<EntryId>,
	State(state): State<AppState<Persistence>>,
	user: AuthenticatedUser<Persistence>,
) -> Result<Redirect, StatusCode>
) -> Result<Redirect, HttpError>
where Persistence: RussetPersistenceLayer {
	let entry = state.domain_service.get_entry(&entry_id, &user.user.id).await;
	match entry {
		Ok(entry) => {
			match entry.url {
				Some(url) => Ok(Redirect::to(&url)),
				None => Ok(Redirect::to("/")),
			}
		}
		Err(e) => {
			error!(error = e.as_ref());
			Err(StatusCode::INTERNAL_SERVER_ERROR)
		}
	let entry = state.domain_service.get_entry(&entry_id, &user.user.id).await?;
	match entry.url {
		Some(url) => Ok(Redirect::to(&url)),
		None => Ok(Redirect::to("/")),
	}
}

A src/http/error.rs => src/http/error.rs +80 -0
@@ 0,0 1,80 @@
use axum::http::StatusCode;
use axum::response::{ Html, IntoResponse, Response  };
use crate::Err;
use crate::persistence::model::User;
use sailfish::RenderError;
use sailfish::TemplateOnce;
use tracing::error;
use ulid::Ulid;

pub enum HttpError {
	BadRequest { description: String },
	Unauthenticated,
	NotFound,
	InternalError { description: String },
}
impl From<Err> for HttpError {
	fn from(err: Err) -> HttpError {
		HttpError::InternalError { description: err.to_string() }
	}
}
// What even is this doing.
// (The answer: apparently, `?` doesn't like traversing multiple levels of
// `into`? `RenderError` is a `std::Error`, so we can convert it to an `Err`,
// which then converts into an `HttpError`.)
impl From<RenderError> for HttpError {
	fn from(err: RenderError) -> HttpError {
		err.into()
	}
}

#[derive(Debug, TemplateOnce)]
#[template(path = "error.stpl")]
pub struct ErrorPageTemplate<'a> {
	error_code: &'a str,
	error_description: &'a str,
	user: Option<&'a User>,
	page_title: &'a str,
	relative_root: &'a str,
}
impl IntoResponse for HttpError {
	fn into_response(self) -> Response {
		let (status, description) = match self {
			HttpError::BadRequest { description } => (StatusCode::BAD_REQUEST, description),
			HttpError::Unauthenticated => (StatusCode::UNAUTHORIZED, "You must <a href=\"/login\">log in</a>.".to_string()),
			HttpError::NotFound => (StatusCode::NOT_FOUND, "No resource exists at this URL.".to_string()),
			HttpError::InternalError { description } => {
				let correlation_id = Ulid::new();
				let description = format!("Internal server error: {description} ({correlation_id})");
				error!(description);
				// Hide full errors from external users in production.
				// TODO: This should actually be runtime-configurable.
				#[cfg(not(debug_assertions))]
				let description = format!("Internal server error ({correlation_id})");
				(StatusCode::INTERNAL_SERVER_ERROR, description)
			}
		};
		let status_str = format!(
				"{}{}",
				status.as_str(),
				status.canonical_reason()
					.map(|reason| format!(": {reason}"))
					.unwrap_or("".to_string())
			);
		let page = Html(
			ErrorPageTemplate{
					error_code: &status_str,
					error_description: &description,
					user: None,
					page_title: &status_str,
					relative_root: "/",
				}
				.render_once()
				.unwrap_or_else(|err| {
					error!(error = err.to_string(), "An error was encountered rendering the error page");
					format!("{status_str}\n<hr>\nAn error was encountered rendering the error page")
				})
			);
		(status, page).into_response()
	}
}

M src/http/feed.rs => src/http/feed.rs +8 -9
@@ 1,8 1,8 @@
use axum::extract::{ Form, Path, State };
use axum::http::StatusCode;
use axum::response::{ Html, Redirect };
use crate::domain::model::{ Entry, Feed };
use crate::http::{ AppState, AuthenticatedUser, PageQuery };
use crate::http::error::HttpError;
use crate::model::{ FeedId, Pagination };
use crate::persistence::model::User;
use crate::persistence::RussetPersistenceLayer;


@@ 24,12 24,12 @@ pub async fn feed_page<Persistence>(
	State(state): State<AppState<Persistence>>,
	user: AuthenticatedUser<Persistence>,
	Form(pagination): Form<PageQuery>,
) -> Html<String>
) -> Result<Html<String>, HttpError>
where Persistence: RussetPersistenceLayer {
	let page_num = pagination.page_num.unwrap_or(0);
	let page_size = pagination.page_size.unwrap_or(100);
	let pagination = Pagination { page_num, page_size };
	let feed = state.domain_service.get_feed(&feed_id).await.unwrap();
	let feed = state.domain_service.get_feed(&feed_id).await?;
	let entries = state.domain_service
		.get_feed_entries(&user.user.id, &feed_id, &pagination)
		.await


@@ 37,7 37,7 @@ where Persistence: RussetPersistenceLayer {
		.filter_map(|entry| entry.ok())
		.collect::<Vec<Entry>>();
	let page_title = format!("Feed - {}", feed.title);
	Html(
	Ok(Html(
		FeedPageTemplate {
			user: Some(&user.user),
			entries: &entries.as_slice(),


@@ 46,9 46,8 @@ where Persistence: RussetPersistenceLayer {
			page_title: &page_title,
			relative_root: "../",
		}
		.render_once()
		.unwrap()
	)
		.render_once()?
	) )
}

#[tracing::instrument]


@@ 56,8 55,8 @@ pub async fn unsubscribe<Persistence>(
	Path(feed_id): Path<FeedId>,
	State(state): State<AppState<Persistence>>,
	user: AuthenticatedUser<Persistence>,
) -> Result<Redirect, StatusCode>
) -> Result<Redirect, HttpError>
where Persistence: RussetPersistenceLayer {
	state.domain_service.unsubscribe(&user.user.id, &feed_id).await.unwrap();
	state.domain_service.unsubscribe(&user.user.id, &feed_id).await?;
	Ok(Redirect::to("../"))
}

M src/http/login.rs => src/http/login.rs +9 -15
@@ 1,12 1,11 @@
use axum::extract::{ Form, State };
use axum_extra::extract::cookie::{ Cookie, CookieJar, Expiration };
use axum::http::StatusCode;
use axum::response::{ Html, Redirect };
use crate::http::AppState;
use crate::http::error::HttpError;
use crate::persistence::RussetPersistenceLayer;
use sailfish::TemplateOnce;
use serde::Deserialize;
use tracing::error;

#[derive(Debug, TemplateOnce)]
#[template(path = "login.stpl")]


@@ 24,18 23,17 @@ pub struct LoginPageQuery {
pub async fn login_page<Persistence>(
	State(_state): State<AppState<Persistence>>,
	Form(login): Form<LoginPageQuery>,
) -> Html<String>
) -> Result<Html<String>, HttpError>
where Persistence: RussetPersistenceLayer {
	Html(
	Ok(Html(
		LoginPageTemplate{
			redirect_to: login.redirect_to.as_ref().map(|redirect| redirect.as_str()),
			page_title: "Login",
			relative_root: "",
			user: None,
		}
		.render_once()
		.unwrap()
	)
		.render_once()?
	) )
}

#[derive(Deserialize, Clone)]


@@ 63,7 61,7 @@ pub async fn login_user<Persistence>(
	State(state): State<AppState<Persistence>>,
	cookies: CookieJar,
	Form(login): Form<LoginRequest>,
) -> Result<(CookieJar, Redirect), StatusCode>
) -> Result<(CookieJar, Redirect), HttpError>
where Persistence: RussetPersistenceLayer {
	let session = state.domain_service
		.login_user(


@@ 71,9 69,9 @@ where Persistence: RussetPersistenceLayer {
			login.plaintext_password,
			login.permanent_session,
		)
		.await;
		.await?;
	match session {
		Ok(Some(session)) => {
		Some(session) => {
			let cookie = Cookie::build(("session_id", session.token.0))
				.expires(
					if login.permanent_session {


@@ 88,10 86,6 @@ where Persistence: RussetPersistenceLayer {
				Redirect::to(&login.redirect_to.unwrap_or("/".to_string())),
			))
		},
		Ok(None) => Err(StatusCode::UNAUTHORIZED),
		Err(e) => {
			error!(error = e.as_ref());
			Err(StatusCode::INTERNAL_SERVER_ERROR)
		}
		None => Err(HttpError::Unauthenticated),
	}
}

M src/http/mod.rs => src/http/mod.rs +4 -2
@@ 1,5 1,5 @@
use axum::middleware::map_response;
use axum::response::{ Redirect, Response };
use axum::response::Response;
use axum::Router;
use axum::routing::{ any, get, post };
use crate::domain::RussetDomainService;


@@ 12,6 12,7 @@ use tower::limit::GlobalConcurrencyLimitLayer;
use tower_http::compression::CompressionLayer;

mod entry;
pub mod error;
mod feed;
mod login;
mod root;


@@ 37,7 38,8 @@ where Persistence: RussetPersistenceLayer {
		.route("/feed/:id", get(feed::feed_page).post(feed::unsubscribe))
		.route("/user/:id", get(user::user_page))
		.route("/subscribe", get(subscribe::subscribe_page).post(subscribe::subscribe))
		.route("/*any", any(|| async { Redirect::to("/") }))
		.route("/error", get(|| async { error::HttpError::InternalError { description: "Juicy details!".to_string() }}))
		.route("/*any", any(|| async { error::HttpError::NotFound }))
		.layer(GlobalConcurrencyLimitLayer::with_semaphore(global_limit_semaphore))
		.layer(map_response(csp_header))
		.layer(CompressionLayer::new())

M src/http/root.rs => src/http/root.rs +17 -21
@@ 1,17 1,15 @@
use axum::extract::{ Form, State };
use axum::http::StatusCode;
use axum::response::{ Html, Redirect };
use crate::domain::model::{ Entry, Feed };
use crate::http::{ AppState, PageQuery };
use crate::http::error::HttpError;
use crate::http::session::AuthenticatedUser;
use crate::model::{ EntryId, FeedId, Pagination, Timestamp };
use crate::persistence::model::{ User, UserEntry };
use crate::persistence::RussetPersistenceLayer;
use crate::Result;
use sailfish::TemplateOnce;
use std::collections::HashMap;
use std::time::SystemTime;
use tracing::error;

// Root (home/entries) page template
#[derive(TemplateOnce)]


@@ 29,11 27,14 @@ pub async fn root<Persistence>(
	State(state): State<AppState<Persistence>>,
	user: AuthenticatedUser<Persistence>,
	Form(pagination): Form<PageQuery>,
) -> Html<String>
) -> Result<Html<String>, HttpError>
where Persistence: RussetPersistenceLayer {
	let page_num = pagination.page_num.unwrap_or(0);
	let page_size = pagination.page_size.unwrap_or(100);
	let pagination = Pagination { page_num, page_size };
	// TODO: If every element of entries or feeds is Err, we didn't partially
	// succeed, we utterly failed, and we should indicate that.
	// TODO: Also we should probably indicate partial failure.
	let entries = state.domain_service
		.get_subscribed_entries(&user.user.id, &pagination)
		.await


@@ 47,7 48,7 @@ where Persistence: RussetPersistenceLayer {
		.filter_map(|feed| feed.ok())
		.map(|feed| (feed.id.clone(), feed))
		.collect::<HashMap<FeedId, Feed>>();
	Html(
	Ok(Html(
		RootPageTemplate {
			user: Some(&user.user),
			entries: entries.as_slice(),


@@ 56,9 57,8 @@ where Persistence: RussetPersistenceLayer {
			page_title: "Entries",
			relative_root: "",
		}
		.render_once()
		.unwrap()
	)
		.render_once()?
	) )
}

#[derive(Debug)]


@@ 73,7 73,7 @@ pub struct EditUserEntriesRequest {
	selected_ids: Vec<EntryId>,
}
impl EditUserEntriesRequest {
	fn from_raw_entries(entries: &Vec<(String, String)>) -> Result<EditUserEntriesRequest> {
	fn from_raw_entries(entries: &Vec<(String, String)>) -> crate::Result<EditUserEntriesRequest> {
		let mut action: Option<Action> = None;
		let mut select_all = false;
		let mut selected_ids: Vec<EntryId> = Vec::new();


@@ 110,23 110,19 @@ pub async fn edit_userentries<Persistence>(
	State(state): State<AppState<Persistence>>,
	user: AuthenticatedUser<Persistence>,
	Form(request): Form<Vec<(String, String)>>,
) -> std::result::Result<Redirect, StatusCode>
) -> Result<Redirect, HttpError>
where Persistence: RussetPersistenceLayer {
	let request = EditUserEntriesRequest::from_raw_entries(&request)
		.map_err(|err| {
			error!("{err:?}");
			StatusCode::INTERNAL_SERVER_ERROR
		})?;
	let request = EditUserEntriesRequest::from_raw_entries(&request)?;
	let time = Some(Timestamp::new(SystemTime::now()));
	let user_entry = match request.action {
		Action::MarkRead => UserEntry { read: time, tombstone: None },
		Action::Delete => UserEntry { read: time.clone(), tombstone: time },
	};
	state.domain_service.set_userentries(&request.selected_ids, &user.user.id, &user_entry)
		.await
		.map_err(|err| {
			error!("{err:?}");
			StatusCode::INTERNAL_SERVER_ERROR
		})?;
	state.domain_service.set_userentries(
			&request.selected_ids,
			&user.user.id,
			&user_entry,
		)
		.await?;
	Ok(Redirect::to("/"))
}

M src/http/static_routes.rs => src/http/static_routes.rs +2 -0
@@ 8,6 8,8 @@ use sailfish::TemplateOnce;
struct Css { }
#[tracing::instrument]
pub async fn styles() -> Response<String> {
	// TODO: This isn't actually infallible. Do something vaguely reasonable if
	// it fails.
	Response::builder()
		.status(StatusCode::OK)
		.header(header::CONTENT_TYPE, "text/css")

M src/http/subscribe.rs => src/http/subscribe.rs +9 -16
@@ 1,7 1,7 @@
use axum::extract::{ Form, State };
use axum::http::StatusCode;
use axum::response::{ Html, Redirect };
use crate::http::AppState;
use crate::http::error::HttpError;
use crate::http::session::AuthenticatedUser;
use crate::persistence::model::User;
use crate::persistence::RussetPersistenceLayer;


@@ 20,17 20,16 @@ pub struct SubscribePage<'a> {
pub async fn subscribe_page<Persistence>(
	State(_state): State<AppState<Persistence>>,
	user: AuthenticatedUser<Persistence>,
) -> Html<String>
) -> Result<Html<String>, HttpError>
where Persistence: RussetPersistenceLayer {
	Html(
	Ok(Html(
		SubscribePage{
			user: Some(&user.user),
			page_title: "Subscribe",
			relative_root: "",
		}
		.render_once()
		.unwrap()
	)
		.render_once()?
	) )
}

#[derive(Debug, Deserialize, Clone)]


@@ 42,19 41,13 @@ pub async fn subscribe<Persistence>(
	State(state): State<AppState<Persistence>>,
	user: AuthenticatedUser<Persistence>,
	Form(subscribe): Form<SubscribeRequest>,
) -> Result<Redirect, StatusCode>
) -> Result<Redirect, HttpError>
where Persistence: RussetPersistenceLayer {
	let url = match Url::parse(&subscribe.url) {
		Ok(url) => url,
		Err(_) => return Err(StatusCode::BAD_REQUEST),
	};
	let feed_id = match state.domain_service.add_feed(&url).await {
		Ok(feed_id) => feed_id,
		Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
	};
	match state.domain_service.subscribe(&user.user.id, &feed_id).await {
		Ok(_) => (),
		Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
		Err(_) => return Err(HttpError::BadRequest { description: format!("Could not parse URL {:?}", subscribe.url) }),
	};
	let feed_id = state.domain_service.add_feed(&url).await?;
	state.domain_service.subscribe(&user.user.id, &feed_id).await?;
	Ok(Redirect::to("/"))
}

M src/http/user.rs => src/http/user.rs +4 -3
@@ 1,6 1,7 @@
use axum::extract::{ Path, State };
use axum::response::Html;
use crate::http::{ AppState, AuthenticatedUser };
use crate::http::error::HttpError;
use crate::model::{ UserId, UserType };
use crate::persistence::RussetPersistenceLayer;



@@ 9,13 10,13 @@ pub async fn user_page<Persistence>(
	Path(user_id): Path<UserId>,
	State(state): State<AppState<Persistence>>,
	auth_user: AuthenticatedUser<Persistence>,
) -> Html<String>
) -> Result<Html<String>, HttpError>
where Persistence: RussetPersistenceLayer {
	// Authentication rules. Sysops can see all user pages. Members can see only
	// themselves.
	if auth_user.user.user_type != UserType::Sysop && auth_user.user.id != user_id {
		panic!("PERMISSION DENIED!!!1!!");
	}
	let user = state.domain_service.get_user(&user_id).await.unwrap();
	Html(format!("User: {}<br />ID: {:?}<br />Type: {:?}", user.name, user.id, user.user_type))
	let user = state.domain_service.get_user(&user_id).await?;
	Ok(Html(format!("User: {}<br />ID: {:?}<br />Type: {:?}", user.name, user.id, user.user_type)))
}

M src/main.rs => src/main.rs +0 -1
@@ 18,7 18,6 @@ use crate::domain::RussetDomainService;
use crate::feed::atom::AtomFeedReader;
use crate::feed::rss::RssFeedReader;
use crate::feed::RussetFeedReader;
use crate::model::UserType;
use crate::persistence::sql::SqlDatabase;
use crate::server::start;
use merge::Merge;

A templates/error.stpl => templates/error.stpl +5 -0
@@ 0,0 1,5 @@
<% include!("head.stpl"); %>
		<%- error_code %>
		<hr />
		<%- error_description %>
<% include!("foot.stpl"); %>