generated from phundrak/rust-poem-openapi-template
feat: OAuth implementation with Discord
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:
1
gejdr-core/.gitignore
vendored
Normal file
1
gejdr-core/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
16
gejdr-core/Cargo.toml
Normal file
16
gejdr-core/Cargo.toml
Normal 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"]
|
||||
3
gejdr-core/migrations/20240809173617_users.down.sql
Normal file
3
gejdr-core/migrations/20240809173617_users.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Add down migration script here
|
||||
DROP TABLE IF EXISTS public.users;
|
||||
DROP EXTENSION IF EXISTS "uuid-ossp";
|
||||
15
gejdr-core/migrations/20240809173617_users.up.sql
Normal file
15
gejdr-core/migrations/20240809173617_users.up.sql
Normal 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)
|
||||
);
|
||||
50
gejdr-core/src/database.rs
Normal file
50
gejdr-core/src/database.rs
Normal 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
4
gejdr-core/src/lib.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod database;
|
||||
pub mod models;
|
||||
pub mod telemetry;
|
||||
pub use sqlx;
|
||||
396
gejdr-core/src/models/accounts.rs
Normal file
396
gejdr-core/src/models/accounts.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
2
gejdr-core/src/models/fixtures/accounts.sql
Normal file
2
gejdr-core/src/models/fixtures/accounts.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO users (id, username) VALUES ('id1', 'user1');
|
||||
INSERT INTO users (id, username) VALUES ('id2', 'user2');
|
||||
1
gejdr-core/src/models/mod.rs
Normal file
1
gejdr-core/src/models/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod accounts;
|
||||
28
gejdr-core/src/telemetry.rs
Normal file
28
gejdr-core/src/telemetry.rs
Normal 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");
|
||||
}
|
||||
Reference in New Issue
Block a user