feat: OAuth implementation with Discord
All checks were successful
CI / tests (push) Successful in 10m39s
CI / tests (pull_request) Successful in 11m17s

This commit separates the core features of GéJDR from the backend as
these will also be used by the bot in the future.

This commit also updates the dependencies of the project. It also
removes the dependency lettre as well as the mailpit docker service
for developers as it appears clearer this project won’t send emails
anytime soon.

The publication of a docker image is also postponed until later.
This commit is contained in:
2024-08-10 11:06:18 +02:00
parent 2013d04cf7
commit aac70e4131
49 changed files with 2699 additions and 720 deletions

1
gejdr-core/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

16
gejdr-core/Cargo.toml Normal file
View File

@@ -0,0 +1,16 @@
[package]
name = "gejdr-core"
version = "0.1.0"
edition = "2021"
[dependencies]
chrono = { version = "0.4.38", features = ["serde"] }
serde = "1.0.215"
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["fmt", "std", "env-filter", "registry", "json", "tracing-log"] }
uuid = { version = "1.11.0", features = ["v4", "serde"] }
[dependencies.sqlx]
version = "0.8.2"
default-features = false
features = ["postgres", "uuid", "chrono", "migrate", "runtime-tokio", "macros"]

View File

@@ -0,0 +1,3 @@
-- Add down migration script here
DROP TABLE IF EXISTS public.users;
DROP EXTENSION IF EXISTS "uuid-ossp";

View File

@@ -0,0 +1,15 @@
-- Add up migration script here
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE IF NOT EXISTS public.users
(
id character varying(255) NOT NULL,
username character varying(255) NOT NULL,
email character varying(255),
avatar character varying(511),
name character varying(255),
created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_updated timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
CONSTRAINT users_email_unique UNIQUE (email)
);

View File

@@ -0,0 +1,50 @@
use sqlx::ConnectOptions;
#[derive(Debug, serde::Deserialize, Clone, Default)]
pub struct Database {
pub host: String,
pub port: u16,
pub name: String,
pub user: String,
pub password: String,
pub require_ssl: bool,
}
impl Database {
#[must_use]
pub fn get_connect_options(&self) -> sqlx::postgres::PgConnectOptions {
let ssl_mode = if self.require_ssl {
sqlx::postgres::PgSslMode::Require
} else {
sqlx::postgres::PgSslMode::Prefer
};
sqlx::postgres::PgConnectOptions::new()
.host(&self.host)
.username(&self.user)
.password(&self.password)
.port(self.port)
.ssl_mode(ssl_mode)
.database(&self.name)
.log_statements(tracing::log::LevelFilter::Trace)
}
#[must_use]
pub fn get_connection_pool(&self) -> sqlx::postgres::PgPool {
tracing::event!(
target: "startup",
tracing::Level::INFO,
"connecting to database with configuration {:?}",
self.clone()
);
sqlx::postgres::PgPoolOptions::new()
.acquire_timeout(std::time::Duration::from_secs(2))
.connect_lazy_with(self.get_connect_options())
}
pub async fn migrate(pool: &sqlx::PgPool) {
sqlx::migrate!()
.run(pool)
.await
.expect("Failed to migrate the database");
}
}

4
gejdr-core/src/lib.rs Normal file
View File

@@ -0,0 +1,4 @@
pub mod database;
pub mod models;
pub mod telemetry;
pub use sqlx;

View File

