diff --git a/Cargo.lock b/Cargo.lock index 9e0911944..d0ca9b81f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -720,6 +720,7 @@ dependencies = [ "mockall", "r2d2", "r2d2_mysql", + "r2d2_postgres", "r2d2_sqlite", "rand 0.10.0", "serde", @@ -803,6 +804,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "blocking" version = "1.6.2" @@ -1134,7 +1144,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -1198,6 +1208,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + [[package]] name = "colorchoice" version = "1.0.5" @@ -1256,6 +1272,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "convert_case" version = "0.10.0" @@ -1474,6 +1496,24 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.20.11" @@ -1644,8 +1684,20 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.1", + "ctutils", ] [[package]] @@ -1776,6 +1828,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -2124,7 +2182,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "wasm-bindgen", ] @@ -2290,6 +2348,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.2", +] + [[package]] name = "home" version = "0.5.12" @@ -2344,6 +2411,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -2913,6 +2989,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.2", +] + [[package]] name = "memchr" version = "2.8.0" @@ -2978,7 +3064,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -3090,7 +3176,7 @@ dependencies = [ "serde", "serde_json", "sha1", - "sha2", + "sha2 0.10.9", "smallvec", "subprocess", "thiserror 1.0.69", @@ -3258,6 +3344,24 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.37.3" @@ -3444,7 +3548,17 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", + "serde", ] [[package]] @@ -3454,7 +3568,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.11.3", ] [[package]] @@ -3463,7 +3577,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", "rand 0.8.5", ] @@ -3476,6 +3590,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.11" @@ -3588,6 +3711,49 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postgres" +version = "0.19.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacf632d0554ff75f58183694f41dc8999c8a3a43a386994d0ec2d034f1dfbe1" +dependencies = [ + "bytes", + "fallible-iterator 0.2.0", + "futures-util", + "log", + "tokio", + "tokio-postgres", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56201207dac53e2f38e848e31b4b91616a6bb6e0c7205b77718994a7f49e70fc" +dependencies = [ + "base64 0.22.1", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "hmac", + "md-5", + "memchr", + "rand 0.10.0", + "sha2 0.11.0", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc729a129e682e8d24170cd30ae1aa01b336b096cbb56df6d534ffec133d186" +dependencies = [ + "bytes", + "fallible-iterator 0.2.0", + "postgres-protocol", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -3872,6 +4038,16 @@ dependencies = [ "r2d2", ] +[[package]] +name = "r2d2_postgres" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efd4b47636dbca581cd057e2f27a5d39be741ea4f85fd3c29e415c55f71c7595" +dependencies = [ + "postgres", + "r2d2", +] + [[package]] name = "r2d2_sqlite" version = "0.33.0" @@ -4238,7 +4414,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" dependencies = [ "bitflags", - "fallible-iterator", + "fallible-iterator 0.3.0", "fallible-streaming-iterator", "hashlink", "libsqlite3-sys", @@ -4652,7 +4828,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] @@ -4663,7 +4839,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", ] [[package]] @@ -4765,6 +4952,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -4912,7 +5110,7 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1923b2d356e080e8bee847c39b58de293309df2fe0bc9ecd859ae3210e868c25" dependencies = [ - "phf", + "phf 0.11.3", "phf_codegen", "tdyne-peer-id", ] @@ -5138,6 +5336,32 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-postgres" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dd8df5ef180f6364759a6f00f7aadda4fbbac86cdee37480826a6ff9f3574ce" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf 0.13.1", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.10.0", + "socket2 0.6.3", + "tokio", + "tokio-util", + "whoami", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -5886,6 +6110,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -5898,6 +6128,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.2" @@ -6047,6 +6292,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -6065,6 +6319,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + [[package]] name = "wasm-bindgen" version = "0.2.117" @@ -6184,6 +6447,19 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "whoami" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6a5b12f9df4f978d2cfdb1bd3bac52433f44393342d7ee9c25f5a1c14c0f45d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/packages/configuration/src/v2_0_0/database.rs b/packages/configuration/src/v2_0_0/database.rs index c2b24d809..b21043aab 100644 --- a/packages/configuration/src/v2_0_0/database.rs +++ b/packages/configuration/src/v2_0_0/database.rs @@ -5,15 +5,17 @@ use url::Url; #[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] pub struct Database { // Database configuration - /// Database driver. Possible values are: `sqlite3`, and `mysql`. + /// Database driver. Possible values are: `sqlite3`, `mysql`, and `postgresql`. #[serde(default = "Database::default_driver")] pub driver: Driver, /// Database connection string. The format depends on the database driver. /// For `sqlite3`, the format is `path/to/database.db`, for example: /// `./storage/tracker/lib/database/sqlite3.db`. - /// For `Mysql`, the format is `mysql://db_user:db_user_password:port/db_name`, for + /// For `MySQL`, the format is `mysql://db_user:db_user_password@host:port/db_name`, for /// example: `mysql://root:password@localhost:3306/torrust`. + /// For `PostgreSQL`, the format is `postgresql://db_user:db_user_password@host:port/db_name`, for + /// example: `postgresql://root:password@localhost:5432/torrust`. #[serde(default = "Database::default_path")] pub path: String, } @@ -51,6 +53,11 @@ impl Database { url.set_password(Some("***")).expect("url password should be changed"); self.path = url.to_string(); } + Driver::PostgreSQL => { + let mut url = Url::parse(&self.path).expect("path for PostgreSQL driver should be a valid URL"); + url.set_password(Some("***")).expect("url password should be changed"); + self.path = url.to_string(); + } } } } @@ -63,6 +70,8 @@ pub enum Driver { Sqlite3, /// The `MySQL` database driver. MySQL, + /// The `PostgreSQL` database driver. + PostgreSQL, } #[cfg(test)] @@ -81,4 +90,16 @@ mod tests { assert_eq!(database.path, "mysql://root:***@localhost:3306/torrust".to_string()); } + + #[test] + fn it_should_allow_masking_the_postgresql_user_password() { + let mut database = Database { + driver: Driver::PostgreSQL, + path: "postgresql://root:password@localhost:5432/torrust".to_string(), + }; + + database.mask_secrets(); + + assert_eq!(database.path, "postgresql://root:***@localhost:5432/torrust".to_string()); + } } diff --git a/packages/tracker-core/Cargo.toml b/packages/tracker-core/Cargo.toml index fb864cde7..5c57917ae 100644 --- a/packages/tracker-core/Cargo.toml +++ b/packages/tracker-core/Cargo.toml @@ -21,6 +21,7 @@ derive_more = { version = "2", features = [ "as_ref", "constructor", "from" ] } mockall = "0" r2d2 = "0" r2d2_mysql = "25" +r2d2_postgres = "0.18" r2d2_sqlite = { version = "0", features = [ "bundled" ] } rand = "0" serde = { version = "1", features = [ "derive" ] } diff --git a/packages/tracker-core/src/databases/driver/mod.rs b/packages/tracker-core/src/databases/driver/mod.rs index 6c849bb70..19c2d745e 100644 --- a/packages/tracker-core/src/databases/driver/mod.rs +++ b/packages/tracker-core/src/databases/driver/mod.rs @@ -1,5 +1,6 @@ //! Database driver factory. use mysql::Mysql; +use postgres::Postgres; use serde::{Deserialize, Serialize}; use sqlite::Sqlite; @@ -23,6 +24,8 @@ pub enum Driver { Sqlite3, /// The `MySQL` database driver. MySQL, + /// The `PostgreSQL` database driver. + PostgreSQL, } /// It builds a new database driver. @@ -62,6 +65,7 @@ pub enum Driver { /// /// This function will panic if unable to create database tables. pub mod mysql; +pub mod postgres; pub mod sqlite; /// It builds a new database driver. @@ -77,6 +81,7 @@ pub(crate) fn build(driver: &Driver, db_path: &str) -> Result, let database: Box = match driver { Driver::Sqlite3 => Box::new(Sqlite::new(db_path)?), Driver::MySQL => Box::new(Mysql::new(db_path)?), + Driver::PostgreSQL => Box::new(Postgres::new(db_path)?), }; database.create_database_tables().expect("Could not create database tables."); diff --git a/packages/tracker-core/src/databases/driver/postgres.rs b/packages/tracker-core/src/databases/driver/postgres.rs new file mode 100644 index 000000000..81f3bb5f2 --- /dev/null +++ b/packages/tracker-core/src/databases/driver/postgres.rs @@ -0,0 +1,576 @@ +//! The `PostgreSQL` database driver. +//! +//! This module provides an implementation of the [`Database`] trait for +//! `PostgreSQL` using the `r2d2_postgres` connection pool. It configures the +//! `PostgreSQL` connection based on a URL, creates the necessary tables (for +//! torrent metrics, torrent whitelist, and authentication keys), and implements +//! all CRUD operations required by the persistence layer. +//! +//! **Note on runtime compatibility:** The synchronous `postgres` crate +//! internally uses its own `tokio::runtime::Runtime`. To avoid panics when +//! called from within an existing tokio runtime (e.g., from async request +//! handlers or `#[tokio::test]`), all pool operations — including connection +//! checkout, query execution, and pool destruction — are executed inside +//! `std::thread::scope` so that connection creation and destruction happen +//! outside the caller's tokio context. +use std::str::FromStr; +use std::time::Duration; + +use bittorrent_primitives::info_hash::InfoHash; +use r2d2::Pool; +use r2d2_postgres::postgres::NoTls; +use r2d2_postgres::PostgresConnectionManager; +use torrust_tracker_primitives::{NumberOfDownloads, NumberOfDownloadsBTreeMap}; + +use super::{Database, Driver, Error, TORRENTS_DOWNLOADS_TOTAL}; +use crate::authentication::key::AUTH_KEY_LENGTH; +use crate::authentication::{self, Key}; + +const DRIVER: Driver = Driver::PostgreSQL; + +/// `PostgreSQL` driver implementation. +/// +/// This struct encapsulates a connection pool for `PostgreSQL`, built using the +/// `r2d2_postgres` connection manager. It implements the [`Database`] trait to +/// provide persistence operations. +/// +/// All database operations (and pool destruction) are executed in a scoped +/// thread to avoid conflicts with the tokio runtime. The sync `postgres` crate +/// creates its own internal tokio runtime, and `Runtime::block_on` panics if +/// called from a thread that already has a tokio context. This includes the +/// `Drop` implementation of `postgres::Client`, which is why the pool is also +/// dropped in a separate thread. +pub(crate) struct Postgres { + /// Wrapped in `Option` so we can take ownership in `Drop` and move + /// the pool to a dedicated thread for cleanup. + pool: Option>>, +} + +impl Drop for Postgres { + fn drop(&mut self) { + // The postgres client's Drop calls block_on, which panics inside + // a tokio runtime. Move the pool to a separate thread for cleanup. + if let Some(pool) = self.pool.take() { + std::thread::spawn(move || drop(pool)).join().ok(); + } + } +} + +impl Postgres { + /// It instantiates a new `PostgreSQL` database driver. + /// + /// # Errors + /// + /// Will return `r2d2::Error` if `db_path` is not able to create `PostgreSQL` database. + pub fn new(db_path: &str) -> Result { + let db_path = db_path.to_string(); + // Build the connection pool in a separate thread to avoid tokio + // runtime conflicts (r2d2 eagerly creates connections during build). + std::thread::scope(|s| { + s.spawn(|| { + let manager = PostgresConnectionManager::new( + db_path.parse().map_err(|e: r2d2_postgres::postgres::Error| { + let source: std::sync::Arc = std::sync::Arc::new(e); + Error::GenericConnectionError { + source: source.into(), + driver: DRIVER, + } + })?, + NoTls, + ); + let pool = r2d2::Pool::builder().build(manager).map_err(|e| (e, DRIVER))?; + Ok(Self { pool: Some(pool) }) + }) + .join() + .expect("PostgreSQL connection pool creation thread panicked") + }) + } + + /// Returns a reference to the connection pool. + fn pool(&self) -> &Pool> { + self.pool.as_ref().expect("PostgreSQL pool has been dropped") + } + + /// Executes a closure with a pooled connection in a scoped thread. + /// + /// This avoids the "Cannot start a runtime from within a runtime" panic + /// that occurs when the sync `postgres` crate's internal tokio runtime + /// clashes with an outer tokio runtime. + fn with_connection(&self, f: F) -> Result + where + F: FnOnce(&mut r2d2::PooledConnection>) -> Result + Send, + T: Send, + { + let pool = self.pool(); + std::thread::scope(|s| { + s.spawn(|| { + let mut conn = pool.get().map_err(|e| (e, DRIVER))?; + f(&mut conn) + }) + .join() + .expect("PostgreSQL worker thread panicked") + }) + } + + fn load_torrent_aggregate_metric(&self, metric_name: &str) -> Result, Error> { + let metric_name = metric_name.to_string(); + self.with_connection(|conn| { + let rows = conn.query( + "SELECT value FROM torrent_aggregate_metrics WHERE metric_name = $1", + &[&metric_name], + )?; + + if let Some(row) = rows.first() { + let value: i32 = row.get(0); + Ok(Some(u32::try_from(value).unwrap())) + } else { + Ok(None) + } + }) + } + + fn save_torrent_aggregate_metric(&self, metric_name: &str, completed: NumberOfDownloads) -> Result<(), Error> { + let metric_name = metric_name.to_string(); + self.with_connection(move |conn| { + let completed_i32 = i32::try_from(completed).unwrap(); + + conn.execute( + "INSERT INTO torrent_aggregate_metrics (metric_name, value) VALUES ($1, $2) ON CONFLICT (metric_name) DO UPDATE SET value = EXCLUDED.value", + &[&metric_name, &completed_i32], + )?; + + Ok(()) + }) + } +} + +impl Database for Postgres { + fn create_database_tables(&self) -> Result<(), Error> { + self.with_connection(|conn| { + let create_whitelist_table = " + CREATE TABLE IF NOT EXISTS whitelist ( + id SERIAL PRIMARY KEY, + info_hash VARCHAR(40) NOT NULL UNIQUE + );"; + + let create_torrents_table = " + CREATE TABLE IF NOT EXISTS torrents ( + id SERIAL PRIMARY KEY, + info_hash VARCHAR(40) NOT NULL UNIQUE, + completed INTEGER DEFAULT 0 NOT NULL + );"; + + let create_torrent_aggregate_metrics_table = " + CREATE TABLE IF NOT EXISTS torrent_aggregate_metrics ( + id SERIAL PRIMARY KEY, + metric_name VARCHAR(50) NOT NULL UNIQUE, + value INTEGER DEFAULT 0 NOT NULL + );"; + + let create_keys_table = format!( + " + CREATE TABLE IF NOT EXISTS keys ( + id SERIAL PRIMARY KEY, + key VARCHAR({}) NOT NULL UNIQUE, + valid_until BIGINT + );", + i8::try_from(AUTH_KEY_LENGTH).expect("authentication key length should fit within a i8!") + ); + + conn.execute(create_torrents_table, &[]) + .expect("Could not create torrents table."); + conn.execute(create_torrent_aggregate_metrics_table, &[]) + .expect("Could not create torrent_aggregate_metrics table."); + conn.execute(&create_keys_table, &[]).expect("Could not create keys table."); + conn.execute(create_whitelist_table, &[]) + .expect("Could not create whitelist table."); + + Ok(()) + }) + } + + fn drop_database_tables(&self) -> Result<(), Error> { + self.with_connection(|conn| { + conn.execute("DROP TABLE whitelist;", &[]) + .expect("Could not drop whitelist table."); + conn.execute("DROP TABLE torrents;", &[]) + .expect("Could not drop torrents table."); + conn.execute("DROP TABLE torrent_aggregate_metrics;", &[]) + .expect("Could not drop torrent_aggregate_metrics table."); + conn.execute("DROP TABLE keys;", &[]).expect("Could not drop keys table."); + + Ok(()) + }) + } + + fn load_all_torrents_downloads(&self) -> Result { + self.with_connection(|conn| { + let rows = conn.query("SELECT info_hash, completed FROM torrents", &[])?; + + let torrents: Vec<(InfoHash, u32)> = rows + .iter() + .map(|row| { + let info_hash_string: String = row.get(0); + let completed: i32 = row.get(1); + let info_hash = InfoHash::from_str(&info_hash_string).unwrap(); + (info_hash, u32::try_from(completed).unwrap()) + }) + .collect(); + + Ok(torrents.iter().copied().collect()) + }) + } + + fn load_torrent_downloads(&self, info_hash: &InfoHash) -> Result, Error> { + let info_hash_hex = info_hash.to_hex_string(); + self.with_connection(move |conn| { + let rows = conn.query("SELECT completed FROM torrents WHERE info_hash = $1", &[&info_hash_hex])?; + + if let Some(row) = rows.first() { + let completed: i32 = row.get(0); + Ok(Some(u32::try_from(completed).unwrap())) + } else { + Ok(None) + } + }) + } + + fn save_torrent_downloads(&self, info_hash: &InfoHash, completed: u32) -> Result<(), Error> { + let info_hash_str = info_hash.to_string(); + self.with_connection(move |conn| { + let completed_i32 = i32::try_from(completed).unwrap(); + + conn.execute( + "INSERT INTO torrents (info_hash, completed) VALUES ($1, $2) ON CONFLICT (info_hash) DO UPDATE SET completed = EXCLUDED.completed", + &[&info_hash_str, &completed_i32], + )?; + + Ok(()) + }) + } + + fn increase_downloads_for_torrent(&self, info_hash: &InfoHash) -> Result<(), Error> { + let info_hash_str = info_hash.to_string(); + self.with_connection(move |conn| { + conn.execute( + "UPDATE torrents SET completed = completed + 1 WHERE info_hash = $1", + &[&info_hash_str], + )?; + + Ok(()) + }) + } + + fn load_global_downloads(&self) -> Result, Error> { + self.load_torrent_aggregate_metric(TORRENTS_DOWNLOADS_TOTAL) + } + + fn save_global_downloads(&self, downloaded: NumberOfDownloads) -> Result<(), Error> { + self.save_torrent_aggregate_metric(TORRENTS_DOWNLOADS_TOTAL, downloaded) + } + + fn increase_global_downloads(&self) -> Result<(), Error> { + self.with_connection(|conn| { + let metric_name = TORRENTS_DOWNLOADS_TOTAL; + + conn.execute( + "UPDATE torrent_aggregate_metrics SET value = value + 1 WHERE metric_name = $1", + &[&metric_name], + )?; + + Ok(()) + }) + } + + fn load_keys(&self) -> Result, Error> { + self.with_connection(|conn| { + let rows = conn.query("SELECT key, valid_until FROM keys", &[])?; + + let keys: Vec = rows + .iter() + .map(|row| { + let key: String = row.get(0); + let valid_until: Option = row.get(1); + match valid_until { + Some(valid_until) => authentication::PeerKey { + key: key.parse::().unwrap(), + valid_until: Some(Duration::from_secs(valid_until.unsigned_abs())), + }, + None => authentication::PeerKey { + key: key.parse::().unwrap(), + valid_until: None, + }, + } + }) + .collect(); + + Ok(keys) + }) + } + + fn load_whitelist(&self) -> Result, Error> { + self.with_connection(|conn| { + let rows = conn.query("SELECT info_hash FROM whitelist", &[])?; + + let info_hashes: Vec = rows + .iter() + .map(|row| { + let info_hash: String = row.get(0); + InfoHash::from_str(&info_hash).unwrap() + }) + .collect(); + + Ok(info_hashes) + }) + } + + fn get_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result, Error> { + let info_hash_hex = info_hash.to_hex_string(); + self.with_connection(move |conn| { + let rows = conn.query("SELECT info_hash FROM whitelist WHERE info_hash = $1", &[&info_hash_hex])?; + + if let Some(row) = rows.first() { + let info_hash_string: String = row.get(0); + Ok(Some( + InfoHash::from_str(&info_hash_string).expect("Failed to decode InfoHash String from DB!"), + )) + } else { + Ok(None) + } + }) + } + + fn add_info_hash_to_whitelist(&self, info_hash: InfoHash) -> Result { + let info_hash_str = info_hash.to_string(); + self.with_connection(move |conn| { + let rows_affected = conn.execute("INSERT INTO whitelist (info_hash) VALUES ($1)", &[&info_hash_str])?; + Ok(usize::try_from(rows_affected).expect("rows affected should fit in usize")) + }) + } + + fn remove_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result { + let info_hash_str = info_hash.to_string(); + self.with_connection(move |conn| { + let rows_affected = conn.execute("DELETE FROM whitelist WHERE info_hash = $1", &[&info_hash_str])?; + Ok(usize::try_from(rows_affected).expect("rows affected should fit in usize")) + }) + } + + fn get_key_from_keys(&self, key: &Key) -> Result, Error> { + let key_str = key.to_string(); + self.with_connection(move |conn| { + let rows = conn.query("SELECT key, valid_until FROM keys WHERE key = $1", &[&key_str])?; + + if let Some(row) = rows.first() { + let key_str: String = row.get(0); + let valid_until: Option = row.get(1); + Ok(Some(match valid_until { + Some(valid_until) => authentication::PeerKey { + key: key_str.parse::().unwrap(), + valid_until: Some(Duration::from_secs(valid_until.unsigned_abs())), + }, + None => authentication::PeerKey { + key: key_str.parse::().unwrap(), + valid_until: None, + }, + })) + } else { + Ok(None) + } + }) + } + + fn add_key_to_keys(&self, auth_key: &authentication::PeerKey) -> Result { + let key_str = auth_key.key.to_string(); + let valid_until = auth_key.valid_until; + self.with_connection(move |conn| { + let rows_affected = if let Some(valid_until) = valid_until { + let valid_until_i64 = i64::try_from(valid_until.as_secs()).unwrap(); + conn.execute( + "INSERT INTO keys (key, valid_until) VALUES ($1, $2)", + &[&key_str, &valid_until_i64], + )? + } else { + let null_value: Option = None; + conn.execute( + "INSERT INTO keys (key, valid_until) VALUES ($1, $2)", + &[&key_str, &null_value], + )? + }; + + Ok(usize::try_from(rows_affected).expect("rows affected should fit in usize")) + }) + } + + fn remove_key_from_keys(&self, key: &Key) -> Result { + let key_str = key.to_string(); + self.with_connection(move |conn| { + let rows_affected = conn.execute("DELETE FROM keys WHERE key = $1", &[&key_str])?; + Ok(usize::try_from(rows_affected).expect("rows affected should fit in usize")) + }) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use testcontainers::core::IntoContainerPort; + /* + We run a PostgreSQL container and run all the tests against the same container and database. + + Tests for this driver are executed with: + + `TORRUST_TRACKER_CORE_RUN_POSTGRES_DRIVER_TEST=true cargo test` + + The `Database` trait is very simple and we only have one driver that needs + a container. In the future we might want to use different approaches like: + + - https://github.com/testcontainers/testcontainers-rs/issues/707 + - https://www.infinyon.com/blog/2021/04/rust-custom-test-harness/ + - https://github.com/torrust/torrust-tracker/blob/develop/src/bin/e2e_tests_runner.rs + + If we increase the number of methods or the number or drivers. + */ + use testcontainers::runners::AsyncRunner; + use testcontainers::{ContainerAsync, GenericImage, ImageExt}; + use torrust_tracker_configuration::Core; + + use super::Postgres; + use crate::databases::driver::tests::run_tests; + use crate::databases::Database; + + #[derive(Debug, Default)] + struct StoppedPostgresContainer {} + + impl StoppedPostgresContainer { + async fn run( + self, + config: &PostgresConfiguration, + ) -> Result> { + let container = GenericImage::new("postgres", "16") + .with_exposed_port(config.internal_port.tcp()) + .with_env_var("POSTGRES_PASSWORD", config.db_root_password.clone()) + .with_env_var("POSTGRES_DB", config.database.clone()) + .with_env_var("POSTGRES_USER", config.db_user.clone()) + .start() + .await?; + + Ok(RunningPostgresContainer::new(container, config.internal_port)) + } + } + + struct RunningPostgresContainer { + container: ContainerAsync, + internal_port: u16, + } + + impl RunningPostgresContainer { + fn new(container: ContainerAsync, internal_port: u16) -> Self { + Self { + container, + internal_port, + } + } + + async fn stop(self) { + self.container.stop().await.unwrap(); + } + + async fn get_host(&self) -> url::Host { + self.container.get_host().await.unwrap() + } + + async fn get_host_port_ipv4(&self) -> u16 { + self.container.get_host_port_ipv4(self.internal_port).await.unwrap() + } + } + + impl Default for PostgresConfiguration { + fn default() -> Self { + Self { + internal_port: 5432, + database: "torrust_tracker_test".to_string(), + db_user: "postgres".to_string(), + db_root_password: "test".to_string(), + } + } + } + + struct PostgresConfiguration { + pub internal_port: u16, + pub database: String, + pub db_user: String, + pub db_root_password: String, + } + + fn core_configuration(host: &url::Host, port: u16, pg_configuration: &PostgresConfiguration) -> Core { + let mut config = Core::default(); + + let database = pg_configuration.database.clone(); + let db_user = pg_configuration.db_user.clone(); + let db_password = pg_configuration.db_root_password.clone(); + + config.database.path = format!("postgresql://{db_user}:{db_password}@{host}:{port}/{database}"); + + config + } + + fn initialize_driver(config: &Core) -> Arc> { + let driver: Arc> = Arc::new(Box::new(Postgres::new(&config.database.path).unwrap())); + driver + } + + /// Runs the full `PostgreSQL` driver test suite using testcontainers. + /// + /// Enable with: + /// `TORRUST_TRACKER_CORE_RUN_POSTGRES_DRIVER_TEST=true cargo test` + #[tokio::test] + async fn run_postgres_driver_tests() -> Result<(), Box> { + if std::env::var("TORRUST_TRACKER_CORE_RUN_POSTGRES_DRIVER_TEST").is_err() { + println!("Skipping the PostgreSQL driver tests (testcontainers)."); + return Ok(()); + } + + let pg_configuration = PostgresConfiguration::default(); + + let stopped_pg_container = StoppedPostgresContainer::default(); + + let pg_container = stopped_pg_container.run(&pg_configuration).await.unwrap(); + + let host = pg_container.get_host().await; + let port = pg_container.get_host_port_ipv4().await; + + let config = core_configuration(&host, port, &pg_configuration); + + let driver = initialize_driver(&config); + + run_tests(&driver).await; + + pg_container.stop().await; + + Ok(()) + } + + /// Runs the full `PostgreSQL` driver test suite against a local `PostgreSQL` + /// instance specified via environment variable. + /// + /// Enable with: + /// `TORRUST_TRACKER_CORE_POSTGRES_DATABASE_URL="postgresql://user:pass@host:port/db" cargo test` + #[tokio::test] + async fn run_postgres_driver_tests_local() -> Result<(), Box> { + let Ok(db_url) = std::env::var("TORRUST_TRACKER_CORE_POSTGRES_DATABASE_URL") else { + println!("Skipping the local PostgreSQL driver tests."); + return Ok(()); + }; + + let mut config = Core::default(); + config.database.path = db_url; + + let driver = initialize_driver(&config); + + run_tests(&driver).await; + + Ok(()) + } +} diff --git a/packages/tracker-core/src/databases/error.rs b/packages/tracker-core/src/databases/error.rs index 2df2cb277..f56a492dc 100644 --- a/packages/tracker-core/src/databases/error.rs +++ b/packages/tracker-core/src/databases/error.rs @@ -6,8 +6,8 @@ //! creation errors. Each error variant includes contextual information such as //! the associated database driver and, when applicable, the source error. //! -//! External errors from database libraries (e.g., `rusqlite`, `mysql`) are -//! converted into this error type using the provided `From` implementations. +//! External errors from database libraries (e.g., `rusqlite`, `mysql`, `postgres`) +//! are converted into this error type using the provided `From` implementations. use std::panic::Location; use std::sync::Arc; @@ -78,6 +78,15 @@ pub enum Error { driver: Driver, }, + /// Indicates a failure to connect to the database (generic). + /// + /// This error variant wraps connection-related errors for drivers that do not use `MySQL` URL errors. + #[error("Failed to connect to {driver} database: {source}")] + GenericConnectionError { + source: LocatedError<'static, dyn std::error::Error + Send + Sync>, + driver: Driver, + }, + /// Indicates a failure to create a connection pool. /// /// This error variant is used when the connection pool creation (using r2d2) fails. @@ -115,6 +124,17 @@ impl From for Error { } } +impl From for Error { + #[track_caller] + fn from(err: r2d2_postgres::postgres::Error) -> Self { + let e: DynError = Arc::new(err); + Error::InvalidQuery { + source: e.into(), + driver: Driver::PostgreSQL, + } + } +} + impl From for Error { #[track_caller] fn from(err: UrlError) -> Self { diff --git a/packages/tracker-core/src/databases/mod.rs b/packages/tracker-core/src/databases/mod.rs index c9d89769a..bb3b7ab2e 100644 --- a/packages/tracker-core/src/databases/mod.rs +++ b/packages/tracker-core/src/databases/mod.rs @@ -2,10 +2,11 @@ //! //! Persistence is currently implemented using a single [`Database`] trait. //! -//! There are two implementations of the trait (two drivers): +//! There are three implementations of the trait (three drivers): //! //! - **`MySQL`** //! - **`Sqlite`** +//! - **`PostgreSQL`** //! //! > **NOTICE**: There are no database migrations at this time. If schema //! > changes occur, either migration functionality will be implemented or a diff --git a/packages/tracker-core/src/databases/setup.rs b/packages/tracker-core/src/databases/setup.rs index 6ba9f2a64..9f7d1cf98 100644 --- a/packages/tracker-core/src/databases/setup.rs +++ b/packages/tracker-core/src/databases/setup.rs @@ -43,6 +43,7 @@ pub fn initialize_database(config: &Core) -> Arc> { let driver = match config.database.driver { torrust_tracker_configuration::Driver::Sqlite3 => Driver::Sqlite3, torrust_tracker_configuration::Driver::MySQL => Driver::MySQL, + torrust_tracker_configuration::Driver::PostgreSQL => Driver::PostgreSQL, }; Arc::new(driver::build(&driver, &config.database.path).expect("Database driver build failed.")) diff --git a/project-words.txt b/project-words.txt index 48c9565cc..0c21910c2 100644 --- a/project-words.txt +++ b/project-words.txt @@ -250,3 +250,4 @@ mysqladmin setgroups taplo trixie +postgresql