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"); %>