@@ -0,0 +1,396 @@
use sqlx::PgPool;
type Timestampz = chrono::DateTime<chrono::Utc>;
#[derive(serde::Deserialize, PartialEq, Eq, Debug, Clone, Default)]
pub struct RemoteUser {
id: String,
username: String,
global_name: Option<String>,
email: Option<String>,
avatar: Option<String>,
}
impl RemoteUser {
/// Refresh in database the row related to the remote user. Maybe
/// create a row for this user if needed.
pub async fn refresh_in_database(self, pool: &PgPool) -> Result<User, sqlx::Error> {
match User::find(pool, &self.id).await? {
Some(local_user) => local_user.update_from_remote(self).update(pool).await,
None => User::from(self).save(pool).await,
}
}
}
#[derive(serde::Deserialize, serde::Serialize, Debug, PartialEq, Eq, Default, Clone)]
pub struct User {
pub id: String,
pub username: String,
pub email: Option<String>,
pub avatar: Option<String>,
pub name: Option<String>,
pub created_at: Timestampz,
pub last_updated: Timestampz,
}
impl From<RemoteUser> for User {
fn from(value: RemoteUser) -> Self {
Self {
id: value.id,
username: value.username,
email: value.email,
avatar: value.avatar,
name: value.global_name,
created_at: chrono::offset::Utc::now(),
last_updated: chrono::offset::Utc::now(),
}
}
}
impl PartialEq<RemoteUser> for User {
#[allow(clippy::suspicious_operation_groupings)]
fn eq(&self, other: &RemoteUser) -> bool {
self.id == other.id
&& self.username == other.username
&& self.email == other.email
&& self.avatar == other.avatar
&& self.name == other.global_name
}
}
impl PartialEq<User> for RemoteUser {
fn eq(&self, other: &User) -> bool {
other == self
}
}
impl User {
pub fn update_from_remote(self, from: RemoteUser) -> Self {
if self == from {
self
} else {
Self {
username: from.username,
email: from.email,
avatar: from.avatar,
name: from.global_name,
last_updated: chrono::offset::Utc::now(),
..self
}
}
}
pub async fn find(pool: &PgPool, id: &String) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as!(Self, r#"SELECT * FROM users WHERE id = $1"#, id)
.fetch_optional(pool)
.await
}
pub async fn save(&self, pool: &PgPool) -> Result<Self, sqlx::Error> {
sqlx::query_as!(
Self,
r#"
INSERT INTO users (id, username, email, avatar, name, created_at, last_updated)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
"#,
self.id,
self.username,
self.email,
self.avatar,
self.name,
self.created_at,
self.last_updated
)
.fetch_one(pool)
.await
}
pub async fn update(&self, pool: &PgPool) -> Result<Self, sqlx::Error> {
sqlx::query_as!(
Self,
r#"
UPDATE users
SET username = $1, email = $2, avatar = $3, name = $4, last_updated = $5
WHERE id = $6
RETURNING *
"#,
self.username,
self.email,
self.avatar,
self.name,
self.last_updated,
self.id
)
.fetch_one(pool)
.await
}
pub async fn save_or_update(&self, pool: &PgPool) -> Result<Self, sqlx::Error> {
if Self::find(pool, &self.id).await?.is_some() {
self.update(pool).await
} else {
self.save(pool).await
}
}
pub async fn delete(pool: &PgPool, id: &String) -> Result<u64, sqlx::Error> {
let rows_affected = sqlx::query!("DELETE FROM users WHERE id = $1", id)
.execute(pool)
.await?
.rows_affected();
Ok(rows_affected)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn convert_remote_user_to_local_user() {
let remote = RemoteUser {
id: "user-id".into(),
username: "username".into(),
global_name: None,
email: Some("user@example.com".into()),
avatar: None,
};
let local: User = remote.into();
let expected = User {
id: "user-id".into(),
username: "username".into(),
email: Some("user@example.com".into()),
avatar: None,
name: None,
created_at: local.created_at,
last_updated: local.last_updated,
};
assert_eq!(expected, local);
}
#[test]
fn can_compare_remote_and_local_user() {
let remote_same = RemoteUser {
id: "user-id".into(),
username: "username".into(),
global_name: None,
email: Some("user@example.com".into()),
avatar: None,
};
let remote_different = RemoteUser {
id: "user-id".into(),
username: "username".into(),
global_name: None,
email: Some("user@example.com".into()),
avatar: Some("some-hash".into()),
};
let local = User {
id: "user-id".into(),
username: "username".into(),
email: Some("user@example.com".into()),
avatar: None,
name: None,
created_at: chrono::offset::Utc::now(),
last_updated: chrono::offset::Utc::now(),
};
assert_eq!(remote_same, local);
assert_ne!(remote_different, local);
}
#[sqlx::test]
async fn add_new_remote_users_in_database(pool: sqlx::PgPool) -> sqlx::Result<()> {
let remote1 = RemoteUser {
id: "id1".into(),
username: "user1".into(),
..Default::default()
};
let remote2 = RemoteUser {
id: "id2".into(),
username: "user2".into(),
..Default::default()
};
remote1.refresh_in_database(&pool).await?;
remote2.refresh_in_database(&pool).await?;
let users = sqlx::query_as!(User, "SELECT * FROM users")
.fetch_all(&pool)
.await?;
assert_eq!(2, users.len());
Ok(())
}
#[sqlx::test(fixtures("accounts"))]
async fn update_local_users_in_db_from_remote(pool: sqlx::PgPool) -> sqlx::Result<()> {
let users = sqlx::query_as!(User, "SELECT * FROM users")
.fetch_all(&pool)
.await?;
assert_eq!(2, users.len());
let remote1 = RemoteUser {
id: "id1".into(),
username: "user1-new".into(),
..Default::default()
};
let remote2 = RemoteUser {
id: "id2".into(),
username: "user2-new".into(),
..Default::default()
};
remote1.refresh_in_database(&pool).await?;
remote2.refresh_in_database(&pool).await?;
let users = sqlx::query_as!(User, "SELECT * FROM users")
.fetch_all(&pool)
.await?;
assert_eq!(2, users.len());
users
.iter()
.for_each(|user| assert!(user.last_updated > user.created_at));
Ok(())
}
#[test]
fn update_local_user_from_identical_remote_shouldnt_change_local() {
let remote = RemoteUser {
id: "id1".into(),
username: "user1".into(),
..Default::default()
};
let local = User {
id: "id1".into(),
username: "user1".into(),
..Default::default()
};
let new_local = local.clone().update_from_remote(remote);
assert_eq!(local, new_local);
}
#[test]
fn update_local_user_from_different_remote() {
let remote = RemoteUser {
id: "id1".into(),
username: "user2".into(),
..Default::default()
};
let local = User {
id: "id1".into(),
username: "user1".into(),
..Default::default()
};
let new_local = local.clone().update_from_remote(remote.clone());
assert_ne!(remote, local);
assert_eq!(remote, new_local);
}
#[sqlx::test]
async fn save_user_in_database(pool: sqlx::PgPool) -> sqlx::Result<()> {
let user = User {
id: "id1".into(),
username: "user1".into(),
..Default::default()
};
user.save(&pool).await?;
let users = sqlx::query_as!(User, "SELECT * FROM users")
.fetch_all(&pool)
.await?;
assert_eq!(1, users.len());
assert_eq!(Some(user), users.first().cloned());
Ok(())
}
#[sqlx::test(fixtures("accounts"))]
async fn update_user_in_database(pool: sqlx::PgPool) -> sqlx::Result<()> {
let db_user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = 'id1'")
.fetch_one(&pool)
.await?;
assert!(db_user.name.is_none());
let user = User {
id: "id1".into(),
username: "user1".into(),
name: Some("Cool Name".into()),
..Default::default()
};
user.update(&pool).await?;
let db_user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = 'id1'")
.fetch_one(&pool)
.await?;
assert!(db_user.name.is_some());
assert_eq!(Some("Cool Name".to_string()), db_user.name);
Ok(())
}
#[sqlx::test]
async fn save_or_update_saves_if_no_exist(pool: sqlx::PgPool) -> sqlx::Result<()> {
let rows = sqlx::query_as!(User, "SELECT * FROM users")
.fetch_all(&pool)
.await?;
assert_eq!(0, rows.len());
let user = User {
id: "id1".into(),
username: "user1".into(),
..Default::default()
};
user.save_or_update(&pool).await?;
let rows = sqlx::query_as!(User, "SELECT * FROM users")
.fetch_all(&pool)
.await?;
assert_eq!(1, rows.len());
let db_user = rows.first();
assert_eq!(Some(user), db_user.cloned());
Ok(())
}
#[sqlx::test(fixtures("accounts"))]
async fn save_or_update_updates_if_exists(pool: sqlx::PgPool) -> sqlx::Result<()> {
let rows = sqlx::query_as!(User, "SELECT * FROM users")
.fetch_all(&pool)
.await?;
assert_eq!(2, rows.len());
let user = User {
id: "id1".into(),
username: "user1".into(),
name: Some("Cool Nam".into()),
..Default::default()
};
user.save_or_update(&pool).await?;
let rows = sqlx::query_as!(User, "SELECT * FROM users")
.fetch_all(&pool)
.await?;
assert_eq!(2, rows.len());
let db_user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = 'id1'")
.fetch_one(&pool)
.await?;
assert_eq!(user.name, db_user.name);
Ok(())
}
#[sqlx::test(fixtures("accounts"))]
async fn delete_removes_account_from_db(pool: sqlx::PgPool) -> sqlx::Result<()> {
let rows = sqlx::query_as!(User, "SELECT * FROM users")
.fetch_all(&pool)
.await?;
assert_eq!(2, rows.len());
let id = "id1".to_string();
let deletions = User::delete(&pool, &id).await?;
assert_eq!(1, deletions);
let rows = sqlx::query_as!(User, "SELECT * FROM users")
.fetch_all(&pool)
.await?;
assert_eq!(1, rows.len());
Ok(())
}
#[sqlx::test(fixtures("accounts"))]
async fn delete_with_wrong_id_shouldnt_delete_anything(pool: sqlx::PgPool) -> sqlx::Result<()> {
let rows = sqlx::query_as!(User, "SELECT * FROM users")
.fetch_all(&pool)
.await?;
assert_eq!(2, rows.len());
let id = "invalid".to_string();
let deletions = User::delete(&pool, &id).await?;
assert_eq!(0, deletions);
let rows = sqlx::query_as!(User, "SELECT * FROM users")
.fetch_all(&pool)
.await?;
assert_eq!(2, rows.len());
Ok(())
}
}

View File

@@ -0,0 +1,2 @@
INSERT INTO users (id, username) VALUES ('id1', 'user1');
INSERT INTO users (id, username) VALUES ('id2', 'user2');

View File

@@ -0,0 +1 @@
pub mod accounts;

View File

@@ -0,0 +1,28 @@
use tracing_subscriber::layer::SubscriberExt;
#[must_use]
pub fn get_subscriber(debug: bool) -> impl tracing::Subscriber + Send + Sync {
let env_filter = if debug { "debug" } else { "info" }.to_string();
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(env_filter));
let stdout_log = tracing_subscriber::fmt::layer().pretty().with_test_writer();
let subscriber = tracing_subscriber::Registry::default()
.with(env_filter)
.with(stdout_log);
let json_log = if debug {
None
} else {
Some(tracing_subscriber::fmt::layer().json())
};
subscriber.with(json_log)
}
/// Initialize the global tracing subscriber.
///
/// # Panics
///
/// May panic if the function fails to set `subscriber` as the default
/// global subscriber.
pub fn init_subscriber(subscriber: impl tracing::Subscriber + Send + Sync) {
tracing::subscriber::set_global_default(subscriber).expect("Failed to set subscriber");
}