Compare commits
3 Commits
f43e143bf6
...
1117d49746
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1117d49746 | ||
|
|
ac96b3c249 | ||
|
|
87c529ecb6 |
55
Cargo.lock
generated
55
Cargo.lock
generated
@@ -17,19 +17,6 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ahash"
|
|
||||||
version = "0.8.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"getrandom 0.3.3",
|
|
||||||
"once_cell",
|
|
||||||
"version_check",
|
|
||||||
"zerocopy",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.3"
|
version = "1.1.3"
|
||||||
@@ -966,16 +953,6 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "gethostname"
|
|
||||||
version = "0.2.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c1ebd34e35c46e00bb73e81363248d627782724609fe1b6396f553f68fe3862e"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"winapi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.16"
|
version = "0.2.16"
|
||||||
@@ -3158,24 +3135,6 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tracing-bunyan-formatter"
|
|
||||||
version = "0.3.10"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2d637245a0d8774bd48df6482e086c59a8b5348a910c3b0579354045a9d82411"
|
|
||||||
dependencies = [
|
|
||||||
"ahash",
|
|
||||||
"gethostname",
|
|
||||||
"log",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"time",
|
|
||||||
"tracing",
|
|
||||||
"tracing-core",
|
|
||||||
"tracing-log 0.1.4",
|
|
||||||
"tracing-subscriber",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-core"
|
name = "tracing-core"
|
||||||
version = "0.1.34"
|
version = "0.1.34"
|
||||||
@@ -3186,17 +3145,6 @@ dependencies = [
|
|||||||
"valuable",
|
"valuable",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tracing-log"
|
|
||||||
version = "0.1.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f751112709b4e791d8ce53e32c4ed2d353565a795ce84da2285393f41557bdf2"
|
|
||||||
dependencies = [
|
|
||||||
"log",
|
|
||||||
"once_cell",
|
|
||||||
"tracing-core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-log"
|
name = "tracing-log"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3223,7 +3171,7 @@ dependencies = [
|
|||||||
"thread_local",
|
"thread_local",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
"tracing-log 0.2.0",
|
"tracing-log",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3884,7 +3832,6 @@ dependencies = [
|
|||||||
"tower-sessions",
|
"tower-sessions",
|
||||||
"tower-sessions-redis-store",
|
"tower-sessions-redis-store",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-bunyan-formatter",
|
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ tower-http = { version = "0.6.6", features = ["fs", "trace"] }
|
|||||||
tower-sessions = "0.14.0"
|
tower-sessions = "0.14.0"
|
||||||
tower-sessions-redis-store = "0.16.0"
|
tower-sessions-redis-store = "0.16.0"
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
tracing-bunyan-formatter = "0.3.10"
|
|
||||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||||
unicode-segmentation = "1.12.0"
|
unicode-segmentation = "1.12.0"
|
||||||
urlencoding = "2.1.3"
|
urlencoding = "2.1.3"
|
||||||
|
|||||||
@@ -56,10 +56,7 @@ fn compute_pasword_hash(password: SecretString) -> Result<SecretString, anyhow::
|
|||||||
Ok(SecretString::from(password_hash))
|
Ok(SecretString::from(password_hash))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(
|
#[tracing::instrument(name = "Validate credentials", skip_all)]
|
||||||
name = "Validate credentials",
|
|
||||||
skip(username, password, connection_pool)
|
|
||||||
)]
|
|
||||||
pub async fn validate_credentials(
|
pub async fn validate_credentials(
|
||||||
Credentials { username, password }: Credentials,
|
Credentials { username, password }: Credentials,
|
||||||
connection_pool: &PgPool,
|
connection_pool: &PgPool,
|
||||||
@@ -97,10 +94,7 @@ CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno"
|
|||||||
.map(|_| uuid)
|
.map(|_| uuid)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(
|
#[tracing::instrument(name = "Verify password", skip_all)]
|
||||||
name = "Verify password",
|
|
||||||
skip(expected_password_hash, password_candidate)
|
|
||||||
)]
|
|
||||||
fn verify_password_hash(
|
fn verify_password_hash(
|
||||||
expected_password_hash: SecretString,
|
expected_password_hash: SecretString,
|
||||||
password_candidate: SecretString,
|
password_candidate: SecretString,
|
||||||
@@ -115,7 +109,7 @@ fn verify_password_hash(
|
|||||||
.context("Password verification failed.")
|
.context("Password verification failed.")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(name = "Get stored credentials", skip(username, connection_pool))]
|
#[tracing::instrument(name = "Get stored credentials", skip(connection_pool))]
|
||||||
async fn get_stored_credentials(
|
async fn get_stored_credentials(
|
||||||
username: &str,
|
username: &str,
|
||||||
connection_pool: &PgPool,
|
connection_pool: &PgPool,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
#[derive(Debug)]
|
||||||
pub struct IdempotencyKey(String);
|
pub struct IdempotencyKey(String);
|
||||||
|
|
||||||
impl TryFrom<String> for IdempotencyKey {
|
impl TryFrom<String> for IdempotencyKey {
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ struct HeaderPairRecord {
|
|||||||
value: Vec<u8>,
|
value: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(
|
||||||
|
name = "Fetching saved response in database if it exists",
|
||||||
|
skip(connection_pool)
|
||||||
|
)]
|
||||||
pub async fn get_saved_response(
|
pub async fn get_saved_response(
|
||||||
connection_pool: &PgPool,
|
connection_pool: &PgPool,
|
||||||
idempotency_key: &IdempotencyKey,
|
idempotency_key: &IdempotencyKey,
|
||||||
@@ -53,6 +57,7 @@ pub async fn get_saved_response(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "Saving response in database", skip(transaction, response))]
|
||||||
pub async fn save_response(
|
pub async fn save_response(
|
||||||
mut transaction: Transaction<'static, Postgres>,
|
mut transaction: Transaction<'static, Postgres>,
|
||||||
idempotency_key: &IdempotencyKey,
|
idempotency_key: &IdempotencyKey,
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ pub async fn try_execute_task(
|
|||||||
let mut issue = get_issue(connection_pool, task.newsletter_issue_id).await?;
|
let mut issue = get_issue(connection_pool, task.newsletter_issue_id).await?;
|
||||||
issue.inject_unsubscribe_token(&task.unsubscribe_token);
|
issue.inject_unsubscribe_token(&task.unsubscribe_token);
|
||||||
if task.kind == EmailType::NewPost.to_string() {
|
if task.kind == EmailType::NewPost.to_string() {
|
||||||
issue.create_tracking_info(&mut transaction).await?;
|
issue.inject_tracking_info(&mut transaction).await?;
|
||||||
}
|
}
|
||||||
if let Err(e) = email_client
|
if let Err(e) = email_client
|
||||||
.send_email(
|
.send_email(
|
||||||
@@ -68,14 +68,14 @@ pub async fn try_execute_task(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
error.message = %e,
|
error = %e,
|
||||||
"Failed to deliver issue to confirmed subscriber. Skipping."
|
"Failed to deliver issue to confirmed subscriber. Skipping."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
error.message = %e,
|
error = %e,
|
||||||
"Skipping a subscriber. Their stored contact details are invalid."
|
"Skipping a subscriber. Their stored contact details are invalid."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -103,7 +103,7 @@ impl NewsletterIssue {
|
|||||||
self.html_content = self.html_content.replace("UNSUBSCRIBE_TOKEN", token);
|
self.html_content = self.html_content.replace("UNSUBSCRIBE_TOKEN", token);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_tracking_info(
|
async fn inject_tracking_info(
|
||||||
&mut self,
|
&mut self,
|
||||||
transaction: &mut Transaction<'static, Postgres>,
|
transaction: &mut Transaction<'static, Postgres>,
|
||||||
) -> Result<(), anyhow::Error> {
|
) -> Result<(), anyhow::Error> {
|
||||||
@@ -126,7 +126,6 @@ impl NewsletterIssue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip_all)]
|
|
||||||
async fn get_issue(
|
async fn get_issue(
|
||||||
connection_pool: &PgPool,
|
connection_pool: &PgPool,
|
||||||
issue_id: Uuid,
|
issue_id: Uuid,
|
||||||
@@ -152,7 +151,6 @@ pub struct Task {
|
|||||||
pub kind: String,
|
pub kind: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip_all)]
|
|
||||||
async fn dequeue_task(
|
async fn dequeue_task(
|
||||||
connection_pool: &PgPool,
|
connection_pool: &PgPool,
|
||||||
) -> Result<Option<(Transaction<'static, Postgres>, Task)>, anyhow::Error> {
|
) -> Result<Option<(Transaction<'static, Postgres>, Task)>, anyhow::Error> {
|
||||||
@@ -180,7 +178,6 @@ async fn dequeue_task(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip_all)]
|
|
||||||
async fn delete_task(
|
async fn delete_task(
|
||||||
mut transaction: Transaction<'static, Postgres>,
|
mut transaction: Transaction<'static, Postgres>,
|
||||||
issue_id: Uuid,
|
issue_id: Uuid,
|
||||||
|
|||||||
@@ -160,12 +160,12 @@ impl From<AuthError> for AppError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn not_found() -> Response {
|
pub async fn not_found() -> Response {
|
||||||
(StatusCode::NOT_FOUND, not_found_html()).into_response()
|
not_found_html()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn not_found_html() -> Response {
|
pub fn not_found_html() -> Response {
|
||||||
let template = HtmlTemplate(NotFoundTemplate);
|
let template = HtmlTemplate(NotFoundTemplate);
|
||||||
template.into_response()
|
(StatusCode::NOT_FOUND, template).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Path<T>(T);
|
pub struct Path<T>(T);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ pub enum AdminError {
|
|||||||
#[error("Trying to access admin dashboard without authentication.")]
|
#[error("Trying to access admin dashboard without authentication.")]
|
||||||
NotAuthenticated,
|
NotAuthenticated,
|
||||||
#[error("Updating password failed.")]
|
#[error("Updating password failed.")]
|
||||||
ChangePassword(String),
|
ChangePassword(anyhow::Error),
|
||||||
#[error("Could not publish newsletter.")]
|
#[error("Could not publish newsletter.")]
|
||||||
Publish(#[source] anyhow::Error),
|
Publish(#[source] anyhow::Error),
|
||||||
#[error("The idempotency key was invalid.")]
|
#[error("The idempotency key was invalid.")]
|
||||||
|
|||||||
@@ -31,16 +31,16 @@ pub async fn change_password(
|
|||||||
password: form.current_password,
|
password: form.current_password,
|
||||||
};
|
};
|
||||||
if form.new_password.expose_secret() != form.new_password_check.expose_secret() {
|
if form.new_password.expose_secret() != form.new_password_check.expose_secret() {
|
||||||
Err(AdminError::ChangePassword(
|
Err(AdminError::ChangePassword(anyhow::anyhow!(
|
||||||
"You entered two different passwords - the field values must match.".to_string(),
|
"You entered two different passwords - the field values must match."
|
||||||
)
|
))
|
||||||
.into())
|
.into())
|
||||||
} else if let Err(e) = validate_credentials(credentials, &connection_pool).await {
|
} else if let Err(e) = validate_credentials(credentials, &connection_pool).await {
|
||||||
match e {
|
match e {
|
||||||
AuthError::UnexpectedError(error) => Err(AdminError::UnexpectedError(error).into()),
|
AuthError::UnexpectedError(error) => Err(AdminError::UnexpectedError(error).into()),
|
||||||
AuthError::InvalidCredentials(_) => Err(AdminError::ChangePassword(
|
AuthError::InvalidCredentials(_) => Err(AdminError::ChangePassword(anyhow::anyhow!(
|
||||||
"The current password is incorrect.".to_string(),
|
"The current password is incorrect."
|
||||||
)
|
))
|
||||||
.into()),
|
.into()),
|
||||||
}
|
}
|
||||||
} else if let Err(e) = verify_password(form.new_password.expose_secret()) {
|
} else if let Err(e) = verify_password(form.new_password.expose_secret()) {
|
||||||
@@ -48,7 +48,7 @@ pub async fn change_password(
|
|||||||
} else {
|
} else {
|
||||||
authentication::change_password(user_id, form.new_password, &connection_pool)
|
authentication::change_password(user_id, form.new_password, &connection_pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AdminError::ChangePassword(e.to_string()))?;
|
.map_err(AdminError::ChangePassword)?;
|
||||||
let template = MessageTemplate::Success {
|
let template = MessageTemplate::Success {
|
||||||
message: "Your password has been changed.".to_string(),
|
message: "Your password has been changed.".to_string(),
|
||||||
};
|
};
|
||||||
@@ -56,9 +56,9 @@ pub async fn change_password(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn verify_password(password: &str) -> Result<(), String> {
|
fn verify_password(password: &str) -> Result<(), anyhow::Error> {
|
||||||
if password.len() < 12 || password.len() > 128 {
|
if password.len() < 12 || password.len() > 128 {
|
||||||
return Err("The password must contain between 12 and 128 characters.".into());
|
anyhow::bail!("The password must contain between 12 and 128 characters.");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ pub async fn admin_dashboard(
|
|||||||
Ok(Html(template.render().unwrap()).into_response())
|
Ok(Html(template.render().unwrap()).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument("Computing dashboard stats", skip_all)]
|
||||||
async fn get_stats(connection_pool: &PgPool) -> Result<DashboardStats, anyhow::Error> {
|
async fn get_stats(connection_pool: &PgPool) -> Result<DashboardStats, anyhow::Error> {
|
||||||
let subscribers =
|
let subscribers =
|
||||||
sqlx::query_scalar!("SELECT count(*) FROM subscriptions WHERE status = 'confirmed'")
|
sqlx::query_scalar!("SELECT count(*) FROM subscriptions WHERE status = 'confirmed'")
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
use std::fmt::Display;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
authentication::AuthenticatedUser,
|
authentication::AuthenticatedUser,
|
||||||
idempotency::{IdempotencyKey, save_response, try_processing},
|
idempotency::{IdempotencyKey, save_response, try_processing},
|
||||||
@@ -15,6 +13,7 @@ use axum::{
|
|||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use sqlx::{Executor, Postgres, Transaction};
|
use sqlx::{Executor, Postgres, Transaction};
|
||||||
|
use std::fmt::Display;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
@@ -25,13 +24,14 @@ pub struct BodyData {
|
|||||||
idempotency_key: String,
|
idempotency_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip_all)]
|
#[tracing::instrument(name = "Creating newsletter isue", skip_all, fields(issue_id = tracing::field::Empty))]
|
||||||
pub async fn insert_newsletter_issue(
|
pub async fn insert_newsletter_issue(
|
||||||
transaction: &mut Transaction<'static, Postgres>,
|
transaction: &mut Transaction<'static, Postgres>,
|
||||||
title: &str,
|
title: &str,
|
||||||
email_template: &dyn EmailTemplate,
|
email_template: &dyn EmailTemplate,
|
||||||
) -> Result<Uuid, sqlx::Error> {
|
) -> Result<Uuid, sqlx::Error> {
|
||||||
let newsletter_issue_id = Uuid::new_v4();
|
let newsletter_issue_id = Uuid::new_v4();
|
||||||
|
tracing::Span::current().record("issue_id", newsletter_issue_id.to_string());
|
||||||
let query = sqlx::query!(
|
let query = sqlx::query!(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO newsletter_issues (
|
INSERT INTO newsletter_issues (
|
||||||
@@ -48,6 +48,7 @@ pub async fn insert_newsletter_issue(
|
|||||||
Ok(newsletter_issue_id)
|
Ok(newsletter_issue_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub enum EmailType {
|
pub enum EmailType {
|
||||||
NewPost,
|
NewPost,
|
||||||
Newsletter,
|
Newsletter,
|
||||||
@@ -62,7 +63,7 @@ impl Display for EmailType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip_all)]
|
#[tracing::instrument(name = "Adding new task to queue", skip(transaction))]
|
||||||
pub async fn enqueue_delivery_tasks(
|
pub async fn enqueue_delivery_tasks(
|
||||||
transaction: &mut Transaction<'static, Postgres>,
|
transaction: &mut Transaction<'static, Postgres>,
|
||||||
newsletter_issue_id: Uuid,
|
newsletter_issue_id: Uuid,
|
||||||
@@ -87,7 +88,7 @@ pub async fn enqueue_delivery_tasks(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(name = "Publishing a newsletter", skip(connection_pool, form))]
|
#[tracing::instrument(name = "Publishing a newsletter", skip_all, fields(title = %form.title))]
|
||||||
pub async fn publish_newsletter(
|
pub async fn publish_newsletter(
|
||||||
State(AppState {
|
State(AppState {
|
||||||
connection_pool,
|
connection_pool,
|
||||||
@@ -134,12 +135,12 @@ pub async fn publish_newsletter(
|
|||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_form(form: &BodyData) -> Result<(), &'static str> {
|
fn validate_form(form: &BodyData) -> Result<(), anyhow::Error> {
|
||||||
if form.title.is_empty() {
|
if form.title.is_empty() {
|
||||||
return Err("The title was empty.");
|
anyhow::bail!("The title was empty.");
|
||||||
}
|
}
|
||||||
if form.html.is_empty() || form.text.is_empty() {
|
if form.html.is_empty() || form.text.is_empty() {
|
||||||
return Err("The content was empty.");
|
anyhow::bail!("The content was empty.");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ fn validate_form(form: &CreatePostForm) -> Result<(), anyhow::Error> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(name = "Creating a post", skip(connection_pool, form))]
|
#[tracing::instrument(
|
||||||
|
name = "Publishing new blog post",
|
||||||
|
skip(connection_pool, base_url, form)
|
||||||
|
fields(title = %form.title)
|
||||||
|
)]
|
||||||
pub async fn create_post(
|
pub async fn create_post(
|
||||||
State(AppState {
|
State(AppState {
|
||||||
connection_pool,
|
connection_pool,
|
||||||
@@ -79,10 +83,7 @@ pub async fn create_post(
|
|||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(
|
#[tracing::instrument(name = "Saving new blog post in the database", skip_all)]
|
||||||
name = "Saving new post in the database",
|
|
||||||
skip(transaction, title, content, author)
|
|
||||||
)]
|
|
||||||
pub async fn insert_post(
|
pub async fn insert_post(
|
||||||
transaction: &mut Transaction<'static, Postgres>,
|
transaction: &mut Transaction<'static, Postgres>,
|
||||||
title: &str,
|
title: &str,
|
||||||
@@ -105,10 +106,7 @@ pub async fn insert_post(
|
|||||||
Ok(post_id)
|
Ok(post_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(
|
#[tracing::instrument(name = "Creating newsletter for new post", skip_all)]
|
||||||
name = "Creating newsletter for new post",
|
|
||||||
skip(transaction, post_title, post_id)
|
|
||||||
)]
|
|
||||||
pub async fn create_newsletter(
|
pub async fn create_newsletter(
|
||||||
transaction: &mut Transaction<'static, Postgres>,
|
transaction: &mut Transaction<'static, Postgres>,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
const SUBS_PER_PAGE: i64 = 5;
|
const SUBS_PER_PAGE: i64 = 5;
|
||||||
|
|
||||||
|
#[tracing::instrument(
|
||||||
|
name = "Retrieving most recent subscribers from database",
|
||||||
|
skip(connection_pool)
|
||||||
|
)]
|
||||||
pub async fn get_subscribers_page(
|
pub async fn get_subscribers_page(
|
||||||
State(AppState {
|
State(AppState {
|
||||||
connection_pool, ..
|
connection_pool, ..
|
||||||
@@ -38,34 +42,52 @@ pub async fn get_subscribers_page(
|
|||||||
Ok(Html(template.render().unwrap()).into_response())
|
Ok(Html(template.render().unwrap()).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(
|
||||||
|
name = "Deleting subscriber from database",
|
||||||
|
skip(connection_pool),
|
||||||
|
fields(email=tracing::field::Empty)
|
||||||
|
)]
|
||||||
pub async fn delete_subscriber(
|
pub async fn delete_subscriber(
|
||||||
State(AppState {
|
State(AppState {
|
||||||
connection_pool, ..
|
connection_pool, ..
|
||||||
}): State<AppState>,
|
}): State<AppState>,
|
||||||
Path(subscriber_id): Path<Uuid>,
|
Path(subscriber_id): Path<Uuid>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let res = sqlx::query!("DELETE FROM subscriptions WHERE id = $1", subscriber_id)
|
let res = sqlx::query!(
|
||||||
.execute(&connection_pool)
|
"DELETE FROM subscriptions WHERE id = $1 RETURNING email",
|
||||||
.await
|
subscriber_id
|
||||||
.context("Failed to delete subscriber from database.")
|
)
|
||||||
.map_err(AppError::unexpected_message)?;
|
.fetch_optional(&connection_pool)
|
||||||
if res.rows_affected() > 1 {
|
.await
|
||||||
|
.context("Failed to delete subscriber from database.")
|
||||||
|
.map_err(AppError::unexpected_message)?;
|
||||||
|
if let Some(record) = res {
|
||||||
|
tracing::Span::current().record("email", tracing::field::display(&record.email));
|
||||||
|
let template = MessageTemplate::Success {
|
||||||
|
message: format!(
|
||||||
|
"The subscriber with email '{}' has been deleted.",
|
||||||
|
record.email
|
||||||
|
),
|
||||||
|
};
|
||||||
|
Ok(template.render().unwrap().into_response())
|
||||||
|
} else {
|
||||||
Err(AppError::unexpected_message(anyhow::anyhow!(
|
Err(AppError::unexpected_message(anyhow::anyhow!(
|
||||||
"We could not find the subscriber in the database."
|
"We could not find the subscriber in the database."
|
||||||
)))
|
)))
|
||||||
} else {
|
|
||||||
let template = MessageTemplate::Success {
|
|
||||||
message: "The subscriber has been deleted.".into(),
|
|
||||||
};
|
|
||||||
Ok(template.render().unwrap().into_response())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(
|
||||||
|
name = "Retrieving next subscribers in database",
|
||||||
|
skip(connection_pool),
|
||||||
|
fields(offset = tracing::field::Empty)
|
||||||
|
)]
|
||||||
pub async fn get_subs(
|
pub async fn get_subs(
|
||||||
connection_pool: &PgPool,
|
connection_pool: &PgPool,
|
||||||
page: i64,
|
page: i64,
|
||||||
) -> Result<Vec<SubscriberEntry>, sqlx::Error> {
|
) -> Result<Vec<SubscriberEntry>, sqlx::Error> {
|
||||||
let offset = (page - 1) * SUBS_PER_PAGE;
|
let offset = (page - 1) * SUBS_PER_PAGE;
|
||||||
|
tracing::Span::current().record("offset", tracing::field::display(&offset));
|
||||||
let subscribers = sqlx::query_as!(
|
let subscribers = sqlx::query_as!(
|
||||||
SubscriberEntry,
|
SubscriberEntry,
|
||||||
"SELECT * FROM subscriptions ORDER BY subscribed_at DESC LIMIT $1 OFFSET $2",
|
"SELECT * FROM subscriptions ORDER BY subscribed_at DESC LIMIT $1 OFFSET $2",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use axum::{http::StatusCode, response::IntoResponse};
|
use axum::http::StatusCode;
|
||||||
|
|
||||||
pub async fn health_check() -> impl IntoResponse {
|
pub async fn health_check() -> StatusCode {
|
||||||
StatusCode::OK
|
StatusCode::OK
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
use askama::Template;
|
use crate::templates::{HomeTemplate, HtmlTemplate};
|
||||||
use axum::response::Html;
|
use axum::response::{IntoResponse, Response};
|
||||||
|
|
||||||
use crate::templates::HomeTemplate;
|
pub async fn home() -> Response {
|
||||||
|
let template = HtmlTemplate(HomeTemplate);
|
||||||
pub async fn home() -> Html<String> {
|
template.into_response()
|
||||||
Html(HomeTemplate.render().unwrap())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ pub async fn get_login(session: TypedSession) -> Result<Response, AppError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "Authenticating user", skip_all, fields(name = %form.username))]
|
||||||
pub async fn post_login(
|
pub async fn post_login(
|
||||||
session: TypedSession,
|
session: TypedSession,
|
||||||
State(AppState {
|
State(AppState {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
const NUM_PER_PAGE: i64 = 3;
|
const NUM_PER_PAGE: i64 = 3;
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "Fetching most recent posts from database", skip_all)]
|
||||||
pub async fn list_posts(
|
pub async fn list_posts(
|
||||||
State(AppState {
|
State(AppState {
|
||||||
connection_pool, ..
|
connection_pool, ..
|
||||||
@@ -67,6 +68,7 @@ pub struct PostParams {
|
|||||||
origin: Option<Uuid>,
|
origin: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "Fetching post from database", skip(connection_pool, origin))]
|
||||||
pub async fn see_post(
|
pub async fn see_post(
|
||||||
State(AppState {
|
State(AppState {
|
||||||
connection_pool, ..
|
connection_pool, ..
|
||||||
@@ -94,6 +96,7 @@ pub async fn see_post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "Mark email notification as opened", skip(connection_pool))]
|
||||||
async fn mark_email_as_opened(connection_pool: &PgPool, email_id: Uuid) -> Result<(), AppError> {
|
async fn mark_email_as_opened(connection_pool: &PgPool, email_id: Uuid) -> Result<(), AppError> {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"UPDATE notifications_delivered SET opened = TRUE WHERE email_id = $1",
|
"UPDATE notifications_delivered SET opened = TRUE WHERE email_id = $1",
|
||||||
@@ -129,6 +132,7 @@ pub struct LoadMoreParams {
|
|||||||
page: i64,
|
page: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "Fetching next posts in the database", skip(connection_pool))]
|
||||||
pub async fn load_more(
|
pub async fn load_more(
|
||||||
State(AppState {
|
State(AppState {
|
||||||
connection_pool, ..
|
connection_pool, ..
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
#[tracing::instrument(
|
#[tracing::instrument(
|
||||||
name = "Adding a new subscriber",
|
name = "Adding a new subscriber",
|
||||||
skip(connection_pool, email_client, base_url, form),
|
skip_all,
|
||||||
fields(
|
fields(
|
||||||
subscriber_email = %form.email,
|
email = %form.email,
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub async fn subscribe(
|
pub async fn subscribe(
|
||||||
@@ -72,10 +72,7 @@ pub async fn subscribe(
|
|||||||
Ok(Html(template.render().unwrap()).into_response())
|
Ok(Html(template.render().unwrap()).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(
|
#[tracing::instrument(name = "Saving new subscriber details in the database", skip_all)]
|
||||||
name = "Saving new subscriber details in the database",
|
|
||||||
skip(transaction, new_subscriber)
|
|
||||||
)]
|
|
||||||
pub async fn insert_subscriber(
|
pub async fn insert_subscriber(
|
||||||
transaction: &mut Transaction<'_, Postgres>,
|
transaction: &mut Transaction<'_, Postgres>,
|
||||||
new_subscriber: &NewSubscriber,
|
new_subscriber: &NewSubscriber,
|
||||||
@@ -123,10 +120,7 @@ async fn store_token(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(
|
#[tracing::instrument(name = "Send confirmation email to the new subscriber", skip_all)]
|
||||||
name = "Send a confirmation email to a new subscriber",
|
|
||||||
skip(email_client, new_subscriber, base_url, subscription_token)
|
|
||||||
)]
|
|
||||||
pub async fn send_confirmation_email(
|
pub async fn send_confirmation_email(
|
||||||
email_client: &EmailClient,
|
email_client: &EmailClient,
|
||||||
new_subscriber: &NewSubscriber,
|
new_subscriber: &NewSubscriber,
|
||||||
|
|||||||
@@ -1,48 +1,39 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
routes::{Query, generate_token},
|
routes::{AppError, Query, generate_token, not_found_html},
|
||||||
startup::AppState,
|
startup::AppState,
|
||||||
templates::ConfirmTemplate,
|
templates::{ConfirmTemplate, HtmlTemplate},
|
||||||
};
|
};
|
||||||
use askama::Template;
|
use anyhow::Context;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::State,
|
||||||
http::StatusCode,
|
response::{IntoResponse, Response},
|
||||||
response::{Html, IntoResponse, Response},
|
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[tracing::instrument(name = "Confirming new subscriber", skip(params))]
|
#[tracing::instrument(name = "Confirming new subscriber", skip_all)]
|
||||||
pub async fn confirm(
|
pub async fn confirm(
|
||||||
State(AppState {
|
State(AppState {
|
||||||
connection_pool, ..
|
connection_pool, ..
|
||||||
}): State<AppState>,
|
}): State<AppState>,
|
||||||
Query(params): Query<Params>,
|
Query(params): Query<Params>,
|
||||||
) -> Response {
|
) -> Result<Response, AppError> {
|
||||||
let Ok(subscriber_id) =
|
let subscriber_id = get_subscriber_id_from_token(&connection_pool, ¶ms.subscription_token)
|
||||||
get_subscriber_id_from_token(&connection_pool, ¶ms.subscription_token).await
|
.await
|
||||||
else {
|
.context("Could not fetch subscriber id given subscription token.")?;
|
||||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
if let Some(id) = subscriber_id {
|
||||||
};
|
confirm_subscriber(&connection_pool, &id)
|
||||||
if let Some(subscriber_id) = subscriber_id {
|
|
||||||
if confirm_subscriber(&connection_pool, &subscriber_id)
|
|
||||||
.await
|
.await
|
||||||
.is_err()
|
.context("Failed to update subscriber status.")?;
|
||||||
{
|
let template = HtmlTemplate(ConfirmTemplate);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
Ok(template.into_response())
|
||||||
} else {
|
|
||||||
Html(ConfirmTemplate.render().unwrap()).into_response()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
StatusCode::UNAUTHORIZED.into_response()
|
Ok(not_found_html())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(
|
#[tracing::instrument(name = "Mark subscriber as confirmed", skip(connection_pool))]
|
||||||
name = "Mark subscriber as confirmed",
|
|
||||||
skip(connection_pool, subscriber_id)
|
|
||||||
)]
|
|
||||||
async fn confirm_subscriber(
|
async fn confirm_subscriber(
|
||||||
connection_pool: &PgPool,
|
connection_pool: &PgPool,
|
||||||
subscriber_id: &Uuid,
|
subscriber_id: &Uuid,
|
||||||
@@ -53,18 +44,11 @@ async fn confirm_subscriber(
|
|||||||
subscriber_id
|
subscriber_id
|
||||||
)
|
)
|
||||||
.execute(connection_pool)
|
.execute(connection_pool)
|
||||||
.await
|
.await?;
|
||||||
.map_err(|e| {
|
|
||||||
tracing::error!("Failed to execute query: {:?}", e);
|
|
||||||
e
|
|
||||||
})?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(
|
#[tracing::instrument(name = "Get subscriber id from token", skip(connection))]
|
||||||
name = "Get subscriber_id from token",
|
|
||||||
skip(connection, subscription_token)
|
|
||||||
)]
|
|
||||||
async fn get_subscriber_id_from_token(
|
async fn get_subscriber_id_from_token(
|
||||||
connection: &PgPool,
|
connection: &PgPool,
|
||||||
subscription_token: &str,
|
subscription_token: &str,
|
||||||
@@ -74,11 +58,7 @@ async fn get_subscriber_id_from_token(
|
|||||||
subscription_token
|
subscription_token
|
||||||
)
|
)
|
||||||
.fetch_optional(connection)
|
.fetch_optional(connection)
|
||||||
.await
|
.await?;
|
||||||
.map_err(|e| {
|
|
||||||
tracing::error!("Failed to execute query: {:?}", e);
|
|
||||||
e
|
|
||||||
})?;
|
|
||||||
Ok(saved.map(|r| r.subscriber_id))
|
Ok(saved.map(|r| r.subscriber_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ pub struct UnsubFormData {
|
|||||||
email: String,
|
email: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(
|
||||||
|
name = "Removing subscriber from database",
|
||||||
|
skip(connection_pool, email_client, base_url)
|
||||||
|
)]
|
||||||
pub async fn post_unsubscribe(
|
pub async fn post_unsubscribe(
|
||||||
State(AppState {
|
State(AppState {
|
||||||
connection_pool,
|
connection_pool,
|
||||||
@@ -54,7 +58,7 @@ pub async fn post_unsubscribe(
|
|||||||
Ok(Html(template.render().unwrap()).into_response())
|
Ok(Html(template.render().unwrap()).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(name = "Fetching unsubscribe token from the database", skip_all)]
|
#[tracing::instrument(name = "Fetching unsubscribe token from database", skip_all)]
|
||||||
async fn fetch_unsubscribe_token(
|
async fn fetch_unsubscribe_token(
|
||||||
connection_pool: &PgPool,
|
connection_pool: &PgPool,
|
||||||
subscriber_email: &SubscriberEmail,
|
subscriber_email: &SubscriberEmail,
|
||||||
@@ -69,7 +73,7 @@ async fn fetch_unsubscribe_token(
|
|||||||
Ok(r.and_then(|r| r.unsubscribe_token))
|
Ok(r.and_then(|r| r.unsubscribe_token))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(name = "Send an unsubscribe confirmation email", skip_all)]
|
#[tracing::instrument(name = "Send an confirmation email", skip_all)]
|
||||||
pub async fn send_unsubscribe_email(
|
pub async fn send_unsubscribe_email(
|
||||||
email_client: &EmailClient,
|
email_client: &EmailClient,
|
||||||
subscriber_email: &SubscriberEmail,
|
subscriber_email: &SubscriberEmail,
|
||||||
@@ -102,7 +106,7 @@ If you did not request this, you can safely ignore this email."#,
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(name = "Removing user from database if he exists", skip_all)]
|
#[tracing::instrument(name = "Removing user from database", skip(connection_pool))]
|
||||||
pub async fn unsubscribe_confirm(
|
pub async fn unsubscribe_confirm(
|
||||||
Query(UnsubQueryParams { token }): Query<UnsubQueryParams>,
|
Query(UnsubQueryParams { token }): Query<UnsubQueryParams>,
|
||||||
State(AppState {
|
State(AppState {
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ impl Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_until_stopped(self) -> Result<(), std::io::Error> {
|
pub async fn run_until_stopped(self) -> Result<(), std::io::Error> {
|
||||||
tracing::debug!("Listening on {}", self.local_addr());
|
tracing::debug!("listening on {}", self.local_addr());
|
||||||
if let Some(tls_config) = self.tls_config {
|
if let Some(tls_config) = self.tls_config {
|
||||||
axum_server::from_tcp_rustls(self.listener, tls_config)
|
axum_server::from_tcp_rustls(self.listener, tls_config)
|
||||||
.serve(self.router.into_make_service())
|
.serve(self.router.into_make_service())
|
||||||
@@ -156,7 +156,6 @@ pub fn app(
|
|||||||
method = ?request.method(),
|
method = ?request.method(),
|
||||||
matched_path,
|
matched_path,
|
||||||
request_id,
|
request_id,
|
||||||
some_other_field = tracing::field::Empty,
|
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -178,7 +177,7 @@ pub async fn favicon() -> Response {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Error while reading favicon.ico: {}", e);
|
tracing::error!(error = %e, "Error while reading favicon.ico.");
|
||||||
StatusCode::NOT_FOUND.into_response()
|
StatusCode::NOT_FOUND.into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer};
|
use tracing_subscriber::{
|
||||||
use tracing_subscriber::{fmt::MakeWriter, layer::SubscriberExt, util::SubscriberInitExt};
|
fmt::{MakeWriter, format::FmtSpan},
|
||||||
|
layer::SubscriberExt,
|
||||||
|
util::SubscriberInitExt,
|
||||||
|
};
|
||||||
|
|
||||||
pub fn init_subscriber<Sink>(sink: Sink)
|
pub fn init_subscriber<Sink>(sink: Sink)
|
||||||
where
|
where
|
||||||
Sink: for<'a> MakeWriter<'a> + Send + Sync + 'static,
|
Sink: for<'a> MakeWriter<'a> + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
let formatting_layer = BunyanFormattingLayer::new(env!("CARGO_CRATE_NAME").into(), sink);
|
|
||||||
tracing_subscriber::registry()
|
tracing_subscriber::registry()
|
||||||
.with(
|
.with(
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||||
@@ -17,8 +19,12 @@ where
|
|||||||
.into()
|
.into()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.with(JsonStorageLayer)
|
.with(
|
||||||
.with(formatting_layer)
|
tracing_subscriber::fmt::layer()
|
||||||
|
.pretty()
|
||||||
|
.with_writer(sink)
|
||||||
|
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
|
||||||
|
)
|
||||||
.init();
|
.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ use sqlx::PgPool;
|
|||||||
use wiremock::ResponseTemplate;
|
use wiremock::ResponseTemplate;
|
||||||
|
|
||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn confirmation_links_without_token_are_rejected_with_a_400(connection_pool: PgPool) {
|
async fn confirmation_links_without_token_are_rejected_with_a_404(connection_pool: PgPool) {
|
||||||
let app = TestApp::spawn(connection_pool).await;
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
|
||||||
let response = reqwest::get(&format!("{}/subscriptions/confirm", &app.address))
|
let response = reqwest::get(&format!("{}/subscriptions/confirm", &app.address))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(400, response.status().as_u16());
|
assert_eq!(404, response.status().as_u16());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
|
|||||||
Reference in New Issue
Block a user