diff --git a/crates/storage-pg/.sqlx/query-037fae6964130343453ef607791c4c3deaa01b5aaa091d3a3487caf3e2634daf.json b/crates/storage-pg/.sqlx/query-037fae6964130343453ef607791c4c3deaa01b5aaa091d3a3487caf3e2634daf.json new file mode 100644 index 00000000..27b346a8 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-037fae6964130343453ef607791c4c3deaa01b5aaa091d3a3487caf3e2634daf.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_terms (user_terms_id, user_id, terms_url, created_at)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (user_id, terms_url) DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "037fae6964130343453ef607791c4c3deaa01b5aaa091d3a3487caf3e2634daf" +} diff --git a/crates/storage-pg/migrations/20240207100003_user_terms.sql b/crates/storage-pg/migrations/20240207100003_user_terms.sql new file mode 100644 index 00000000..d94888a8 --- /dev/null +++ b/crates/storage-pg/migrations/20240207100003_user_terms.sql @@ -0,0 +1,32 @@ +-- Copyright 2024 The Matrix.org Foundation C.I.C. +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- Track when users have accepted the terms of service, and which version they accepted. +CREATE TABLE user_terms ( + "user_terms_id" UUID NOT NULL + PRIMARY KEY, + + -- The user who accepted the terms of service. + "user_id" UUID NOT NULL + REFERENCES users (user_id) ON DELETE CASCADE, + + -- The URL of the terms of service that the user accepted. + "terms_url" TEXT NOT NULL, + + -- When the user accepted the terms of service. + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + + -- Unique constraint to ensure that a user can only accept a given version of the terms once. + UNIQUE ("user_id", "terms_url") +); diff --git a/crates/storage-pg/src/repository.rs b/crates/storage-pg/src/repository.rs index 0ac94e10..b51004d0 100644 --- a/crates/storage-pg/src/repository.rs +++ b/crates/storage-pg/src/repository.rs @@ -54,7 +54,7 @@ use crate::{ }, user::{ PgBrowserSessionRepository, PgUserEmailRepository, PgUserPasswordRepository, - PgUserRepository, + PgUserRepository, PgUserTermsRepository, }, DatabaseError, }; @@ -179,6 +179,12 @@ where Box::new(PgUserPasswordRepository::new(self.conn.as_mut())) } + fn user_terms<'c>( + &'c mut self, + ) -> Box + 'c> { + Box::new(PgUserTermsRepository::new(self.conn.as_mut())) + } + fn browser_session<'c>( &'c mut self, ) -> Box + 'c> { diff --git a/crates/storage-pg/src/user/mod.rs b/crates/storage-pg/src/user/mod.rs index 34fc37ce..01ba7529 100644 --- a/crates/storage-pg/src/user/mod.rs +++ b/crates/storage-pg/src/user/mod.rs @@ -29,13 +29,14 @@ use crate::{tracing::ExecuteExt, DatabaseError}; mod email; mod password; mod session; +mod terms; #[cfg(test)] mod tests; pub use self::{ email::PgUserEmailRepository, password::PgUserPasswordRepository, - session::PgBrowserSessionRepository, + session::PgBrowserSessionRepository, terms::PgUserTermsRepository, }; /// An implementation of [`UserRepository`] for a PostgreSQL connection diff --git a/crates/storage-pg/src/user/terms.rs b/crates/storage-pg/src/user/terms.rs new file mode 100644 index 00000000..abb58e9e --- /dev/null +++ b/crates/storage-pg/src/user/terms.rs @@ -0,0 +1,82 @@ +// Copyright 2024 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use async_trait::async_trait; +use mas_data_model::User; +use mas_storage::{user::UserTermsRepository, Clock}; +use rand::RngCore; +use sqlx::PgConnection; +use ulid::Ulid; +use url::Url; +use uuid::Uuid; + +use crate::{tracing::ExecuteExt, DatabaseError}; + +/// An implementation of [`UserTermsRepository`] for a PostgreSQL connection +pub struct PgUserTermsRepository<'c> { + conn: &'c mut PgConnection, +} + +impl<'c> PgUserTermsRepository<'c> { + /// Create a new [`PgUserTermsRepository`] from an active PostgreSQL + /// connection + pub fn new(conn: &'c mut PgConnection) -> Self { + Self { conn } + } +} + +#[async_trait] +impl<'c> UserTermsRepository for PgUserTermsRepository<'c> { + type Error = DatabaseError; + + #[tracing::instrument( + name = "db.user_terms.accept_terms", + skip_all, + fields( + db.statement, + %user.id, + user_terms.id, + %user_terms.url = terms_url.as_str(), + ), + err, + )] + async fn accept_terms( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + user: &User, + terms_url: Url, + ) -> Result<(), Self::Error> { + let created_at = clock.now(); + let id = Ulid::from_datetime_with_source(created_at.into(), rng); + tracing::Span::current().record("user_terms.id", tracing::field::display(id)); + + sqlx::query!( + r#" + INSERT INTO user_terms (user_terms_id, user_id, terms_url, created_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, terms_url) DO NOTHING + "#, + Uuid::from(id), + Uuid::from(user.id), + terms_url.as_str(), + created_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + Ok(()) + } +} diff --git a/crates/storage-pg/src/user/tests.rs b/crates/storage-pg/src/user/tests.rs index 53ab9b9e..3e7bc090 100644 --- a/crates/storage-pg/src/user/tests.rs +++ b/crates/storage-pg/src/user/tests.rs @@ -517,3 +517,58 @@ async fn test_user_session(pool: PgPool) { // This time the session is finished assert!(session_lookup.finished_at.is_some()); } + +#[sqlx::test(migrator = "crate::MIGRATOR")] +async fn test_user_terms(pool: PgPool) { + let mut repo = PgRepository::from_pool(&pool).await.unwrap(); + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + + let user = repo + .user() + .add(&mut rng, &clock, "john".to_owned()) + .await + .unwrap(); + + // Accepting the terms should work + repo.user_terms() + .accept_terms( + &mut rng, + &clock, + &user, + "https://example.com/terms".parse().unwrap(), + ) + .await + .unwrap(); + + // Accepting a second time should also work + repo.user_terms() + .accept_terms( + &mut rng, + &clock, + &user, + "https://example.com/terms".parse().unwrap(), + ) + .await + .unwrap(); + + // Accepting a different terms should also work + repo.user_terms() + .accept_terms( + &mut rng, + &clock, + &user, + "https://example.com/terms?v=2".parse().unwrap(), + ) + .await + .unwrap(); + + let mut conn = repo.into_inner(); + + // We should have two rows, as the first terms was deduped + let res: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_terms") + .fetch_one(&mut *conn) + .await + .unwrap(); + assert_eq!(res, 2); +} diff --git a/crates/storage/src/repository.rs b/crates/storage/src/repository.rs index 5328178b..65f4526d 100644 --- a/crates/storage/src/repository.rs +++ b/crates/storage/src/repository.rs @@ -30,7 +30,10 @@ use crate::{ UpstreamOAuthLinkRepository, UpstreamOAuthProviderRepository, UpstreamOAuthSessionRepository, }, - user::{BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, UserRepository}, + user::{ + BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, UserRepository, + UserTermsRepository, + }, MapErr, }; @@ -146,6 +149,9 @@ pub trait RepositoryAccess: Send { fn user_password<'c>(&'c mut self) -> Box + 'c>; + /// Get an [`UserTermsRepository`] + fn user_terms<'c>(&'c mut self) -> Box + 'c>; + /// Get a [`BrowserSessionRepository`] fn browser_session<'c>( &'c mut self, @@ -231,6 +237,7 @@ mod impls { }, user::{ BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, UserRepository, + UserTermsRepository, }, MapErr, Repository, RepositoryTransaction, }; @@ -315,6 +322,10 @@ mod impls { Box::new(MapErr::new(self.inner.user_password(), &mut self.mapper)) } + fn user_terms<'c>(&'c mut self) -> Box + 'c> { + Box::new(MapErr::new(self.inner.user_terms(), &mut self.mapper)) + } + fn browser_session<'c>( &'c mut self, ) -> Box + 'c> { @@ -445,6 +456,10 @@ mod impls { (**self).user_password() } + fn user_terms<'c>(&'c mut self) -> Box + 'c> { + (**self).user_terms() + } + fn browser_session<'c>( &'c mut self, ) -> Box + 'c> { diff --git a/crates/storage/src/user/mod.rs b/crates/storage/src/user/mod.rs index 86c9adcb..9f845f8e 100644 --- a/crates/storage/src/user/mod.rs +++ b/crates/storage/src/user/mod.rs @@ -24,11 +24,13 @@ use crate::{repository_impl, Clock}; mod email; mod password; mod session; +mod terms; pub use self::{ email::{UserEmailFilter, UserEmailRepository}, password::UserPasswordRepository, session::{BrowserSessionFilter, BrowserSessionRepository}, + terms::UserTermsRepository, }; /// A [`UserRepository`] helps interacting with [`User`] saved in the storage diff --git a/crates/storage/src/user/terms.rs b/crates/storage/src/user/terms.rs new file mode 100644 index 00000000..292197a2 --- /dev/null +++ b/crates/storage/src/user/terms.rs @@ -0,0 +1,57 @@ +// Copyright 2024 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use async_trait::async_trait; +use mas_data_model::User; +use rand_core::RngCore; +use url::Url; + +use crate::{repository_impl, Clock}; + +/// A [`UserTermsRepository`] helps interacting with the terms of service agreed by a [`User`] +#[async_trait] +pub trait UserTermsRepository: Send + Sync { + /// The error type returned by the repository + type Error; + + /// Accept the terms of service by a [`User`] + /// + /// # Parameters + /// + /// * `rng`: A random number generator used to generate IDs + /// * `clock`: The clock used to generate timestamps + /// * `user`: The [`User`] accepting the terms + /// * `terms_url`: The URL of the terms of service the user is accepting + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn accept_terms( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + user: &User, + terms_url: Url, + ) -> Result<(), Self::Error>; +} + +repository_impl!(UserTermsRepository: + async fn accept_terms( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + user: &User, + terms_url: Url, + ) -> Result<(), Self::Error>; +);