From 71d9ed7d38129704a16453e3d62f5fff721dae7b Mon Sep 17 00:00:00 2001 From: lewis Date: Wed, 14 Jan 2026 22:21:38 +0200 Subject: [PATCH] tranquil-db crates, repository pattern for db access --- Cargo.lock | 37 + Cargo.toml | 6 +- crates/tranquil-db-traits/Cargo.toml | 17 + crates/tranquil-db-traits/src/backlink.rs | 28 + crates/tranquil-db-traits/src/backup.rs | 109 + crates/tranquil-db-traits/src/blob.rs | 100 + crates/tranquil-db-traits/src/delegation.rs | 141 + crates/tranquil-db-traits/src/error.rs | 39 + crates/tranquil-db-traits/src/infra.rs | 339 ++ crates/tranquil-db-traits/src/lib.rs | 58 + crates/tranquil-db-traits/src/oauth.rs | 245 ++ crates/tranquil-db-traits/src/repo.rs | 388 +++ crates/tranquil-db-traits/src/session.rs | 203 ++ crates/tranquil-db-traits/src/user.rs | 902 ++++++ crates/tranquil-db/Cargo.toml | 25 + crates/tranquil-db/src/lib.rs | 7 + crates/tranquil-db/src/postgres/backlink.rs | 99 + crates/tranquil-db/src/postgres/backup.rs | 299 ++ crates/tranquil-db/src/postgres/blob.rs | 295 ++ crates/tranquil-db/src/postgres/delegation.rs | 476 +++ .../src/postgres/event_notifier.rs | 41 + crates/tranquil-db/src/postgres/infra.rs | 1018 ++++++ crates/tranquil-db/src/postgres/mod.rs | 60 + crates/tranquil-db/src/postgres/oauth.rs | 1214 +++++++ crates/tranquil-db/src/postgres/repo.rs | 1447 +++++++++ crates/tranquil-db/src/postgres/session.rs | 567 ++++ crates/tranquil-db/src/postgres/user.rs | 2778 +++++++++++++++++ 27 files changed, 10937 insertions(+), 1 deletion(-) create mode 100644 crates/tranquil-db-traits/Cargo.toml create mode 100644 crates/tranquil-db-traits/src/backlink.rs create mode 100644 crates/tranquil-db-traits/src/backup.rs create mode 100644 crates/tranquil-db-traits/src/blob.rs create mode 100644 crates/tranquil-db-traits/src/delegation.rs create mode 100644 crates/tranquil-db-traits/src/error.rs create mode 100644 crates/tranquil-db-traits/src/infra.rs create mode 100644 crates/tranquil-db-traits/src/lib.rs create mode 100644 crates/tranquil-db-traits/src/oauth.rs create mode 100644 crates/tranquil-db-traits/src/repo.rs create mode 100644 crates/tranquil-db-traits/src/session.rs create mode 100644 crates/tranquil-db-traits/src/user.rs create mode 100644 crates/tranquil-db/Cargo.toml create mode 100644 crates/tranquil-db/src/lib.rs create mode 100644 crates/tranquil-db/src/postgres/backlink.rs create mode 100644 crates/tranquil-db/src/postgres/backup.rs create mode 100644 crates/tranquil-db/src/postgres/blob.rs create mode 100644 crates/tranquil-db/src/postgres/delegation.rs create mode 100644 crates/tranquil-db/src/postgres/event_notifier.rs create mode 100644 crates/tranquil-db/src/postgres/infra.rs create mode 100644 crates/tranquil-db/src/postgres/mod.rs create mode 100644 crates/tranquil-db/src/postgres/oauth.rs create mode 100644 crates/tranquil-db/src/postgres/repo.rs create mode 100644 crates/tranquil-db/src/postgres/session.rs create mode 100644 crates/tranquil-db/src/postgres/user.rs diff --git a/Cargo.lock b/Cargo.lock index c5ec49e..010e337 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6375,6 +6375,7 @@ dependencies = [ "sqlx", "thiserror 2.0.17", "tokio", + "tranquil-db-traits", "urlencoding", "uuid", ] @@ -6396,6 +6397,40 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "tranquil-db" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "rand 0.8.5", + "serde", + "serde_json", + "sqlx", + "thiserror 2.0.17", + "tracing", + "tranquil-db-traits", + "tranquil-oauth", + "tranquil-types", + "uuid", +] + +[[package]] +name = "tranquil-db-traits" +version = "0.1.0" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "serde", + "serde_json", + "sqlx", + "thiserror 2.0.17", + "tranquil-oauth", + "tranquil-types", + "uuid", +] + [[package]] name = "tranquil-infra" version = "0.1.0" @@ -6503,6 +6538,8 @@ dependencies = [ "tranquil-cache", "tranquil-comms", "tranquil-crypto", + "tranquil-db", + "tranquil-db-traits", "tranquil-infra", "tranquil-oauth", "tranquil-repo", diff --git a/Cargo.toml b/Cargo.toml index 5e9740e..b0d8cc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ members = [ "crates/tranquil-auth", "crates/tranquil-oauth", "crates/tranquil-comms", + "crates/tranquil-db-traits", + "crates/tranquil-db", "crates/tranquil-pds", ] @@ -30,6 +32,8 @@ tranquil-scopes = { path = "crates/tranquil-scopes" } tranquil-auth = { path = "crates/tranquil-auth" } tranquil-oauth = { path = "crates/tranquil-oauth" } tranquil-comms = { path = "crates/tranquil-comms" } +tranquil-db-traits = { path = "crates/tranquil-db-traits" } +tranquil-db = { path = "crates/tranquil-db" } aes-gcm = "0.10" backon = "1" @@ -92,7 +96,7 @@ tower-layer = "0.3" tracing = "0.1" tracing-subscriber = "0.3" urlencoding = "2.1" -uuid = { version = "1.19", features = ["v4", "v5", "v7", "fast-rng"] } +uuid = { version = "1.19", features = ["v4", "v5", "v7", "fast-rng", "serde"] } webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] } webauthn-rs-proto = "0.5" zip = { version = "7.0", default-features = false, features = ["deflate"] } diff --git a/crates/tranquil-db-traits/Cargo.toml b/crates/tranquil-db-traits/Cargo.toml new file mode 100644 index 0000000..5f9aeb1 --- /dev/null +++ b/crates/tranquil-db-traits/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "tranquil-db-traits" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +async-trait = { workspace = true } +base64 = { workspace = true } +chrono = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sqlx = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } +tranquil-oauth = { workspace = true } +tranquil-types = { workspace = true } diff --git a/crates/tranquil-db-traits/src/backlink.rs b/crates/tranquil-db-traits/src/backlink.rs new file mode 100644 index 0000000..8268001 --- /dev/null +++ b/crates/tranquil-db-traits/src/backlink.rs @@ -0,0 +1,28 @@ +use async_trait::async_trait; +use tranquil_types::{AtUri, Nsid}; +use uuid::Uuid; + +use crate::DbError; + +#[derive(Debug, Clone)] +pub struct Backlink { + pub uri: AtUri, + pub path: String, + pub link_to: String, +} + +#[async_trait] +pub trait BacklinkRepository: Send + Sync { + async fn get_backlink_conflicts( + &self, + repo_id: Uuid, + collection: &Nsid, + backlinks: &[Backlink], + ) -> Result, DbError>; + + async fn add_backlinks(&self, repo_id: Uuid, backlinks: &[Backlink]) -> Result<(), DbError>; + + async fn remove_backlinks_by_uri(&self, uri: &AtUri) -> Result<(), DbError>; + + async fn remove_backlinks_by_repo(&self, repo_id: Uuid) -> Result<(), DbError>; +} diff --git a/crates/tranquil-db-traits/src/backup.rs b/crates/tranquil-db-traits/src/backup.rs new file mode 100644 index 0000000..ee5d4ba --- /dev/null +++ b/crates/tranquil-db-traits/src/backup.rs @@ -0,0 +1,109 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use tranquil_types::Did; +use uuid::Uuid; + +use crate::DbError; + +#[derive(Debug, Clone)] +pub struct BackupRow { + pub id: Uuid, + pub repo_rev: String, + pub repo_root_cid: String, + pub block_count: i32, + pub size_bytes: i64, + pub created_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct BackupStorageInfo { + pub storage_key: String, + pub repo_rev: String, +} + +#[derive(Debug, Clone)] +pub struct BackupForDeletion { + pub id: Uuid, + pub storage_key: String, + pub deactivated_at: Option>, +} + +#[derive(Debug, Clone)] +pub struct OldBackupInfo { + pub id: Uuid, + pub storage_key: String, +} + +#[derive(Debug, Clone)] +pub struct UserBackupInfo { + pub id: Uuid, + pub did: Did, + pub backup_enabled: bool, + pub deactivated_at: Option>, + pub repo_root_cid: String, + pub repo_rev: Option, +} + +#[derive(Debug, Clone)] +pub struct BlobExportInfo { + pub cid: String, + pub storage_key: String, + pub mime_type: String, +} + +#[async_trait] +pub trait BackupRepository: Send + Sync { + async fn get_user_backup_status( + &self, + did: &Did, + ) -> Result, DbError>; + + async fn list_backups_for_user(&self, user_id: Uuid) -> Result, DbError>; + + async fn get_backup_storage_info( + &self, + backup_id: Uuid, + did: &Did, + ) -> Result, DbError>; + + async fn get_user_for_backup(&self, did: &Did) -> Result, DbError>; + + async fn insert_backup( + &self, + user_id: Uuid, + storage_key: &str, + repo_root_cid: &str, + repo_rev: &str, + block_count: i32, + size_bytes: i64, + ) -> Result; + + async fn get_old_backups( + &self, + user_id: Uuid, + retention_offset: i64, + ) -> Result, DbError>; + + async fn delete_backup(&self, backup_id: Uuid) -> Result<(), DbError>; + + async fn get_backup_for_deletion( + &self, + backup_id: Uuid, + did: &Did, + ) -> Result, DbError>; + + async fn get_user_deactivated_status(&self, did: &Did) + -> Result>>, DbError>; + + async fn update_backup_enabled(&self, did: &Did, enabled: bool) -> Result<(), DbError>; + + async fn get_user_id_by_did(&self, did: &Did) -> Result, DbError>; + + async fn get_blobs_for_export(&self, user_id: Uuid) -> Result, DbError>; + + async fn get_users_needing_backup( + &self, + backup_interval_secs: i64, + limit: i64, + ) -> Result, DbError>; +} diff --git a/crates/tranquil-db-traits/src/blob.rs b/crates/tranquil-db-traits/src/blob.rs new file mode 100644 index 0000000..ed1c697 --- /dev/null +++ b/crates/tranquil-db-traits/src/blob.rs @@ -0,0 +1,100 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use tranquil_types::{AtUri, CidLink, Did}; +use uuid::Uuid; + +use crate::DbError; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlobMetadata { + pub storage_key: String, + pub mime_type: String, + pub size_bytes: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlobWithTakedown { + pub cid: CidLink, + pub takedown_ref: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlobForExport { + pub cid: CidLink, + pub storage_key: String, + pub mime_type: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MissingBlobInfo { + pub blob_cid: CidLink, + pub record_uri: AtUri, +} + +#[async_trait] +pub trait BlobRepository: Send + Sync { + async fn insert_blob( + &self, + cid: &CidLink, + mime_type: &str, + size_bytes: i64, + created_by_user: Uuid, + storage_key: &str, + ) -> Result, DbError>; + + async fn get_blob_metadata(&self, cid: &CidLink) -> Result, DbError>; + + async fn get_blob_with_takedown( + &self, + cid: &CidLink, + ) -> Result, DbError>; + + async fn get_blob_storage_key(&self, cid: &CidLink) -> Result, DbError>; + + async fn list_blobs_by_user( + &self, + user_id: Uuid, + cursor: Option<&str>, + limit: i64, + ) -> Result, DbError>; + + async fn list_blobs_since_rev( + &self, + did: &Did, + since: &str, + ) -> Result, DbError>; + + async fn count_blobs_by_user(&self, user_id: Uuid) -> Result; + + async fn sum_blob_storage(&self) -> Result; + + async fn update_blob_takedown( + &self, + cid: &CidLink, + takedown_ref: Option<&str>, + ) -> Result; + + async fn delete_blob_by_cid(&self, cid: &CidLink) -> Result; + + async fn delete_blobs_by_user(&self, user_id: Uuid) -> Result; + + async fn get_blob_storage_keys_by_user(&self, user_id: Uuid) -> Result, DbError>; + + async fn insert_record_blobs( + &self, + repo_id: Uuid, + record_uris: &[AtUri], + blob_cids: &[CidLink], + ) -> Result<(), DbError>; + + async fn list_missing_blobs( + &self, + repo_id: Uuid, + cursor: Option<&str>, + limit: i64, + ) -> Result, DbError>; + + async fn count_distinct_record_blobs(&self, repo_id: Uuid) -> Result; + + async fn get_blobs_for_export(&self, repo_id: Uuid) -> Result, DbError>; +} diff --git a/crates/tranquil-db-traits/src/delegation.rs b/crates/tranquil-db-traits/src/delegation.rs new file mode 100644 index 0000000..2f63344 --- /dev/null +++ b/crates/tranquil-db-traits/src/delegation.rs @@ -0,0 +1,141 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use tranquil_types::{Did, Handle}; +use uuid::Uuid; + +use crate::DbError; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DelegationGrant { + pub id: Uuid, + pub delegated_did: Did, + pub controller_did: Did, + pub granted_scopes: String, + pub granted_at: DateTime, + pub granted_by: Did, + pub revoked_at: Option>, + pub revoked_by: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DelegatedAccountInfo { + pub did: Did, + pub handle: Handle, + pub granted_scopes: String, + pub granted_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ControllerInfo { + pub did: Did, + pub handle: Handle, + pub granted_scopes: String, + pub granted_at: DateTime, + pub is_active: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum DelegationActionType { + GrantCreated, + GrantRevoked, + ScopesModified, + TokenIssued, + RepoWrite, + BlobUpload, + AccountAction, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuditLogEntry { + pub id: Uuid, + pub delegated_did: Did, + pub actor_did: Did, + pub controller_did: Option, + pub action_type: DelegationActionType, + pub action_details: Option, + pub ip_address: Option, + pub user_agent: Option, + pub created_at: DateTime, +} + +#[async_trait] +pub trait DelegationRepository: Send + Sync { + async fn is_delegated_account(&self, did: &Did) -> Result; + + async fn create_delegation( + &self, + delegated_did: &Did, + controller_did: &Did, + granted_scopes: &str, + granted_by: &Did, + ) -> Result; + + async fn revoke_delegation( + &self, + delegated_did: &Did, + controller_did: &Did, + revoked_by: &Did, + ) -> Result; + + async fn update_delegation_scopes( + &self, + delegated_did: &Did, + controller_did: &Did, + new_scopes: &str, + ) -> Result; + + async fn get_delegation( + &self, + delegated_did: &Did, + controller_did: &Did, + ) -> Result, DbError>; + + async fn get_delegations_for_account( + &self, + delegated_did: &Did, + ) -> Result, DbError>; + + async fn get_accounts_controlled_by( + &self, + controller_did: &Did, + ) -> Result, DbError>; + + async fn get_active_controllers_for_account( + &self, + delegated_did: &Did, + ) -> Result, DbError>; + + async fn count_active_controllers(&self, delegated_did: &Did) -> Result; + + async fn has_any_controllers(&self, did: &Did) -> Result; + + async fn controls_any_accounts(&self, did: &Did) -> Result; + + async fn log_delegation_action( + &self, + delegated_did: &Did, + actor_did: &Did, + controller_did: Option<&Did>, + action_type: DelegationActionType, + action_details: Option, + ip_address: Option<&str>, + user_agent: Option<&str>, + ) -> Result; + + async fn get_audit_log_for_account( + &self, + delegated_did: &Did, + limit: i64, + offset: i64, + ) -> Result, DbError>; + + async fn get_audit_log_by_controller( + &self, + controller_did: &Did, + limit: i64, + offset: i64, + ) -> Result, DbError>; + + async fn count_audit_log_entries(&self, delegated_did: &Did) -> Result; +} diff --git a/crates/tranquil-db-traits/src/error.rs b/crates/tranquil-db-traits/src/error.rs new file mode 100644 index 0000000..8cdc29c --- /dev/null +++ b/crates/tranquil-db-traits/src/error.rs @@ -0,0 +1,39 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum DbError { + #[error("Database query error: {0}")] + Query(String), + + #[error("Record not found")] + NotFound, + + #[error("Constraint violation: {0}")] + Constraint(String), + + #[error("Connection error: {0}")] + Connection(String), + + #[error("Transaction error: {0}")] + Transaction(String), + + #[error("Serialization error: {0}")] + Serialization(String), + + #[error("Other database error: {0}")] + Other(String), +} + +impl DbError { + pub fn from_query_error(msg: impl Into) -> Self { + DbError::Query(msg.into()) + } + + pub fn from_constraint_error(msg: impl Into) -> Self { + DbError::Constraint(msg.into()) + } + + pub fn from_connection_error(msg: impl Into) -> Self { + DbError::Connection(msg.into()) + } +} diff --git a/crates/tranquil-db-traits/src/infra.rs b/crates/tranquil-db-traits/src/infra.rs new file mode 100644 index 0000000..f529903 --- /dev/null +++ b/crates/tranquil-db-traits/src/infra.rs @@ -0,0 +1,339 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use tranquil_types::{CidLink, Did, Handle}; +use uuid::Uuid; + +use crate::DbError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum InviteCodeSortOrder { + #[default] + Recent, + Usage, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "comms_channel", rename_all = "snake_case")] +pub enum CommsChannel { + Email, + Discord, + Telegram, + Signal, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "comms_type", rename_all = "snake_case")] +pub enum CommsType { + Welcome, + EmailVerification, + PasswordReset, + EmailUpdate, + AccountDeletion, + AdminEmail, + PlcOperation, + TwoFactorCode, + PasskeyRecovery, + LegacyLoginAlert, + MigrationVerification, + ChannelVerification, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "comms_status", rename_all = "snake_case")] +pub enum CommsStatus { + Pending, + Processing, + Sent, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueuedComms { + pub id: Uuid, + pub user_id: Option, + pub channel: CommsChannel, + pub comms_type: CommsType, + pub status: CommsStatus, + pub recipient: String, + pub subject: Option, + pub body: String, + pub metadata: Option, + pub attempts: i32, + pub max_attempts: i32, + pub last_error: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub scheduled_for: DateTime, + pub processed_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InviteCodeInfo { + pub code: String, + pub available_uses: i32, + pub disabled: bool, + pub for_account: Option, + pub created_at: DateTime, + pub created_by: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InviteCodeUse { + pub code: String, + pub used_by_did: Did, + pub used_by_handle: Option, + pub used_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InviteCodeRow { + pub code: String, + pub available_uses: i32, + pub disabled: Option, + pub created_by_user: Uuid, + pub created_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct ReservedSigningKey { + pub id: Uuid, + pub private_key_bytes: Vec, +} + +#[derive(Debug, Clone)] +pub struct DeletionRequest { + pub did: Did, + pub expires_at: DateTime, +} + +#[async_trait] +pub trait InfraRepository: Send + Sync { + async fn enqueue_comms( + &self, + user_id: Option, + channel: CommsChannel, + comms_type: CommsType, + recipient: &str, + subject: Option<&str>, + body: &str, + metadata: Option, + ) -> Result; + + async fn fetch_pending_comms( + &self, + now: DateTime, + batch_size: i64, + ) -> Result, DbError>; + + async fn mark_comms_sent(&self, id: Uuid) -> Result<(), DbError>; + + async fn mark_comms_failed(&self, id: Uuid, error: &str) -> Result<(), DbError>; + + async fn create_invite_code( + &self, + code: &str, + use_count: i32, + for_account: Option<&Did>, + ) -> Result; + + async fn create_invite_codes_batch( + &self, + codes: &[String], + use_count: i32, + created_by_user: Uuid, + for_account: Option<&Did>, + ) -> Result<(), DbError>; + + async fn get_invite_code_available_uses(&self, code: &str) -> Result, DbError>; + + async fn is_invite_code_valid(&self, code: &str) -> Result; + + async fn decrement_invite_code_uses(&self, code: &str) -> Result<(), DbError>; + + async fn record_invite_code_use(&self, code: &str, used_by_user: Uuid) -> Result<(), DbError>; + + async fn get_invite_codes_for_account( + &self, + for_account: &Did, + ) -> Result, DbError>; + + async fn get_invite_code_uses(&self, code: &str) -> Result, DbError>; + + async fn disable_invite_codes_by_code(&self, codes: &[String]) -> Result<(), DbError>; + + async fn disable_invite_codes_by_account(&self, accounts: &[Did]) -> Result<(), DbError>; + + async fn list_invite_codes( + &self, + cursor: Option<&str>, + limit: i64, + sort: InviteCodeSortOrder, + ) -> Result, DbError>; + + async fn get_user_dids_by_ids(&self, user_ids: &[Uuid]) -> Result, DbError>; + + async fn get_invite_code_uses_batch( + &self, + codes: &[String], + ) -> Result, DbError>; + + async fn get_invites_created_by_user( + &self, + user_id: Uuid, + ) -> Result, DbError>; + + async fn get_invite_code_info(&self, code: &str) -> Result, DbError>; + + async fn get_invite_codes_by_users( + &self, + user_ids: &[Uuid], + ) -> Result, DbError>; + + async fn get_invite_code_used_by_user(&self, user_id: Uuid) -> Result, DbError>; + + async fn delete_invite_code_uses_by_user(&self, user_id: Uuid) -> Result<(), DbError>; + + async fn delete_invite_codes_by_user(&self, user_id: Uuid) -> Result<(), DbError>; + + async fn reserve_signing_key( + &self, + did: Option<&Did>, + public_key_did_key: &str, + private_key_bytes: &[u8], + expires_at: DateTime, + ) -> Result; + + async fn get_reserved_signing_key( + &self, + public_key_did_key: &str, + ) -> Result, DbError>; + + async fn mark_signing_key_used(&self, key_id: Uuid) -> Result<(), DbError>; + + async fn create_deletion_request( + &self, + token: &str, + did: &Did, + expires_at: DateTime, + ) -> Result<(), DbError>; + + async fn get_deletion_request(&self, token: &str) -> Result, DbError>; + + async fn delete_deletion_request(&self, token: &str) -> Result<(), DbError>; + + async fn delete_deletion_requests_by_did(&self, did: &Did) -> Result<(), DbError>; + + async fn upsert_account_preference( + &self, + user_id: Uuid, + name: &str, + value_json: serde_json::Value, + ) -> Result<(), DbError>; + + async fn insert_account_preference_if_not_exists( + &self, + user_id: Uuid, + name: &str, + value_json: serde_json::Value, + ) -> Result<(), DbError>; + + async fn get_server_config(&self, key: &str) -> Result, DbError>; + + async fn health_check(&self) -> Result; + + async fn insert_report( + &self, + id: i64, + reason_type: &str, + reason: Option<&str>, + subject_json: serde_json::Value, + reported_by_did: &Did, + created_at: DateTime, + ) -> Result<(), DbError>; + + async fn delete_plc_tokens_for_user(&self, user_id: Uuid) -> Result<(), DbError>; + + async fn insert_plc_token( + &self, + user_id: Uuid, + token: &str, + expires_at: DateTime, + ) -> Result<(), DbError>; + + async fn get_plc_token_expiry( + &self, + user_id: Uuid, + token: &str, + ) -> Result>, DbError>; + + async fn delete_plc_token(&self, user_id: Uuid, token: &str) -> Result<(), DbError>; + + async fn get_account_preferences( + &self, + user_id: Uuid, + ) -> Result, DbError>; + + async fn replace_namespace_preferences( + &self, + user_id: Uuid, + namespace: &str, + preferences: Vec<(String, serde_json::Value)>, + ) -> Result<(), DbError>; + + async fn get_notification_history( + &self, + user_id: Uuid, + limit: i64, + ) -> Result, DbError>; + + async fn get_server_configs( + &self, + keys: &[&str], + ) -> Result, DbError>; + + async fn upsert_server_config(&self, key: &str, value: &str) -> Result<(), DbError>; + + async fn delete_server_config(&self, key: &str) -> Result<(), DbError>; + + async fn get_blob_storage_key_by_cid(&self, cid: &CidLink) -> Result, DbError>; + + async fn delete_blob_by_cid(&self, cid: &CidLink) -> Result<(), DbError>; + + async fn get_admin_account_info_by_did( + &self, + did: &Did, + ) -> Result, DbError>; + + async fn get_admin_account_infos_by_dids( + &self, + dids: &[Did], + ) -> Result, DbError>; + + async fn get_invite_code_uses_by_users( + &self, + user_ids: &[Uuid], + ) -> Result, DbError>; +} + +#[derive(Debug, Clone)] +pub struct NotificationHistoryRow { + pub created_at: DateTime, + pub channel: String, + pub comms_type: String, + pub status: String, + pub subject: Option, + pub body: String, +} + +#[derive(Debug, Clone)] +pub struct AdminAccountInfo { + pub id: Uuid, + pub did: Did, + pub handle: Handle, + pub email: Option, + pub created_at: DateTime, + pub invites_disabled: bool, + pub email_verified: bool, + pub deactivated_at: Option>, +} diff --git a/crates/tranquil-db-traits/src/lib.rs b/crates/tranquil-db-traits/src/lib.rs new file mode 100644 index 0000000..d629f86 --- /dev/null +++ b/crates/tranquil-db-traits/src/lib.rs @@ -0,0 +1,58 @@ +mod backlink; +mod backup; +mod blob; +mod delegation; +mod error; +mod infra; +mod oauth; +mod repo; +mod session; +mod user; + +pub use backlink::{Backlink, BacklinkRepository}; +pub use backup::{ + BackupForDeletion, BackupRepository, BackupRow, BackupStorageInfo, BlobExportInfo, + OldBackupInfo, UserBackupInfo, +}; +pub use blob::{ + BlobForExport, BlobMetadata, BlobRepository, BlobWithTakedown, MissingBlobInfo, +}; +pub use delegation::{ + AuditLogEntry, ControllerInfo, DelegatedAccountInfo, DelegationActionType, DelegationGrant, + DelegationRepository, +}; +pub use error::DbError; +pub use infra::{ + AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DeletionRequest, InfraRepository, + InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeUse, NotificationHistoryRow, + QueuedComms, ReservedSigningKey, +}; +pub use oauth::{ + DeviceAccountRow, DeviceTrustInfo, OAuthRepository, OAuthSessionListItem, RefreshTokenLookup, + ScopePreference, TrustedDeviceRow, TwoFactorChallenge, +}; +pub use repo::{ + ApplyCommitError, ApplyCommitInput, ApplyCommitResult, BrokenGenesisCommit, CommitEventData, + EventBlocksCids, FullRecordInfo, ImportBlock, ImportRecord, ImportRepoError, RecordDelete, + RecordInfo, RecordUpsert, RecordWithTakedown, RepoAccountInfo, RepoEventNotifier, + RepoEventReceiver, RepoInfo, RepoListItem, RepoRepository, RepoSeqEvent, RepoWithoutRev, + SequencedEvent, UserNeedingRecordBlobsBackfill, UserWithoutBlocks, +}; +pub use session::{ + AppPasswordCreate, AppPasswordRecord, RefreshSessionResult, SessionForRefresh, SessionListItem, + SessionMfaStatus, SessionRefreshData, SessionRepository, SessionToken, SessionTokenCreate, +}; +pub use user::{ + AccountSearchResult, CompletePasskeySetupInput, CreateAccountError, CreateDelegatedAccountInput, + CreatePasskeyAccountInput, CreatePasswordAccountInput, CreatePasswordAccountResult, + DidWebOverrides, MigrationReactivationError, MigrationReactivationInput, NotificationPrefs, + OAuthTokenWithUser, PasswordResetResult, ReactivatedAccountInfo, RecoverPasskeyAccountInput, + RecoverPasskeyAccountResult, ScheduledDeletionAccount, StoredBackupCode, StoredPasskey, + TotpRecord, User2faStatus, UserAuthInfo, UserCommsPrefs, UserConfirmSignup, UserDidWebInfo, + UserEmailInfo, UserForDeletion, UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery, + UserForPasskeySetup, UserForRecovery, UserForVerification, UserIdAndHandle, + UserIdAndPasswordHash, UserIdHandleEmail, UserInfoForAuth, UserKeyInfo, UserKeyWithId, + UserLegacyLoginPref, UserLoginCheck, UserLoginFull, UserLoginInfo, UserPasswordInfo, + UserRepository, UserResendVerification, UserResetCodeInfo, UserRow, UserSessionInfo, UserStatus, + UserVerificationInfo, UserWithKey, +}; diff --git a/crates/tranquil-db-traits/src/oauth.rs b/crates/tranquil-db-traits/src/oauth.rs new file mode 100644 index 0000000..dc36a85 --- /dev/null +++ b/crates/tranquil-db-traits/src/oauth.rs @@ -0,0 +1,245 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use tranquil_oauth::{AuthorizedClientData, DeviceData, RequestData, TokenData}; +use tranquil_types::{AuthorizationCode, ClientId, DPoPProofId, DeviceId, Did, Handle, RefreshToken, RequestId, TokenId}; +use uuid::Uuid; + +use crate::DbError; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScopePreference { + pub scope: String, + pub granted: bool, +} + +#[derive(Debug, Clone)] +pub struct DeviceAccountRow { + pub did: Did, + pub handle: Handle, + pub email: Option, + pub last_used_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct TwoFactorChallenge { + pub id: Uuid, + pub did: Did, + pub request_uri: String, + pub code: String, + pub attempts: i32, + pub created_at: DateTime, + pub expires_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct TrustedDeviceRow { + pub id: String, + pub user_agent: Option, + pub friendly_name: Option, + pub trusted_at: Option>, + pub trusted_until: Option>, + pub last_seen_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct DeviceTrustInfo { + pub trusted_at: Option>, + pub trusted_until: Option>, +} + +#[derive(Debug, Clone)] +pub struct OAuthSessionListItem { + pub id: i32, + pub token_id: TokenId, + pub created_at: DateTime, + pub expires_at: DateTime, + pub client_id: ClientId, +} + +pub enum RefreshTokenLookup { + Valid { + db_id: i32, + token_data: TokenData, + }, + InGracePeriod { + db_id: i32, + token_data: TokenData, + rotated_at: DateTime, + }, + Used { + original_token_id: i32, + }, + Expired { + db_id: i32, + }, + NotFound, +} + +impl RefreshTokenLookup { + pub fn state(&self) -> &'static str { + match self { + Self::Valid { .. } => "valid", + Self::InGracePeriod { .. } => "grace_period", + Self::Used { .. } => "used", + Self::Expired { .. } => "expired", + Self::NotFound => "not_found", + } + } +} + +#[async_trait] +pub trait OAuthRepository: Send + Sync { + async fn create_token(&self, data: &TokenData) -> Result; + async fn get_token_by_id(&self, token_id: &TokenId) -> Result, DbError>; + async fn get_token_by_refresh_token( + &self, + refresh_token: &RefreshToken, + ) -> Result, DbError>; + async fn get_token_by_previous_refresh_token( + &self, + refresh_token: &RefreshToken, + ) -> Result, DbError>; + async fn rotate_token( + &self, + old_db_id: i32, + new_refresh_token: &RefreshToken, + new_expires_at: DateTime, + ) -> Result<(), DbError>; + async fn check_refresh_token_used(&self, refresh_token: &RefreshToken) -> Result, DbError>; + async fn delete_token(&self, token_id: &TokenId) -> Result<(), DbError>; + async fn delete_token_family(&self, db_id: i32) -> Result<(), DbError>; + async fn list_tokens_for_user(&self, did: &Did) -> Result, DbError>; + async fn count_tokens_for_user(&self, did: &Did) -> Result; + async fn delete_oldest_tokens_for_user( + &self, + did: &Did, + keep_count: i64, + ) -> Result; + async fn revoke_tokens_for_client(&self, did: &Did, client_id: &ClientId) -> Result; + async fn revoke_tokens_for_controller( + &self, + delegated_did: &Did, + controller_did: &Did, + ) -> Result; + + async fn create_authorization_request( + &self, + request_id: &RequestId, + data: &RequestData, + ) -> Result<(), DbError>; + async fn get_authorization_request( + &self, + request_id: &RequestId, + ) -> Result, DbError>; + async fn set_authorization_did( + &self, + request_id: &RequestId, + did: &Did, + device_id: Option<&DeviceId>, + ) -> Result<(), DbError>; + async fn update_authorization_request( + &self, + request_id: &RequestId, + did: &Did, + device_id: Option<&DeviceId>, + code: &AuthorizationCode, + ) -> Result<(), DbError>; + async fn consume_authorization_request_by_code( + &self, + code: &AuthorizationCode, + ) -> Result, DbError>; + async fn delete_authorization_request(&self, request_id: &RequestId) -> Result<(), DbError>; + async fn delete_expired_authorization_requests(&self) -> Result; + async fn mark_request_authenticated( + &self, + request_id: &RequestId, + did: &Did, + device_id: Option<&DeviceId>, + ) -> Result<(), DbError>; + async fn update_request_scope(&self, request_id: &RequestId, scope: &str) -> Result<(), DbError>; + async fn set_controller_did(&self, request_id: &RequestId, controller_did: &Did) + -> Result<(), DbError>; + async fn set_request_did(&self, request_id: &RequestId, did: &Did) -> Result<(), DbError>; + + async fn create_device(&self, device_id: &DeviceId, data: &DeviceData) -> Result<(), DbError>; + async fn get_device(&self, device_id: &DeviceId) -> Result, DbError>; + async fn update_device_last_seen(&self, device_id: &DeviceId) -> Result<(), DbError>; + async fn delete_device(&self, device_id: &DeviceId) -> Result<(), DbError>; + async fn upsert_account_device(&self, did: &Did, device_id: &DeviceId) -> Result<(), DbError>; + async fn get_device_accounts(&self, device_id: &DeviceId) -> Result, DbError>; + async fn verify_account_on_device(&self, device_id: &DeviceId, did: &Did) -> Result; + + async fn check_and_record_dpop_jti(&self, jti: &DPoPProofId) -> Result; + async fn cleanup_expired_dpop_jtis(&self, max_age_secs: i64) -> Result; + + async fn create_2fa_challenge( + &self, + did: &Did, + request_uri: &RequestId, + ) -> Result; + async fn get_2fa_challenge( + &self, + request_uri: &RequestId, + ) -> Result, DbError>; + async fn increment_2fa_attempts(&self, id: Uuid) -> Result; + async fn delete_2fa_challenge(&self, id: Uuid) -> Result<(), DbError>; + async fn delete_2fa_challenge_by_request_uri(&self, request_uri: &RequestId) -> Result<(), DbError>; + async fn cleanup_expired_2fa_challenges(&self) -> Result; + async fn check_user_2fa_enabled(&self, did: &Did) -> Result; + + async fn get_scope_preferences( + &self, + did: &Did, + client_id: &ClientId, + ) -> Result, DbError>; + async fn upsert_scope_preferences( + &self, + did: &Did, + client_id: &ClientId, + prefs: &[ScopePreference], + ) -> Result<(), DbError>; + async fn delete_scope_preferences(&self, did: &Did, client_id: &ClientId) -> Result<(), DbError>; + + async fn upsert_authorized_client( + &self, + did: &Did, + client_id: &ClientId, + data: &AuthorizedClientData, + ) -> Result<(), DbError>; + async fn get_authorized_client( + &self, + did: &Did, + client_id: &ClientId, + ) -> Result, DbError>; + + async fn list_trusted_devices(&self, did: &Did) -> Result, DbError>; + async fn get_device_trust_info( + &self, + device_id: &DeviceId, + did: &Did, + ) -> Result, DbError>; + async fn device_belongs_to_user(&self, device_id: &DeviceId, did: &Did) -> Result; + async fn revoke_device_trust(&self, device_id: &DeviceId) -> Result<(), DbError>; + async fn update_device_friendly_name( + &self, + device_id: &DeviceId, + friendly_name: Option<&str>, + ) -> Result<(), DbError>; + async fn trust_device( + &self, + device_id: &DeviceId, + trusted_at: DateTime, + trusted_until: DateTime, + ) -> Result<(), DbError>; + async fn extend_device_trust( + &self, + device_id: &DeviceId, + trusted_until: DateTime, + ) -> Result<(), DbError>; + + async fn list_sessions_by_did(&self, did: &Did) -> Result, DbError>; + async fn delete_session_by_id(&self, session_id: i32, did: &Did) -> Result; + async fn delete_sessions_by_did(&self, did: &Did) -> Result; + async fn delete_sessions_by_did_except(&self, did: &Did, except_token_id: &TokenId) -> Result; +} diff --git a/crates/tranquil-db-traits/src/repo.rs b/crates/tranquil-db-traits/src/repo.rs new file mode 100644 index 0000000..ead32f1 --- /dev/null +++ b/crates/tranquil-db-traits/src/repo.rs @@ -0,0 +1,388 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey}; +use uuid::Uuid; + +use crate::DbError; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepoAccountInfo { + pub user_id: Uuid, + pub did: Did, + pub deactivated_at: Option>, + pub takedown_ref: Option, + pub repo_root_cid: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepoInfo { + pub user_id: Uuid, + pub repo_root_cid: CidLink, + pub repo_rev: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecordInfo { + pub rkey: Rkey, + pub record_cid: CidLink, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FullRecordInfo { + pub collection: Nsid, + pub rkey: Rkey, + pub record_cid: CidLink, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecordWithTakedown { + pub id: Uuid, + pub takedown_ref: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepoWithoutRev { + pub user_id: Uuid, + pub repo_root_cid: CidLink, +} + +#[derive(Debug, Clone)] +pub struct BrokenGenesisCommit { + pub seq: i64, + pub did: Did, + pub commit_cid: Option, +} + +#[derive(Debug, Clone)] +pub struct UserWithoutBlocks { + pub user_id: Uuid, + pub repo_root_cid: CidLink, + pub repo_rev: Option, +} + +#[derive(Debug, Clone)] +pub struct UserNeedingRecordBlobsBackfill { + pub user_id: Uuid, + pub did: Did, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepoSeqEvent { + pub seq: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SequencedEvent { + pub seq: i64, + pub did: Did, + pub created_at: DateTime, + pub event_type: String, + pub commit_cid: Option, + pub prev_cid: Option, + pub prev_data_cid: Option, + pub ops: Option, + pub blobs: Option>, + pub blocks_cids: Option>, + pub handle: Option, + pub active: Option, + pub status: Option, + pub rev: Option, +} + +#[derive(Debug, Clone)] +pub struct CommitEventData { + pub did: Did, + pub event_type: String, + pub commit_cid: Option, + pub prev_cid: Option, + pub ops: Option, + pub blobs: Option>, + pub blocks_cids: Option>, + pub prev_data_cid: Option, + pub rev: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventBlocksCids { + pub blocks_cids: Option>, + pub commit_cid: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepoListItem { + pub did: Did, + pub deactivated_at: Option>, + pub takedown_ref: Option, + pub repo_root_cid: CidLink, + pub repo_rev: Option, +} + +#[derive(Debug, Clone)] +pub struct ImportBlock { + pub cid_bytes: Vec, + pub data: Vec, +} + +#[derive(Debug, Clone)] +pub struct ImportRecord { + pub collection: Nsid, + pub rkey: Rkey, + pub record_cid: CidLink, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ImportRepoError { + RepoNotFound, + ConcurrentModification, + Database(String), +} + +#[derive(Debug, Clone)] +pub struct RecordUpsert { + pub collection: Nsid, + pub rkey: Rkey, + pub cid: CidLink, +} + +#[derive(Debug, Clone)] +pub struct RecordDelete { + pub collection: Nsid, + pub rkey: Rkey, +} + +#[derive(Debug, Clone)] +pub struct ApplyCommitInput { + pub user_id: Uuid, + pub did: Did, + pub expected_root_cid: Option, + pub new_root_cid: CidLink, + pub new_rev: String, + pub new_block_cids: Vec>, + pub obsolete_block_cids: Vec>, + pub record_upserts: Vec, + pub record_deletes: Vec, + pub commit_event: CommitEventData, +} + +#[derive(Debug, Clone)] +pub struct ApplyCommitResult { + pub seq: i64, + pub is_account_active: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ApplyCommitError { + RepoNotFound, + ConcurrentModification, + Database(String), +} + +#[async_trait] +pub trait RepoRepository: Send + Sync { + async fn create_repo( + &self, + user_id: Uuid, + repo_root_cid: &CidLink, + repo_rev: &str, + ) -> Result<(), DbError>; + + async fn update_repo_root( + &self, + user_id: Uuid, + repo_root_cid: &CidLink, + repo_rev: &str, + ) -> Result<(), DbError>; + + async fn update_repo_rev(&self, user_id: Uuid, repo_rev: &str) -> Result<(), DbError>; + + async fn delete_repo(&self, user_id: Uuid) -> Result<(), DbError>; + + async fn get_repo_root_for_update(&self, user_id: Uuid) -> Result, DbError>; + + async fn get_repo(&self, user_id: Uuid) -> Result, DbError>; + + async fn get_repo_root_by_did(&self, did: &Did) -> Result, DbError>; + + async fn count_repos(&self) -> Result; + + async fn get_repos_without_rev(&self) -> Result, DbError>; + + async fn upsert_records( + &self, + repo_id: Uuid, + collections: &[Nsid], + rkeys: &[Rkey], + record_cids: &[CidLink], + repo_rev: &str, + ) -> Result<(), DbError>; + + async fn delete_records( + &self, + repo_id: Uuid, + collections: &[Nsid], + rkeys: &[Rkey], + ) -> Result<(), DbError>; + + async fn delete_all_records(&self, repo_id: Uuid) -> Result<(), DbError>; + + async fn get_record_cid( + &self, + repo_id: Uuid, + collection: &Nsid, + rkey: &Rkey, + ) -> Result, DbError>; + + async fn list_records( + &self, + repo_id: Uuid, + collection: &Nsid, + cursor: Option<&Rkey>, + limit: i64, + reverse: bool, + rkey_start: Option<&Rkey>, + rkey_end: Option<&Rkey>, + ) -> Result, DbError>; + + async fn get_all_records(&self, repo_id: Uuid) -> Result, DbError>; + + async fn list_collections(&self, repo_id: Uuid) -> Result, DbError>; + + async fn count_records(&self, repo_id: Uuid) -> Result; + + async fn count_all_records(&self) -> Result; + + async fn get_record_by_cid(&self, cid: &CidLink) -> Result, DbError>; + + async fn set_record_takedown(&self, cid: &CidLink, takedown_ref: Option<&str>) + -> Result<(), DbError>; + + async fn insert_user_blocks( + &self, + user_id: Uuid, + block_cids: &[Vec], + repo_rev: &str, + ) -> Result<(), DbError>; + + async fn delete_user_blocks(&self, user_id: Uuid, block_cids: &[Vec]) + -> Result<(), DbError>; + + async fn get_user_block_cids_since_rev( + &self, + user_id: Uuid, + since_rev: &str, + ) -> Result>, DbError>; + + async fn count_user_blocks(&self, user_id: Uuid) -> Result; + + async fn insert_commit_event(&self, data: &CommitEventData) -> Result; + + async fn insert_identity_event(&self, did: &Did, handle: Option<&Handle>) -> Result; + + async fn insert_account_event( + &self, + did: &Did, + active: bool, + status: Option<&str>, + ) -> Result; + + async fn insert_sync_event( + &self, + did: &Did, + commit_cid: &CidLink, + rev: Option<&str>, + ) -> Result; + + async fn insert_genesis_commit_event( + &self, + did: &Did, + commit_cid: &CidLink, + mst_root_cid: &CidLink, + rev: &str, + ) -> Result; + + async fn update_seq_blocks_cids(&self, seq: i64, blocks_cids: &[String]) + -> Result<(), DbError>; + + async fn delete_sequences_except(&self, did: &Did, keep_seq: i64) -> Result<(), DbError>; + + async fn get_max_seq(&self) -> Result; + + async fn get_min_seq_since(&self, since: DateTime) -> Result, DbError>; + + async fn get_account_with_repo(&self, did: &Did) -> Result, DbError>; + + async fn get_events_since_seq( + &self, + since_seq: i64, + limit: Option, + ) -> Result, DbError>; + + async fn get_events_in_seq_range( + &self, + start_seq: i64, + end_seq: i64, + ) -> Result, DbError>; + + async fn get_event_by_seq(&self, seq: i64) -> Result, DbError>; + + async fn get_events_since_cursor( + &self, + cursor: i64, + limit: i64, + ) -> Result, DbError>; + + async fn get_events_since_rev( + &self, + did: &Did, + since_rev: &str, + ) -> Result, DbError>; + + async fn list_repos_paginated( + &self, + cursor_did: Option<&Did>, + limit: i64, + ) -> Result, DbError>; + + async fn get_repo_root_cid_by_user_id(&self, user_id: Uuid) -> Result, DbError>; + + async fn notify_update(&self, seq: i64) -> Result<(), DbError>; + + async fn import_repo_data( + &self, + user_id: Uuid, + blocks: &[ImportBlock], + records: &[ImportRecord], + ) -> Result<(), ImportRepoError>; + + async fn apply_commit( + &self, + input: ApplyCommitInput, + ) -> Result; + + async fn get_broken_genesis_commits(&self) -> Result, DbError>; + + async fn get_users_without_blocks(&self) -> Result, DbError>; + + async fn get_users_needing_record_blobs_backfill( + &self, + limit: i64, + ) -> Result, DbError>; + + async fn insert_record_blobs( + &self, + repo_id: Uuid, + record_uris: &[AtUri], + blob_cids: &[CidLink], + ) -> Result<(), DbError>; +} + +#[async_trait] +pub trait RepoEventNotifier: Send + Sync { + async fn subscribe(&self) -> Result, DbError>; +} + +#[async_trait] +pub trait RepoEventReceiver: Send { + async fn recv(&mut self) -> Option; +} diff --git a/crates/tranquil-db-traits/src/session.rs b/crates/tranquil-db-traits/src/session.rs new file mode 100644 index 0000000..1fbcbe6 --- /dev/null +++ b/crates/tranquil-db-traits/src/session.rs @@ -0,0 +1,203 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use tranquil_types::Did; +use uuid::Uuid; + +use crate::DbError; + +#[derive(Debug, Clone)] +pub struct SessionToken { + pub id: i32, + pub did: Did, + pub access_jti: String, + pub refresh_jti: String, + pub access_expires_at: DateTime, + pub refresh_expires_at: DateTime, + pub legacy_login: bool, + pub mfa_verified: bool, + pub scope: Option, + pub controller_did: Option, + pub app_password_name: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct SessionTokenCreate { + pub did: Did, + pub access_jti: String, + pub refresh_jti: String, + pub access_expires_at: DateTime, + pub refresh_expires_at: DateTime, + pub legacy_login: bool, + pub mfa_verified: bool, + pub scope: Option, + pub controller_did: Option, + pub app_password_name: Option, +} + +#[derive(Debug, Clone)] +pub struct SessionForRefresh { + pub id: i32, + pub did: Did, + pub scope: Option, + pub controller_did: Option, + pub key_bytes: Vec, + pub encryption_version: i32, +} + +#[derive(Debug, Clone)] +pub struct SessionListItem { + pub id: i32, + pub access_jti: String, + pub created_at: DateTime, + pub refresh_expires_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct AppPasswordRecord { + pub id: Uuid, + pub user_id: Uuid, + pub name: String, + pub password_hash: String, + pub created_at: DateTime, + pub privileged: bool, + pub scopes: Option, + pub created_by_controller_did: Option, +} + +#[derive(Debug, Clone)] +pub struct AppPasswordCreate { + pub user_id: Uuid, + pub name: String, + pub password_hash: String, + pub privileged: bool, + pub scopes: Option, + pub created_by_controller_did: Option, +} + +#[derive(Debug, Clone)] +pub struct SessionMfaStatus { + pub legacy_login: bool, + pub mfa_verified: bool, + pub last_reauth_at: Option>, +} + +#[derive(Debug, Clone)] +pub enum RefreshSessionResult { + Success, + TokenAlreadyUsed, + ConcurrentRefresh, +} + +#[derive(Debug, Clone)] +pub struct SessionRefreshData { + pub old_refresh_jti: String, + pub session_id: i32, + pub new_access_jti: String, + pub new_refresh_jti: String, + pub new_access_expires_at: DateTime, + pub new_refresh_expires_at: DateTime, +} + +#[async_trait] +pub trait SessionRepository: Send + Sync { + async fn create_session(&self, data: &SessionTokenCreate) -> Result; + + async fn get_session_by_access_jti( + &self, + access_jti: &str, + ) -> Result, DbError>; + + async fn get_session_for_refresh( + &self, + refresh_jti: &str, + ) -> Result, DbError>; + + async fn update_session_tokens( + &self, + session_id: i32, + new_access_jti: &str, + new_refresh_jti: &str, + new_access_expires_at: DateTime, + new_refresh_expires_at: DateTime, + ) -> Result<(), DbError>; + + async fn delete_session_by_access_jti(&self, access_jti: &str) -> Result; + + async fn delete_session_by_id(&self, session_id: i32) -> Result; + + async fn delete_sessions_by_did(&self, did: &Did) -> Result; + + async fn delete_sessions_by_did_except_jti( + &self, + did: &Did, + except_jti: &str, + ) -> Result; + + async fn list_sessions_by_did(&self, did: &Did) -> Result, DbError>; + + async fn get_session_access_jti_by_id( + &self, + session_id: i32, + did: &Did, + ) -> Result, DbError>; + + async fn delete_sessions_by_app_password( + &self, + did: &Did, + app_password_name: &str, + ) -> Result; + + async fn get_session_jtis_by_app_password( + &self, + did: &Did, + app_password_name: &str, + ) -> Result, DbError>; + + async fn check_refresh_token_used(&self, refresh_jti: &str) -> Result, DbError>; + + async fn mark_refresh_token_used( + &self, + refresh_jti: &str, + session_id: i32, + ) -> Result; + + async fn list_app_passwords(&self, user_id: Uuid) -> Result, DbError>; + + async fn get_app_passwords_for_login( + &self, + user_id: Uuid, + ) -> Result, DbError>; + + async fn get_app_password_by_name( + &self, + user_id: Uuid, + name: &str, + ) -> Result, DbError>; + + async fn create_app_password(&self, data: &AppPasswordCreate) -> Result; + + async fn delete_app_password(&self, user_id: Uuid, name: &str) -> Result; + + async fn delete_app_passwords_by_controller( + &self, + did: &Did, + controller_did: &Did, + ) -> Result; + + async fn get_last_reauth_at(&self, did: &Did) -> Result>, DbError>; + + async fn update_last_reauth(&self, did: &Did) -> Result, DbError>; + + async fn get_session_mfa_status(&self, did: &Did) -> Result, DbError>; + + async fn update_mfa_verified(&self, did: &Did) -> Result<(), DbError>; + + async fn get_app_password_hashes_by_did(&self, did: &Did) -> Result, DbError>; + + async fn refresh_session_atomic( + &self, + data: &SessionRefreshData, + ) -> Result; +} diff --git a/crates/tranquil-db-traits/src/user.rs b/crates/tranquil-db-traits/src/user.rs new file mode 100644 index 0000000..74beb90 --- /dev/null +++ b/crates/tranquil-db-traits/src/user.rs @@ -0,0 +1,902 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use tranquil_types::{Did, Handle}; +use uuid::Uuid; + +use crate::{CommsChannel, DbError}; + +#[derive(Debug, Clone)] +pub struct UserRow { + pub id: Uuid, + pub did: Did, + pub handle: Handle, + pub email: Option, + pub created_at: DateTime, + pub deactivated_at: Option>, + pub takedown_ref: Option, + pub is_admin: bool, +} + +#[derive(Debug, Clone)] +pub struct UserWithKey { + pub id: Uuid, + pub did: Did, + pub handle: Handle, + pub email: Option, + pub deactivated_at: Option>, + pub takedown_ref: Option, + pub is_admin: bool, + pub key_bytes: Vec, + pub encryption_version: Option, +} + +#[derive(Debug, Clone)] +pub struct UserStatus { + pub deactivated_at: Option>, + pub takedown_ref: Option, + pub is_admin: bool, +} + +#[derive(Debug, Clone)] +pub struct UserEmailInfo { + pub id: Uuid, + pub handle: Handle, + pub email: Option, + pub email_verified: bool, +} + +#[derive(Debug, Clone)] +pub struct UserLoginCheck { + pub did: Did, + pub password_hash: Option, +} + +#[derive(Debug, Clone)] +pub struct UserLoginInfo { + pub id: Uuid, + pub did: Did, + pub email: Option, + pub password_hash: Option, + pub password_required: bool, + pub two_factor_enabled: bool, + pub preferred_comms_channel: CommsChannel, + pub deactivated_at: Option>, + pub takedown_ref: Option, + pub email_verified: bool, + pub discord_verified: bool, + pub telegram_verified: bool, + pub signal_verified: bool, + pub account_type: String, +} + +#[derive(Debug, Clone)] +pub struct User2faStatus { + pub id: Uuid, + pub two_factor_enabled: bool, + pub preferred_comms_channel: CommsChannel, + pub email_verified: bool, + pub discord_verified: bool, + pub telegram_verified: bool, + pub signal_verified: bool, +} + +#[async_trait] +pub trait UserRepository: Send + Sync { + async fn get_by_did(&self, did: &Did) -> Result, DbError>; + + async fn get_by_handle(&self, handle: &Handle) -> Result, DbError>; + + async fn get_with_key_by_did(&self, did: &Did) -> Result, DbError>; + + async fn get_status_by_did(&self, did: &Did) -> Result, DbError>; + + async fn count_users(&self) -> Result; + + async fn get_session_access_expiry( + &self, + did: &Did, + access_jti: &str, + ) -> Result>, DbError>; + + async fn get_oauth_token_with_user( + &self, + token_id: &str, + ) -> Result, DbError>; + + async fn get_user_info_by_did(&self, did: &Did) -> Result, DbError>; + + async fn get_any_admin_user_id(&self) -> Result, DbError>; + + async fn set_invites_disabled(&self, did: &Did, disabled: bool) -> Result; + + async fn search_accounts( + &self, + cursor_did: Option<&Did>, + email_filter: Option<&str>, + handle_filter: Option<&str>, + limit: i64, + ) -> Result, DbError>; + + async fn get_auth_info_by_did(&self, did: &Did) -> Result, DbError>; + + async fn get_by_email(&self, email: &str) -> Result, DbError>; + + async fn get_login_check_by_handle_or_email( + &self, + identifier: &str, + ) -> Result, DbError>; + + async fn get_login_info_by_handle_or_email( + &self, + identifier: &str, + ) -> Result, DbError>; + + async fn get_2fa_status_by_did(&self, did: &Did) -> Result, DbError>; + + async fn get_comms_prefs(&self, user_id: Uuid) -> Result, DbError>; + + async fn get_id_by_did(&self, did: &Did) -> Result, DbError>; + + async fn get_user_key_by_id(&self, user_id: Uuid) -> Result, DbError>; + + async fn get_id_and_handle_by_did(&self, did: &Did) -> Result, DbError>; + + async fn get_did_web_info_by_handle( + &self, + handle: &Handle, + ) -> Result, DbError>; + + async fn get_did_web_overrides(&self, user_id: Uuid) -> Result, DbError>; + + async fn get_handle_by_did(&self, did: &Did) -> Result, DbError>; + + async fn is_account_active_by_did(&self, did: &Did) -> Result, DbError>; + + async fn get_user_for_deletion( + &self, + did: &Did, + ) -> Result, DbError>; + + async fn check_handle_exists(&self, handle: &Handle, exclude_user_id: Uuid) -> Result; + + async fn update_handle(&self, user_id: Uuid, handle: &Handle) -> Result<(), DbError>; + + async fn get_user_with_key_by_did( + &self, + did: &Did, + ) -> Result, DbError>; + + async fn is_account_migrated(&self, did: &Did) -> Result; + + async fn has_verified_comms_channel(&self, did: &Did) -> Result; + + async fn get_id_by_handle(&self, handle: &Handle) -> Result, DbError>; + + async fn get_email_info_by_did(&self, did: &Did) -> Result, DbError>; + + async fn check_email_exists(&self, email: &str, exclude_user_id: Uuid) -> Result; + + async fn update_email(&self, user_id: Uuid, email: &str) -> Result<(), DbError>; + + async fn set_email_verified(&self, user_id: Uuid, verified: bool) -> Result<(), DbError>; + + async fn check_email_verified_by_identifier( + &self, + identifier: &str, + ) -> Result, DbError>; + + async fn admin_update_email(&self, did: &Did, email: &str) -> Result; + + async fn admin_update_handle(&self, did: &Did, handle: &Handle) -> Result; + + async fn admin_update_password(&self, did: &Did, password_hash: &str) -> Result; + + async fn get_notification_prefs(&self, did: &Did) -> Result, DbError>; + + async fn get_id_handle_email_by_did( + &self, + did: &Did, + ) -> Result, DbError>; + + async fn update_preferred_comms_channel(&self, did: &Did, channel: &str) -> Result<(), DbError>; + + async fn clear_discord(&self, user_id: Uuid) -> Result<(), DbError>; + + async fn clear_telegram(&self, user_id: Uuid) -> Result<(), DbError>; + + async fn clear_signal(&self, user_id: Uuid) -> Result<(), DbError>; + + async fn get_verification_info( + &self, + did: &Did, + ) -> Result, DbError>; + + async fn verify_email_channel(&self, user_id: Uuid, email: &str) -> Result; + + async fn verify_discord_channel(&self, user_id: Uuid, discord_id: &str) -> Result<(), DbError>; + + async fn verify_telegram_channel( + &self, + user_id: Uuid, + telegram_username: &str, + ) -> Result<(), DbError>; + + async fn verify_signal_channel(&self, user_id: Uuid, signal_number: &str) + -> Result<(), DbError>; + + async fn set_email_verified_flag(&self, user_id: Uuid) -> Result<(), DbError>; + + async fn set_discord_verified_flag(&self, user_id: Uuid) -> Result<(), DbError>; + + async fn set_telegram_verified_flag(&self, user_id: Uuid) -> Result<(), DbError>; + + async fn set_signal_verified_flag(&self, user_id: Uuid) -> Result<(), DbError>; + + async fn has_totp_enabled(&self, did: &Did) -> Result; + + async fn has_passkeys(&self, did: &Did) -> Result; + + async fn get_password_hash_by_did(&self, did: &Did) -> Result, DbError>; + + async fn get_passkeys_for_user(&self, did: &Did) -> Result, DbError>; + + async fn get_passkey_by_credential_id( + &self, + credential_id: &[u8], + ) -> Result, DbError>; + + async fn save_passkey( + &self, + did: &Did, + credential_id: &[u8], + public_key: &[u8], + friendly_name: Option<&str>, + ) -> Result; + + async fn update_passkey_counter( + &self, + credential_id: &[u8], + new_counter: i32, + ) -> Result; + + async fn delete_passkey(&self, id: Uuid, did: &Did) -> Result; + + async fn update_passkey_name(&self, id: Uuid, did: &Did, name: &str) -> Result; + + async fn save_webauthn_challenge( + &self, + did: &Did, + challenge_type: &str, + state_json: &str, + ) -> Result; + + async fn load_webauthn_challenge( + &self, + did: &Did, + challenge_type: &str, + ) -> Result, DbError>; + + async fn delete_webauthn_challenge(&self, did: &Did, challenge_type: &str) + -> Result<(), DbError>; + + async fn get_totp_record(&self, did: &Did) -> Result, DbError>; + + async fn upsert_totp_secret( + &self, + did: &Did, + secret_encrypted: &[u8], + encryption_version: i32, + ) -> Result<(), DbError>; + + async fn set_totp_verified(&self, did: &Did) -> Result<(), DbError>; + + async fn update_totp_last_used(&self, did: &Did) -> Result<(), DbError>; + + async fn delete_totp(&self, did: &Did) -> Result<(), DbError>; + + async fn get_unused_backup_codes(&self, did: &Did) -> Result, DbError>; + + async fn mark_backup_code_used(&self, code_id: Uuid) -> Result; + + async fn count_unused_backup_codes(&self, did: &Did) -> Result; + + async fn delete_backup_codes(&self, did: &Did) -> Result; + + async fn insert_backup_codes(&self, did: &Did, code_hashes: &[String]) -> Result<(), DbError>; + + async fn enable_totp_with_backup_codes( + &self, + did: &Did, + code_hashes: &[String], + ) -> Result<(), DbError>; + + async fn delete_totp_and_backup_codes(&self, did: &Did) -> Result<(), DbError>; + + async fn replace_backup_codes(&self, did: &Did, code_hashes: &[String]) -> Result<(), DbError>; + + async fn get_session_info_by_did(&self, did: &Did) -> Result, DbError>; + + async fn get_legacy_login_pref(&self, did: &Did) -> Result, DbError>; + + async fn update_legacy_login(&self, did: &Did, allow: bool) -> Result; + + async fn update_locale(&self, did: &Did, locale: &str) -> Result; + + async fn get_login_full_by_identifier( + &self, + identifier: &str, + ) -> Result, DbError>; + + async fn get_confirm_signup_by_did( + &self, + did: &Did, + ) -> Result, DbError>; + + async fn get_resend_verification_by_did( + &self, + did: &Did, + ) -> Result, DbError>; + + async fn set_channel_verified(&self, did: &Did, channel: CommsChannel) -> Result<(), DbError>; + + async fn get_id_by_email_or_handle( + &self, + email: &str, + handle: &str, + ) -> Result, DbError>; + + async fn set_password_reset_code( + &self, + user_id: Uuid, + code: &str, + expires_at: DateTime, + ) -> Result<(), DbError>; + + async fn get_user_by_reset_code( + &self, + code: &str, + ) -> Result, DbError>; + + async fn clear_password_reset_code(&self, user_id: Uuid) -> Result<(), DbError>; + + async fn get_id_and_password_hash_by_did( + &self, + did: &Did, + ) -> Result, DbError>; + + async fn update_password_hash(&self, user_id: Uuid, password_hash: &str) -> Result<(), DbError>; + + async fn reset_password_with_sessions( + &self, + user_id: Uuid, + password_hash: &str, + ) -> Result; + + async fn activate_account(&self, did: &Did) -> Result; + + async fn deactivate_account( + &self, + did: &Did, + delete_after: Option>, + ) -> Result; + + async fn has_password_by_did(&self, did: &Did) -> Result, DbError>; + + async fn get_password_info_by_did( + &self, + did: &Did, + ) -> Result, DbError>; + + async fn remove_user_password(&self, user_id: Uuid) -> Result<(), DbError>; + + async fn set_new_user_password(&self, user_id: Uuid, password_hash: &str) -> Result<(), DbError>; + + async fn get_user_key_by_did(&self, did: &Did) -> Result, DbError>; + + async fn delete_account_complete( + &self, + user_id: Uuid, + did: &Did, + ) -> Result<(), DbError>; + + async fn set_user_takedown(&self, did: &Did, takedown_ref: Option<&str>) -> Result; + + async fn admin_delete_account_complete(&self, user_id: Uuid, did: &Did) -> Result<(), DbError>; + + async fn get_user_for_did_doc(&self, did: &Did) -> Result, DbError>; + + async fn get_user_for_did_doc_build(&self, did: &Did) -> Result, DbError>; + + async fn upsert_did_web_overrides( + &self, + user_id: Uuid, + verification_methods: Option, + also_known_as: Option>, + ) -> Result<(), DbError>; + + async fn update_migrated_to_pds( + &self, + did: &Did, + endpoint: &str, + ) -> Result<(), DbError>; + + async fn get_user_for_passkey_setup(&self, did: &Did) -> Result, DbError>; + + async fn get_user_for_passkey_recovery( + &self, + identifier: &str, + normalized_handle: &str, + ) -> Result, DbError>; + + async fn set_recovery_token( + &self, + did: &Did, + token_hash: &str, + expires_at: DateTime, + ) -> Result<(), DbError>; + + async fn get_user_for_recovery(&self, did: &Did) -> Result, DbError>; + + async fn get_accounts_scheduled_for_deletion( + &self, + limit: i64, + ) -> Result, DbError>; + + async fn delete_account_with_firehose( + &self, + user_id: Uuid, + did: &Did, + ) -> Result; + + async fn create_password_account( + &self, + input: &CreatePasswordAccountInput, + ) -> Result; + + async fn create_delegated_account( + &self, + input: &CreateDelegatedAccountInput, + ) -> Result; + + async fn create_passkey_account( + &self, + input: &CreatePasskeyAccountInput, + ) -> Result; + + async fn reactivate_migration_account( + &self, + input: &MigrationReactivationInput, + ) -> Result; + + async fn check_handle_available_for_new_account(&self, handle: &Handle) -> Result; + + async fn check_and_consume_invite_code(&self, code: &str) -> Result; + + async fn complete_passkey_setup( + &self, + input: &CompletePasskeySetupInput, + ) -> Result<(), DbError>; + + async fn recover_passkey_account( + &self, + input: &RecoverPasskeyAccountInput, + ) -> Result; +} + +#[derive(Debug, Clone)] +pub struct UserKeyWithId { + pub id: Uuid, + pub key_bytes: Vec, + pub encryption_version: Option, +} + +#[derive(Debug, Clone)] +pub struct UserKeyInfo { + pub key_bytes: Vec, + pub encryption_version: Option, +} + +#[derive(Debug, Clone)] +pub struct UserIdAndHandle { + pub id: Uuid, + pub handle: Handle, +} + +#[derive(Debug, Clone)] +pub struct UserDidWebInfo { + pub id: Uuid, + pub did: Did, + pub migrated_to_pds: Option, +} + +#[derive(Debug, Clone)] +pub struct DidWebOverrides { + pub verification_methods: serde_json::Value, + pub also_known_as: Vec, +} + +#[derive(Debug, Clone)] +pub struct UserCommsPrefs { + pub email: Option, + pub handle: Handle, + pub preferred_channel: String, + pub preferred_locale: Option, +} + +#[derive(Debug, Clone)] +pub struct UserForVerification { + pub id: Uuid, + pub did: Did, + pub email: Option, + pub email_verified: bool, + pub handle: Handle, +} + +#[derive(Debug, Clone)] +pub struct OAuthTokenWithUser { + pub did: Did, + pub expires_at: DateTime, + pub deactivated_at: Option>, + pub takedown_ref: Option, + pub is_admin: bool, + pub key_bytes: Option>, + pub encryption_version: Option, +} + +#[derive(Debug, Clone)] +pub struct UserInfoForAuth { + pub deactivated_at: Option>, + pub takedown_ref: Option, + pub is_admin: bool, + pub key_bytes: Option>, + pub encryption_version: Option, +} + +#[derive(Debug, Clone)] +pub struct AccountSearchResult { + pub did: Did, + pub handle: Handle, + pub email: Option, + pub created_at: DateTime, + pub email_verified: bool, + pub deactivated_at: Option>, + pub invites_disabled: Option, +} + +#[derive(Debug, Clone)] +pub struct UserAuthInfo { + pub id: Uuid, + pub did: Did, + pub password_hash: Option, + pub deactivated_at: Option>, + pub takedown_ref: Option, + pub email_verified: bool, + pub discord_verified: bool, + pub telegram_verified: bool, + pub signal_verified: bool, +} + +#[derive(Debug, Clone)] +pub struct NotificationPrefs { + pub email: String, + pub preferred_channel: String, + pub discord_id: Option, + pub discord_verified: bool, + pub telegram_username: Option, + pub telegram_verified: bool, + pub signal_number: Option, + pub signal_verified: bool, +} + +#[derive(Debug, Clone)] +pub struct UserIdHandleEmail { + pub id: Uuid, + pub handle: Handle, + pub email: Option, +} + +#[derive(Debug, Clone)] +pub struct UserVerificationInfo { + pub id: Uuid, + pub handle: Handle, + pub email: Option, + pub email_verified: bool, + pub discord_verified: bool, + pub telegram_verified: bool, + pub signal_verified: bool, +} + +#[derive(Debug, Clone)] +pub struct StoredPasskey { + pub id: Uuid, + pub did: Did, + pub credential_id: Vec, + pub public_key: Vec, + pub sign_count: i32, + pub created_at: DateTime, + pub last_used: Option>, + pub friendly_name: Option, + pub aaguid: Option>, + pub transports: Option>, +} + +impl StoredPasskey { + pub fn credential_id_base64(&self) -> String { + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + URL_SAFE_NO_PAD.encode(&self.credential_id) + } +} + +#[derive(Debug, Clone)] +pub struct TotpRecord { + pub secret_encrypted: Vec, + pub encryption_version: i32, + pub verified: bool, +} + +#[derive(Debug, Clone)] +pub struct StoredBackupCode { + pub id: Uuid, + pub code_hash: String, +} + +#[derive(Debug, Clone)] +pub struct UserSessionInfo { + pub handle: Handle, + pub email: Option, + pub email_verified: bool, + pub is_admin: bool, + pub deactivated_at: Option>, + pub takedown_ref: Option, + pub preferred_locale: Option, + pub preferred_comms_channel: CommsChannel, + pub discord_verified: bool, + pub telegram_verified: bool, + pub signal_verified: bool, + pub migrated_to_pds: Option, + pub migrated_at: Option>, +} + +#[derive(Debug, Clone)] +pub struct UserLegacyLoginPref { + pub allow_legacy_login: bool, + pub has_mfa: bool, +} + +#[derive(Debug, Clone)] +pub struct UserLoginFull { + pub id: Uuid, + pub did: Did, + pub handle: Handle, + pub password_hash: Option, + pub email: Option, + pub deactivated_at: Option>, + pub takedown_ref: Option, + pub email_verified: bool, + pub discord_verified: bool, + pub telegram_verified: bool, + pub signal_verified: bool, + pub allow_legacy_login: bool, + pub migrated_to_pds: Option, + pub preferred_comms_channel: CommsChannel, + pub key_bytes: Vec, + pub encryption_version: Option, + pub totp_enabled: bool, +} + +#[derive(Debug, Clone)] +pub struct UserConfirmSignup { + pub id: Uuid, + pub did: Did, + pub handle: Handle, + pub email: Option, + pub channel: CommsChannel, + pub discord_id: Option, + pub telegram_username: Option, + pub signal_number: Option, + pub key_bytes: Vec, + pub encryption_version: Option, +} + +#[derive(Debug, Clone)] +pub struct UserResendVerification { + pub id: Uuid, + pub handle: Handle, + pub email: Option, + pub channel: CommsChannel, + pub discord_id: Option, + pub telegram_username: Option, + pub signal_number: Option, + pub email_verified: bool, + pub discord_verified: bool, + pub telegram_verified: bool, + pub signal_verified: bool, +} + +#[derive(Debug, Clone)] +pub struct UserResetCodeInfo { + pub id: Uuid, + pub expires_at: Option>, +} + +#[derive(Debug, Clone)] +pub struct UserPasswordInfo { + pub id: Uuid, + pub password_hash: Option, +} + +#[derive(Debug, Clone)] +pub struct UserIdAndPasswordHash { + pub id: Uuid, + pub password_hash: String, +} + +#[derive(Debug, Clone)] +pub struct PasswordResetResult { + pub did: Did, + pub session_jtis: Vec, +} + +#[derive(Debug, Clone)] +pub struct UserForDeletion { + pub id: Uuid, + pub password_hash: Option, + pub handle: Handle, +} + +#[derive(Debug, Clone)] +pub struct ScheduledDeletionAccount { + pub id: Uuid, + pub did: Did, + pub handle: Handle, +} + +#[derive(Debug, Clone)] +pub struct UserForDidDoc { + pub id: Uuid, + pub handle: Handle, + pub deactivated_at: Option>, +} + +#[derive(Debug, Clone)] +pub struct UserForDidDocBuild { + pub id: Uuid, + pub handle: Handle, + pub migrated_to_pds: Option, +} + +#[derive(Debug, Clone)] +pub struct UserForPasskeySetup { + pub id: Uuid, + pub handle: Handle, + pub recovery_token: Option, + pub recovery_token_expires_at: Option>, + pub password_required: bool, +} + +#[derive(Debug, Clone)] +pub struct UserForPasskeyRecovery { + pub id: Uuid, + pub did: Did, + pub handle: Handle, + pub password_required: bool, +} + +#[derive(Debug, Clone)] +pub struct UserForRecovery { + pub id: Uuid, + pub did: Did, + pub recovery_token: Option, + pub recovery_token_expires_at: Option>, +} + +#[derive(Debug, Clone)] +pub struct CreatePasswordAccountInput { + pub handle: Handle, + pub email: Option, + pub did: Did, + pub password_hash: String, + pub preferred_comms_channel: CommsChannel, + pub discord_id: Option, + pub telegram_username: Option, + pub signal_number: Option, + pub deactivated_at: Option>, + pub encrypted_key_bytes: Vec, + pub encryption_version: i32, + pub reserved_key_id: Option, + pub commit_cid: String, + pub repo_rev: String, + pub genesis_block_cids: Vec>, + pub invite_code: Option, + pub birthdate_pref: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct CreatePasswordAccountResult { + pub user_id: Uuid, + pub is_admin: bool, +} + +#[derive(Debug, Clone)] +pub enum CreateAccountError { + HandleTaken, + EmailTaken, + DidExists, + Database(String), +} + +#[derive(Debug, Clone)] +pub struct CreateDelegatedAccountInput { + pub handle: Handle, + pub email: Option, + pub did: Did, + pub controller_did: Did, + pub controller_scopes: String, + pub encrypted_key_bytes: Vec, + pub encryption_version: i32, + pub commit_cid: String, + pub repo_rev: String, + pub genesis_block_cids: Vec>, + pub invite_code: Option, +} + +#[derive(Debug, Clone)] +pub struct CreatePasskeyAccountInput { + pub handle: Handle, + pub email: String, + pub did: Did, + pub preferred_comms_channel: CommsChannel, + pub discord_id: Option, + pub telegram_username: Option, + pub signal_number: Option, + pub setup_token_hash: String, + pub setup_expires_at: DateTime, + pub deactivated_at: Option>, + pub encrypted_key_bytes: Vec, + pub encryption_version: i32, + pub reserved_key_id: Option, + pub commit_cid: String, + pub repo_rev: String, + pub genesis_block_cids: Vec>, + pub invite_code: Option, + pub birthdate_pref: Option, +} + +#[derive(Debug, Clone)] +pub struct CompletePasskeySetupInput { + pub user_id: Uuid, + pub did: Did, + pub app_password_name: String, + pub app_password_hash: String, +} + +#[derive(Debug, Clone)] +pub struct RecoverPasskeyAccountInput { + pub did: Did, + pub password_hash: String, +} + +#[derive(Debug, Clone)] +pub struct RecoverPasskeyAccountResult { + pub passkeys_deleted: u64, +} + +#[derive(Debug, Clone)] +pub struct MigrationReactivationInput { + pub did: Did, + pub new_handle: Handle, +} + +#[derive(Debug, Clone)] +pub struct ReactivatedAccountInfo { + pub user_id: Uuid, + pub old_handle: Handle, +} + +#[derive(Debug, Clone)] +pub enum MigrationReactivationError { + NotFound, + NotDeactivated, + HandleTaken, + Database(String), +} diff --git a/crates/tranquil-db/Cargo.toml b/crates/tranquil-db/Cargo.toml new file mode 100644 index 0000000..b1c5acb --- /dev/null +++ b/crates/tranquil-db/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "tranquil-db" +version.workspace = true +edition.workspace = true +license.workspace = true + +[features] +default = ["postgres"] +postgres = [] +sqlite = [] + +[dependencies] +tranquil-db-traits = { workspace = true } +tranquil-oauth = { workspace = true } +tranquil-types = { workspace = true } +async-trait = { workspace = true } +chrono = { workspace = true } +rand = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } + +sqlx = { workspace = true } diff --git a/crates/tranquil-db/src/lib.rs b/crates/tranquil-db/src/lib.rs new file mode 100644 index 0000000..038b46c --- /dev/null +++ b/crates/tranquil-db/src/lib.rs @@ -0,0 +1,7 @@ +#[cfg(feature = "postgres")] +pub mod postgres; + +pub use tranquil_db_traits::*; + +#[cfg(feature = "postgres")] +pub use postgres::PostgresRepositories; diff --git a/crates/tranquil-db/src/postgres/backlink.rs b/crates/tranquil-db/src/postgres/backlink.rs new file mode 100644 index 0000000..1928c41 --- /dev/null +++ b/crates/tranquil-db/src/postgres/backlink.rs @@ -0,0 +1,99 @@ +use async_trait::async_trait; +use sqlx::PgPool; +use tranquil_db_traits::{Backlink, BacklinkRepository, DbError}; +use tranquil_types::{AtUri, Nsid}; +use uuid::Uuid; + +use super::user::map_sqlx_error; + +pub struct PostgresBacklinkRepository { + pool: PgPool, +} + +impl PostgresBacklinkRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl BacklinkRepository for PostgresBacklinkRepository { + async fn get_backlink_conflicts( + &self, + repo_id: Uuid, + collection: &Nsid, + backlinks: &[Backlink], + ) -> Result, DbError> { + if backlinks.is_empty() { + return Ok(Vec::new()); + } + + let paths: Vec<&str> = backlinks.iter().map(|b| b.path.as_str()).collect(); + let link_tos: Vec<&str> = backlinks.iter().map(|b| b.link_to.as_str()).collect(); + let collection_pattern = format!("%/{}/%", collection.as_str()); + + let results = sqlx::query_scalar!( + r#" + SELECT DISTINCT uri + FROM backlinks + WHERE repo_id = $1 + AND uri LIKE $4 + AND (path, link_to) IN (SELECT unnest($2::text[]), unnest($3::text[])) + "#, + repo_id, + &paths as &[&str], + &link_tos as &[&str], + collection_pattern + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(results.into_iter().map(Into::into).collect()) + } + + async fn add_backlinks(&self, repo_id: Uuid, backlinks: &[Backlink]) -> Result<(), DbError> { + if backlinks.is_empty() { + return Ok(()); + } + + let uris: Vec<&str> = backlinks.iter().map(|b| b.uri.as_str()).collect(); + let paths: Vec<&str> = backlinks.iter().map(|b| b.path.as_str()).collect(); + let link_tos: Vec<&str> = backlinks.iter().map(|b| b.link_to.as_str()).collect(); + + sqlx::query!( + r#" + INSERT INTO backlinks (uri, path, link_to, repo_id) + SELECT unnest($1::text[]), unnest($2::text[]), unnest($3::text[]), $4 + ON CONFLICT (uri, path) DO NOTHING + "#, + &uris as &[&str], + &paths as &[&str], + &link_tos as &[&str], + repo_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn remove_backlinks_by_uri(&self, uri: &AtUri) -> Result<(), DbError> { + sqlx::query!("DELETE FROM backlinks WHERE uri = $1", uri.as_str()) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn remove_backlinks_by_repo(&self, repo_id: Uuid) -> Result<(), DbError> { + sqlx::query!("DELETE FROM backlinks WHERE repo_id = $1", repo_id) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } +} diff --git a/crates/tranquil-db/src/postgres/backup.rs b/crates/tranquil-db/src/postgres/backup.rs new file mode 100644 index 0000000..437451b --- /dev/null +++ b/crates/tranquil-db/src/postgres/backup.rs @@ -0,0 +1,299 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use tranquil_db_traits::{ + BackupForDeletion, BackupRepository, BackupRow, BackupStorageInfo, BlobExportInfo, DbError, + OldBackupInfo, UserBackupInfo, +}; +use tranquil_types::Did; +use uuid::Uuid; + +use super::user::map_sqlx_error; + +pub struct PostgresBackupRepository { + pool: PgPool, +} + +impl PostgresBackupRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl BackupRepository for PostgresBackupRepository { + async fn get_user_backup_status(&self, did: &Did) -> Result, DbError> { + let result = sqlx::query!( + "SELECT id, backup_enabled FROM users WHERE did = $1", + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.map(|r| (r.id, r.backup_enabled))) + } + + async fn list_backups_for_user(&self, user_id: Uuid) -> Result, DbError> { + let results = sqlx::query_as!( + BackupRow, + r#" + SELECT id, repo_rev, repo_root_cid, block_count, size_bytes, created_at + FROM account_backups + WHERE user_id = $1 + ORDER BY created_at DESC + "#, + user_id + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(results) + } + + async fn get_backup_storage_info( + &self, + backup_id: Uuid, + did: &Did, + ) -> Result, DbError> { + let result = sqlx::query!( + r#" + SELECT ab.storage_key, ab.repo_rev + FROM account_backups ab + JOIN users u ON u.id = ab.user_id + WHERE ab.id = $1 AND u.did = $2 + "#, + backup_id, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.map(|r| BackupStorageInfo { + storage_key: r.storage_key, + repo_rev: r.repo_rev, + })) + } + + async fn get_user_for_backup(&self, did: &Did) -> Result, DbError> { + let result = sqlx::query!( + r#" + SELECT u.id, u.did, u.backup_enabled, u.deactivated_at, r.repo_root_cid, r.repo_rev + FROM users u + JOIN repos r ON r.user_id = u.id + WHERE u.did = $1 + "#, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.map(|r| UserBackupInfo { + id: r.id, + did: r.did.into(), + backup_enabled: r.backup_enabled, + deactivated_at: r.deactivated_at, + repo_root_cid: r.repo_root_cid, + repo_rev: r.repo_rev, + })) + } + + async fn insert_backup( + &self, + user_id: Uuid, + storage_key: &str, + repo_root_cid: &str, + repo_rev: &str, + block_count: i32, + size_bytes: i64, + ) -> Result { + let id = sqlx::query_scalar!( + r#" + INSERT INTO account_backups (user_id, storage_key, repo_root_cid, repo_rev, block_count, size_bytes) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id + "#, + user_id, + storage_key, + repo_root_cid, + repo_rev, + block_count, + size_bytes + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(id) + } + + async fn get_old_backups( + &self, + user_id: Uuid, + retention_offset: i64, + ) -> Result, DbError> { + let results = sqlx::query!( + r#" + SELECT id, storage_key + FROM account_backups + WHERE user_id = $1 + ORDER BY created_at DESC + OFFSET $2 + "#, + user_id, + retention_offset + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(results + .into_iter() + .map(|r| OldBackupInfo { + id: r.id, + storage_key: r.storage_key, + }) + .collect()) + } + + async fn delete_backup(&self, backup_id: Uuid) -> Result<(), DbError> { + sqlx::query!("DELETE FROM account_backups WHERE id = $1", backup_id) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn get_backup_for_deletion( + &self, + backup_id: Uuid, + did: &Did, + ) -> Result, DbError> { + let result = sqlx::query!( + r#" + SELECT ab.id, ab.storage_key, u.deactivated_at + FROM account_backups ab + JOIN users u ON u.id = ab.user_id + WHERE ab.id = $1 AND u.did = $2 + "#, + backup_id, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.map(|r| BackupForDeletion { + id: r.id, + storage_key: r.storage_key, + deactivated_at: r.deactivated_at, + })) + } + + async fn get_user_deactivated_status( + &self, + did: &Did, + ) -> Result>>, DbError> { + let result = sqlx::query!( + "SELECT deactivated_at FROM users WHERE did = $1", + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.map(|r| r.deactivated_at)) + } + + async fn update_backup_enabled(&self, did: &Did, enabled: bool) -> Result<(), DbError> { + sqlx::query!( + "UPDATE users SET backup_enabled = $1 WHERE did = $2", + enabled, + did.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn get_user_id_by_did(&self, did: &Did) -> Result, DbError> { + let result = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did.as_str()) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result) + } + + async fn get_blobs_for_export(&self, user_id: Uuid) -> Result, DbError> { + let results = sqlx::query!( + r#" + SELECT DISTINCT b.cid, b.storage_key, b.mime_type + FROM blobs b + JOIN record_blobs rb ON rb.blob_cid = b.cid + WHERE rb.repo_id = $1 + "#, + user_id + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(results + .into_iter() + .map(|r| BlobExportInfo { + cid: r.cid, + storage_key: r.storage_key, + mime_type: r.mime_type, + }) + .collect()) + } + + async fn get_users_needing_backup( + &self, + backup_interval_secs: i64, + limit: i64, + ) -> Result, DbError> { + let results = sqlx::query!( + r#" + SELECT u.id, u.did, u.backup_enabled, u.deactivated_at, r.repo_root_cid, r.repo_rev + FROM users u + JOIN repos r ON r.user_id = u.id + WHERE u.backup_enabled = true + AND u.deactivated_at IS NULL + AND ( + NOT EXISTS ( + SELECT 1 FROM account_backups ab WHERE ab.user_id = u.id + ) + OR ( + SELECT MAX(ab.created_at) FROM account_backups ab WHERE ab.user_id = u.id + ) < NOW() - make_interval(secs => $1) + ) + LIMIT $2 + "#, + backup_interval_secs as f64, + limit + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(results + .into_iter() + .map(|r| UserBackupInfo { + id: r.id, + did: r.did.into(), + backup_enabled: r.backup_enabled, + deactivated_at: r.deactivated_at, + repo_root_cid: r.repo_root_cid, + repo_rev: r.repo_rev, + }) + .collect()) + } +} diff --git a/crates/tranquil-db/src/postgres/blob.rs b/crates/tranquil-db/src/postgres/blob.rs new file mode 100644 index 0000000..7b61339 --- /dev/null +++ b/crates/tranquil-db/src/postgres/blob.rs @@ -0,0 +1,295 @@ +use async_trait::async_trait; +use sqlx::PgPool; +use tranquil_db_traits::{ + BlobForExport, BlobMetadata, BlobRepository, BlobWithTakedown, DbError, MissingBlobInfo, +}; +use tranquil_types::{AtUri, CidLink, Did}; +use uuid::Uuid; + +use super::user::map_sqlx_error; + +pub struct PostgresBlobRepository { + pool: PgPool, +} + +impl PostgresBlobRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl BlobRepository for PostgresBlobRepository { + async fn insert_blob( + &self, + cid: &CidLink, + mime_type: &str, + size_bytes: i64, + created_by_user: Uuid, + storage_key: &str, + ) -> Result, DbError> { + let result = sqlx::query_scalar!( + r#"INSERT INTO blobs (cid, mime_type, size_bytes, created_by_user, storage_key) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (cid) DO NOTHING RETURNING cid"#, + cid.as_str(), + mime_type, + size_bytes, + created_by_user, + storage_key + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.map(CidLink::from)) + } + + async fn get_blob_metadata(&self, cid: &CidLink) -> Result, DbError> { + let result = sqlx::query!( + "SELECT storage_key, mime_type, size_bytes FROM blobs WHERE cid = $1", + cid.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.map(|r| BlobMetadata { + storage_key: r.storage_key, + mime_type: r.mime_type, + size_bytes: r.size_bytes, + })) + } + + async fn get_blob_with_takedown( + &self, + cid: &CidLink, + ) -> Result, DbError> { + let result = sqlx::query!( + "SELECT cid, takedown_ref FROM blobs WHERE cid = $1", + cid.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.map(|r| BlobWithTakedown { + cid: CidLink::from(r.cid), + takedown_ref: r.takedown_ref, + })) + } + + async fn get_blob_storage_key(&self, cid: &CidLink) -> Result, DbError> { + let result = sqlx::query_scalar!( + "SELECT storage_key FROM blobs WHERE cid = $1", + cid.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result) + } + + async fn list_blobs_by_user( + &self, + user_id: Uuid, + cursor: Option<&str>, + limit: i64, + ) -> Result, DbError> { + let cursor_val = cursor.unwrap_or(""); + let results = sqlx::query_scalar!( + r#"SELECT cid FROM blobs + WHERE created_by_user = $1 AND cid > $2 + ORDER BY cid ASC + LIMIT $3"#, + user_id, + cursor_val, + limit + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(results.into_iter().map(CidLink::from).collect()) + } + + async fn list_blobs_since_rev( + &self, + did: &Did, + since: &str, + ) -> Result, DbError> { + let results = sqlx::query_scalar!( + r#"SELECT DISTINCT unnest(blobs) as "cid!" + FROM repo_seq + WHERE did = $1 AND rev > $2 AND blobs IS NOT NULL"#, + did.as_str(), + since + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(results.into_iter().map(CidLink::from).collect()) + } + + async fn count_blobs_by_user(&self, user_id: Uuid) -> Result { + let result = sqlx::query_scalar!( + r#"SELECT COUNT(*) as "count!" FROM blobs WHERE created_by_user = $1"#, + user_id + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result) + } + + async fn sum_blob_storage(&self) -> Result { + let result = sqlx::query_scalar!( + r#"SELECT COALESCE(SUM(size_bytes), 0)::BIGINT as "total!" FROM blobs"# + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result) + } + + async fn update_blob_takedown( + &self, + cid: &CidLink, + takedown_ref: Option<&str>, + ) -> Result { + let result = sqlx::query!( + "UPDATE blobs SET takedown_ref = $1 WHERE cid = $2", + takedown_ref, + cid.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.rows_affected() > 0) + } + + async fn delete_blob_by_cid(&self, cid: &CidLink) -> Result { + let result = sqlx::query!("DELETE FROM blobs WHERE cid = $1", cid.as_str()) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.rows_affected() > 0) + } + + async fn delete_blobs_by_user(&self, user_id: Uuid) -> Result { + let result = sqlx::query!("DELETE FROM blobs WHERE created_by_user = $1", user_id) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.rows_affected()) + } + + async fn get_blob_storage_keys_by_user(&self, user_id: Uuid) -> Result, DbError> { + let results = sqlx::query_scalar!( + r#"SELECT storage_key as "storage_key!" FROM blobs WHERE created_by_user = $1"#, + user_id + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(results) + } + + async fn insert_record_blobs( + &self, + repo_id: Uuid, + record_uris: &[AtUri], + blob_cids: &[CidLink], + ) -> Result<(), DbError> { + let uris_str: Vec<&str> = record_uris.iter().map(|u| u.as_str()).collect(); + let cids_str: Vec<&str> = blob_cids.iter().map(|c| c.as_str()).collect(); + + sqlx::query!( + r#"INSERT INTO record_blobs (repo_id, record_uri, blob_cid) + SELECT $1, record_uri, blob_cid + FROM UNNEST($2::text[], $3::text[]) AS t(record_uri, blob_cid) + ON CONFLICT (repo_id, record_uri, blob_cid) DO NOTHING"#, + repo_id, + &uris_str as &[&str], + &cids_str as &[&str] + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn list_missing_blobs( + &self, + repo_id: Uuid, + cursor: Option<&str>, + limit: i64, + ) -> Result, DbError> { + let cursor_val = cursor.unwrap_or(""); + let results = sqlx::query!( + r#"SELECT rb.blob_cid, rb.record_uri + FROM record_blobs rb + LEFT JOIN blobs b ON rb.blob_cid = b.cid + WHERE rb.repo_id = $1 AND b.cid IS NULL AND rb.blob_cid > $2 + ORDER BY rb.blob_cid + LIMIT $3"#, + repo_id, + cursor_val, + limit + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(results + .into_iter() + .map(|r| MissingBlobInfo { + blob_cid: CidLink::from(r.blob_cid), + record_uri: AtUri::from(r.record_uri), + }) + .collect()) + } + + async fn count_distinct_record_blobs(&self, repo_id: Uuid) -> Result { + let result = sqlx::query_scalar!( + r#"SELECT COUNT(DISTINCT blob_cid) as "count!" FROM record_blobs WHERE repo_id = $1"#, + repo_id + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result) + } + + async fn get_blobs_for_export(&self, repo_id: Uuid) -> Result, DbError> { + let results = sqlx::query!( + r#"SELECT DISTINCT b.cid, b.storage_key, b.mime_type + FROM blobs b + JOIN record_blobs rb ON rb.blob_cid = b.cid + WHERE rb.repo_id = $1"#, + repo_id + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(results + .into_iter() + .map(|r| BlobForExport { + cid: CidLink::from(r.cid), + storage_key: r.storage_key, + mime_type: r.mime_type, + }) + .collect()) + } +} diff --git a/crates/tranquil-db/src/postgres/delegation.rs b/crates/tranquil-db/src/postgres/delegation.rs new file mode 100644 index 0000000..c2fcff6 --- /dev/null +++ b/crates/tranquil-db/src/postgres/delegation.rs @@ -0,0 +1,476 @@ +use async_trait::async_trait; +use sqlx::PgPool; +use tranquil_db_traits::{ + AuditLogEntry, ControllerInfo, DbError, DelegatedAccountInfo, DelegationActionType, + DelegationGrant, DelegationRepository, +}; +use tranquil_types::Did; +use uuid::Uuid; + +use super::user::map_sqlx_error; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)] +#[sqlx(type_name = "delegation_action_type", rename_all = "snake_case")] +pub enum PgDelegationActionType { + GrantCreated, + GrantRevoked, + ScopesModified, + TokenIssued, + RepoWrite, + BlobUpload, + AccountAction, +} + +impl From for PgDelegationActionType { + fn from(t: DelegationActionType) -> Self { + match t { + DelegationActionType::GrantCreated => Self::GrantCreated, + DelegationActionType::GrantRevoked => Self::GrantRevoked, + DelegationActionType::ScopesModified => Self::ScopesModified, + DelegationActionType::TokenIssued => Self::TokenIssued, + DelegationActionType::RepoWrite => Self::RepoWrite, + DelegationActionType::BlobUpload => Self::BlobUpload, + DelegationActionType::AccountAction => Self::AccountAction, + } + } +} + +impl From for DelegationActionType { + fn from(t: PgDelegationActionType) -> Self { + match t { + PgDelegationActionType::GrantCreated => Self::GrantCreated, + PgDelegationActionType::GrantRevoked => Self::GrantRevoked, + PgDelegationActionType::ScopesModified => Self::ScopesModified, + PgDelegationActionType::TokenIssued => Self::TokenIssued, + PgDelegationActionType::RepoWrite => Self::RepoWrite, + PgDelegationActionType::BlobUpload => Self::BlobUpload, + PgDelegationActionType::AccountAction => Self::AccountAction, + } + } +} + +pub struct PostgresDelegationRepository { + pool: PgPool, +} + +impl PostgresDelegationRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl DelegationRepository for PostgresDelegationRepository { + async fn is_delegated_account(&self, did: &Did) -> Result { + let result = sqlx::query_scalar!( + r#"SELECT account_type::text = 'delegated' as "is_delegated!" FROM users WHERE did = $1"#, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.unwrap_or(false)) + } + + async fn create_delegation( + &self, + delegated_did: &Did, + controller_did: &Did, + granted_scopes: &str, + granted_by: &Did, + ) -> Result { + let id = sqlx::query_scalar!( + r#" + INSERT INTO account_delegations (delegated_did, controller_did, granted_scopes, granted_by) + VALUES ($1, $2, $3, $4) + RETURNING id + "#, + delegated_did.as_str(), + controller_did.as_str(), + granted_scopes, + granted_by.as_str() + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(id) + } + + async fn revoke_delegation( + &self, + delegated_did: &Did, + controller_did: &Did, + revoked_by: &Did, + ) -> Result { + let result = sqlx::query!( + r#" + UPDATE account_delegations + SET revoked_at = NOW(), revoked_by = $1 + WHERE delegated_did = $2 AND controller_did = $3 AND revoked_at IS NULL + "#, + revoked_by.as_str(), + delegated_did.as_str(), + controller_did.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.rows_affected() > 0) + } + + async fn update_delegation_scopes( + &self, + delegated_did: &Did, + controller_did: &Did, + new_scopes: &str, + ) -> Result { + let result = sqlx::query!( + r#" + UPDATE account_delegations + SET granted_scopes = $1 + WHERE delegated_did = $2 AND controller_did = $3 AND revoked_at IS NULL + "#, + new_scopes, + delegated_did.as_str(), + controller_did.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.rows_affected() > 0) + } + + async fn get_delegation( + &self, + delegated_did: &Did, + controller_did: &Did, + ) -> Result, DbError> { + let row = sqlx::query!( + r#" + SELECT id, delegated_did, controller_did, granted_scopes, + granted_at, granted_by, revoked_at, revoked_by + FROM account_delegations + WHERE delegated_did = $1 AND controller_did = $2 AND revoked_at IS NULL + "#, + delegated_did.as_str(), + controller_did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| DelegationGrant { + id: r.id, + delegated_did: r.delegated_did.into(), + controller_did: r.controller_did.into(), + granted_scopes: r.granted_scopes, + granted_at: r.granted_at, + granted_by: r.granted_by.into(), + revoked_at: r.revoked_at, + revoked_by: r.revoked_by.map(Into::into), + })) + } + + async fn get_delegations_for_account( + &self, + delegated_did: &Did, + ) -> Result, DbError> { + let rows = sqlx::query!( + r#" + SELECT + u.did, + u.handle, + d.granted_scopes, + d.granted_at, + (u.deactivated_at IS NULL AND u.takedown_ref IS NULL) as "is_active!" + FROM account_delegations d + JOIN users u ON u.did = d.controller_did + WHERE d.delegated_did = $1 AND d.revoked_at IS NULL + ORDER BY d.granted_at DESC + "#, + delegated_did.as_str() + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows + .into_iter() + .map(|r| ControllerInfo { + did: r.did.into(), + handle: r.handle.into(), + granted_scopes: r.granted_scopes, + granted_at: r.granted_at, + is_active: r.is_active, + }) + .collect()) + } + + async fn get_accounts_controlled_by( + &self, + controller_did: &Did, + ) -> Result, DbError> { + let rows = sqlx::query!( + r#" + SELECT + u.did, + u.handle, + d.granted_scopes, + d.granted_at + FROM account_delegations d + JOIN users u ON u.did = d.delegated_did + WHERE d.controller_did = $1 + AND d.revoked_at IS NULL + AND u.deactivated_at IS NULL + AND u.takedown_ref IS NULL + ORDER BY d.granted_at DESC + "#, + controller_did.as_str() + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows + .into_iter() + .map(|r| DelegatedAccountInfo { + did: r.did.into(), + handle: r.handle.into(), + granted_scopes: r.granted_scopes, + granted_at: r.granted_at, + }) + .collect()) + } + + async fn get_active_controllers_for_account( + &self, + delegated_did: &Did, + ) -> Result, DbError> { + let rows = sqlx::query!( + r#" + SELECT + u.did, + u.handle, + d.granted_scopes, + d.granted_at, + true as "is_active!" + FROM account_delegations d + JOIN users u ON u.did = d.controller_did + WHERE d.delegated_did = $1 + AND d.revoked_at IS NULL + AND u.deactivated_at IS NULL + AND u.takedown_ref IS NULL + ORDER BY d.granted_at DESC + "#, + delegated_did.as_str() + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows + .into_iter() + .map(|r| ControllerInfo { + did: r.did.into(), + handle: r.handle.into(), + granted_scopes: r.granted_scopes, + granted_at: r.granted_at, + is_active: r.is_active, + }) + .collect()) + } + + async fn count_active_controllers(&self, delegated_did: &Did) -> Result { + let count = sqlx::query_scalar!( + r#" + SELECT COUNT(*) as "count!" + FROM account_delegations d + JOIN users u ON u.did = d.controller_did + WHERE d.delegated_did = $1 + AND d.revoked_at IS NULL + AND u.deactivated_at IS NULL + AND u.takedown_ref IS NULL + "#, + delegated_did.as_str() + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(count) + } + + async fn has_any_controllers(&self, did: &Did) -> Result { + let exists = sqlx::query_scalar!( + r#"SELECT EXISTS( + SELECT 1 FROM account_delegations + WHERE delegated_did = $1 AND revoked_at IS NULL + ) as "exists!""#, + did.as_str() + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(exists) + } + + async fn controls_any_accounts(&self, did: &Did) -> Result { + let exists = sqlx::query_scalar!( + r#"SELECT EXISTS( + SELECT 1 FROM account_delegations + WHERE controller_did = $1 AND revoked_at IS NULL + ) as "exists!""#, + did.as_str() + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(exists) + } + + async fn log_delegation_action( + &self, + delegated_did: &Did, + actor_did: &Did, + controller_did: Option<&Did>, + action_type: DelegationActionType, + action_details: Option, + ip_address: Option<&str>, + user_agent: Option<&str>, + ) -> Result { + let pg_action_type: PgDelegationActionType = action_type.into(); + let controller_did_str = controller_did.map(|d| d.as_str()); + let id = sqlx::query_scalar!( + r#" + INSERT INTO delegation_audit_log + (delegated_did, actor_did, controller_did, action_type, action_details, ip_address, user_agent) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id + "#, + delegated_did.as_str(), + actor_did.as_str(), + controller_did_str, + pg_action_type as PgDelegationActionType, + action_details, + ip_address, + user_agent + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(id) + } + + async fn get_audit_log_for_account( + &self, + delegated_did: &Did, + limit: i64, + offset: i64, + ) -> Result, DbError> { + let rows = sqlx::query!( + r#" + SELECT + id, + delegated_did, + actor_did, + controller_did, + action_type as "action_type: PgDelegationActionType", + action_details, + ip_address, + user_agent, + created_at + FROM delegation_audit_log + WHERE delegated_did = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + "#, + delegated_did.as_str(), + limit, + offset + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows + .into_iter() + .map(|r| AuditLogEntry { + id: r.id, + delegated_did: r.delegated_did.into(), + actor_did: r.actor_did.into(), + controller_did: r.controller_did.map(Into::into), + action_type: r.action_type.into(), + action_details: r.action_details, + ip_address: r.ip_address, + user_agent: r.user_agent, + created_at: r.created_at, + }) + .collect()) + } + + async fn get_audit_log_by_controller( + &self, + controller_did: &Did, + limit: i64, + offset: i64, + ) -> Result, DbError> { + let rows = sqlx::query!( + r#" + SELECT + id, + delegated_did, + actor_did, + controller_did, + action_type as "action_type: PgDelegationActionType", + action_details, + ip_address, + user_agent, + created_at + FROM delegation_audit_log + WHERE controller_did = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + "#, + controller_did.as_str(), + limit, + offset + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows + .into_iter() + .map(|r| AuditLogEntry { + id: r.id, + delegated_did: r.delegated_did.into(), + actor_did: r.actor_did.into(), + controller_did: r.controller_did.map(Into::into), + action_type: r.action_type.into(), + action_details: r.action_details, + ip_address: r.ip_address, + user_agent: r.user_agent, + created_at: r.created_at, + }) + .collect()) + } + + async fn count_audit_log_entries(&self, delegated_did: &Did) -> Result { + let count = sqlx::query_scalar!( + r#"SELECT COUNT(*) as "count!" FROM delegation_audit_log WHERE delegated_did = $1"#, + delegated_did.as_str() + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(count) + } +} diff --git a/crates/tranquil-db/src/postgres/event_notifier.rs b/crates/tranquil-db/src/postgres/event_notifier.rs new file mode 100644 index 0000000..e12aeeb --- /dev/null +++ b/crates/tranquil-db/src/postgres/event_notifier.rs @@ -0,0 +1,41 @@ +use async_trait::async_trait; +use sqlx::postgres::PgListener; +use sqlx::PgPool; +use tranquil_db_traits::{DbError, RepoEventNotifier, RepoEventReceiver}; + +use super::user::map_sqlx_error; + +pub struct PostgresRepoEventNotifier { + pool: PgPool, +} + +impl PostgresRepoEventNotifier { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl RepoEventNotifier for PostgresRepoEventNotifier { + async fn subscribe(&self) -> Result, DbError> { + let mut listener = PgListener::connect_with(&self.pool) + .await + .map_err(map_sqlx_error)?; + listener.listen("repo_updates").await.map_err(map_sqlx_error)?; + Ok(Box::new(PostgresRepoEventReceiver { listener })) + } +} + +pub struct PostgresRepoEventReceiver { + listener: PgListener, +} + +#[async_trait] +impl RepoEventReceiver for PostgresRepoEventReceiver { + async fn recv(&mut self) -> Option { + match self.listener.recv().await { + Ok(notification) => notification.payload().parse().ok(), + Err(_) => None, + } + } +} diff --git a/crates/tranquil-db/src/postgres/infra.rs b/crates/tranquil-db/src/postgres/infra.rs new file mode 100644 index 0000000..185928d --- /dev/null +++ b/crates/tranquil-db/src/postgres/infra.rs @@ -0,0 +1,1018 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use tranquil_db_traits::{ + AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DbError, DeletionRequest, + InfraRepository, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeUse, + NotificationHistoryRow, QueuedComms, ReservedSigningKey, +}; +use tranquil_types::{CidLink, Did, Handle}; +use uuid::Uuid; + +use super::user::map_sqlx_error; + +pub struct PostgresInfraRepository { + pool: PgPool, +} + +impl PostgresInfraRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl InfraRepository for PostgresInfraRepository { + async fn enqueue_comms( + &self, + user_id: Option, + channel: CommsChannel, + comms_type: CommsType, + recipient: &str, + subject: Option<&str>, + body: &str, + metadata: Option, + ) -> Result { + let id = sqlx::query_scalar!( + r#"INSERT INTO comms_queue + (user_id, channel, comms_type, recipient, subject, body, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id"#, + user_id, + channel as CommsChannel, + comms_type as CommsType, + recipient, + subject, + body, + metadata + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(id) + } + + async fn fetch_pending_comms( + &self, + now: DateTime, + batch_size: i64, + ) -> Result, DbError> { + let results = sqlx::query_as!( + QueuedComms, + r#"UPDATE comms_queue + SET status = 'processing', updated_at = NOW() + WHERE id IN ( + SELECT id FROM comms_queue + WHERE status = 'pending' + AND scheduled_for <= $1 + AND attempts < max_attempts + ORDER BY scheduled_for ASC + LIMIT $2 + FOR UPDATE SKIP LOCKED + ) + RETURNING + id, user_id, + channel as "channel: CommsChannel", + comms_type as "comms_type: CommsType", + status as "status: CommsStatus", + recipient, subject, body, metadata, + attempts, max_attempts, last_error, + created_at, updated_at, scheduled_for, processed_at"#, + now, + batch_size + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(results) + } + + async fn mark_comms_sent(&self, id: Uuid) -> Result<(), DbError> { + sqlx::query!( + r#"UPDATE comms_queue + SET status = 'sent', processed_at = NOW(), updated_at = NOW() + WHERE id = $1"#, + id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn mark_comms_failed(&self, id: Uuid, error: &str) -> Result<(), DbError> { + sqlx::query!( + r#"UPDATE comms_queue + SET + status = CASE + WHEN attempts + 1 >= max_attempts THEN 'failed'::comms_status + ELSE 'pending'::comms_status + END, + attempts = attempts + 1, + last_error = $2, + updated_at = NOW(), + scheduled_for = NOW() + (INTERVAL '1 minute' * (attempts + 1)) + WHERE id = $1"#, + id, + error + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn create_invite_code( + &self, + code: &str, + use_count: i32, + for_account: Option<&Did>, + ) -> Result { + let for_account_str = for_account.map(|d| d.as_str()); + let result = sqlx::query!( + r#"INSERT INTO invite_codes (code, available_uses, created_by_user, for_account) + SELECT $1, $2, id, $3 FROM users WHERE is_admin = true LIMIT 1"#, + code, + use_count, + for_account_str + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.rows_affected() > 0) + } + + async fn create_invite_codes_batch( + &self, + codes: &[String], + use_count: i32, + created_by_user: Uuid, + for_account: Option<&Did>, + ) -> Result<(), DbError> { + let for_account_str = for_account.map(|d| d.as_str()); + sqlx::query!( + r#"INSERT INTO invite_codes (code, available_uses, created_by_user, for_account) + SELECT code, $2, $3, $4 FROM UNNEST($1::text[]) AS t(code)"#, + codes, + use_count, + created_by_user, + for_account_str + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn get_invite_code_available_uses(&self, code: &str) -> Result, DbError> { + let result = sqlx::query_scalar!( + "SELECT available_uses FROM invite_codes WHERE code = $1 FOR UPDATE", + code + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result) + } + + async fn is_invite_code_valid(&self, code: &str) -> Result { + let result = sqlx::query_scalar!( + r#"SELECT (available_uses > 0 AND NOT COALESCE(disabled, false)) as "valid!" FROM invite_codes WHERE code = $1"#, + code + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.unwrap_or(false)) + } + + async fn decrement_invite_code_uses(&self, code: &str) -> Result<(), DbError> { + sqlx::query!( + "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1", + code + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn record_invite_code_use(&self, code: &str, used_by_user: Uuid) -> Result<(), DbError> { + sqlx::query!( + "INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", + code, + used_by_user + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn get_invite_codes_for_account( + &self, + for_account: &Did, + ) -> Result, DbError> { + let results = sqlx::query!( + r#"SELECT + ic.code, + ic.available_uses, + ic.created_at, + ic.disabled, + ic.for_account, + (SELECT COUNT(*) FROM invite_code_uses icu WHERE icu.code = ic.code)::int as "use_count!" + FROM invite_codes ic + WHERE ic.for_account = $1 + ORDER BY ic.created_at DESC"#, + for_account.as_str() + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(results + .into_iter() + .map(|r| InviteCodeInfo { + code: r.code, + available_uses: r.available_uses, + disabled: r.disabled.unwrap_or(false), + for_account: Some(Did::from(r.for_account)), + created_at: r.created_at, + created_by: None, + }) + .collect()) + } + + async fn get_invite_code_uses(&self, code: &str) -> Result, DbError> { + let results = sqlx::query!( + r#"SELECT u.did, u.handle, icu.used_at + FROM invite_code_uses icu + JOIN users u ON icu.used_by_user = u.id + WHERE icu.code = $1 + ORDER BY icu.used_at DESC"#, + code + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(results + .into_iter() + .map(|r| InviteCodeUse { + code: code.to_string(), + used_by_did: Did::from(r.did), + used_by_handle: Some(Handle::from(r.handle)), + used_at: r.used_at, + }) + .collect()) + } + + async fn disable_invite_codes_by_code(&self, codes: &[String]) -> Result<(), DbError> { + sqlx::query!( + "UPDATE invite_codes SET disabled = TRUE WHERE code = ANY($1)", + codes + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn disable_invite_codes_by_account(&self, accounts: &[Did]) -> Result<(), DbError> { + let accounts_str: Vec<&str> = accounts.iter().map(|d| d.as_str()).collect(); + sqlx::query!( + r#"UPDATE invite_codes SET disabled = TRUE + WHERE created_by_user IN (SELECT id FROM users WHERE did = ANY($1))"#, + &accounts_str as &[&str] + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn list_invite_codes( + &self, + cursor: Option<&str>, + limit: i64, + sort: InviteCodeSortOrder, + ) -> Result, DbError> { + let results = match (cursor, sort) { + (Some(cursor_code), InviteCodeSortOrder::Recent) => { + sqlx::query_as!( + InviteCodeRow, + r#"SELECT ic.code, ic.available_uses, ic.disabled, ic.created_by_user, ic.created_at + FROM invite_codes ic + WHERE ic.created_at < (SELECT created_at FROM invite_codes WHERE code = $1) + ORDER BY created_at DESC + LIMIT $2"#, + cursor_code, + limit + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)? + } + (None, InviteCodeSortOrder::Recent) => { + sqlx::query_as!( + InviteCodeRow, + r#"SELECT ic.code, ic.available_uses, ic.disabled, ic.created_by_user, ic.created_at + FROM invite_codes ic + ORDER BY created_at DESC + LIMIT $1"#, + limit + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)? + } + (Some(cursor_code), InviteCodeSortOrder::Usage) => { + sqlx::query_as!( + InviteCodeRow, + r#"SELECT ic.code, ic.available_uses, ic.disabled, ic.created_by_user, ic.created_at + FROM invite_codes ic + WHERE ic.created_at < (SELECT created_at FROM invite_codes WHERE code = $1) + ORDER BY available_uses DESC + LIMIT $2"#, + cursor_code, + limit + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)? + } + (None, InviteCodeSortOrder::Usage) => { + sqlx::query_as!( + InviteCodeRow, + r#"SELECT ic.code, ic.available_uses, ic.disabled, ic.created_by_user, ic.created_at + FROM invite_codes ic + ORDER BY available_uses DESC + LIMIT $1"#, + limit + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)? + } + }; + + Ok(results) + } + + async fn get_user_dids_by_ids(&self, user_ids: &[Uuid]) -> Result, DbError> { + let results = sqlx::query!( + "SELECT id, did FROM users WHERE id = ANY($1)", + user_ids + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(results.into_iter().map(|r| (r.id, Did::from(r.did))).collect()) + } + + async fn get_invite_code_uses_batch( + &self, + codes: &[String], + ) -> Result, DbError> { + let results = sqlx::query!( + r#"SELECT icu.code, u.did, icu.used_at + FROM invite_code_uses icu + JOIN users u ON icu.used_by_user = u.id + WHERE icu.code = ANY($1) + ORDER BY icu.used_at DESC"#, + codes + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(results + .into_iter() + .map(|r| InviteCodeUse { + code: r.code, + used_by_did: Did::from(r.did), + used_by_handle: None, + used_at: r.used_at, + }) + .collect()) + } + + async fn get_invites_created_by_user( + &self, + user_id: Uuid, + ) -> Result, DbError> { + let results = sqlx::query!( + r#"SELECT ic.code, ic.available_uses, ic.disabled, ic.for_account, ic.created_at, u.did as created_by + FROM invite_codes ic + JOIN users u ON ic.created_by_user = u.id + WHERE ic.created_by_user = $1"#, + user_id + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(results + .into_iter() + .map(|r| InviteCodeInfo { + code: r.code, + available_uses: r.available_uses, + disabled: r.disabled.unwrap_or(false), + for_account: Some(Did::from(r.for_account)), + created_at: r.created_at, + created_by: Some(Did::from(r.created_by)), + }) + .collect()) + } + + async fn get_invite_code_info(&self, code: &str) -> Result, DbError> { + let result = sqlx::query!( + r#"SELECT ic.code, ic.available_uses, ic.disabled, ic.for_account, ic.created_at, u.did as created_by + FROM invite_codes ic + JOIN users u ON ic.created_by_user = u.id + WHERE ic.code = $1"#, + code + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.map(|r| InviteCodeInfo { + code: r.code, + available_uses: r.available_uses, + disabled: r.disabled.unwrap_or(false), + for_account: Some(Did::from(r.for_account)), + created_at: r.created_at, + created_by: Some(Did::from(r.created_by)), + })) + } + + async fn get_invite_codes_by_users( + &self, + user_ids: &[Uuid], + ) -> Result, DbError> { + let results = sqlx::query!( + r#"SELECT ic.code, ic.available_uses, ic.disabled, ic.for_account, ic.created_at, + ic.created_by_user, u.did as created_by + FROM invite_codes ic + JOIN users u ON ic.created_by_user = u.id + WHERE ic.created_by_user = ANY($1)"#, + user_ids + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(results + .into_iter() + .map(|r| { + ( + r.created_by_user, + InviteCodeInfo { + code: r.code, + available_uses: r.available_uses, + disabled: r.disabled.unwrap_or(false), + for_account: Some(Did::from(r.for_account)), + created_at: r.created_at, + created_by: Some(Did::from(r.created_by)), + }, + ) + }) + .collect()) + } + + async fn get_invite_code_used_by_user(&self, user_id: Uuid) -> Result, DbError> { + let result = sqlx::query_scalar!( + "SELECT code FROM invite_code_uses WHERE used_by_user = $1", + user_id + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result) + } + + async fn delete_invite_code_uses_by_user(&self, user_id: Uuid) -> Result<(), DbError> { + sqlx::query!( + "DELETE FROM invite_code_uses WHERE used_by_user = $1", + user_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn delete_invite_codes_by_user(&self, user_id: Uuid) -> Result<(), DbError> { + sqlx::query!( + "DELETE FROM invite_codes WHERE created_by_user = $1", + user_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn reserve_signing_key( + &self, + did: Option<&Did>, + public_key_did_key: &str, + private_key_bytes: &[u8], + expires_at: DateTime, + ) -> Result { + let did_str = did.map(|d| d.as_str()); + let id = sqlx::query_scalar!( + r#"INSERT INTO reserved_signing_keys (did, public_key_did_key, private_key_bytes, expires_at) + VALUES ($1, $2, $3, $4) + RETURNING id"#, + did_str, + public_key_did_key, + private_key_bytes, + expires_at + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(id) + } + + async fn get_reserved_signing_key( + &self, + public_key_did_key: &str, + ) -> Result, DbError> { + let result = sqlx::query!( + r#"SELECT id, private_key_bytes + FROM reserved_signing_keys + WHERE public_key_did_key = $1 + AND used_at IS NULL + AND expires_at > NOW() + FOR UPDATE"#, + public_key_did_key + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.map(|r| ReservedSigningKey { + id: r.id, + private_key_bytes: r.private_key_bytes, + })) + } + + async fn mark_signing_key_used(&self, key_id: Uuid) -> Result<(), DbError> { + sqlx::query!( + "UPDATE reserved_signing_keys SET used_at = NOW() WHERE id = $1", + key_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn create_deletion_request( + &self, + token: &str, + did: &Did, + expires_at: DateTime, + ) -> Result<(), DbError> { + sqlx::query!( + "INSERT INTO account_deletion_requests (token, did, expires_at) VALUES ($1, $2, $3)", + token, + did.as_str(), + expires_at + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn get_deletion_request(&self, token: &str) -> Result, DbError> { + let result = sqlx::query!( + "SELECT did, expires_at FROM account_deletion_requests WHERE token = $1", + token + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.map(|r| DeletionRequest { + did: Did::from(r.did), + expires_at: r.expires_at, + })) + } + + async fn delete_deletion_request(&self, token: &str) -> Result<(), DbError> { + sqlx::query!( + "DELETE FROM account_deletion_requests WHERE token = $1", + token + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn delete_deletion_requests_by_did(&self, did: &Did) -> Result<(), DbError> { + sqlx::query!( + "DELETE FROM account_deletion_requests WHERE did = $1", + did.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn upsert_account_preference( + &self, + user_id: Uuid, + name: &str, + value_json: serde_json::Value, + ) -> Result<(), DbError> { + sqlx::query!( + r#"INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3) + ON CONFLICT (user_id, name) DO UPDATE SET value_json = $3"#, + user_id, + name, + value_json + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn insert_account_preference_if_not_exists( + &self, + user_id: Uuid, + name: &str, + value_json: serde_json::Value, + ) -> Result<(), DbError> { + sqlx::query!( + r#"INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3) + ON CONFLICT (user_id, name) DO NOTHING"#, + user_id, + name, + value_json + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn get_server_config(&self, key: &str) -> Result, DbError> { + let row = + sqlx::query_scalar!("SELECT value FROM server_config WHERE key = $1", key) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row) + } + + async fn health_check(&self) -> Result { + sqlx::query_scalar!("SELECT 1 as one") + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(true) + } + + async fn insert_report( + &self, + id: i64, + reason_type: &str, + reason: Option<&str>, + subject_json: serde_json::Value, + reported_by_did: &Did, + created_at: DateTime, + ) -> Result<(), DbError> { + sqlx::query!( + "INSERT INTO reports (id, reason_type, reason, subject_json, reported_by_did, created_at) VALUES ($1, $2, $3, $4, $5, $6)", + id, + reason_type, + reason, + subject_json, + reported_by_did.as_str(), + created_at + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn delete_plc_tokens_for_user(&self, user_id: Uuid) -> Result<(), DbError> { + sqlx::query!( + "DELETE FROM plc_operation_tokens WHERE user_id = $1 OR expires_at < NOW()", + user_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn insert_plc_token( + &self, + user_id: Uuid, + token: &str, + expires_at: DateTime, + ) -> Result<(), DbError> { + sqlx::query!( + "INSERT INTO plc_operation_tokens (user_id, token, expires_at) VALUES ($1, $2, $3)", + user_id, + token, + expires_at + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn get_plc_token_expiry( + &self, + user_id: Uuid, + token: &str, + ) -> Result>, DbError> { + let expiry = sqlx::query_scalar!( + "SELECT expires_at FROM plc_operation_tokens WHERE user_id = $1 AND token = $2", + user_id, + token + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(expiry) + } + + async fn delete_plc_token(&self, user_id: Uuid, token: &str) -> Result<(), DbError> { + sqlx::query!( + "DELETE FROM plc_operation_tokens WHERE user_id = $1 AND token = $2", + user_id, + token + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn get_account_preferences( + &self, + user_id: Uuid, + ) -> Result, DbError> { + let rows = sqlx::query!( + "SELECT name, value_json FROM account_preferences WHERE user_id = $1", + user_id + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows.into_iter().map(|r| (r.name, r.value_json)).collect()) + } + + async fn replace_namespace_preferences( + &self, + user_id: Uuid, + namespace: &str, + preferences: Vec<(String, serde_json::Value)>, + ) -> Result<(), DbError> { + let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?; + + let like_pattern = format!("{}.%", namespace); + sqlx::query!( + "DELETE FROM account_preferences WHERE user_id = $1 AND (name = $2 OR name LIKE $3)", + user_id, + namespace, + like_pattern + ) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + for (name, value_json) in preferences { + sqlx::query!( + "INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3)", + user_id, + name, + value_json + ) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + } + + tx.commit().await.map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn get_notification_history( + &self, + user_id: Uuid, + limit: i64, + ) -> Result, DbError> { + let rows = sqlx::query!( + r#" + SELECT + created_at, + channel as "channel: String", + comms_type as "comms_type: String", + status as "status: String", + subject, + body + FROM comms_queue + WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT $2 + "#, + user_id, + limit + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(rows + .into_iter() + .map(|r| NotificationHistoryRow { + created_at: r.created_at, + channel: r.channel, + comms_type: r.comms_type, + status: r.status, + subject: r.subject, + body: r.body, + }) + .collect()) + } + + async fn get_server_configs(&self, keys: &[&str]) -> Result, DbError> { + let keys_vec: Vec = keys.iter().map(|s| s.to_string()).collect(); + let rows: Vec<(String, String)> = sqlx::query_as( + "SELECT key, value FROM server_config WHERE key = ANY($1)", + ) + .bind(&keys_vec) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows) + } + + async fn upsert_server_config(&self, key: &str, value: &str) -> Result<(), DbError> { + sqlx::query( + "INSERT INTO server_config (key, value, updated_at) VALUES ($1, $2, NOW()) + ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()", + ) + .bind(key) + .bind(value) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn delete_server_config(&self, key: &str) -> Result<(), DbError> { + sqlx::query("DELETE FROM server_config WHERE key = $1") + .bind(key) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn get_blob_storage_key_by_cid(&self, cid: &CidLink) -> Result, DbError> { + let result = sqlx::query_scalar!("SELECT storage_key FROM blobs WHERE cid = $1", cid.as_str()) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result) + } + + async fn delete_blob_by_cid(&self, cid: &CidLink) -> Result<(), DbError> { + sqlx::query!("DELETE FROM blobs WHERE cid = $1", cid.as_str()) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn get_admin_account_info_by_did( + &self, + did: &Did, + ) -> Result, DbError> { + let result = sqlx::query!( + r#" + SELECT id, did, handle, email, created_at, invites_disabled, email_verified, deactivated_at + FROM users + WHERE did = $1 + "#, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.map(|r| AdminAccountInfo { + id: r.id, + did: Did::from(r.did), + handle: Handle::from(r.handle), + email: r.email, + created_at: r.created_at, + invites_disabled: r.invites_disabled.unwrap_or(false), + email_verified: r.email_verified, + deactivated_at: r.deactivated_at, + })) + } + + async fn get_admin_account_infos_by_dids( + &self, + dids: &[Did], + ) -> Result, DbError> { + let dids_str: Vec<&str> = dids.iter().map(|d| d.as_str()).collect(); + let results = sqlx::query!( + r#" + SELECT id, did, handle, email, created_at, invites_disabled, email_verified, deactivated_at + FROM users + WHERE did = ANY($1) + "#, + &dids_str as &[&str] + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(results + .into_iter() + .map(|r| AdminAccountInfo { + id: r.id, + did: Did::from(r.did), + handle: Handle::from(r.handle), + email: r.email, + created_at: r.created_at, + invites_disabled: r.invites_disabled.unwrap_or(false), + email_verified: r.email_verified, + deactivated_at: r.deactivated_at, + }) + .collect()) + } + + async fn get_invite_code_uses_by_users( + &self, + user_ids: &[Uuid], + ) -> Result, DbError> { + let results = sqlx::query!( + r#" + SELECT used_by_user, code + FROM invite_code_uses + WHERE used_by_user = ANY($1) + "#, + user_ids + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(results + .into_iter() + .map(|r| (r.used_by_user, r.code)) + .collect()) + } +} diff --git a/crates/tranquil-db/src/postgres/mod.rs b/crates/tranquil-db/src/postgres/mod.rs new file mode 100644 index 0000000..2f6c304 --- /dev/null +++ b/crates/tranquil-db/src/postgres/mod.rs @@ -0,0 +1,60 @@ +mod backlink; +mod backup; +mod blob; +mod delegation; +mod event_notifier; +mod infra; +mod oauth; +mod repo; +mod session; +mod user; + +use sqlx::PgPool; +use std::sync::Arc; + +pub use backlink::PostgresBacklinkRepository; +pub use backup::PostgresBackupRepository; +pub use blob::PostgresBlobRepository; +pub use delegation::PostgresDelegationRepository; +pub use event_notifier::PostgresRepoEventNotifier; +pub use infra::PostgresInfraRepository; +pub use oauth::PostgresOAuthRepository; +pub use repo::PostgresRepoRepository; +pub use session::PostgresSessionRepository; +pub use user::PostgresUserRepository; +use tranquil_db_traits::{ + BacklinkRepository, BackupRepository, BlobRepository, DelegationRepository, InfraRepository, + OAuthRepository, RepoEventNotifier, RepoRepository, SessionRepository, UserRepository, +}; + +pub struct PostgresRepositories { + pub pool: PgPool, + pub user: Arc, + pub oauth: Arc, + pub session: Arc, + pub delegation: Arc, + pub repo: Arc, + pub blob: Arc, + pub infra: Arc, + pub backup: Arc, + pub backlink: Arc, + pub event_notifier: Arc, +} + +impl PostgresRepositories { + pub fn new(pool: PgPool) -> Self { + Self { + pool: pool.clone(), + user: Arc::new(PostgresUserRepository::new(pool.clone())), + oauth: Arc::new(PostgresOAuthRepository::new(pool.clone())), + session: Arc::new(PostgresSessionRepository::new(pool.clone())), + delegation: Arc::new(PostgresDelegationRepository::new(pool.clone())), + repo: Arc::new(PostgresRepoRepository::new(pool.clone())), + blob: Arc::new(PostgresBlobRepository::new(pool.clone())), + infra: Arc::new(PostgresInfraRepository::new(pool.clone())), + backup: Arc::new(PostgresBackupRepository::new(pool.clone())), + backlink: Arc::new(PostgresBacklinkRepository::new(pool.clone())), + event_notifier: Arc::new(PostgresRepoEventNotifier::new(pool)), + } + } +} diff --git a/crates/tranquil-db/src/postgres/oauth.rs b/crates/tranquil-db/src/postgres/oauth.rs new file mode 100644 index 0000000..118401d --- /dev/null +++ b/crates/tranquil-db/src/postgres/oauth.rs @@ -0,0 +1,1214 @@ +use async_trait::async_trait; +use chrono::{DateTime, Duration, Utc}; +use rand::Rng; +use sqlx::PgPool; +use tranquil_db_traits::{ + DbError, DeviceAccountRow, DeviceTrustInfo, OAuthRepository, OAuthSessionListItem, + ScopePreference, TrustedDeviceRow, TwoFactorChallenge, +}; +use tranquil_oauth::{AuthorizedClientData, ClientAuth, AuthorizationRequestParameters, DeviceData, RequestData, TokenData}; +use tranquil_types::{AuthorizationCode, ClientId, DPoPProofId, DeviceId, Did, Handle, RefreshToken, RequestId, TokenId}; +use uuid::Uuid; + +use super::user::map_sqlx_error; + +fn to_json(value: &T) -> Result { + serde_json::to_value(value).map_err(|e| { + tracing::error!("JSON serialization error: {}", e); + DbError::Serialization("Internal serialization error".to_string()) + }) +} + +fn from_json(value: serde_json::Value) -> Result { + serde_json::from_value(value).map_err(|e| { + tracing::error!("JSON deserialization error: {}", e); + DbError::Serialization("Internal data corruption".to_string()) + }) +} + +pub struct PostgresOAuthRepository { + pool: PgPool, +} + +impl PostgresOAuthRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +const REFRESH_GRACE_PERIOD_SECS: i64 = 60; + +#[async_trait] +impl OAuthRepository for PostgresOAuthRepository { + async fn create_token(&self, data: &TokenData) -> Result { + let client_auth_json = to_json(&data.client_auth)?; + let parameters_json = to_json(&data.parameters)?; + let row = sqlx::query!( + r#" + INSERT INTO oauth_token + (did, token_id, created_at, updated_at, expires_at, client_id, client_auth, + device_id, parameters, details, code, current_refresh_token, scope, controller_did) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING id + "#, + data.did, + data.token_id, + data.created_at, + data.updated_at, + data.expires_at, + data.client_id, + client_auth_json, + data.device_id, + parameters_json, + data.details, + data.code, + data.current_refresh_token, + data.scope, + data.controller_did, + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row.id) + } + + async fn get_token_by_id(&self, token_id: &TokenId) -> Result, DbError> { + let row = sqlx::query!( + r#" + SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth, + device_id, parameters, details, code, current_refresh_token, scope, controller_did + FROM oauth_token + WHERE token_id = $1 + "#, + token_id.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + match row { + Some(r) => Ok(Some(TokenData { + did: r.did, + token_id: r.token_id, + created_at: r.created_at, + updated_at: r.updated_at, + expires_at: r.expires_at, + client_id: r.client_id, + client_auth: from_json(r.client_auth)?, + device_id: r.device_id, + parameters: from_json(r.parameters)?, + details: r.details, + code: r.code, + current_refresh_token: r.current_refresh_token, + scope: r.scope, + controller_did: r.controller_did, + })), + None => Ok(None), + } + } + + async fn get_token_by_refresh_token( + &self, + refresh_token: &RefreshToken, + ) -> Result, DbError> { + let row = sqlx::query!( + r#" + SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth, + device_id, parameters, details, code, current_refresh_token, scope, controller_did + FROM oauth_token + WHERE current_refresh_token = $1 + "#, + refresh_token.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + match row { + Some(r) => Ok(Some(( + r.id, + TokenData { + did: r.did, + token_id: r.token_id, + created_at: r.created_at, + updated_at: r.updated_at, + expires_at: r.expires_at, + client_id: r.client_id, + client_auth: from_json(r.client_auth)?, + device_id: r.device_id, + parameters: from_json(r.parameters)?, + details: r.details, + code: r.code, + current_refresh_token: r.current_refresh_token, + scope: r.scope, + controller_did: r.controller_did, + }, + ))), + None => Ok(None), + } + } + + async fn get_token_by_previous_refresh_token( + &self, + refresh_token: &RefreshToken, + ) -> Result, DbError> { + let grace_cutoff = Utc::now() - Duration::seconds(REFRESH_GRACE_PERIOD_SECS); + let row = sqlx::query!( + r#" + SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth, + device_id, parameters, details, code, current_refresh_token, scope, controller_did + FROM oauth_token + WHERE previous_refresh_token = $1 AND rotated_at > $2 + "#, + refresh_token.as_str(), + grace_cutoff + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + match row { + Some(r) => Ok(Some(( + r.id, + TokenData { + did: r.did, + token_id: r.token_id, + created_at: r.created_at, + updated_at: r.updated_at, + expires_at: r.expires_at, + client_id: r.client_id, + client_auth: from_json(r.client_auth)?, + device_id: r.device_id, + parameters: from_json(r.parameters)?, + details: r.details, + code: r.code, + current_refresh_token: r.current_refresh_token, + scope: r.scope, + controller_did: r.controller_did, + }, + ))), + None => Ok(None), + } + } + + async fn rotate_token( + &self, + old_db_id: i32, + new_refresh_token: &RefreshToken, + new_expires_at: DateTime, + ) -> Result<(), DbError> { + let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?; + let old_refresh = sqlx::query_scalar!( + r#" + SELECT current_refresh_token FROM oauth_token WHERE id = $1 + "#, + old_db_id + ) + .fetch_one(&mut *tx) + .await + .map_err(map_sqlx_error)?; + if let Some(ref old_rt) = old_refresh { + sqlx::query!( + r#" + INSERT INTO oauth_used_refresh_token (refresh_token, token_id) + VALUES ($1, $2) + "#, + old_rt, + old_db_id + ) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + } + sqlx::query!( + r#" + UPDATE oauth_token + SET current_refresh_token = $2, expires_at = $3, updated_at = NOW(), + previous_refresh_token = $4, rotated_at = NOW() + WHERE id = $1 + "#, + old_db_id, + new_refresh_token.as_str(), + new_expires_at, + old_refresh + ) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + tx.commit().await.map_err(map_sqlx_error)?; + Ok(()) + } + + async fn check_refresh_token_used(&self, refresh_token: &RefreshToken) -> Result, DbError> { + let row = sqlx::query_scalar!( + r#" + SELECT token_id FROM oauth_used_refresh_token WHERE refresh_token = $1 + "#, + refresh_token.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row) + } + + async fn delete_token(&self, token_id: &TokenId) -> Result<(), DbError> { + sqlx::query!( + r#" + DELETE FROM oauth_token WHERE token_id = $1 + "#, + token_id.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn delete_token_family(&self, db_id: i32) -> Result<(), DbError> { + sqlx::query!( + r#" + DELETE FROM oauth_token WHERE id = $1 + "#, + db_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn list_tokens_for_user(&self, did: &Did) -> Result, DbError> { + let rows = sqlx::query!( + r#" + SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth, + device_id, parameters, details, code, current_refresh_token, scope, controller_did + FROM oauth_token + WHERE did = $1 + "#, + did.as_str() + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + rows.into_iter() + .map(|r| { + Ok(TokenData { + did: r.did, + token_id: r.token_id, + created_at: r.created_at, + updated_at: r.updated_at, + expires_at: r.expires_at, + client_id: r.client_id, + client_auth: from_json(r.client_auth)?, + device_id: r.device_id, + parameters: from_json(r.parameters)?, + details: r.details, + code: r.code, + current_refresh_token: r.current_refresh_token, + scope: r.scope, + controller_did: r.controller_did, + }) + }) + .collect() + } + + async fn count_tokens_for_user(&self, did: &Did) -> Result { + let count = sqlx::query_scalar!( + r#" + SELECT COUNT(*) as "count!" FROM oauth_token WHERE did = $1 + "#, + did.as_str() + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(count) + } + + async fn delete_oldest_tokens_for_user( + &self, + did: &Did, + keep_count: i64, + ) -> Result { + let result = sqlx::query!( + r#" + DELETE FROM oauth_token + WHERE id IN ( + SELECT id FROM oauth_token + WHERE did = $1 + ORDER BY updated_at ASC + OFFSET $2 + ) + "#, + did.as_str(), + keep_count + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(result.rows_affected()) + } + + async fn revoke_tokens_for_client(&self, did: &Did, client_id: &ClientId) -> Result { + let result = sqlx::query!( + "DELETE FROM oauth_token WHERE did = $1 AND client_id = $2", + did.as_str(), + client_id.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(result.rows_affected()) + } + + async fn revoke_tokens_for_controller( + &self, + delegated_did: &Did, + controller_did: &Did, + ) -> Result { + let result = sqlx::query!( + "DELETE FROM oauth_token WHERE did = $1 AND controller_did = $2", + delegated_did.as_str(), + controller_did.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(result.rows_affected()) + } + + async fn create_authorization_request( + &self, + request_id: &RequestId, + data: &RequestData, + ) -> Result<(), DbError> { + let client_auth_json = match &data.client_auth { + Some(ca) => Some(to_json(ca)?), + None => None, + }; + let parameters_json = to_json(&data.parameters)?; + sqlx::query!( + r#" + INSERT INTO oauth_authorization_request + (id, did, device_id, client_id, client_auth, parameters, expires_at, code) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + "#, + request_id.as_str(), + data.did, + data.device_id, + data.client_id, + client_auth_json, + parameters_json, + data.expires_at, + data.code, + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn get_authorization_request( + &self, + request_id: &RequestId, + ) -> Result, DbError> { + let row = sqlx::query!( + r#" + SELECT did, device_id, client_id, client_auth, parameters, expires_at, code, controller_did + FROM oauth_authorization_request + WHERE id = $1 + "#, + request_id.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + match row { + Some(r) => { + let client_auth: Option = match r.client_auth { + Some(v) => Some(from_json(v)?), + None => None, + }; + let parameters: AuthorizationRequestParameters = from_json(r.parameters)?; + Ok(Some(RequestData { + client_id: r.client_id, + client_auth, + parameters, + expires_at: r.expires_at, + did: r.did, + device_id: r.device_id, + code: r.code, + controller_did: r.controller_did, + })) + } + None => Ok(None), + } + } + + async fn set_authorization_did( + &self, + request_id: &RequestId, + did: &Did, + device_id: Option<&DeviceId>, + ) -> Result<(), DbError> { + sqlx::query!( + r#" + UPDATE oauth_authorization_request + SET did = $2, device_id = $3 + WHERE id = $1 + "#, + request_id.as_str(), + did.as_str(), + device_id.map(|d| d.as_str()) + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn update_authorization_request( + &self, + request_id: &RequestId, + did: &Did, + device_id: Option<&DeviceId>, + code: &AuthorizationCode, + ) -> Result<(), DbError> { + sqlx::query!( + r#" + UPDATE oauth_authorization_request + SET did = $2, device_id = $3, code = $4 + WHERE id = $1 + "#, + request_id.as_str(), + did.as_str(), + device_id.map(|d| d.as_str()), + code.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn consume_authorization_request_by_code( + &self, + code: &AuthorizationCode, + ) -> Result, DbError> { + let row = sqlx::query!( + r#" + DELETE FROM oauth_authorization_request + WHERE code = $1 + RETURNING did, device_id, client_id, client_auth, parameters, expires_at, code, controller_did + "#, + code.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + match row { + Some(r) => { + let client_auth: Option = match r.client_auth { + Some(v) => Some(from_json(v)?), + None => None, + }; + let parameters: AuthorizationRequestParameters = from_json(r.parameters)?; + Ok(Some(RequestData { + client_id: r.client_id, + client_auth, + parameters, + expires_at: r.expires_at, + did: r.did, + device_id: r.device_id, + code: r.code, + controller_did: r.controller_did, + })) + } + None => Ok(None), + } + } + + async fn delete_authorization_request(&self, request_id: &RequestId) -> Result<(), DbError> { + sqlx::query!( + r#" + DELETE FROM oauth_authorization_request WHERE id = $1 + "#, + request_id.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn delete_expired_authorization_requests(&self) -> Result { + let result = sqlx::query!( + r#" + DELETE FROM oauth_authorization_request + WHERE expires_at < NOW() + "# + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(result.rows_affected()) + } + + async fn mark_request_authenticated( + &self, + request_id: &RequestId, + did: &Did, + device_id: Option<&DeviceId>, + ) -> Result<(), DbError> { + sqlx::query!( + r#" + UPDATE oauth_authorization_request + SET did = $2, device_id = $3 + WHERE id = $1 + "#, + request_id.as_str(), + did.as_str(), + device_id.map(|d| d.as_str()) + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn update_request_scope(&self, request_id: &RequestId, scope: &str) -> Result<(), DbError> { + sqlx::query!( + r#" + UPDATE oauth_authorization_request + SET parameters = jsonb_set(parameters, '{scope}', to_jsonb($2::text)) + WHERE id = $1 + "#, + request_id.as_str(), + scope + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn set_controller_did( + &self, + request_id: &RequestId, + controller_did: &Did, + ) -> Result<(), DbError> { + sqlx::query!( + r#" + UPDATE oauth_authorization_request + SET controller_did = $2 + WHERE id = $1 + "#, + request_id.as_str(), + controller_did.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn set_request_did(&self, request_id: &RequestId, did: &Did) -> Result<(), DbError> { + sqlx::query!( + r#" + UPDATE oauth_authorization_request + SET did = $2 + WHERE id = $1 + "#, + request_id.as_str(), + did.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn create_device(&self, device_id: &DeviceId, data: &DeviceData) -> Result<(), DbError> { + sqlx::query!( + r#" + INSERT INTO oauth_device (id, session_id, user_agent, ip_address, last_seen_at) + VALUES ($1, $2, $3, $4, $5) + "#, + device_id.as_str(), + data.session_id, + data.user_agent, + data.ip_address, + data.last_seen_at, + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn get_device(&self, device_id: &DeviceId) -> Result, DbError> { + let row = sqlx::query!( + r#" + SELECT session_id, user_agent, ip_address, last_seen_at + FROM oauth_device + WHERE id = $1 + "#, + device_id.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row.map(|r| DeviceData { + session_id: r.session_id, + user_agent: r.user_agent, + ip_address: r.ip_address, + last_seen_at: r.last_seen_at, + })) + } + + async fn update_device_last_seen(&self, device_id: &DeviceId) -> Result<(), DbError> { + sqlx::query!( + r#" + UPDATE oauth_device + SET last_seen_at = NOW() + WHERE id = $1 + "#, + device_id.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn delete_device(&self, device_id: &DeviceId) -> Result<(), DbError> { + sqlx::query!( + r#" + DELETE FROM oauth_device WHERE id = $1 + "#, + device_id.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn upsert_account_device(&self, did: &Did, device_id: &DeviceId) -> Result<(), DbError> { + sqlx::query!( + r#" + INSERT INTO oauth_account_device (did, device_id, created_at, updated_at) + VALUES ($1, $2, NOW(), NOW()) + ON CONFLICT (did, device_id) DO UPDATE SET updated_at = NOW() + "#, + did.as_str(), + device_id.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn get_device_accounts(&self, device_id: &DeviceId) -> Result, DbError> { + let rows = sqlx::query!( + r#" + SELECT u.did, u.handle, u.email, ad.updated_at as last_used_at + FROM oauth_account_device ad + JOIN users u ON u.did = ad.did + WHERE ad.device_id = $1 + AND u.deactivated_at IS NULL + AND u.takedown_ref IS NULL + ORDER BY ad.updated_at DESC + "#, + device_id.as_str() + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(rows + .into_iter() + .map(|r| DeviceAccountRow { + did: Did::from(r.did), + handle: Handle::from(r.handle), + email: r.email, + last_used_at: r.last_used_at, + }) + .collect()) + } + + async fn verify_account_on_device(&self, device_id: &DeviceId, did: &Did) -> Result { + let row = sqlx::query!( + r#" + SELECT 1 as "exists!" + FROM oauth_account_device ad + JOIN users u ON u.did = ad.did + WHERE ad.device_id = $1 + AND ad.did = $2 + AND u.deactivated_at IS NULL + AND u.takedown_ref IS NULL + "#, + device_id.as_str(), + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row.is_some()) + } + + async fn check_and_record_dpop_jti(&self, jti: &DPoPProofId) -> Result { + let result = sqlx::query!( + r#" + INSERT INTO oauth_dpop_jti (jti) + VALUES ($1) + ON CONFLICT (jti) DO NOTHING + "#, + jti.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(result.rows_affected() > 0) + } + + async fn cleanup_expired_dpop_jtis(&self, max_age_secs: i64) -> Result { + let result = sqlx::query!( + r#" + DELETE FROM oauth_dpop_jti + WHERE created_at < NOW() - INTERVAL '1 second' * $1 + "#, + max_age_secs as f64 + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(result.rows_affected()) + } + + async fn create_2fa_challenge( + &self, + did: &Did, + request_uri: &RequestId, + ) -> Result { + let code = { + let mut rng = rand::thread_rng(); + let code_num: u32 = rng.gen_range(0..1_000_000); + format!("{:06}", code_num) + }; + let expires_at = Utc::now() + Duration::minutes(10); + let row = sqlx::query!( + r#" + INSERT INTO oauth_2fa_challenge (did, request_uri, code, expires_at) + VALUES ($1, $2, $3, $4) + RETURNING id, did, request_uri, code, attempts, created_at, expires_at + "#, + did.as_str(), + request_uri.as_str(), + code, + expires_at, + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(TwoFactorChallenge { + id: row.id, + did: Did::from(row.did), + request_uri: row.request_uri, + code: row.code, + attempts: row.attempts, + created_at: row.created_at, + expires_at: row.expires_at, + }) + } + + async fn get_2fa_challenge( + &self, + request_uri: &RequestId, + ) -> Result, DbError> { + let row = sqlx::query!( + r#" + SELECT id, did, request_uri, code, attempts, created_at, expires_at + FROM oauth_2fa_challenge + WHERE request_uri = $1 + "#, + request_uri.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row.map(|r| TwoFactorChallenge { + id: r.id, + did: Did::from(r.did), + request_uri: r.request_uri, + code: r.code, + attempts: r.attempts, + created_at: r.created_at, + expires_at: r.expires_at, + })) + } + + async fn increment_2fa_attempts(&self, id: Uuid) -> Result { + let row = sqlx::query!( + r#" + UPDATE oauth_2fa_challenge + SET attempts = attempts + 1 + WHERE id = $1 + RETURNING attempts + "#, + id + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row.attempts) + } + + async fn delete_2fa_challenge(&self, id: Uuid) -> Result<(), DbError> { + sqlx::query!( + r#" + DELETE FROM oauth_2fa_challenge WHERE id = $1 + "#, + id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn delete_2fa_challenge_by_request_uri(&self, request_uri: &RequestId) -> Result<(), DbError> { + sqlx::query!( + r#" + DELETE FROM oauth_2fa_challenge WHERE request_uri = $1 + "#, + request_uri.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn cleanup_expired_2fa_challenges(&self) -> Result { + let result = sqlx::query!( + r#" + DELETE FROM oauth_2fa_challenge WHERE expires_at < NOW() + "# + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(result.rows_affected()) + } + + async fn check_user_2fa_enabled(&self, did: &Did) -> Result { + let row = sqlx::query!( + r#" + SELECT two_factor_enabled + FROM users + WHERE did = $1 + "#, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row.map(|r| r.two_factor_enabled).unwrap_or(false)) + } + + async fn get_scope_preferences( + &self, + did: &Did, + client_id: &ClientId, + ) -> Result, DbError> { + let rows = sqlx::query!( + r#" + SELECT scope, granted FROM oauth_scope_preference + WHERE did = $1 AND client_id = $2 + "#, + did.as_str(), + client_id.as_str() + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows + .into_iter() + .map(|r| ScopePreference { + scope: r.scope, + granted: r.granted, + }) + .collect()) + } + + async fn upsert_scope_preferences( + &self, + did: &Did, + client_id: &ClientId, + prefs: &[ScopePreference], + ) -> Result<(), DbError> { + for pref in prefs { + sqlx::query!( + r#" + INSERT INTO oauth_scope_preference (did, client_id, scope, granted, created_at, updated_at) + VALUES ($1, $2, $3, $4, NOW(), NOW()) + ON CONFLICT (did, client_id, scope) DO UPDATE SET granted = $4, updated_at = NOW() + "#, + did.as_str(), + client_id.as_str(), + pref.scope, + pref.granted + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + } + Ok(()) + } + + async fn delete_scope_preferences(&self, did: &Did, client_id: &ClientId) -> Result<(), DbError> { + sqlx::query!( + r#" + DELETE FROM oauth_scope_preference + WHERE did = $1 AND client_id = $2 + "#, + did.as_str(), + client_id.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn upsert_authorized_client( + &self, + did: &Did, + client_id: &ClientId, + data: &AuthorizedClientData, + ) -> Result<(), DbError> { + let data_json = to_json(data)?; + sqlx::query!( + r#" + INSERT INTO oauth_authorized_client (did, client_id, created_at, updated_at, data) + VALUES ($1, $2, NOW(), NOW(), $3) + ON CONFLICT (did, client_id) DO UPDATE SET updated_at = NOW(), data = $3 + "#, + did.as_str(), + client_id.as_str(), + data_json + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn get_authorized_client( + &self, + did: &Did, + client_id: &ClientId, + ) -> Result, DbError> { + let row = sqlx::query_scalar!( + r#" + SELECT data FROM oauth_authorized_client + WHERE did = $1 AND client_id = $2 + "#, + did.as_str(), + client_id.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + match row { + Some(v) => Ok(Some(from_json(v)?)), + None => Ok(None), + } + } + + async fn list_trusted_devices(&self, did: &Did) -> Result, DbError> { + let rows = sqlx::query!( + r#"SELECT od.id, od.user_agent, od.friendly_name, od.trusted_at, od.trusted_until, od.last_seen_at + FROM oauth_device od + JOIN oauth_account_device oad ON od.id = oad.device_id + WHERE oad.did = $1 AND od.trusted_until IS NOT NULL AND od.trusted_until > NOW() + ORDER BY od.last_seen_at DESC"#, + did.as_str() + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows + .into_iter() + .map(|r| TrustedDeviceRow { + id: r.id, + user_agent: r.user_agent, + friendly_name: r.friendly_name, + trusted_at: r.trusted_at, + trusted_until: r.trusted_until, + last_seen_at: r.last_seen_at, + }) + .collect()) + } + + async fn get_device_trust_info( + &self, + device_id: &DeviceId, + did: &Did, + ) -> Result, DbError> { + let row = sqlx::query!( + r#"SELECT trusted_at, trusted_until FROM oauth_device od + JOIN oauth_account_device oad ON od.id = oad.device_id + WHERE od.id = $1 AND oad.did = $2"#, + device_id.as_str(), + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| DeviceTrustInfo { + trusted_at: r.trusted_at, + trusted_until: r.trusted_until, + })) + } + + async fn device_belongs_to_user(&self, device_id: &DeviceId, did: &Did) -> Result { + let exists = sqlx::query_scalar!( + r#"SELECT 1 as "one!" FROM oauth_device od + JOIN oauth_account_device oad ON od.id = oad.device_id + WHERE oad.did = $1 AND od.id = $2"#, + did.as_str(), + device_id.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(exists.is_some()) + } + + async fn revoke_device_trust(&self, device_id: &DeviceId) -> Result<(), DbError> { + sqlx::query!( + "UPDATE oauth_device SET trusted_at = NULL, trusted_until = NULL WHERE id = $1", + device_id.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn update_device_friendly_name( + &self, + device_id: &DeviceId, + friendly_name: Option<&str>, + ) -> Result<(), DbError> { + sqlx::query!( + "UPDATE oauth_device SET friendly_name = $1 WHERE id = $2", + friendly_name, + device_id.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn trust_device( + &self, + device_id: &DeviceId, + trusted_at: DateTime, + trusted_until: DateTime, + ) -> Result<(), DbError> { + sqlx::query!( + "UPDATE oauth_device SET trusted_at = $1, trusted_until = $2 WHERE id = $3", + trusted_at, + trusted_until, + device_id.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn extend_device_trust( + &self, + device_id: &DeviceId, + trusted_until: DateTime, + ) -> Result<(), DbError> { + sqlx::query!( + "UPDATE oauth_device SET trusted_until = $1 WHERE id = $2 AND trusted_until IS NOT NULL", + trusted_until, + device_id.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn list_sessions_by_did(&self, did: &Did) -> Result, DbError> { + let rows = sqlx::query!( + r#" + SELECT id, token_id, created_at, expires_at, client_id + FROM oauth_token + WHERE did = $1 AND expires_at > NOW() + ORDER BY created_at DESC + "#, + did.as_str() + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows + .into_iter() + .map(|r| OAuthSessionListItem { + id: r.id, + token_id: TokenId::from(r.token_id), + created_at: r.created_at, + expires_at: r.expires_at, + client_id: ClientId::from(r.client_id), + }) + .collect()) + } + + async fn delete_session_by_id(&self, session_id: i32, did: &Did) -> Result { + let result = sqlx::query!( + "DELETE FROM oauth_token WHERE id = $1 AND did = $2", + session_id, + did.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(result.rows_affected()) + } + + async fn delete_sessions_by_did(&self, did: &Did) -> Result { + let result = sqlx::query!("DELETE FROM oauth_token WHERE did = $1", did.as_str()) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(result.rows_affected()) + } + + async fn delete_sessions_by_did_except( + &self, + did: &Did, + except_token_id: &TokenId, + ) -> Result { + let result = sqlx::query!( + "DELETE FROM oauth_token WHERE did = $1 AND token_id != $2", + did.as_str(), + except_token_id.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(result.rows_affected()) + } +} diff --git a/crates/tranquil-db/src/postgres/repo.rs b/crates/tranquil-db/src/postgres/repo.rs new file mode 100644 index 0000000..cf3c71e --- /dev/null +++ b/crates/tranquil-db/src/postgres/repo.rs @@ -0,0 +1,1447 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use tranquil_db_traits::{ + BrokenGenesisCommit, CommitEventData, DbError, EventBlocksCids, FullRecordInfo, ImportBlock, + ImportRecord, ImportRepoError, RecordInfo, RecordWithTakedown, RepoAccountInfo, RepoInfo, + RepoListItem, RepoRepository, RepoWithoutRev, SequencedEvent, UserNeedingRecordBlobsBackfill, + UserWithoutBlocks, +}; +use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey}; +use uuid::Uuid; + +use super::user::map_sqlx_error; + +struct RecordRow { + rkey: String, + record_cid: String, +} + +struct SequencedEventRow { + seq: i64, + did: String, + created_at: DateTime, + event_type: String, + commit_cid: Option, + prev_cid: Option, + prev_data_cid: Option, + ops: Option, + blobs: Option>, + blocks_cids: Option>, + handle: Option, + active: Option, + status: Option, + rev: Option, +} + +pub struct PostgresRepoRepository { + pool: PgPool, +} + +impl PostgresRepoRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl RepoRepository for PostgresRepoRepository { + async fn create_repo( + &self, + user_id: Uuid, + repo_root_cid: &CidLink, + repo_rev: &str, + ) -> Result<(), DbError> { + sqlx::query!( + "INSERT INTO repos (user_id, repo_root_cid, repo_rev) VALUES ($1, $2, $3)", + user_id, + repo_root_cid.as_str(), + repo_rev + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn update_repo_root( + &self, + user_id: Uuid, + repo_root_cid: &CidLink, + repo_rev: &str, + ) -> Result<(), DbError> { + sqlx::query!( + "UPDATE repos SET repo_root_cid = $1, repo_rev = $2, updated_at = NOW() WHERE user_id = $3", + repo_root_cid.as_str(), + repo_rev, + user_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn update_repo_rev(&self, user_id: Uuid, repo_rev: &str) -> Result<(), DbError> { + sqlx::query!( + "UPDATE repos SET repo_rev = $1 WHERE user_id = $2", + repo_rev, + user_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn delete_repo(&self, user_id: Uuid) -> Result<(), DbError> { + sqlx::query!("DELETE FROM repos WHERE user_id = $1", user_id) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn get_repo_root_for_update(&self, user_id: Uuid) -> Result, DbError> { + let result = sqlx::query_scalar!( + "SELECT repo_root_cid FROM repos WHERE user_id = $1 FOR UPDATE NOWAIT", + user_id + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.map(CidLink::from)) + } + + async fn get_repo(&self, user_id: Uuid) -> Result, DbError> { + let row = sqlx::query!( + "SELECT user_id, repo_root_cid, repo_rev FROM repos WHERE user_id = $1", + user_id + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| RepoInfo { + user_id: r.user_id, + repo_root_cid: CidLink::from(r.repo_root_cid), + repo_rev: r.repo_rev, + })) + } + + async fn get_repo_root_by_did(&self, did: &Did) -> Result, DbError> { + let result = sqlx::query_scalar!( + "SELECT r.repo_root_cid FROM repos r JOIN users u ON r.user_id = u.id WHERE u.did = $1", + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.map(CidLink::from)) + } + + async fn count_repos(&self) -> Result { + let count = + sqlx::query_scalar!(r#"SELECT COUNT(*) as "count!" FROM repos"#) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(count) + } + + async fn get_repos_without_rev(&self) -> Result, DbError> { + let rows = sqlx::query!( + "SELECT user_id, repo_root_cid FROM repos WHERE repo_rev IS NULL" + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows + .into_iter() + .map(|r| RepoWithoutRev { + user_id: r.user_id, + repo_root_cid: CidLink::from(r.repo_root_cid), + }) + .collect()) + } + + async fn upsert_records( + &self, + repo_id: Uuid, + collections: &[Nsid], + rkeys: &[Rkey], + record_cids: &[CidLink], + repo_rev: &str, + ) -> Result<(), DbError> { + let collections_str: Vec<&str> = collections.iter().map(|c| c.as_str()).collect(); + let rkeys_str: Vec<&str> = rkeys.iter().map(|r| r.as_str()).collect(); + let cids_str: Vec<&str> = record_cids.iter().map(|c| c.as_str()).collect(); + + sqlx::query!( + r#" + INSERT INTO records (repo_id, collection, rkey, record_cid, repo_rev) + SELECT $1, collection, rkey, record_cid, $5 + FROM UNNEST($2::text[], $3::text[], $4::text[]) AS t(collection, rkey, record_cid) + ON CONFLICT (repo_id, collection, rkey) DO UPDATE + SET record_cid = EXCLUDED.record_cid, repo_rev = EXCLUDED.repo_rev, created_at = NOW() + "#, + repo_id, + &collections_str as &[&str], + &rkeys_str as &[&str], + &cids_str as &[&str], + repo_rev + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn delete_records( + &self, + repo_id: Uuid, + collections: &[Nsid], + rkeys: &[Rkey], + ) -> Result<(), DbError> { + let collections_str: Vec<&str> = collections.iter().map(|c| c.as_str()).collect(); + let rkeys_str: Vec<&str> = rkeys.iter().map(|r| r.as_str()).collect(); + + sqlx::query!( + r#" + DELETE FROM records + WHERE repo_id = $1 + AND (collection, rkey) IN (SELECT * FROM UNNEST($2::text[], $3::text[])) + "#, + repo_id, + &collections_str as &[&str], + &rkeys_str as &[&str] + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn delete_all_records(&self, repo_id: Uuid) -> Result<(), DbError> { + sqlx::query!("DELETE FROM records WHERE repo_id = $1", repo_id) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn get_record_cid( + &self, + repo_id: Uuid, + collection: &Nsid, + rkey: &Rkey, + ) -> Result, DbError> { + let result = sqlx::query_scalar!( + "SELECT record_cid FROM records WHERE repo_id = $1 AND collection = $2 AND rkey = $3", + repo_id, + collection.as_str(), + rkey.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.map(CidLink::from)) + } + + async fn list_records( + &self, + repo_id: Uuid, + collection: &Nsid, + cursor: Option<&Rkey>, + limit: i64, + reverse: bool, + rkey_start: Option<&Rkey>, + rkey_end: Option<&Rkey>, + ) -> Result, DbError> { + let to_record_info = |rows: Vec| { + rows.into_iter() + .map(|r| RecordInfo { + rkey: Rkey::from(r.rkey), + record_cid: CidLink::from(r.record_cid), + }) + .collect() + }; + + let collection_str = collection.as_str(); + + if let Some(cursor_val) = cursor { + let cursor_str = cursor_val.as_str(); + return match reverse { + false => { + let rows = sqlx::query_as!( + RecordRow, + r#"SELECT rkey, record_cid FROM records + WHERE repo_id = $1 AND collection = $2 AND rkey < $3 + ORDER BY rkey DESC LIMIT $4"#, + repo_id, + collection_str, + cursor_str, + limit + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(to_record_info(rows)) + } + true => { + let rows = sqlx::query_as!( + RecordRow, + r#"SELECT rkey, record_cid FROM records + WHERE repo_id = $1 AND collection = $2 AND rkey > $3 + ORDER BY rkey ASC LIMIT $4"#, + repo_id, + collection_str, + cursor_str, + limit + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(to_record_info(rows)) + } + }; + } + + if let (Some(start), Some(end)) = (rkey_start, rkey_end) { + let start_str = start.as_str(); + let end_str = end.as_str(); + return match reverse { + false => { + let rows = sqlx::query_as!( + RecordRow, + r#"SELECT rkey, record_cid FROM records + WHERE repo_id = $1 AND collection = $2 AND rkey >= $3 AND rkey <= $4 + ORDER BY rkey DESC LIMIT $5"#, + repo_id, + collection_str, + start_str, + end_str, + limit + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(to_record_info(rows)) + } + true => { + let rows = sqlx::query_as!( + RecordRow, + r#"SELECT rkey, record_cid FROM records + WHERE repo_id = $1 AND collection = $2 AND rkey >= $3 AND rkey <= $4 + ORDER BY rkey ASC LIMIT $5"#, + repo_id, + collection_str, + start_str, + end_str, + limit + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(to_record_info(rows)) + } + }; + } + + if let Some(start) = rkey_start { + let start_str = start.as_str(); + return match reverse { + false => { + let rows = sqlx::query_as!( + RecordRow, + r#"SELECT rkey, record_cid FROM records + WHERE repo_id = $1 AND collection = $2 AND rkey >= $3 + ORDER BY rkey DESC LIMIT $4"#, + repo_id, + collection_str, + start_str, + limit + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(to_record_info(rows)) + } + true => { + let rows = sqlx::query_as!( + RecordRow, + r#"SELECT rkey, record_cid FROM records + WHERE repo_id = $1 AND collection = $2 AND rkey >= $3 + ORDER BY rkey ASC LIMIT $4"#, + repo_id, + collection_str, + start_str, + limit + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(to_record_info(rows)) + } + }; + } + + if let Some(end) = rkey_end { + let end_str = end.as_str(); + return match reverse { + false => { + let rows = sqlx::query_as!( + RecordRow, + r#"SELECT rkey, record_cid FROM records + WHERE repo_id = $1 AND collection = $2 AND rkey <= $3 + ORDER BY rkey DESC LIMIT $4"#, + repo_id, + collection_str, + end_str, + limit + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(to_record_info(rows)) + } + true => { + let rows = sqlx::query_as!( + RecordRow, + r#"SELECT rkey, record_cid FROM records + WHERE repo_id = $1 AND collection = $2 AND rkey <= $3 + ORDER BY rkey ASC LIMIT $4"#, + repo_id, + collection_str, + end_str, + limit + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(to_record_info(rows)) + } + }; + } + + match reverse { + false => { + let rows = sqlx::query_as!( + RecordRow, + r#"SELECT rkey, record_cid FROM records + WHERE repo_id = $1 AND collection = $2 + ORDER BY rkey DESC LIMIT $3"#, + repo_id, + collection_str, + limit + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(to_record_info(rows)) + } + true => { + let rows = sqlx::query_as!( + RecordRow, + r#"SELECT rkey, record_cid FROM records + WHERE repo_id = $1 AND collection = $2 + ORDER BY rkey ASC LIMIT $3"#, + repo_id, + collection_str, + limit + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(to_record_info(rows)) + } + } + } + + async fn get_all_records(&self, repo_id: Uuid) -> Result, DbError> { + let rows = sqlx::query!( + "SELECT collection, rkey, record_cid FROM records WHERE repo_id = $1", + repo_id + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows + .into_iter() + .map(|r| FullRecordInfo { + collection: Nsid::from(r.collection), + rkey: Rkey::from(r.rkey), + record_cid: CidLink::from(r.record_cid), + }) + .collect()) + } + + async fn list_collections(&self, repo_id: Uuid) -> Result, DbError> { + let rows = sqlx::query_scalar!( + "SELECT DISTINCT collection FROM records WHERE repo_id = $1", + repo_id + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows.into_iter().map(Nsid::from).collect()) + } + + async fn count_records(&self, repo_id: Uuid) -> Result { + let count = sqlx::query_scalar!( + r#"SELECT COUNT(*) as "count!" FROM records WHERE repo_id = $1"#, + repo_id + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(count) + } + + async fn count_all_records(&self) -> Result { + let count = sqlx::query_scalar!(r#"SELECT COUNT(*) as "count!" FROM records"#) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(count) + } + + async fn get_record_by_cid(&self, cid: &CidLink) -> Result, DbError> { + let row = sqlx::query!( + "SELECT id, takedown_ref FROM records WHERE record_cid = $1", + cid.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| RecordWithTakedown { + id: r.id, + takedown_ref: r.takedown_ref, + })) + } + + async fn set_record_takedown( + &self, + cid: &CidLink, + takedown_ref: Option<&str>, + ) -> Result<(), DbError> { + sqlx::query!( + "UPDATE records SET takedown_ref = $1 WHERE record_cid = $2", + takedown_ref, + cid.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn insert_user_blocks( + &self, + user_id: Uuid, + block_cids: &[Vec], + repo_rev: &str, + ) -> Result<(), DbError> { + sqlx::query( + r#" + INSERT INTO user_blocks (user_id, block_cid, repo_rev) + SELECT $1, block_cid, $3 FROM UNNEST($2::bytea[]) AS t(block_cid) + ON CONFLICT (user_id, block_cid) DO NOTHING + "#, + ) + .bind(user_id) + .bind(block_cids) + .bind(repo_rev) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn delete_user_blocks( + &self, + user_id: Uuid, + block_cids: &[Vec], + ) -> Result<(), DbError> { + sqlx::query!( + "DELETE FROM user_blocks WHERE user_id = $1 AND block_cid = ANY($2)", + user_id, + block_cids + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn count_user_blocks(&self, user_id: Uuid) -> Result { + let count = sqlx::query_scalar!( + r#"SELECT COUNT(*) as "count!" FROM user_blocks WHERE user_id = $1"#, + user_id + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(count) + } + + async fn get_user_block_cids_since_rev( + &self, + user_id: Uuid, + since_rev: &str, + ) -> Result>, DbError> { + let rows: Vec<(Vec,)> = sqlx::query_as( + r#" + SELECT block_cid FROM user_blocks + WHERE user_id = $1 AND repo_rev > $2 + ORDER BY repo_rev ASC + "#, + ) + .bind(user_id) + .bind(since_rev) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows.into_iter().map(|(cid,)| cid).collect()) + } + + async fn insert_commit_event(&self, data: &CommitEventData) -> Result { + let seq = sqlx::query_scalar!( + r#" + INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, prev_data_cid, rev) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING seq + "#, + data.did.as_str(), + data.event_type, + data.commit_cid.as_ref().map(|c| c.as_str()), + data.prev_cid.as_ref().map(|c| c.as_str()), + data.ops, + data.blobs.as_deref(), + data.blocks_cids.as_deref(), + data.prev_data_cid.as_ref().map(|c| c.as_str()), + data.rev + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(seq) + } + + async fn insert_identity_event(&self, did: &Did, handle: Option<&Handle>) -> Result { + let handle_str = handle.map(|h| h.as_str()); + let seq = sqlx::query_scalar!( + r#" + INSERT INTO repo_seq (did, event_type, handle) + VALUES ($1, 'identity', $2) + RETURNING seq + "#, + did.as_str(), + handle_str + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq)) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(seq) + } + + async fn insert_account_event( + &self, + did: &Did, + active: bool, + status: Option<&str>, + ) -> Result { + let seq = sqlx::query_scalar!( + r#" + INSERT INTO repo_seq (did, event_type, active, status) + VALUES ($1, 'account', $2, $3) + RETURNING seq + "#, + did.as_str(), + active, + status + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq)) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(seq) + } + + async fn insert_sync_event( + &self, + did: &Did, + commit_cid: &CidLink, + rev: Option<&str>, + ) -> Result { + let seq = sqlx::query_scalar!( + r#" + INSERT INTO repo_seq (did, event_type, commit_cid, rev) + VALUES ($1, 'sync', $2, $3) + RETURNING seq + "#, + did.as_str(), + commit_cid.as_str(), + rev + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq)) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(seq) + } + + async fn insert_genesis_commit_event( + &self, + did: &Did, + commit_cid: &CidLink, + mst_root_cid: &CidLink, + rev: &str, + ) -> Result { + let ops = serde_json::json!([]); + let blobs: Vec = vec![]; + let blocks_cids: Vec = vec![mst_root_cid.to_string(), commit_cid.to_string()]; + let prev_cid: Option<&str> = None; + + let seq = sqlx::query_scalar!( + r#" + INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, rev) + VALUES ($1, 'commit', $2, $3::TEXT, $4, $5, $6, $7) + RETURNING seq + "#, + did.as_str(), + commit_cid.as_str(), + prev_cid, + ops, + &blobs, + &blocks_cids, + rev + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq)) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(seq) + } + + async fn update_seq_blocks_cids( + &self, + seq: i64, + blocks_cids: &[String], + ) -> Result<(), DbError> { + sqlx::query!( + "UPDATE repo_seq SET blocks_cids = $1 WHERE seq = $2", + blocks_cids, + seq + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn delete_sequences_except(&self, did: &Did, keep_seq: i64) -> Result<(), DbError> { + sqlx::query!( + "DELETE FROM repo_seq WHERE did = $1 AND seq != $2", + did.as_str(), + keep_seq + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn get_max_seq(&self) -> Result { + let seq = sqlx::query_scalar!(r#"SELECT COALESCE(MAX(seq), 0) as "max!" FROM repo_seq"#) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(seq) + } + + async fn get_min_seq_since(&self, since: DateTime) -> Result, DbError> { + let seq = sqlx::query_scalar!( + "SELECT MIN(seq) FROM repo_seq WHERE created_at >= $1", + since + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(seq) + } + + async fn get_account_with_repo(&self, did: &Did) -> Result, DbError> { + let row = sqlx::query!( + r#"SELECT u.id, u.did, u.deactivated_at, u.takedown_ref, r.repo_root_cid as "repo_root_cid?" + FROM users u + LEFT JOIN repos r ON r.user_id = u.id + WHERE u.did = $1"#, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| RepoAccountInfo { + user_id: r.id, + did: Did::from(r.did), + deactivated_at: r.deactivated_at, + takedown_ref: r.takedown_ref, + repo_root_cid: r.repo_root_cid.map(CidLink::from), + })) + } + + async fn get_events_since_seq( + &self, + since_seq: i64, + limit: Option, + ) -> Result, DbError> { + let map_row = |r: SequencedEventRow| SequencedEvent { + seq: r.seq, + did: Did::from(r.did), + created_at: r.created_at, + event_type: r.event_type, + commit_cid: r.commit_cid.map(CidLink::from), + prev_cid: r.prev_cid.map(CidLink::from), + prev_data_cid: r.prev_data_cid.map(CidLink::from), + ops: r.ops, + blobs: r.blobs, + blocks_cids: r.blocks_cids, + handle: r.handle.map(Handle::from), + active: r.active, + status: r.status, + rev: r.rev, + }; + match limit { + Some(lim) => { + let rows = sqlx::query_as!( + SequencedEventRow, + r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid, + ops, blobs, blocks_cids, handle, active, status, rev + FROM repo_seq + WHERE seq > $1 + ORDER BY seq ASC + LIMIT $2"#, + since_seq, + lim + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(rows.into_iter().map(map_row).collect()) + } + None => { + let rows = sqlx::query_as!( + SequencedEventRow, + r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid, + ops, blobs, blocks_cids, handle, active, status, rev + FROM repo_seq + WHERE seq > $1 + ORDER BY seq ASC"#, + since_seq + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(rows.into_iter().map(map_row).collect()) + } + } + } + + async fn get_events_in_seq_range( + &self, + start_seq: i64, + end_seq: i64, + ) -> Result, DbError> { + let rows = sqlx::query!( + r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid, + ops, blobs, blocks_cids, handle, active, status, rev + FROM repo_seq + WHERE seq > $1 AND seq < $2 + ORDER BY seq ASC"#, + start_seq, + end_seq + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(rows + .into_iter() + .map(|r| SequencedEvent { + seq: r.seq, + did: Did::from(r.did), + created_at: r.created_at, + event_type: r.event_type, + commit_cid: r.commit_cid.map(CidLink::from), + prev_cid: r.prev_cid.map(CidLink::from), + prev_data_cid: r.prev_data_cid.map(CidLink::from), + ops: r.ops, + blobs: r.blobs, + blocks_cids: r.blocks_cids, + handle: r.handle.map(Handle::from), + active: r.active, + status: r.status, + rev: r.rev, + }) + .collect()) + } + + async fn get_event_by_seq(&self, seq: i64) -> Result, DbError> { + let row = sqlx::query!( + r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid, + ops, blobs, blocks_cids, handle, active, status, rev + FROM repo_seq + WHERE seq = $1"#, + seq + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row.map(|r| SequencedEvent { + seq: r.seq, + did: Did::from(r.did), + created_at: r.created_at, + event_type: r.event_type, + commit_cid: r.commit_cid.map(CidLink::from), + prev_cid: r.prev_cid.map(CidLink::from), + prev_data_cid: r.prev_data_cid.map(CidLink::from), + ops: r.ops, + blobs: r.blobs, + blocks_cids: r.blocks_cids, + handle: r.handle.map(Handle::from), + active: r.active, + status: r.status, + rev: r.rev, + })) + } + + async fn get_events_since_cursor( + &self, + cursor: i64, + limit: i64, + ) -> Result, DbError> { + let rows = sqlx::query!( + r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid, + ops, blobs, blocks_cids, handle, active, status, rev + FROM repo_seq + WHERE seq > $1 + ORDER BY seq ASC + LIMIT $2"#, + cursor, + limit + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(rows + .into_iter() + .map(|r| SequencedEvent { + seq: r.seq, + did: Did::from(r.did), + created_at: r.created_at, + event_type: r.event_type, + commit_cid: r.commit_cid.map(CidLink::from), + prev_cid: r.prev_cid.map(CidLink::from), + prev_data_cid: r.prev_data_cid.map(CidLink::from), + ops: r.ops, + blobs: r.blobs, + blocks_cids: r.blocks_cids, + handle: r.handle.map(Handle::from), + active: r.active, + status: r.status, + rev: r.rev, + }) + .collect()) + } + + async fn get_events_since_rev( + &self, + did: &Did, + since_rev: &str, + ) -> Result, DbError> { + let rows = sqlx::query!( + r#"SELECT blocks_cids, commit_cid + FROM repo_seq + WHERE did = $1 AND rev > $2 + ORDER BY seq DESC"#, + did.as_str(), + since_rev + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows + .into_iter() + .map(|r| EventBlocksCids { + blocks_cids: r.blocks_cids, + commit_cid: r.commit_cid.map(CidLink::from), + }) + .collect()) + } + + async fn list_repos_paginated( + &self, + cursor_did: Option<&Did>, + limit: i64, + ) -> Result, DbError> { + let cursor_str = cursor_did.map(|d| d.as_str()).unwrap_or(""); + let rows = sqlx::query!( + r#"SELECT u.did, u.deactivated_at, u.takedown_ref, r.repo_root_cid, r.repo_rev + FROM repos r + JOIN users u ON r.user_id = u.id + WHERE u.did > $1 + ORDER BY u.did ASC + LIMIT $2"#, + cursor_str, + limit + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows + .into_iter() + .map(|r| RepoListItem { + did: Did::from(r.did), + deactivated_at: r.deactivated_at, + takedown_ref: r.takedown_ref, + repo_root_cid: CidLink::from(r.repo_root_cid), + repo_rev: r.repo_rev, + }) + .collect()) + } + + async fn get_repo_root_cid_by_user_id(&self, user_id: Uuid) -> Result, DbError> { + let cid = sqlx::query_scalar!( + "SELECT repo_root_cid FROM repos WHERE user_id = $1", + user_id + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(cid.map(CidLink::from)) + } + + async fn notify_update(&self, seq: i64) -> Result<(), DbError> { + sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq)) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn import_repo_data( + &self, + user_id: Uuid, + blocks: &[ImportBlock], + records: &[ImportRecord], + ) -> Result<(), ImportRepoError> { + let mut tx = self + .pool + .begin() + .await + .map_err(|e| ImportRepoError::Database(e.to_string()))?; + + let repo = sqlx::query!( + "SELECT repo_root_cid FROM repos WHERE user_id = $1 FOR UPDATE NOWAIT", + user_id + ) + .fetch_optional(&mut *tx) + .await + .map_err(|e| { + if let sqlx::Error::Database(ref db_err) = e + && db_err.code().as_deref() == Some("55P03") + { + return ImportRepoError::ConcurrentModification; + } + ImportRepoError::Database(e.to_string()) + })?; + + if repo.is_none() { + return Err(ImportRepoError::RepoNotFound); + } + + let block_chunks: Vec> = blocks + .iter() + .collect::>() + .chunks(100) + .map(|c| c.to_vec()) + .collect(); + + for chunk in block_chunks { + for block in chunk { + sqlx::query!( + "INSERT INTO blocks (cid, data) VALUES ($1, $2) ON CONFLICT (cid) DO NOTHING", + &block.cid_bytes, + &block.data + ) + .execute(&mut *tx) + .await + .map_err(|e| ImportRepoError::Database(e.to_string()))?; + } + } + + sqlx::query!("DELETE FROM records WHERE repo_id = $1", user_id) + .execute(&mut *tx) + .await + .map_err(|e| ImportRepoError::Database(e.to_string()))?; + + for record in records { + sqlx::query!( + r#" + INSERT INTO records (repo_id, collection, rkey, record_cid) + VALUES ($1, $2, $3, $4) + ON CONFLICT (repo_id, collection, rkey) DO UPDATE SET record_cid = $4 + "#, + user_id, + record.collection.as_str(), + record.rkey.as_str(), + record.record_cid.as_str() + ) + .execute(&mut *tx) + .await + .map_err(|e| ImportRepoError::Database(e.to_string()))?; + } + + tx.commit() + .await + .map_err(|e| ImportRepoError::Database(e.to_string()))?; + + Ok(()) + } + + async fn apply_commit( + &self, + input: tranquil_db_traits::ApplyCommitInput, + ) -> Result { + use tranquil_db_traits::ApplyCommitError; + + let mut tx = self + .pool + .begin() + .await + .map_err(|e| ApplyCommitError::Database(e.to_string()))?; + + let lock_result: Result, sqlx::Error> = sqlx::query!( + "SELECT repo_root_cid FROM repos WHERE user_id = $1 FOR UPDATE NOWAIT", + input.user_id + ) + .fetch_optional(&mut *tx) + .await; + + match lock_result { + Err(e) => { + if let Some(db_err) = e.as_database_error() + && db_err.code().as_deref() == Some("55P03") + { + return Err(ApplyCommitError::ConcurrentModification); + } + return Err(ApplyCommitError::Database(format!( + "Failed to acquire repo lock: {}", + e + ))); + } + Ok(Some(row)) => { + if let Some(expected_root) = &input.expected_root_cid + && row.repo_root_cid != expected_root.as_str() + { + return Err(ApplyCommitError::ConcurrentModification); + } + } + Ok(None) => { + return Err(ApplyCommitError::RepoNotFound); + } + } + + let is_account_active: bool = sqlx::query_scalar( + "SELECT deactivated_at IS NULL FROM users WHERE id = $1", + ) + .bind(input.user_id) + .fetch_optional(&mut *tx) + .await + .map_err(|e| ApplyCommitError::Database(e.to_string()))? + .flatten() + .unwrap_or(false); + + sqlx::query( + "UPDATE repos SET repo_root_cid = $1, repo_rev = $2 WHERE user_id = $3", + ) + .bind(&input.new_root_cid) + .bind(&input.new_rev) + .bind(input.user_id) + .execute(&mut *tx) + .await + .map_err(|e| ApplyCommitError::Database(e.to_string()))?; + + if !input.new_block_cids.is_empty() { + sqlx::query( + r#" + INSERT INTO user_blocks (user_id, block_cid, repo_rev) + SELECT $1, block_cid, $3 FROM UNNEST($2::bytea[]) AS t(block_cid) + ON CONFLICT (user_id, block_cid) DO NOTHING + "#, + ) + .bind(input.user_id) + .bind(&input.new_block_cids) + .bind(&input.new_rev) + .execute(&mut *tx) + .await + .map_err(|e| ApplyCommitError::Database(e.to_string()))?; + } + + if !input.obsolete_block_cids.is_empty() { + sqlx::query( + r#" + DELETE FROM user_blocks + WHERE user_id = $1 + AND block_cid = ANY($2) + "#, + ) + .bind(input.user_id) + .bind(&input.obsolete_block_cids) + .execute(&mut *tx) + .await + .map_err(|e| ApplyCommitError::Database(e.to_string()))?; + } + + if !input.record_upserts.is_empty() { + let collections: Vec<&str> = input + .record_upserts + .iter() + .map(|r| r.collection.as_str()) + .collect(); + let rkeys: Vec<&str> = input.record_upserts.iter().map(|r| r.rkey.as_str()).collect(); + let cids: Vec<&str> = input.record_upserts.iter().map(|r| r.cid.as_str()).collect(); + + sqlx::query( + r#" + INSERT INTO records (repo_id, collection, rkey, record_cid, repo_rev) + SELECT $1, t.collection, t.rkey, t.cid, $5 + FROM UNNEST($2::text[], $3::text[], $4::text[]) AS t(collection, rkey, cid) + ON CONFLICT (repo_id, collection, rkey) DO UPDATE SET record_cid = EXCLUDED.record_cid, repo_rev = EXCLUDED.repo_rev + "#, + ) + .bind(input.user_id) + .bind(&collections) + .bind(&rkeys) + .bind(&cids) + .bind(&input.new_rev) + .execute(&mut *tx) + .await + .map_err(|e| ApplyCommitError::Database(e.to_string()))?; + } + + if !input.record_deletes.is_empty() { + let collections: Vec<&str> = input + .record_deletes + .iter() + .map(|r| r.collection.as_str()) + .collect(); + let rkeys: Vec<&str> = input.record_deletes.iter().map(|r| r.rkey.as_str()).collect(); + + sqlx::query( + r#" + DELETE FROM records + WHERE repo_id = $1 + AND (collection, rkey) IN (SELECT collection, rkey FROM UNNEST($2::text[], $3::text[]) AS t(collection, rkey)) + "#, + ) + .bind(input.user_id) + .bind(&collections) + .bind(&rkeys) + .execute(&mut *tx) + .await + .map_err(|e| ApplyCommitError::Database(e.to_string()))?; + } + + let event = &input.commit_event; + let seq: i64 = sqlx::query_scalar( + r#" + INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, prev_data_cid, rev) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING seq + "#, + ) + .bind(&event.did) + .bind(&event.event_type) + .bind(&event.commit_cid) + .bind(&event.prev_cid) + .bind(&event.ops) + .bind(&event.blobs) + .bind(&event.blocks_cids) + .bind(&event.prev_data_cid) + .bind(&event.rev) + .fetch_one(&mut *tx) + .await + .map_err(|e| ApplyCommitError::Database(e.to_string()))?; + + sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq)) + .execute(&mut *tx) + .await + .map_err(|e| ApplyCommitError::Database(e.to_string()))?; + + tx.commit() + .await + .map_err(|e| ApplyCommitError::Database(e.to_string()))?; + + Ok(tranquil_db_traits::ApplyCommitResult { + seq, + is_account_active, + }) + } + + async fn get_broken_genesis_commits( + &self, + ) -> Result, DbError> { + let rows = sqlx::query!( + r#" + SELECT seq, did, commit_cid + FROM repo_seq + WHERE event_type = 'commit' + AND prev_cid IS NULL + AND (blocks_cids IS NULL OR array_length(blocks_cids, 1) IS NULL OR array_length(blocks_cids, 1) = 0) + "# + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows + .into_iter() + .map(|r| BrokenGenesisCommit { + seq: r.seq, + did: Did::from(r.did), + commit_cid: r.commit_cid.map(CidLink::from), + }) + .collect()) + } + + async fn get_users_without_blocks( + &self, + ) -> Result, DbError> { + let rows: Vec<(Uuid, String, Option)> = sqlx::query_as( + r#" + SELECT u.id as user_id, r.repo_root_cid, r.repo_rev + FROM users u + JOIN repos r ON r.user_id = u.id + WHERE NOT EXISTS (SELECT 1 FROM user_blocks ub WHERE ub.user_id = u.id) + "#, + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows + .into_iter() + .map(|(user_id, repo_root_cid, repo_rev)| UserWithoutBlocks { + user_id, + repo_root_cid: CidLink::from(repo_root_cid), + repo_rev, + }) + .collect()) + } + + async fn get_users_needing_record_blobs_backfill( + &self, + limit: i64, + ) -> Result, DbError> { + let rows = sqlx::query!( + r#" + SELECT DISTINCT u.id as user_id, u.did + FROM users u + JOIN records r ON r.repo_id = u.id + WHERE NOT EXISTS (SELECT 1 FROM record_blobs rb WHERE rb.repo_id = u.id) + LIMIT $1 + "#, + limit + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows + .into_iter() + .map(|r| UserNeedingRecordBlobsBackfill { + user_id: r.user_id, + did: Did::from(r.did), + }) + .collect()) + } + + async fn insert_record_blobs( + &self, + repo_id: Uuid, + record_uris: &[AtUri], + blob_cids: &[CidLink], + ) -> Result<(), DbError> { + let uris_str: Vec<&str> = record_uris.iter().map(|u| u.as_str()).collect(); + let cids_str: Vec<&str> = blob_cids.iter().map(|c| c.as_str()).collect(); + + sqlx::query!( + r#" + INSERT INTO record_blobs (repo_id, record_uri, blob_cid) + SELECT $1, record_uri, blob_cid + FROM UNNEST($2::text[], $3::text[]) AS t(record_uri, blob_cid) + ON CONFLICT (repo_id, record_uri, blob_cid) DO NOTHING + "#, + repo_id, + &uris_str as &[&str], + &cids_str as &[&str] + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } +} diff --git a/crates/tranquil-db/src/postgres/session.rs b/crates/tranquil-db/src/postgres/session.rs new file mode 100644 index 0000000..72c8069 --- /dev/null +++ b/crates/tranquil-db/src/postgres/session.rs @@ -0,0 +1,567 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use tranquil_db_traits::{ + AppPasswordCreate, AppPasswordRecord, DbError, RefreshSessionResult, SessionForRefresh, + SessionListItem, SessionMfaStatus, SessionRefreshData, SessionRepository, SessionToken, + SessionTokenCreate, +}; +use tranquil_types::Did; +use uuid::Uuid; + +use super::user::map_sqlx_error; + +pub struct PostgresSessionRepository { + pool: PgPool, +} + +impl PostgresSessionRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl SessionRepository for PostgresSessionRepository { + async fn create_session(&self, data: &SessionTokenCreate) -> Result { + let row = sqlx::query!( + r#" + INSERT INTO session_tokens + (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, + legacy_login, mfa_verified, scope, controller_did, app_password_name) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id + "#, + data.did.as_str(), + data.access_jti, + data.refresh_jti, + data.access_expires_at, + data.refresh_expires_at, + data.legacy_login, + data.mfa_verified, + data.scope, + data.controller_did.as_ref().map(|d| d.as_str()), + data.app_password_name + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.id) + } + + async fn get_session_by_access_jti( + &self, + access_jti: &str, + ) -> Result, DbError> { + let row = sqlx::query!( + r#" + SELECT id, did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, + legacy_login, mfa_verified, scope, controller_did, app_password_name, + created_at, updated_at + FROM session_tokens + WHERE access_jti = $1 + "#, + access_jti + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| SessionToken { + id: r.id, + did: Did::from(r.did), + access_jti: r.access_jti, + refresh_jti: r.refresh_jti, + access_expires_at: r.access_expires_at, + refresh_expires_at: r.refresh_expires_at, + legacy_login: r.legacy_login, + mfa_verified: r.mfa_verified, + scope: r.scope, + controller_did: r.controller_did.map(Did::from), + app_password_name: r.app_password_name, + created_at: r.created_at, + updated_at: r.updated_at, + })) + } + + async fn get_session_for_refresh( + &self, + refresh_jti: &str, + ) -> Result, DbError> { + let row = sqlx::query!( + r#" + SELECT st.id, st.did, st.scope, st.controller_did, k.key_bytes, k.encryption_version + FROM session_tokens st + JOIN users u ON st.did = u.did + JOIN user_keys k ON u.id = k.user_id + WHERE st.refresh_jti = $1 AND st.refresh_expires_at > NOW() + "#, + refresh_jti + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| SessionForRefresh { + id: r.id, + did: Did::from(r.did), + scope: r.scope, + controller_did: r.controller_did.map(Did::from), + key_bytes: r.key_bytes, + encryption_version: r.encryption_version.unwrap_or(0), + })) + } + + async fn update_session_tokens( + &self, + session_id: i32, + new_access_jti: &str, + new_refresh_jti: &str, + new_access_expires_at: DateTime, + new_refresh_expires_at: DateTime, + ) -> Result<(), DbError> { + sqlx::query!( + r#" + UPDATE session_tokens + SET access_jti = $1, refresh_jti = $2, access_expires_at = $3, + refresh_expires_at = $4, updated_at = NOW() + WHERE id = $5 + "#, + new_access_jti, + new_refresh_jti, + new_access_expires_at, + new_refresh_expires_at, + session_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn delete_session_by_access_jti(&self, access_jti: &str) -> Result { + let result = sqlx::query!( + "DELETE FROM session_tokens WHERE access_jti = $1", + access_jti + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.rows_affected()) + } + + async fn delete_session_by_id(&self, session_id: i32) -> Result { + let result = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", session_id) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.rows_affected()) + } + + async fn delete_sessions_by_did(&self, did: &Did) -> Result { + let result = sqlx::query!("DELETE FROM session_tokens WHERE did = $1", did.as_str()) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.rows_affected()) + } + + async fn delete_sessions_by_did_except_jti( + &self, + did: &Did, + except_jti: &str, + ) -> Result { + let result = sqlx::query!( + "DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2", + did.as_str(), + except_jti + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.rows_affected()) + } + + async fn list_sessions_by_did(&self, did: &Did) -> Result, DbError> { + let rows = sqlx::query!( + r#" + SELECT id, access_jti, created_at, refresh_expires_at + FROM session_tokens + WHERE did = $1 AND refresh_expires_at > NOW() + ORDER BY created_at DESC + "#, + did.as_str() + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows + .into_iter() + .map(|r| SessionListItem { + id: r.id, + access_jti: r.access_jti, + created_at: r.created_at, + refresh_expires_at: r.refresh_expires_at, + }) + .collect()) + } + + async fn get_session_access_jti_by_id( + &self, + session_id: i32, + did: &Did, + ) -> Result, DbError> { + let row = sqlx::query_scalar!( + "SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2", + session_id, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row) + } + + async fn delete_sessions_by_app_password( + &self, + did: &Did, + app_password_name: &str, + ) -> Result { + let result = sqlx::query!( + "DELETE FROM session_tokens WHERE did = $1 AND app_password_name = $2", + did.as_str(), + app_password_name + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.rows_affected()) + } + + async fn get_session_jtis_by_app_password( + &self, + did: &Did, + app_password_name: &str, + ) -> Result, DbError> { + let rows = sqlx::query_scalar!( + "SELECT access_jti FROM session_tokens WHERE did = $1 AND app_password_name = $2", + did.as_str(), + app_password_name + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows) + } + + async fn check_refresh_token_used(&self, refresh_jti: &str) -> Result, DbError> { + let row = sqlx::query_scalar!( + "SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1", + refresh_jti + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row) + } + + async fn mark_refresh_token_used( + &self, + refresh_jti: &str, + session_id: i32, + ) -> Result { + let result = sqlx::query!( + r#" + INSERT INTO used_refresh_tokens (refresh_jti, session_id) + VALUES ($1, $2) + ON CONFLICT (refresh_jti) DO NOTHING + "#, + refresh_jti, + session_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.rows_affected() > 0) + } + + async fn list_app_passwords(&self, user_id: Uuid) -> Result, DbError> { + let rows = sqlx::query!( + r#" + SELECT id, user_id, name, password_hash, created_at, privileged, scopes, created_by_controller_did + FROM app_passwords + WHERE user_id = $1 + ORDER BY created_at DESC + "#, + user_id + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows + .into_iter() + .map(|r| AppPasswordRecord { + id: r.id, + user_id: r.user_id, + name: r.name, + password_hash: r.password_hash, + created_at: r.created_at, + privileged: r.privileged, + scopes: r.scopes, + created_by_controller_did: r.created_by_controller_did.map(Did::from), + }) + .collect()) + } + + async fn get_app_passwords_for_login( + &self, + user_id: Uuid, + ) -> Result, DbError> { + let rows = sqlx::query!( + r#" + SELECT id, user_id, name, password_hash, created_at, privileged, scopes, created_by_controller_did + FROM app_passwords + WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT 20 + "#, + user_id + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows + .into_iter() + .map(|r| AppPasswordRecord { + id: r.id, + user_id: r.user_id, + name: r.name, + password_hash: r.password_hash, + created_at: r.created_at, + privileged: r.privileged, + scopes: r.scopes, + created_by_controller_did: r.created_by_controller_did.map(Did::from), + }) + .collect()) + } + + async fn get_app_password_by_name( + &self, + user_id: Uuid, + name: &str, + ) -> Result, DbError> { + let row = sqlx::query!( + r#" + SELECT id, user_id, name, password_hash, created_at, privileged, scopes, created_by_controller_did + FROM app_passwords + WHERE user_id = $1 AND name = $2 + "#, + user_id, + name + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| AppPasswordRecord { + id: r.id, + user_id: r.user_id, + name: r.name, + password_hash: r.password_hash, + created_at: r.created_at, + privileged: r.privileged, + scopes: r.scopes, + created_by_controller_did: r.created_by_controller_did.map(Did::from), + })) + } + + async fn create_app_password(&self, data: &AppPasswordCreate) -> Result { + let row = sqlx::query!( + r#" + INSERT INTO app_passwords (user_id, name, password_hash, privileged, scopes, created_by_controller_did) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id + "#, + data.user_id, + data.name, + data.password_hash, + data.privileged, + data.scopes, + data.created_by_controller_did.as_ref().map(|d| d.as_str()) + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.id) + } + + async fn delete_app_password(&self, user_id: Uuid, name: &str) -> Result { + let result = sqlx::query!( + "DELETE FROM app_passwords WHERE user_id = $1 AND name = $2", + user_id, + name + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.rows_affected()) + } + + async fn delete_app_passwords_by_controller( + &self, + did: &Did, + controller_did: &Did, + ) -> Result { + let result = sqlx::query!( + r#"DELETE FROM app_passwords + WHERE user_id = (SELECT id FROM users WHERE did = $1) + AND created_by_controller_did = $2"#, + did.as_str(), + controller_did.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.rows_affected()) + } + + async fn get_last_reauth_at(&self, did: &Did) -> Result>, DbError> { + let row = sqlx::query_scalar!( + r#"SELECT last_reauth_at FROM session_tokens + WHERE did = $1 ORDER BY created_at DESC LIMIT 1"#, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.flatten()) + } + + async fn update_last_reauth(&self, did: &Did) -> Result, DbError> { + let now = Utc::now(); + sqlx::query!( + "UPDATE session_tokens SET last_reauth_at = $1, mfa_verified = TRUE WHERE did = $2", + now, + did.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(now) + } + + async fn get_session_mfa_status(&self, did: &Did) -> Result, DbError> { + let row = sqlx::query!( + r#"SELECT legacy_login, mfa_verified, last_reauth_at FROM session_tokens + WHERE did = $1 ORDER BY created_at DESC LIMIT 1"#, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| SessionMfaStatus { + legacy_login: r.legacy_login, + mfa_verified: r.mfa_verified, + last_reauth_at: r.last_reauth_at, + })) + } + + async fn update_mfa_verified(&self, did: &Did) -> Result<(), DbError> { + sqlx::query!( + "UPDATE session_tokens SET mfa_verified = TRUE, last_reauth_at = NOW() WHERE did = $1", + did.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn get_app_password_hashes_by_did(&self, did: &Did) -> Result, DbError> { + let rows = sqlx::query_scalar!( + r#"SELECT ap.password_hash FROM app_passwords ap + JOIN users u ON ap.user_id = u.id + WHERE u.did = $1"#, + did.as_str() + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows) + } + + async fn refresh_session_atomic( + &self, + data: &SessionRefreshData, + ) -> Result { + let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?; + + if let Ok(Some(session_id)) = sqlx::query_scalar!( + "SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1 FOR UPDATE", + data.old_refresh_jti + ) + .fetch_optional(&mut *tx) + .await + { + let _ = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", session_id) + .execute(&mut *tx) + .await; + tx.commit().await.map_err(map_sqlx_error)?; + return Ok(RefreshSessionResult::TokenAlreadyUsed); + } + + let result = sqlx::query!( + "INSERT INTO used_refresh_tokens (refresh_jti, session_id) VALUES ($1, $2) ON CONFLICT (refresh_jti) DO NOTHING", + data.old_refresh_jti, + data.session_id + ) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + if result.rows_affected() == 0 { + let _ = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", data.session_id) + .execute(&mut *tx) + .await; + tx.commit().await.map_err(map_sqlx_error)?; + return Ok(RefreshSessionResult::ConcurrentRefresh); + } + + sqlx::query!( + "UPDATE session_tokens SET access_jti = $1, refresh_jti = $2, access_expires_at = $3, refresh_expires_at = $4, updated_at = NOW() WHERE id = $5", + data.new_access_jti, + data.new_refresh_jti, + data.new_access_expires_at, + data.new_refresh_expires_at, + data.session_id + ) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + tx.commit().await.map_err(map_sqlx_error)?; + Ok(RefreshSessionResult::Success) + } +} diff --git a/crates/tranquil-db/src/postgres/user.rs b/crates/tranquil-db/src/postgres/user.rs new file mode 100644 index 0000000..349796b --- /dev/null +++ b/crates/tranquil-db/src/postgres/user.rs @@ -0,0 +1,2778 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use tranquil_types::{Did, Handle}; +use uuid::Uuid; + +use tranquil_db_traits::{ + AccountSearchResult, CommsChannel, DbError, DidWebOverrides, NotificationPrefs, + OAuthTokenWithUser, PasswordResetResult, StoredBackupCode, StoredPasskey, TotpRecord, + User2faStatus, UserAuthInfo, UserCommsPrefs, UserConfirmSignup, UserDidWebInfo, UserEmailInfo, + UserForDeletion, UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery, UserForPasskeySetup, + UserForRecovery, UserForVerification, UserIdAndHandle, UserIdAndPasswordHash, UserIdHandleEmail, + UserInfoForAuth, UserKeyInfo, UserKeyWithId, UserLegacyLoginPref, UserLoginCheck, UserLoginFull, + UserLoginInfo, UserPasswordInfo, UserRepository, UserResendVerification, UserResetCodeInfo, + UserRow, UserSessionInfo, UserStatus, UserVerificationInfo, UserWithKey, +}; + +pub struct PostgresUserRepository { + pool: PgPool, +} + +impl PostgresUserRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +pub(crate) fn map_sqlx_error(e: sqlx::Error) -> DbError { + match e { + sqlx::Error::RowNotFound => DbError::NotFound, + sqlx::Error::Database(db_err) => { + let msg = db_err.message().to_string(); + if db_err.is_unique_violation() || db_err.is_foreign_key_violation() { + DbError::Constraint(msg) + } else { + DbError::Query(msg) + } + } + sqlx::Error::PoolTimedOut => DbError::Connection("Pool timed out".into()), + _ => DbError::Other(e.to_string()), + } +} + +#[async_trait] +impl UserRepository for PostgresUserRepository { + async fn get_by_did(&self, did: &Did) -> Result, DbError> { + let row = sqlx::query!( + r#"SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin + FROM users WHERE did = $1"#, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| UserRow { + id: r.id, + did: Did::from(r.did), + handle: Handle::from(r.handle), + email: r.email, + created_at: r.created_at, + deactivated_at: r.deactivated_at, + takedown_ref: r.takedown_ref, + is_admin: r.is_admin, + })) + } + + async fn get_by_handle(&self, handle: &Handle) -> Result, DbError> { + let row = sqlx::query!( + r#"SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin + FROM users WHERE handle = $1"#, + handle.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| UserRow { + id: r.id, + did: Did::from(r.did), + handle: Handle::from(r.handle), + email: r.email, + created_at: r.created_at, + deactivated_at: r.deactivated_at, + takedown_ref: r.takedown_ref, + is_admin: r.is_admin, + })) + } + + async fn get_with_key_by_did(&self, did: &Did) -> Result, DbError> { + let row = sqlx::query!( + r#"SELECT u.id, u.did, u.handle, u.email, u.deactivated_at, u.takedown_ref, u.is_admin, + k.key_bytes, k.encryption_version + FROM users u + JOIN user_keys k ON u.id = k.user_id + WHERE u.did = $1"#, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| UserWithKey { + id: r.id, + did: Did::from(r.did), + handle: Handle::from(r.handle), + email: r.email, + deactivated_at: r.deactivated_at, + takedown_ref: r.takedown_ref, + is_admin: r.is_admin, + key_bytes: r.key_bytes, + encryption_version: r.encryption_version, + })) + } + + async fn get_status_by_did(&self, did: &Did) -> Result, DbError> { + let row = sqlx::query!( + "SELECT deactivated_at, takedown_ref, is_admin FROM users WHERE did = $1", + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| UserStatus { + deactivated_at: r.deactivated_at, + takedown_ref: r.takedown_ref, + is_admin: r.is_admin, + })) + } + + async fn count_users(&self) -> Result { + let row = sqlx::query_scalar!("SELECT COUNT(*) FROM users") + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row.unwrap_or(0)) + } + + async fn get_session_access_expiry( + &self, + did: &Did, + access_jti: &str, + ) -> Result>, DbError> { + let row = sqlx::query!( + "SELECT access_expires_at FROM session_tokens WHERE did = $1 AND access_jti = $2", + did.as_str(), + access_jti + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| r.access_expires_at)) + } + + async fn get_oauth_token_with_user( + &self, + token_id: &str, + ) -> Result, DbError> { + let row = sqlx::query!( + r#"SELECT t.did, t.expires_at, u.deactivated_at, u.takedown_ref, u.is_admin, + k.key_bytes as "key_bytes?", k.encryption_version as "encryption_version?" + FROM oauth_token t + JOIN users u ON t.did = u.did + LEFT JOIN user_keys k ON u.id = k.user_id + WHERE t.token_id = $1"#, + token_id + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| OAuthTokenWithUser { + did: Did::from(r.did), + expires_at: r.expires_at, + deactivated_at: r.deactivated_at, + takedown_ref: r.takedown_ref, + is_admin: r.is_admin, + key_bytes: r.key_bytes, + encryption_version: r.encryption_version, + })) + } + + async fn get_user_info_by_did(&self, did: &Did) -> Result, DbError> { + let row = sqlx::query!( + r#"SELECT u.deactivated_at, u.takedown_ref, u.is_admin, + k.key_bytes as "key_bytes?", k.encryption_version as "encryption_version?" + FROM users u + LEFT JOIN user_keys k ON u.id = k.user_id + WHERE u.did = $1"#, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| UserInfoForAuth { + deactivated_at: r.deactivated_at, + takedown_ref: r.takedown_ref, + is_admin: r.is_admin, + key_bytes: r.key_bytes, + encryption_version: r.encryption_version, + })) + } + + async fn get_any_admin_user_id(&self) -> Result, DbError> { + let row = sqlx::query_scalar!("SELECT id FROM users WHERE is_admin = true LIMIT 1") + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row) + } + + async fn set_invites_disabled(&self, did: &Did, disabled: bool) -> Result { + let result = sqlx::query!( + "UPDATE users SET invites_disabled = $2 WHERE did = $1", + did.as_str(), + disabled + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(result.rows_affected() > 0) + } + + async fn search_accounts( + &self, + cursor_did: Option<&Did>, + email_filter: Option<&str>, + handle_filter: Option<&str>, + limit: i64, + ) -> Result, DbError> { + let cursor_str = cursor_did.map(|d| d.as_str()); + let rows = sqlx::query!( + r#"SELECT did, handle, email, created_at, email_verified, deactivated_at, invites_disabled + FROM users + WHERE ($1::text IS NULL OR did > $1) + AND ($2::text IS NULL OR email ILIKE $2) + AND ($3::text IS NULL OR handle ILIKE $3) + ORDER BY did ASC + LIMIT $4"#, + cursor_str, + email_filter, + handle_filter, + limit + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(rows + .into_iter() + .map(|r| AccountSearchResult { + did: Did::from(r.did), + handle: Handle::from(r.handle), + email: r.email, + created_at: r.created_at, + email_verified: r.email_verified, + deactivated_at: r.deactivated_at, + invites_disabled: r.invites_disabled, + }) + .collect()) + } + + async fn get_auth_info_by_did(&self, did: &Did) -> Result, DbError> { + let row = sqlx::query!( + r#"SELECT id, did, password_hash, deactivated_at, takedown_ref, + email_verified, discord_verified, telegram_verified, signal_verified + FROM users + WHERE did = $1"#, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row.map(|r| UserAuthInfo { + id: r.id, + did: Did::from(r.did), + password_hash: r.password_hash, + deactivated_at: r.deactivated_at, + takedown_ref: r.takedown_ref, + email_verified: r.email_verified, + discord_verified: r.discord_verified, + telegram_verified: r.telegram_verified, + signal_verified: r.signal_verified, + })) + } + + async fn get_by_email(&self, email: &str) -> Result, DbError> { + let row = sqlx::query!( + r#"SELECT id, did, email, email_verified, handle + FROM users + WHERE LOWER(email) = $1"#, + email + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row.map(|r| UserForVerification { + id: r.id, + did: Did::from(r.did), + email: r.email, + email_verified: r.email_verified, + handle: Handle::from(r.handle), + })) + } + + async fn get_comms_prefs(&self, user_id: Uuid) -> Result, DbError> { + let row = sqlx::query!( + r#"SELECT email, handle, preferred_comms_channel::text as "preferred_channel!", preferred_locale + FROM users WHERE id = $1"#, + user_id + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row.map(|r| UserCommsPrefs { + email: r.email, + handle: Handle::from(r.handle), + preferred_channel: r.preferred_channel, + preferred_locale: r.preferred_locale, + })) + } + + async fn get_id_by_did(&self, did: &Did) -> Result, DbError> { + let id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did.as_str()) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(id) + } + + async fn get_user_key_by_id(&self, user_id: Uuid) -> Result, DbError> { + let row = sqlx::query!( + "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", + user_id + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row.map(|r| UserKeyInfo { + key_bytes: r.key_bytes, + encryption_version: r.encryption_version, + })) + } + + async fn get_id_and_handle_by_did(&self, did: &Did) -> Result, DbError> { + let row = sqlx::query!( + "SELECT id, handle FROM users WHERE did = $1", + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row.map(|r| UserIdAndHandle { + id: r.id, + handle: Handle::from(r.handle), + })) + } + + async fn get_did_web_info_by_handle( + &self, + handle: &Handle, + ) -> Result, DbError> { + let row = sqlx::query!( + "SELECT id, did, migrated_to_pds FROM users WHERE handle = $1", + handle.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row.map(|r| UserDidWebInfo { + id: r.id, + did: Did::from(r.did), + migrated_to_pds: r.migrated_to_pds, + })) + } + + async fn get_did_web_overrides(&self, user_id: Uuid) -> Result, DbError> { + let row = sqlx::query!( + "SELECT verification_methods, also_known_as FROM did_web_overrides WHERE user_id = $1", + user_id + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row.map(|r| DidWebOverrides { + verification_methods: r.verification_methods, + also_known_as: r.also_known_as, + })) + } + + async fn get_handle_by_did(&self, did: &Did) -> Result, DbError> { + let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did.as_str()) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(handle.map(Handle::from)) + } + + async fn check_handle_exists(&self, handle: &Handle, exclude_user_id: Uuid) -> Result { + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM users WHERE handle = $1 AND id != $2) as \"exists!\"", + handle.as_str(), + exclude_user_id + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(exists) + } + + async fn update_handle(&self, user_id: Uuid, handle: &Handle) -> Result<(), DbError> { + sqlx::query!( + "UPDATE users SET handle = $1 WHERE id = $2", + handle.as_str(), + user_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn get_user_with_key_by_did( + &self, + did: &Did, + ) -> Result, DbError> { + let row = sqlx::query!( + r#"SELECT u.id, uk.key_bytes, uk.encryption_version + FROM users u + JOIN user_keys uk ON u.id = uk.user_id + WHERE u.did = $1"#, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row.map(|r| UserKeyWithId { + id: r.id, + key_bytes: r.key_bytes, + encryption_version: r.encryption_version, + })) + } + + async fn is_account_migrated(&self, did: &Did) -> Result { + let row = sqlx::query!( + r#"SELECT (migrated_to_pds IS NOT NULL AND deactivated_at IS NOT NULL) as "migrated!: bool" FROM users WHERE did = $1"#, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row.map(|r| r.migrated).unwrap_or(false)) + } + + async fn has_verified_comms_channel(&self, did: &Did) -> Result { + let row = sqlx::query!( + r#"SELECT + email_verified, + discord_verified, + telegram_verified, + signal_verified + FROM users + WHERE did = $1"#, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row + .map(|r| r.email_verified || r.discord_verified || r.telegram_verified || r.signal_verified) + .unwrap_or(false)) + } + + async fn get_id_by_handle(&self, handle: &Handle) -> Result, DbError> { + let id = sqlx::query_scalar!("SELECT id FROM users WHERE handle = $1", handle.as_str()) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(id) + } + + async fn get_email_info_by_did(&self, did: &Did) -> Result, DbError> { + let row = sqlx::query!( + "SELECT id, handle, email, email_verified FROM users WHERE did = $1", + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row.map(|r| UserEmailInfo { + id: r.id, + handle: Handle::from(r.handle), + email: r.email, + email_verified: r.email_verified, + })) + } + + async fn check_email_exists(&self, email: &str, exclude_user_id: Uuid) -> Result { + let row = sqlx::query!( + "SELECT 1 as one FROM users WHERE LOWER(email) = $1 AND id != $2", + email.to_lowercase(), + exclude_user_id + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row.is_some()) + } + + async fn update_email(&self, user_id: Uuid, email: &str) -> Result<(), DbError> { + sqlx::query!( + "UPDATE users SET email = $1, email_verified = FALSE, updated_at = NOW() WHERE id = $2", + email, + user_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn set_email_verified(&self, user_id: Uuid, verified: bool) -> Result<(), DbError> { + sqlx::query!( + "UPDATE users SET email_verified = $1, updated_at = NOW() WHERE id = $2", + verified, + user_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn check_email_verified_by_identifier( + &self, + identifier: &str, + ) -> Result, DbError> { + let row = sqlx::query_scalar!( + "SELECT email_verified FROM users WHERE email = $1 OR handle = $1", + identifier + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row) + } + + async fn admin_update_email(&self, did: &Did, email: &str) -> Result { + let result = sqlx::query!( + "UPDATE users SET email = $1 WHERE did = $2", + email, + did.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(result.rows_affected()) + } + + async fn admin_update_handle(&self, did: &Did, handle: &Handle) -> Result { + let result = sqlx::query!( + "UPDATE users SET handle = $1 WHERE did = $2", + handle.as_str(), + did.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(result.rows_affected()) + } + + async fn admin_update_password(&self, did: &Did, password_hash: &str) -> Result { + let result = sqlx::query!( + "UPDATE users SET password_hash = $1 WHERE did = $2", + password_hash, + did.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(result.rows_affected()) + } + + async fn get_notification_prefs(&self, did: &Did) -> Result, DbError> { + let row = sqlx::query!( + r#"SELECT + email, + preferred_comms_channel::text as "preferred_channel!", + discord_id, + discord_verified, + telegram_username, + telegram_verified, + signal_number, + signal_verified + FROM users WHERE did = $1"#, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row.map(|r| NotificationPrefs { + email: r.email.unwrap_or_default(), + preferred_channel: r.preferred_channel, + discord_id: r.discord_id, + discord_verified: r.discord_verified, + telegram_username: r.telegram_username, + telegram_verified: r.telegram_verified, + signal_number: r.signal_number, + signal_verified: r.signal_verified, + })) + } + + async fn get_id_handle_email_by_did( + &self, + did: &Did, + ) -> Result, DbError> { + let row = sqlx::query!( + "SELECT id, handle, email FROM users WHERE did = $1", + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row.map(|r| UserIdHandleEmail { + id: r.id, + handle: Handle::from(r.handle), + email: r.email, + })) + } + + async fn update_preferred_comms_channel(&self, did: &Did, channel: &str) -> Result<(), DbError> { + sqlx::query( + "UPDATE users SET preferred_comms_channel = $1::comms_channel, updated_at = NOW() WHERE did = $2", + ) + .bind(channel) + .bind(did.as_str()) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn clear_discord(&self, user_id: Uuid) -> Result<(), DbError> { + sqlx::query!( + "UPDATE users SET discord_id = NULL, discord_verified = FALSE, updated_at = NOW() WHERE id = $1", + user_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn clear_telegram(&self, user_id: Uuid) -> Result<(), DbError> { + sqlx::query!( + "UPDATE users SET telegram_username = NULL, telegram_verified = FALSE, updated_at = NOW() WHERE id = $1", + user_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn clear_signal(&self, user_id: Uuid) -> Result<(), DbError> { + sqlx::query!( + "UPDATE users SET signal_number = NULL, signal_verified = FALSE, updated_at = NOW() WHERE id = $1", + user_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn get_verification_info( + &self, + did: &Did, + ) -> Result, DbError> { + let row = sqlx::query!( + r#"SELECT id, handle, email, email_verified, discord_verified, telegram_verified, signal_verified + FROM users WHERE did = $1"#, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row.map(|r| UserVerificationInfo { + id: r.id, + handle: Handle::from(r.handle), + email: r.email, + email_verified: r.email_verified, + discord_verified: r.discord_verified, + telegram_verified: r.telegram_verified, + signal_verified: r.signal_verified, + })) + } + + async fn verify_email_channel(&self, user_id: Uuid, email: &str) -> Result { + let result = sqlx::query!( + "UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2", + email, + user_id + ) + .execute(&self.pool) + .await; + match result { + Ok(_) => Ok(true), + Err(e) => { + if e.as_database_error() + .map(|db| db.is_unique_violation()) + .unwrap_or(false) + { + Ok(false) + } else { + Err(map_sqlx_error(e)) + } + } + } + } + + async fn verify_discord_channel(&self, user_id: Uuid, discord_id: &str) -> Result<(), DbError> { + sqlx::query!( + "UPDATE users SET discord_id = $1, discord_verified = TRUE, updated_at = NOW() WHERE id = $2", + discord_id, + user_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn verify_telegram_channel( + &self, + user_id: Uuid, + telegram_username: &str, + ) -> Result<(), DbError> { + sqlx::query!( + "UPDATE users SET telegram_username = $1, telegram_verified = TRUE, updated_at = NOW() WHERE id = $2", + telegram_username, + user_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn verify_signal_channel( + &self, + user_id: Uuid, + signal_number: &str, + ) -> Result<(), DbError> { + sqlx::query!( + "UPDATE users SET signal_number = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2", + signal_number, + user_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn set_email_verified_flag(&self, user_id: Uuid) -> Result<(), DbError> { + sqlx::query!( + "UPDATE users SET email_verified = TRUE WHERE id = $1", + user_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn set_discord_verified_flag(&self, user_id: Uuid) -> Result<(), DbError> { + sqlx::query!( + "UPDATE users SET discord_verified = TRUE WHERE id = $1", + user_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn set_telegram_verified_flag(&self, user_id: Uuid) -> Result<(), DbError> { + sqlx::query!( + "UPDATE users SET telegram_verified = TRUE WHERE id = $1", + user_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn set_signal_verified_flag(&self, user_id: Uuid) -> Result<(), DbError> { + sqlx::query!( + "UPDATE users SET signal_verified = TRUE WHERE id = $1", + user_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn has_totp_enabled(&self, did: &Did) -> Result { + let row = sqlx::query_scalar!( + "SELECT verified FROM user_totp WHERE did = $1", + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(matches!(row, Some(true))) + } + + async fn has_passkeys(&self, did: &Did) -> Result { + let count = sqlx::query_scalar!( + "SELECT COUNT(*) as count FROM passkeys WHERE did = $1", + did.as_str() + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(count.unwrap_or(0) > 0) + } + + async fn get_password_hash_by_did(&self, did: &Did) -> Result, DbError> { + let row = sqlx::query_scalar!( + "SELECT password_hash FROM users WHERE did = $1", + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.flatten()) + } + + async fn get_passkeys_for_user(&self, did: &Did) -> Result, DbError> { + let rows = sqlx::query!( + r#"SELECT id, did, credential_id, public_key, sign_count, created_at, last_used, + friendly_name, aaguid, transports + FROM passkeys WHERE did = $1 ORDER BY created_at DESC"#, + did.as_str() + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows + .into_iter() + .map(|r| StoredPasskey { + id: r.id, + did: Did::from(r.did), + credential_id: r.credential_id, + public_key: r.public_key, + sign_count: r.sign_count, + created_at: r.created_at, + last_used: r.last_used, + friendly_name: r.friendly_name, + aaguid: r.aaguid, + transports: r.transports, + }) + .collect()) + } + + async fn get_passkey_by_credential_id( + &self, + credential_id: &[u8], + ) -> Result, DbError> { + let row = sqlx::query!( + r#"SELECT id, did, credential_id, public_key, sign_count, created_at, last_used, + friendly_name, aaguid, transports + FROM passkeys WHERE credential_id = $1"#, + credential_id + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| StoredPasskey { + id: r.id, + did: Did::from(r.did), + credential_id: r.credential_id, + public_key: r.public_key, + sign_count: r.sign_count, + created_at: r.created_at, + last_used: r.last_used, + friendly_name: r.friendly_name, + aaguid: r.aaguid, + transports: r.transports, + })) + } + + async fn save_passkey( + &self, + did: &Did, + credential_id: &[u8], + public_key: &[u8], + friendly_name: Option<&str>, + ) -> Result { + let id = Uuid::new_v4(); + let aaguid: Option> = None; + sqlx::query!( + r#"INSERT INTO passkeys (id, did, credential_id, public_key, sign_count, friendly_name, aaguid) + VALUES ($1, $2, $3, $4, 0, $5, $6)"#, + id, + did.as_str(), + credential_id, + public_key, + friendly_name, + aaguid, + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(id) + } + + async fn update_passkey_counter( + &self, + credential_id: &[u8], + new_counter: i32, + ) -> Result { + let stored = self.get_passkey_by_credential_id(credential_id).await?; + let Some(stored) = stored else { + return Err(DbError::NotFound); + }; + + if new_counter > 0 && new_counter <= stored.sign_count { + return Ok(false); + } + + sqlx::query!( + "UPDATE passkeys SET sign_count = $1, last_used = NOW() WHERE credential_id = $2", + new_counter, + credential_id, + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(true) + } + + async fn delete_passkey(&self, id: Uuid, did: &Did) -> Result { + let result = sqlx::query!( + "DELETE FROM passkeys WHERE id = $1 AND did = $2", + id, + did.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.rows_affected() > 0) + } + + async fn update_passkey_name(&self, id: Uuid, did: &Did, name: &str) -> Result { + let result = sqlx::query!( + "UPDATE passkeys SET friendly_name = $1 WHERE id = $2 AND did = $3", + name, + id, + did.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.rows_affected() > 0) + } + + async fn save_webauthn_challenge( + &self, + did: &Did, + challenge_type: &str, + state_json: &str, + ) -> Result { + let id = Uuid::new_v4(); + let challenge = id.as_bytes().to_vec(); + let expires_at = chrono::Utc::now() + chrono::Duration::minutes(5); + sqlx::query!( + r#"INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at) + VALUES ($1, $2, $3, $4, $5, $6)"#, + id, + did.as_str(), + challenge, + challenge_type, + state_json, + expires_at, + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(id) + } + + async fn load_webauthn_challenge( + &self, + did: &Did, + challenge_type: &str, + ) -> Result, DbError> { + let row = sqlx::query_scalar!( + r#"SELECT state_json FROM webauthn_challenges + WHERE did = $1 AND challenge_type = $2 AND expires_at > NOW() + ORDER BY created_at DESC LIMIT 1"#, + did.as_str(), + challenge_type + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row) + } + + async fn delete_webauthn_challenge( + &self, + did: &Did, + challenge_type: &str, + ) -> Result<(), DbError> { + sqlx::query!( + "DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = $2", + did.as_str(), + challenge_type + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn get_totp_record(&self, did: &Did) -> Result, DbError> { + let row = sqlx::query!( + "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1", + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| TotpRecord { + secret_encrypted: r.secret_encrypted, + encryption_version: r.encryption_version, + verified: r.verified, + })) + } + + async fn upsert_totp_secret( + &self, + did: &Did, + secret_encrypted: &[u8], + encryption_version: i32, + ) -> Result<(), DbError> { + sqlx::query!( + r#"INSERT INTO user_totp (did, secret_encrypted, encryption_version, verified, created_at) + VALUES ($1, $2, $3, false, NOW()) + ON CONFLICT (did) DO UPDATE SET + secret_encrypted = $2, + encryption_version = $3, + verified = false, + created_at = NOW(), + last_used = NULL"#, + did.as_str(), + secret_encrypted, + encryption_version + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn set_totp_verified(&self, did: &Did) -> Result<(), DbError> { + sqlx::query!( + "UPDATE user_totp SET verified = true, last_used = NOW() WHERE did = $1", + did.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn update_totp_last_used(&self, did: &Did) -> Result<(), DbError> { + sqlx::query!( + "UPDATE user_totp SET last_used = NOW() WHERE did = $1", + did.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn delete_totp(&self, did: &Did) -> Result<(), DbError> { + sqlx::query!("DELETE FROM user_totp WHERE did = $1", did.as_str()) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn get_unused_backup_codes(&self, did: &Did) -> Result, DbError> { + let rows = sqlx::query!( + "SELECT id, code_hash FROM backup_codes WHERE did = $1 AND used_at IS NULL", + did.as_str() + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows + .into_iter() + .map(|r| StoredBackupCode { + id: r.id, + code_hash: r.code_hash, + }) + .collect()) + } + + async fn mark_backup_code_used(&self, code_id: Uuid) -> Result { + let result = sqlx::query!( + "UPDATE backup_codes SET used_at = NOW() WHERE id = $1", + code_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.rows_affected() > 0) + } + + async fn count_unused_backup_codes(&self, did: &Did) -> Result { + let row = sqlx::query!( + "SELECT COUNT(*) as count FROM backup_codes WHERE did = $1 AND used_at IS NULL", + did.as_str() + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.count.unwrap_or(0)) + } + + async fn delete_backup_codes(&self, did: &Did) -> Result { + let result = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", did.as_str()) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.rows_affected()) + } + + async fn insert_backup_codes(&self, did: &Did, code_hashes: &[String]) -> Result<(), DbError> { + sqlx::query!( + r#" + INSERT INTO backup_codes (did, code_hash, created_at) + SELECT $1, hash, NOW() FROM UNNEST($2::text[]) AS t(hash) + "#, + did.as_str(), + code_hashes + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn enable_totp_with_backup_codes( + &self, + did: &Did, + code_hashes: &[String], + ) -> Result<(), DbError> { + let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?; + + sqlx::query!( + "UPDATE user_totp SET verified = true, last_used = NOW() WHERE did = $1", + did.as_str() + ) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + sqlx::query!("DELETE FROM backup_codes WHERE did = $1", did.as_str()) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + sqlx::query!( + r#" + INSERT INTO backup_codes (did, code_hash, created_at) + SELECT $1, hash, NOW() FROM UNNEST($2::text[]) AS t(hash) + "#, + did.as_str(), + code_hashes + ) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + tx.commit().await.map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn delete_totp_and_backup_codes(&self, did: &Did) -> Result<(), DbError> { + let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?; + + sqlx::query!("DELETE FROM user_totp WHERE did = $1", did.as_str()) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + sqlx::query!("DELETE FROM backup_codes WHERE did = $1", did.as_str()) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + tx.commit().await.map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn replace_backup_codes(&self, did: &Did, code_hashes: &[String]) -> Result<(), DbError> { + let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?; + + sqlx::query!("DELETE FROM backup_codes WHERE did = $1", did.as_str()) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + sqlx::query!( + r#" + INSERT INTO backup_codes (did, code_hash, created_at) + SELECT $1, hash, NOW() FROM UNNEST($2::text[]) AS t(hash) + "#, + did.as_str(), + code_hashes + ) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + tx.commit().await.map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn get_login_check_by_handle_or_email( + &self, + identifier: &str, + ) -> Result, DbError> { + sqlx::query!( + "SELECT did, password_hash FROM users WHERE handle = $1 OR email = $1", + identifier + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error) + .map(|opt| { + opt.map(|r| UserLoginCheck { + did: Did::from(r.did), + password_hash: r.password_hash, + }) + }) + } + + async fn get_login_info_by_handle_or_email( + &self, + identifier: &str, + ) -> Result, DbError> { + sqlx::query!( + r#" + SELECT id, did, email, password_hash, password_required, two_factor_enabled, + preferred_comms_channel as "preferred_comms_channel!: CommsChannel", + deactivated_at, takedown_ref, + email_verified, discord_verified, telegram_verified, signal_verified, + account_type::text as "account_type!" + FROM users + WHERE handle = $1 OR email = $1 + "#, + identifier + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error) + .map(|opt| { + opt.map(|row| UserLoginInfo { + id: row.id, + did: Did::from(row.did), + email: row.email, + password_hash: row.password_hash, + password_required: row.password_required, + two_factor_enabled: row.two_factor_enabled, + preferred_comms_channel: row.preferred_comms_channel, + deactivated_at: row.deactivated_at, + takedown_ref: row.takedown_ref, + email_verified: row.email_verified, + discord_verified: row.discord_verified, + telegram_verified: row.telegram_verified, + signal_verified: row.signal_verified, + account_type: row.account_type, + }) + }) + } + + async fn get_2fa_status_by_did(&self, did: &Did) -> Result, DbError> { + sqlx::query!( + r#" + SELECT id, two_factor_enabled, + preferred_comms_channel as "preferred_comms_channel!: CommsChannel", + email_verified, discord_verified, telegram_verified, signal_verified + FROM users + WHERE did = $1 + "#, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error) + .map(|opt| { + opt.map(|row| User2faStatus { + id: row.id, + two_factor_enabled: row.two_factor_enabled, + preferred_comms_channel: row.preferred_comms_channel, + email_verified: row.email_verified, + discord_verified: row.discord_verified, + telegram_verified: row.telegram_verified, + signal_verified: row.signal_verified, + }) + }) + } + + async fn get_session_info_by_did( + &self, + did: &Did, + ) -> Result, DbError> { + sqlx::query!( + r#" + SELECT handle, email, email_verified, is_admin, deactivated_at, takedown_ref, + preferred_locale, + preferred_comms_channel as "preferred_comms_channel!: CommsChannel", + discord_verified, telegram_verified, signal_verified, + migrated_to_pds, migrated_at + FROM users + WHERE did = $1 + "#, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error) + .map(|opt| { + opt.map(|row| UserSessionInfo { + handle: Handle::from(row.handle), + email: row.email, + email_verified: row.email_verified, + is_admin: row.is_admin, + deactivated_at: row.deactivated_at, + takedown_ref: row.takedown_ref, + preferred_locale: row.preferred_locale, + preferred_comms_channel: row.preferred_comms_channel, + discord_verified: row.discord_verified, + telegram_verified: row.telegram_verified, + signal_verified: row.signal_verified, + migrated_to_pds: row.migrated_to_pds, + migrated_at: row.migrated_at, + }) + }) + } + + async fn get_legacy_login_pref( + &self, + did: &Did, + ) -> Result, DbError> { + sqlx::query!( + r#" + SELECT u.allow_legacy_login, + (EXISTS(SELECT 1 FROM user_totp t WHERE t.did = u.did AND t.verified = TRUE) OR + EXISTS(SELECT 1 FROM passkeys p WHERE p.did = u.did)) as "has_mfa!" + FROM users u + WHERE u.did = $1 + "#, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error) + .map(|opt| { + opt.map(|row| UserLegacyLoginPref { + allow_legacy_login: row.allow_legacy_login, + has_mfa: row.has_mfa, + }) + }) + } + + async fn update_legacy_login(&self, did: &Did, allow: bool) -> Result { + let result = sqlx::query!( + "UPDATE users SET allow_legacy_login = $1 WHERE did = $2 RETURNING did", + allow, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(result.is_some()) + } + + async fn update_locale(&self, did: &Did, locale: &str) -> Result { + let result = sqlx::query!( + "UPDATE users SET preferred_locale = $1 WHERE did = $2 RETURNING did", + locale, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(result.is_some()) + } + + async fn get_login_full_by_identifier( + &self, + identifier: &str, + ) -> Result, DbError> { + sqlx::query!( + r#"SELECT + u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref, + u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified, + u.allow_legacy_login, u.migrated_to_pds, + u.preferred_comms_channel as "preferred_comms_channel: CommsChannel", + k.key_bytes, k.encryption_version, + (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled + FROM users u + JOIN user_keys k ON u.id = k.user_id + WHERE u.handle = $1 OR u.email = $1 OR u.did = $1"#, + identifier + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error) + .map(|opt| { + opt.map(|row| UserLoginFull { + id: row.id, + did: Did::from(row.did), + handle: Handle::from(row.handle), + password_hash: row.password_hash, + email: row.email, + deactivated_at: row.deactivated_at, + takedown_ref: row.takedown_ref, + email_verified: row.email_verified, + discord_verified: row.discord_verified, + telegram_verified: row.telegram_verified, + signal_verified: row.signal_verified, + allow_legacy_login: row.allow_legacy_login, + migrated_to_pds: row.migrated_to_pds, + preferred_comms_channel: row.preferred_comms_channel, + key_bytes: row.key_bytes, + encryption_version: row.encryption_version, + totp_enabled: row.totp_enabled.unwrap_or(false), + }) + }) + } + + async fn get_confirm_signup_by_did( + &self, + did: &Did, + ) -> Result, DbError> { + sqlx::query!( + r#"SELECT + u.id, u.did, u.handle, u.email, + u.preferred_comms_channel as "channel: CommsChannel", + u.discord_id, u.telegram_username, u.signal_number, + k.key_bytes, k.encryption_version + FROM users u + JOIN user_keys k ON u.id = k.user_id + WHERE u.did = $1"#, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error) + .map(|opt| { + opt.map(|row| UserConfirmSignup { + id: row.id, + did: Did::from(row.did), + handle: Handle::from(row.handle), + email: row.email, + channel: row.channel, + discord_id: row.discord_id, + telegram_username: row.telegram_username, + signal_number: row.signal_number, + key_bytes: row.key_bytes, + encryption_version: row.encryption_version, + }) + }) + } + + async fn get_resend_verification_by_did( + &self, + did: &Did, + ) -> Result, DbError> { + sqlx::query!( + r#"SELECT + id, handle, email, + preferred_comms_channel as "channel: CommsChannel", + discord_id, telegram_username, signal_number, + email_verified, discord_verified, telegram_verified, signal_verified + FROM users + WHERE did = $1"#, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error) + .map(|opt| { + opt.map(|row| UserResendVerification { + id: row.id, + handle: Handle::from(row.handle), + email: row.email, + channel: row.channel, + discord_id: row.discord_id, + telegram_username: row.telegram_username, + signal_number: row.signal_number, + email_verified: row.email_verified, + discord_verified: row.discord_verified, + telegram_verified: row.telegram_verified, + signal_verified: row.signal_verified, + }) + }) + } + + async fn set_channel_verified(&self, did: &Did, channel: CommsChannel) -> Result<(), DbError> { + let column = match channel { + CommsChannel::Email => "email_verified", + CommsChannel::Discord => "discord_verified", + CommsChannel::Telegram => "telegram_verified", + CommsChannel::Signal => "signal_verified", + }; + let query = format!("UPDATE users SET {} = TRUE WHERE did = $1", column); + sqlx::query(&query) + .bind(did.as_str()) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn get_id_by_email_or_handle( + &self, + email: &str, + handle: &str, + ) -> Result, DbError> { + sqlx::query_scalar!( + "SELECT id FROM users WHERE LOWER(email) = $1 OR handle = $2", + email, + handle + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error) + } + + async fn set_password_reset_code( + &self, + user_id: Uuid, + code: &str, + expires_at: DateTime, + ) -> Result<(), DbError> { + sqlx::query!( + "UPDATE users SET password_reset_code = $1, password_reset_code_expires_at = $2 WHERE id = $3", + code, + expires_at, + user_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn get_user_by_reset_code( + &self, + code: &str, + ) -> Result, DbError> { + sqlx::query!( + "SELECT id, password_reset_code_expires_at FROM users WHERE password_reset_code = $1", + code + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error) + .map(|opt| { + opt.map(|row| UserResetCodeInfo { + id: row.id, + expires_at: row.password_reset_code_expires_at, + }) + }) + } + + async fn clear_password_reset_code(&self, user_id: Uuid) -> Result<(), DbError> { + sqlx::query!( + "UPDATE users SET password_reset_code = NULL, password_reset_code_expires_at = NULL WHERE id = $1", + user_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn get_id_and_password_hash_by_did( + &self, + did: &Did, + ) -> Result, DbError> { + sqlx::query!( + "SELECT id, password_hash FROM users WHERE did = $1", + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error) + .map(|opt| { + opt.and_then(|row| { + row.password_hash.map(|hash| UserIdAndPasswordHash { + id: row.id, + password_hash: hash, + }) + }) + }) + } + + async fn update_password_hash(&self, user_id: Uuid, password_hash: &str) -> Result<(), DbError> { + sqlx::query!( + "UPDATE users SET password_hash = $1 WHERE id = $2", + password_hash, + user_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn reset_password_with_sessions( + &self, + user_id: Uuid, + password_hash: &str, + ) -> Result { + let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?; + + sqlx::query!( + "UPDATE users SET password_hash = $1, password_reset_code = NULL, password_reset_code_expires_at = NULL, password_required = TRUE WHERE id = $2", + password_hash, + user_id + ) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + let user_did = sqlx::query_scalar!("SELECT did FROM users WHERE id = $1", user_id) + .fetch_one(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + let session_jtis: Vec = sqlx::query_scalar!( + "SELECT access_jti FROM session_tokens WHERE did = $1", + user_did + ) + .fetch_all(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + sqlx::query!("DELETE FROM session_tokens WHERE did = $1", user_did) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + tx.commit().await.map_err(map_sqlx_error)?; + + Ok(PasswordResetResult { + did: Did::from(user_did), + session_jtis, + }) + } + + async fn activate_account(&self, did: &Did) -> Result { + let result = sqlx::query!( + "UPDATE users SET deactivated_at = NULL WHERE did = $1", + did.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(result.rows_affected() > 0) + } + + async fn deactivate_account( + &self, + did: &Did, + delete_after: Option>, + ) -> Result { + let result = sqlx::query!( + "UPDATE users SET deactivated_at = NOW(), delete_after = $2 WHERE did = $1", + did.as_str(), + delete_after + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(result.rows_affected() > 0) + } + + async fn has_password_by_did(&self, did: &Did) -> Result, DbError> { + sqlx::query_scalar!( + "SELECT password_hash IS NOT NULL as has_password FROM users WHERE did = $1", + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error) + .map(|opt| opt.flatten()) + } + + async fn get_password_info_by_did( + &self, + did: &Did, + ) -> Result, DbError> { + sqlx::query!( + "SELECT id, password_hash FROM users WHERE did = $1", + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error) + .map(|opt| { + opt.map(|row| UserPasswordInfo { + id: row.id, + password_hash: row.password_hash, + }) + }) + } + + async fn remove_user_password(&self, user_id: Uuid) -> Result<(), DbError> { + sqlx::query!( + "UPDATE users SET password_hash = NULL, password_required = FALSE WHERE id = $1", + user_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn set_new_user_password(&self, user_id: Uuid, password_hash: &str) -> Result<(), DbError> { + sqlx::query!( + "UPDATE users SET password_hash = $1, password_required = TRUE WHERE id = $2", + password_hash, + user_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn is_account_active_by_did(&self, did: &Did) -> Result, DbError> { + sqlx::query_scalar!( + "SELECT deactivated_at IS NULL as is_active FROM users WHERE did = $1", + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error) + .map(|opt| opt.flatten()) + } + + async fn get_user_for_deletion(&self, did: &Did) -> Result, DbError> { + sqlx::query!( + "SELECT id, password_hash, handle FROM users WHERE did = $1", + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error) + .map(|opt| { + opt.map(|row| UserForDeletion { + id: row.id, + password_hash: row.password_hash, + handle: Handle::from(row.handle), + }) + }) + } + + async fn get_user_key_by_did(&self, did: &Did) -> Result, DbError> { + sqlx::query!( + r#"SELECT uk.key_bytes, uk.encryption_version + FROM user_keys uk + JOIN users u ON uk.user_id = u.id + WHERE u.did = $1"#, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error) + .map(|opt| { + opt.map(|row| UserKeyInfo { + key_bytes: row.key_bytes, + encryption_version: row.encryption_version, + }) + }) + } + + async fn delete_account_complete(&self, user_id: Uuid, did: &Did) -> Result<(), DbError> { + let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?; + sqlx::query!("DELETE FROM session_tokens WHERE did = $1", did.as_str()) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + sqlx::query!("DELETE FROM records WHERE repo_id = $1", user_id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + sqlx::query!("DELETE FROM repos WHERE user_id = $1", user_id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + sqlx::query!("DELETE FROM blobs WHERE created_by_user = $1", user_id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + sqlx::query!("DELETE FROM user_keys WHERE user_id = $1", user_id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + sqlx::query!("DELETE FROM app_passwords WHERE user_id = $1", user_id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + sqlx::query!("DELETE FROM account_deletion_requests WHERE did = $1", did.as_str()) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + sqlx::query!("DELETE FROM users WHERE id = $1", user_id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + tx.commit().await.map_err(map_sqlx_error)?; + Ok(()) + } + + async fn set_user_takedown(&self, did: &Did, takedown_ref: Option<&str>) -> Result { + let result = sqlx::query!( + "UPDATE users SET takedown_ref = $1 WHERE did = $2", + takedown_ref, + did.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(result.rows_affected() > 0) + } + + async fn admin_delete_account_complete(&self, user_id: Uuid, did: &Did) -> Result<(), DbError> { + let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?; + sqlx::query!("DELETE FROM session_tokens WHERE did = $1", did.as_str()) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + sqlx::query!( + "DELETE FROM used_refresh_tokens WHERE session_id IN (SELECT id FROM session_tokens WHERE did = $1)", + did.as_str() + ) + .execute(&mut *tx) + .await + .ok(); + sqlx::query!("DELETE FROM records WHERE repo_id = $1", user_id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + sqlx::query!("DELETE FROM repos WHERE user_id = $1", user_id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + sqlx::query!("DELETE FROM blobs WHERE created_by_user = $1", user_id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + sqlx::query!("DELETE FROM app_passwords WHERE user_id = $1", user_id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + sqlx::query!( + "DELETE FROM invite_code_uses WHERE used_by_user = $1", + user_id + ) + .execute(&mut *tx) + .await + .ok(); + sqlx::query!( + "DELETE FROM invite_codes WHERE created_by_user = $1", + user_id + ) + .execute(&mut *tx) + .await + .ok(); + sqlx::query!("DELETE FROM user_keys WHERE user_id = $1", user_id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + sqlx::query!("DELETE FROM users WHERE id = $1", user_id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + tx.commit().await.map_err(map_sqlx_error)?; + Ok(()) + } + + async fn get_user_for_did_doc(&self, did: &Did) -> Result, DbError> { + let row = sqlx::query!( + "SELECT id, handle, deactivated_at FROM users WHERE did = $1", + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| UserForDidDoc { + id: r.id, + handle: Handle::from(r.handle), + deactivated_at: r.deactivated_at, + })) + } + + async fn get_user_for_did_doc_build(&self, did: &Did) -> Result, DbError> { + let row = sqlx::query!( + "SELECT id, handle, migrated_to_pds FROM users WHERE did = $1", + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| UserForDidDocBuild { + id: r.id, + handle: Handle::from(r.handle), + migrated_to_pds: r.migrated_to_pds, + })) + } + + async fn upsert_did_web_overrides( + &self, + user_id: Uuid, + verification_methods: Option, + also_known_as: Option>, + ) -> Result<(), DbError> { + let now = chrono::Utc::now(); + sqlx::query!( + r#" + INSERT INTO did_web_overrides (user_id, verification_methods, also_known_as, updated_at) + VALUES ($1, COALESCE($2, '[]'::jsonb), COALESCE($3, '{}'::text[]), $4) + ON CONFLICT (user_id) DO UPDATE SET + verification_methods = CASE WHEN $2 IS NOT NULL THEN $2 ELSE did_web_overrides.verification_methods END, + also_known_as = CASE WHEN $3 IS NOT NULL THEN $3 ELSE did_web_overrides.also_known_as END, + updated_at = $4 + "#, + user_id, + verification_methods, + also_known_as.as_deref(), + now + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn update_migrated_to_pds(&self, did: &Did, endpoint: &str) -> Result<(), DbError> { + let now = chrono::Utc::now(); + sqlx::query!( + "UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3", + endpoint, + now, + did.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn get_user_for_passkey_setup(&self, did: &Did) -> Result, DbError> { + let row = sqlx::query!( + r#"SELECT id, handle, recovery_token, recovery_token_expires_at, password_required + FROM users WHERE did = $1"#, + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| UserForPasskeySetup { + id: r.id, + handle: Handle::from(r.handle), + recovery_token: r.recovery_token, + recovery_token_expires_at: r.recovery_token_expires_at, + password_required: r.password_required, + })) + } + + async fn get_user_for_passkey_recovery( + &self, + identifier: &str, + normalized_handle: &str, + ) -> Result, DbError> { + let row = sqlx::query!( + "SELECT id, did, handle, password_required FROM users WHERE LOWER(email) = $1 OR handle = $2", + identifier, + normalized_handle + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| UserForPasskeyRecovery { + id: r.id, + did: Did::from(r.did), + handle: Handle::from(r.handle), + password_required: r.password_required, + })) + } + + async fn set_recovery_token( + &self, + did: &Did, + token_hash: &str, + expires_at: DateTime, + ) -> Result<(), DbError> { + sqlx::query!( + "UPDATE users SET recovery_token = $1, recovery_token_expires_at = $2 WHERE did = $3", + token_hash, + expires_at, + did.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn get_user_for_recovery(&self, did: &Did) -> Result, DbError> { + let row = sqlx::query!( + "SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1", + did.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| UserForRecovery { + id: r.id, + did: Did::from(r.did), + recovery_token: r.recovery_token, + recovery_token_expires_at: r.recovery_token_expires_at, + })) + } + + async fn get_accounts_scheduled_for_deletion( + &self, + limit: i64, + ) -> Result, DbError> { + let rows = sqlx::query!( + r#" + SELECT id, did, handle + FROM users + WHERE delete_after IS NOT NULL + AND delete_after < NOW() + AND deactivated_at IS NOT NULL + LIMIT $1 + "#, + limit + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows + .into_iter() + .map(|r| tranquil_db_traits::ScheduledDeletionAccount { + id: r.id, + did: Did::from(r.did), + handle: Handle::from(r.handle), + }) + .collect()) + } + + async fn delete_account_with_firehose( + &self, + user_id: Uuid, + did: &Did, + ) -> Result { + let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?; + + sqlx::query!("DELETE FROM blobs WHERE created_by_user = $1", user_id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + sqlx::query!("DELETE FROM record_blobs WHERE repo_id = $1", user_id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + sqlx::query!("DELETE FROM records WHERE repo_id = $1", user_id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + sqlx::query!("DELETE FROM repos WHERE user_id = $1", user_id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + sqlx::query!("DELETE FROM user_blocks WHERE user_id = $1", user_id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + sqlx::query!("DELETE FROM user_keys WHERE user_id = $1", user_id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + sqlx::query!("DELETE FROM session_tokens WHERE did = $1", did.as_str()) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + sqlx::query!("DELETE FROM app_passwords WHERE user_id = $1", user_id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + sqlx::query!("DELETE FROM passkeys WHERE did = $1", did.as_str()) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + sqlx::query!("DELETE FROM user_totp WHERE did = $1", did.as_str()) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + sqlx::query!("DELETE FROM backup_codes WHERE did = $1", did.as_str()) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + sqlx::query!("DELETE FROM webauthn_challenges WHERE did = $1", did.as_str()) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + sqlx::query!("DELETE FROM account_backups WHERE user_id = $1", user_id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + sqlx::query!("DELETE FROM account_deletion_requests WHERE did = $1", did.as_str()) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + sqlx::query!("DELETE FROM users WHERE id = $1", user_id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + let account_seq: i64 = sqlx::query_scalar!( + r#" + INSERT INTO repo_seq (did, event_type, active, status) + VALUES ($1, 'account', false, 'deleted') + RETURNING seq + "#, + did.as_str() + ) + .fetch_one(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + sqlx::query!( + "DELETE FROM repo_seq WHERE did = $1 AND seq != $2", + did.as_str(), + account_seq + ) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + tx.commit().await.map_err(map_sqlx_error)?; + + sqlx::query(&format!("NOTIFY repo_updates, '{}'", account_seq)) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(account_seq) + } + + async fn create_password_account( + &self, + input: &tranquil_db_traits::CreatePasswordAccountInput, + ) -> Result< + tranquil_db_traits::CreatePasswordAccountResult, + tranquil_db_traits::CreateAccountError, + > { + let mut tx = self + .pool + .begin() + .await + .map_err(|e: sqlx::Error| tranquil_db_traits::CreateAccountError::Database(e.to_string()))?; + + let is_first_user: bool = sqlx::query_scalar!("SELECT COUNT(*) as count FROM users") + .fetch_one(&mut *tx) + .await + .map(|c| c.unwrap_or(0) == 0) + .unwrap_or(false); + + let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as( + r#"INSERT INTO users ( + handle, email, did, password_hash, + preferred_comms_channel, + discord_id, telegram_username, signal_number, + is_admin, deactivated_at, email_verified + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, FALSE) RETURNING id"#, + ) + .bind(input.handle.as_str()) + .bind(&input.email) + .bind(input.did.as_str()) + .bind(&input.password_hash) + .bind(input.preferred_comms_channel) + .bind(&input.discord_id) + .bind(&input.telegram_username) + .bind(&input.signal_number) + .bind(is_first_user) + .bind(input.deactivated_at) + .fetch_one(&mut *tx) + .await; + + let user_id = match user_insert { + Ok((id,)) => id, + Err(e) => { + if let Some(db_err) = e.as_database_error() + && db_err.code().as_deref() == Some("23505") + { + let constraint = db_err.constraint().unwrap_or(""); + if constraint.contains("handle") { + return Err(tranquil_db_traits::CreateAccountError::HandleTaken); + } else if constraint.contains("email") { + return Err(tranquil_db_traits::CreateAccountError::EmailTaken); + } else if constraint.contains("did") { + return Err(tranquil_db_traits::CreateAccountError::DidExists); + } + } + return Err(tranquil_db_traits::CreateAccountError::Database(e.to_string())); + } + }; + + sqlx::query!( + "INSERT INTO user_keys (user_id, key_bytes, encryption_version, encrypted_at) VALUES ($1, $2, $3, NOW())", + user_id, + &input.encrypted_key_bytes[..], + input.encryption_version + ) + .execute(&mut *tx) + .await + .map_err(|e: sqlx::Error| tranquil_db_traits::CreateAccountError::Database(e.to_string()))?; + + if let Some(key_id) = input.reserved_key_id { + sqlx::query!( + "UPDATE reserved_signing_keys SET used_at = NOW() WHERE id = $1", + key_id + ) + .execute(&mut *tx) + .await + .map_err(|e: sqlx::Error| tranquil_db_traits::CreateAccountError::Database(e.to_string()))?; + } + + sqlx::query!( + "INSERT INTO repos (user_id, repo_root_cid, repo_rev) VALUES ($1, $2, $3)", + user_id, + input.commit_cid, + input.repo_rev + ) + .execute(&mut *tx) + .await + .map_err(|e: sqlx::Error| tranquil_db_traits::CreateAccountError::Database(e.to_string()))?; + + sqlx::query( + r#" + INSERT INTO user_blocks (user_id, block_cid, repo_rev) + SELECT $1, block_cid, $3 FROM UNNEST($2::bytea[]) AS t(block_cid) + ON CONFLICT (user_id, block_cid) DO NOTHING + "#, + ) + .bind(user_id) + .bind(&input.genesis_block_cids) + .bind(&input.repo_rev) + .execute(&mut *tx) + .await + .map_err(|e: sqlx::Error| tranquil_db_traits::CreateAccountError::Database(e.to_string()))?; + + if let Some(code) = &input.invite_code { + let _ = sqlx::query!( + "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1", + code + ) + .execute(&mut *tx) + .await; + + let _ = sqlx::query!( + "INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", + code, + user_id + ) + .execute(&mut *tx) + .await; + } + + if let Some(birthdate_pref) = &input.birthdate_pref { + let _ = sqlx::query!( + "INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3) + ON CONFLICT (user_id, name) DO NOTHING", + user_id, + "app.bsky.actor.defs#personalDetailsPref", + birthdate_pref + ) + .execute(&mut *tx) + .await; + } + + tx.commit() + .await + .map_err(|e: sqlx::Error| tranquil_db_traits::CreateAccountError::Database(e.to_string()))?; + + Ok(tranquil_db_traits::CreatePasswordAccountResult { + user_id, + is_admin: is_first_user, + }) + } + + async fn create_delegated_account( + &self, + input: &tranquil_db_traits::CreateDelegatedAccountInput, + ) -> Result { + let mut tx = self + .pool + .begin() + .await + .map_err(|e: sqlx::Error| tranquil_db_traits::CreateAccountError::Database(e.to_string()))?; + + let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as( + r#"INSERT INTO users ( + handle, email, did, password_hash, password_required, + account_type, preferred_comms_channel + ) VALUES ($1, $2, $3, NULL, FALSE, 'delegated'::account_type, 'email'::comms_channel) RETURNING id"#, + ) + .bind(input.handle.as_str()) + .bind(&input.email) + .bind(input.did.as_str()) + .fetch_one(&mut *tx) + .await; + + let user_id = match user_insert { + Ok((id,)) => id, + Err(e) => { + if let Some(db_err) = e.as_database_error() + && db_err.code().as_deref() == Some("23505") + { + let constraint = db_err.constraint().unwrap_or(""); + if constraint.contains("handle") { + return Err(tranquil_db_traits::CreateAccountError::HandleTaken); + } else if constraint.contains("email") { + return Err(tranquil_db_traits::CreateAccountError::EmailTaken); + } + } + return Err(tranquil_db_traits::CreateAccountError::Database(e.to_string())); + } + }; + + sqlx::query!( + "INSERT INTO user_keys (user_id, key_bytes, encryption_version, encrypted_at) VALUES ($1, $2, $3, NOW())", + user_id, + &input.encrypted_key_bytes[..], + input.encryption_version + ) + .execute(&mut *tx) + .await + .map_err(|e: sqlx::Error| tranquil_db_traits::CreateAccountError::Database(e.to_string()))?; + + sqlx::query!( + r#"INSERT INTO account_delegations (delegated_did, controller_did, granted_scopes, granted_by) + VALUES ($1, $2, $3, $4)"#, + input.did.as_str(), + input.controller_did.as_str(), + &input.controller_scopes, + input.controller_did.as_str() + ) + .execute(&mut *tx) + .await + .map_err(|e: sqlx::Error| tranquil_db_traits::CreateAccountError::Database(e.to_string()))?; + + sqlx::query!( + "INSERT INTO repos (user_id, repo_root_cid, repo_rev) VALUES ($1, $2, $3)", + user_id, + input.commit_cid, + input.repo_rev + ) + .execute(&mut *tx) + .await + .map_err(|e: sqlx::Error| tranquil_db_traits::CreateAccountError::Database(e.to_string()))?; + + sqlx::query( + r#" + INSERT INTO user_blocks (user_id, block_cid, repo_rev) + SELECT $1, block_cid, $3 FROM UNNEST($2::bytea[]) AS t(block_cid) + ON CONFLICT (user_id, block_cid) DO NOTHING + "#, + ) + .bind(user_id) + .bind(&input.genesis_block_cids) + .bind(&input.repo_rev) + .execute(&mut *tx) + .await + .map_err(|e: sqlx::Error| tranquil_db_traits::CreateAccountError::Database(e.to_string()))?; + + if let Some(code) = &input.invite_code { + let _ = sqlx::query!( + "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1", + code + ) + .execute(&mut *tx) + .await; + + let _ = sqlx::query!( + "INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", + code, + user_id + ) + .execute(&mut *tx) + .await; + } + + tx.commit() + .await + .map_err(|e: sqlx::Error| tranquil_db_traits::CreateAccountError::Database(e.to_string()))?; + + Ok(user_id) + } + + async fn create_passkey_account( + &self, + input: &tranquil_db_traits::CreatePasskeyAccountInput, + ) -> Result< + tranquil_db_traits::CreatePasswordAccountResult, + tranquil_db_traits::CreateAccountError, + > { + let mut tx = self + .pool + .begin() + .await + .map_err(|e: sqlx::Error| tranquil_db_traits::CreateAccountError::Database(e.to_string()))?; + + let is_first_user: bool = sqlx::query_scalar!("SELECT COUNT(*) as count FROM users") + .fetch_one(&mut *tx) + .await + .map(|c| c.unwrap_or(0) == 0) + .unwrap_or(false); + + let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as( + r#"INSERT INTO users ( + handle, email, did, password_hash, password_required, + preferred_comms_channel, + discord_id, telegram_username, signal_number, + recovery_token, recovery_token_expires_at, + is_admin, deactivated_at + ) VALUES ($1, $2, $3, NULL, FALSE, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id"#, + ) + .bind(input.handle.as_str()) + .bind(&input.email) + .bind(input.did.as_str()) + .bind(input.preferred_comms_channel) + .bind(&input.discord_id) + .bind(&input.telegram_username) + .bind(&input.signal_number) + .bind(&input.setup_token_hash) + .bind(input.setup_expires_at) + .bind(is_first_user) + .bind(input.deactivated_at) + .fetch_one(&mut *tx) + .await; + + let user_id = match user_insert { + Ok((id,)) => id, + Err(e) => { + if let Some(db_err) = e.as_database_error() + && db_err.code().as_deref() == Some("23505") + { + let constraint = db_err.constraint().unwrap_or(""); + if constraint.contains("handle") { + return Err(tranquil_db_traits::CreateAccountError::HandleTaken); + } else if constraint.contains("email") { + return Err(tranquil_db_traits::CreateAccountError::EmailTaken); + } + } + return Err(tranquil_db_traits::CreateAccountError::Database(e.to_string())); + } + }; + + sqlx::query!( + "INSERT INTO user_keys (user_id, key_bytes, encryption_version, encrypted_at) VALUES ($1, $2, $3, NOW())", + user_id, + &input.encrypted_key_bytes[..], + input.encryption_version + ) + .execute(&mut *tx) + .await + .map_err(|e: sqlx::Error| tranquil_db_traits::CreateAccountError::Database(e.to_string()))?; + + if let Some(key_id) = input.reserved_key_id { + sqlx::query!( + "UPDATE reserved_signing_keys SET used_at = NOW() WHERE id = $1", + key_id + ) + .execute(&mut *tx) + .await + .map_err(|e: sqlx::Error| tranquil_db_traits::CreateAccountError::Database(e.to_string()))?; + } + + sqlx::query!( + "INSERT INTO repos (user_id, repo_root_cid, repo_rev) VALUES ($1, $2, $3)", + user_id, + input.commit_cid, + input.repo_rev + ) + .execute(&mut *tx) + .await + .map_err(|e: sqlx::Error| tranquil_db_traits::CreateAccountError::Database(e.to_string()))?; + + sqlx::query( + r#" + INSERT INTO user_blocks (user_id, block_cid, repo_rev) + SELECT $1, block_cid, $3 FROM UNNEST($2::bytea[]) AS t(block_cid) + ON CONFLICT (user_id, block_cid) DO NOTHING + "#, + ) + .bind(user_id) + .bind(&input.genesis_block_cids) + .bind(&input.repo_rev) + .execute(&mut *tx) + .await + .map_err(|e: sqlx::Error| tranquil_db_traits::CreateAccountError::Database(e.to_string()))?; + + if let Some(code) = &input.invite_code { + let _ = sqlx::query!( + "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1", + code + ) + .execute(&mut *tx) + .await; + + let _ = sqlx::query!( + "INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", + code, + user_id + ) + .execute(&mut *tx) + .await; + } + + if let Some(birthdate_pref) = &input.birthdate_pref { + let _ = sqlx::query!( + "INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3) + ON CONFLICT (user_id, name) DO NOTHING", + user_id, + "app.bsky.actor.defs#personalDetailsPref", + birthdate_pref + ) + .execute(&mut *tx) + .await; + } + + tx.commit() + .await + .map_err(|e: sqlx::Error| tranquil_db_traits::CreateAccountError::Database(e.to_string()))?; + + Ok(tranquil_db_traits::CreatePasswordAccountResult { + user_id, + is_admin: is_first_user, + }) + } + + async fn reactivate_migration_account( + &self, + input: &tranquil_db_traits::MigrationReactivationInput, + ) -> Result< + tranquil_db_traits::ReactivatedAccountInfo, + tranquil_db_traits::MigrationReactivationError, + > { + let mut tx = self + .pool + .begin() + .await + .map_err(|e| tranquil_db_traits::MigrationReactivationError::Database(e.to_string()))?; + + let existing: Option<(uuid::Uuid, String, Option>)> = + sqlx::query_as("SELECT id, handle, deactivated_at FROM users WHERE did = $1 FOR UPDATE") + .bind(input.did.as_str()) + .fetch_optional(&mut *tx) + .await + .map_err(|e| { + tranquil_db_traits::MigrationReactivationError::Database(e.to_string()) + })?; + + let (account_id, old_handle, deactivated_at) = existing + .ok_or(tranquil_db_traits::MigrationReactivationError::NotFound)?; + + if deactivated_at.is_none() { + return Err(tranquil_db_traits::MigrationReactivationError::NotDeactivated); + } + + let update_result: Result<_, sqlx::Error> = + sqlx::query("UPDATE users SET handle = $1 WHERE id = $2") + .bind(input.new_handle.as_str()) + .bind(account_id) + .execute(&mut *tx) + .await; + + if let Err(e) = update_result { + if let Some(db_err) = e.as_database_error() + && db_err + .constraint() + .map(|c| c.contains("handle")) + .unwrap_or(false) + { + return Err(tranquil_db_traits::MigrationReactivationError::HandleTaken); + } + return Err(tranquil_db_traits::MigrationReactivationError::Database( + e.to_string(), + )); + } + + tx.commit() + .await + .map_err(|e| tranquil_db_traits::MigrationReactivationError::Database(e.to_string()))?; + + Ok(tranquil_db_traits::ReactivatedAccountInfo { + user_id: account_id, + old_handle: Handle::from(old_handle), + }) + } + + async fn check_handle_available_for_new_account(&self, handle: &Handle) -> Result { + let exists: Option<(i32,)> = + sqlx::query_as("SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL") + .bind(handle.as_str()) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(exists.is_none()) + } + + async fn check_and_consume_invite_code(&self, code: &str) -> Result { + let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?; + + let invite = sqlx::query!( + "SELECT available_uses FROM invite_codes WHERE code = $1 FOR UPDATE", + code + ) + .fetch_optional(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + let Some(row) = invite else { + return Ok(false); + }; + + if row.available_uses <= 0 { + return Ok(false); + } + + sqlx::query!( + "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1", + code + ) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + tx.commit().await.map_err(map_sqlx_error)?; + + Ok(true) + } + + async fn complete_passkey_setup( + &self, + input: &tranquil_db_traits::CompletePasskeySetupInput, + ) -> Result<(), DbError> { + let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?; + + sqlx::query!( + "INSERT INTO app_passwords (user_id, name, password_hash, privileged) VALUES ($1, $2, $3, FALSE)", + input.user_id, + input.app_password_name, + input.app_password_hash + ) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + sqlx::query!( + "UPDATE users SET recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $1", + input.did.as_str() + ) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + tx.commit().await.map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn recover_passkey_account( + &self, + input: &tranquil_db_traits::RecoverPasskeyAccountInput, + ) -> Result { + let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?; + + sqlx::query!( + "UPDATE users SET password_hash = $1, password_required = TRUE, recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $2", + input.password_hash, + input.did.as_str() + ) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + let deleted = sqlx::query!("DELETE FROM passkeys WHERE did = $1", input.did.as_str()) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + + tx.commit().await.map_err(map_sqlx_error)?; + + Ok(tranquil_db_traits::RecoverPasskeyAccountResult { + passkeys_deleted: deleted.rows_affected(), + }) + } +}